mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-19 09:07:44 +01:00
Compare commits
31 Commits
activity-l
...
backups
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca4fbc0ad5 | ||
|
|
b259dd7b00 | ||
|
|
dc2c2f1164 | ||
|
|
bc2e9cffda | ||
|
|
ade032241a | ||
|
|
eff313be41 | ||
|
|
ff73c72b0e | ||
|
|
1bb83c88d9 | ||
|
|
195813c058 | ||
|
|
733ab37539 | ||
|
|
c0c91b4aad | ||
|
|
83712a6937 | ||
|
|
290d02d248 | ||
|
|
9cd402a15d | ||
|
|
1a6897637f | ||
|
|
213b1e7f9e | ||
|
|
10c8d4ad2f | ||
|
|
4fcb58aefa | ||
|
|
8c2a35f755 | ||
|
|
a66c522b73 | ||
|
|
d0de1142ae | ||
|
|
8d6ad7e3c8 | ||
|
|
8ae5dd97b2 | ||
|
|
cf747c1ddb | ||
|
|
8cb53d1c6f | ||
|
|
bd8ecebf89 | ||
|
|
09158b5bb5 | ||
|
|
aa30f1c392 | ||
|
|
4a2fc6d418 | ||
|
|
1846e31bf5 | ||
|
|
1be20d471d |
@@ -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:
|
||||
|
||||
@@ -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}}
|
||||
|
||||
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/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
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)
|
||||
}
|
||||
@@ -45,14 +45,24 @@ func (app *appContext) checkInvites() {
|
||||
app.storage.SetInvitesKey(data.Code, data)
|
||||
}
|
||||
|
||||
if data.IsReferral {
|
||||
if data.IsReferral && (!data.UseReferralExpiry || data.ReferrerJellyfinID == "") {
|
||||
continue
|
||||
}
|
||||
expiry := data.ValidTill
|
||||
if !currentTime.After(expiry) {
|
||||
continue
|
||||
}
|
||||
|
||||
app.debug.Printf("Housekeeping: Deleting old invite %s", data.Code)
|
||||
|
||||
// Disable referrals for the user if UseReferralExpiry is enabled, so no new ones are made.
|
||||
if data.IsReferral && data.UseReferralExpiry && data.ReferrerJellyfinID != "" {
|
||||
user, ok := app.storage.GetEmailsKey(data.ReferrerJellyfinID)
|
||||
if ok {
|
||||
user.ReferralTemplateKey = ""
|
||||
app.storage.SetEmailsKey(data.ReferrerJellyfinID, user)
|
||||
}
|
||||
}
|
||||
notify := data.Notify
|
||||
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
|
||||
app.debug.Printf("%s: Expiry notification", data.Code)
|
||||
@@ -136,6 +146,13 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
|
||||
}
|
||||
wait.Wait()
|
||||
}
|
||||
if inv.IsReferral && inv.ReferrerJellyfinID != "" && inv.UseReferralExpiry {
|
||||
user, ok := app.storage.GetEmailsKey(inv.ReferrerJellyfinID)
|
||||
if ok {
|
||||
user.ReferralTemplateKey = ""
|
||||
app.storage.SetEmailsKey(inv.ReferrerJellyfinID, user)
|
||||
}
|
||||
}
|
||||
match = false
|
||||
app.storage.DeleteInvitesKey(code)
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
|
||||
@@ -36,8 +36,8 @@ func (app *appContext) GetCustomContent(gc *gin.Context) {
|
||||
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled},
|
||||
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled},
|
||||
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled},
|
||||
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("Login").Enabled},
|
||||
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("Page").Enabled},
|
||||
"UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled},
|
||||
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled},
|
||||
}
|
||||
|
||||
filter := gc.Query("filter")
|
||||
|
||||
@@ -130,15 +130,17 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param profile path string true "name of profile to enable referrals for."
|
||||
// @Param invite path string true "invite code to create referral template from."
|
||||
// @Param useExpiry path string true "with-expiry or none."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} stringResponse
|
||||
// @Failure 500 {object} stringResponse
|
||||
// @Router /profiles/referral/{profile}/{invite} [post]
|
||||
// @Router /profiles/referral/{profile}/{invite}/{useExpiry} [post]
|
||||
// @Security Bearer
|
||||
// @tags Profiles & Settings
|
||||
func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
|
||||
profileName := gc.Param("profile")
|
||||
invCode := gc.Param("invite")
|
||||
useExpiry := gc.Param("useExpiry") == "with-expiry"
|
||||
inv, ok := app.storage.GetInvitesKey(invCode)
|
||||
if !ok {
|
||||
respond(400, "Invalid invite code", gc)
|
||||
@@ -154,9 +156,15 @@ func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
|
||||
|
||||
// Generate new code for referral template
|
||||
inv.Code = GenerateInviteCode()
|
||||
expiryDelta := inv.ValidTill.Sub(inv.Created)
|
||||
inv.Created = time.Now()
|
||||
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
||||
if useExpiry {
|
||||
inv.ValidTill = inv.Created.Add(expiryDelta)
|
||||
} else {
|
||||
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
||||
}
|
||||
inv.IsReferral = true
|
||||
inv.UseReferralExpiry = useExpiry
|
||||
// Since this is a template for multiple users, ReferrerJellyfinID is not set.
|
||||
// inv.ReferrerJellyfinID = ...
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -746,21 +749,37 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
|
||||
// Since this key is shared between users in a profile, we make a copy.
|
||||
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
|
||||
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
|
||||
if !ok || err != nil {
|
||||
if !ok || err != nil || user.ReferralTemplateKey == "" {
|
||||
app.debug.Printf("Ignoring referral request, couldn't find template.")
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
inv.Code = GenerateInviteCode()
|
||||
expiryDelta := inv.ValidTill.Sub(inv.Created)
|
||||
inv.Created = time.Now()
|
||||
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
||||
if inv.UseReferralExpiry {
|
||||
inv.ValidTill = inv.Created.Add(expiryDelta)
|
||||
} else {
|
||||
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
||||
}
|
||||
inv.IsReferral = true
|
||||
inv.ReferrerJellyfinID = gc.GetString("jfId")
|
||||
app.storage.SetInvitesKey(inv.Code, inv)
|
||||
} else if time.Now().After(inv.ValidTill) {
|
||||
// 3. We found an invite for us, but it's expired.
|
||||
// We delete it from storage, and put it back with a fresh code and expiry.
|
||||
// If UseReferralExpiry is enabled, we delete it and return nothing.
|
||||
app.storage.DeleteInvitesKey(inv.Code)
|
||||
if inv.UseReferralExpiry {
|
||||
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
|
||||
if ok {
|
||||
user.ReferralTemplateKey = ""
|
||||
app.storage.SetEmailsKey(gc.GetString("jfId"), user)
|
||||
}
|
||||
app.debug.Printf("Ignoring referral request, expired.")
|
||||
respondBool(400, false, gc)
|
||||
return
|
||||
}
|
||||
inv.Code = GenerateInviteCode()
|
||||
inv.Created = time.Now()
|
||||
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
||||
@@ -771,5 +790,6 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
|
||||
RemainingUses: inv.RemainingUses,
|
||||
NoLimit: inv.NoLimit,
|
||||
Expiry: inv.ValidTill.Unix(),
|
||||
UseExpiry: inv.UseReferralExpiry,
|
||||
})
|
||||
}
|
||||
|
||||
49
api-users.go
49
api-users.go
@@ -367,6 +367,19 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
|
||||
emailStore.ReferralTemplateKey = profile.ReferralTemplateKey
|
||||
// Store here, just incase email are disabled (whether this is even possible, i don't know)
|
||||
app.storage.SetEmailsKey(id, emailStore)
|
||||
|
||||
// If UseReferralExpiry is enabled, create the ref now so the clock starts ticking
|
||||
refInv := Invite{}
|
||||
err = app.storage.db.Get(profile.ReferralTemplateKey, &refInv)
|
||||
if refInv.UseReferralExpiry {
|
||||
refInv.Code = GenerateInviteCode()
|
||||
expiryDelta := refInv.ValidTill.Sub(refInv.Created)
|
||||
refInv.Created = time.Now()
|
||||
refInv.ValidTill = refInv.Created.Add(expiryDelta)
|
||||
refInv.IsReferral = true
|
||||
refInv.ReferrerJellyfinID = id
|
||||
app.storage.SetInvitesKey(refInv.Code, refInv)
|
||||
}
|
||||
}
|
||||
}
|
||||
// if app.config.Section("password_resets").Key("enabled").MustBool(false) {
|
||||
@@ -694,7 +707,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Extend time before the user(s) expiry, or create and expiry if it doesn't exist.
|
||||
// @Summary Extend time before the user(s) expiry, or create an expiry if it doesn't exist.
|
||||
// @Produce json
|
||||
// @Param extendExpiryDTO body extendExpiryDTO true "Extend expiry object"
|
||||
// @Success 200 {object} boolResponse
|
||||
@@ -706,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
|
||||
}
|
||||
@@ -718,29 +731,47 @@ 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)
|
||||
}
|
||||
|
||||
// @Summary Remove an expiry from a user's account.
|
||||
// @Produce json
|
||||
// @Param id path string true "id of user to extend expiry of."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Router /users/{id}/expiry [delete]
|
||||
// @tags Users
|
||||
func (app *appContext) RemoveExpiry(gc *gin.Context) {
|
||||
app.storage.DeleteUserExpiryKey(gc.Param("id"))
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Enable referrals for the given user(s) based on the rules set in the given invite code, or profile.
|
||||
// @Produce json
|
||||
// @Param EnableDisableReferralDTO body EnableDisableReferralDTO true "List of users"
|
||||
// @Param mode path string true "mode of template sourcing from 'invite' or 'profile'."
|
||||
// @Param source path string true "invite code or profile name, depending on what mode is."
|
||||
// @Param useExpiry path string true "with-expiry or none."
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Failure 400 {object} boolResponse
|
||||
// @Failure 500 {object} boolResponse
|
||||
// @Router /users/referral/{mode}/{source} [post]
|
||||
// @Router /users/referral/{mode}/{source}/{useExpiry} [post]
|
||||
// @Security Bearer
|
||||
// @tags Users
|
||||
func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
|
||||
var req EnableDisableReferralDTO
|
||||
gc.BindJSON(&req)
|
||||
mode := gc.Param("mode")
|
||||
source := gc.Param("source")
|
||||
|
||||
source := gc.Param("source")
|
||||
useExpiry := gc.Param("useExpiry") == "with-expiry"
|
||||
baseInv := Invite{}
|
||||
if mode == "profile" {
|
||||
profile, ok := app.storage.GetProfileKey(source)
|
||||
@@ -768,10 +799,16 @@ func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
|
||||
// 2. Generate referral invite.
|
||||
inv := baseInv
|
||||
inv.Code = GenerateInviteCode()
|
||||
expiryDelta := inv.ValidTill.Sub(inv.Created)
|
||||
inv.Created = time.Now()
|
||||
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
||||
if useExpiry {
|
||||
inv.ValidTill = inv.Created.Add(expiryDelta)
|
||||
} else {
|
||||
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
||||
}
|
||||
inv.IsReferral = true
|
||||
inv.ReferrerJellyfinID = u
|
||||
inv.UseReferralExpiry = useExpiry
|
||||
app.storage.SetInvitesKey(inv.Code, inv)
|
||||
}
|
||||
}
|
||||
|
||||
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.")
|
||||
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
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("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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
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.
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
132
html/admin.html
132
html/admin.html
@@ -130,6 +130,11 @@
|
||||
<div class="select ~neutral @low mb-4 unfocused">
|
||||
<select id="enable-referrals-user-invites"></select>
|
||||
</div>
|
||||
<label class="switch mb-4">
|
||||
<input type="checkbox" id="enable-referrals-user-expiry">
|
||||
<span>{{ .strings.useInviteExpiry }}</span>
|
||||
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
|
||||
@@ -144,6 +149,11 @@
|
||||
<div class="select ~neutral @low mb-4 mt-2">
|
||||
<select id="enable-referrals-profile-invites"></select>
|
||||
</div>
|
||||
<label class="switch mb-4">
|
||||
<input type="checkbox" id="enable-referrals-profile-expiry">
|
||||
<span>{{ .strings.useInviteExpiry }}</span>
|
||||
<span class="flex flex-row support mt-2">{{ .strings.useInviteExpiryNote }}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="submit" class="unfocused">
|
||||
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
|
||||
@@ -171,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">×</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>
|
||||
@@ -308,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">×</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 class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
|
||||
<span class="heading">{{ .strings.settingsApplied }}</span>
|
||||
@@ -669,7 +730,15 @@
|
||||
{{ if .referralsEnabled }}
|
||||
<span class="col button ~urge @low center max-w-[20%]" id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
|
||||
{{ end }}
|
||||
<span class="col button ~warning @low center max-w-[20%]" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
|
||||
<div id="accounts-expiry-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
|
||||
<span class="w-100 button ~positive @low center" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low">
|
||||
<span class="button ~warning full-width @low center" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
|
||||
<span class="button ~critical full-width @low center mt-2" id="accounts-remove-expiry">{{ .strings.removeExpiry }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accounts-disable-enable-dropdown" class="col dropdown manual pb-0i max-w-[20%]" tabindex="0">
|
||||
<span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
|
||||
<div class="dropdown-display">
|
||||
@@ -782,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>
|
||||
|
||||
@@ -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 }}
|
||||
@@ -155,7 +161,7 @@
|
||||
<div>
|
||||
<div class="card @low dark:~d_neutral unfocused" id="card-referrals">
|
||||
<span class="heading mb-2">{{ .strings.referrals }}</span>
|
||||
<aside class="aside ~neutral my-4 col">{{ .strings.referralsDescription }}</aside>
|
||||
<aside class="aside ~neutral my-4 col user-referrals-description"></aside>
|
||||
<div class="row flex-expand">
|
||||
<div class="user-referrals-info"></div>
|
||||
<div class="grid my-2">
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
"profile": "Profil",
|
||||
"unknown": "Ukendt",
|
||||
"label": "Etiket",
|
||||
"announce": "Annoncere",
|
||||
"announce": "Meddelelse",
|
||||
"subject": "Emne",
|
||||
"message": "Meddelelse",
|
||||
"message": "Besked",
|
||||
"variables": "Variabler",
|
||||
"conditionals": "Betingelser",
|
||||
"preview": "Eksempel",
|
||||
@@ -47,13 +47,13 @@
|
||||
"donate": "Doner",
|
||||
"contactThrough": "Kontakt gennem:",
|
||||
"extendExpiry": "Forlæng udløb",
|
||||
"customizeMessages": "Tilpas Meddelelser",
|
||||
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's meddelelses skabeloner, kan du oprette din egen ved hjælp af Markdown.",
|
||||
"customizeMessages": "Tilpas Beskeder",
|
||||
"customizeMessagesDescription": "Hvis du ikke vil bruge jfa-go's besked skabeloner, kan du oprette din egen ved hjælp af Markdown.",
|
||||
"markdownSupported": "Markdown understøttes.",
|
||||
"modifySettings": "Rediger indstillinger",
|
||||
"modifySettingsDescription": "Anvend indstillinger fra en eksisterende profil, eller hent dem direkte fra en bruger.",
|
||||
"applyHomescreenLayout": "Anvend startskærmens layout",
|
||||
"sendDeleteNotificationEmail": "Send notifikations meddelelse",
|
||||
"sendDeleteNotificationEmail": "Send notifikations besked",
|
||||
"sendDeleteNotifiationExample": "Din konto er blevet slettet.",
|
||||
"settingsRestart": "Genstart",
|
||||
"settingsRestarting": "Genstarter…",
|
||||
@@ -102,7 +102,35 @@
|
||||
"sendPWRSuccessManual": "Hvis brugeren ikke er modtaget den, så tryk på kopier for manuelt at sende et link til dem.",
|
||||
"sendPWRValidFor": "Dette link er gyldigt i 30m.",
|
||||
"accessJFA": "Få adgang til jfa-go",
|
||||
"accessJFASettings": "Kan ikke ændres, da enten \"Kun administrator\" eller \"Tillad alle\" er blevet indstillet i Indstillinger > Generelt."
|
||||
"accessJFASettings": "Kan ikke ændres, da enten \"Kun administrator\" eller \"Tillad alle\" er blevet indstillet i Indstillinger > Generelt.",
|
||||
"after": "Efter",
|
||||
"settingsHiddenDependency": "Matchende indstillinger er skjult, fordi de afhænger af værdien af en anden indstilling:",
|
||||
"userPageLogin": "Brugerside: Login",
|
||||
"buildTime": "Bygnings Tid",
|
||||
"invite": "inviter",
|
||||
"loginNotAdmin": "Ikke en Admin?",
|
||||
"userLabel": "Brugeretiket",
|
||||
"userLabelDescription": "Etiket, der skal anvendes på brugere, der er oprettet med denne invitation.",
|
||||
"sortingBy": "Sortering Efter",
|
||||
"clickToRemoveFilter": "Klik for at fjerne dette filter.",
|
||||
"clearSearch": "Ryd søgning",
|
||||
"actions": "Handlinger",
|
||||
"unlink": "Fjern linket til konto",
|
||||
"enableReferrals": "Aktiver henvisninger",
|
||||
"disableReferrals": "Deaktiver henvisninger",
|
||||
"enableReferralsDescription": "Giv brugerne et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Kan hentes fra en henvisningsskabelon i en profil eller fra en eksisterende invitation.",
|
||||
"enableReferralsProfileDescription": "Giv brugere oprettet med denne profil et personligt henvisningslink, der ligner en invitation, til at sende til venner/familie. Opret en invitation med de ønskede indstillinger, og vælg den her. Hver henvisning vil så være baseret på denne invitation. Du kan slette invitationen, når den er fuldført.",
|
||||
"before": "Før",
|
||||
"noResultsFound": "Ingen Resultater Fundet",
|
||||
"settingsDependsOn": "{setting}: afhænger af {dependency}",
|
||||
"settingsMaybeUnderAdvanced": "Tip: Du finder muligvis det du leder efter, ved at aktivere Avancerede indstillinger.",
|
||||
"settingsAdvancedMode": "{setting}: Avanceret Indstillinger skal være aktiveret",
|
||||
"filters": "Filtre",
|
||||
"searchOptions": "Søge Indstillinger",
|
||||
"matchText": "Match Tekst",
|
||||
"jellyfinID": "Jellyfin ID",
|
||||
"userPagePage": "Brugerside: Side",
|
||||
"builtBy": "Bygget Af"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Ændret e-mail adresse på {n}.",
|
||||
@@ -133,14 +161,16 @@
|
||||
"errorFailureCheckLogs": "Mislykkedes (tjek konsol/logfiler)",
|
||||
"errorPartialFailureCheckLogs": "Delvis fejl (tjek konsol/logfiler)",
|
||||
"errorUserCreated": "Kunne ikke oprette bruger {n}.",
|
||||
"errorSendWelcomeEmail": "Kunne ikke sende velkomst meddelelse (tjek konsol/logfiler",
|
||||
"errorSendWelcomeEmail": "Kunne ikke sende velkomst besked (tjek konsol/logfiler",
|
||||
"errorApplyUpdate": "Kunne ikke anvende opdateringen, prøv manuelt.",
|
||||
"errorCheckUpdate": "Kunne ikke kontrollere for opdatering.",
|
||||
"updateAvailable": "En ny opdatering er tilgængelig, tjek indstillingerne.",
|
||||
"noUpdatesAvailable": "Ingen nye opdateringer tilgængelige.",
|
||||
"savedAnnouncement": "Meddelelse gemt.",
|
||||
"setOmbiProfile": "Gemt i ombi profilen.",
|
||||
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes."
|
||||
"errorSetOmbiProfile": "Ombi profilen kunne ikke gemmes.",
|
||||
"referralsEnabled": "Henvisninger aktiveret.",
|
||||
"errorNoReferralTemplate": "Profilen indeholder ikke en henvisningsskabelon. Tilføj en i indstillingerne."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
@@ -180,8 +210,8 @@
|
||||
"plural": "Aktiveret {n} brugere."
|
||||
},
|
||||
"announceTo": {
|
||||
"singular": "Annoncer til {n} bruger",
|
||||
"plural": "Annoncer til {n} brugere"
|
||||
"singular": "Send Meddelelse til {n} bruger",
|
||||
"plural": "Send Meddelelse til {n} brugere"
|
||||
},
|
||||
"appliedSettings": {
|
||||
"singular": "Anvendte indstillinger til {n} bruger.",
|
||||
@@ -198,6 +228,10 @@
|
||||
"setExpiry": {
|
||||
"singular": "Indstil udløb for {n} bruger",
|
||||
"plural": "Indstil udløb for {n} brugere"
|
||||
},
|
||||
"enableReferralsFor": {
|
||||
"singular": "Aktiver Henvisninger for {n} bruger",
|
||||
"plural": "Aktiver Henvisninger for {n} brugere"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"select": "Select",
|
||||
"name": "Name",
|
||||
"date": "Date",
|
||||
"setExpiry": "Set expiry",
|
||||
"updates": "Updates",
|
||||
"update": "Update",
|
||||
"download": "Download",
|
||||
@@ -63,6 +62,9 @@
|
||||
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
|
||||
"contactThrough": "Contact through:",
|
||||
"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.",
|
||||
@@ -76,6 +78,8 @@
|
||||
"disableReferrals": "Disable Referrals",
|
||||
"enableReferralsDescription": "Give users a personal referral link similiar to an invite, to send to friends/family. Can be sourced from a referral template in a profile, or from an existing invite.",
|
||||
"enableReferralsProfileDescription": "Give users created with this profile a personal referral link similiar to an invite, to send to friends/family. Create an invite with the desired settings, then select it here. Each referral will then be based on this invite. You can delete the invite once complete.",
|
||||
"useInviteExpiry": "Set expiry from profile/invite",
|
||||
"useInviteExpiryNote": "By default, invites expire after 90 days but can be renewed by the user. Enable for the referral to be disabled after the time set.",
|
||||
"applyHomescreenLayout": "Apply homescreen layout",
|
||||
"sendDeleteNotificationEmail": "Send notification message",
|
||||
"sendDeleteNotifiationExample": "Your account has been deleted.",
|
||||
@@ -146,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}",
|
||||
@@ -175,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}.",
|
||||
@@ -216,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."
|
||||
},
|
||||
|
||||
@@ -102,7 +102,35 @@
|
||||
"ombiProfile": "Ombi gebruikersprofiel",
|
||||
"logs": "Logs",
|
||||
"accessJFA": "Toegang tot jfa-go",
|
||||
"accessJFASettings": "Kan niet worden aangepast, omdat \"Alleen beheerders\" of \"Laat alle Jellyfin-gebruikers inloggen\" is aangevinkt in Instellingen > Algemeen."
|
||||
"accessJFASettings": "Kan niet worden aangepast, omdat \"Alleen beheerders\" of \"Laat alle Jellyfin-gebruikers inloggen\" is aangevinkt in Instellingen > Algemeen.",
|
||||
"noResultsFound": "Geen resultaten gevonden",
|
||||
"settingsHiddenDependency": "Overeenkomende instellingen zijn verborgen, omdat ze afhangen van een andere instelling:",
|
||||
"settingsAdvancedMode": "{setting}: Geavanceerde instellingen moet ingeschakeld zijn",
|
||||
"builtBy": "Build door",
|
||||
"buildTime": "Build moment",
|
||||
"userPageLogin": "Gebruikerspagina: Inloggen",
|
||||
"loginNotAdmin": "Geen beheerder?",
|
||||
"before": "Voor",
|
||||
"unlink": "Ontkoppel account",
|
||||
"after": "Na",
|
||||
"invite": "Uitnodiging",
|
||||
"userLabel": "Gebruikerslabel",
|
||||
"userLabelDescription": "Label om toe te wijzen aan gebruikers aangemaakt met deze uitnodiging.",
|
||||
"enableReferrals": "Verwijzingen inschakelen",
|
||||
"disableReferrals": "Verwijzingen uitschakelen",
|
||||
"enableReferralsDescription": "Geef gebruikers een persoonlijke verwijslink gelijkend op een uitnodiging, om naar vrienden/familie te sturen. Kan opgebouwd worden aan de hand van een verwijssjabloon in een profiel, of een bestaande uitnodiging.",
|
||||
"enableReferralsProfileDescription": "Geef gebruikers aangemaakt met dit profiel een persoonlijke verwijslink gelijkend op een uitnodiging, om naar vrienden/familie te sturen. Maak een uitnodiging aan met de gewenste instellingen, en selecteer die hier. Elke verwijzing wordt gebaseerd op die uitnodiging. Je kunt de uitnodiging daarna verwijderen.",
|
||||
"settingsDependsOn": "{setting}: hangt af van {dependency}",
|
||||
"settingsMaybeUnderAdvanced": "Tip: je vindt misschien wat je zoekt door Geavanceerde instellingen in te schakelen.",
|
||||
"sortingBy": "Sorteren naar",
|
||||
"filters": "Filters",
|
||||
"clickToRemoveFilter": "Klik om dit filter te verwijderen.",
|
||||
"clearSearch": "Zoekopdracht verwijderen",
|
||||
"actions": "Acties",
|
||||
"searchOptions": "Zoekopties",
|
||||
"matchText": "Tekstovereenkomst",
|
||||
"jellyfinID": "Jellyfin ID",
|
||||
"userPagePage": "Gebruikerspagina: Pagina"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "E-mailadres van {n} gewijzigd.",
|
||||
@@ -140,7 +168,9 @@
|
||||
"accountConnected": "Account gekoppeld.",
|
||||
"savedAnnouncement": "Aankondiging opgeslagen.",
|
||||
"setOmbiProfile": "Opgeslagen ombi-profiel.",
|
||||
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt."
|
||||
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.",
|
||||
"errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.",
|
||||
"referralsEnabled": "Verwijzingen actief."
|
||||
},
|
||||
"quantityStrings": {
|
||||
"modifySettingsFor": {
|
||||
@@ -198,6 +228,10 @@
|
||||
"setExpiry": {
|
||||
"singular": "Stel verloop in voor {n} gebruiker",
|
||||
"plural": "Stel verloop in voor {n} gebruikers"
|
||||
},
|
||||
"enableReferralsFor": {
|
||||
"plural": "Verwijzingen activeren voor {1} gebruikers",
|
||||
"singular": "Verwijzingen activeren voor {1} gebruiker"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"strings": {
|
||||
"username": "Brugernavn",
|
||||
"password": "Adgangskode",
|
||||
"emailAddress": "E-mail Adresse",
|
||||
"emailAddress": "Email adresse",
|
||||
"name": "Navn",
|
||||
"submit": "Indsend",
|
||||
"send": "Send",
|
||||
@@ -36,7 +36,12 @@
|
||||
"add": "Tilføj",
|
||||
"edit": "Rediger",
|
||||
"delete": "Slet",
|
||||
"inviteRemainingUses": "Resterende anvendelser"
|
||||
"inviteRemainingUses": "Resterende anvendelser",
|
||||
"referrals": "Henvisninger",
|
||||
"contactMethods": "Kontakt Metoder",
|
||||
"accountStatus": "Kontostatus",
|
||||
"notSet": "Ikke sat",
|
||||
"myAccount": "Min Konto"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.",
|
||||
@@ -45,5 +50,18 @@
|
||||
"error401Unauthorized": "Adgang nægtet. Prøv at genindlæse siden.",
|
||||
"errorSaveSettings": "Kunne ikke gemme indstillingerne."
|
||||
},
|
||||
"quantityStrings": {}
|
||||
}
|
||||
"quantityStrings": {
|
||||
"year": {
|
||||
"singular": "{n} År",
|
||||
"plural": "{n} År"
|
||||
},
|
||||
"month": {
|
||||
"singular": "{n} Månede",
|
||||
"plural": "{n} Måneder"
|
||||
},
|
||||
"day": {
|
||||
"singular": "{n} Dag",
|
||||
"plural": "{n} Dage"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,12 @@
|
||||
"add": "Voeg toe",
|
||||
"edit": "Bewerken",
|
||||
"delete": "Verwijderen",
|
||||
"inviteRemainingUses": "Resterend aantal keer te gebruiken"
|
||||
"inviteRemainingUses": "Resterend aantal keer te gebruiken",
|
||||
"referrals": "Verwijzingen",
|
||||
"contactMethods": "Contactmethodes",
|
||||
"accountStatus": "Account status",
|
||||
"notSet": "Niet ingesteld",
|
||||
"myAccount": "Mijn account"
|
||||
},
|
||||
"notifications": {
|
||||
"errorLoginBlank": "De gebruikersnaam en/of wachtwoord is leeg.",
|
||||
@@ -45,5 +50,18 @@
|
||||
"error401Unauthorized": "Geen toegang. Probeer de pagina te vernieuwen.",
|
||||
"errorSaveSettings": "Opslaan van instellingen mislukt."
|
||||
},
|
||||
"quantityStrings": {}
|
||||
}
|
||||
"quantityStrings": {
|
||||
"year": {
|
||||
"singular": "{n} jaar",
|
||||
"plural": "{n} jaar"
|
||||
},
|
||||
"month": {
|
||||
"singular": "{n} maand",
|
||||
"plural": "{n} maanden"
|
||||
},
|
||||
"day": {
|
||||
"singular": "{n} dag",
|
||||
"plural": "{n} dagen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,25 @@
|
||||
"yourAccountIsValidUntil": "Din konto er gyldig indtil {date}.",
|
||||
"sendPIN": "Send nedenstående pinkode til botten, og kom derefter tilbage her for at sammenkoble din konto.",
|
||||
"sendPINDiscord": "Skriv {command} i {server_channel} på Discord, og send PIN-koden nedenfor.",
|
||||
"matrixEnterUser": "Skriv dit Bruger ID, tryk Indsend, og en PIN-kode vil blive sendt til dig. Skriv den her efter, for at fortsætte."
|
||||
"matrixEnterUser": "Skriv dit Bruger ID, tryk Indsend, og en PIN-kode vil blive sendt til dig. Skriv den her efter, for at fortsætte.",
|
||||
"referralsDescription": "Inviter venner og familie til Jellyfin med dette link. Kom tilbage her for et nyt, hvis det udløber.",
|
||||
"oldPassword": "Gammelt Kodeord",
|
||||
"newPassword": "Nyt Kodeord",
|
||||
"welcomeUser": "Velkommen, {user}!",
|
||||
"addContactMethod": "Tilføj Kontakt Metode",
|
||||
"editContactMethod": "Rediger Kontakt Metode",
|
||||
"joinTheServer": "Tilslut dig serveren:",
|
||||
"customMessagePlaceholderHeader": "Tilpas dette kort",
|
||||
"customMessagePlaceholderContent": "Klik på knappen Rediger brugersiden i indstillinger for at tilpasse dette kort, eller vis et på login-skærmen, og bare rolig, brugeren kan ikke se dette.",
|
||||
"userPageSuccessMessage": "Du kan se og ændre detaljer om din konto senere på {myAccount} siden.",
|
||||
"resetPassword": "Nulstille Kodeord",
|
||||
"resetPasswordThroughJellyfin": "For at nulstille din adgangskode skal du besøge {jfLink} og trykke på knappen \"Glemt adgangskode\".",
|
||||
"resetPasswordThroughLink": "For at nulstille din adgangskode skal du indtaste dit brugernavn, din e-mail adresse eller et linket brugernavn til en kontakt metode og indsende. Et link vil blive sendt for at nulstille din adgangskode.",
|
||||
"resetSent": "Nulstilling Sendt.",
|
||||
"resetSentDescription": "Hvis der findes en konto med det givne brugernavn/kontakt metode, er et link til nulstilling af adgangskode blevet sendt via alle tilgængelige kontakt metoder. Koden udløber om 30 minutter.",
|
||||
"changePassword": "Skift Kodeord",
|
||||
"copyReferral": "Kopier Link",
|
||||
"invitedBy": "Du blev inviteret af brugeren {user}."
|
||||
},
|
||||
"notifications": {
|
||||
"errorUserExists": "Brugeren eksistere allerede.",
|
||||
@@ -33,7 +51,11 @@
|
||||
"errorNoEmail": "E-mail er påkrævet.",
|
||||
"errorCaptcha": "Forkert Captcha.",
|
||||
"errorPassword": "Tjek krav til adgangskode.",
|
||||
"errorNoMatch": "Adgangskoder stemmer ikke overens."
|
||||
"errorNoMatch": "Adgangskoder stemmer ikke overens.",
|
||||
"errorEmailLinked": "E-mail er allerede i brug.",
|
||||
"errorAccountLinked": "Kontoen er allerede i brug.",
|
||||
"errorOldPassword": "Den gamle adgangskode er forkert.",
|
||||
"passwordChanged": "Adgangskode Ændret."
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
@@ -57,4 +79,4 @@
|
||||
"plural": "Skal mindst have {n} specialtegn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,16 @@
|
||||
"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",
|
||||
"referralsDescription": "Invite friends & family to Jellyfin with this link. Come back here for a new one if it expires.",
|
||||
"referralsWithExpiryDescription": "Invite friends & family to Jellyfin with this link. The link will be disabled once it expires.",
|
||||
"copyReferral": "Copy Link",
|
||||
"invitedBy": "You were invited by user {user}."
|
||||
},
|
||||
|
||||
@@ -27,7 +27,17 @@
|
||||
"editContactMethod": "Wijzig contact methode",
|
||||
"joinTheServer": "Word lid van de server:",
|
||||
"resetPassword": "Wachtwoord opnieuw instellen",
|
||||
"changePassword": "Wachtwoord wijzigen"
|
||||
"changePassword": "Wachtwoord wijzigen",
|
||||
"resetSentDescription": "Als er een account met de opgegeven gebruikersnaam/contactmethode bestaat, is er een wachtwoordreset-link verstuurd via alle bekende contactmethodes. De link is 30 minuten geldig.",
|
||||
"customMessagePlaceholderHeader": "Kaart aanpassen",
|
||||
"customMessagePlaceholderContent": "Klik op de gebruikerspagina aanpassen knop in instellingen om deze kaart aan te passen, of om op het loginscherm te tonen. En wees maar niet bang: de gebruiker kan dit niet zien.",
|
||||
"userPageSuccessMessage": "Je kunt details van je account later bekijken en aanpassen op de {myAccount} pagina.",
|
||||
"resetPasswordThroughJellyfin": "Om je wachtwoord te resetten, ga naar {jfLink} en druk op de \"Wachtwoord vergeten\" knop.",
|
||||
"resetPasswordThroughLink": "Om je wachtwoord te resetten, vul je gebruikersnaam, e-mailadres of gebruikersnaam van een gelinkte contactmethode in, en verstuur. Er wordt een wachtwoord-reset link gestuurd.",
|
||||
"resetSent": "Reset-link verstuurd.",
|
||||
"referralsDescription": "Nodig vrienden en familie uit met deze link. Kom hier terug voor een nieuwe als hij verloopt.",
|
||||
"copyReferral": "Kopieer link",
|
||||
"invitedBy": "Je bent uitgenodigd door gebruiker {user}."
|
||||
},
|
||||
"validationStrings": {
|
||||
"length": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"disabled": "Deaktiveret",
|
||||
"enabled": "Aktiveret",
|
||||
"port": "Port",
|
||||
"message": "Meddelelse",
|
||||
"message": "Besked",
|
||||
"serverAddress": "Serveradresse",
|
||||
"emailSubject": "E-mail emne",
|
||||
"URL": "URL",
|
||||
@@ -20,7 +20,9 @@
|
||||
"errorInvalidUserPass": "Ugyldigt brugernavn/adgangskode.",
|
||||
"errorUserDisabled": "Bruger kan være deaktiveret.",
|
||||
"error404": "404, tjek den interne URL.",
|
||||
"errorConnectionRefused": "Tilslutning afvist."
|
||||
"errorConnectionRefused": "Tilslutning afvist.",
|
||||
"error": "Fejl",
|
||||
"errorUnknown": "Ukendt fejl, tjek app logfiler."
|
||||
},
|
||||
"startPage": {
|
||||
"welcome": "Velkommen!",
|
||||
@@ -30,7 +32,7 @@
|
||||
},
|
||||
"endPage": {
|
||||
"finished": "Færdig!",
|
||||
"restartMessage": "Du kan konfigurere Discord/Telegram/Matrix bots, tilpasse dine meddelelser og mere i Indstillinger. Klik herunder for at genstarte, og opdater siden.",
|
||||
"restartMessage": "Funktioner som Discord/Telegram/Matrix bots, brugerdefinerede Markdown beskeder og en brugertilgængelig side \"Min konto\" kan findes i Indstillinger, så sørg for at gennemse den. Klik nedenfor for at genstarte, og opdater derefter siden.",
|
||||
"refreshPage": "Opdater"
|
||||
},
|
||||
"language": {
|
||||
@@ -68,7 +70,8 @@
|
||||
"adminOnly": "Kun administratorbrugere (anbefalet)",
|
||||
"emailNotice": "Din e-mail adresse kan bruges til at modtage underretninger.",
|
||||
"allowAll": "Tillad alle Jellyfin brugere at logge ind",
|
||||
"allowAllDescription": "Det anbefales ikke, du bør tillade individuelle brugere at logge ind, når de er konfigureret."
|
||||
"allowAllDescription": "Det anbefales ikke, du bør tillade individuelle brugere at logge ind, når de er konfigureret.",
|
||||
"authorizeManualUserPageNotice": "Brug af dette vil deaktivere \"Brugerside\" funktionen."
|
||||
},
|
||||
"jellyfinEmby": {
|
||||
"title": "Jellyfin/Emby",
|
||||
@@ -113,14 +116,15 @@
|
||||
},
|
||||
"passwordResets": {
|
||||
"title": "Nulstilling af Adgangskoder",
|
||||
"description": "Når en bruger forsøger at nulstille deres adgangskode, opretter Jellyfin en fil med navnet 'passwordreset - *. Json', som indeholder en PIN-kode. jfa-go læser filen og sender PIN-koden til brugeren.",
|
||||
"description": "Når en bruger forsøger at nulstille deres adgangskode, opretter Jellyfin en fil med navnet 'passwordreset - *. Json', som indeholder en Pinkode. jfa-go læser filen og sender Pinkoden til brugeren. jfa-go læser filen og sender Pinkoden til brugeren. Hvis du aktiverede funktionen \"Brugerside\", kan en nulstilling også udføres der, givet et brugernavn, email eller kontaktmetode.",
|
||||
"pathToJellyfin": "Sti til Jellyfin's konfigurations mappe",
|
||||
"pathToJellyfinNotice": "Hvis du ikke ved hvor dette er, kan du prøve at nulstille din adgangskode i Jellyfin. En popup med '<sti til jellyfin>/passwordreset - *. Json' vises.",
|
||||
"pathToJellyfinNotice": "Hvis du ikke ved hvor dette er, kan du prøve at nulstille din adgangskode i Jellyfin. En popup med '<sti til jellyfin>/passwordreset - *. Json' vises. Dette er ikke nødvendigt, hvis du kun ønsker at bruge selvbetjenings nulstilling af adgangskode via \"Brugersiden\".",
|
||||
"resetLinks": "Send et link i stedet for en PIN-kode",
|
||||
"resetLinksNotice": "Hvis Ombi integration er aktiveret, skal du bruge denne til at synkronisere nulstilling af Jellyfin's adgangskode med Ombi.",
|
||||
"resetLinksLanguage": "Standard sprog til nulstillings link",
|
||||
"setPassword": "Angiv adgangskode gennem link",
|
||||
"setPasswordNotice": "Aktivering af dette betyder at brugeren ikke behøver at ændre sin adgangskode væk fra pinkoden efter nulstillingen. Adgangskodevalidering håndhæves også."
|
||||
"setPasswordNotice": "Aktivering af dette betyder at brugeren ikke behøver at ændre sin adgangskode væk fra pinkoden efter nulstillingen. Adgangskodevalidering håndhæves også.",
|
||||
"resetLinksRequiredForUserPage": "Nødvendig for nulstilling af selvbetjenings adgangskode på brugersiden."
|
||||
},
|
||||
"passwordValidation": {
|
||||
"title": "Validering af adgangskode",
|
||||
@@ -146,5 +150,11 @@
|
||||
"messages": {
|
||||
"title": "Beskeder",
|
||||
"description": "jfa-go kan sende nulstilling af adgangskoder og forskellige meddelelser via E-mail, Discord, Telegram og/eller Matrix. Du kan konfigurere E-mail herunder, og de andre kan konfigureres i Indstillinger senere. Instruktioner kan findes på {n}. Hvis du ikke har brug for dette, kan du deaktivere disse funktioner her."
|
||||
},
|
||||
"userPage": {
|
||||
"description": "Brugersiden (vist som \"Min konto\") giver brugerne adgang til oplysninger om deres konto, såsom deres kontaktmetoder og kontoudløb. De kan også ændre deres adgangskode, starte en nulstilling af adgangskode og linke/ændre kontaktmetoder uden at skulle spørge dig. Derudover kan tilpassede Markdown beskeder vises til brugerne før og efter login.",
|
||||
"title": "Brugerside",
|
||||
"customizeMessages": "Klik på redigeringsknappen ved siden af \"Brugerside\" i indstillingerne for at indstille dem senere.",
|
||||
"requiredSettings": "Log ind på jfa-go via Jellyfin skal indstilles. Sørg for, at \"nulstil adgangskode via link\" er valgt senere for selvbetjenings nulstilling af adgangskode."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"errorUserDisabled": "De gebruiker kan uitgeschakeld zijn.",
|
||||
"error404": "404, controleer de interne URL.",
|
||||
"errorNotAdmin": "Gebruiker heeft geen beheersrechten.",
|
||||
"errorConnectionRefused": "Verbinding geweigerd."
|
||||
"errorConnectionRefused": "Verbinding geweigerd.",
|
||||
"errorUnknown": "Onbekende fout, bekijk de logs.",
|
||||
"error": "Fout"
|
||||
},
|
||||
"startPage": {
|
||||
"welcome": "Welkom!",
|
||||
@@ -30,7 +32,7 @@
|
||||
},
|
||||
"endPage": {
|
||||
"finished": "Klaar!",
|
||||
"restartMessage": "Je kunt Discord/Telegram/Matrix bots instellen, berichten aanpassen en meer bij Instellingen. Klik hieronder om te herstarten, en ververs de pagina.",
|
||||
"restartMessage": "Instellingen als Discord/Telegram/Matrix bots, aangepaste Markdown berichten, en een gebruiker-toegankelijke \"Mijn Account\" pagina zijn te vinden onder \"Instellingen\", dus kijk daar even rond. Klik hieronder om te herstarten, en ververs de pagina.",
|
||||
"refreshPage": "Verversen"
|
||||
},
|
||||
"language": {
|
||||
@@ -61,7 +63,8 @@
|
||||
"adminOnly": "Alleen beheerders (aanbevolen)",
|
||||
"emailNotice": "Je e-mailadres kan gebruikt worden om meldingen te ontvangen.",
|
||||
"allowAll": "Laat alle Jellyfin-gebruikers inloggen",
|
||||
"allowAllDescription": "Afgeraden, je kunt beter individuele gebruikers toegang geven na de setup."
|
||||
"allowAllDescription": "Afgeraden, je kunt beter individuele gebruikers toegang geven na de setup.",
|
||||
"authorizeManualUserPageNotice": "Gebruik hiervan schakelt de \"Gebruikerspagina\" functie uit."
|
||||
},
|
||||
"jellyfinEmby": {
|
||||
"title": "Jellyfin/Emby",
|
||||
@@ -106,14 +109,15 @@
|
||||
},
|
||||
"passwordResets": {
|
||||
"title": "Wachtwoordresets",
|
||||
"description": "Wanneer een gebruiker een wachtwoordreset aanvraagt, maakt Jellyfin een bestand aan dat 'passwordreset-*.json' heet en een pincode bevat. jfa-go leest dit bestand uit en stuurt de pincode naar de gebruiker.",
|
||||
"description": "Wanneer een gebruiker een wachtwoordreset aanvraagt, maakt Jellyfin een bestand aan dat 'passwordreset-*.json' heet en een pincode bevat. jfa-go leest dit bestand uit en stuurt de pincode naar de gebruiker. Als je de \"Gebruikerspagina\" functionaliteit inschakelt, kan een reset ook daar worden gedaan aan de hand van een gebruikersnaam, e-mail, of contactmethode.",
|
||||
"pathToJellyfin": "Pad naar Jellyfin configuratiemap",
|
||||
"pathToJellyfinNotice": "Als je niet weet waar dit is, probeer de je wachtwoord te resetten in Jellyfin. Er verschijnt dan een popup met '<path to jellyfin>/passwordreset-*.json'.",
|
||||
"pathToJellyfinNotice": "Als je niet weet waar dit is, probeer de je wachtwoord te resetten in Jellyfin. Er verschijnt dan een popup met '<path to jellyfin>/passwordreset-*.json'. Dit is niet nodig als je alleen zelfzervice wachtwoordresets via de \"Gebruikerspagina\" wilt gebruiken.",
|
||||
"resetLinks": "Stuur een link in plaats van een pincode",
|
||||
"resetLinksNotice": "Als Ombi-integratie is ingeschakeld, gebruik dan dit om Jellyfin wachtwoordresets te synchroniseren met Ombi.",
|
||||
"resetLinksLanguage": "Standaard reset-link taal",
|
||||
"setPassword": "Stel wachtwoord in via link",
|
||||
"setPasswordNotice": "Als dit aanstaat hoeft de gebruiker het wachtwoord niet te wijzigen van de PINcode na de reset. Wachtwoordvalidatie wordt ook afgedwongen."
|
||||
"setPasswordNotice": "Als dit aanstaat hoeft de gebruiker het wachtwoord niet te wijzigen van de PINcode na de reset. Wachtwoordvalidatie wordt ook afgedwongen.",
|
||||
"resetLinksRequiredForUserPage": "Nodig voor zelfservice wachtwoordreset op de gebruikerspagina."
|
||||
},
|
||||
"passwordValidation": {
|
||||
"title": "Wachtwoordvalidatie",
|
||||
@@ -146,5 +150,11 @@
|
||||
"messages": {
|
||||
"title": "Berichten",
|
||||
"description": "jfa-go kan wachtwoordresets en verschillende berichten sturen via E-mail, Discord, Telegram, en/of Matrix. Je kunt e-mail hieronder instellen, en de rest kan later bij Instellingen aangepast worden. Instructies staan op de {n}. Als je dit niet nodig hebt, kun je deze onderdelen hier uitschakelen."
|
||||
},
|
||||
"userPage": {
|
||||
"description": "De gebruikerspagina (getoond als \"Mijn account\") geeft gebruikers toegang tot informatie over hun account, zoals contactmethodes en verloopdatum. Ze kunnen ook hun wachtwoord wijzigen, een wachtwoord-reset starten, en contactmethodes koppelen/wijzigen zonder het jou te hoeven vragen. Bovendien kunnen aanpasbare MArkdown berichten worden getoond aan gebruikers voor en na het inloggen.",
|
||||
"title": "Gebruikerspagina",
|
||||
"customizeMessages": "Gebruik de bewerken knop naast \"Gebruikerspagina\" in de instellingen om dit later in te stellen.",
|
||||
"requiredSettings": "Inloggen bij jfa-go via Jellyfin moet ingesteld zijn. Controleer dat \"reset wachtwoord via link\" later wordt gekozen voor zelfservice wachtwoord-resets."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
"pinSuccess": "Sådan! Du kan nu gå tilbage til tilmeldingssiden.",
|
||||
"languageMessage": "Note: Se tilgængelige sprog med {command}, og vælg sprog med {command} <sprog kode>.",
|
||||
"discordStartMessage": "Hej!\n Indtast din pinkode med `/pin <PIN>` for at bekræfte din konto.",
|
||||
"languageMessageDiscord": "Bemærk: Indstil dit sprog med /lang <sprognavn>.",
|
||||
"languageSet": "Sprog indstillet til {language}.",
|
||||
"discordDMs": "Tjek venligst dine DM's for et svar."
|
||||
"languageMessageDiscord": "Note: Sæt dit sprog med /lang <sprog navn>.",
|
||||
"languageSet": "Sprog sat til {language}.",
|
||||
"discordDMs": "Tjek venligst dine DMs for et svar.",
|
||||
"sentInvite": "Invitation Sendt.",
|
||||
"sentInviteFailure": "Kunne ikke sende invitation, tjek logfilerne."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"discordStartMessage": "Hallo!\nVoer je pincode in met `/pin <PINCODE>` om je account te verifiëren.",
|
||||
"languageMessageDiscord": "Opmerking: stel je taal in met /lang <taal>.",
|
||||
"languageSet": "Taal ingesteld als {language}.",
|
||||
"discordDMs": "Bekijk alsjeblieft je DMs voor een antwoord."
|
||||
"discordDMs": "Bekijk alsjeblieft je DMs voor een antwoord.",
|
||||
"sentInviteFailure": "Fout bij versturen uitnodiging, bekijk de logs.",
|
||||
"sentInvite": "Uitnodiging verstuurd."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
14
main.go
@@ -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.
|
||||
|
||||
|
||||
25
models.go
25
models.go
@@ -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 {
|
||||
@@ -424,7 +425,8 @@ type GetMyReferralRespDTO struct {
|
||||
Code string `json:"code"`
|
||||
RemainingUses int `json:"remaining_uses"`
|
||||
NoLimit bool `json:"no_limit"`
|
||||
Expiry int64 `json:"expiry"` // Come back after this time to get a new referral
|
||||
Expiry int64 `json:"expiry"` // Come back after this time to get a new referral (if UseExpiry, a new one can't be made).
|
||||
UseExpiry bool `json:"use_expiry"`
|
||||
}
|
||||
|
||||
type EnableDisableReferralDTO struct {
|
||||
@@ -459,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"`
|
||||
}
|
||||
|
||||
10
router.go
10
router.go
@@ -173,6 +173,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
|
||||
api.GET(p+"/users", app.GetUsers)
|
||||
api.POST(p+"/users", app.NewUserAdmin)
|
||||
api.POST(p+"/users/extend", app.ExtendExpiry)
|
||||
api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry)
|
||||
api.POST(p+"/users/enable", app.EnableDisableUsers)
|
||||
api.POST(p+"/invites", app.GenerateInvite)
|
||||
api.GET(p+"/invites", app.GetInvites)
|
||||
@@ -207,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)
|
||||
@@ -229,9 +235,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
|
||||
}
|
||||
api.POST(p+"/matrix/login", app.MatrixLogin)
|
||||
if app.config.Section("user_page").Key("referrals").MustBool(false) {
|
||||
api.POST(p+"/users/referral/:mode/:source", app.EnableReferralForUsers)
|
||||
api.POST(p+"/users/referral/:mode/:source/:useExpiry", app.EnableReferralForUsers)
|
||||
api.DELETE(p+"/users/referral", app.DisableReferralForUsers)
|
||||
api.POST(p+"/profiles/referral/:profile/:invite", app.EnableReferralForProfile)
|
||||
api.POST(p+"/profiles/referral/:profile/:invite/:useExpiry", app.EnableReferralForProfile)
|
||||
api.DELETE(p+"/profiles/referral/:profile", app.DisableReferralForProfile)
|
||||
}
|
||||
|
||||
|
||||
18
storage.go
18
storage.go
@@ -659,15 +659,15 @@ type Invite struct {
|
||||
UserMinutes int `json:"user-minutes,omitempty"`
|
||||
SendTo string `json:"email"`
|
||||
// Used to be stored as formatted time, now as Unix.
|
||||
UsedBy [][]string `json:"used-by"`
|
||||
Notify map[string]map[string]bool `json:"notify"`
|
||||
Profile string `json:"profile"`
|
||||
Label string `json:"label,omitempty"`
|
||||
UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite.
|
||||
Captchas map[string]Captcha // Map of Captcha IDs to images & answers
|
||||
IsReferral bool `json:"is_referral" badgerhold:"index"`
|
||||
ReferrerJellyfinID string `json:"referrer_id"`
|
||||
ReferrerTemplateForProfile string
|
||||
UsedBy [][]string `json:"used-by"`
|
||||
Notify map[string]map[string]bool `json:"notify"`
|
||||
Profile string `json:"profile"`
|
||||
Label string `json:"label,omitempty"`
|
||||
UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite.
|
||||
Captchas map[string]Captcha // Map of Captcha IDs to images & answers
|
||||
IsReferral bool `json:"is_referral" badgerhold:"index"`
|
||||
ReferrerJellyfinID string `json:"referrer_id"`
|
||||
UseReferralExpiry bool `json:"use_referral_expiry"`
|
||||
}
|
||||
|
||||
type Captcha struct {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -769,7 +769,15 @@ export class accountsList {
|
||||
private _enableExpiry = document.getElementById("accounts-enable-expiry") as HTMLSpanElement;
|
||||
private _deleteNotify = document.getElementById("delete-user-notify") as HTMLInputElement;
|
||||
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;
|
||||
private _modifySettings = document.getElementById("accounts-modify-user") as HTMLSpanElement;
|
||||
@@ -783,6 +791,7 @@ export class accountsList {
|
||||
private _userSelect = document.getElementById("modify-user-users") as HTMLSelectElement;
|
||||
private _referralsProfileSelect = document.getElementById("enable-referrals-user-profiles") as HTMLSelectElement;
|
||||
private _referralsInviteSelect = document.getElementById("enable-referrals-user-invites") as HTMLSelectElement;
|
||||
private _referralsExpiry = document.getElementById("enable-referrals-user-expiry") as HTMLInputElement;
|
||||
private _searchBox = document.getElementById("accounts-search") as HTMLInputElement;
|
||||
private _search: Search;
|
||||
|
||||
@@ -984,7 +993,7 @@ export class accountsList {
|
||||
if (window.emailEnabled || window.telegramEnabled) {
|
||||
this._announceButton.parentElement.classList.add("unfocused");
|
||||
}
|
||||
this._extendExpiry.classList.add("unfocused");
|
||||
this._expiryDropdown.classList.add("unfocused");
|
||||
this._disableEnable.parentElement.classList.add("unfocused");
|
||||
this._sendPWR.classList.add("unfocused");
|
||||
} else {
|
||||
@@ -1020,7 +1029,7 @@ export class accountsList {
|
||||
for (let id of list) {
|
||||
if (!anyNonExpiries && !this._users[id].expiry) {
|
||||
anyNonExpiries = true;
|
||||
this._extendExpiry.classList.add("unfocused");
|
||||
this._expiryDropdown.classList.add("unfocused");
|
||||
}
|
||||
if (this._users[id].expiry) {
|
||||
allNonExpiries = false;
|
||||
@@ -1039,13 +1048,15 @@ export class accountsList {
|
||||
}
|
||||
this._settingExpiry = false;
|
||||
if (!anyNonExpiries && !allNonExpiries) {
|
||||
this._extendExpiry.classList.remove("unfocused");
|
||||
this._expiryDropdown.classList.remove("unfocused");
|
||||
this._extendExpiry.textContent = window.lang.strings("extendExpiry");
|
||||
this._removeExpiry.classList.remove("unfocused");
|
||||
}
|
||||
if (allNonExpiries) {
|
||||
this._extendExpiry.classList.remove("unfocused");
|
||||
this._expiryDropdown.classList.remove("unfocused");
|
||||
this._extendExpiry.textContent = window.lang.strings("setExpiry");
|
||||
this._settingExpiry = true;
|
||||
this._removeExpiry.classList.add("unfocused");
|
||||
}
|
||||
// Only show "Send PWR" if a maximum of 1 user selected doesn't have a contact method
|
||||
if (noContactCount > 1) {
|
||||
@@ -1578,7 +1589,7 @@ export class accountsList {
|
||||
send["from"] = "invite";
|
||||
send["id"] = this._referralsInviteSelect.value;
|
||||
}
|
||||
_post("/users/referral/" + send["from"] + "/" + (send["id"] ? send["id"] : send["profile"]), send, (req: XMLHttpRequest) => {
|
||||
_post("/users/referral/" + send["from"] + "/" + (send["id"] ? send["id"] : send["profile"]) + "/" + (this._referralsExpiry.checked ? "with-expiry" : "none"), send, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
toggleLoader(button);
|
||||
if (req.status == 400) {
|
||||
@@ -1593,9 +1604,72 @@ export class accountsList {
|
||||
};
|
||||
this._enableReferralsProfile.checked = true;
|
||||
this._enableReferralsInvite.checked = false;
|
||||
this._referralsExpiry.checked = false;
|
||||
window.modals.enableReferralsUser.show();
|
||||
}
|
||||
|
||||
removeExpiry = () => {
|
||||
const list = this._collectUsers();
|
||||
|
||||
let success = true;
|
||||
for (let id of list) {
|
||||
_delete("/users/" + id + "/expiry", null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) {
|
||||
success = false;
|
||||
return;
|
||||
}
|
||||
});
|
||||
if (!success) break;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
window.notifications.customSuccess("modifySettingsSuccess", window.lang.quantity("appliedSettings", list.length));
|
||||
} else {
|
||||
window.notifications.customError("modifySettingsError", window.lang.notif("errorSettingsFailed"));
|
||||
}
|
||||
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[] = [];
|
||||
@@ -1618,10 +1692,20 @@ export class accountsList {
|
||||
}
|
||||
document.getElementById("header-extend-expiry").textContent = header;
|
||||
const extend = () => {
|
||||
let send = { "users": applyList }
|
||||
for (let field of ["months", "days", "hours", "minutes"]) {
|
||||
send[field] = +(document.getElementById("extend-expiry-"+field) as HTMLSelectElement).value;
|
||||
let send = { "users": applyList, "timestamp": 0 }
|
||||
if (this._usingExtendExpiryTextInput) {
|
||||
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) => {
|
||||
if (req.readyState == 4) {
|
||||
if (req.status != 200 && req.status != 204) {
|
||||
@@ -1634,8 +1718,7 @@ export class accountsList {
|
||||
}
|
||||
});
|
||||
};
|
||||
const form = document.getElementById("form-extend-expiry") as HTMLFormElement;
|
||||
form.onsubmit = (event: Event) => {
|
||||
this._extendExpiryForm.onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (enableUser) {
|
||||
this._enableDisableUsers(applyList, true, this._enableExpiryNotify.checked, this._enableExpiryNotify ? this._enableExpiryReason.value : null, (req: XMLHttpRequest) => {
|
||||
@@ -1656,6 +1739,7 @@ export class accountsList {
|
||||
extend();
|
||||
}
|
||||
}
|
||||
this._extendExpiryTextInput.value = "";
|
||||
window.modals.extendExpiry.show();
|
||||
}
|
||||
|
||||
@@ -1790,7 +1874,39 @@ export class accountsList {
|
||||
this._announceButton.parentElement.classList.add("unfocused");
|
||||
|
||||
this._extendExpiry.onclick = () => { this.extendExpiry(); };
|
||||
this._extendExpiry.classList.add("unfocused");
|
||||
this._removeExpiry.onclick = () => { this.removeExpiry(); };
|
||||
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.parentElement.classList.add("unfocused");
|
||||
|
||||
@@ -40,6 +40,31 @@ export const _get = (url: string, data: Object, onreadystatechange: (req: XMLHtt
|
||||
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 => {
|
||||
let req = new XMLHttpRequest();
|
||||
req.open("POST", window.URLBase + url, true);
|
||||
|
||||
@@ -225,6 +225,7 @@ export class ProfileEditor {
|
||||
|
||||
enableReferrals = (name: string) => {
|
||||
const referralsInviteSelect = document.getElementById("enable-referrals-profile-invites") as HTMLSelectElement;
|
||||
const referralsExpiry = document.getElementById("enable-referrals-profile-expiry") as HTMLInputElement;
|
||||
_get("/invites", null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4 || req.status != 200) return;
|
||||
|
||||
@@ -257,7 +258,7 @@ export class ProfileEditor {
|
||||
"invite": referralsInviteSelect.value
|
||||
};
|
||||
|
||||
_post("/profiles/referral/" + send["profile"] + "/" + send["invite"], send, (req: XMLHttpRequest) => {
|
||||
_post("/profiles/referral/" + send["profile"] + "/" + send["invite"] + "/" + (referralsExpiry.checked ? "with-expiry" : "none"), send, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
toggleLoader(button);
|
||||
if (req.status == 400) {
|
||||
@@ -270,6 +271,7 @@ export class ProfileEditor {
|
||||
}
|
||||
});
|
||||
};
|
||||
referralsExpiry.checked = false;
|
||||
window.modals.profiles.close();
|
||||
window.modals.enableReferralsProfile.show();
|
||||
};
|
||||
|
||||
@@ -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 { stripMarkdown } from "../modules/stripmd.js";
|
||||
|
||||
interface BackupDTO {
|
||||
size: string;
|
||||
name: string;
|
||||
path: string;
|
||||
date: number;
|
||||
}
|
||||
|
||||
interface settingsBoolEvent extends Event {
|
||||
detail: boolean;
|
||||
}
|
||||
@@ -635,6 +642,8 @@ export class settingsList {
|
||||
|
||||
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) => {
|
||||
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() {
|
||||
this._sections = {};
|
||||
this._buttons = {};
|
||||
@@ -748,7 +819,32 @@ export class settingsList {
|
||||
this._saveButton.onclick = this._save;
|
||||
document.addEventListener("settings-requires-restart", () => { this._needsRestart = true; });
|
||||
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 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 = () => {
|
||||
document.dispatchEvent(new CustomEvent("settings-advancedState", { detail: advancedEnableToggle.checked }));
|
||||
const parent = advancedEnableToggle.parentElement;
|
||||
|
||||
@@ -117,6 +117,8 @@ declare interface Modals {
|
||||
email?: Modal;
|
||||
enableReferralsUser?: Modal;
|
||||
enableReferralsProfile?: Modal;
|
||||
backedUp?: Modal;
|
||||
backups?: Modal;
|
||||
}
|
||||
|
||||
interface Invite {
|
||||
|
||||
18
ts/user.ts
18
ts/user.ts
@@ -116,6 +116,7 @@ interface MyReferral {
|
||||
remaining_uses: number;
|
||||
no_limit: boolean;
|
||||
expiry: number;
|
||||
use_expiry: boolean;
|
||||
}
|
||||
|
||||
interface ContactDTO {
|
||||
@@ -252,6 +253,7 @@ class ReferralCard {
|
||||
private _url: string;
|
||||
private _expiry: Date;
|
||||
private _expiryUnix: number;
|
||||
private _useExpiry: boolean;
|
||||
private _remainingUses: number;
|
||||
private _noLimit: boolean;
|
||||
|
||||
@@ -259,6 +261,7 @@ class ReferralCard {
|
||||
private _infoArea: HTMLDivElement;
|
||||
private _remainingUsesEl: HTMLSpanElement;
|
||||
private _expiryEl: HTMLSpanElement;
|
||||
private _descriptionEl: HTMLSpanElement;
|
||||
|
||||
get code(): string { return this._code; }
|
||||
set code(c: string) {
|
||||
@@ -294,11 +297,22 @@ class ReferralCard {
|
||||
this._expiry = new Date(expiryUnix * 1000);
|
||||
this._expiryEl.textContent = toDateString(this._expiry);
|
||||
}
|
||||
|
||||
get use_expiry(): boolean { return this._useExpiry; }
|
||||
set use_expiry(v: boolean) {
|
||||
this._useExpiry = v;
|
||||
if (v) {
|
||||
this._descriptionEl.textContent = window.lang.strings("referralsWithExpiryDescription");
|
||||
} else {
|
||||
this._descriptionEl.textContent = window.lang.strings("referralsDescription");
|
||||
}
|
||||
}
|
||||
|
||||
constructor(card: HTMLElement) {
|
||||
this._card = card;
|
||||
this._button = this._card.querySelector(".user-referrals-button") as HTMLButtonElement;
|
||||
this._infoArea = this._card.querySelector(".user-referrals-info") as HTMLDivElement;
|
||||
this._descriptionEl = this._card.querySelector(".user-referrals-description") as HTMLSpanElement;
|
||||
|
||||
this._infoArea.innerHTML = `
|
||||
<div class="row my-3">
|
||||
@@ -344,6 +358,7 @@ class ReferralCard {
|
||||
this.no_limit = referral.no_limit;
|
||||
this.expiry = referral.expiry;
|
||||
this._card.classList.remove("unfocused");
|
||||
this.use_expiry = referral.use_expiry;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -620,9 +635,8 @@ document.addEventListener("details-reload", () => {
|
||||
}
|
||||
}
|
||||
|
||||
messageCard.innerHTML = messageCard.innerHTML.replace(new RegExp("{username}", "g"), details.username);
|
||||
|
||||
if (typeof(messageCard) != "undefined" && messageCard != null) {
|
||||
messageCard.innerHTML = messageCard.innerHTML.replace(new RegExp("{username}", "g"), details.username);
|
||||
setBestRowSpan(messageCard, false);
|
||||
// contactCard.querySelector(".content").classList.add("h-100");
|
||||
} else if (!statusCard.classList.contains("unfocused")) {
|
||||
|
||||
@@ -211,7 +211,7 @@ func (ud *Updater) GetTag() (Tag, int, error) {
|
||||
var tag Tag
|
||||
err = json.Unmarshal(body, &tag)
|
||||
if tag.Version == "" {
|
||||
err = errors.New("Tag was empty")
|
||||
err = errors.New("Tag at \"" + url + "\" was empty")
|
||||
}
|
||||
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["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{}
|
||||
pageMessages := map[string]CustomContent{}
|
||||
@@ -275,7 +280,8 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
|
||||
"ombiEnabled": app.config.Section("ombi").Key("enabled").MustBool(false),
|
||||
}
|
||||
pwr, isInternal := app.internalPWRs[pin]
|
||||
if isInternal && setPassword {
|
||||
// if isInternal && setPassword {
|
||||
if setPassword {
|
||||
data["helpMessage"] = app.config.Section("ui").Key("help_message").String()
|
||||
data["successMessage"] = app.config.Section("ui").Key("success_message").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 err error
|
||||
var username string
|
||||
if !isInternal {
|
||||
if !isInternal && !setPassword {
|
||||
resp, status, err = app.jf.ResetPassword(pin)
|
||||
} else if time.Now().After(pwr.Expiry) {
|
||||
app.debug.Printf("Ignoring PWR request due to expired internal PIN: %s", pin)
|
||||
|
||||
Reference in New Issue
Block a user