1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-06-20 00:17:46 +02:00

Compare commits

...

19 Commits

Author SHA1 Message Date
dependabot[bot]
643eb0086a
Merge 97506c0bcd into 7f518f55b2 2023-12-22 05:14:10 -07:00
7f518f55b2
Merge backups
Backups, manual & scheduled
2023-12-21 21:53:50 +00:00
ca4fbc0ad5
backups: change update button wording 2023-12-21 21:40:24 +00:00
b259dd7b00
backups: add wiki link 2023-12-21 21:38:42 +00:00
dc2c2f1164
backups: show uploaded backups on-page 2023-12-21 21:11:40 +00:00
bc2e9cffda
backups: move code to own files 2023-12-21 18:17:03 +00:00
ade032241a
backups: upload and restore backup in-app 2023-12-21 18:12:58 +00:00
eff313be41
backups: restore local backups in-app 2023-12-21 17:42:07 +00:00
ff73c72b0e
backups: add -restore cli argument 2023-12-21 17:27:28 +00:00
1bb83c88d9
backups: add filesize to list 2023-12-21 16:51:33 +00:00
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
733ab37539
backups: add backup daemon to run every n minutes, keep x most recent backups 2023-12-21 13:03:16 +00:00
c0c91b4aad
drone: source buildrone key from drone in docker build 2023-12-20 20:06:44 +00:00
83712a6937
pwr: fix set password for jellyfin PWRs 2023-12-20 19:04:40 +00:00
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
9cd402a15d
logs: fix file identifier 2023-12-20 18:28:42 +00:00
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
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]
97506c0bcd
build(deps): bump postcss from 8.4.24 to 8.4.31
Bumps [postcss](https://github.com/postcss/postcss) from 8.4.24 to 8.4.31.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.24...8.4.31)

---
updated-dependencies:
- dependency-name: postcss
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-05 11:38:10 +00:00
29 changed files with 852 additions and 99 deletions

View File

@ -131,6 +131,9 @@ steps:
volumes:
- name: ssh_key
path: /root/drone_rsa
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
settings:
host:
from_secret: ssh2_host
@ -140,13 +143,15 @@ steps:
from_secret: ssh2_port
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
envs:
- buildrone_key
key_path: /root/drone_rsa
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh
- wget https://builds.hrfee.pw/upload.py -O /mnt/buildx/jfa-go/jfa-go/upload.py
- 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
trigger:
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/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-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/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}}

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/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-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/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.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() {
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")
if address == "" {
app.debug.Println("Ignoring empty request for PWR")
@ -600,7 +603,7 @@ func (app *appContext) ResetMyPassword(gc *gin.Context) {
var pwr InternalPWR
var err error
jfUser, ok := app.ReverseUserSearch(address)
jfUser, ok := app.ReverseUserSearch(address, usernameAllowed, emailAllowed, contactMethodAllowed)
if !ok {
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
gc.BindJSON(&req)
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)
return
}
@ -731,7 +731,12 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
} else {
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)
}
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.")
PORT = flag.Int("port", 0, "alternate port to host web ui on.")
flag.IntVar(PORT, "p", 0, "SHORTHAND")
_LOADBAK = flag.String("restore", "", "path to database backup to restore.")
DEBUG = flag.Bool("debug", false, "Enables debug logging.")
PPROF = flag.Bool("pprof", false, "Exposes pprof profiler on /debug/pprof.")
SWAGGER = flag.Bool("swagger", false, "Enable swagger at /swagger/index.html")
@ -41,6 +42,9 @@ func (app *appContext) loadArgs(firstCall bool) {
if *PPROF {
os.Setenv("PPROF", "1")
}
if *_LOADBAK != "" {
LOADBAK = *_LOADBAK
}
}
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("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("device").SetValue("jfa-go")
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("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)
telegramEnabled = app.config.Section("telegram").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",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "bool",
"value": true,
"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",
"required": false,
"requires_restart": true,
"depends_true": "enabled",
"type": "bool",
"value": true,
"description": "Users are given their own \"invite\" to send to others."
@ -648,6 +650,41 @@
"depends_true": "referrals",
"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."
},
"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": {
"order": [],
"meta": {

View File

@ -75,7 +75,7 @@ func (app *appContext) clearTelegram() {
}
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)
maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90)
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.
// 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
user, status, err := app.jf.UserByName(address, false)
if status == 200 && err == nil {
ok = true
return
var status int
var err error = nil
if matchUsername {
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 err == nil && len(emailAddresses) > 0 {
for _, emailUser := range emailAddresses {
user, status, err = app.jf.UserByID(emailUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
if matchEmail {
emailAddresses := []EmailAddress{}
err = app.storage.db.Find(&emailAddresses, badgerhold.Where("Addr").Eq(address))
if err == nil && len(emailAddresses) > 0 {
for _, emailUser := range emailAddresses {
user, status, err = app.jf.UserByID(emailUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
}
}
}
}
// Dont know how we'd use badgerhold when we need to render each username,
// Apart from storing the rendered name in the db.
for _, dcUser := range app.storage.GetDiscord() {
if RenderDiscordUsername(dcUser) == strings.ToLower(address) {
user, status, err = app.jf.UserByID(dcUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
if matchContactMethod {
for _, dcUser := range app.storage.GetDiscord() {
if RenderDiscordUsername(dcUser) == strings.ToLower(address) {
user, status, err = app.jf.UserByID(dcUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
}
}
}
}
tgUsername := strings.TrimPrefix(address, "@")
telegramUsers := []TelegramUser{}
err = app.storage.db.Find(&telegramUsers, badgerhold.Where("Username").Eq(tgUsername))
if err == nil && len(telegramUsers) > 0 {
for _, telegramUser := range telegramUsers {
user, status, err = app.jf.UserByID(telegramUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
tgUsername := strings.TrimPrefix(address, "@")
telegramUsers := []TelegramUser{}
err = app.storage.db.Find(&telegramUsers, badgerhold.Where("Username").Eq(tgUsername))
if err == nil && len(telegramUsers) > 0 {
for _, telegramUser := range telegramUsers {
user, status, err = app.jf.UserByID(telegramUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
}
}
}
}
matrixUsers := []MatrixUser{}
err = app.storage.db.Find(&matrixUsers, badgerhold.Where("UserID").Eq(address))
if err == nil && len(matrixUsers) > 0 {
for _, matrixUser := range matrixUsers {
user, status, err = app.jf.UserByID(matrixUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
return
matrixUsers := []MatrixUser{}
err = app.storage.db.Find(&matrixUsers, badgerhold.Where("UserID").Eq(address))
if err == nil && len(matrixUsers) > 0 {
for _, matrixUser := range matrixUsers {
user, status, err = app.jf.UserByID(matrixUser.JellyfinID, false)
if status == 200 && err == nil {
ok = true
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="">
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8">
<div class="row">
<div class="col">
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<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>
<aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside>
<div>
<span class="text-xl supra row py-1">{{ .strings.setExpiry }}</span>
<div class="row">
<input type="text" id="extend-expiry-text" class="input ~neutral @low mb-2 mt-4" placeholder="{{ .strings.enterExpiry }}">
</div>
</div>
<div class="row">
<div class="col">
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-hours">
<option>0</option>
</select>
<div id="extend-expiry-field-inputs">
<span class="text-xl supra row py-1">{{ .strings.extendExpiry }}</span>
<div class="row">
<div class="col">
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<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 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 class="row">
<div class="col">
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="extend-expiry-hours">
<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>
@ -318,6 +328,47 @@
</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 class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.settingsApplied }}</span>
@ -800,7 +851,8 @@
</label>
</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 ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
</div>

View File

@ -48,11 +48,17 @@
</div>
{{ if .pwrEnabled }}
<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>
<p class="content my-2">
{{ 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 }}
{{ .strings.resetPasswordThroughJellyfin }}
{{ end }}

View File

@ -64,6 +64,7 @@
"extendExpiry": "Extend expiry",
"setExpiry": "Set 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.",
"sendPWRSuccess": "Password reset link sent.",
"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}",
"accountReEnabled": "Account re-enabled: {user}",
"accountExpired": "Account expired: {user}",
"accountWillExpire": "Account will expire on {date}",
"userDeleted": "User was deleted.",
"userDisabled": "User was disabled",
"inviteCreated": "Invite created: {invite}",
@ -178,9 +180,23 @@
"noMoreResults": "No more results.",
"totalRecords": "{n} Total Records",
"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": {
"pathCopied": "Full path copied to clipboard.",
"changedEmailAddress": "Changed email address of {n}.",
"userCreated": "User {n} created.",
"createProfile": "Created profile {n}.",
@ -219,6 +235,7 @@
"errorCheckUpdate": "Failed to check for update.",
"errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.",
"errorLoadActivities": "Failed to load activities.",
"errorInvalidDate": "Date is invalid.",
"updateAvailable": "A new update is available, check settings.",
"noUpdatesAvailable": "No new updates available."
},

View File

@ -32,6 +32,11 @@
"resetPassword": "Reset Password",
"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.",
"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.",
"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",

View File

@ -49,7 +49,7 @@ func Lshortfile(level int) string {
}
func lshortfile() string {
return Lshortfile(2)
return Lshortfile(3)
}
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
buildTimeUnix string
builtBy string
_LOADBAK *string
LOADBAK = ""
)
var temp = func() string {
@ -355,8 +357,10 @@ func start(asDaemon, firstCall bool) {
}
app.storage.db_path = filepath.Join(app.dataPath, "db")
app.loadPendingBackup()
app.ConnectDB()
defer app.storage.db.Close()
// Read config-base for settings on web.
app.configBasePath = "config-base.json"
configBase, _ := fs.ReadFile(localFS, app.configBasePath)
@ -475,6 +479,13 @@ func start(asDaemon, firstCall bool) {
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 {
app.telegram, err = newTelegramDaemon(app)
if err != nil {
@ -647,6 +658,9 @@ func flagPassed(name string) (found bool) {
// @tag.name Ombi
// @tag.description Ombi related operations.
// @tag.name Backups
// @tag.description Database backup/restore operations.
// @tag.name Other
// @tag.description Things that dont fit elsewhere.

View File

@ -261,11 +261,12 @@ type customEmailDTO struct {
}
type extendExpiryDTO struct {
Users []string `json:"users"` // List of user IDs to apply to.
Months int `json:"months" example:"1"` // Number of months to add.
Days int `json:"days" example:"1"` // Number of days to add.
Hours int `json:"hours" example:"2"` // Number of hours to add.
Minutes int `json:"minutes" example:"3"` // Number of minutes to add.
Users []string `json:"users"` // List of user IDs to apply to.
Months int `json:"months" example:"1"` // Number of months to add.
Days int `json:"days" example:"1"` // Number of days to add.
Hours int `json:"hours" example:"2"` // Number of hours 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 {
@ -460,3 +461,14 @@ type GetActivitiesRespDTO struct {
type GetActivityCountDTO struct {
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"`
}

14
package-lock.json generated
View File

@ -22,7 +22,7 @@
"mjml": "^4.14.1",
"nightwind": "^1.1.13",
"perl-regex": "^1.0.4",
"postcss": "^8.4.24",
"postcss": "^8.4.31",
"remixicon": "^3.3.0",
"remove-markdown": "^0.5.0",
"tailwindcss": "^3.3.2",
@ -4273,9 +4273,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.24",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz",
"integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==",
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
@ -9990,9 +9990,9 @@
"dev": true
},
"postcss": {
"version": "8.4.24",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz",
"integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==",
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"requires": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",

View File

@ -30,7 +30,7 @@
"mjml": "^4.14.1",
"nightwind": "^1.1.13",
"perl-regex": "^1.0.4",
"postcss": "^8.4.24",
"postcss": "^8.4.31",
"remixicon": "^3.3.0",
"remove-markdown": "^0.5.0",
"tailwindcss": "^3.3.2",

View File

@ -208,6 +208,11 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.POST(p+"/config", app.ModifyConfig)
api.POST(p+"/restart", app.restart)
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 {
api.GET(p+"/telegram/pin", app.TelegramGetPin)
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.backedUp = new Modal(document.getElementById("modal-backed-up"));
window.modals.backups = new Modal(document.getElementById("modal-backups"));
if (window.telegramEnabled) {
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 _expiryDropdown = document.getElementById("accounts-expiry-dropdown") as HTMLElement;
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 _enableExpiryNotify = document.getElementById("expiry-extend-enable") as HTMLInputElement;
private _enableExpiryReason = document.getElementById("textarea-extend-enable") as HTMLTextAreaElement;
@ -1625,6 +1631,45 @@ export class accountsList {
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) => {
const list = this._collectUsers();
let applyList: string[] = [];
@ -1647,10 +1692,20 @@ export class accountsList {
}
document.getElementById("header-extend-expiry").textContent = header;
const extend = () => {