Compare commits

...

19 Commits

Author SHA1 Message Date
dependabot[bot] 12451b23ea
Merge 7db12fdbe7 into 7f518f55b2 2023-12-22 05:14:10 -07:00
Harvey Tindall 7f518f55b2
Merge backups
Backups, manual & scheduled
2023-12-21 21:53:50 +00:00
Harvey Tindall ca4fbc0ad5
backups: change update button wording 2023-12-21 21:40:24 +00:00
Harvey Tindall b259dd7b00
backups: add wiki link 2023-12-21 21:38:42 +00:00
Harvey Tindall dc2c2f1164
backups: show uploaded backups on-page 2023-12-21 21:11:40 +00:00
Harvey Tindall bc2e9cffda
backups: move code to own files 2023-12-21 18:17:03 +00:00
Harvey Tindall ade032241a
backups: upload and restore backup in-app 2023-12-21 18:12:58 +00:00
Harvey Tindall eff313be41
backups: restore local backups in-app 2023-12-21 17:42:07 +00:00
Harvey Tindall ff73c72b0e
backups: add -restore cli argument 2023-12-21 17:27:28 +00:00
Harvey Tindall 1bb83c88d9
backups: add filesize to list 2023-12-21 16:51:33 +00:00
Harvey Tindall 195813c058
backups: triggerable in ui, viewable, downloadable
new "Backups" menu in settings lists all available backups, lets you
trigger a new one, and lets you download them.
2023-12-21 16:47:17 +00:00
Harvey Tindall 733ab37539
backups: add backup daemon to run every n minutes, keep x most recent backups 2023-12-21 13:03:16 +00:00
Harvey Tindall c0c91b4aad
drone: source buildrone key from drone in docker build 2023-12-20 20:06:44 +00:00
Harvey Tindall 83712a6937
pwr: fix set password for jellyfin PWRs 2023-12-20 19:04:40 +00:00
Harvey Tindall 290d02d248
pwr: include pwr-pin in build process, whoops
copying the PIN on the external PWR link page wasn't working since the
code's typescript wasn't being compiled.
2023-12-20 18:40:18 +00:00
Harvey Tindall 9cd402a15d
logs: fix file identifier 2023-12-20 18:28:42 +00:00
Harvey Tindall 1a6897637f
userpage: allow manual disable of pwr through username/email/contact
Checkboxes added to userpage settings allowing enabling/disabling of
specific ways of starting a PWR. For #312.
2023-12-20 18:18:39 +00:00
Harvey Tindall 213b1e7f9e
accounts: allow setting exact expiry date
set with a text input field which uses the same date parsing library as
the search function. Parsed expiry date will appear once you've typed
something in, so you can make sure it's right.
2023-12-20 17:20:59 +00:00
dependabot[bot] 7db12fdbe7
build(deps): bump word-wrap from 1.2.3 to 1.2.4
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-19 02:53:03 +00:00
28 changed files with 850 additions and 97 deletions

View File

@ -131,6 +131,9 @@ steps:
volumes: volumes:
- name: ssh_key - name: ssh_key
path: /root/drone_rsa path: /root/drone_rsa
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
settings: settings:
host: host:
from_secret: ssh2_host from_secret: ssh2_host
@ -140,13 +143,15 @@ steps:
from_secret: ssh2_port from_secret: ssh2_port
volumes: volumes:
- /root/.ssh/docker-build:/root/drone_rsa - /root/.ssh/docker-build:/root/drone_rsa
envs:
- buildrone_key
key_path: /root/drone_rsa key_path: /root/drone_rsa
command_timeout: 50m command_timeout: 50m
script: script:
- /mnt/buildx/jfa-go/build.sh - /mnt/buildx/jfa-go/build.sh
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py - wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
- pip3 install requests - pip3 install requests
- bash -c 'cd /mnt/buildx/jfa-go/jfa-go && BUILDRONE_KEY=$(cat /mnt/buildx/jfa-go/key) python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true' - bash -c 'cd /mnt/buildx/jfa-go/jfa-go && python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true'
- rm -f /mnt/buildx/jfa-go/jfa-go/upload.py - rm -f /mnt/buildx/jfa-go/jfa-go/upload.py
trigger: trigger:
branch: branch:

View File

@ -29,6 +29,7 @@ before:
- npx esbuild --target=es6 --bundle tempts/admin.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/admin.js {{.Env.JFA_GO_MINIFY}} - npx esbuild --target=es6 --bundle tempts/admin.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/admin.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/user.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/user.js {{.Env.JFA_GO_MINIFY}} - npx esbuild --target=es6 --bundle tempts/user.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/user.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/pwr.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/pwr.js {{.Env.JFA_GO_MINIFY}} - npx esbuild --target=es6 --bundle tempts/pwr.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/pwr.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/pwr-pin.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/pwr-pin.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/form.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/form.js {{.Env.JFA_GO_MINIFY}} - npx esbuild --target=es6 --bundle tempts/form.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/form.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/setup.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/setup.js {{.Env.JFA_GO_MINIFY}} - npx esbuild --target=es6 --bundle tempts/setup.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/web/js/setup.js {{.Env.JFA_GO_MINIFY}}
- npx esbuild --target=es6 --bundle tempts/crash.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/crash.js {{.Env.JFA_GO_MINIFY}} - npx esbuild --target=es6 --bundle tempts/crash.ts {{.Env.JFA_GO_SOURCEMAP}} --outfile=./data/crash.js {{.Env.JFA_GO_MINIFY}}

View File

@ -121,6 +121,7 @@ typescript:
$(ESBUILD) --target=es6 --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify $(ESBUILD) --target=es6 --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify
$(ESBUILD) --target=es6 --bundle tempts/user.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/user.js --minify $(ESBUILD) --target=es6 --bundle tempts/user.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/user.js --minify
$(ESBUILD) --target=es6 --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify $(ESBUILD) --target=es6 --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify
$(ESBUILD) --target=es6 --bundle tempts/pwr-pin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr-pin.js --minify
$(ESBUILD) --target=es6 --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify $(ESBUILD) --target=es6 --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify
$(ESBUILD) --target=es6 --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify $(ESBUILD) --target=es6 --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify
$(ESBUILD) --target=es6 --bundle tempts/crash.ts --outfile=./$(DATA)/crash.js --minify $(ESBUILD) --target=es6 --bundle tempts/crash.ts --outfile=./$(DATA)/crash.js --minify

117
api-backups.go Normal file
View File

@ -0,0 +1,117 @@
package main
import (
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// @Summary Creates a backup of the database.
// @Router /backups [post]
// @Success 200 {object} CreateBackupDTO
// @Security Bearer
// @tags Backups
func (app *appContext) CreateBackup(gc *gin.Context) {
backup := app.makeBackup()
gc.JSON(200, backup)
}
// @Summary Download a specific backup file. Requires auth, so can't be accessed plainly in the browser.
// @Param fname path string true "backup filename"
// @Router /backups/{fname} [get]
// @Produce octet-stream
// @Produce json
// @Success 200 {body} file
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
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() {
app.debug.Printf("Ignoring backup DL request due to fname: %v\n", err)
respondBool(400, false, gc)
return
}
path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, fname)
gc.FileAttachment(fullpath, fname)
}
// @Summary Get a list of backups.
// @Router /backups [get]
// @Produce json
// @Success 200 {object} GetBackupsDTO
// @Security Bearer
// @tags Backups
func (app *appContext) GetBackups(gc *gin.Context) {
path := app.config.Section("backups").Key("path").String()
backups := app.getBackups()
sort.Sort(backups)
resp := GetBackupsDTO{}
resp.Backups = make([]CreateBackupDTO, backups.count)
for i, item := range backups.files[:backups.count] {
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()
fstat, err := os.Stat(fullpath)
if err == nil {
resp.Backups[i].Size = fileSize(fstat.Size())
}
}
gc.JSON(200, resp)
}
// @Summary Restore a backup file stored locally to the server.
// @Param fname path string true "backup filename"
// @Router /backups/restore/{fname} [post]
// @Produce json
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
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() {
app.debug.Printf("Ignoring backup DL request due to fname: %v\n", err)
respondBool(400, false, gc)
return
}
path := app.config.Section("backups").Key("path").String()
fullpath := filepath.Join(path, fname)
LOADBAK = fullpath
app.restart(gc)
}
// @Summary Restore a backup file uploaded by the user.
// @Param file formData file true ".bak file"
// @Router /backups/restore [post]
// @Produce json
// @Failure 400 {object} boolResponse
// @Security Bearer
// @tags Backups
func (app *appContext) RestoreBackup(gc *gin.Context) {
file, err := gc.FormFile("backups-file")
if err != nil {
app.err.Printf("Failed to get file from form data: %v\n", err)
respondBool(400, false, gc)
return
}
app.debug.Printf("Got uploaded file \"%s\"\n", 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)
gc.SaveUploadedFile(file, fullpath)
app.debug.Printf("Saved to \"%s\"\n", fullpath)
LOADBAK = fullpath
app.restart(gc)
}

View File

@ -590,6 +590,9 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
cancel := time.AfterFunc(1*time.Second, func() { cancel := time.AfterFunc(1*time.Second, func() {
timerWait <- true timerWait <- true
}) })
usernameAllowed := app.config.Section("user_page").Key("allow_pwr_username").MustBool(true)
emailAllowed := app.config.Section("user_page").Key("allow_pwr_email").MustBool(true)
contactMethodAllowed := app.config.Section("user_page").Key("allow_pwr_contact_method").MustBool(true)
address := gc.Param("address") address := gc.Param("address")
if address == "" { if address == "" {
app.debug.Println("Ignoring empty request for PWR") app.debug.Println("Ignoring empty request for PWR")
@ -600,7 +603,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
var pwr InternalPWR var pwr InternalPWR
var err error var err error
jfUser, ok := app.ReverseUserSearch(address) jfUser, ok := app.ReverseUserSearch(address, usernameAllowed, emailAllowed, contactMethodAllowed)
if !ok { if !ok {
app.debug.Printf("Ignoring PWR request: User not found") app.debug.Printf("Ignoring PWR request: User not found")

View File

@ -719,7 +719,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
var req extendExpiryDTO var req extendExpiryDTO
gc.BindJSON(&req) gc.BindJSON(&req)
app.info.Printf("Expiry extension requested for %d user(s)", len(req.Users)) app.info.Printf("Expiry extension requested for %d user(s)", len(req.Users))
if req.Months <= 0 && req.Days <= 0 && req.Hours <= 0 && req.Minutes <= 0 { if req.Months <= 0 && req.Days <= 0 && req.Hours <= 0 && req.Minutes <= 0 && req.Timestamp <= 0 {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
@ -731,7 +731,12 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
} else { } else {
app.debug.Printf("Created expiry for \"%s\"", id) app.debug.Printf("Created expiry for \"%s\"", id)
} }
expiry := UserExpiry{Expiry: base.AddDate(0, req.Months, req.Days).Add(time.Duration(((60 * req.Hours) + req.Minutes)) * time.Minute)} expiry := UserExpiry{}
if req.Timestamp != 0 {
expiry.Expiry = time.Unix(req.Timestamp, 0)
} else {
expiry.Expiry = base.AddDate(0, req.Months, req.Days).Add(time.Duration(((60 * req.Hours) + req.Minutes)) * time.Minute)
}
app.storage.SetUserExpiryKey(id, expiry) app.storage.SetUserExpiryKey(id, expiry)
} }
respondBool(204, true, gc) respondBool(204, true, gc)

View File

@ -23,6 +23,7 @@ func (app *appContext) loadArgs(firstCall bool) {
HOST = flag.String("host", "", "alternate address to host web ui on.") HOST = flag.String("host", "", "alternate address to host web ui on.")
PORT = flag.Int("port", 0, "alternate port to host web ui on.") PORT = flag.Int("port", 0, "alternate port to host web ui on.")
flag.IntVar(PORT, "p", 0, "SHORTHAND") flag.IntVar(PORT, "p", 0, "SHORTHAND")
_LOADBAK = flag.String("restore", "", "path to database backup to restore.")
DEBUG = flag.Bool("debug", false, "Enables debug logging.") DEBUG = flag.Bool("debug", false, "Enables debug logging.")
PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.") PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.")
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html") SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
@ -41,6 +42,9 @@ func (app *appContext) loadArgs(firstCall bool) {
if *PPROF { if *PPROF {
os.Setenv("PPROF", "1") os.Setenv("PPROF", "1")
} }
if *_LOADBAK != "" {
LOADBAK = *_LOADBAK
}
} }
if os.Getenv("SWAGGER") == "1" { if os.Getenv("SWAGGER") == "1" {

180
backups.go Normal file
View File

@ -0,0 +1,180 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
const (
BACKUP_PREFIX = "jfa-go-db-"
BACKUP_UPLOAD_PREFIX = "upload-"
BACKUP_DATEFMT = "2006-01-02T15-04-05"
BACKUP_SUFFIX = ".bak"
)
type BackupList struct {
files []os.DirEntry
dates []time.Time
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]
}
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() {
return false
}
if bl.dates[j].IsZero() {
return true
}
// Sort by oldest first
return bl.dates[j].After(bl.dates[i])
}
// Get human-readable file size from f.Size() result.
// https://programming.guide/go/formatting-byte-size-to-human-readable-format.html
func fileSize(l int64) string {
const unit = 1000
if l < unit {
return fmt.Sprintf("%dB", l)
}
div, exp := int64(unit), 0
for n := l / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%c", float64(l)/float64(div), "KMGTPE"[exp])
}
func (app *appContext) getBackups() *BackupList {
path := app.config.Section("backups").Key("path").String()
err := os.MkdirAll(path, 0755)
if err != nil {
app.err.Printf("Failed to create backup directory \"%s\": %v\n", path, err)
return nil
}
items, err := os.ReadDir(path)
if err != nil {
app.err.Printf("Failed to read backup directory \"%s\": %v\n", path, err)
return nil
}
backups := &BackupList{}
backups.files = items
backups.dates = make([]time.Time, len(items))
backups.count = 0
for i, item := range items {
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("Failed to parse backup filename \"%s\": %v\n", item.Name(), err)
continue
}
backups.dates[i] = t
backups.count++
}
return backups
}
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
path := app.config.Section("backups").Key("path").String()
backups := app.getBackups()
if backups == nil {
return
}
toDelete := backups.count + 1 - toKeep
// 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] {
fullpath := filepath.Join(path, item.Name())
app.debug.Printf("Deleting old backup \"%s\"\n", item.Name())
err := os.Remove(fullpath)
if err != nil {
app.err.Printf("Failed to delete old backup \"%s\": %v\n", fullpath, err)
return
}
}
}
fullpath := filepath.Join(path, fname)
f, err := os.Create(fullpath)
if err != nil {
app.err.Printf("Failed to open backup file \"%s\": %v\n", fullpath, err)
return
}
defer f.Close()
_, err = app.storage.db.Badger().Backup(f, 0)
if err != nil {
app.err.Printf("Failed to create backup: %v\n", err)
return
}
fstat, err := f.Stat()
if err != nil {
app.err.Printf("Failed to get info on new backup: %v\n", err)
return
}
fileDetails.Size = fileSize(fstat.Size())
fileDetails.Name = fname
fileDetails.Path = fullpath
// fmt.Printf("Created backup %+v\n", fileDetails)
return
}
func (app *appContext) loadPendingBackup() {
if LOADBAK == "" {
return
}
oldPath := filepath.Join(app.dataPath, "db-"+string(time.Now().Unix())+"-pre-"+filepath.Base(LOADBAK))
app.info.Printf("Moving existing database to \"%s\"\n", oldPath)
err := os.Rename(app.storage.db_path, oldPath)
if err != nil {
app.err.Fatalf("Failed to move existing database: %v\n", err)
}
app.ConnectDB()
defer app.storage.db.Close()
f, err := os.Open(LOADBAK)
if err != nil {
app.err.Fatalf("Failed to open backup file \"%s\": %v\n", LOADBAK, err)
}
err = app.storage.db.Badger().Load(f, 256)
f.Close()
if err != nil {
app.err.Fatalf("Failed to restore backup file \"%s\": %v\n", LOADBAK, err)
}
app.info.Printf("Restored backup \"%s\".", LOADBAK)
LOADBAK = ""
}
func newBackupDaemon(app *appContext) *housekeepingDaemon {
interval := time.Duration(app.config.Section("backups").Key("every_n_minutes").MustInt(1440)) * time.Minute
daemon := housekeepingDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
daemon.jobs = []func(app *appContext){
func(app *appContext) {
app.debug.Println("Backups: Creating backup")
app.makeBackup()
},
}
return &daemon
}

View File

@ -112,6 +112,10 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("telegram", "show_on_reg", "true") app.MustSetValue("telegram", "show_on_reg", "true")
app.MustSetValue("backups", "every_n_minutes", "1440")
app.MustSetValue("backups", "path", filepath.Join(app.dataPath, "backups"))
app.MustSetValue("backups", "keep_n_backups", "20")
app.config.Section("jellyfin").Key("version").SetValue(version) app.config.Section("jellyfin").Key("version").SetValue(version)
app.config.Section("jellyfin").Key("device").SetValue("jfa-go") app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit)) app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
@ -122,6 +126,20 @@ func (app *appContext) loadConfig() error {
app.MustSetValue("password_resets", "url_base", strings.TrimSuffix(url1, "/invite")) app.MustSetValue("password_resets", "url_base", strings.TrimSuffix(url1, "/invite"))
app.MustSetValue("invite_emails", "url_base", url2) app.MustSetValue("invite_emails", "url_base", url2)
pwrMethods := []string{"allow_pwr_username", "allow_pwr_email", "allow_pwr_contact_method"}
allDisabled := true
for _, v := range pwrMethods {
if app.config.Section("user_page").Key(v).MustBool(true) {
allDisabled = false
}
}
if allDisabled {
fmt.Println("SETALLTRUE")
for _, v := range pwrMethods {
app.config.Section("user_page").Key(v).SetValue("true")
}
}
messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false) messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false)
telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false) telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false)
discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false) discordEnabled = app.config.Section("discord").Key("enabled").MustBool(false)

View File

@ -629,6 +629,7 @@
"name": "Show Link on Admin Login page", "name": "Show Link on Admin Login page",
"required": false, "required": false,
"requires_restart": false, "requires_restart": false,
"depends_true": "enabled",
"type": "bool", "type": "bool",
"value": true, "value": true,
"description": "Whether or not to show a link to the \"My Account\" page on the admin login screen, to direct lost users." "description": "Whether or not to show a link to the \"My Account\" page on the admin login screen, to direct lost users."
@ -637,6 +638,7 @@
"name": "User Referrals", "name": "User Referrals",
"required": false, "required": false,
"requires_restart": true, "requires_restart": true,
"depends_true": "enabled",
"type": "bool", "type": "bool",
"value": true, "value": true,
"description": "Users are given their own \"invite\" to send to others." "description": "Users are given their own \"invite\" to send to others."
@ -648,6 +650,41 @@
"depends_true": "referrals", "depends_true": "referrals",
"required": "false", "required": "false",
"description": "Create an invite with your desired settings, then either assign it to a user in the accounts tab, or to a profile in settings." "description": "Create an invite with your desired settings, then either assign it to a user in the accounts tab, or to a profile in settings."
},
"allow_pwr_username": {
"name": "Allow PWR with username",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Allow users to start a Password Reset by inputting their username."
},
"allow_pwr_email": {
"name": "Allow PWR with email address",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Allow users to start a Password Reset by inputting their email address."
},
"allow_pwr_contact_method": {
"name": "Allow PWR with Discord/Telegram/Matrix",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Allow users to start a Password Reset by inputting their Discord/Telegram/Matrix username/id."
},
"pwr_note": {
"name": "PWR Methods",
"type": "note",
"depends_true": "enabled",
"value": "",
"required": "false",
"description": "Select at least one PWR initiation method. If none are selected, all will be enabled."
} }
} }
}, },
@ -1520,6 +1557,48 @@
} }
} }
}, },
"backups": {
"order": [],
"meta": {
"name": "Backups",
"description": "Settings for database backups. Press the \"Backups\" button above to create, download and restore backups."
},
"settings": {
"enabled": {
"name": "Scheduled Backups",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Enable to generate database backups on a schedule."
},
"path": {
"name": "Backup Path",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Path to directory to store backups in. defaults to <data_directory>/backups."
},
"every_n_minutes": {
"name": "Backup frequency (Minutes)",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "number",
"value": 1440,
"description": "Backup after this many minutes has passed since the last. Resets every restart."
},
"keep_n_backups": {
"name": "Number of backups to keep",
"required": false,
"requires_restart": true,
"type": "number",
"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."
}
}
},
"welcome_email": { "welcome_email": {
"order": [], "order": [],
"meta": { "meta": {

View File

@ -75,7 +75,7 @@ func (app *appContext) clearTelegram() {
} }
func (app *appContext) clearActivities() { func (app *appContext) clearActivities() {
app.debug.Println("Husekeeping: Cleaning up Activity log...") app.debug.Println("Housekeeping: Cleaning up Activity log...")
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000) keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90) maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90)
minAge := time.Now().AddDate(0, 0, -maxAgeDays) minAge := time.Now().AddDate(0, 0, -maxAgeDays)

View File

@ -899,55 +899,65 @@ func (app *appContext) getAddressOrName(jfID string) string {
// ReverseUserSearch returns the jellyfin ID of the user with the given username, email, or contact method username. // ReverseUserSearch returns the jellyfin ID of the user with the given username, email, or contact method username.
// returns "" if none found. returns only the first match, might be an issue if there are users with the same contact method usernames. // returns "" if none found. returns only the first match, might be an issue if there are users with the same contact method usernames.
func (app *appContext) ReverseUserSearch(address string) (user mediabrowser.User, ok bool) { func (app *appContext) ReverseUserSearch(address string, matchUsername, matchEmail, matchContactMethod bool) (user mediabrowser.User, ok bool) {
ok = false ok = false
user, status, err := app.jf.UserByName(address, false) var status int
if status == 200 && err == nil { var err error = nil
ok = true if matchUsername {
return user, status, err = app.jf.UserByName(address, false)
if status == 200 && err == nil {
ok = true
return
}
} }
emailAddresses := []EmailAddress{}
err = app.storage.db.Find(&emailAddresses, badgerhold.Where("Addr").Eq(address)) if matchEmail {
if err == nil && len(emailAddresses) > 0 { emailAddresses := []EmailAddress{}
for _, emailUser := range emailAddresses { err = app.storage.db.Find(&emailAddresses, badgerhold.Where("Addr").Eq(address))
user, status, err = app.jf.UserByID(emailUser.JellyfinID, false) if err == nil && len(emailAddresses) > 0 {
if status == 200 && err == nil { for _, emailUser := range emailAddresses {
ok = true user, status, err = app.jf.UserByID(emailUser.JellyfinID, false)
return if status == 200 && err == nil {
ok = true
return
}
} }
} }
} }
// Dont know how we'd use badgerhold when we need to render each username, // Dont know how we'd use badgerhold when we need to render each username,
// Apart from storing the rendered name in the db. // Apart from storing the rendered name in the db.
for _, dcUser := range app.storage.GetDiscord() { if matchContactMethod {
if RenderDiscordUsername(dcUser) == strings.ToLower(address) { for _, dcUser := range app.storage.GetDiscord() {
user, status, err = app.jf.UserByID(dcUser.JellyfinID, false) if RenderDiscordUsername(dcUser) == strings.ToLower(address) {
if status == 200 && err == nil { user, status, err = app.jf.UserByID(dcUser.JellyfinID, false)
ok = true if status == 200 && err == nil {
return ok = true
return
}
} }
} }
} tgUsername := strings.TrimPrefix(address, "@")
tgUsername := strings.TrimPrefix(address, "@") telegramUsers := []TelegramUser{}
telegramUsers := []TelegramUser{} err = app.storage.db.Find(&telegramUsers, badgerhold.Where("Username").Eq(tgUsername))
err = app.storage.db.Find(&telegramUsers, badgerhold.Where("Username").Eq(tgUsername)) if err == nil && len(telegramUsers) > 0 {
if err == nil && len(telegramUsers) > 0 { for _, telegramUser := range telegramUsers {
for _, telegramUser := range telegramUsers { user, status, err = app.jf.UserByID(telegramUser.JellyfinID, false)
user, status, err = app.jf.UserByID(telegramUser.JellyfinID, false) if status == 200 && err == nil {
if status == 200 && err == nil { ok = true
ok = true return
return }
} }
} }
} matrixUsers := []MatrixUser{}
matrixUsers := []MatrixUser{} err = app.storage.db.Find(&matrixUsers, badgerhold.Where("UserID").Eq(address))
err = app.storage.db.Find(&matrixUsers, badgerhold.Where("UserID").Eq(address)) if err == nil && len(matrixUsers) > 0 {
if err == nil && len(matrixUsers) > 0 { for _, matrixUser := range matrixUsers {
for _, matrixUser := range matrixUsers { user, status, err = app.jf.UserByID(matrixUser.JellyfinID, false)
user, status, err = app.jf.UserByID(matrixUser.JellyfinID, false) if status == 200 && err == nil {
if status == 200 && err == nil { ok = true
ok = true return
return }
} }
} }
} }

View File

@ -181,39 +181,49 @@
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-extend-expiry" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8"> <div class="content mt-8">
<div class="row"> <aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside>
<div class="col"> <div>
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label> <span class="text-xl supra row py-1">{{ .strings.setExpiry }}</span>
<div class="select ~neutral @low mb-2 mt-4"> <div class="row">
<select id="extend-expiry-months"> <input type="text" id="extend-expiry-text" class="input ~neutral @low mb-2 mt-4" placeholder="{{ .strings.enterExpiry }}">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-days">
<option>0</option>
</select>
</div>
</div> </div>
</div> </div>
<div class="row"> <div id="extend-expiry-field-inputs">
<div class="col"> <span class="text-xl supra row py-1">{{ .strings.extendExpiry }}</span>
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label> <div class="row">
<div class="select ~neutral @low mb-2 mt-4"> <div class="col">
<select id="extend-expiry-hours"> <label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
<option>0</option> <div class="select ~neutral @low mb-2 mt-4">
</select> <select id="extend-expiry-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-days">
<option>0</option>
</select>
</div>
</div> </div>
</div> </div>
<div class="col"> <div class="row">
<label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label> <div class="col">
<div class="select ~neutral @low mb-2 mt-4"> <label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
<select id="extend-expiry-minutes"> <div class="select ~neutral @low mb-2 mt-4">
<option>0</option> <select id="extend-expiry-hours">
</select> <option>0</option>
</select>
</div>
</div>
<div class="col">
<label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-minutes">
<option>0</option>
</select>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -318,6 +328,47 @@
</div> </div>
</div> </div>
</div> </div>
<div id="modal-backups" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading">{{ .strings.backups }} <span class="modal-close">&times;</span></span>
<div class="content my-4">
{{ .strings.backupsDescription }}
<ul>
<li>{{ .strings.backupsCopy }}</li>
<li>{{ .strings.backupsFormatNote }}</li>
<li><a target="_blank" href="https://wiki.jfa-go.com/docs/backups/">{{ .strings.wikiPage }}</a></li>
</ul>
</div>
<div class="flex flex-row flex-wrap my-2">
<button class="button ~info @low mr-2 mb-2" id="settings-backups-backup">{{ .strings.backupNow }}</button>
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-upload">{{ .strings.backupUpload }}</button>
<input id="backups-file" name="backups-file" type="file" hidden>
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-sort-direction">{{ .strings.sortDirection }}</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{{ .strings.name }}</th>
<th>{{ .strings.date }}</th>
<th class="table-inline justify-center">{{ .strings.backupDownloadRestore }}</th>
</tr>
</thead>
<tbody id="backups-list"></tbody>
</table>
</div>
</div>
</div>
<div id="modal-backed-up" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.backupCreated }} <span class="modal-close">&times;</span></span>
<p class="content my-4" id="settings-backed-up-location"></p>
<p class="content my-4">{{ .strings.backupCanDownload }}</p>
<div>
<button class="button flex w-100 ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
</div>
</div>
</div>
<div id="modal-refresh" class="modal"> <div id="modal-refresh" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.settingsApplied }}</span> <span class="heading">{{ .strings.settingsApplied }}</span>
@ -800,7 +851,8 @@
</label> </label>
</div> </div>
<div> <div>
<span class="button ~info @low my-1" id="settings-logs">{{ .strings.logs }}</span> <span class="button ~neutral @low my-1" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low my-1" id="settings-backups">{{ .strings.backups }}</span>
<span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span> <span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span> <span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
</div> </div>

View File

@ -48,11 +48,17 @@
</div> </div>
{{ if .pwrEnabled }} {{ if .pwrEnabled }}
<div id="modal-pwr" class="modal"> <div id="modal-pwr" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low"> <div class="card content relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.resetPassword }}</span> <span class="heading">{{ .strings.resetPassword }}</span>
<p class="content my-2"> <p class="content my-2">
{{ if .linkResetEnabled }} {{ if .linkResetEnabled }}
{{ .strings.resetPasswordThroughLink }} {{ .strings.resetPasswordThroughLinkStart }}
<ul class="content">
{{ if .resetPasswordUsername }}<li>{{ .strings.resetPasswordUsername }}</li>{{ end }}
{{ if .resetPasswordEmail }}<li>{{ .strings.resetPasswordEmail }}</li>{{ end }}
{{ if .resetPasswordContactMethod }}<li>{{ .strings.resetPasswordContactMethod }}</li>{{ end }}
</ul>
{{ .strings.resetPasswordThroughLinkEnd }}
{{ else }} {{ else }}
{{ .strings.resetPasswordThroughJellyfin }} {{ .strings.resetPasswordThroughJellyfin }}
{{ end }} {{ end }}

View File

@ -64,6 +64,7 @@
"extendExpiry": "Extend expiry", "extendExpiry": "Extend expiry",
"setExpiry": "Set expiry", "setExpiry": "Set expiry",
"removeExpiry": "Remove expiry", "removeExpiry": "Remove expiry",
"enterExpiry": "Enter an expiry",
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.", "sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
"sendPWRSuccess": "Password reset link sent.", "sendPWRSuccess": "Password reset link sent.",
"sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.", "sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.",
@ -149,6 +150,7 @@
"accountDisabled": "Account disabled: {user}", "accountDisabled": "Account disabled: {user}",
"accountReEnabled": "Account re-enabled: {user}", "accountReEnabled": "Account re-enabled: {user}",
"accountExpired": "Account expired: {user}", "accountExpired": "Account expired: {user}",
"accountWillExpire": "Account will expire on {date}",
"userDeleted": "User was deleted.", "userDeleted": "User was deleted.",
"userDisabled": "User was disabled", "userDisabled": "User was disabled",
"inviteCreated": "Invite created: {invite}", "inviteCreated": "Invite created: {invite}",
@ -178,9 +180,23 @@
"noMoreResults": "No more results.", "noMoreResults": "No more results.",
"totalRecords": "{n} Total Records", "totalRecords": "{n} Total Records",
"loadedRecords": "{n} Loaded", "loadedRecords": "{n} Loaded",
"shownRecords": "{n} Shown" "shownRecords": "{n} Shown",
"backups": "Backups",
"backupsDescription": "Backups of the database can be made, restored, or downloaded from here.",
"backupsFormatNote": "Only backup files with the standard name format will be shown here. To use any other, upload the backup manually.",
"backupsCopy": "When applying a backup, a copy of the original \"db\" folder will be made next to it, in case anything goes wrong.",
"backupDownloadRestore": "Download / Restore",
"backupUpload": "Upload & Restore Backup",
"backupDownload": "Download Backup",
"backupRestore": "Restore Backup",
"backupNow": "Backup Now",
"backupCreated": "Backup created",
"backupCanBeFound": "The backup can be found on the server at {filepath}.",
"backupCanDownload": "Alternatively, click below to download the backup.",
"wikiPage": "Wiki Page"
}, },
"notifications": { "notifications": {
"pathCopied": "Full path copied to clipboard.",
"changedEmailAddress": "Changed email address of {n}.", "changedEmailAddress": "Changed email address of {n}.",
"userCreated": "User {n} created.", "userCreated": "User {n} created.",
"createProfile": "Created profile {n}.", "createProfile": "Created profile {n}.",
@ -219,6 +235,7 @@
"errorCheckUpdate": "Failed to check for update.", "errorCheckUpdate": "Failed to check for update.",
"errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.", "errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.",
"errorLoadActivities": "Failed to load activities.", "errorLoadActivities": "Failed to load activities.",
"errorInvalidDate": "Date is invalid.",
"updateAvailable": "A new update is available, check settings.", "updateAvailable": "A new update is available, check settings.",
"noUpdatesAvailable": "No new updates available." "noUpdatesAvailable": "No new updates available."
}, },

View File

@ -32,6 +32,11 @@
"resetPassword": "Reset Password", "resetPassword": "Reset Password",
"resetPasswordThroughJellyfin": "To reset your password, visit {jfLink} and press the \"Forgot Password\" button.", "resetPasswordThroughJellyfin": "To reset your password, visit {jfLink} and press the \"Forgot Password\" button.",
"resetPasswordThroughLink": "To reset your password, enter your username, email address or a linked contact method username, and submit. A link will be sent to reset your password.", "resetPasswordThroughLink": "To reset your password, enter your username, email address or a linked contact method username, and submit. A link will be sent to reset your password.",
"resetPasswordThroughLinkStart": "To reset your password, enter one of the following below:",
"resetPasswordThroughLinkEnd": "Then press submit. A link will be sent to reset your password.",
"resetPasswordUsername": "Your Jellyfin username",
"resetPasswordEmail": "Your email address",
"resetPasswordContactMethod": "The username of any contact method linked to your account",
"resetSent": "Reset Sent.", "resetSent": "Reset Sent.",
"resetSentDescription": "If an account with the given username/contact method exists, a password reset link has been sent via all contact methods available. The code will expire in 30 minutes.", "resetSentDescription": "If an account with the given username/contact method exists, a password reset link has been sent via all contact methods available. The code will expire in 30 minutes.",
"changePassword": "Change Password", "changePassword": "Change Password",

View File

@ -49,7 +49,7 @@ func Lshortfile(level int) string {
} }
func lshortfile() string { func lshortfile() string {
return Lshortfile(2) return Lshortfile(3)
} }
func NewLogger(out io.Writer, prefix string, flag int, color c.Attribute) (l *Logger) { func NewLogger(out io.Writer, prefix string, flag int, color c.Attribute) (l *Logger) {

14
main.go
View File

@ -56,6 +56,8 @@ var (
commit string commit string
buildTimeUnix string buildTimeUnix string
builtBy string builtBy string
_LOADBAK *string
LOADBAK = ""
) )
var temp = func() string { var temp = func() string {
@ -355,8 +357,10 @@ func start(asDaemon, firstCall bool) {
} }
app.storage.db_path = filepath.Join(app.dataPath, "db") app.storage.db_path = filepath.Join(app.dataPath, "db")
app.loadPendingBackup()
app.ConnectDB() app.ConnectDB()
defer app.storage.db.Close() defer app.storage.db.Close()
// Read config-base for settings on web. // Read config-base for settings on web.
app.configBasePath = "config-base.json" app.configBasePath = "config-base.json"
configBase, _ := fs.ReadFile(localFS, app.configBasePath) configBase, _ := fs.ReadFile(localFS, app.configBasePath)
@ -475,6 +479,13 @@ func start(asDaemon, firstCall bool) {
go app.checkForUpdates() go app.checkForUpdates()
} }
var backupDaemon *housekeepingDaemon
if app.config.Section("backups").Key("enabled").MustBool(false) {
backupDaemon = newBackupDaemon(app)
go backupDaemon.run()
defer backupDaemon.Shutdown()
}
if telegramEnabled { if telegramEnabled {
app.telegram, err = newTelegramDaemon(app) app.telegram, err = newTelegramDaemon(app)
if err != nil { if err != nil {
@ -647,6 +658,9 @@ func flagPassed(name string) (found bool) {
// @tag.name Ombi // @tag.name Ombi
// @tag.description Ombi related operations. // @tag.description Ombi related operations.
// @tag.name Backups
// @tag.description Database backup/restore operations.
// @tag.name Other // @tag.name Other
// @tag.description Things that dont fit elsewhere. // @tag.description Things that dont fit elsewhere.

View File

@ -261,11 +261,12 @@ type customEmailDTO struct {
} }
type extendExpiryDTO struct { type extendExpiryDTO struct {
Users []string `json:"users"` // List of user IDs to apply to. Users []string `json:"users"` // List of user IDs to apply to.
Months int `json:"months" example:"1"` // Number of months to add. Months int `json:"months" example:"1"` // Number of months to add.
Days int `json:"days" example:"1"` // Number of days to add. Days int `json:"days" example:"1"` // Number of days to add.
Hours int `json:"hours" example:"2"` // Number of hours to add. Hours int `json:"hours" example:"2"` // Number of hours to add.
Minutes int `json:"minutes" example:"3"` // Number of minutes to add. Minutes int `json:"minutes" example:"3"` // Number of minutes to add.
Timestamp int64 `json:"timestamp"` // Optional, exact time to expire at. Overrides other fields.
} }
type checkUpdateDTO struct { type checkUpdateDTO struct {
@ -460,3 +461,14 @@ type GetActivitiesRespDTO struct {
type GetActivityCountDTO struct { type GetActivityCountDTO struct {
Count uint64 `json:"count"` Count uint64 `json:"count"`
} }
type CreateBackupDTO struct {
Size string `json:"size"`
Name string `json:"name"`
Path string `json:"path"`
Date int64 `json:"date"`
}
type GetBackupsDTO struct {
Backups []CreateBackupDTO `json:"backups"`
}

12
package-lock.json generated
View File

@ -6677,9 +6677,9 @@
} }
}, },
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.3", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -11812,9 +11812,9 @@
} }
}, },
"word-wrap": { "word-wrap": {
"version": "1.2.3", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA=="
}, },
"wrap-ansi": { "wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",

View File

@ -208,6 +208,11 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.POST(p+"/config", app.ModifyConfig) api.POST(p+"/config", app.ModifyConfig)
api.POST(p+"/restart", app.restart) api.POST(p+"/restart", app.restart)
api.GET(p+"/logs", app.GetLog) api.GET(p+"/logs", app.GetLog)
api.POST(p+"/backups", app.CreateBackup)
api.GET(p+"/backups/:fname", app.GetBackup)
api.GET(p+"/backups", app.GetBackups)
api.POST(p+"/backups/restore/:fname", app.RestoreLocalBackup)
api.POST(p+"/backups/restore", app.RestoreBackup)
if telegramEnabled || discordEnabled || matrixEnabled { if telegramEnabled || discordEnabled || matrixEnabled {
api.GET(p+"/telegram/pin", app.TelegramGetPin) api.GET(p+"/telegram/pin", app.TelegramGetPin)
api.GET(p+"/telegram/verified/:pin", app.TelegramVerified) api.GET(p+"/telegram/verified/:pin", app.TelegramVerified)

View File

@ -68,6 +68,10 @@ window.availableProfiles = window.availableProfiles || [];
window.modals.logs = new Modal(document.getElementById("modal-logs")); window.modals.logs = new Modal(document.getElementById("modal-logs"));
window.modals.backedUp = new Modal(document.getElementById("modal-backed-up"));
window.modals.backups = new Modal(document.getElementById("modal-backups"));
if (window.telegramEnabled) { if (window.telegramEnabled) {
window.modals.telegram = new Modal(document.getElementById("modal-telegram")); window.modals.telegram = new Modal(document.getElementById("modal-telegram"));
} }

View File

@ -771,6 +771,12 @@ export class accountsList {
private _deleteReason = document.getElementById("textarea-delete-user") as HTMLTextAreaElement; private _deleteReason = document.getElementById("textarea-delete-user") as HTMLTextAreaElement;
private _expiryDropdown = document.getElementById("accounts-expiry-dropdown") as HTMLElement; private _expiryDropdown = document.getElementById("accounts-expiry-dropdown") as HTMLElement;
private _extendExpiry = document.getElementById("accounts-extend-expiry") as HTMLSpanElement; private _extendExpiry = document.getElementById("accounts-extend-expiry") as HTMLSpanElement;
private _extendExpiryForm = document.getElementById("form-extend-expiry") as HTMLFormElement;
private _extendExpiryTextInput = document.getElementById("extend-expiry-text") as HTMLInputElement;
private _extendExpiryFieldInputs = document.getElementById("extend-expiry-field-inputs") as HTMLElement;
private _usingExtendExpiryTextInput = true;
private _extendExpiryDate = document.getElementById("extend-expiry-date") as HTMLElement;
private _removeExpiry = document.getElementById("accounts-remove-expiry") as HTMLSpanElement; private _removeExpiry = document.getElementById("accounts-remove-expiry") as HTMLSpanElement;
private _enableExpiryNotify = document.getElementById("expiry-extend-enable") as HTMLInputElement; private _enableExpiryNotify = document.getElementById("expiry-extend-enable") as HTMLInputElement;
private _enableExpiryReason = document.getElementById("textarea-extend-enable") as HTMLTextAreaElement; private _enableExpiryReason = document.getElementById("textarea-extend-enable") as HTMLTextAreaElement;
@ -1625,6 +1631,45 @@ export class accountsList {
this.reload(); this.reload();
} }
_displayExpiryDate = () => {
let date: Date;
let invalid = false;
if (this._usingExtendExpiryTextInput) {
date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
invalid = "invalid" in (date as any);
} else {
let fields: Array<HTMLSelectElement> = [
document.getElementById("extend-expiry-months") as HTMLSelectElement,
document.getElementById("extend-expiry-days") as HTMLSelectElement,
document.getElementById("extend-expiry-hours") as HTMLSelectElement,
document.getElementById("extend-expiry-minutes") as HTMLSelectElement
];
invalid = fields[0].value == "0" && fields[1].value == "0" && fields[2].value == "0" && fields[3].value == "0";
let id = this._collectUsers().length == 1 ? this._collectUsers()[0] : "";
if (!id) invalid = true;
else {
date = new Date(this._users[id].expiry*1000);
if (this._users[id].expiry == 0) date = new Date();
date.setMonth(date.getMonth() + (+fields[0].value))
date.setDate(date.getDate() + (+fields[1].value));
date.setHours(date.getHours() + (+fields[2].value));
date.setMinutes(date.getMinutes() + (+fields[3].value));
}
}
const submit = this._extendExpiryForm.querySelector(`input[type="submit"]`) as HTMLInputElement;
const submitSpan = submit.nextElementSibling;
if (invalid) {
submit.disabled = true;
submitSpan.classList.add("opacity-60");
this._extendExpiryDate.classList.add("unfocused");
} else {
submit.disabled = false;
submitSpan.classList.remove("opacity-60");
this._extendExpiryDate.textContent = window.lang.strings("accountWillExpire").replace("{date}", toDateString(date));
this._extendExpiryDate.classList.remove("unfocused");
}
}
extendExpiry = (enableUser?: boolean) => { extendExpiry = (enableUser?: boolean) => {
const list = this._collectUsers(); const list = this._collectUsers();
let applyList: string[] = []; let applyList: string[] = [];
@ -1647,10 +1692,20 @@ export class accountsList {
} }
document.getElementById("header-extend-expiry").textContent = header; document.getElementById("header-extend-expiry").textContent = header;
const extend = () => { const extend = () => {
let send = { "users": applyList } let send = { "users": applyList, "timestamp": 0 }
for (let field of ["months", "days", "hours", "minutes"]) { if (this._usingExtendExpiryTextInput) {
send[field] = +(document.getElementById("extend-expiry-"+field) as HTMLSelectElement).value; let date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
send["timestamp"] = Math.floor(date.getTime() / 1000);
if ("invalid" in (date as any)) {
window.notifications.customError("extendExpiryError", window.lang.notif("errorInvalidDate"));
return;
}
} else {
for (let field of ["months", "days", "hours", "minutes"]) {
send[field] = +(document.getElementById("extend-expiry-"+field) as HTMLSelectElement).value;
}
} }
_post("/users/extend", send, (req: XMLHttpRequest) => { _post("/users/extend", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) { if (req.readyState == 4) {
if (req.status != 200 && req.status != 204) { if (req.status != 200 && req.status != 204) {
@ -1663,8 +1718,7 @@ export class accountsList {
} }
}); });
}; };
const form = document.getElementById("form-extend-expiry") as HTMLFormElement; this._extendExpiryForm.onsubmit = (event: Event) => {
form.onsubmit = (event: Event) => {
event.preventDefault(); event.preventDefault();
if (enableUser) { if (enableUser) {
this._enableDisableUsers(applyList, true, this._enableExpiryNotify.checked, this._enableExpiryNotify ? this._enableExpiryReason.value : null, (req: XMLHttpRequest) => { this._enableDisableUsers(applyList, true, this._enableExpiryNotify.checked, this._enableExpiryNotify ? this._enableExpiryReason.value : null, (req: XMLHttpRequest) => {
@ -1685,6 +1739,7 @@ export class accountsList {
extend(); extend();
} }
} }
this._extendExpiryTextInput.value = "";
window.modals.extendExpiry.show(); window.modals.extendExpiry.show();
} }
@ -1821,6 +1876,37 @@ export class accountsList {
this._extendExpiry.onclick = () => { this.extendExpiry(); }; this._extendExpiry.onclick = () => { this.extendExpiry(); };
this._removeExpiry.onclick = () => { this.removeExpiry(); }; this._removeExpiry.onclick = () => { this.removeExpiry(); };
this._expiryDropdown.classList.add("unfocused"); this._expiryDropdown.classList.add("unfocused");
this._extendExpiryDate.classList.add("unfocused");
this._extendExpiryTextInput.onkeyup = () => {
this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60");
this._extendExpiryFieldInputs.classList.add("opacity-60");
this._usingExtendExpiryTextInput = true;
this._displayExpiryDate();
}
this._extendExpiryTextInput.onclick = () => {
this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60");
this._extendExpiryFieldInputs.classList.add("opacity-60");
this._usingExtendExpiryTextInput = true;
this._displayExpiryDate();
};
this._extendExpiryFieldInputs.onclick = () => {
this._extendExpiryFieldInputs.classList.remove("opacity-60");
this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60");
this._usingExtendExpiryTextInput = false;
this._displayExpiryDate();
};
for (let field of ["months", "days", "hours", "minutes"]) {
(document.getElementById("extend-expiry-"+field) as HTMLSelectElement).onchange = () => {
this._extendExpiryFieldInputs.classList.remove("opacity-60");
this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60");
this._usingExtendExpiryTextInput = false;
this._displayExpiryDate();
};
}
this._disableEnable.onclick = this.enableDisableUsers; this._disableEnable.onclick = this.enableDisableUsers;
this._disableEnable.parentElement.classList.add("unfocused"); this._disableEnable.parentElement.classList.add("unfocused");

View File

@ -40,6 +40,31 @@ export const _get = (url: string, data: Object, onreadystatechange: (req: XMLHtt
req.send(JSON.stringify(data)); req.send(JSON.stringify(data));
}; };
export const _download = (url: string, fname: string): void => {
let req = new XMLHttpRequest();
if (window.URLBase) { url = window.URLBase + url; }
req.open("GET", url, true);
req.responseType = 'blob';
req.setRequestHeader("Authorization", "Bearer " + window.token);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onload = (e: Event) => {
let link = document.createElement("a") as HTMLAnchorElement;
link.href = URL.createObjectURL(req.response);
link.download = fname;
link.dispatchEvent(new MouseEvent("click"));
};
req.send();
};
export const _upload = (url: string, formData: FormData): void => {
let req = new XMLHttpRequest();
if (window.URLBase) { url = window.URLBase + url; }
req.open("POST", url, true);
req.setRequestHeader("Authorization", "Bearer " + window.token);
// req.setRequestHeader('Content-Type', 'multipart/form-data');
req.send(formData);
};
export const _post = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, response?: boolean, statusHandler?: (req: XMLHttpRequest) => void): void => { export const _post = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, response?: boolean, statusHandler?: (req: XMLHttpRequest) => void): void => {
let req = new XMLHttpRequest(); let req = new XMLHttpRequest();
req.open("POST", window.URLBase + url, true); req.open("POST", window.URLBase + url, true);

View File

@ -1,7 +1,14 @@
import { _get, _post, _delete, toggleLoader, addLoader, removeLoader, insertText } from "../modules/common.js"; import { _get, _post, _delete, _download, _upload, toggleLoader, addLoader, removeLoader, insertText, toClipboard, toDateString } from "../modules/common.js";
import { Marked } from "@ts-stack/markdown"; import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js"; import { stripMarkdown } from "../modules/stripmd.js";
interface BackupDTO {
size: string;
name: string;
path: string;
date: number;
}
interface settingsBoolEvent extends Event { interface settingsBoolEvent extends Event {
detail: boolean; detail: boolean;
} }
@ -635,6 +642,8 @@ export class settingsList {
private _noResultsPanel: HTMLElement = document.getElementById("settings-not-found"); private _noResultsPanel: HTMLElement = document.getElementById("settings-not-found");
private _backupSortDirection = document.getElementById("settings-backups-sort-direction") as HTMLButtonElement;
private _backupSortAscending = true;
addSection = (name: string, s: Section, subButton?: HTMLElement) => { addSection = (name: string, s: Section, subButton?: HTMLElement) => {
const section = new sectionPanel(s, name); const section = new sectionPanel(s, name);
@ -736,6 +745,68 @@ export class settingsList {
} }
}); });
setBackupSort = (ascending: boolean) => {
this._backupSortAscending = ascending;
this._backupSortDirection.innerHTML = `${window.lang.strings("sortDirection")} <i class="ri-arrow-${ascending ? "up" : "down"}-s-line ml-2"></i>`;
this._getBackups();
};
private _backup = () => _post("/backups", null, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;
const backupDTO = req.response as BackupDTO;
if (backupDTO.path == "") {
window.notifications.customError("backupError", window.lang.strings("errorFailureCheckLogs"));
return;
}
const location = document.getElementById("settings-backed-up-location");
const download = document.getElementById("settings-backed-up-download");
location.innerHTML = window.lang.strings("backupCanBeFound").replace("{filepath}", `<span class="text-black dark:text-white font-mono bg-inherit">"`+backupDTO.path+`"</span>`);
download.innerHTML = `
<i class="ri-download-line"></i>
<span class="ml-2">${window.lang.strings("download")}</span>
<span class="badge ~info @low ml-2">${backupDTO.size}</span>
`;
download.parentElement.onclick = () => _download("/backups/" + backupDTO.name, backupDTO.name);
window.modals.backedUp.show();
}, true);
private _getBackups = () => _get("/backups", null, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;
const backups = req.response["backups"] as BackupDTO[];
const table = document.getElementById("backups-list");
table.textContent = ``;
if (!this._backupSortAscending) {
backups.reverse();
}
for (let b of backups) {
const tr = document.createElement("tr") as HTMLTableRowElement;
tr.innerHTML = `
<td class="whitespace-nowrap"><span class="text-black dark:text-white font-mono bg-inherit">${b.name}</span> <span class="button ~info @low ml-2 backup-copy" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span></td>
<td>${toDateString(new Date(b.date*1000))}</td>
<td class="table-inline justify-center">
<span class="backup-download button ~positive @low" title="${window.lang.strings("backupDownload")}">
<i class="ri-download-line"></i>
<span class="badge ~positive @low ml-2">${b.size}</span>
</span>
<span class="backup-restore button ~critical @low ml-2 py-[inherit]" title="${window.lang.strings("backupRestore")}"><i class="icon ri-restart-line"></i></span>
</td>
`;
tr.querySelector(".backup-copy").addEventListener("click", () => {
toClipboard(b.path);
window.notifications.customPositive("pathCopied", "", window.lang.notif("pathCopied"));
});
tr.querySelector(".backup-download").addEventListener("click", () => _download("/backups/" + b.name, b.name));
tr.querySelector(".backup-restore").addEventListener("click", () => {
_post("/backups/restore/"+b.name, null, () => {});
window.modals.backups.close();
window.modals.settingsRefresh.modal.querySelector("span.heading").textContent = window.lang.strings("settingsRestarting");
window.modals.settingsRefresh.show();
});
table.appendChild(tr);
}
});
constructor() { constructor() {
this._sections = {}; this._sections = {};
this._buttons = {}; this._buttons = {};
@ -748,7 +819,32 @@ export class settingsList {
this._saveButton.onclick = this._save; this._saveButton.onclick = this._save;
document.addEventListener("settings-requires-restart", () => { this._needsRestart = true; }); document.addEventListener("settings-requires-restart", () => { this._needsRestart = true; });
document.getElementById("settings-logs").onclick = this._showLogs; document.getElementById("settings-logs").onclick = this._showLogs;
document.getElementById("settings-backups-backup").onclick = () => {
window.modals.backups.close();
this._backup();
};
document.getElementById("settings-backups").onclick = () => {
this.setBackupSort(this._backupSortAscending);
window.modals.backups.show();
};
this._backupSortDirection.onclick = () => this.setBackupSort(!(this._backupSortAscending));
const advancedEnableToggle = document.getElementById("settings-advanced-enabled") as HTMLInputElement; const advancedEnableToggle = document.getElementById("settings-advanced-enabled") as HTMLInputElement;
const filedlg = document.getElementById("backups-file") as HTMLInputElement;
document.getElementById("settings-backups-upload").onclick = () => {
filedlg.click();
};
filedlg.addEventListener("change", () => {
if (filedlg.files.length == 0) return;
const form = new FormData();
form.append("backups-file", filedlg.files[0], filedlg.files[0].name);
_upload("/backups/restore", form);
window.modals.backups.close();
window.modals.settingsRefresh.modal.querySelector("span.heading").textContent = window.lang.strings("settingsRestarting");
window.modals.settingsRefresh.show();
});
advancedEnableToggle.onchange = () => { advancedEnableToggle.onchange = () => {
document.dispatchEvent(new CustomEvent("settings-advancedState", { detail: advancedEnableToggle.checked })); document.dispatchEvent(new CustomEvent("settings-advancedState", { detail: advancedEnableToggle.checked }));
const parent = advancedEnableToggle.parentElement; const parent = advancedEnableToggle.parentElement;

View File

@ -117,6 +117,8 @@ declare interface Modals {
email?: Modal; email?: Modal;
enableReferralsUser?: Modal; enableReferralsUser?: Modal;
enableReferralsProfile?: Modal; enableReferralsProfile?: Modal;
backedUp?: Modal;
backups?: Modal;
} }
interface Invite { interface Invite {

View File

@ -211,7 +211,7 @@ func (ud *Updater) GetTag() (Tag, int, error) {
var tag Tag var tag Tag
err = json.Unmarshal(body, &tag) err = json.Unmarshal(body, &tag)
if tag.Version == "" { if tag.Version == "" {
err = errors.New("Tag was empty") err = errors.New("Tag at \"" + url + "\" was empty")
} }
return tag, resp.StatusCode, err return tag, resp.StatusCode, err
} }

View File

@ -234,6 +234,11 @@ func (app *appContext) MyUserPage(gc *gin.Context) {
data["discordServerName"] = app.discord.serverName data["discordServerName"] = app.discord.serverName
data["discordInviteLink"] = app.discord.inviteChannelName != "" data["discordInviteLink"] = app.discord.inviteChannelName != ""
} }
if data["linkResetEnabled"].(bool) {
data["resetPasswordUsername"] = app.config.Section("user_page").Key("allow_pwr_username").MustBool(true)
data["resetPasswordEmail"] = app.config.Section("user_page").Key("allow_pwr_email").MustBool(true)
data["resetPasswordContactMethod"] = app.config.Section("user_page").Key("allow_pwr_contact_method").MustBool(true)
}
pageMessagesExist := map[string]bool{} pageMessagesExist := map[string]bool{}
pageMessages := map[string]CustomContent{} pageMessages := map[string]CustomContent{}
@ -275,7 +280,8 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
"ombiEnabled": app.config.Section("ombi").Key("enabled").MustBool(false), "ombiEnabled": app.config.Section("ombi").Key("enabled").MustBool(false),
} }
pwr, isInternal := app.internalPWRs[pin] pwr, isInternal := app.internalPWRs[pin]
if isInternal && setPassword { // if isInternal && setPassword {
if setPassword {
data["helpMessage"] = app.config.Section("ui").Key("help_message").String() data["helpMessage"] = app.config.Section("ui").Key("help_message").String()
data["successMessage"] = app.config.Section("ui").Key("success_message").String() data["successMessage"] = app.config.Section("ui").Key("success_message").String()
data["jfLink"] = app.config.Section("ui").Key("redirect_url").String() data["jfLink"] = app.config.Section("ui").Key("redirect_url").String()
@ -319,7 +325,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
var status int var status int
var err error var err error
var username string var username string
if !isInternal { if !isInternal && !setPassword {
resp, status, err = app.jf.ResetPassword(pin) resp, status, err = app.jf.ResetPassword(pin)
} else if time.Now().After(pwr.Expiry) { } else if time.Now().After(pwr.Expiry) {
app.debug.Printf("Ignoring PWR request due to expired internal PIN: %s", pin) app.debug.Printf("Ignoring PWR request due to expired internal PIN: %s", pin)