mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-28 03:50:10 +00:00
Compare commits
18 Commits
9c8cf7cc9f
...
bb7b8e8a48
Author | SHA1 | Date | |
---|---|---|---|
|
bb7b8e8a48 | ||
7f518f55b2 | |||
ca4fbc0ad5 | |||
b259dd7b00 | |||
dc2c2f1164 | |||
bc2e9cffda | |||
ade032241a | |||
eff313be41 | |||
ff73c72b0e | |||
1bb83c88d9 | |||
195813c058 | |||
733ab37539 | |||
c0c91b4aad | |||
83712a6937 | |||
290d02d248 | |||
9cd402a15d | |||
1a6897637f | |||
213b1e7f9e |
@ -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:
|
||||||
|
@ -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}}
|
||||||
|
1
Makefile
1
Makefile
@ -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
117
api-backups.go
Normal 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)
|
||||||
|
}
|
@ -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")
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
4
args.go
4
args.go
@ -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
180
backups.go
Normal 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
|
||||||
|
}
|
18
config.go
18
config.go
@ -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)
|
||||||
|
@ -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": {
|
||||||
|
@ -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)
|
||||||
|
86
email.go
86
email.go
@ -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
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
112
html/admin.html
112
html/admin.html
@ -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">×</span></span>
|
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">×</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">×</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">×</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>
|
||||||
|
@ -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 }}
|
||||||
|
@ -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."
|
||||||
},
|
},
|
||||||
|
@ -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",
|
||||||
|
@ -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
14
main.go
@ -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.
|
||||||
|
|
||||||
|
22
models.go
22
models.go
@ -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"`
|
||||||
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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"));
|
||||||
}
|
}
|
||||||
|
@ -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");
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
10
views.go
10
views.go
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user