1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-22 17:10:10 +00:00

Merge "My Account"

User Page/My Account
This commit is contained in:
Harvey Tindall 2023-06-22 22:03:50 +01:00 committed by GitHub
commit ef5d89f323
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
179 changed files with 4500 additions and 1633 deletions

3
.gitignore vendored
View File

@ -21,3 +21,6 @@ cl.md
mautrix/ mautrix/
tempts/ tempts/
matacc.txt matacc.txt
scripts/langmover/lang
scripts/langmover/lang2
scripts/langmover/out

View File

@ -26,11 +26,11 @@ before:
- cp -r ts tempts - cp -r ts tempts
- scripts/dark-variant.sh tempts - scripts/dark-variant.sh tempts
- scripts/dark-variant.sh tempts/modules - scripts/dark-variant.sh tempts/modules
- npx esbuild --bundle tempts/admin.ts --outfile=./data/web/js/admin.js --minify - npx esbuild --target=es6 --format=esm --bundle tempts/admin.ts --outfile=./data/web/js/admin.js --minify
- npx esbuild --bundle tempts/pwr.ts --outfile=./data/web/js/pwr.js --minify - npx esbuild --target=es6 --format=esm --bundle tempts/pwr.ts --outfile=./data/web/js/pwr.js --minify
- npx esbuild --bundle tempts/form.ts --outfile=./data/web/js/form.js --minify - npx esbuild --target=es6 --format=esm --bundle tempts/form.ts --outfile=./data/web/js/form.js --minify
- npx esbuild --bundle tempts/setup.ts --outfile=./data/web/js/setup.js --minify - npx esbuild --target=es6 --format=esm --bundle tempts/setup.ts --outfile=./data/web/js/setup.js --minify
- npx esbuild --bundle tempts/crash.ts --outfile=./data/crash.js --minify - npx esbuild --target=es6 --format=esm --bundle tempts/crash.ts --outfile=./data/crash.js --minify
- rm -r tempts - rm -r tempts
- npx esbuild --bundle css/base.css --outfile=./data/web/css/bundle.css --external:remixicon.css --minify - npx esbuild --bundle css/base.css --outfile=./data/web/css/bundle.css --external:remixicon.css --minify
- cp html/crash.html data/ - cp html/crash.html data/

View File

@ -104,6 +104,7 @@ typescript:
$(info compiling typescript) $(info compiling typescript)
mkdir -p $(DATA)/web/js mkdir -p $(DATA)/web/js
$(ESBUILD) --target=es6 --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify $(ESBUILD) --target=es6 --bundle tempts/admin.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/admin.js --minify
$(ESBUILD) --target=es6 --bundle tempts/user.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/user.js --minify
$(ESBUILD) --target=es6 --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify $(ESBUILD) --target=es6 --bundle tempts/pwr.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/pwr.js --minify
$(ESBUILD) --target=es6 --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify $(ESBUILD) --target=es6 --bundle tempts/form.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/form.js --minify
$(ESBUILD) --target=es6 --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify $(ESBUILD) --target=es6 --bundle tempts/setup.ts $(SOURCEMAP) --outfile=./$(DATA)/web/js/setup.js --minify
@ -177,4 +178,6 @@ clean:
-rm docs/docs.go docs/swagger.json docs/swagger.yaml -rm docs/docs.go docs/swagger.json docs/swagger.yaml
go clean go clean
quick: configuration typescript variants-html bundle-css inline-css copy compile
all: configuration npm email typescript variants-html bundle-css inline-css swagger copy compile all: configuration npm email typescript variants-html bundle-css inline-css swagger copy compile

View File

@ -16,7 +16,7 @@ func (app *appContext) checkInvites() {
currentTime := time.Now() currentTime := time.Now()
app.storage.loadInvites() app.storage.loadInvites()
changed := false changed := false
for code, data := range app.storage.invites { for code, data := range app.storage.GetInvites() {
expiry := data.ValidTill expiry := data.ValidTill
if !currentTime.After(expiry) { if !currentTime.After(expiry) {
continue continue
@ -54,7 +54,7 @@ func (app *appContext) checkInvites() {
wait.Wait() wait.Wait()
} }
changed = true changed = true
delete(app.storage.invites, code) app.storage.DeleteInvitesKey(code)
} }
if changed { if changed {
app.storage.storeInvites() app.storage.storeInvites()
@ -65,7 +65,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
currentTime := time.Now() currentTime := time.Now()
app.storage.loadInvites() app.storage.loadInvites()
changed := false changed := false
inv, match := app.storage.invites[code] inv, match := app.storage.GetInvitesKey(code)
if !match { if !match {
return false return false
} }
@ -105,21 +105,21 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
} }
changed = true changed = true
match = false match = false
delete(app.storage.invites, code) app.storage.DeleteInvitesKey(code)
} else if used { } else if used {
changed = true changed = true
del := false del := false
newInv := inv newInv := inv
if newInv.RemainingUses == 1 { if newInv.RemainingUses == 1 {
del = true del = true
delete(app.storage.invites, code) app.storage.DeleteInvitesKey(code)
} else if newInv.RemainingUses != 0 { } else if newInv.RemainingUses != 0 {
// 0 means infinite i guess? // 0 means infinite i guess?
newInv.RemainingUses-- newInv.RemainingUses--
} }
newInv.UsedBy = append(newInv.UsedBy, []string{username, strconv.FormatInt(currentTime.Unix(), 10)}) newInv.UsedBy = append(newInv.UsedBy, []string{username, strconv.FormatInt(currentTime.Unix(), 10)})
if !del { if !del {
app.storage.invites[code] = newInv app.storage.SetInvitesKey(code, newInv)
} }
} }
if changed { if changed {
@ -219,7 +219,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
invite.Profile = "Default" invite.Profile = "Default"
} }
} }
app.storage.invites[inviteCode] = invite app.storage.SetInvitesKey(inviteCode, invite)
app.storage.storeInvites() app.storage.storeInvites()
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -236,7 +236,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
app.storage.loadInvites() app.storage.loadInvites()
app.checkInvites() app.checkInvites()
var invites []inviteDTO var invites []inviteDTO
for code, inv := range app.storage.invites { for code, inv := range app.storage.GetInvites() {
_, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime) _, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
invite := inviteDTO{ invite := inviteDTO{
Code: code, Code: code,
@ -280,7 +280,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
var address string var address string
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) { if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
app.storage.loadEmails() app.storage.loadEmails()
if addr, ok := app.storage.emails[gc.GetString("jfId")]; ok && addr.Addr != "" { if addr, ok := app.storage.GetEmailsKey(gc.GetString("jfId")); ok && addr.Addr != "" {
address = addr.Addr address = addr.Addr
} }
} else { } else {
@ -335,9 +335,9 @@ func (app *appContext) SetProfile(gc *gin.Context) {
respond(500, "Profile not found", gc) respond(500, "Profile not found", gc)
return return
} }
inv := app.storage.invites[req.Invite] inv, _ := app.storage.GetInvitesKey(req.Invite)
inv.Profile = req.Profile inv.Profile = req.Profile
app.storage.invites[req.Invite] = inv app.storage.SetInvitesKey(req.Invite, inv)
app.storage.storeInvites() app.storage.storeInvites()
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -359,7 +359,7 @@ func (app *appContext) SetNotify(gc *gin.Context) {
app.debug.Printf("%s: Notification settings change requested", code) app.debug.Printf("%s: Notification settings change requested", code)
app.storage.loadInvites() app.storage.loadInvites()
app.storage.loadEmails() app.storage.loadEmails()
invite, ok := app.storage.invites[code] invite, ok := app.storage.GetInvitesKey(code)
if !ok { if !ok {
app.err.Printf("%s Notification setting change failed: Invalid code", code) app.err.Printf("%s Notification setting change failed: Invalid code", code)
respond(400, "Invalid invite code", gc) respond(400, "Invalid invite code", gc)
@ -398,7 +398,7 @@ func (app *appContext) SetNotify(gc *gin.Context) {
changed = true changed = true
} }
if changed { if changed {
app.storage.invites[code] = invite app.storage.SetInvitesKey(code, invite)
} }
} }
if changed { if changed {
@ -419,9 +419,9 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
gc.BindJSON(&req) gc.BindJSON(&req)
app.debug.Printf("%s: Deletion requested", req.Code) app.debug.Printf("%s: Deletion requested", req.Code)
var ok bool var ok bool
_, ok = app.storage.invites[req.Code] _, ok = app.storage.GetInvitesKey(req.Code)
if ok { if ok {
delete(app.storage.invites, req.Code) app.storage.DeleteInvitesKey(req.Code)
app.storage.storeInvites() app.storage.storeInvites()
app.info.Printf("%s: Invite deleted", req.Code) app.info.Printf("%s: Invite deleted", req.Code)
respondBool(200, true, gc) respondBool(200, true, gc)

View File

@ -15,12 +15,16 @@ import (
// @Router /config/emails [get] // @Router /config/emails [get]
// @Security Bearer // @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) GetCustomEmails(gc *gin.Context) { func (app *appContext) GetCustomContent(gc *gin.Context) {
lang := gc.Query("lang") lang := gc.Query("lang")
if _, ok := app.storage.lang.Email[lang]; !ok { if _, ok := app.storage.lang.Email[lang]; !ok {
lang = app.storage.lang.chosenEmailLang lang = app.storage.lang.chosenEmailLang
} }
gc.JSON(200, emailListDTO{ adminLang := lang
if _, ok := app.storage.lang.Admin[lang]; !ok {
adminLang = app.storage.lang.chosenAdminLang
}
list := emailListDTO{
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.customEmails.UserCreated.Enabled}, "UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.customEmails.UserCreated.Enabled},
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.customEmails.InviteExpiry.Enabled}, "InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.customEmails.InviteExpiry.Enabled},
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.customEmails.PasswordReset.Enabled}, "PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.customEmails.PasswordReset.Enabled},
@ -31,13 +35,25 @@ func (app *appContext) GetCustomEmails(gc *gin.Context) {
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.customEmails.WelcomeEmail.Enabled}, "WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.customEmails.WelcomeEmail.Enabled},
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.customEmails.EmailConfirmation.Enabled}, "EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.customEmails.EmailConfirmation.Enabled},
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.customEmails.UserExpired.Enabled}, "UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.customEmails.UserExpired.Enabled},
}) "UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.userPage.Login.Enabled},
"UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.userPage.Page.Enabled},
} }
func (app *appContext) getCustomEmail(id string) *customEmail { filter := gc.Query("filter")
if filter == "user" {
list = emailListDTO{"UserLogin": list["UserLogin"], "UserPage": list["UserPage"]}
} else {
delete(list, "UserLogin")
delete(list, "UserPage")
}
gc.JSON(200, list)
}
func (app *appContext) getCustomMessage(id string) *customContent {
switch id { switch id {
case "Announcement": case "Announcement":
return &customEmail{} return &customContent{}
case "UserCreated": case "UserCreated":
return &app.storage.customEmails.UserCreated return &app.storage.customEmails.UserCreated
case "InviteExpiry": case "InviteExpiry":
@ -58,13 +74,17 @@ func (app *appContext) getCustomEmail(id string) *customEmail {
return &app.storage.customEmails.EmailConfirmation return &app.storage.customEmails.EmailConfirmation
case "UserExpired": case "UserExpired":
return &app.storage.customEmails.UserExpired return &app.storage.customEmails.UserExpired
case "UserLogin":
return &app.storage.userPage.Login
case "UserPage":
return &app.storage.userPage.Page
} }
return nil return nil
} }
// @Summary Sets the corresponding custom email. // @Summary Sets the corresponding custom email.
// @Produce json // @Produce json
// @Param customEmail body customEmail true "Content = email (in markdown)." // @Param customEmails body customEmails true "Content = email (in markdown)."
// @Success 200 {object} boolResponse // @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse // @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse // @Failure 500 {object} boolResponse
@ -72,25 +92,29 @@ func (app *appContext) getCustomEmail(id string) *customEmail {
// @Router /config/emails/{id} [post] // @Router /config/emails/{id} [post]
// @Security Bearer // @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) SetCustomEmail(gc *gin.Context) { func (app *appContext) SetCustomMessage(gc *gin.Context) {
var req customEmail var req customContent
gc.BindJSON(&req) gc.BindJSON(&req)
id := gc.Param("id") id := gc.Param("id")
if req.Content == "" { if req.Content == "" {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
email := app.getCustomEmail(id) message := app.getCustomMessage(id)
if email == nil { if message == nil {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
email.Content = req.Content message.Content = req.Content
email.Enabled = true message.Enabled = true
if app.storage.storeCustomEmails() != nil { if app.storage.storeCustomEmails() != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
if app.storage.storeUserPageContent() != nil {
respondBool(500, false, gc)
return
}
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -104,7 +128,7 @@ func (app *appContext) SetCustomEmail(gc *gin.Context) {
// @Router /config/emails/{id}/state/{enable/disable} [post] // @Router /config/emails/{id}/state/{enable/disable} [post]
// @Security Bearer // @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) SetCustomEmailState(gc *gin.Context) { func (app *appContext) SetCustomMessageState(gc *gin.Context) {
id := gc.Param("id") id := gc.Param("id")
s := gc.Param("state") s := gc.Param("state")
enabled := false enabled := false
@ -113,20 +137,24 @@ func (app *appContext) SetCustomEmailState(gc *gin.Context) {
} else if s != "disable" { } else if s != "disable" {
respondBool(400, false, gc) respondBool(400, false, gc)
} }
email := app.getCustomEmail(id) message := app.getCustomMessage(id)
if email == nil { if message == nil {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
email.Enabled = enabled message.Enabled = enabled
if app.storage.storeCustomEmails() != nil { if app.storage.storeCustomEmails() != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
if app.storage.storeUserPageContent() != nil {
respondBool(500, false, gc)
return
}
respondBool(200, true, gc) respondBool(200, true, gc)
} }
// @Summary Returns the custom email (generating it if not set) and list of used variables in it. // @Summary Returns the custom email/message (generating it if not set) and list of used variables in it.
// @Produce json // @Produce json
// @Success 200 {object} customEmailDTO // @Success 200 {object} customEmailDTO
// @Failure 400 {object} boolResponse // @Failure 400 {object} boolResponse
@ -135,7 +163,7 @@ func (app *appContext) SetCustomEmailState(gc *gin.Context) {
// @Router /config/emails/{id} [get] // @Router /config/emails/{id} [get]
// @Security Bearer // @Security Bearer
// @tags Configuration // @tags Configuration
func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) { func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
lang := app.storage.lang.chosenEmailLang lang := app.storage.lang.chosenEmailLang
id := gc.Param("id") id := gc.Param("id")
var content string var content string
@ -146,20 +174,26 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) {
var values map[string]interface{} var values map[string]interface{}
username := app.storage.lang.Email[lang].Strings.get("username") username := app.storage.lang.Email[lang].Strings.get("username")
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress") emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
email := app.getCustomEmail(id) customMessage := app.getCustomMessage(id)
if email == nil { if customMessage == nil {
app.err.Printf("Failed to get custom email with ID \"%s\"", id) app.err.Printf("Failed to get custom message with ID \"%s\"", id)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
if id == "WelcomeEmail" { if id == "WelcomeEmail" {
conditionals = []string{"{yourAccountWillExpire}"} conditionals = []string{"{yourAccountWillExpire}"}
email.Conditionals = conditionals customMessage.Conditionals = conditionals
} else if id == "UserPage" {
variables = []string{"{username}"}
customMessage.Variables = variables
} else if id == "UserLogin" {
variables = []string{}
customMessage.Variables = variables
} }
content = email.Content content = customMessage.Content
noContent := content == "" noContent := content == ""
if !noContent { if !noContent {
variables = email.Variables variables = customMessage.Variables
} }
switch id { switch id {
case "Announcement": case "Announcement":
@ -215,12 +249,14 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) {
msg, err = app.email.constructUserExpired(app, true) msg, err = app.email.constructUserExpired(app, true)
} }
values = app.email.userExpiredValues(app, false) values = app.email.userExpiredValues(app, false)
case "UserLogin", "UserPage":
values = map[string]interface{}{}
} }
if err != nil { if err != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
if noContent && id != "Announcement" { if noContent && id != "Announcement" && id != "UserPage" && id != "UserLogin" {
content = msg.Text content = msg.Text
variables = make([]string, strings.Count(content, "{")) variables = make([]string, strings.Count(content, "{"))
i := 0 i := 0
@ -239,7 +275,7 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) {
i++ i++
} }
} }
email.Variables = variables customMessage.Variables = variables
} }
if variables == nil { if variables == nil {
variables = []string{} variables = []string{}
@ -248,11 +284,22 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
mail, err := app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app) if app.storage.storeUserPageContent() != nil {
respondBool(500, false, gc)
}
var mail *Message
if id != "UserLogin" && id != "UserPage" {
mail, err = app.email.constructTemplate("", "<div class=\"preview-content\"></div>", app)
if err != nil { if err != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
} else {
mail = &Message{
HTML: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
Markdown: "<div class=\"card ~neutral dark:~d_neutral @low preview-content\"></div>",
}
}
gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text}) gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Conditionals: conditionals, Values: values, HTML: mail.HTML, Plaintext: mail.Text})
} }
@ -285,18 +332,12 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
tokenIndex := -1 tgToken, ok := app.telegram.TokenVerified(req.Token)
for i, v := range app.telegram.verifiedTokens { app.telegram.DeleteVerifiedToken(req.Token)
if v.Token == req.Token { if !ok {
tokenIndex = i
break
}
}
if tokenIndex == -1 {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
tgToken := app.telegram.verifiedTokens[tokenIndex]
tgUser := TelegramUser{ tgUser := TelegramUser{
ChatID: tgToken.ChatID, ChatID: tgToken.ChatID,
Username: tgToken.Username, Username: tgToken.Username,
@ -305,17 +346,7 @@ func (app *appContext) TelegramAddUser(gc *gin.Context) {
if lang, ok := app.telegram.languages[tgToken.ChatID]; ok { if lang, ok := app.telegram.languages[tgToken.ChatID]; ok {
tgUser.Lang = lang tgUser.Lang = lang
} }
if app.storage.telegram == nil { app.storage.SetTelegramKey(req.ID, tgUser)
app.storage.telegram = map[string]TelegramUser{}
}
app.storage.telegram[req.ID] = tgUser
err := app.storage.storeTelegramUsers()
if err != nil {
app.err.Printf("Failed to store Telegram users: %v", err)
} else {
app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1]
app.telegram.verifiedTokens = app.telegram.verifiedTokens[:len(app.telegram.verifiedTokens)-1]
}
linkExistingOmbiDiscordTelegram(app) linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -336,15 +367,14 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
if tgUser, ok := app.storage.telegram[req.ID]; ok { app.setContactMethods(req, gc)
}
func (app *appContext) setContactMethods(req SetContactMethodsDTO, gc *gin.Context) {
if tgUser, ok := app.storage.GetTelegramKey(req.ID); ok {
change := tgUser.Contact != req.Telegram change := tgUser.Contact != req.Telegram
tgUser.Contact = req.Telegram tgUser.Contact = req.Telegram
app.storage.telegram[req.ID] = tgUser app.storage.SetTelegramKey(req.ID, tgUser)
if err := app.storage.storeTelegramUsers(); err != nil {
respondBool(500, false, gc)
app.err.Printf("Telegram: Failed to store users: %v", err)
return
}
if change { if change {
msg := "" msg := ""
if !req.Telegram { if !req.Telegram {
@ -353,10 +383,10 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg) app.debug.Printf("Telegram: User \"%s\" will%s be notified through Telegram.", tgUser.Username, msg)
} }
} }
if dcUser, ok := app.storage.discord[req.ID]; ok { if dcUser, ok := app.storage.GetDiscordKey(req.ID); ok {
change := dcUser.Contact != req.Discord change := dcUser.Contact != req.Discord
dcUser.Contact = req.Discord dcUser.Contact = req.Discord
app.storage.discord[req.ID] = dcUser app.storage.SetDiscordKey(req.ID, dcUser)
if err := app.storage.storeDiscordUsers(); err != nil { if err := app.storage.storeDiscordUsers(); err != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
app.err.Printf("Discord: Failed to store users: %v", err) app.err.Printf("Discord: Failed to store users: %v", err)
@ -370,10 +400,10 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg) app.debug.Printf("Discord: User \"%s\" will%s be notified through Discord.", dcUser.Username, msg)
} }
} }
if mxUser, ok := app.storage.matrix[req.ID]; ok { if mxUser, ok := app.storage.GetMatrixKey(req.ID); ok {
change := mxUser.Contact != req.Matrix change := mxUser.Contact != req.Matrix
mxUser.Contact = req.Matrix mxUser.Contact = req.Matrix
app.storage.matrix[req.ID] = mxUser app.storage.SetMatrixKey(req.ID, mxUser)
if err := app.storage.storeMatrixUsers(); err != nil { if err := app.storage.storeMatrixUsers(); err != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
app.err.Printf("Matrix: Failed to store users: %v", err) app.err.Printf("Matrix: Failed to store users: %v", err)
@ -387,10 +417,10 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
app.debug.Printf("Matrix: User \"%s\" will%s be notified through Matrix.", mxUser.UserID, msg) app.debug.Printf("Matrix: User \"%s\" will%s be notified through Matrix.", mxUser.UserID, msg)
} }
} }
if email, ok := app.storage.emails[req.ID]; ok { if email, ok := app.storage.GetEmailsKey(req.ID); ok {
change := email.Contact != req.Email change := email.Contact != req.Email
email.Contact = req.Email email.Contact = req.Email
app.storage.emails[req.ID] = email app.storage.SetEmailsKey(req.ID, email)
if err := app.storage.storeEmails(); err != nil { if err := app.storage.storeEmails(); err != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
app.err.Printf("Failed to store emails: %v", err) app.err.Printf("Failed to store emails: %v", err)
@ -416,19 +446,8 @@ func (app *appContext) SetContactMethods(gc *gin.Context) {
// @tags Other // @tags Other
func (app *appContext) TelegramVerified(gc *gin.Context) { func (app *appContext) TelegramVerified(gc *gin.Context) {
pin := gc.Param("pin") pin := gc.Param("pin")
tokenIndex := -1 _, ok := app.telegram.TokenVerified(pin)
for i, v := range app.telegram.verifiedTokens { respondBool(200, ok, gc)
if v.Token == pin {
tokenIndex = i
break
}
}
// if tokenIndex != -1 {
// length := len(app.telegram.verifiedTokens)
// app.telegram.verifiedTokens[length-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[length-1]
// app.telegram.verifiedTokens = app.telegram.verifiedTokens[:length-1]
// }
respondBool(200, tokenIndex != -1, gc)
} }
// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code. // @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code.
@ -441,32 +460,18 @@ func (app *appContext) TelegramVerified(gc *gin.Context) {
// @tags Other // @tags Other
func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) { func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
code := gc.Param("invCode") code := gc.Param("invCode")
if _, ok := app.storage.invites[code]; !ok { if _, ok := app.storage.GetInvitesKey(code); !ok {
respondBool(401, false, gc) respondBool(401, false, gc)
return return
} }
pin := gc.Param("pin") pin := gc.Param("pin")
tokenIndex := -1 token, ok := app.telegram.TokenVerified(pin)
for i, v := range app.telegram.verifiedTokens { if ok && app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) {
if v.Token == pin { app.discord.DeleteVerifiedUser(pin)
tokenIndex = i
break
}
}
if app.config.Section("telegram").Key("require_unique").MustBool(false) {
for _, u := range app.storage.telegram {
if app.telegram.verifiedTokens[tokenIndex].Username == u.Username {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
} respondBool(200, ok, gc)
}
// if tokenIndex != -1 {
// length := len(app.telegram.verifiedTokens)
// app.telegram.verifiedTokens[length-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[length-1]
// app.telegram.verifiedTokens = app.telegram.verifiedTokens[:length-1]
// }
respondBool(200, tokenIndex != -1, gc)
} }
// @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code. // @Summary Returns true/false on whether or not a discord PIN was verified. Requires invite code.
@ -479,21 +484,17 @@ func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) {
// @tags Other // @tags Other
func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) { func (app *appContext) DiscordVerifiedInvite(gc *gin.Context) {
code := gc.Param("invCode") code := gc.Param("invCode")
if _, ok := app.storage.invites[code]; !ok { if _, ok := app.storage.GetInvitesKey(code); !ok {
respondBool(401, false, gc) respondBool(401, false, gc)
return return
} }
pin := gc.Param("pin") pin := gc.Param("pin")
_, ok := app.discord.verifiedTokens[pin] user, ok := app.discord.UserVerified(pin)
if app.config.Section("discord").Key("require_unique").MustBool(false) { if ok && app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(user.ID) {
for _, u := range app.storage.discord {
if app.discord.verifiedTokens[pin].ID == u.ID {
delete(app.discord.verifiedTokens, pin) delete(app.discord.verifiedTokens, pin)
respondBool(400, false, gc) respondBool(400, false, gc)
return return
} }
}
}
respondBool(200, ok, gc) respondBool(200, ok, gc)
} }
@ -512,7 +513,7 @@ func (app *appContext) DiscordServerInvite(gc *gin.Context) {
return return
} }
code := gc.Param("invCode") code := gc.Param("invCode")
if _, ok := app.storage.invites[code]; !ok { if _, ok := app.storage.GetInvitesKey(code); !ok {
respondBool(401, false, gc) respondBool(401, false, gc)
return return
} }
@ -536,7 +537,7 @@ func (app *appContext) DiscordServerInvite(gc *gin.Context) {
// @tags Other // @tags Other
func (app *appContext) MatrixSendPIN(gc *gin.Context) { func (app *appContext) MatrixSendPIN(gc *gin.Context) {
code := gc.Param("invCode") code := gc.Param("invCode")
if _, ok := app.storage.invites[code]; !ok { if _, ok := app.storage.GetInvitesKey(code); !ok {
respondBool(401, false, gc) respondBool(401, false, gc)
return return
} }
@ -547,7 +548,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) {
return return
} }
if app.config.Section("matrix").Key("require_unique").MustBool(false) { if app.config.Section("matrix").Key("require_unique").MustBool(false) {
for _, u := range app.storage.matrix { for _, u := range app.storage.GetMatrix() {
if req.UserID == u.UserID { if req.UserID == u.UserID {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
@ -574,7 +575,7 @@ func (app *appContext) MatrixSendPIN(gc *gin.Context) {
// @tags Other // @tags Other
func (app *appContext) MatrixCheckPIN(gc *gin.Context) { func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
code := gc.Param("invCode") code := gc.Param("invCode")
if _, ok := app.storage.invites[code]; !ok { if _, ok := app.storage.GetInvitesKey(code); !ok {
app.debug.Println("Matrix: Invite code was invalid") app.debug.Println("Matrix: Invite code was invalid")
respondBool(401, false, gc) respondBool(401, false, gc)
return return
@ -644,8 +645,8 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
func (app *appContext) MatrixConnect(gc *gin.Context) { func (app *appContext) MatrixConnect(gc *gin.Context) {
var req MatrixConnectUserDTO var req MatrixConnectUserDTO
gc.BindJSON(&req) gc.BindJSON(&req)
if app.storage.matrix == nil { if app.storage.GetMatrix() == nil {
app.storage.matrix = map[string]MatrixUser{} app.storage.matrix = matrixStore{}
} }
roomID, encrypted, err := app.matrix.CreateRoom(req.UserID) roomID, encrypted, err := app.matrix.CreateRoom(req.UserID)
if err != nil { if err != nil {
@ -653,13 +654,13 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
app.storage.matrix[req.JellyfinID] = MatrixUser{ app.storage.SetMatrixKey(req.JellyfinID, MatrixUser{
UserID: req.UserID, UserID: req.UserID,
RoomID: string(roomID), RoomID: string(roomID),
Lang: "en-us", Lang: "en-us",
Contact: true, Contact: true,
Encrypted: encrypted, Encrypted: encrypted,
} })
app.matrix.isEncrypted[roomID] = encrypted app.matrix.isEncrypted[roomID] = encrypted
if err := app.storage.storeMatrixUsers(); err != nil { if err := app.storage.storeMatrixUsers(); err != nil {
app.err.Printf("Failed to store Matrix users: %v", err) app.err.Printf("Failed to store Matrix users: %v", err)
@ -715,7 +716,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
app.storage.discord[req.JellyfinID] = user app.storage.SetDiscordKey(req.JellyfinID, user)
if err := app.storage.storeDiscordUsers(); err != nil { if err := app.storage.storeDiscordUsers(); err != nil {
app.err.Printf("Failed to store Discord users: %v", err) app.err.Printf("Failed to store Discord users: %v", err)
respondBool(500, false, gc) respondBool(500, false, gc)
@ -739,8 +740,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
respond(400, "User not found", gc) respond(400, "User not found", gc)
return return
} */ } */
delete(app.storage.discord, req.ID) app.storage.DeleteDiscordKey(req.ID)
app.storage.storeDiscordUsers()
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -758,8 +758,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
respond(400, "User not found", gc) respond(400, "User not found", gc)
return return
} */ } */
delete(app.storage.telegram, req.ID) app.storage.DeleteTelegramKey(req.ID)
app.storage.storeTelegramUsers()
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -777,7 +776,6 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
respond(400, "User not found", gc) respond(400, "User not found", gc)
return return
} */ } */
delete(app.storage.matrix, req.ID) app.storage.DeleteMatrixKey(req.ID)
app.storage.storeMatrixUsers()
respondBool(200, true, gc) respondBool(200, true, gc)
} }

View File

@ -17,7 +17,7 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
} }
username := jfUser.Name username := jfUser.Name
email := "" email := ""
if e, ok := app.storage.emails[jfID]; ok { if e, ok := app.storage.GetEmailsKey(jfID); ok {
email = e.Addr email = e.Addr
} }
for _, ombiUser := range ombiUsers { for _, ombiUser := range ombiUsers {

629
api-userpage.go Normal file
View File

@ -0,0 +1,629 @@
package main
import (
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
)
// @Summary Returns the logged-in user's Jellyfin ID & Username, and other details.
// @Produce json
// @Success 200 {object} MyDetailsDTO
// @Router /my/details [get]
// @tags User Page
func (app *appContext) MyDetails(gc *gin.Context) {
resp := MyDetailsDTO{
Id: gc.GetString("jfId"),
}
user, status, err := app.jf.UserByID(resp.Id, false)
if status != 200 || err != nil {
app.err.Printf("Failed to get Jellyfin user (%d): %+v\n", status, err)
respond(500, "Failed to get user", gc)
return
}
resp.Username = user.Name
resp.Admin = user.Policy.IsAdministrator
resp.AccountsAdmin = false
if !app.config.Section("ui").Key("allow_all").MustBool(false) {
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
if emailStore, ok := app.storage.GetEmailsKey(resp.Id); ok {
resp.AccountsAdmin = emailStore.Admin
}
resp.AccountsAdmin = resp.AccountsAdmin || (adminOnly && resp.Admin)
}
resp.Disabled = user.Policy.IsDisabled
if exp, ok := app.storage.users[user.ID]; ok {
resp.Expiry = exp.Unix()
}
app.storage.loadEmails()
app.storage.loadDiscordUsers()
app.storage.loadMatrixUsers()
app.storage.loadTelegramUsers()
if emailEnabled {
resp.Email = &MyDetailsContactMethodsDTO{}
if email, ok := app.storage.GetEmailsKey(user.ID); ok && email.Addr != "" {
resp.Email.Value = email.Addr
resp.Email.Enabled = email.Contact
}
}
if discordEnabled {
resp.Discord = &MyDetailsContactMethodsDTO{}
if discord, ok := app.storage.GetDiscordKey(user.ID); ok {
resp.Discord.Value = RenderDiscordUsername(discord)
resp.Discord.Enabled = discord.Contact
}
}
if telegramEnabled {
resp.Telegram = &MyDetailsContactMethodsDTO{}
if telegram, ok := app.storage.GetTelegramKey(user.ID); ok {
resp.Telegram.Value = telegram.Username
resp.Telegram.Enabled = telegram.Contact
}
}
if matrixEnabled {
resp.Matrix = &MyDetailsContactMethodsDTO{}
if matrix, ok := app.storage.GetMatrixKey(user.ID); ok {
resp.Matrix.Value = matrix.UserID
resp.Matrix.Enabled = matrix.Contact
}
}
gc.JSON(200, resp)
}
// @Summary Sets whether to notify yourself through telegram/discord/matrix/email or not.
// @Produce json
// @Param SetContactMethodsDTO body SetContactMethodsDTO true "User's Jellyfin ID and whether or not to notify then through Telegram."
// @Success 200 {object} boolResponse
// @Success 400 {object} boolResponse
// @Success 500 {object} boolResponse
// @Router /my/contact [post]
// @Security Bearer
// @tags User Page
func (app *appContext) SetMyContactMethods(gc *gin.Context) {
var req SetContactMethodsDTO
gc.BindJSON(&req)
req.ID = gc.GetString("jfId")
if req.ID == "" {
respondBool(400, false, gc)
return
}
app.setContactMethods(req, gc)
}
// @Summary Logout by deleting refresh token from cookies.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /my/logout [post]
// @Security Bearer
// @tags User Page
func (app *appContext) LogoutUser(gc *gin.Context) {
cookie, err := gc.Cookie("user-refresh")
if err != nil {
app.debug.Printf("Couldn't get cookies: %s", err)
respond(500, "Couldn't fetch cookies", gc)
return
}
app.invalidTokens = append(app.invalidTokens, cookie)
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
respondBool(200, true, gc)
}
// @Summary confirm an action (e.g. changing an email address.)
// @Produce json
// @Param jwt path string true "jwt confirmation code"
// @Router /my/confirm/{jwt} [post]
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 404
// @Success 303
// @Failure 500 {object} stringResponse
// @tags User Page
func (app *appContext) ConfirmMyAction(gc *gin.Context) {
app.confirmMyAction(gc, "")
}
func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
var claims jwt.MapClaims
var target ConfirmationTarget
var id string
fail := func() {
gcHTML(gc, 404, "404.html", gin.H{
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
// Validate key
if key == "" {
key = gc.Param("jwt")
}
token, err := jwt.Parse(key, checkToken)
if err != nil {
app.err.Printf("Failed to parse key: %s", err)
fail()
// respond(500, "unknownError", gc)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
app.err.Printf("Failed to parse key: %s", err)
fail()
// respond(500, "unknownError", gc)
return
}
expiry := time.Unix(int64(claims["exp"].(float64)), 0)
if !(ok && token.Valid && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
app.err.Printf("Invalid key")
fail()
// respond(400, "invalidKey", gc)
return
}
target = ConfirmationTarget(int(claims["target"].(float64)))
id = claims["id"].(string)
// Perform an Action
if target == NoOp {
gc.Redirect(http.StatusSeeOther, "/my/account")
return
} else if target == UserEmailChange {
emailStore, ok := app.storage.GetEmailsKey(id)
if !ok {
emailStore = EmailAddress{
Contact: true,
}
}
emailStore.Addr = claims["email"].(string)
app.storage.SetEmailsKey(id, emailStore)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
ombiUser, code, err := app.getOmbiUser(id)
if code == 200 && err == nil {
ombiUser["emailAddress"] = claims["email"].(string)
code, err = app.ombi.ModifyUser(ombiUser)
if code != 200 || err != nil {
app.err.Printf("%s: Failed to change ombi email address (%d): %v", ombiUser["userName"].(string), code, err)
}
}
}
app.storage.storeEmails()
app.info.Println("Email list modified")
gc.Redirect(http.StatusSeeOther, "/my/account")
return
}
}
// @Summary Modify your email address.
// @Produce json
// @Param ModifyMyEmailDTO body ModifyMyEmailDTO true "New email address."
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 401 {object} stringResponse
// @Failure 500 {object} stringResponse
// @Router /my/email [post]
// @Security Bearer
// @tags User Page
func (app *appContext) ModifyMyEmail(gc *gin.Context) {
var req ModifyMyEmailDTO
gc.BindJSON(&req)
app.debug.Println("Email modification requested")
if !strings.ContainsRune(req.Email, '@') {
respond(400, "Invalid Email Address", gc)
return
}
id := gc.GetString("jfId")
// We'll use the ConfirmMyAction route to do the work, even if we don't need to confirm the address.
claims := jwt.MapClaims{
"valid": true,
"id": id,
"email": req.Email,
"type": "confirmation",
"target": UserEmailChange,
"exp": time.Now().Add(time.Hour).Unix(),
}
tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
if err != nil {
app.err.Printf("Failed to generate confirmation token: %v", err)
respond(500, "errorUnknown", gc)
return
}
if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) {
user, status, err := app.jf.UserByID(id, false)
name := ""
if status == 200 && err == nil {
name = user.Name
}
app.debug.Printf("%s: Email confirmation required", id)
respond(401, "confirmEmail", gc)
msg, err := app.email.constructConfirmation("", name, key, app, false)
if err != nil {
app.err.Printf("%s: Failed to construct confirmation email: %v", name, err)
} else if err := app.email.send(msg, req.Email); err != nil {
app.err.Printf("%s: Failed to send user confirmation email: %v", name, err)
} else {
app.info.Printf("%s: Sent user confirmation email to \"%s\"", name, req.Email)
}
return
}
app.confirmMyAction(gc, key)
return
}
// @Summary Returns a 10-minute, one-use Discord server invite
// @Produce json
// @Success 200 {object} DiscordInviteDTO
// @Failure 400 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param invCode path string true "invite Code"
// @Router /my/discord/invite [get]
// @Security Bearer
// @tags User Page
func (app *appContext) MyDiscordServerInvite(gc *gin.Context) {
if app.discord.inviteChannelName == "" {
respondBool(400, false, gc)
return
}
invURL, iconURL := app.discord.NewTempInvite(10*60, 1)
if invURL == "" {
respondBool(500, false, gc)
return
}
gc.JSON(200, DiscordInviteDTO{invURL, iconURL})
}
// @Summary Returns a linking PIN for discord/telegram
// @Produce json
// @Success 200 {object} GetMyPINDTO
// @Failure 400 {object} stringResponse
// Param service path string true "discord/telegram"
// @Router /my/pin/{service} [get]
// @Security Bearer
// @tags User Page
func (app *appContext) GetMyPIN(gc *gin.Context) {
service := gc.Param("service")
resp := GetMyPINDTO{}
switch service {
case "discord":
resp.PIN = app.discord.NewAssignedAuthToken(gc.GetString("jfId"))
break
case "telegram":
resp.PIN = app.telegram.NewAssignedAuthToken(gc.GetString("jfId"))
break
default:
respond(400, "invalid service", gc)
return
}
gc.JSON(200, resp)
}
// @Summary Returns true/false on whether or not your discord PIN was verified, and assigns the discord user to you.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Router /my/discord/verified/{pin} [get]
// @Security Bearer
// @tags User Page
func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin")
dcUser, ok := app.discord.AssignedUserVerified(pin, gc.GetString("jfId"))
app.discord.DeleteVerifiedUser(pin)
if !ok {
respondBool(200, false, gc)
return
}
if app.config.Section("discord").Key("require_unique").MustBool(false) && app.discord.UserExists(dcUser.ID) {
respondBool(400, false, gc)
return
}
existingUser, ok := app.storage.GetDiscordKey(gc.GetString("jfId"))
if ok {
dcUser.Lang = existingUser.Lang
dcUser.Contact = existingUser.Contact
}
app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser)
respondBool(200, true, gc)
}
// @Summary Returns true/false on whether or not your telegram PIN was verified, and assigns the telegram user to you.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Router /my/telegram/verified/{pin} [get]
// @Security Bearer
// @tags User Page
func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
pin := gc.Param("pin")
token, ok := app.telegram.AssignedTokenVerified(pin, gc.GetString("jfId"))
app.telegram.DeleteVerifiedToken(pin)
if !ok {
respondBool(200, false, gc)
return
}
if app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(token.Username) {
respondBool(400, false, gc)
return
}
tgUser := TelegramUser{
ChatID: token.ChatID,
Username: token.Username,
Contact: true,
}
if lang, ok := app.telegram.languages[tgUser.ChatID]; ok {
tgUser.Lang = lang
}
existingUser, ok := app.storage.GetTelegramKey(gc.GetString("jfId"))
if ok {
tgUser.Lang = existingUser.Lang
tgUser.Contact = existingUser.Contact
}
app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser)
respondBool(200, true, gc)
}
// @Summary Generate and send a new PIN to your given matrix user.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 400 {object} stringResponse
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param MatrixSendPINDTO body MatrixSendPINDTO true "User's Matrix ID."
// @Router /my/matrix/user [post]
// @Security Bearer
// @tags User Page
func (app *appContext) MatrixSendMyPIN(gc *gin.Context) {
var req MatrixSendPINDTO
gc.BindJSON(&req)
if req.UserID == "" {
respond(400, "errorNoUserID", gc)
return
}
if app.config.Section("matrix").Key("require_unique").MustBool(false) {
for _, u := range app.storage.GetMatrix() {
if req.UserID == u.UserID {
respondBool(400, false, gc)
return
}
}
}
ok := app.matrix.SendStart(req.UserID)
if !ok {
respondBool(500, false, gc)
return
}
respondBool(200, true, gc)
}
// @Summary Check whether your matrix PIN is valid, and link the account to yours if so.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 401 {object} boolResponse
// @Param pin path string true "PIN code to check"
// @Param invCode path string true "invite Code"
// @Param userID path string true "Matrix User ID"
// @Router /my/matrix/verified/{userID}/{pin} [get]
// @Security Bearer
// @tags User Page
func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
userID := gc.Param("userID")
pin := gc.Param("pin")
user, ok := app.matrix.tokens[pin]
if !ok {
app.debug.Println("Matrix: PIN not found")
respondBool(200, false, gc)
return
}
if user.User.UserID != userID {
app.debug.Println("Matrix: User ID of PIN didn't match")
respondBool(200, false, gc)
return
}
mxUser := *user.User
mxUser.Contact = true
existingUser, ok := app.storage.GetMatrixKey(gc.GetString("jfId"))
if ok {
mxUser.Lang = existingUser.Lang
mxUser.Contact = existingUser.Contact
}
app.storage.SetMatrixKey(gc.GetString("jfId"), mxUser)
delete(app.matrix.tokens, pin)
respondBool(200, true, gc)
}
// @Summary unlink the Discord account from your Jellyfin user. Always succeeds.
// @Produce json
// @Success 200 {object} boolResponse
// @Router /my/discord [delete]
// @Security Bearer
// @Tags User Page
func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
app.storage.DeleteDiscordKey(gc.GetString("jfId"))
respondBool(200, true, gc)
}
// @Summary unlink the Telegram account from your Jellyfin user. Always succeeds.
// @Produce json
// @Success 200 {object} boolResponse
// @Router /my/telegram [delete]
// @Security Bearer
// @Tags User Page
func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
app.storage.DeleteTelegramKey(gc.GetString("jfId"))
respondBool(200, true, gc)
}
// @Summary unlink the Matrix account from your Jellyfin user. Always succeeds.
// @Produce json
// @Success 200 {object} boolResponse
// @Router /my/matrix [delete]
// @Security Bearer
// @Tags User Page
func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
app.storage.DeleteMatrixKey(gc.GetString("jfId"))
respondBool(200, true, gc)
}
// @Summary Generate & send a password reset link if the given username/email/contact method exists. Doesn't give you any info about it's success.
// @Produce json
// @Param address path string true "address/contact method associated w/ your account."
// @Success 204 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /my/password/reset/{address} [post]
// @Tags User Page
func (app *appContext) ResetMyPassword(gc *gin.Context) {
// All requests should take 1 second, to make it harder to tell if a success occured or not.
timerWait := make(chan bool)
cancel := time.AfterFunc(1*time.Second, func() {
timerWait <- true
})
address := gc.Param("address")
if address == "" {
app.debug.Println("Ignoring empty request for PWR")
cancel.Stop()
respondBool(400, false, gc)
return
}
var pwr InternalPWR
var err error
jfID := app.ReverseUserSearch(address)
if jfID == "" {
app.debug.Printf("Ignoring PWR request: User not found")
for range timerWait {
respondBool(204, true, gc)
return
}
return
}
pwr, err = app.GenInternalReset(jfID)
if err != nil {
app.err.Printf("Failed to get user from Jellyfin: %v", err)
for range timerWait {
respondBool(204, true, gc)
return
}
return
}
if app.internalPWRs == nil {
app.internalPWRs = map[string]InternalPWR{}
}
app.internalPWRs[pwr.PIN] = pwr
// FIXME: Send to all contact methods
msg, err := app.email.constructReset(
PasswordReset{
Pin: pwr.PIN,
Username: pwr.Username,
Expiry: pwr.Expiry,
Internal: true,
}, app, false,
)
if err != nil {
app.err.Printf("Failed to construct password reset message for \"%s\": %v", pwr.Username, err)
for range timerWait {
respondBool(204, true, gc)
return
}
return
} else if err := app.sendByID(msg, jfID); err != nil {
app.err.Printf("Failed to send password reset message to \"%s\": %v", address, err)
} else {
app.info.Printf("Sent password reset message to \"%s\"", address)
}
for range timerWait {
respondBool(204, true, gc)
return
}
}
// @Summary Change your password, given the old one and the new one.
// @Produce json
// @Param ChangeMyPasswordDTO body ChangeMyPasswordDTO true "User's old & new passwords."
// @Success 204 {object} boolResponse
// @Failure 400 {object} PasswordValidation
// @Failure 401 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /my/password [post]
// @Security Bearer
// @Tags User Page
func (app *appContext) ChangeMyPassword(gc *gin.Context) {
var req ChangeMyPasswordDTO
gc.BindJSON(&req)
if req.Old == "" || req.New == "" {
respondBool(400, false, gc)
}
validation := app.validator.validate(req.New)
for _, val := range validation {
if !val {
app.debug.Printf("%s: Change password failed: Invalid password", gc.GetString("jfId"))
gc.JSON(400, validation)
return
}
}
user, status, err := app.jf.UserByID(gc.GetString("jfId"), false)
if status != 200 || err != nil {
app.err.Printf("Failed to change password: couldn't find user (%d): %+v", status, err)
respondBool(500, false, gc)
return
}
// Authenticate as user to confirm old password.
user, status, err = app.authJf.Authenticate(user.Name, req.Old)
if status != 200 || err != nil {
respondBool(401, false, gc)
return
}
status, err = app.jf.SetPassword(gc.GetString("jfId"), req.Old, req.New)
if (status != 200 && status != 204) || err != nil {
respondBool(500, false, gc)
return
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
func() {
ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId"))
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", user.Name, status, err)
return
}
ombiUser["password"] = req.New
status, err = app.ombi.ModifyUser(ombiUser)
if status != 200 || err != nil {
app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err)
return
}
app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"])
}()
}
cookie, err := gc.Cookie("user-refresh")
if err == nil {
app.invalidTokens = append(app.invalidTokens, cookie)
gc.SetCookie("refresh", "invalid", -1, "/my", gc.Request.URL.Hostname(), true, true)
} else {
app.debug.Printf("Couldn't get cookies: %s", err)
}
respondBool(204, true, gc)
}

View File

@ -61,7 +61,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
} }
app.jf.CacheExpiry = time.Now() app.jf.CacheExpiry = time.Now()
if emailEnabled { if emailEnabled {
app.storage.emails[id] = EmailAddress{Addr: req.Email, Contact: true} app.storage.SetEmailsKey(id, EmailAddress{Addr: req.Email, Contact: true})
app.storage.storeEmails() app.storage.storeEmails()
} }
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
@ -121,7 +121,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
return return
} }
} else { } else {
discordUser, discordVerified = app.discord.verifiedTokens[req.DiscordPIN] discordUser, discordVerified = app.discord.UserVerified(req.DiscordPIN)
if !discordVerified { if !discordVerified {
f = func(gc *gin.Context) { f = func(gc *gin.Context) {
app.debug.Printf("%s: New user failed: Discord PIN was invalid", req.Code) app.debug.Printf("%s: New user failed: Discord PIN was invalid", req.Code)
@ -131,7 +131,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
return return
} }
if app.config.Section("discord").Key("require_unique").MustBool(false) { if app.config.Section("discord").Key("require_unique").MustBool(false) {
for _, u := range app.storage.discord { for _, u := range app.storage.GetDiscord() {
if discordUser.ID == u.ID { if discordUser.ID == u.ID {
f = func(gc *gin.Context) { f = func(gc *gin.Context) {
app.debug.Printf("%s: New user failed: Discord user already linked", req.Code) app.debug.Printf("%s: New user failed: Discord user already linked", req.Code)
@ -177,7 +177,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
return return
} }
if app.config.Section("matrix").Key("require_unique").MustBool(false) { if app.config.Section("matrix").Key("require_unique").MustBool(false) {
for _, u := range app.storage.matrix { for _, u := range app.storage.GetMatrix() {
if user.User.UserID == u.UserID { if user.User.UserID == u.UserID {
f = func(gc *gin.Context) { f = func(gc *gin.Context) {
app.debug.Printf("%s: New user failed: Matrix user already linked", req.Code) app.debug.Printf("%s: New user failed: Matrix user already linked", req.Code)
@ -193,7 +193,8 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
} }
} }
telegramTokenIndex := -1 var tgToken TelegramVerifiedToken
telegramVerified := false
if telegramEnabled { if telegramEnabled {
if req.TelegramPIN == "" { if req.TelegramPIN == "" {
if app.config.Section("telegram").Key("required").MustBool(false) { if app.config.Section("telegram").Key("required").MustBool(false) {
@ -205,13 +206,8 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
return return
} }
} else { } else {
for i, v := range app.telegram.verifiedTokens { tgToken, telegramVerified = app.telegram.TokenVerified(req.TelegramPIN)
if v.Token == req.TelegramPIN { if !telegramVerified {
telegramTokenIndex = i
break
}
}
if telegramTokenIndex == -1 {
f = func(gc *gin.Context) { f = func(gc *gin.Context) {
app.debug.Printf("%s: New user failed: Telegram PIN was invalid", req.Code) app.debug.Printf("%s: New user failed: Telegram PIN was invalid", req.Code)
respond(401, "errorInvalidPIN", gc) respond(401, "errorInvalidPIN", gc)
@ -219,9 +215,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
success = false success = false
return return
} }
if app.config.Section("telegram").Key("require_unique").MustBool(false) { if app.config.Section("telegram").Key("require_unique").MustBool(false) && app.telegram.UserExists(tgToken.Username) {
for _, u := range app.storage.telegram {
if app.telegram.verifiedTokens[telegramTokenIndex].Username == u.Username {
f = func(gc *gin.Context) { f = func(gc *gin.Context) {
app.debug.Printf("%s: New user failed: Telegram user already linked", req.Code) app.debug.Printf("%s: New user failed: Telegram user already linked", req.Code)
respond(400, "errorAccountLinked", gc) respond(400, "errorAccountLinked", gc)
@ -231,17 +225,11 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
} }
} }
} }
}
}
if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) && !confirmed { if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) && !confirmed {
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"valid": true, "valid": true,
"invite": req.Code, "invite": req.Code,
"email": req.Email, "exp": time.Now().Add(30 * time.Minute).Unix(),
"username": req.Username,
"password": req.Password,
"telegramPIN": req.TelegramPIN,
"exp": time.Now().Add(time.Hour * 12).Unix(),
"type": "confirmation", "type": "confirmation",
} }
tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@ -254,10 +242,17 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
success = false success = false
return return
} }
inv := app.storage.invites[req.Code] if app.ConfirmationKeys == nil {
inv.Keys = append(inv.Keys, key) app.ConfirmationKeys = map[string]map[string]newUserDTO{}
app.storage.invites[req.Code] = inv }
app.storage.storeInvites() cKeys, ok := app.ConfirmationKeys[req.Code]
if !ok {
cKeys = map[string]newUserDTO{}
}
cKeys[key] = req
app.confirmationKeysLock.Lock()
app.ConfirmationKeys[req.Code] = cKeys
app.confirmationKeysLock.Unlock()
f = func(gc *gin.Context) { f = func(gc *gin.Context) {
app.debug.Printf("%s: Email confirmation required", req.Code) app.debug.Printf("%s: Email confirmation required", req.Code)
respond(401, "confirmEmail", gc) respond(401, "confirmEmail", gc)
@ -284,7 +279,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
return return
} }
app.storage.loadProfiles() app.storage.loadProfiles()
invite := app.storage.invites[req.Code] invite, _ := app.storage.GetInvitesKey(req.Code)
app.checkInvite(req.Code, true, req.Username) app.checkInvite(req.Code, true, req.Username)
if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) { if emailEnabled && app.config.Section("notifications").Key("enabled").MustBool(false) {
for address, settings := range invite.Notify { for address, settings := range invite.Notify {
@ -339,7 +334,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
} }
// if app.config.Section("password_resets").Key("enabled").MustBool(false) { // if app.config.Section("password_resets").Key("enabled").MustBool(false) {
if req.Email != "" { if req.Email != "" {
app.storage.emails[id] = EmailAddress{Addr: req.Email, Contact: true} app.storage.SetEmailsKey(id, EmailAddress{Addr: req.Email, Contact: true})
app.storage.storeEmails() app.storage.storeEmails()
} }
expiry := time.Time{} expiry := time.Time{}
@ -352,20 +347,19 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
app.err.Printf("Failed to store user duration: %v", err) app.err.Printf("Failed to store user duration: %v", err)
} }
} }
if discordEnabled && discordVerified { if discordVerified {
discordUser.Contact = req.DiscordContact discordUser.Contact = req.DiscordContact
if app.storage.discord == nil { if app.storage.discord == nil {
app.storage.discord = map[string]DiscordUser{} app.storage.discord = discordStore{}
} }
app.storage.discord[user.ID] = discordUser app.storage.SetDiscordKey(user.ID, discordUser)
if err := app.storage.storeDiscordUsers(); err != nil { if err := app.storage.storeDiscordUsers(); err != nil {
app.err.Printf("Failed to store Discord users: %v", err) app.err.Printf("Failed to store Discord users: %v", err)
} else { } else {
delete(app.discord.verifiedTokens, req.DiscordPIN) delete(app.discord.verifiedTokens, req.DiscordPIN)
} }
} }
if telegramEnabled && telegramTokenIndex != -1 { if telegramVerified {
tgToken := app.telegram.verifiedTokens[telegramTokenIndex]
tgUser := TelegramUser{ tgUser := TelegramUser{
ChatID: tgToken.ChatID, ChatID: tgToken.ChatID,
Username: tgToken.Username, Username: tgToken.Username,
@ -375,15 +369,10 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
tgUser.Lang = lang tgUser.Lang = lang
} }
if app.storage.telegram == nil { if app.storage.telegram == nil {
app.storage.telegram = map[string]TelegramUser{} app.storage.telegram = telegramStore{}
}
app.storage.telegram[user.ID] = tgUser
if err := app.storage.storeTelegramUsers(); err != nil {
app.err.Printf("Failed to store Telegram users: %v", err)
} else {
app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1], app.telegram.verifiedTokens[telegramTokenIndex] = app.telegram.verifiedTokens[telegramTokenIndex], app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1]
app.telegram.verifiedTokens = app.telegram.verifiedTokens[:len(app.telegram.verifiedTokens)-1]
} }
app.telegram.DeleteVerifiedToken(req.TelegramPIN)
app.storage.SetTelegramKey(user.ID, tgUser)
} }
if invite.Profile != "" && app.config.Section("ombi").Key("enabled").MustBool(false) { if invite.Profile != "" && app.config.Section("ombi").Key("enabled").MustBool(false) {
if profile.Ombi != nil && len(profile.Ombi) != 0 { if profile.Ombi != nil && len(profile.Ombi) != 0 {
@ -394,18 +383,19 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
app.debug.Printf("Errors reported by Ombi: %s", strings.Join(errors, ", ")) app.debug.Printf("Errors reported by Ombi: %s", strings.Join(errors, ", "))
} else { } else {
app.info.Println("Created Ombi user") app.info.Println("Created Ombi user")
if (discordEnabled && discordVerified) || (telegramEnabled && telegramTokenIndex != -1) { if discordVerified || telegramVerified {
ombiUser, status, err := app.getOmbiUser(id) ombiUser, status, err := app.getOmbiUser(id)
if status != 200 || err != nil { if status != 200 || err != nil {
app.err.Printf("Failed to get Ombi user (%d): %v", status, err) app.err.Printf("Failed to get Ombi user (%d): %v", status, err)
} else { } else {
dID := "" dID := ""
tUser := "" tUser := ""
if discordEnabled && discordVerified { if discordVerified {
dID = discordUser.ID dID = discordUser.ID
} }
if telegramEnabled && telegramTokenIndex != -1 { if telegramVerified {
tUser = app.storage.telegram[user.ID].Username u, _ := app.storage.GetTelegramKey(user.ID)
tUser = u.Username
} }
resp, status, err := app.ombi.SetNotificationPrefs(ombiUser, dID, tUser) resp, status, err := app.ombi.SetNotificationPrefs(ombiUser, dID, tUser)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
@ -423,14 +413,14 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
matrixUser.Contact = req.MatrixContact matrixUser.Contact = req.MatrixContact
delete(app.matrix.tokens, req.MatrixPIN) delete(app.matrix.tokens, req.MatrixPIN)
if app.storage.matrix == nil { if app.storage.matrix == nil {
app.storage.matrix = map[string]MatrixUser{} app.storage.matrix = matrixStore{}
} }
app.storage.matrix[user.ID] = matrixUser app.storage.SetMatrixKey(user.ID, matrixUser)
if err := app.storage.storeMatrixUsers(); err != nil { if err := app.storage.storeMatrixUsers(); err != nil {
app.err.Printf("Failed to store Matrix users: %v", err) app.err.Printf("Failed to store Matrix users: %v", err)
} }
} }
if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramTokenIndex != -1 || discordVerified { if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramVerified || discordVerified || matrixVerified {
name := app.getAddressOrName(user.ID) name := app.getAddressOrName(user.ID)
app.debug.Printf("%s: Sending welcome message to %s", req.Username, name) app.debug.Printf("%s: Sending welcome message to %s", req.Username, name)
msg, err := app.email.constructWelcome(req.Username, expiry, app, false) msg, err := app.email.constructWelcome(req.Username, expiry, app, false)
@ -473,6 +463,7 @@ func (app *appContext) NewUser(gc *gin.Context) {
for _, val := range validation { for _, val := range validation {
if !val { if !val {
valid = false valid = false
break
} }
} }
if !valid { if !valid {
@ -488,7 +479,7 @@ func (app *appContext) NewUser(gc *gin.Context) {
return return
} }
if app.config.Section("email").Key("require_unique").MustBool(false) && req.Email != "" { if app.config.Section("email").Key("require_unique").MustBool(false) && req.Email != "" {
for _, email := range app.storage.emails { for _, email := range app.storage.GetEmails() {
if req.Email == email.Addr { if req.Email == email.Addr {
app.info.Printf("%s: New user failed: Email already in use", req.Code) app.info.Printf("%s: New user failed: Email already in use", req.Code)
respond(400, "errorEmailLinked", gc) respond(400, "errorEmailLinked", gc)
@ -897,7 +888,7 @@ func (app *appContext) GetUsers(gc *gin.Context) {
if !jfUser.LastActivityDate.IsZero() { if !jfUser.LastActivityDate.IsZero() {
user.LastActive = jfUser.LastActivityDate.Unix() user.LastActive = jfUser.LastActivityDate.Unix()
} }
if email, ok := app.storage.emails[jfUser.ID]; ok { if email, ok := app.storage.GetEmailsKey(jfUser.ID); ok {
user.Email = email.Addr user.Email = email.Addr
user.NotifyThroughEmail = email.Contact user.NotifyThroughEmail = email.Contact
user.Label = email.Label user.Label = email.Label
@ -907,15 +898,15 @@ func (app *appContext) GetUsers(gc *gin.Context) {
if ok { if ok {
user.Expiry = expiry.Unix() user.Expiry = expiry.Unix()
} }
if tgUser, ok := app.storage.telegram[jfUser.ID]; ok { if tgUser, ok := app.storage.GetTelegramKey(jfUser.ID); ok {
user.Telegram = tgUser.Username user.Telegram = tgUser.Username
user.NotifyThroughTelegram = tgUser.Contact user.NotifyThroughTelegram = tgUser.Contact
} }
if mxUser, ok := app.storage.matrix[jfUser.ID]; ok { if mxUser, ok := app.storage.GetMatrixKey(jfUser.ID); ok {
user.Matrix = mxUser.UserID user.Matrix = mxUser.UserID
user.NotifyThroughMatrix = mxUser.Contact user.NotifyThroughMatrix = mxUser.Contact
} }
if dcUser, ok := app.storage.discord[jfUser.ID]; ok { if dcUser, ok := app.storage.GetDiscordKey(jfUser.ID); ok {
user.Discord = RenderDiscordUsername(dcUser) user.Discord = RenderDiscordUsername(dcUser)
// user.Discord = dcUser.Username + "#" + dcUser.Discriminator // user.Discord = dcUser.Username + "#" + dcUser.Discriminator
user.DiscordID = dcUser.ID user.DiscordID = dcUser.ID
@ -949,11 +940,11 @@ func (app *appContext) SetAccountsAdmin(gc *gin.Context) {
id := jfUser.ID id := jfUser.ID
if admin, ok := req[id]; ok { if admin, ok := req[id]; ok {
var emailStore = EmailAddress{} var emailStore = EmailAddress{}
if oldEmail, ok := app.storage.emails[id]; ok { if oldEmail, ok := app.storage.GetEmailsKey(id); ok {
emailStore = oldEmail emailStore = oldEmail
} }
emailStore.Admin = admin emailStore.Admin = admin
app.storage.emails[id] = emailStore app.storage.SetEmailsKey(id, emailStore)
} }
} }
if err := app.storage.storeEmails(); err != nil { if err := app.storage.storeEmails(); err != nil {
@ -986,11 +977,11 @@ func (app *appContext) ModifyLabels(gc *gin.Context) {
id := jfUser.ID id := jfUser.ID
if label, ok := req[id]; ok { if label, ok := req[id]; ok {
var emailStore = EmailAddress{} var emailStore = EmailAddress{}
if oldEmail, ok := app.storage.emails[id]; ok { if oldEmail, ok := app.storage.GetEmailsKey(id); ok {
emailStore = oldEmail emailStore = oldEmail
} }
emailStore.Label = label emailStore.Label = label
app.storage.emails[id] = emailStore app.storage.SetEmailsKey(id, emailStore)
} }
} }
if err := app.storage.storeEmails(); err != nil { if err := app.storage.storeEmails(); err != nil {
@ -1024,7 +1015,7 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
id := jfUser.ID id := jfUser.ID
if address, ok := req[id]; ok { if address, ok := req[id]; ok {
var emailStore = EmailAddress{} var emailStore = EmailAddress{}
oldEmail, ok := app.storage.emails[id] oldEmail, ok := app.storage.GetEmailsKey(id)
if ok { if ok {
emailStore = oldEmail emailStore = oldEmail
} }
@ -1035,7 +1026,7 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
} }
emailStore.Addr = address emailStore.Addr = address
app.storage.emails[id] = emailStore app.storage.SetEmailsKey(id, emailStore)
if ombiEnabled { if ombiEnabled {
ombiUser, code, err := app.getOmbiUser(id) ombiUser, code, err := app.getOmbiUser(id)
if code == 200 && err == nil { if code == 200 && err == nil {

13
api.go
View File

@ -143,6 +143,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
respondBool(status, false, gc) respondBool(status, false, gc)
return return
} }
delete(app.internalPWRs, req.PIN)
} else { } else {
resp, status, err := app.jf.ResetPassword(req.PIN) resp, status, err := app.jf.ResetPassword(req.PIN)
if status != 200 || err != nil || !resp.Success { if status != 200 || err != nil || !resp.Success {
@ -214,7 +215,7 @@ func (app *appContext) GetConfig(gc *gin.Context) {
app.info.Println("Config requested") app.info.Println("Config requested")
resp := app.configBase resp := app.configBase
// Load language options // Load language options
formOptions := app.storage.lang.Form.getOptions() formOptions := app.storage.lang.User.getOptions()
fl := resp.Sections["ui"].Settings["language-form"] fl := resp.Sections["ui"].Settings["language-form"]
fl.Options = formOptions fl.Options = formOptions
fl.Value = app.config.Section("ui").Key("language-form").MustString("en-us") fl.Value = app.config.Section("ui").Key("language-form").MustString("en-us")
@ -268,7 +269,7 @@ func (app *appContext) GetConfig(gc *gin.Context) {
val := app.config.Section(sectName).Key(settingName) val := app.config.Section(sectName).Key(settingName)
s := resp.Sections[sectName].Settings[settingName] s := resp.Sections[sectName].Settings[settingName]
switch setting.Type { switch setting.Type {
case "text", "email", "select", "password": case "text", "email", "select", "password", "note":
s.Value = val.MustString("") s.Value = val.MustString("")
case "number": case "number":
s.Value = val.MustInt(0) s.Value = val.MustInt(0)
@ -452,8 +453,8 @@ func (app *appContext) GetLanguages(gc *gin.Context) {
page := gc.Param("page") page := gc.Param("page")
resp := langDTO{} resp := langDTO{}
switch page { switch page {
case "form": case "form", "user":
for key, lang := range app.storage.lang.Form { for key, lang := range app.storage.lang.User {
resp[key] = lang.Meta.Name resp[key] = lang.Meta.Name
} }
case "admin": case "admin":
@ -494,8 +495,8 @@ func (app *appContext) ServeLang(gc *gin.Context) {
if page == "admin" { if page == "admin" {
gc.JSON(200, app.storage.lang.Admin[lang]) gc.JSON(200, app.storage.lang.Admin[lang])
return return
} else if page == "form" { } else if page == "form" || page == "user" {
gc.JSON(200, app.storage.lang.Form[lang]) gc.JSON(200, app.storage.lang.User[lang])
return return
} }
respondBool(400, false, gc) respondBool(400, false, gc)

159
auth.go
View File

@ -9,21 +9,28 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
"github.com/hrfee/mediabrowser"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
) )
const (
TOKEN_VALIDITY_SEC = 20 * 60
REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24
)
func (app *appContext) webAuth() gin.HandlerFunc { func (app *appContext) webAuth() gin.HandlerFunc {
return app.authenticate return app.authenticate
} }
// CreateToken returns a web token as well as a refresh token, which can be used to obtain new tokens. // CreateToken returns a web token as well as a refresh token, which can be used to obtain new tokens.
func CreateToken(userId, jfId string) (string, string, error) { func CreateToken(userId, jfId string, admin bool) (string, string, error) {
var token, refresh string var token, refresh string
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"valid": true, "valid": true,
"id": userId, "id": userId,
"exp": time.Now().Add(time.Minute * 20).Unix(), "exp": time.Now().Add(time.Second * TOKEN_VALIDITY_SEC).Unix(),
"jfid": jfId, "jfid": jfId,
"admin": admin,
"type": "bearer", "type": "bearer",
} }
tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@ -31,7 +38,7 @@ func CreateToken(userId, jfId string) (string, string, error) {
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
claims["exp"] = time.Now().Add(time.Hour * 24).Unix() claims["exp"] = time.Now().Add(time.Second * REFRESH_TOKEN_VALIDITY_SEC).Unix()
claims["type"] = "refresh" claims["type"] = "refresh"
tk = jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tk = jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
refresh, err = tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) refresh, err = tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
@ -41,8 +48,9 @@ func CreateToken(userId, jfId string) (string, string, error) {
return token, refresh, nil return token, refresh, nil
} }
// Check header for token // Caller should return if this returns false.
func (app *appContext) authenticate(gc *gin.Context) { func (app *appContext) decodeValidateAuthHeader(gc *gin.Context) (claims jwt.MapClaims, ok bool) {
ok = false
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2) header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
if header[0] != "Bearer" { if header[0] != "Bearer" {
app.debug.Println("Invalid authorization header") app.debug.Println("Invalid authorization header")
@ -55,23 +63,48 @@ func (app *appContext) authenticate(gc *gin.Context) {
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
claims, ok := token.Claims.(jwt.MapClaims) claims, ok = token.Claims.(jwt.MapClaims)
if !ok {
app.debug.Println("Invalid JWT")
respond(401, "Unauthorized", gc)
return
}
expiryUnix := int64(claims["exp"].(float64)) expiryUnix := int64(claims["exp"].(float64))
if err != nil { if err != nil {
app.debug.Printf("Auth denied: %s", err) app.debug.Printf("Auth denied: %s", err)
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
ok = false
return return
} }
expiry := time.Unix(expiryUnix, 0) expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "bearer" && expiry.After(time.Now())) { if !(ok && token.Valid && claims["type"].(string) == "bearer" && expiry.After(time.Now())) {
app.debug.Printf("Auth denied: Invalid token") app.debug.Printf("Auth denied: Invalid token")
// app.debug.Printf("Expiry: %+v, OK: %t, Valid: %t, ClaimType: %s\n", expiry, ok, token.Valid, claims["type"].(string))
respond(401, "Unauthorized", gc)
ok = false
return
}
ok = true
return
}
// Check header for token
func (app *appContext) authenticate(gc *gin.Context) {
claims, ok := app.decodeValidateAuthHeader(gc)
if !ok {
return
}
isAdminToken := claims["admin"].(bool)
if !isAdminToken {
app.debug.Printf("Auth denied: Token was not for admin access")
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
userID := claims["id"].(string) userID := claims["id"].(string)
jfID := claims["jfid"].(string) jfID := claims["jfid"].(string)
match := false match := false
for _, user := range app.users { for _, user := range app.adminUsers {
if user.UserID == userID { if user.UserID == userID {
match = true match = true
break break
@ -84,6 +117,7 @@ func (app *appContext) authenticate(gc *gin.Context) {
} }
gc.Set("jfId", jfID) gc.Set("jfId", jfID)
gc.Set("userId", userID) gc.Set("userId", userID)
gc.Set("userMode", false)
app.debug.Println("Auth succeeded") app.debug.Println("Auth succeeded")
gc.Next() gc.Next()
} }
@ -99,6 +133,44 @@ type getTokenDTO struct {
Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else. Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else.
} }
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, password string, ok bool) {
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
auth, _ := base64.StdEncoding.DecodeString(header[1])
creds := strings.SplitN(string(auth), ":", 2)
username = creds[0]
password = creds[1]
ok = false
if username == "" || password == "" {
app.debug.Println("Auth denied: blank username/password")
respond(401, "Unauthorized", gc)
return
}
ok = true
return
}
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context) (user mediabrowser.User, ok bool) {
ok = false
user, status, err := app.authJf.Authenticate(username, password)
if status != 200 || err != nil {
if status == 401 || status == 400 {
app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
respond(401, "Unauthorized", gc)
return
}
if status == 403 {
app.info.Println("Auth denied: Jellyfin account disabled")
respond(403, "yourAccountWasDisabled", gc)
return
}
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err)
respond(500, "Jellyfin error", gc)
return
}
ok = true
return
}
// @Summary Grabs an API token using username & password. // @Summary Grabs an API token using username & password.
// @description If viewing docs locally, click the lock icon next to this, login with your normal jfa-go credentials. Click 'try it out', then 'execute' and an API Key will be returned, copy it (not including quotes). On any of the other routes, click the lock icon and set the API key as "Bearer `your api key`". // @description If viewing docs locally, click the lock icon next to this, login with your normal jfa-go credentials. Click 'try it out', then 'execute' and an API Key will be returned, copy it (not including quotes). On any of the other routes, click the lock icon and set the API key as "Bearer `your api key`".
// @Produce json // @Produce json
@ -109,18 +181,14 @@ type getTokenDTO struct {
// @Security getTokenAuth // @Security getTokenAuth
func (app *appContext) getTokenLogin(gc *gin.Context) { func (app *appContext) getTokenLogin(gc *gin.Context) {
app.info.Println("Token requested (login attempt)") app.info.Println("Token requested (login attempt)")
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2) username, password, ok := app.decodeValidateLoginHeader(gc)
auth, _ := base64.StdEncoding.DecodeString(header[1]) if !ok {
creds := strings.SplitN(string(auth), ":", 2)
var userID, jfID string
if creds[0] == "" || creds[1] == "" {
app.debug.Println("Auth denied: blank username/password")
respond(401, "Unauthorized", gc)
return return
} }
var userID, jfID string
match := false match := false
for _, user := range app.users { for _, user := range app.adminUsers {
if user.Username == creds[0] && user.Password == creds[1] { if user.Username == username && user.Password == password {
match = true match = true
app.debug.Println("Found existing user") app.debug.Println("Found existing user")
userID = user.UserID userID = user.UserID
@ -133,27 +201,20 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
return return
} }
if !match { if !match {
user, status, err := app.authJf.Authenticate(creds[0], creds[1]) user, ok := app.validateJellyfinCredentials(username, password, gc)
if status != 200 || err != nil { if !ok {
if status == 401 || status == 400 {
app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
respond(401, "Unauthorized", gc)
return
}
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin (%d/%s)", status, err)
respond(500, "Jellyfin error", gc)
return return
} }
jfID = user.ID jfID = user.ID
if !app.config.Section("ui").Key("allow_all").MustBool(false) { if !app.config.Section("ui").Key("allow_all").MustBool(false) {
accountsAdmin := false accountsAdmin := false
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true) adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
if emailStore, ok := app.storage.emails[jfID]; ok { if emailStore, ok := app.storage.GetEmailsKey(jfID); ok {
accountsAdmin = emailStore.Admin accountsAdmin = emailStore.Admin
} }
accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator) accountsAdmin = accountsAdmin || (adminOnly && user.Policy.IsAdministrator)
if !accountsAdmin { if !accountsAdmin {
app.debug.Printf("Auth denied: Users \"%s\" isn't admin", creds[0]) app.debug.Printf("Auth denied: Users \"%s\" isn't admin", username)
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
@ -163,10 +224,10 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
newUser := User{ newUser := User{
UserID: userID, UserID: userID,
} }
app.debug.Printf("Token generated for user \"%s\"", creds[0]) app.debug.Printf("Token generated for user \"%s\"", username)
app.users = append(app.users, newUser) app.adminUsers = append(app.adminUsers, newUser)
} }
token, refresh, err := CreateToken(userID, jfID) token, refresh, err := CreateToken(userID, jfID, true)
if err != nil { if err != nil {
app.err.Printf("getToken failed: Couldn't generate token (%s)", err) app.err.Printf("getToken failed: Couldn't generate token (%s)", err)
respond(500, "Couldn't generate token", gc) respond(500, "Couldn't generate token", gc)
@ -176,15 +237,9 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
gc.JSON(200, getTokenDTO{token}) gc.JSON(200, getTokenDTO{token})
} }
// @Summary Grabs an API token using a refresh token from cookies. func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName string) (claims jwt.MapClaims, ok bool) {
// @Produce json ok = false
// @Success 200 {object} getTokenDTO cookie, err := gc.Cookie(cookieName)
// @Failure 401 {object} stringResponse
// @Router /token/refresh [get]
// @tags Auth
func (app *appContext) getTokenRefresh(gc *gin.Context) {
app.debug.Println("Token requested (refresh token)")
cookie, err := gc.Cookie("refresh")
if err != nil || cookie == "" { if err != nil || cookie == "" {
app.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", err) app.debug.Printf("getTokenRefresh denied: Couldn't get token: %s", err)
respond(400, "Couldn't get token", gc) respond(400, "Couldn't get token", gc)
@ -203,27 +258,45 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
respond(400, "Invalid token", gc) respond(400, "Invalid token", gc)
return return
} }
claims, ok := token.Claims.(jwt.MapClaims) claims, ok = token.Claims.(jwt.MapClaims)
expiryUnix := int64(claims["exp"].(float64)) expiryUnix := int64(claims["exp"].(float64))
if err != nil { if err != nil {
app.debug.Printf("getTokenRefresh: Invalid token expiry: %s", err) app.debug.Printf("getTokenRefresh: Invalid token expiry: %s", err)
respond(401, "Invalid token", gc) respond(401, "Invalid token", gc)
ok = false
return return
} }
expiry := time.Unix(expiryUnix, 0) expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) { if !(ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now())) {
app.debug.Printf("getTokenRefresh: Invalid token: %s", err) app.debug.Printf("getTokenRefresh: Invalid token: %+v", err)
respond(401, "Invalid token", gc) respond(401, "Invalid token", gc)
ok = false
return
}
ok = true
return
}
// @Summary Grabs an API token using a refresh token from cookies.
// @Produce json
// @Success 200 {object} getTokenDTO
// @Failure 401 {object} stringResponse
// @Router /token/refresh [get]
// @tags Auth
func (app *appContext) getTokenRefresh(gc *gin.Context) {
app.debug.Println("Token requested (refresh token)")
claims, ok := app.decodeValidateRefreshCookie(gc, "refresh")
if !ok {
return return
} }
userID := claims["id"].(string) userID := claims["id"].(string)
jfID := claims["jfid"].(string) jfID := claims["jfid"].(string)
jwt, refresh, err := CreateToken(userID, jfID) jwt, refresh, err := CreateToken(userID, jfID, true)
if err != nil { if err != nil {
app.err.Printf("getTokenRefresh failed: Couldn't generate token (%s)", err) app.err.Printf("getTokenRefresh failed: Couldn't generate token (%s)", err)
respond(500, "Couldn't generate token", gc) respond(500, "Couldn't generate token", gc)
return return
} }
gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true) gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", gc.Request.URL.Hostname(), true, true)
gc.JSON(200, getTokenDTO{jwt}) gc.JSON(200, getTokenDTO{jwt})
} }

View File

@ -46,7 +46,7 @@ func (app *appContext) loadConfig() error {
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json")))) key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
} }
} }
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements"} { for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements", "custom_user_page_content"} {
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json")))) app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
} }
for _, key := range []string{"matrix_sql"} { for _, key := range []string{"matrix_sql"} {
@ -160,6 +160,12 @@ func (app *appContext) loadConfig() error {
app.storage.customEmails_path = app.config.Section("files").Key("custom_emails").String() app.storage.customEmails_path = app.config.Section("files").Key("custom_emails").String()
app.storage.loadCustomEmails() app.storage.loadCustomEmails()
app.MustSetValue("user_page", "enabled", "true")
if app.config.Section("user_page").Key("enabled").MustBool(false) {
app.storage.userPage_path = app.config.Section("files").Key("custom_user_page_content").String()
app.storage.loadUserPageContent()
}
substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("") substituteStrings = app.config.Section("jellyfin").Key("substitute_jellyfin_strings").MustString("")
if substituteStrings != "" { if substituteStrings != "" {
@ -169,11 +175,11 @@ func (app *appContext) loadConfig() error {
oldFormLang := app.config.Section("ui").Key("language").MustString("") oldFormLang := app.config.Section("ui").Key("language").MustString("")
if oldFormLang != "" { if oldFormLang != "" {
app.storage.lang.chosenFormLang = oldFormLang app.storage.lang.chosenUserLang = oldFormLang
} }
newFormLang := app.config.Section("ui").Key("language-form").MustString("") newFormLang := app.config.Section("ui").Key("language-form").MustString("")
if newFormLang != "" { if newFormLang != "" {
app.storage.lang.chosenFormLang = newFormLang app.storage.lang.chosenUserLang = newFormLang
} }
app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us") app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us")
app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us") app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us")

View File

@ -373,6 +373,40 @@
} }
} }
}, },
"user_page": {
"order": [],
"meta": {
"name": "User Page",
"description": "The User Page (My Account) allows users to access and modify info directly, such as changing/adding contact methods, seeing their expiry date, or changing their password. Password resets can also be initiated from here, given a contact method or username. ",
"depends_true": "ui|jellyfin_login"
},
"settings": {
"enabled": {
"name": "Enabled",
"required": false,
"requires_restart": false,
"type": "bool",
"value": true
},
"jellyfin_login_note": {
"name": "Note:",
"type": "note",
"value": "",
"depends_true": "enabled",
"required": "false",
"description": "Jellyfin Login must be enabled to use this feature, and password resets with a link must be enabled for self-service.",
"style": "critical"
},
"edit_note": {
"name": "Message Cards:",
"type": "note",
"value": "",
"depends_true": "enabled",
"required": "false",
"description": "Click the edit icon next to the \"User Page\" Setting to add custom Markdown messages that will be shown to the user. Note message cards are not private, little effort is required for anyone to view them."
}
}
},
"password_validation": { "password_validation": {
"order": [], "order": [],
"meta": { "meta": {
@ -465,6 +499,14 @@
"type": "text", "type": "text",
"value": "Need help? contact me.", "value": "Need help? contact me.",
"description": "Message displayed at bottom of emails." "description": "Message displayed at bottom of emails."
},
"edit_note": {
"name": "Customize Messages:",
"type": "note",
"value": "",
"depends_true": "enabled",
"required": "false",
"description": "Click the edit icon next to the \"Messages/Notifications\" Setting to customize the messages sent to users with Markdown."
} }
} }
}, },
@ -1534,6 +1576,14 @@
"value": "", "value": "",
"description": "JSON file generated by program in settings, different from email_html/email_text. See wiki for more info." "description": "JSON file generated by program in settings, different from email_html/email_text. See wiki for more info."
}, },
"custom_user_page_content": {
"name": "Custom user page content",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "JSON file generated by program in settings, containing user page messages. See wiki for more info."
},
"telegram_users": { "telegram_users": {
"name": "Telegram users", "name": "Telegram users",
"required": false, "required": false,

View File

@ -13,6 +13,8 @@
--border-width-2: 3px; --border-width-2: 3px;
--border-width-4: 5px; --border-width-4: 5px;
--border-width-8: 8px; --border-width-8: 8px;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
} }
.light { .light {
@ -485,6 +487,15 @@ a:hover:not(.lang-link):not(.\~urge), a:active:not(.lang-link):not(.\~urge) {
color: var(--color-urge-200); color: var(--color-urge-200);
} }
a.button,
a.button:link,
a.button:visited,
a.button:focus,
a.buton:hover {
color: var(--color-content) !important;
}
.link-center { .link-center {
display: block; display: block;
text-align: center; text-align: center;

View File

@ -13,14 +13,16 @@ func (app *appContext) clearEmails() {
return return
} }
// Rebuild email storage to from existing users to reduce time complexity // Rebuild email storage to from existing users to reduce time complexity
emails := map[string]EmailAddress{} emails := emailStore{}
app.storage.emailsLock.Lock()
for _, user := range users { for _, user := range users {
if email, ok := app.storage.emails[user.ID]; ok { if email, ok := app.storage.GetEmailsKey(user.ID); ok {
emails[user.ID] = email emails[user.ID] = email
} }
} }
app.storage.emails = emails app.storage.emails = emails
app.storage.storeEmails() app.storage.storeEmails()
app.storage.emailsLock.Unlock()
} }
// clearDiscord does the same as clearEmails, but for Discord Users. // clearDiscord does the same as clearEmails, but for Discord Users.
@ -32,14 +34,16 @@ func (app *appContext) clearDiscord() {
return return
} }
// Rebuild discord storage to from existing users to reduce time complexity // Rebuild discord storage to from existing users to reduce time complexity
dcUsers := map[string]DiscordUser{} dcUsers := discordStore{}
app.storage.discordLock.Lock()
for _, user := range users { for _, user := range users {
if dcUser, ok := app.storage.discord[user.ID]; ok { if dcUser, ok := app.storage.GetDiscordKey(user.ID); ok {
dcUsers[user.ID] = dcUser dcUsers[user.ID] = dcUser
} }
} }
app.storage.discord = dcUsers app.storage.discord = dcUsers
app.storage.storeDiscordUsers() app.storage.storeDiscordUsers()
app.storage.discordLock.Unlock()
} }
// clearMatrix does the same as clearEmails, but for Matrix Users. // clearMatrix does the same as clearEmails, but for Matrix Users.
@ -51,14 +55,16 @@ func (app *appContext) clearMatrix() {
return return
} }
// Rebuild matrix storage to from existing users to reduce time complexity // Rebuild matrix storage to from existing users to reduce time complexity
mxUsers := map[string]MatrixUser{} mxUsers := matrixStore{}
app.storage.matrixLock.Lock()
for _, user := range users { for _, user := range users {
if mxUser, ok := app.storage.matrix[user.ID]; ok { if mxUser, ok := app.storage.GetMatrixKey(user.ID); ok {
mxUsers[user.ID] = mxUser mxUsers[user.ID] = mxUser
} }
} }
app.storage.matrix = mxUsers app.storage.matrix = mxUsers
app.storage.storeMatrixUsers() app.storage.storeMatrixUsers()
app.storage.matrixLock.Unlock()
} }
// clearTelegram does the same as clearEmails, but for Telegram Users. // clearTelegram does the same as clearEmails, but for Telegram Users.
@ -70,14 +76,16 @@ func (app *appContext) clearTelegram() {
return return
} }
// Rebuild telegram storage to from existing users to reduce time complexity // Rebuild telegram storage to from existing users to reduce time complexity
tgUsers := map[string]TelegramUser{} tgUsers := telegramStore{}
app.storage.telegramLock.Lock()
for _, user := range users { for _, user := range users {
if tgUser, ok := app.storage.telegram[user.ID]; ok { if tgUser, ok := app.storage.GetTelegramKey(user.ID); ok {
tgUsers[user.ID] = tgUser tgUsers[user.ID] = tgUser
} }
} }
app.storage.telegram = tgUsers app.storage.telegram = tgUsers
app.storage.storeTelegramUsers() app.storage.storeTelegramUsers()
app.storage.telegramLock.Unlock()
} }
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS // https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS

View File

@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
dg "github.com/bwmarrin/discordgo" dg "github.com/bwmarrin/discordgo"
) )
@ -12,8 +13,8 @@ type DiscordDaemon struct {
ShutdownChannel chan string ShutdownChannel chan string
bot *dg.Session bot *dg.Session
username string username string
tokens []string tokens map[string]VerifToken // Map of pins to tokens.
verifiedTokens map[string]DiscordUser // Map of tokens to discord users. verifiedTokens map[string]DiscordUser // Map of token pins to discord users.
channelID, channelName, inviteChannelID, inviteChannelName string channelID, channelName, inviteChannelID, inviteChannelName string
guildID string guildID string
serverChannelName, serverName string serverChannelName, serverName string
@ -37,7 +38,7 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
Stopped: false, Stopped: false,
ShutdownChannel: make(chan string), ShutdownChannel: make(chan string),
bot: bot, bot: bot,
tokens: []string{}, tokens: map[string]VerifToken{},
verifiedTokens: map[string]DiscordUser{}, verifiedTokens: map[string]DiscordUser{},
users: map[string]DiscordUser{}, users: map[string]DiscordUser{},
app: app, app: app,
@ -48,7 +49,7 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
dd.commandHandlers[app.config.Section("discord").Key("start_command").MustString("start")] = dd.cmdStart dd.commandHandlers[app.config.Section("discord").Key("start_command").MustString("start")] = dd.cmdStart
dd.commandHandlers["lang"] = dd.cmdLang dd.commandHandlers["lang"] = dd.cmdLang
dd.commandHandlers["pin"] = dd.cmdPIN dd.commandHandlers["pin"] = dd.cmdPIN
for _, user := range app.storage.discord { for _, user := range app.storage.GetDiscord() {
dd.users[user.ID] = user dd.users[user.ID] = user
} }
@ -58,7 +59,15 @@ func newDiscordDaemon(app *appContext) (*DiscordDaemon, error) {
// NewAuthToken generates an 8-character pin in the form "A1-2B-CD". // NewAuthToken generates an 8-character pin in the form "A1-2B-CD".
func (d *DiscordDaemon) NewAuthToken() string { func (d *DiscordDaemon) NewAuthToken() string {
pin := genAuthToken() pin := genAuthToken()
d.tokens = append(d.tokens, pin) d.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: ""}
return pin
}
// NewAssignedAuthToken generates an 8-character pin in the form "A1-2B-CD",
// and assigns it for access only with the given Jellyfin ID.
func (d *DiscordDaemon) NewAssignedAuthToken(id string) string {
pin := genAuthToken()
d.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: id}
return pin return pin
} }
@ -210,7 +219,9 @@ func (d *DiscordDaemon) NewTempInvite(ageSeconds, maxUses int) (inviteURL, iconU
d.app.err.Printf("Discord: Failed to get guild: %v", err) d.app.err.Printf("Discord: Failed to get guild: %v", err)
return return
} }
// FIXME: Fix CSS, and handle no icon
iconURL = guild.IconURL("256") iconURL = guild.IconURL("256")
fmt.Println("GOT ICON", iconURL)
return return
} }
@ -429,14 +440,8 @@ func (d *DiscordDaemon) cmdStart(s *dg.Session, i *dg.InteractionCreate, lang st
func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang string) { func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang string) {
pin := i.ApplicationCommandData().Options[0].StringValue() pin := i.ApplicationCommandData().Options[0].StringValue()
tokenIndex := -1 user, ok := d.tokens[pin]
for i, token := range d.tokens { if !ok || time.Now().After(user.Expiry) {
if pin == token {
tokenIndex = i
break
}
}
if tokenIndex == -1 {
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{ err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
// Type: dg.InteractionResponseChannelMessageWithSource, // Type: dg.InteractionResponseChannelMessageWithSource,
Type: dg.InteractionResponseChannelMessageWithSource, Type: dg.InteractionResponseChannelMessageWithSource,
@ -448,6 +453,7 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err) d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err)
} }
delete(d.tokens, pin)
return return
} }
err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{ err := s.InteractionRespond(i.Interaction, &dg.InteractionResponse{
@ -461,23 +467,21 @@ func (d *DiscordDaemon) cmdPIN(s *dg.Session, i *dg.InteractionCreate, lang stri
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err) d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", i.Interaction.Member.User.Username, err)
} }
d.verifiedTokens[pin] = d.users[i.Interaction.Member.User.ID] dcUser := d.users[i.Interaction.Member.User.ID]
d.tokens[len(d.tokens)-1], d.tokens[tokenIndex] = d.tokens[tokenIndex], d.tokens[len(d.tokens)-1] dcUser.JellyfinID = user.JellyfinID
d.tokens = d.tokens[:len(d.tokens)-1] d.verifiedTokens[pin] = dcUser
delete(d.tokens, pin)
} }
func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang string) { func (d *DiscordDaemon) cmdLang(s *dg.Session, i *dg.InteractionCreate, lang string) {
code := i.ApplicationCommandData().Options[0].StringValue() code := i.ApplicationCommandData().Options[0].StringValue()
if _, ok := d.app.storage.lang.Telegram[code]; ok { if _, ok := d.app.storage.lang.Telegram[code]; ok {
var user DiscordUser var user DiscordUser
for jfID, u := range d.app.storage.discord { for jfID, u := range d.app.storage.GetDiscord() {
if u.ID == i.Interaction.Member.User.ID { if u.ID == i.Interaction.Member.User.ID {
u.Lang = code u.Lang = code
lang = code lang = code
d.app.storage.discord[jfID] = u d.app.storage.SetDiscordKey(jfID, u)
if err := d.app.storage.storeDiscordUsers(); err != nil {
d.app.err.Printf("Failed to store Discord users: %v", err)
}
user = u user = u
break break
} }
@ -580,13 +584,10 @@ func (d *DiscordDaemon) msgLang(s *dg.Session, m *dg.MessageCreate, sects []stri
} }
if _, ok := d.app.storage.lang.Telegram[sects[1]]; ok { if _, ok := d.app.storage.lang.Telegram[sects[1]]; ok {
var user DiscordUser var user DiscordUser
for jfID, u := range d.app.storage.discord { for jfID, u := range d.app.storage.GetDiscord() {
if u.ID == m.Author.ID { if u.ID == m.Author.ID {
u.Lang = sects[1] u.Lang = sects[1]
d.app.storage.discord[jfID] = u d.app.storage.SetDiscordKey(jfID, u)
if err := d.app.storage.storeDiscordUsers(); err != nil {
d.app.err.Printf("Failed to store Discord users: %v", err)
}
user = u user = u
break break
} }
@ -610,14 +611,8 @@ func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []strin
d.app.debug.Println("Discord: Ignoring message as user was not found") d.app.debug.Println("Discord: Ignoring message as user was not found")
return return
} }
tokenIndex := -1 user, ok := d.tokens[sects[0]]
for i, token := range d.tokens { if !ok || time.Now().After(user.Expiry) {
if sects[0] == token {
tokenIndex = i
break
}
}
if tokenIndex == -1 {
_, err := s.ChannelMessageSend( _, err := s.ChannelMessageSend(
m.ChannelID, m.ChannelID,
d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"), d.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"),
@ -625,6 +620,7 @@ func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []strin
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err) d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
} }
delete(d.tokens, sects[0])
return return
} }
_, err := s.ChannelMessageSend( _, err := s.ChannelMessageSend(
@ -634,9 +630,10 @@ func (d *DiscordDaemon) msgPIN(s *dg.Session, m *dg.MessageCreate, sects []strin
if err != nil { if err != nil {
d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err) d.app.err.Printf("Discord: Failed to send message to \"%s\": %v", m.Author.Username, err)
} }
d.verifiedTokens[sects[0]] = d.users[m.Author.ID] dcUser := d.users[m.Author.ID]
d.tokens[len(d.tokens)-1], d.tokens[tokenIndex] = d.tokens[tokenIndex], d.tokens[len(d.tokens)-1] dcUser.JellyfinID = user.JellyfinID
d.tokens = d.tokens[:len(d.tokens)-1] d.verifiedTokens[sects[0]] = dcUser
delete(d.tokens, sects[0])
} }
func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error { func (d *DiscordDaemon) SendDM(message *Message, userID ...string) error {
@ -690,3 +687,38 @@ func (d *DiscordDaemon) Send(message *Message, channelID ...string) error {
} }
return nil return nil
} }
// UserVerified returns whether or not a token with the given PIN has been verified, and the user itself.
func (d *DiscordDaemon) UserVerified(pin string) (user DiscordUser, ok bool) {
user, ok = d.verifiedTokens[pin]
// delete(d.verifiedTokens, pin)
return
}
// AssignedUserVerified returns whether or not a user with the given PIN has been verified, and the token itself.
// Returns false if the given Jellyfin ID does not match the one in the user.
func (d *DiscordDaemon) AssignedUserVerified(pin string, jfID string) (user DiscordUser, ok bool) {
user, ok = d.verifiedTokens[pin]
if ok && user.JellyfinID != jfID {
ok = false
}
// delete(d.verifiedUsers, pin)
return
}
// UserExists returns whether or not a user with the given ID exists.
func (d *DiscordDaemon) UserExists(id string) (ok bool) {
ok = false
for _, u := range d.app.storage.GetDiscord() {
if u.ID == id {
ok = true
break
}
}
return
}
// DeleteVerifiedUser removes the token with the given PIN.
func (d *DiscordDaemon) DeleteVerifiedUser(pin string) {
delete(d.verifiedTokens, pin)
}

109
email.go
View File

@ -10,6 +10,7 @@ import (
"html/template" "html/template"
"io" "io"
"io/fs" "io/fs"
"net/url"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@ -23,7 +24,7 @@ import (
sMail "github.com/xhit/go-simple-mail/v2" sMail "github.com/xhit/go-simple-mail/v2"
) )
var renderer = html.NewRenderer(html.RendererOptions{Flags: html.Smartypants}) var markdownRenderer = html.NewRenderer(html.RendererOptions{Flags: html.Smartypants})
// EmailClient implements email sending, right now via smtp, mailgun or a dummy client. // EmailClient implements email sending, right now via smtp, mailgun or a dummy client.
type EmailClient interface { type EmailClient interface {
@ -304,10 +305,17 @@ func (emailer *Emailer) confirmationValues(code, username, key string, app *appC
} else { } else {
message := app.config.Section("messages").Key("message").String() message := app.config.Section("messages").Key("message").String()
inviteLink := app.config.Section("invite_emails").Key("url_base").String() inviteLink := app.config.Section("invite_emails").Key("url_base").String()
if code == "" { // Personal email change
if strings.HasSuffix(inviteLink, "/invite") {
inviteLink = strings.TrimSuffix(inviteLink, "/invite")
}
inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key))
} else { // Invite email confirmation
if !strings.HasSuffix(inviteLink, "/invite") { if !strings.HasSuffix(inviteLink, "/invite") {
inviteLink += "/invite" inviteLink += "/invite"
} }
inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, key) inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, url.PathEscape(key))
}
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username}) template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username})
template["confirmationURL"] = inviteLink template["confirmationURL"] = inviteLink
template["message"] = message template["message"] = message
@ -345,7 +353,7 @@ func (emailer *Emailer) constructTemplate(subject, md string, app *appContext, u
subject = templateEmail(subject, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]}) subject = templateEmail(subject, []string{"{username}"}, nil, map[string]interface{}{"username": username[0]})
} }
email := &Message{Subject: subject} email := &Message{Subject: subject}
html := markdown.ToHTML([]byte(md), nil, renderer) html := markdown.ToHTML([]byte(md), nil, markdownRenderer)
text := stripMarkdown(md) text := stripMarkdown(md)
message := app.config.Section("messages").Key("message").String() message := app.config.Section("messages").Key("message").String()
var err error var err error
@ -514,18 +522,6 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
return email, nil return email, nil
} }
// GenResetLink generates and returns a password reset link.
func (app *appContext) GenResetLink(pin string) (string, error) {
url := app.config.Section("password_resets").Key("url_base").String()
var pinLink string
if url == "" {
return pinLink, fmt.Errorf("disabled as no URL Base provided. Set in Settings > Password Resets.")
}
// Strip /invite from end of this URL, ik it's ugly.
pinLink = fmt.Sprintf("%s/reset?pin=%s", url, pin)
return pinLink, nil
}
func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} { func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} {
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern) d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
message := app.config.Section("messages").Key("message").String() message := app.config.Section("messages").Key("message").String()
@ -827,49 +823,82 @@ func (emailer *Emailer) send(email *Message, address ...string) error {
return emailer.sender.Send(emailer.fromName, emailer.fromAddr, email, address...) return emailer.sender.Send(emailer.fromName, emailer.fromAddr, email, address...)
} }
func (app *appContext) sendByID(email *Message, ID ...string) error { func (app *appContext) sendByID(email *Message, ID ...string) (err error) {
for _, id := range ID { for _, id := range ID {
var err error if tgChat, ok := app.storage.GetTelegramKey(id); ok && tgChat.Contact && telegramEnabled {
if tgChat, ok := app.storage.telegram[id]; ok && tgChat.Contact && telegramEnabled {
err = app.telegram.Send(email, tgChat.ChatID) err = app.telegram.Send(email, tgChat.ChatID)
if err != nil { // if err != nil {
return err // return err
// }
} }
} if dcChat, ok := app.storage.GetDiscordKey(id); ok && dcChat.Contact && discordEnabled {
if dcChat, ok := app.storage.discord[id]; ok && dcChat.Contact && discordEnabled {
err = app.discord.Send(email, dcChat.ChannelID) err = app.discord.Send(email, dcChat.ChannelID)
if err != nil { // if err != nil {
return err // return err
// }
} }
} if mxChat, ok := app.storage.GetMatrixKey(id); ok && mxChat.Contact && matrixEnabled {
if mxChat, ok := app.storage.matrix[id]; ok && mxChat.Contact && matrixEnabled {
err = app.matrix.Send(email, mxChat) err = app.matrix.Send(email, mxChat)
if err != nil { // if err != nil {
return err // return err
// }
} }
} if address, ok := app.storage.GetEmailsKey(id); ok && address.Contact && emailEnabled {
if address, ok := app.storage.emails[id]; ok && address.Contact && emailEnabled {
err = app.email.send(email, address.Addr) err = app.email.send(email, address.Addr)
if err != nil { // if err != nil {
return err // return err
// }
} }
// if err != nil {
// return err
// }
} }
if err != nil { return
return err
}
}
return nil
} }
func (app *appContext) getAddressOrName(jfID string) string { func (app *appContext) getAddressOrName(jfID string) string {
if dcChat, ok := app.storage.discord[jfID]; ok && dcChat.Contact && discordEnabled { if dcChat, ok := app.storage.GetDiscordKey(jfID); ok && dcChat.Contact && discordEnabled {
return RenderDiscordUsername(dcChat) return RenderDiscordUsername(dcChat)
} }
if tgChat, ok := app.storage.telegram[jfID]; ok && tgChat.Contact && telegramEnabled { if tgChat, ok := app.storage.GetTelegramKey(jfID); ok && tgChat.Contact && telegramEnabled {
return "@" + tgChat.Username return "@" + tgChat.Username
} }
if addr, ok := app.storage.emails[jfID]; ok { if addr, ok := app.storage.GetEmailsKey(jfID); ok {
return addr.Addr return addr.Addr
} }
if mxChat, ok := app.storage.GetMatrixKey(jfID); ok && mxChat.Contact && matrixEnabled {
return mxChat.UserID
}
return ""
}
// 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) string {
user, status, err := app.jf.UserByName(address, false)
if status == 200 && err == nil {
return user.ID
}
for id, email := range app.storage.GetEmails() {
if strings.ToLower(address) == strings.ToLower(email.Addr) {
return id
}
}
for id, dcUser := range app.storage.GetDiscord() {
if RenderDiscordUsername(dcUser) == strings.ToLower(address) {
return id
}
}
tgUsername := strings.TrimPrefix(address, "@")
for id, tgUser := range app.storage.GetTelegram() {
if tgUsername == tgUser.Username {
return id
}
}
for id, mxUser := range app.storage.GetMatrix() {
if address == mxUser.UserID {
return id
}
}
return "" return ""
} }

52
html/account-linking.html Normal file
View File

@ -0,0 +1,52 @@
{{ if .discordEnabled }}
<div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkDiscord }}</span>
<p class="content mb-4"> {{ .discordSendPINMessage }}</p>
<h1 class="text-center text-2xl mb-2 pin"></h1>
<div class="row center">
<a class="my-5 hover:underline">
<span class="mr-2">{{ .strings.joinTheServer }}</span>
<span id="discord-invite"></span>
</a>
</div>
<span class="button ~info @low full-width center mt-4" id="discord-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
{{ if .telegramEnabled }}
<div id="modal-telegram" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p>
<p class="text-center text-2xl mb-2 pin"></p>
<a class="subheading link-center" href="{{ .telegramURL }}" target="_blank">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-telegram-line"></i>
</span>
</span>
&#64;{{ .telegramUsername }}
</a>
<span class="button ~info @low full-width center mt-4" id="telegram-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
{{ if .matrixEnabled }}
<div id="modal-matrix" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkMatrix }}</span>
<p class="content mb-4"> {{ .strings.matrixEnterUser }}</p>
<input type="text" class="input ~neutral @high" placeholder="@user:riot.im" id="matrix-userid">
<div class="subheading link-center mt-4">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-chat-3-line"></i>
</span>
</span>
{{ .matrixUser }}
</div>
<span class="button ~info @low full-width center mt-4" id="matrix-send">{{ .strings.submit }}</span>
</div>
</div>
{{ end }}

View File

@ -22,17 +22,7 @@
{{ template "header.html" . }} {{ template "header.html" . }}
</head> </head>
<body class="max-w-full overflow-x-hidden section"> <body class="max-w-full overflow-x-hidden section">
<div id="modal-login" class="modal"> {{ template "login-modal.html" . }}
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-login" href="">
<span class="heading">{{ .strings.login }}</span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="login-user">
<input type="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="login-password">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.login }}</span>
</label>
</form>
</div>
<div id="modal-add-user" class="modal"> <div id="modal-add-user" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-user" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-user" href="">
<span class="heading">{{ .strings.newUser }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.newUser }} <span class="modal-close">&times;</span></span>
@ -420,6 +410,9 @@
</span> </span>
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span> <span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
</div> </div>
<div class="top-4 right-4 absolute">
<a class="button ~info" href="/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
</div>
<div class="page-container"> <div class="page-container">
<div class="mb-4"> <div class="mb-4">
<header class="flex flex-wrap items-center justify-between"> <header class="flex flex-wrap items-center justify-between">

View File

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}"> <html lang="en" class="{{ .cssClass }}">
<head> <head>
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css"> <link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }} {{ template "header.html" . }}
<title>{{ .strings.successHeader }} - jfa-go</title> <title>{{ .strings.successHeader }} - jfa-go</title>
</head> </head>

View File

@ -29,6 +29,8 @@
window.captcha = {{ .captcha }}; window.captcha = {{ .captcha }};
window.reCAPTCHA = {{ .reCAPTCHA }}; window.reCAPTCHA = {{ .reCAPTCHA }};
window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}"; window.reCAPTCHASiteKey = "{{ .reCAPTCHASiteKey }}";
window.userPageEnabled = {{ .userPageEnabled }};
window.userPageAddress = "{{ .userPageAddress }}";
</script> </script>
{{ if .passwordReset }} {{ if .passwordReset }}
<script src="js/pwr.js" type="module"></script> <script src="js/pwr.js" type="module"></script>

View File

@ -17,6 +17,7 @@
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span> <span class="heading mb-4">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span>
<p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p> <p class="content mb-4">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p>
{{ if .userPageEnabled }}<p class="content mb-4" id="modal-success-user-page-area" my-account-term="{{ .strings.myAccount }}">{{ .strings.userPageSuccessMessage }}</p>{{ end }}
<a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a> <a class="button ~urge @low full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
</div> </div>
</div> </div>
@ -26,53 +27,7 @@
<p class="content mb-4">{{ .strings.confirmationRequiredMessage }}</p> <p class="content mb-4">{{ .strings.confirmationRequiredMessage }}</p>
</div> </div>
</div> </div>
{{ if .telegramEnabled }} {{ template "account-linking.html" . }}
<div id="modal-telegram" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p>
<p class="text-center text-2xl mb-2">{{ .telegramPIN }}</p>
<a class="subheading link-center" href="{{ .telegramURL }}" target="_blank">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-telegram-line"></i>
</span>
</span>
&#64;{{ .telegramUsername }}
</a>
<span class="button ~info @low full-width center mt-4" id="telegram-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
{{ if .discordEnabled }}
<div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkDiscord }}</span>
<p class="content mb-4"> {{ .discordSendPINMessage }}</p>
<h1 class="text-center text-2xl mb-2">{{ .discordPIN }}</h1>
<a id="discord-invite"></a>
<span class="button ~info @low full-width center mt-4" id="discord-waiting">{{ .strings.success }}</span>
</div>
</div>
{{ end }}
{{ if .matrixEnabled }}
<div id="modal-matrix" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkMatrix }}</span>
<p class="content mb-4"> {{ .strings.matrixEnterUser }}</p>
<input type="text" class="input ~neutral @high" placeholder="@user:riot.im" id="matrix-userid">
<div class="subheading link-center mt-4">
<span class="shield ~info mr-4">
<span class="icon">
<i class="ri-chat-3-line"></i>
</span>
</span>
{{ .matrixUser }}
</div>
<span class="button ~info @low full-width center mt-4" id="matrix-send">{{ .strings.submit }}</span>
</div>
</div>
{{ end }}
<div class="top-4 left-4 absolute"> <div class="top-4 left-4 absolute">
<span class="dropdown" tabindex="0" id="lang-dropdown"> <span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button"> <span class="button ~urge dropdown-button">
@ -169,7 +124,7 @@
</div> </div>
<div class="flex-initial"> <div class="flex-initial">
<div class="card ~neutral @low mb-4"> <div class="card ~neutral @low mb-4">
<span class="label supra" for="inv-uses">{{ .strings.passwordRequirementsHeader }}</span> <span class="label supra">{{ .strings.passwordRequirementsHeader }}</span>
<ul> <ul>
{{ range $key, $value := .requirements }} {{ range $key, $value := .requirements }}
<li class="" id="requirement-{{ $key }}" min="{{ $value }}"> <li class="" id="requirement-{{ $key }}" min="{{ $value }}">

View File

@ -1,4 +1,4 @@
<meta charset="utf-8"> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="Description" content="jfa-go, a better way to manage Jellyfin users."> <meta name="Description" content="jfa-go, a better way to manage Jellyfin users.">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">

View File

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}"> <html lang="en" class="{{ .cssClass }}">
<head> <head>
<link rel="stylesheet" type="text/css" href="css/{{ .cssVersion }}bundle.css"> <link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
{{ template "header.html" . }} {{ template "header.html" . }}
<title>Invalid Code - jfa-go</title> <title>Invalid Code - jfa-go</title>
</head> </head>

25
html/login-modal.html Normal file
View File

@ -0,0 +1,25 @@
<div id="modal-login" class="modal">
<div class="my-[10%] row items-stretch relative mx-auto w-[40%] lg:w-[60%]">
{{ if index . "LoginMessageEnabled" }}
{{ if .LoginMessageEnabled }}
<div class="card mx-2 flex-initial w-[100%] xl:w-[35%] mb-4 xl:mb-0 dark:~d_neutral @low content">
{{ .LoginMessageContent }}
</div>
{{ end }}
{{ end }}
<form class="card mx-2 flex-auto form-login w-[100%] xl:w-[55%] mb-0" href="">
<span class="heading">{{ .strings.login }}</span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="login-user">
<input type="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="login-password">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low full-width center supra submit">{{ .strings.login }}</span>
{{ if index . "pwrEnabled" }}
{{ if .pwrEnabled }}
<span class="button ~info @low full-width center supra submit my-2" id="modal-login-pwr">{{ .strings.resetPassword }}</span>
{{ end }}
{{ end }}
</label>
</form>
</div>
</div>

158
html/user.html Normal file
View File

@ -0,0 +1,158 @@
<html lang="en" class="light">
<head>
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">
<script>
window.URLBase = "{{ .urlBase }}";
window.notificationsEnabled = {{ .notifications }};
window.ombiEnabled = {{ .ombiEnabled }};
window.langFile = JSON.parse({{ .language }});
window.pwrEnabled = {{ .pwrEnabled }};
window.linkResetEnabled = {{ .linkResetEnabled }};
window.language = "{{ .langName }}";
window.telegramEnabled = {{ .telegramEnabled }};
window.telegramRequired = {{ .telegramRequired }};
window.telegramUsername = {{ .telegramUsername }};
window.telegramURL = {{ .telegramURL }};
window.emailEnabled = {{ .emailEnabled }};
window.emailRequired = {{ .emailRequired }};
window.discordEnabled = {{ .discordEnabled }};
window.discordRequired = {{ .discordRequired }};
window.discordServerName = "{{ .discordServerName }}";
window.discordInviteLink = {{ .discordInviteLink }};
window.discordSendPINMessage = "{{ .discordSendPINMessage }}";
window.matrixEnabled = {{ .matrixEnabled }};
window.matrixRequired = {{ .matrixRequired }};
window.matrixUserID = "{{ .matrixUser }}";
window.validationStrings = JSON.parse({{ .validationStrings }});
</script>
{{ template "header.html" . }}
<title>{{ .strings.myAccount }}</title>
</head>
<body class="max-w-full overflow-x-hidden section">
<div id="modal-email" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="content">
<span class="heading mb-4 my-2"></span>
<label class="label supra row m-1" for="modal-email-input">{{ .strings.emailAddress }}</label>
<div class="row">
<input type="email" class="col sm field ~neutral @low input" id="modal-email-input" placeholder="{{ .strings.emailAddress }}">
</div>
<button class="button ~urge @low supra full-width center lg my-2 modal-submit">{{ .strings.submit }}</button>
</div>
<div class="confirmation-required unfocused">
<span class="heading mb-4">{{ .strings.confirmationRequired }} <span class="modal-close">&times;</span></span>
<p class="content mb-4">{{ .strings.confirmationRequiredMessage }}</p>
</div>
</div>
</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">
<span class="heading">{{ .strings.resetPassword }}</span>
<p class="content my-2">
{{ if .linkResetEnabled }}
{{ .strings.resetPasswordThroughLink }}
{{ else }}
{{ .strings.resetPasswordThroughJellyfin }}
{{ end }}
</p>
<div class="row">
<input type="text" class="col sm field ~neutral @low input" id="pwr-address" placeholder="username | example@example.com | user#1234 | @user:host | @username">
</div>
{{ if .linkResetEnabled }}
<span class="button ~info @low full-width center mt-4" id="pwr-submit">
{{ .strings.submit }}
</span>
{{ else }}
<a class="button ~info @low full-width center mt-4" href="{{ .jfLink }}" target="_blank">{{ .strings.continue }}</a>
{{ end }}
</div>
</div>
{{ end }}
{{ template "login-modal.html" . }}
{{ template "account-linking.html" . }}
<div id="notification-box"></div>
<div class="top-4 left-4 absolute">
<span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
<i class="ri-global-line"></i>
<span class="ml-2 chev"></span>
</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<label class="switch pb-4">
<input type="radio" name="lang-time" id="lang-12h">
<span>{{ .strings.time12h }}</span>
</label>
<label class="switch pb-4">
<input type="radio" name="lang-time" id="lang-24h">
<span>{{ .strings.time24h }}</span>
</label>
<div id="lang-list"></div>
</div>
</div>
</span>
<span class="button ~warning" alt="{{ .strings.theme }}" id="button-theme"><i class="ri-sun-line"></i></span>
<span class="button ~critical @low mb-4 unfocused" id="logout-button">{{ .strings.logout }}</span>
</div>
<div class="top-4 right-4 absolute">
<a class="button ~info unfocused" href="/" id="admin-back-button"><i class="ri-arrow-left-fill mr-2"></i>{{ .strings.admin }}</a>
</div>
<div class="page-container unfocused">
<div class="card @low dark:~d_neutral mb-4" id="card-user">
<span class="heading mb-2"></span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{{ if index . "PageMessageEnabled" }}
{{ if .PageMessageEnabled }}
<div class="card @low dark:~d_neutral content" id="card-message">
{{ .PageMessageContent }}
</div>
{{ end }}
{{ end }}
<div class="card @low dark:~d_neutral flex-col" id="card-contact">
<span class="heading mb-2">{{ .strings.contactMethods }}</span>
<div class="content flex justify-between flex-col h-100"></div>
</div>
<div>
<div class="card @low dark:~d_neutral content" id="card-password">
<span class="heading row mb-2">{{ .strings.changePassword }}</span>
<div class="">
<div class="my-2">
<span class="label supra row">{{ .strings.passwordRequirementsHeader }}</span>
<ul>
{{ range $key, $value := .requirements }}
<li class="" id="requirement-{{ $key }}" min="{{ $value }}">
<span class="badge lg ~positive requirement-valid"></span> <span class="content requirement-content"></span>
</li>
{{ end }}
</ul>
</div>
<div class="my-2">
<label class="label supra" for="user-old-password">{{ .strings.oldPassword }}</label>
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-old-password" aria-label="{{ .strings.oldPassword }}">
<label class="label supra" for="user-new-password">{{ .strings.newPassword }}</label>
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-new-password" aria-label="{{ .strings.newPassword }}">
<label class="label supra" for="user-reenter-password">{{ .strings.reEnterPassword }}</label>
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-reenter-new-password" aria-label="{{ .strings.reEnterPassword }}">
<span class="button ~info @low full-width center mt-4" id="user-password-submit">
{{ .strings.changePassword }}
</span>
</div>
</div>
</div>
</div>
<div>
<div class="card @low dark:~d_neutral unfocused" id="card-status">
<span class="heading mb-2">{{ .strings.expiry }}</span>
<aside class="aside ~warning user-expiry my-4"></aside>
<div class="user-expiry-countdown"></div>
</div>
</div>
</div>
</div>
<script src="{{ .urlBase }}/js/user.js" type="module"></script>
</body>
</html>

10
lang.go
View File

@ -28,6 +28,8 @@ type commonLangs map[string]commonLang
type commonLang struct { type commonLang struct {
Meta langMeta `json:"meta"` Meta langMeta `json:"meta"`
Strings langSection `json:"strings"` Strings langSection `json:"strings"`
Notifications langSection `json:"notifications"`
QuantityStrings map[string]quantityString `json:"quantityStrings"`
} }
type adminLang struct { type adminLang struct {
@ -38,9 +40,9 @@ type adminLang struct {
JSON string JSON string
} }
type formLangs map[string]formLang type userLangs map[string]userLang
func (ls *formLangs) getOptions() [][2]string { func (ls *userLangs) getOptions() [][2]string {
opts := make([][2]string, len(*ls)) opts := make([][2]string, len(*ls))
i := 0 i := 0
for key, lang := range *ls { for key, lang := range *ls {
@ -50,13 +52,15 @@ func (ls *formLangs) getOptions() [][2]string {
return opts return opts
} }
type formLang struct { type userLang struct {
Meta langMeta `json:"meta"` Meta langMeta `json:"meta"`
Strings langSection `json:"strings"` Strings langSection `json:"strings"`
Notifications langSection `json:"notifications"` Notifications langSection `json:"notifications"`
notificationsJSON string notificationsJSON string
ValidationStrings map[string]quantityString `json:"validationStrings"` ValidationStrings map[string]quantityString `json:"validationStrings"`
validationStringsJSON string validationStringsJSON string
QuantityStrings map[string]quantityString `json:"quantityStrings"`
JSON string
} }
type pwrLangs map[string]pwrLang type pwrLangs map[string]pwrLang

View File

@ -15,20 +15,11 @@
"warning": "Advarsel", "warning": "Advarsel",
"inviteInfiniteUsesWarning": "invitationer med uendelig brug kan blive misbrugt", "inviteInfiniteUsesWarning": "invitationer med uendelig brug kan blive misbrugt",
"inviteSendToEmail": "Send til", "inviteSendToEmail": "Send til",
"login": "Log på",
"logout": "Log ud",
"create": "Opret", "create": "Opret",
"apply": "Anvend", "apply": "Anvend",
"delete": "Slet",
"add": "Tilføj",
"select": "Vælg", "select": "Vælg",
"name": "Navn", "name": "Navn",
"date": "Dato", "date": "Dato",
"enabled": "Aktiveret",
"disabled": "Deaktiveret",
"reEnable": "Genaktiver",
"disable": "Deaktiver",
"admin": "Administrator",
"updates": "Opdateringer", "updates": "Opdateringer",
"update": "Opdatering", "update": "Opdatering",
"download": "Hent", "download": "Hent",
@ -37,7 +28,6 @@
"lastActiveTime": "Sidst Aktiv", "lastActiveTime": "Sidst Aktiv",
"from": "Fra", "from": "Fra",
"user": "Bruger", "user": "Bruger",
"expiry": "Udløb",
"userExpiry": "Brugerens Udløb", "userExpiry": "Brugerens Udløb",
"userExpiryDescription": "En specificeret tid efter hver tilmelding, sletter/deaktiverer jfa-go kontoen. Du kan ændre denne adfærd i indstillingerne.", "userExpiryDescription": "En specificeret tid efter hver tilmelding, sletter/deaktiverer jfa-go kontoen. Du kan ændre denne adfærd i indstillingerne.",
"aboutProgram": "Om", "aboutProgram": "Om",
@ -54,7 +44,6 @@
"conditionals": "Betingelser", "conditionals": "Betingelser",
"preview": "Eksempel", "preview": "Eksempel",
"reset": "Nulstil", "reset": "Nulstil",
"edit": "Rediger",
"donate": "Doner", "donate": "Doner",
"contactThrough": "Kontakt gennem:", "contactThrough": "Kontakt gennem:",
"extendExpiry": "Forlæng udløb", "extendExpiry": "Forlæng udløb",
@ -128,13 +117,9 @@
"updateAppliedRefresh": "Opdatering anvendt, genindlæs venligst siden.", "updateAppliedRefresh": "Opdatering anvendt, genindlæs venligst siden.",
"telegramVerified": "Telegram konto verificeret.", "telegramVerified": "Telegram konto verificeret.",
"accountConnected": "Konto tilsluttet.", "accountConnected": "Konto tilsluttet.",
"errorConnection": "Kunne ikke oprette forbindelse til jfa-go.",
"error401Unauthorized": "Adgang nægtet. Prøv at genindlæse siden.",
"errorSettingsAppliedNoHomescreenLayout": "Indstillingerne blev anvendt, men anvendelse af startskærmens layout mislykkedes muligvis.", "errorSettingsAppliedNoHomescreenLayout": "Indstillingerne blev anvendt, men anvendelse af startskærmens layout mislykkedes muligvis.",
"errorHomescreenAppliedNoSettings": "Startskærmens layout blev anvendt, men anvendelsen af indstillingerne mislykkedes muligvis.", "errorHomescreenAppliedNoSettings": "Startskærmens layout blev anvendt, men anvendelsen af indstillingerne mislykkedes muligvis.",
"errorSettingsFailed": "Ansøgningen mislykkedes.", "errorSettingsFailed": "Ansøgningen mislykkedes.",
"errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.",
"errorUnknown": "Ukendt fejl.",
"errorSaveEmail": "Kunne ikke gemme e-mail.", "errorSaveEmail": "Kunne ikke gemme e-mail.",
"errorBlankFields": "Felter blev efterladt tomme", "errorBlankFields": "Felter blev efterladt tomme",
"errorDeleteProfile": "Kunne ikke slette profilen {n}", "errorDeleteProfile": "Kunne ikke slette profilen {n}",
@ -142,7 +127,6 @@
"errorCreateProfile": "Kunne ikke oprette profilen {n}", "errorCreateProfile": "Kunne ikke oprette profilen {n}",
"errorSetDefaultProfile": "Standard profilen kunne ikke indstilles.", "errorSetDefaultProfile": "Standard profilen kunne ikke indstilles.",
"errorLoadUsers": "Kunne ikke indlæse brugere.", "errorLoadUsers": "Kunne ikke indlæse brugere.",
"errorSaveSettings": "Kunne ikke gemme indstillingerne.",
"errorLoadSettings": "Indstillingerne kunne ikke indlæses.", "errorLoadSettings": "Indstillingerne kunne ikke indlæses.",
"errorSetOmbiDefaults": "Ombi standarderne kunne ikke gemmes.", "errorSetOmbiDefaults": "Ombi standarderne kunne ikke gemmes.",
"errorLoadOmbiUsers": "Kunne ikke indlæse ombi brugere.", "errorLoadOmbiUsers": "Kunne ikke indlæse ombi brugere.",

View File

@ -13,11 +13,8 @@
"warning": "Warnung", "warning": "Warnung",
"inviteInfiniteUsesWarning": "Invites mit unendlich vielen Verwendungen können missbräuchlich verwendet werden", "inviteInfiniteUsesWarning": "Invites mit unendlich vielen Verwendungen können missbräuchlich verwendet werden",
"inviteSendToEmail": "Senden an", "inviteSendToEmail": "Senden an",
"login": "Anmelden",
"logout": "Abmelden",
"create": "Erstellen", "create": "Erstellen",
"apply": "Anwenden", "apply": "Anwenden",
"delete": "Löschen",
"name": "Name", "name": "Name",
"date": "Datum", "date": "Datum",
"lastActiveTime": "Zuletzt aktiv", "lastActiveTime": "Zuletzt aktiv",
@ -68,7 +65,6 @@
"variables": "Variablen", "variables": "Variablen",
"preview": "Vorschau", "preview": "Vorschau",
"reset": "Zurücksetzen", "reset": "Zurücksetzen",
"edit": "Bearbeiten",
"customizeMessages": "Benachrichtigungen anpassen", "customizeMessages": "Benachrichtigungen anpassen",
"customizeMessagesDescription": "Wenn du jfa-go's E-Mail-Vorlagen nicht benutzen willst, kannst du deinen eigenen unter Verwendung von Markdown erstellen.", "customizeMessagesDescription": "Wenn du jfa-go's E-Mail-Vorlagen nicht benutzen willst, kannst du deinen eigenen unter Verwendung von Markdown erstellen.",
"announce": "Ankündigen", "announce": "Ankündigen",
@ -79,23 +75,16 @@
"search": "Suchen", "search": "Suchen",
"userExpiry": "Benutzer Ablaufdatum", "userExpiry": "Benutzer Ablaufdatum",
"inviteDuration": "Invite Dauer", "inviteDuration": "Invite Dauer",
"enabled": "Aktiviert",
"userExpiryDescription": "Eine bestimmte Zeit nach der Anmeldung wird jfa-go das Konto löschen/deaktivieren. Du kannst dieses Verhalten in den Einstellungen ändern.", "userExpiryDescription": "Eine bestimmte Zeit nach der Anmeldung wird jfa-go das Konto löschen/deaktivieren. Du kannst dieses Verhalten in den Einstellungen ändern.",
"disabled": "Deaktiviert",
"admin": "Admin",
"download": "Herunterladen", "download": "Herunterladen",
"update": "Aktualisieren", "update": "Aktualisieren",
"updates": "Aktualisierungen", "updates": "Aktualisierungen",
"expiry": "Ablaufdatum",
"extendExpiry": "Ablaufdatum verlängern", "extendExpiry": "Ablaufdatum verlängern",
"reEnable": "Wieder aktivieren",
"disable": "Deaktivieren",
"donate": "Spenden", "donate": "Spenden",
"conditionals": "Bedingungen", "conditionals": "Bedingungen",
"contactThrough": "Kontakt über:", "contactThrough": "Kontakt über:",
"sendPIN": "Bitte den Benutzer, die unten stehende PIN an den Bot zu senden.", "sendPIN": "Bitte den Benutzer, die unten stehende PIN an den Bot zu senden.",
"inviteMonths": "Monate", "inviteMonths": "Monate",
"add": "Hinzufügen",
"select": "Auswählen", "select": "Auswählen",
"searchDiscordUser": "Gib den Discord-Benutzername ein, um den Benutzer zu finden.", "searchDiscordUser": "Gib den Discord-Benutzername ein, um den Benutzer zu finden.",
"findDiscordUser": "Suche Discord-Benutzer", "findDiscordUser": "Suche Discord-Benutzer",
@ -122,20 +111,15 @@
"createProfile": "Profil {n} erstellt.", "createProfile": "Profil {n} erstellt.",
"saveSettings": "Einstellungen wurden gespeichert", "saveSettings": "Einstellungen wurden gespeichert",
"setOmbiDefaults": "Ombi-Standardeinstellungen gespeichert.", "setOmbiDefaults": "Ombi-Standardeinstellungen gespeichert.",
"errorConnection": "Konnte keine Verbindung zu jfa-go herstellen.",
"error401Unauthorized": "Unberechtigt. Versuch, die Seite zu aktualisieren.",
"errorSettingsAppliedNoHomescreenLayout": "Einstellungen wurden angewendet, aber die Anwendung des Startbildschirmlayouts ist möglicherweise fehlgeschlagen.", "errorSettingsAppliedNoHomescreenLayout": "Einstellungen wurden angewendet, aber die Anwendung des Startbildschirmlayouts ist möglicherweise fehlgeschlagen.",
"errorHomescreenAppliedNoSettings": "Startbildschirmlayout wurde angewendet, aber die Anwendung der Einstellungen ist möglicherweise fehlgeschlagen.", "errorHomescreenAppliedNoSettings": "Startbildschirmlayout wurde angewendet, aber die Anwendung der Einstellungen ist möglicherweise fehlgeschlagen.",
"errorSettingsFailed": "Anwendung ist fehlgeschlagen.", "errorSettingsFailed": "Anwendung ist fehlgeschlagen.",
"errorLoginBlank": "Der Benutzername und/oder das Passwort wurden nicht ausgefüllt.",
"errorUnknown": "Unbekannter Fehler.",
"errorBlankFields": "Felder wurden nicht ausgefüllt", "errorBlankFields": "Felder wurden nicht ausgefüllt",
"errorDeleteProfile": "Fehler beim Löschen des Profils {n}", "errorDeleteProfile": "Fehler beim Löschen des Profils {n}",
"errorLoadProfiles": "Fehler beim Laden der Profile.", "errorLoadProfiles": "Fehler beim Laden der Profile.",
"errorCreateProfile": "Fehler beim Erstellen des Profils {n}", "errorCreateProfile": "Fehler beim Erstellen des Profils {n}",
"errorSetDefaultProfile": "Fehler beim Setzen des Standardprofils.", "errorSetDefaultProfile": "Fehler beim Setzen des Standardprofils.",
"errorLoadUsers": "Fehler beim Laden der Benutzer.", "errorLoadUsers": "Fehler beim Laden der Benutzer.",
"errorSaveSettings": "Einstellungen konnten nicht gespeichert werden.",
"errorLoadSettings": "Fehler beim Laden der Einstellungen.", "errorLoadSettings": "Fehler beim Laden der Einstellungen.",
"errorSetOmbiDefaults": "Fehler beim Speichern der Ombi-Standardeinstellungen.", "errorSetOmbiDefaults": "Fehler beim Speichern der Ombi-Standardeinstellungen.",
"errorLoadOmbiUsers": "Fehler beim Laden der Ombi-Benutzer.", "errorLoadOmbiUsers": "Fehler beim Laden der Ombi-Benutzer.",

View File

@ -13,11 +13,8 @@
"warning": "Προσοχή", "warning": "Προσοχή",
"inviteInfiniteUsesWarning": "μπορεί να γίνει κατάχρηση των προσκλήσεων με άπειρες χρήσεις", "inviteInfiniteUsesWarning": "μπορεί να γίνει κατάχρηση των προσκλήσεων με άπειρες χρήσεις",
"inviteSendToEmail": "Αποστολή σε", "inviteSendToEmail": "Αποστολή σε",
"login": "Σύνδεση",
"logout": "Αποσύνδεση",
"create": "Δημιουργία", "create": "Δημιουργία",
"apply": "Εφαρμογή", "apply": "Εφαρμογή",
"delete": "Διαγραφή",
"name": "Όνομα", "name": "Όνομα",
"date": "Ημερομηνία", "date": "Ημερομηνία",
"lastActiveTime": "Τελευταία Ενεργός", "lastActiveTime": "Τελευταία Ενεργός",
@ -68,7 +65,6 @@
"variables": "Μεταβλητές", "variables": "Μεταβλητές",
"preview": "Προεπισκόπηση", "preview": "Προεπισκόπηση",
"reset": "Επαναφορά", "reset": "Επαναφορά",
"edit": "Επεξεργασία",
"customizeMessages": "Παραμετροποίηση Emails", "customizeMessages": "Παραμετροποίηση Emails",
"advancedSettings": "Προχωρημένες Ρυθμίσεις", "advancedSettings": "Προχωρημένες Ρυθμίσεις",
"customizeMessagesDescription": "Αν δεν θέλετε να ζρησιμοποιήσετε τα πρότυπα email του jfa-go, μπορείτε να δημιουργήσετε τα δικά σας με χρήση Markdown.", "customizeMessagesDescription": "Αν δεν θέλετε να ζρησιμοποιήσετε τα πρότυπα email του jfa-go, μπορείτε να δημιουργήσετε τα δικά σας με χρήση Markdown.",
@ -77,10 +73,6 @@
"download": "Λήψη", "download": "Λήψη",
"search": "Αναζήτηση", "search": "Αναζήτηση",
"inviteDuration": "Διάρκεια Πρόσκλησης", "inviteDuration": "Διάρκεια Πρόσκλησης",
"enabled": "Ενεργοποιημένο",
"disabled": "Απενεργοποιημένο",
"admin": "Διαχειριστής",
"expiry": "Λήξη",
"userExpiry": "Λήξη Χρήστη", "userExpiry": "Λήξη Χρήστη",
"userExpiryDescription": "Μετά απο ένα καθορισμένο χρόνο μετά απο κάθε εγγραφή, το jfa-go θα διαγράφει/απενεργοποιεί τον λογαριασμό. Μπορείτε να αλλάξετε αυτή την συμπεριφορά στις ρυθμίσεις.", "userExpiryDescription": "Μετά απο ένα καθορισμένο χρόνο μετά απο κάθε εγγραφή, το jfa-go θα διαγράφει/απενεργοποιεί τον λογαριασμό. Μπορείτε να αλλάξετε αυτή την συμπεριφορά στις ρυθμίσεις.",
"announce": "Ανακοίνωση", "announce": "Ανακοίνωση",
@ -88,8 +80,6 @@
"message": "Μήνυμα", "message": "Μήνυμα",
"extendExpiry": "Παράταση λήξης", "extendExpiry": "Παράταση λήξης",
"markdownSupported": "Το Markdown υποστυρίζεται.", "markdownSupported": "Το Markdown υποστυρίζεται.",
"reEnable": "Επανα-ενεργοποίηση",
"disable": "Απενεργοποίηση",
"inviteMonths": "Μήνες" "inviteMonths": "Μήνες"
}, },
"notifications": { "notifications": {
@ -98,20 +88,15 @@
"createProfile": "Δημιουργήθηκε το {n} προφίλ.", "createProfile": "Δημιουργήθηκε το {n} προφίλ.",
"saveSettings": "Οι ρυθμίσεις αποθηκεύτηκαν", "saveSettings": "Οι ρυθμίσεις αποθηκεύτηκαν",
"setOmbiDefaults": "Αποθηκεύτηκαν οι προκαθορισμένες ρυθμίσεις του ombi.", "setOmbiDefaults": "Αποθηκεύτηκαν οι προκαθορισμένες ρυθμίσεις του ombi.",
"errorConnection": "Δεν μπόρεσε να συνδεθεί με το jfa-go.",
"error401Unauthorized": "Ανεξουσιοδότητος. Προσπαθήστε να κάνετε επαναφόρτωση την σελίδα.",
"errorSettingsAppliedNoHomescreenLayout": "Οι ρυθμίσεις αποθηκεύτηκαν, αλλά η καταχώρηση δομής αρχικής οθόνης ίσως απέτυχε.", "errorSettingsAppliedNoHomescreenLayout": "Οι ρυθμίσεις αποθηκεύτηκαν, αλλά η καταχώρηση δομής αρχικής οθόνης ίσως απέτυχε.",
"errorHomescreenAppliedNoSettings": "Η δομή αρχικής οθόνης εφαρμόστηκε, αλλά οι ρυθμίσεις ίσως απέτυχαν.", "errorHomescreenAppliedNoSettings": "Η δομή αρχικής οθόνης εφαρμόστηκε, αλλά οι ρυθμίσεις ίσως απέτυχαν.",
"errorSettingsFailed": "Η εφαρμογή απέτυχε.", "errorSettingsFailed": "Η εφαρμογή απέτυχε.",
"errorLoginBlank": "Το όνομα χρήστη και/ή ο κωδικός ήταν κενά.",
"errorUnknown": "Άγνωστο σφάλμα.",
"errorBlankFields": "Τα πεφία ήταν κενά", "errorBlankFields": "Τα πεφία ήταν κενά",
"errorDeleteProfile": "Αποτυχία διαγραφής του προφίλ {n}", "errorDeleteProfile": "Αποτυχία διαγραφής του προφίλ {n}",
"errorLoadProfiles": "Αποτυχία φόρτωσης των προφίλ.", "errorLoadProfiles": "Αποτυχία φόρτωσης των προφίλ.",
"errorCreateProfile": "Αποτυχία δημιουργίας του προφίλ {n}", "errorCreateProfile": "Αποτυχία δημιουργίας του προφίλ {n}",
"errorSetDefaultProfile": "Αποτυχία ορισμού του προκαθορισμένου προφίλ.", "errorSetDefaultProfile": "Αποτυχία ορισμού του προκαθορισμένου προφίλ.",
"errorLoadUsers": "Αποτυχία φόρτωσης χρηστών.", "errorLoadUsers": "Αποτυχία φόρτωσης χρηστών.",
"errorSaveSettings": "Αποτυχία αποθήκευσης ρυθμίσεων.",
"errorLoadSettings": "Αποτυχία φόρτωσης ρυθμίσεων.", "errorLoadSettings": "Αποτυχία φόρτωσης ρυθμίσεων.",
"errorSetOmbiDefaults": "Αποτυχία αποθήκευσης προκαθορισμένων ρυθμίσεων για το Ombi.", "errorSetOmbiDefaults": "Αποτυχία αποθήκευσης προκαθορισμένων ρυθμίσεων για το Ombi.",
"errorLoadOmbiUsers": "Αποτυχία φόρτωσης χρηστών Ombi.", "errorLoadOmbiUsers": "Αποτυχία φόρτωσης χρηστών Ombi.",

View File

@ -68,12 +68,8 @@
"inviteHours": "Hours", "inviteHours": "Hours",
"inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively", "inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively",
"inviteSendToEmail": "Send to", "inviteSendToEmail": "Send to",
"login": "Login",
"logout": "Logout",
"apply": "Apply", "apply": "Apply",
"delete": "Delete",
"updates": "Updates", "updates": "Updates",
"expiry": "Expiry",
"variables": "Variables", "variables": "Variables",
"preview": "Preview", "preview": "Preview",
"markdownSupported": "Markdown is supported.", "markdownSupported": "Markdown is supported.",
@ -96,16 +92,10 @@
"contactThrough": "Contact through:", "contactThrough": "Contact through:",
"select": "Select", "select": "Select",
"date": "Date", "date": "Date",
"enabled": "Enabled",
"disabled": "Disabled",
"disable": "Disable",
"edit": "Edit",
"extendExpiry": "Extend expiry", "extendExpiry": "Extend expiry",
"sendPWR": "Send Password Reset", "sendPWR": "Send Password Reset",
"inviteMonths": "Months", "inviteMonths": "Months",
"inviteDuration": "Invite Duration", "inviteDuration": "Invite Duration",
"add": "Add",
"reEnable": "Re-enable",
"update": "Update", "update": "Update",
"user": "User", "user": "User",
"userExpiryDescription": "A specified amount of time after each signup, jfa-go will delete/disable the account. You can change this behaviour in settings.", "userExpiryDescription": "A specified amount of time after each signup, jfa-go will delete/disable the account. You can change this behaviour in settings.",
@ -150,7 +140,6 @@
"settingsApplyRestartLater": "Apply, restart later", "settingsApplyRestartLater": "Apply, restart later",
"subject": "Subject", "subject": "Subject",
"setExpiry": "Set expiry", "setExpiry": "Set expiry",
"admin": "Admin",
"download": "Download", "download": "Download",
"search": "Search", "search": "Search",
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
@ -175,9 +164,7 @@
}, },
"notifications": { "notifications": {
"errorSettingsFailed": "Application failed.", "errorSettingsFailed": "Application failed.",
"errorLoginBlank": "The username and/or password was left blank.",
"errorLoadSettings": "Failed to load settings.", "errorLoadSettings": "Failed to load settings.",
"errorUnknown": "Unknown error.",
"errorDeleteProfile": "Failed to delete profile {n}", "errorDeleteProfile": "Failed to delete profile {n}",
"sentAnnouncement": "Announcement sent.", "sentAnnouncement": "Announcement sent.",
"savedAnnouncement": "Announcement saved.", "savedAnnouncement": "Announcement saved.",
@ -186,13 +173,11 @@
"updateAppliedRefresh": "Update applied, please refresh.", "updateAppliedRefresh": "Update applied, please refresh.",
"telegramVerified": "Telegram account verified.", "telegramVerified": "Telegram account verified.",
"accountConnected": "Account connected.", "accountConnected": "Account connected.",
"error401Unauthorized": "Unauthorised. Try refreshing the page.",
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.", "errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
"errorSaveEmail": "Failed to save email.", "errorSaveEmail": "Failed to save email.",
"errorLoadProfiles": "Failed to load profiles.", "errorLoadProfiles": "Failed to load profiles.",
"errorCreateProfile": "Failed to create profile {n}", "errorCreateProfile": "Failed to create profile {n}",
"errorLoadUsers": "Failed to load users.", "errorLoadUsers": "Failed to load users.",
"errorSaveSettings": "Couldn't save settings.",
"errorSetOmbiProfile": "Failed to store ombi profile.", "errorSetOmbiProfile": "Failed to store ombi profile.",
"errorLoadOmbiUsers": "Failed to load ombi users.", "errorLoadOmbiUsers": "Failed to load ombi users.",
"errorFailureCheckLogs": "Failed (check console/logs)", "errorFailureCheckLogs": "Failed (check console/logs)",
@ -207,7 +192,6 @@
"saveEmail": "Email saved.", "saveEmail": "Email saved.",
"createProfile": "Created profile {n}.", "createProfile": "Created profile {n}.",
"saveSettings": "Settings were saved", "saveSettings": "Settings were saved",
"errorConnection": "Couldn't connect to jfa-go.",
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.", "errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
"errorBlankFields": "Fields were left blank", "errorBlankFields": "Fields were left blank",
"errorSetDefaultProfile": "Failed to set default profile.", "errorSetDefaultProfile": "Failed to set default profile.",

View File

@ -15,21 +15,12 @@
"warning": "Warning", "warning": "Warning",
"inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively", "inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively",
"inviteSendToEmail": "Send to", "inviteSendToEmail": "Send to",
"login": "Login",
"logout": "Logout",
"create": "Create", "create": "Create",
"apply": "Apply", "apply": "Apply",
"delete": "Delete",
"add": "Add",
"select": "Select", "select": "Select",
"name": "Name", "name": "Name",
"date": "Date", "date": "Date",
"enabled": "Enabled",
"disabled": "Disabled",
"reEnable": "Re-enable",
"setExpiry": "Set expiry", "setExpiry": "Set expiry",
"disable": "Disable",
"admin": "Admin",
"updates": "Updates", "updates": "Updates",
"update": "Update", "update": "Update",
"download": "Download", "download": "Download",
@ -40,7 +31,6 @@
"after": "After", "after": "After",
"before": "Before", "before": "Before",
"user": "User", "user": "User",
"expiry": "Expiry",
"userExpiry": "User Expiry", "userExpiry": "User Expiry",
"userExpiryDescription": "A specified amount of time after each signup, jfa-go will delete/disable the account. You can change this behaviour in settings.", "userExpiryDescription": "A specified amount of time after each signup, jfa-go will delete/disable the account. You can change this behaviour in settings.",
"aboutProgram": "About", "aboutProgram": "About",
@ -59,7 +49,6 @@
"conditionals": "Conditionals", "conditionals": "Conditionals",
"preview": "Preview", "preview": "Preview",
"reset": "Reset", "reset": "Reset",
"edit": "Edit",
"donate": "Donate", "donate": "Donate",
"unlink": "Unlink Account", "unlink": "Unlink Account",
"sendPWR": "Send Password Reset", "sendPWR": "Send Password Reset",
@ -124,7 +113,9 @@
"actions": "Actions", "actions": "Actions",
"searchOptions": "Search Options", "searchOptions": "Search Options",
"matchText": "Match Text", "matchText": "Match Text",
"jellyfinID": "Jellyfin ID" "jellyfinID": "Jellyfin ID",
"userPageLogin": "User Page: Login",
"userPagePage": "User Page: Page"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "Changed email address of {n}.", "changedEmailAddress": "Changed email address of {n}.",
@ -139,13 +130,9 @@
"updateAppliedRefresh": "Update applied, please refresh.", "updateAppliedRefresh": "Update applied, please refresh.",
"telegramVerified": "Telegram account verified.", "telegramVerified": "Telegram account verified.",
"accountConnected": "Account connected.", "accountConnected": "Account connected.",
"errorConnection": "Couldn't connect to jfa-go.",
"error401Unauthorized": "Unauthorized. Try refreshing the page.",
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.", "errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.", "errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
"errorSettingsFailed": "Application failed.", "errorSettingsFailed": "Application failed.",
"errorLoginBlank": "The username and/or password were left blank.",
"errorUnknown": "Unknown error.",
"errorSaveEmail": "Failed to save email.", "errorSaveEmail": "Failed to save email.",
"errorBlankFields": "Fields were left blank", "errorBlankFields": "Fields were left blank",
"errorDeleteProfile": "Failed to delete profile {n}", "errorDeleteProfile": "Failed to delete profile {n}",
@ -153,7 +140,6 @@
"errorCreateProfile": "Failed to create profile {n}", "errorCreateProfile": "Failed to create profile {n}",
"errorSetDefaultProfile": "Failed to set default profile.", "errorSetDefaultProfile": "Failed to set default profile.",
"errorLoadUsers": "Failed to load users.", "errorLoadUsers": "Failed to load users.",
"errorSaveSettings": "Couldn't save settings.",
"errorLoadSettings": "Failed to load settings.", "errorLoadSettings": "Failed to load settings.",
"errorSetOmbiProfile": "Failed to store ombi profile.", "errorSetOmbiProfile": "Failed to store ombi profile.",
"errorLoadOmbiUsers": "Failed to load ombi users.", "errorLoadOmbiUsers": "Failed to load ombi users.",

View File

@ -15,18 +15,10 @@
"warning": "Advertencia", "warning": "Advertencia",
"inviteInfiniteUsesWarning": "Las invitaciones con usos infinitos se pueden usar de forma abusiva", "inviteInfiniteUsesWarning": "Las invitaciones con usos infinitos se pueden usar de forma abusiva",
"inviteSendToEmail": "Enviar a", "inviteSendToEmail": "Enviar a",
"login": "Acceso",
"logout": "Cerrar sesión",
"create": "Crear", "create": "Crear",
"apply": "Aplicar", "apply": "Aplicar",
"delete": "Eliminar",
"name": "Nombre", "name": "Nombre",
"date": "Fecha", "date": "Fecha",
"enabled": "Activado",
"disabled": "Desactivado",
"reEnable": "Reactivar",
"disable": "Desactivar",
"admin": "Administrador",
"updates": "Actualizaciones", "updates": "Actualizaciones",
"update": "Actualizar", "update": "Actualizar",
"download": "Descargar", "download": "Descargar",
@ -35,7 +27,6 @@
"lastActiveTime": "Último activo", "lastActiveTime": "Último activo",
"from": "De", "from": "De",
"user": "Usuario", "user": "Usuario",
"expiry": "Expiración",
"userExpiry": "Caducidad del usuario", "userExpiry": "Caducidad del usuario",
"userExpiryDescription": "Una cantidad de tiempo específica después de cada registro, jfa-go eliminará / deshabilitará la cuenta. Puede cambiar este comportamiento en la configuración.", "userExpiryDescription": "Una cantidad de tiempo específica después de cada registro, jfa-go eliminará / deshabilitará la cuenta. Puede cambiar este comportamiento en la configuración.",
"aboutProgram": "Acerca de", "aboutProgram": "Acerca de",
@ -51,7 +42,6 @@
"variables": "Variables", "variables": "Variables",
"preview": "Vista previa", "preview": "Vista previa",
"reset": "Reiniciar", "reset": "Reiniciar",
"edit": "Editar",
"extendExpiry": "Extender el vencimiento", "extendExpiry": "Extender el vencimiento",
"customizeMessages": "Personalizar mensajes", "customizeMessages": "Personalizar mensajes",
"customizeMessagesDescription": "Si no desea utilizar las plantillas de mensajes de jfa-go, puede crear las suyas con Markdown.", "customizeMessagesDescription": "Si no desea utilizar las plantillas de mensajes de jfa-go, puede crear las suyas con Markdown.",
@ -93,7 +83,6 @@
"notifyUserCreation": "Sobre la creación de usuarios", "notifyUserCreation": "Sobre la creación de usuarios",
"conditionals": "Condicionales", "conditionals": "Condicionales",
"donate": "Donar", "donate": "Donar",
"add": "Agregar",
"templates": "Plantillas", "templates": "Plantillas",
"contactThrough": "Contactar a través de:", "contactThrough": "Contactar a través de:",
"select": "Seleccionar", "select": "Seleccionar",
@ -125,13 +114,9 @@
"sentAnnouncement": "Anuncio enviado.", "sentAnnouncement": "Anuncio enviado.",
"setOmbiDefaults": "Valores predeterminados de ombi almacenados.", "setOmbiDefaults": "Valores predeterminados de ombi almacenados.",
"updateApplied": "Actualización aplicada, por favor reinicie.", "updateApplied": "Actualización aplicada, por favor reinicie.",
"errorConnection": "No se pudo conectar a jfa-go.",
"error401Unauthorized": "No autorizado. Intente actualizar la página.",
"errorSettingsAppliedNoHomescreenLayout": "Se aplicó la configuración, pero es posible que no se haya aplicado el diseño de la pantalla de inicio.", "errorSettingsAppliedNoHomescreenLayout": "Se aplicó la configuración, pero es posible que no se haya aplicado el diseño de la pantalla de inicio.",
"errorHomescreenAppliedNoSettings": "Se aplicó el diseño de la pantalla de inicio, pero es posible que la aplicación de la configuración haya fallado.", "errorHomescreenAppliedNoSettings": "Se aplicó el diseño de la pantalla de inicio, pero es posible que la aplicación de la configuración haya fallado.",
"errorSettingsFailed": "La aplicación falló.", "errorSettingsFailed": "La aplicación falló.",
"errorLoginBlank": "El nombre de usuario y/o la contraseña se dejaron en blanco.",
"errorUnknown": "Error desconocido.",
"errorSaveEmail": "No se pudo guardar el correo electrónico.", "errorSaveEmail": "No se pudo guardar el correo electrónico.",
"errorBlankFields": "Los campos se dejaron en blanco", "errorBlankFields": "Los campos se dejaron en blanco",
"errorDeleteProfile": "No se pudo borrar el perfil {n}", "errorDeleteProfile": "No se pudo borrar el perfil {n}",
@ -139,7 +124,6 @@
"errorCreateProfile": "No se pudo crear el perfil {n}", "errorCreateProfile": "No se pudo crear el perfil {n}",
"errorSetDefaultProfile": "No se pudo establecer el perfil predeterminado.", "errorSetDefaultProfile": "No se pudo establecer el perfil predeterminado.",
"errorLoadUsers": "No se pudieron cargar los usuarios.", "errorLoadUsers": "No se pudieron cargar los usuarios.",
"errorSaveSettings": "No se pudo guardar la configuración.",
"errorLoadSettings": "No se pudo cargar la configuración.", "errorLoadSettings": "No se pudo cargar la configuración.",
"errorSetOmbiDefaults": "No se pudieron almacenar los valores predeterminados de ombi.", "errorSetOmbiDefaults": "No se pudieron almacenar los valores predeterminados de ombi.",
"errorLoadOmbiUsers": "No se pudieron cargar los usuarios de Ombi.", "errorLoadOmbiUsers": "No se pudieron cargar los usuarios de Ombi.",

View File

@ -15,11 +15,8 @@
"warning": "Attention", "warning": "Attention",
"inviteInfiniteUsesWarning": "les invitations infinies peuvent être utilisées abusivement", "inviteInfiniteUsesWarning": "les invitations infinies peuvent être utilisées abusivement",
"inviteSendToEmail": "Envoyer à", "inviteSendToEmail": "Envoyer à",
"login": "S'identifier",
"logout": "Se déconnecter",
"create": "Créer", "create": "Créer",
"apply": "Appliquer", "apply": "Appliquer",
"delete": "Effacer",
"name": "Nom", "name": "Nom",
"date": "Date", "date": "Date",
"lastActiveTime": "Dernière activité", "lastActiveTime": "Dernière activité",
@ -75,15 +72,8 @@
"variables": "Variables", "variables": "Variables",
"preview": "Aperçu", "preview": "Aperçu",
"reset": "Réinitialisation", "reset": "Réinitialisation",
"edit": "Éditer",
"customizeMessages": "Personnaliser les e-mails", "customizeMessages": "Personnaliser les e-mails",
"inviteDuration": "Durée de l'invitation", "inviteDuration": "Durée de l'invitation",
"enabled": "Activé",
"disabled": "Désactivé",
"reEnable": "Ré-activé",
"disable": "Désactivé",
"admin": "Administrateur",
"expiry": "Expiration",
"advancedSettings": "Paramètres avancés", "advancedSettings": "Paramètres avancés",
"userExpiry": "Expiration de l'utilisateur", "userExpiry": "Expiration de l'utilisateur",
"updates": "Mises à jour", "updates": "Mises à jour",
@ -96,7 +86,6 @@
"extendExpiry": "Prolonger l'expiration", "extendExpiry": "Prolonger l'expiration",
"contactThrough": "Contacté par :", "contactThrough": "Contacté par :",
"sendPIN": "Demandez à l'utilisateur d'envoyer le code PIN ci-dessous au bot.", "sendPIN": "Demandez à l'utilisateur d'envoyer le code PIN ci-dessous au bot.",
"add": "Ajouter",
"select": "Sélectionner", "select": "Sélectionner",
"findDiscordUser": "Trouver l'utilisateur Discord", "findDiscordUser": "Trouver l'utilisateur Discord",
"linkMatrixDescription": "Entrez le nom d'utilisateur et le mot de passe de l'utilisateur pour lutilisateur comme bot. Une fois soumis, l'application va redémarrer.", "linkMatrixDescription": "Entrez le nom d'utilisateur et le mot de passe de l'utilisateur pour lutilisateur comme bot. Une fois soumis, l'application va redémarrer.",
@ -123,20 +112,15 @@
"createProfile": "Profil créé {n}.", "createProfile": "Profil créé {n}.",
"saveSettings": "Les paramètres ont été enregistrés", "saveSettings": "Les paramètres ont été enregistrés",
"setOmbiDefaults": "Valeurs par défaut de Ombi.", "setOmbiDefaults": "Valeurs par défaut de Ombi.",
"errorConnection": "Impossible de se connecter à jfa-go.",
"error401Unauthorized": "Non autorisé. Essayez d'actualiser la page.",
"errorSettingsAppliedNoHomescreenLayout": "Les paramètres ont été appliqués, mais l'application de la disposition de l'écran d'accueil a peut-être échoué.", "errorSettingsAppliedNoHomescreenLayout": "Les paramètres ont été appliqués, mais l'application de la disposition de l'écran d'accueil a peut-être échoué.",
"errorHomescreenAppliedNoSettings": "La disposition de l'écran d'accueil a été appliquée, mais l'application des paramètres a peut-être échoué.", "errorHomescreenAppliedNoSettings": "La disposition de l'écran d'accueil a été appliquée, mais l'application des paramètres a peut-être échoué.",
"errorSettingsFailed": "L'application a échoué.", "errorSettingsFailed": "L'application a échoué.",
"errorLoginBlank": "Le nom d'utilisateur et/ou le mot de passe sont vides.",
"errorUnknown": "Erreur inconnue.",
"errorBlankFields": "Les champs sont vides", "errorBlankFields": "Les champs sont vides",
"errorDeleteProfile": "Échec de la suppression du profil {n}", "errorDeleteProfile": "Échec de la suppression du profil {n}",
"errorLoadProfiles": "Échec du chargement des profils.", "errorLoadProfiles": "Échec du chargement des profils.",
"errorCreateProfile": "Échec de la création du profil {n}", "errorCreateProfile": "Échec de la création du profil {n}",
"errorSetDefaultProfile": "Échec de la définition du profil par défaut.", "errorSetDefaultProfile": "Échec de la définition du profil par défaut.",
"errorLoadUsers": "Échec du chargement des utilisateurs.", "errorLoadUsers": "Échec du chargement des utilisateurs.",
"errorSaveSettings": "Impossible d'enregistrer les paramètres.",
"errorLoadSettings": "Échec du chargement des paramètres.", "errorLoadSettings": "Échec du chargement des paramètres.",
"errorSetOmbiDefaults": "Impossible de stocker les valeurs par défaut d'Ombi.", "errorSetOmbiDefaults": "Impossible de stocker les valeurs par défaut d'Ombi.",
"errorLoadOmbiUsers": "Échec du chargement des utilisateurs Ombi.", "errorLoadOmbiUsers": "Échec du chargement des utilisateurs Ombi.",

View File

@ -15,21 +15,12 @@
"warning": "Figyelmeztetés", "warning": "Figyelmeztetés",
"inviteInfiniteUsesWarning": "a végtelen felhasználású meghívókkal visszaélhetnek", "inviteInfiniteUsesWarning": "a végtelen felhasználású meghívókkal visszaélhetnek",
"inviteSendToEmail": "Címzett", "inviteSendToEmail": "Címzett",
"login": "Belépés",
"logout": "Kijelentkezés",
"create": "Létrehozás", "create": "Létrehozás",
"apply": "Alkalmaz", "apply": "Alkalmaz",
"delete": "Törlés",
"add": "Hozzáadás",
"select": "Kiválasztás", "select": "Kiválasztás",
"name": "Név", "name": "Név",
"date": "Dátum", "date": "Dátum",
"enabled": "Engedélyezve",
"disabled": "Tiltva",
"reEnable": "Újra engedélyezés",
"setExpiry": "Lejárat beállítása", "setExpiry": "Lejárat beállítása",
"disable": "Letiltás",
"admin": "Adminisztrátor",
"updates": "Frissítések", "updates": "Frissítések",
"update": "Frissítés", "update": "Frissítés",
"download": "Letöltés", "download": "Letöltés",
@ -38,7 +29,6 @@
"lastActiveTime": "Utoljára aktív", "lastActiveTime": "Utoljára aktív",
"from": "Feladó", "from": "Feladó",
"user": "Felhasználó", "user": "Felhasználó",
"expiry": "Lejárat",
"userExpiry": "Felhasználói lejárat", "userExpiry": "Felhasználói lejárat",
"userExpiryDescription": "Egy meghatározott idő után minden regisztrációt töröl, vagy felfüggeszt a jfa-go. Ezt a működést megváltoztathatod a beállításokban.", "userExpiryDescription": "Egy meghatározott idő után minden regisztrációt töröl, vagy felfüggeszt a jfa-go. Ezt a működést megváltoztathatod a beállításokban.",
"aboutProgram": "Névjegy", "aboutProgram": "Névjegy",
@ -57,7 +47,6 @@
"conditionals": "Feltételek", "conditionals": "Feltételek",
"preview": "Előnézet", "preview": "Előnézet",
"reset": "Visszaállítás", "reset": "Visszaállítás",
"edit": "Szerkesztés",
"donate": "Támogatás", "donate": "Támogatás",
"sendPWR": "Jelszó visszaállítás küldése", "sendPWR": "Jelszó visszaállítás küldése",
"contactThrough": "", "contactThrough": "",
@ -126,13 +115,9 @@
"updateAppliedRefresh": "", "updateAppliedRefresh": "",
"telegramVerified": "", "telegramVerified": "",
"accountConnected": "", "accountConnected": "",
"errorConnection": "",
"error401Unauthorized": "",
"errorSettingsAppliedNoHomescreenLayout": "", "errorSettingsAppliedNoHomescreenLayout": "",
"errorHomescreenAppliedNoSettings": "", "errorHomescreenAppliedNoSettings": "",
"errorSettingsFailed": "", "errorSettingsFailed": "",
"errorLoginBlank": "",
"errorUnknown": "",
"errorSaveEmail": "", "errorSaveEmail": "",
"errorBlankFields": "", "errorBlankFields": "",
"errorDeleteProfile": "", "errorDeleteProfile": "",
@ -140,7 +125,6 @@
"errorCreateProfile": "", "errorCreateProfile": "",
"errorSetDefaultProfile": "", "errorSetDefaultProfile": "",
"errorLoadUsers": "", "errorLoadUsers": "",
"errorSaveSettings": "",
"errorLoadSettings": "", "errorLoadSettings": "",
"errorSetOmbiProfile": "", "errorSetOmbiProfile": "",
"errorLoadOmbiUsers": "", "errorLoadOmbiUsers": "",

View File

@ -13,11 +13,8 @@
"warning": "Peringatan", "warning": "Peringatan",
"inviteInfiniteUsesWarning": "Undangan dalam jumlah tak terbatas dapat disalahgunakan", "inviteInfiniteUsesWarning": "Undangan dalam jumlah tak terbatas dapat disalahgunakan",
"inviteSendToEmail": "Dikirim kepada", "inviteSendToEmail": "Dikirim kepada",
"login": "Masuk",
"logout": "Keluar",
"create": "Buat", "create": "Buat",
"apply": "Terapkan", "apply": "Terapkan",
"delete": "Hapus",
"name": "Nama", "name": "Nama",
"date": "Tanggal", "date": "Tanggal",
"lastActiveTime": "Terakhir Aktif", "lastActiveTime": "Terakhir Aktif",
@ -68,7 +65,6 @@
"variables": "Variabel", "variables": "Variabel",
"preview": "Pratinjau", "preview": "Pratinjau",
"reset": "Setel ulang", "reset": "Setel ulang",
"edit": "Edit",
"customizeMessages": "Sesuaikan Email", "customizeMessages": "Sesuaikan Email",
"customizeMessagesDescription": "Jika Anda tidak ingin menggunakan templat email jfa-go, Anda dapat membuatnya sendiri menggunakan Markdown.", "customizeMessagesDescription": "Jika Anda tidak ingin menggunakan templat email jfa-go, Anda dapat membuatnya sendiri menggunakan Markdown.",
"announce": "Mengumumkan", "announce": "Mengumumkan",
@ -82,20 +78,15 @@
"createProfile": "Membuat profil {n}.", "createProfile": "Membuat profil {n}.",
"saveSettings": "Pengaturan telah disimpan", "saveSettings": "Pengaturan telah disimpan",
"setOmbiDefaults": "Default ombi tersimpan.", "setOmbiDefaults": "Default ombi tersimpan.",
"errorConnection": "Tidak dapat terhubung ke jfa-go.",
"error401Unauthorized": "Tidak ter-otorisasi. Coba segarkan halaman.",
"errorSettingsAppliedNoHomescreenLayout": "Pengaturan telah diterapkan, tetapi menerapkan tata letak layar utama mungkin gagal.", "errorSettingsAppliedNoHomescreenLayout": "Pengaturan telah diterapkan, tetapi menerapkan tata letak layar utama mungkin gagal.",
"errorHomescreenAppliedNoSettings": "Tata letak layar beranda diterapkan, tetapi menerapkan pengaturan mungkin gagal.", "errorHomescreenAppliedNoSettings": "Tata letak layar beranda diterapkan, tetapi menerapkan pengaturan mungkin gagal.",
"errorSettingsFailed": "Aplikasi gagal.", "errorSettingsFailed": "Aplikasi gagal.",
"errorLoginBlank": "Nama pengguna dan / atau sandi kosong.",
"errorUnknown": "Kesalahan yang tidak diketahui.",
"errorBlankFields": "Isian dibiarkan kosong", "errorBlankFields": "Isian dibiarkan kosong",
"errorDeleteProfile": "Gagal menghapus profil {n}", "errorDeleteProfile": "Gagal menghapus profil {n}",
"errorLoadProfiles": "Gagal memuat profil.", "errorLoadProfiles": "Gagal memuat profil.",
"errorCreateProfile": "Gagal membuat profil {n}", "errorCreateProfile": "Gagal membuat profil {n}",
"errorSetDefaultProfile": "Gagal menyetel profil default.", "errorSetDefaultProfile": "Gagal menyetel profil default.",
"errorLoadUsers": "Gagal memuat pengguna.", "errorLoadUsers": "Gagal memuat pengguna.",
"errorSaveSettings": "Tidak dapat menyimpan pengaturan.",
"errorLoadSettings": "Gagal memuat pengaturan.", "errorLoadSettings": "Gagal memuat pengaturan.",
"errorSetOmbiDefaults": "Gagal menyimpan default ombi.", "errorSetOmbiDefaults": "Gagal menyimpan default ombi.",
"errorLoadOmbiUsers": "Gagal memuat pengguna ombi.", "errorLoadOmbiUsers": "Gagal memuat pengguna ombi.",

View File

@ -13,11 +13,8 @@
"warning": "Waarschuwing", "warning": "Waarschuwing",
"inviteInfiniteUsesWarning": "ongelimiteerde uitnodigingen kunnen misbruikt worden", "inviteInfiniteUsesWarning": "ongelimiteerde uitnodigingen kunnen misbruikt worden",
"inviteSendToEmail": "Stuur naar", "inviteSendToEmail": "Stuur naar",
"login": "Inloggen",
"logout": "Uitloggen",
"create": "Aanmaken", "create": "Aanmaken",
"apply": "Toepassen", "apply": "Toepassen",
"delete": "Verwijderen",
"name": "Naam", "name": "Naam",
"date": "Datum", "date": "Datum",
"lastActiveTime": "Laatst actief", "lastActiveTime": "Laatst actief",
@ -73,14 +70,9 @@
"customizeMessagesDescription": "Als je de e-mailsjablonen van jfa-go niet wilt gebruiken, kun je met gebruik van Markdown je eigen aanmaken.", "customizeMessagesDescription": "Als je de e-mailsjablonen van jfa-go niet wilt gebruiken, kun je met gebruik van Markdown je eigen aanmaken.",
"preview": "Voorbeeld", "preview": "Voorbeeld",
"reset": "Reset", "reset": "Reset",
"edit": "Bewerken",
"customizeMessages": "E-mails aanpassen", "customizeMessages": "E-mails aanpassen",
"inviteDuration": "Geldigheidsduur uitnodiging", "inviteDuration": "Geldigheidsduur uitnodiging",
"userExpiryDescription": "Een bepaalde tijd na elke aanmelding, wordt de account verwijderd/uitgeschakeld door jfa-go. Dit kan aangepast worden in de instellingen.", "userExpiryDescription": "Een bepaalde tijd na elke aanmelding, wordt de account verwijderd/uitgeschakeld door jfa-go. Dit kan aangepast worden in de instellingen.",
"enabled": "Ingeschakeld",
"disabled": "Uitgeschakeld",
"admin": "Beheerder",
"expiry": "Verloop",
"userExpiry": "Gebruikersverloop", "userExpiry": "Gebruikersverloop",
"extendExpiry": "Verleng verloop", "extendExpiry": "Verleng verloop",
"updates": "Updates", "updates": "Updates",
@ -89,13 +81,10 @@
"search": "Zoeken", "search": "Zoeken",
"advancedSettings": "Geavanceerde instellingen", "advancedSettings": "Geavanceerde instellingen",
"inviteMonths": "Maanden", "inviteMonths": "Maanden",
"reEnable": "Opnieuw inschakelen",
"disable": "Uitschakelen",
"conditionals": "Voorwaarden", "conditionals": "Voorwaarden",
"donate": "Doneer", "donate": "Doneer",
"contactThrough": "Stuur bericht via:", "contactThrough": "Stuur bericht via:",
"sendPIN": "Vraag de gebruiker om onderstaande pincode naar de bot te sturen.", "sendPIN": "Vraag de gebruiker om onderstaande pincode naar de bot te sturen.",
"add": "Voeg toe",
"searchDiscordUser": "Begin de Discord gebruikersnaam te typen om de gebruiker te vinden.", "searchDiscordUser": "Begin de Discord gebruikersnaam te typen om de gebruiker te vinden.",
"linkMatrixDescription": "Vul de gebruikersnaam en wachtwoord in van de gebruiker om als bot te gebruiken. De app start zodra ze zijn verstuurd.", "linkMatrixDescription": "Vul de gebruikersnaam en wachtwoord in van de gebruiker om als bot te gebruiken. De app start zodra ze zijn verstuurd.",
"select": "Selecteer", "select": "Selecteer",
@ -122,20 +111,15 @@
"createProfile": "Profiel {n} aangemaakt.", "createProfile": "Profiel {n} aangemaakt.",
"saveSettings": "De instellingen zijn opgeslagen", "saveSettings": "De instellingen zijn opgeslagen",
"setOmbiDefaults": "De ombi standaardinstellingen zijn opgeslagen.", "setOmbiDefaults": "De ombi standaardinstellingen zijn opgeslagen.",
"errorConnection": "Kon geen verbinding maken met jfa-go.",
"error401Unauthorized": "Geen toegang. Probeer de pagina te vernieuwen.",
"errorSettingsAppliedNoHomescreenLayout": "De instellingen zijn toegepast, maar wijzigen van de startpaginaindeling is misschien mislukt.", "errorSettingsAppliedNoHomescreenLayout": "De instellingen zijn toegepast, maar wijzigen van de startpaginaindeling is misschien mislukt.",
"errorHomescreenAppliedNoSettings": "Startpaginaindeling toegepast, maar opslaan van instellingen is misschien mislukt.", "errorHomescreenAppliedNoSettings": "Startpaginaindeling toegepast, maar opslaan van instellingen is misschien mislukt.",
"errorSettingsFailed": "Opslaan mislukt.", "errorSettingsFailed": "Opslaan mislukt.",
"errorLoginBlank": "De gebruikersnaam en/of wachtwoord is leeg.",
"errorUnknown": "Onbekende fout.",
"errorBlankFields": "Velden leeggelaten", "errorBlankFields": "Velden leeggelaten",
"errorDeleteProfile": "Verwijderen van profiel {n} mislukt", "errorDeleteProfile": "Verwijderen van profiel {n} mislukt",
"errorLoadProfiles": "Fout bij het laden van profielen.", "errorLoadProfiles": "Fout bij het laden van profielen.",
"errorCreateProfile": "Aanmaken van profile {n} mislukt", "errorCreateProfile": "Aanmaken van profile {n} mislukt",
"errorSetDefaultProfile": "Fout bij instellen van standaardprofiel.", "errorSetDefaultProfile": "Fout bij instellen van standaardprofiel.",
"errorLoadUsers": "Laden van gebruikers mislukt.", "errorLoadUsers": "Laden van gebruikers mislukt.",
"errorSaveSettings": "Opslaan van instellingen mislukt.",
"errorLoadSettings": "Laden van instellingen mislukt.", "errorLoadSettings": "Laden van instellingen mislukt.",
"errorSetOmbiDefaults": "Opslaan van ombi standaardinstellingen mislukt.", "errorSetOmbiDefaults": "Opslaan van ombi standaardinstellingen mislukt.",
"errorLoadOmbiUsers": "Laden van ombi gebruikers mislukt.", "errorLoadOmbiUsers": "Laden van ombi gebruikers mislukt.",

View File

@ -15,21 +15,12 @@
"warning": "Ostrzeżenie", "warning": "Ostrzeżenie",
"inviteInfiniteUsesWarning": "", "inviteInfiniteUsesWarning": "",
"inviteSendToEmail": "", "inviteSendToEmail": "",
"login": "",
"logout": "",
"create": "", "create": "",
"apply": "", "apply": "",
"delete": "",
"add": "",
"select": "", "select": "",
"name": "Imię", "name": "Imię",
"date": "Data", "date": "Data",
"enabled": "Włączone",
"disabled": "Wyłączone",
"reEnable": "",
"setExpiry": "", "setExpiry": "",
"disable": "Wyłączone",
"admin": "Admin",
"updates": "Aktualizacje", "updates": "Aktualizacje",
"update": "Aktualizacja", "update": "Aktualizacja",
"download": "Pobierz", "download": "Pobierz",
@ -38,7 +29,6 @@
"lastActiveTime": "Ostatnia aktywność", "lastActiveTime": "Ostatnia aktywność",
"from": "Od", "from": "Od",
"user": "Użytkownik", "user": "Użytkownik",
"expiry": "Wygasa",
"userExpiry": "Użytkownik wygasa", "userExpiry": "Użytkownik wygasa",
"userExpiryDescription": "", "userExpiryDescription": "",
"aboutProgram": "O", "aboutProgram": "O",
@ -57,7 +47,6 @@
"conditionals": "", "conditionals": "",
"preview": "", "preview": "",
"reset": "Zresetuj", "reset": "Zresetuj",
"edit": "Edytuj",
"donate": "", "donate": "",
"sendPWR": "", "sendPWR": "",
"contactThrough": "", "contactThrough": "",
@ -128,13 +117,9 @@
"updateAppliedRefresh": "Aktualizacja zastosowana, odśwież.", "updateAppliedRefresh": "Aktualizacja zastosowana, odśwież.",
"telegramVerified": "Konto telegramu zweryfikowane.", "telegramVerified": "Konto telegramu zweryfikowane.",
"accountConnected": "Konto połączone.", "accountConnected": "Konto połączone.",
"errorConnection": "Nie udało się połączyć z jfa-go.",
"error401Unauthorized": "Nieautoryzowany. Spróbuj odświeżyć stronę.",
"errorSettingsAppliedNoHomescreenLayout": "Zastosowano ustawienia, ale zastosowanie układu ekranu głównego mogło się nie powieść.", "errorSettingsAppliedNoHomescreenLayout": "Zastosowano ustawienia, ale zastosowanie układu ekranu głównego mogło się nie powieść.",
"errorHomescreenAppliedNoSettings": "", "errorHomescreenAppliedNoSettings": "",
"errorSettingsFailed": "", "errorSettingsFailed": "",
"errorLoginBlank": "",
"errorUnknown": "Nieznany błąd.",
"errorSaveEmail": "", "errorSaveEmail": "",
"errorBlankFields": "", "errorBlankFields": "",
"errorDeleteProfile": "", "errorDeleteProfile": "",
@ -142,7 +127,6 @@
"errorCreateProfile": "", "errorCreateProfile": "",
"errorSetDefaultProfile": "", "errorSetDefaultProfile": "",
"errorLoadUsers": "", "errorLoadUsers": "",
"errorSaveSettings": "",
"errorLoadSettings": "", "errorLoadSettings": "",
"errorSetOmbiProfile": "", "errorSetOmbiProfile": "",
"errorLoadOmbiUsers": "", "errorLoadOmbiUsers": "",

View File

@ -13,11 +13,8 @@
"warning": "Aviso", "warning": "Aviso",
"inviteInfiniteUsesWarning": "convites infinitos podem ser usados de forma abusiva", "inviteInfiniteUsesWarning": "convites infinitos podem ser usados de forma abusiva",
"inviteSendToEmail": "Enviar para", "inviteSendToEmail": "Enviar para",
"login": "Login",
"logout": "Sair",
"create": "Criar", "create": "Criar",
"apply": "Aplicar", "apply": "Aplicar",
"delete": "Deletar",
"name": "Nome", "name": "Nome",
"date": "Data", "date": "Data",
"lastActiveTime": "Ativo pela última vez", "lastActiveTime": "Ativo pela última vez",
@ -73,14 +70,9 @@
"variables": "Variáveis", "variables": "Variáveis",
"preview": "Pre-visualizar", "preview": "Pre-visualizar",
"reset": "Redefinir", "reset": "Redefinir",
"edit": "Editar",
"customizeMessages": "Customizar Emails", "customizeMessages": "Customizar Emails",
"disabled": "Desativado",
"userExpiryDescription": "Após um determinado período de tempo de cada inscrição, o jfa-go apagará/desabilitará a conta. Você pode alterar essa opção nas configurações.", "userExpiryDescription": "Após um determinado período de tempo de cada inscrição, o jfa-go apagará/desabilitará a conta. Você pode alterar essa opção nas configurações.",
"inviteDuration": "Duração do Convite", "inviteDuration": "Duração do Convite",
"enabled": "Habilitado",
"admin": "Admin",
"expiry": "Expira",
"userExpiry": "Vencimento do Usuário", "userExpiry": "Vencimento do Usuário",
"extendExpiry": "Extender o vencimento", "extendExpiry": "Extender o vencimento",
"updates": "Atualizações", "updates": "Atualizações",
@ -89,15 +81,12 @@
"search": "Procurar", "search": "Procurar",
"advancedSettings": "Configurações Avançada", "advancedSettings": "Configurações Avançada",
"inviteMonths": "Meses", "inviteMonths": "Meses",
"reEnable": "Reativar",
"disable": "Desativar",
"conditionals": "Condicionais", "conditionals": "Condicionais",
"donate": "Doar", "donate": "Doar",
"contactThrough": "Contato através:", "contactThrough": "Contato através:",
"sendPIN": "Peça que o usuário envie o PIN abaixo para o bot.", "sendPIN": "Peça que o usuário envie o PIN abaixo para o bot.",
"searchDiscordUser": "Digite o nome de usuário do Discord.", "searchDiscordUser": "Digite o nome de usuário do Discord.",
"findDiscordUser": "Encontrar usuário Discord", "findDiscordUser": "Encontrar usuário Discord",
"add": "Adicionar",
"linkMatrixDescription": "Digite o nome de usuário e a senha para usar como bot. Depois de enviado, o aplicativo será reiniciado.", "linkMatrixDescription": "Digite o nome de usuário e a senha para usar como bot. Depois de enviado, o aplicativo será reiniciado.",
"select": "Selecionar", "select": "Selecionar",
"templates": "Modelos", "templates": "Modelos",
@ -122,20 +111,15 @@
"createProfile": "Perfil {n} criado.", "createProfile": "Perfil {n} criado.",
"saveSettings": "As configurações foram salvas", "saveSettings": "As configurações foram salvas",
"setOmbiDefaults": "Padrões do ombi armazenados.", "setOmbiDefaults": "Padrões do ombi armazenados.",
"errorConnection": "Não foi possível conectar ao jfa-go.",
"error401Unauthorized": "Não autorizado. Tente atualizar a página.",
"errorSettingsAppliedNoHomescreenLayout": "As configurações foram aplicadas, mas a aplicação do layout da tela inicial pode ter falhado.", "errorSettingsAppliedNoHomescreenLayout": "As configurações foram aplicadas, mas a aplicação do layout da tela inicial pode ter falhado.",
"errorHomescreenAppliedNoSettings": "O layout da tela inicial foi aplicado, mas a aplicação das configurações pode ter falhado.", "errorHomescreenAppliedNoSettings": "O layout da tela inicial foi aplicado, mas a aplicação das configurações pode ter falhado.",
"errorSettingsFailed": "Falha na aplicação.", "errorSettingsFailed": "Falha na aplicação.",
"errorLoginBlank": "O nome de usuário e/ou senha foram deixados em branco.",
"errorUnknown": "Erro desconhecido.",
"errorBlankFields": "Os campos foram deixados em branco", "errorBlankFields": "Os campos foram deixados em branco",
"errorDeleteProfile": "Falha ao excluir perfil {n}", "errorDeleteProfile": "Falha ao excluir perfil {n}",
"errorLoadProfiles": "Falha ao carregar perfis.", "errorLoadProfiles": "Falha ao carregar perfis.",
"errorCreateProfile": "Falha ao criar perfil {n}", "errorCreateProfile": "Falha ao criar perfil {n}",
"errorSetDefaultProfile": "Falha ao definir o perfil padrão.", "errorSetDefaultProfile": "Falha ao definir o perfil padrão.",
"errorLoadUsers": "Falha ao carregar usuários.", "errorLoadUsers": "Falha ao carregar usuários.",
"errorSaveSettings": "Não foi possível salvar as configurações.",
"errorLoadSettings": "Falha ao carregar as configurações.", "errorLoadSettings": "Falha ao carregar as configurações.",
"errorSetOmbiDefaults": "Falha em armazenar os padrões ombi.", "errorSetOmbiDefaults": "Falha em armazenar os padrões ombi.",
"errorLoadOmbiUsers": "Falha ao carregar usuários ombi.", "errorLoadOmbiUsers": "Falha ao carregar usuários ombi.",

View File

@ -13,11 +13,8 @@
"warning": "Varning", "warning": "Varning",
"inviteInfiniteUsesWarning": "inbjudningar med oändligt antal användningar kan missbrukas", "inviteInfiniteUsesWarning": "inbjudningar med oändligt antal användningar kan missbrukas",
"inviteSendToEmail": "Skicka till", "inviteSendToEmail": "Skicka till",
"login": "Logga in",
"logout": "Logga ut",
"create": "Skapa", "create": "Skapa",
"apply": "Tillämpa", "apply": "Tillämpa",
"delete": "Radera",
"name": "Namn", "name": "Namn",
"date": "Datum", "date": "Datum",
"lastActiveTime": "Senast aktiv", "lastActiveTime": "Senast aktiv",
@ -36,7 +33,6 @@
"variables": "Variabler", "variables": "Variabler",
"preview": "Förhandsvisning", "preview": "Förhandsvisning",
"reset": "Återställ", "reset": "Återställ",
"edit": "Redigera",
"customizeMessages": "Anpassa e-post", "customizeMessages": "Anpassa e-post",
"customizeMessagesDescription": "Om du inte vill använda jfa-go's e-postmallar, så kan du skapa dina egna med Markdown.", "customizeMessagesDescription": "Om du inte vill använda jfa-go's e-postmallar, så kan du skapa dina egna med Markdown.",
"markdownSupported": "Markdown stöds.", "markdownSupported": "Markdown stöds.",
@ -75,11 +71,7 @@
"notifyEvent": "Meddela den:", "notifyEvent": "Meddela den:",
"notifyInviteExpiry": "Vid utgång", "notifyInviteExpiry": "Vid utgång",
"notifyUserCreation": "Vid användarskapande", "notifyUserCreation": "Vid användarskapande",
"disabled": "Inaktiverad",
"enabled": "Aktiverad",
"inviteDuration": "Varaktighet för inbjudan", "inviteDuration": "Varaktighet för inbjudan",
"admin": "Admin",
"expiry": "Löper ut",
"userExpiry": "Användarutgång", "userExpiry": "Användarutgång",
"userExpiryDescription": "Efter en angiven tid efter varje registrering så tar jfa-go bort/inaktiverar kontot. Du kan ändra detta beteende i inställningarna.", "userExpiryDescription": "Efter en angiven tid efter varje registrering så tar jfa-go bort/inaktiverar kontot. Du kan ändra detta beteende i inställningarna.",
"extendExpiry": "Förläng utgång" "extendExpiry": "Förläng utgång"
@ -92,13 +84,9 @@
"saveEmail": "E-post sparad.", "saveEmail": "E-post sparad.",
"sentAnnouncement": "Meddelande skickat.", "sentAnnouncement": "Meddelande skickat.",
"setOmbiDefaults": "Lagrade ombi-standardvärden.", "setOmbiDefaults": "Lagrade ombi-standardvärden.",
"errorConnection": "Det gick inte att ansluta till jfa-go.",
"error401Unauthorized": "Obehörig. Prova att uppdatera sidan.",
"errorSettingsAppliedNoHomescreenLayout": "Inställningarna tillämpades, men tillämpningen av hemskärmslayout kan ha misslyckats.", "errorSettingsAppliedNoHomescreenLayout": "Inställningarna tillämpades, men tillämpningen av hemskärmslayout kan ha misslyckats.",
"errorHomescreenAppliedNoSettings": "Hemskärmslayout tillämpades, men tillämpningen av inställningar kan ha misslyckats.", "errorHomescreenAppliedNoSettings": "Hemskärmslayout tillämpades, men tillämpningen av inställningar kan ha misslyckats.",
"errorSettingsFailed": "Tillämpning misslyckades.", "errorSettingsFailed": "Tillämpning misslyckades.",
"errorLoginBlank": "Användarnamnet och/eller lösenordet lämnades tomt.",
"errorUnknown": "Okänt fel.",
"errorSaveEmail": "Det gick inte att spara e-postmeddelandet.", "errorSaveEmail": "Det gick inte att spara e-postmeddelandet.",
"errorBlankFields": "Fält lämnades tomma", "errorBlankFields": "Fält lämnades tomma",
"errorDeleteProfile": "Det gick inte att ta bort profilen {n}", "errorDeleteProfile": "Det gick inte att ta bort profilen {n}",
@ -106,7 +94,6 @@
"errorCreateProfile": "Det gick inte att skapa profilen {n}", "errorCreateProfile": "Det gick inte att skapa profilen {n}",
"errorSetDefaultProfile": "Det gick inte att ange standardprofil.", "errorSetDefaultProfile": "Det gick inte att ange standardprofil.",
"errorLoadUsers": "Det gick inte att läsa in användare.", "errorLoadUsers": "Det gick inte att läsa in användare.",
"errorSaveSettings": "Det gick inte att spara inställningarna.",
"errorLoadSettings": "Det gick inte att läsa in inställningarna.", "errorLoadSettings": "Det gick inte att läsa in inställningarna.",
"errorSetOmbiDefaults": "Det gick inte att lagra ombi-standardvärden.", "errorSetOmbiDefaults": "Det gick inte att lagra ombi-standardvärden.",
"errorLoadOmbiUsers": "Det gick inte att ladda ombi-användare.", "errorLoadOmbiUsers": "Det gick inte att ladda ombi-användare.",

View File

@ -15,21 +15,12 @@
"warning": "Cảnh báo", "warning": "Cảnh báo",
"inviteInfiniteUsesWarning": "các lời mời không giới hạn số lần sử dụng có thể bị lạm dụng", "inviteInfiniteUsesWarning": "các lời mời không giới hạn số lần sử dụng có thể bị lạm dụng",
"inviteSendToEmail": "Gửi tới", "inviteSendToEmail": "Gửi tới",
"login": "Đăng nhập",
"logout": "Đăng xuất",
"create": "Tạo mới", "create": "Tạo mới",
"apply": "Áp dụng", "apply": "Áp dụng",
"delete": "Xóa",
"add": "Thêm",
"select": "Chọn", "select": "Chọn",
"name": "Tên", "name": "Tên",
"date": "Ngày", "date": "Ngày",
"enabled": "Mở",
"disabled": "Tắt",
"reEnable": "Mở lại",
"setExpiry": "Đặt hết hạn", "setExpiry": "Đặt hết hạn",
"disable": "Tắt",
"admin": "Admin",
"updates": "Cập nhật", "updates": "Cập nhật",
"update": "Cập nhật", "update": "Cập nhật",
"download": "Tải về", "download": "Tải về",
@ -38,7 +29,6 @@
"lastActiveTime": "Lần cuối Hoạt động", "lastActiveTime": "Lần cuối Hoạt động",
"from": "Từ", "from": "Từ",
"user": "Người dùng", "user": "Người dùng",
"expiry": "Hết hạn",
"userExpiry": "Hết hạn Người dùng", "userExpiry": "Hết hạn Người dùng",
"userExpiryDescription": "Sau một khoảng thời gian nhất định sau khi mỗi đăng ký, jfa-go sẽ xóa/vô hiệu hóa tài khoản. Bạn có thể chỉnh sửa chế độ này trong cài đặt.", "userExpiryDescription": "Sau một khoảng thời gian nhất định sau khi mỗi đăng ký, jfa-go sẽ xóa/vô hiệu hóa tài khoản. Bạn có thể chỉnh sửa chế độ này trong cài đặt.",
"aboutProgram": "Thông tin", "aboutProgram": "Thông tin",
@ -56,7 +46,6 @@
"conditionals": "Điều kiện", "conditionals": "Điều kiện",
"preview": "Xem trước", "preview": "Xem trước",
"reset": "Đặt lại", "reset": "Đặt lại",
"edit": "Chỉnh sửa",
"donate": "Đóng góp", "donate": "Đóng góp",
"sendPWR": "Gửi Đặt lại Mật khẩu", "sendPWR": "Gửi Đặt lại Mật khẩu",
"contactThrough": "Liên lạc qua:", "contactThrough": "Liên lạc qua:",
@ -127,13 +116,9 @@
"updateAppliedRefresh": "Cập nhật mới đã được áp dụng, vui lòng làm mới lại trang.", "updateAppliedRefresh": "Cập nhật mới đã được áp dụng, vui lòng làm mới lại trang.",
"telegramVerified": "Tài khoản Telegram đã được xác thực.", "telegramVerified": "Tài khoản Telegram đã được xác thực.",
"accountConnected": "Tài khoản đã được kết nối.", "accountConnected": "Tài khoản đã được kết nối.",
"errorConnection": "Không thể kết nối với jfa-go.",
"error401Unauthorized": "Không được phép. Hãy thử làm mới trang.",
"errorSettingsAppliedNoHomescreenLayout": "Cài đặt đã được áp dụng, nhưng việc áp dụng bố cục màn hình chính có thể không thành công.", "errorSettingsAppliedNoHomescreenLayout": "Cài đặt đã được áp dụng, nhưng việc áp dụng bố cục màn hình chính có thể không thành công.",
"errorHomescreenAppliedNoSettings": "", "errorHomescreenAppliedNoSettings": "",
"errorSettingsFailed": "", "errorSettingsFailed": "",
"errorLoginBlank": "",
"errorUnknown": "",
"errorSaveEmail": "", "errorSaveEmail": "",
"errorBlankFields": "", "errorBlankFields": "",
"errorDeleteProfile": "", "errorDeleteProfile": "",
@ -141,7 +126,6 @@
"errorCreateProfile": "", "errorCreateProfile": "",
"errorSetDefaultProfile": "", "errorSetDefaultProfile": "",
"errorLoadUsers": "", "errorLoadUsers": "",
"errorSaveSettings": "",
"errorLoadSettings": "", "errorLoadSettings": "",
"errorSetOmbiProfile": "", "errorSetOmbiProfile": "",
"errorLoadOmbiUsers": "", "errorLoadOmbiUsers": "",

View File

@ -15,20 +15,11 @@
"warning": "警告", "warning": "警告",
"inviteInfiniteUsesWarning": "无限使用次数的邀请码可能被滥用", "inviteInfiniteUsesWarning": "无限使用次数的邀请码可能被滥用",
"inviteSendToEmail": "发送到", "inviteSendToEmail": "发送到",
"login": "登录",
"logout": "登出",
"create": "创建", "create": "创建",
"apply": "申请", "apply": "申请",
"delete": "删除",
"add": "添加",
"select": "选择", "select": "选择",
"name": "名称", "name": "名称",
"date": "日期", "date": "日期",
"enabled": "已启用",
"disabled": "已禁用",
"reEnable": "重新启用",
"disable": "禁用",
"admin": "管理员",
"updates": "更新", "updates": "更新",
"update": "更新", "update": "更新",
"download": "下载", "download": "下载",
@ -37,7 +28,6 @@
"lastActiveTime": "上次活动", "lastActiveTime": "上次活动",
"from": "从", "from": "从",
"user": "用户", "user": "用户",
"expiry": "到期",
"userExpiry": "用户到期", "userExpiry": "用户到期",
"userExpiryDescription": "每次注册后的指定时间jfa-go 将删除/禁用该帐户。您可以在设置中更改此行为。", "userExpiryDescription": "每次注册后的指定时间jfa-go 将删除/禁用该帐户。您可以在设置中更改此行为。",
"aboutProgram": "关于", "aboutProgram": "关于",
@ -55,7 +45,6 @@
"conditionals": "条件性条款", "conditionals": "条件性条款",
"preview": "预览", "preview": "预览",
"reset": "重设", "reset": "重设",
"edit": "编辑",
"donate": "捐助", "donate": "捐助",
"contactThrough": "联系方式:", "contactThrough": "联系方式:",
"extendExpiry": "延长有效期", "extendExpiry": "延长有效期",
@ -129,13 +118,9 @@
"updateAppliedRefresh": "已应用更新,请刷新。", "updateAppliedRefresh": "已应用更新,请刷新。",
"telegramVerified": "Telegram账户已验证。", "telegramVerified": "Telegram账户已验证。",
"accountConnected": "帐户已连接。", "accountConnected": "帐户已连接。",
"errorConnection": "无法连接到 jfa-go。",
"error401Unauthorized": "无授权。尝试刷新页面。",
"errorSettingsAppliedNoHomescreenLayout": "已应用设置,但应用主屏幕布局可能失败。", "errorSettingsAppliedNoHomescreenLayout": "已应用设置,但应用主屏幕布局可能失败。",
"errorHomescreenAppliedNoSettings": "已应用主屏幕布局,但应用设置可能失败。", "errorHomescreenAppliedNoSettings": "已应用主屏幕布局,但应用设置可能失败。",
"errorSettingsFailed": "应用失败。", "errorSettingsFailed": "应用失败。",
"errorLoginBlank": "用户名/密码留空。",
"errorUnknown": "未知错误。",
"errorSaveEmail": "电子邮箱保存失败。", "errorSaveEmail": "电子邮箱保存失败。",
"errorBlankFields": "字段留空", "errorBlankFields": "字段留空",
"errorDeleteProfile": "删除配置文件{n}失败", "errorDeleteProfile": "删除配置文件{n}失败",
@ -143,7 +128,6 @@
"errorCreateProfile": "创建配置文件{n}失败", "errorCreateProfile": "创建配置文件{n}失败",
"errorSetDefaultProfile": "设置默认配置文件失败。", "errorSetDefaultProfile": "设置默认配置文件失败。",
"errorLoadUsers": "加载用户列表失败。", "errorLoadUsers": "加载用户列表失败。",
"errorSaveSettings": "无法保存设置。",
"errorLoadSettings": "加载配置列表失败。", "errorLoadSettings": "加载配置列表失败。",
"errorSetOmbiDefaults": "存储Ombi默认值失败。", "errorSetOmbiDefaults": "存储Ombi默认值失败。",
"errorLoadOmbiUsers": "加载ombi用户列表失败。", "errorLoadOmbiUsers": "加载ombi用户列表失败。",

View File

@ -15,21 +15,12 @@
"warning": "警告", "warning": "警告",
"inviteInfiniteUsesWarning": "無限使用次數的邀請碼可能被濫用", "inviteInfiniteUsesWarning": "無限使用次數的邀請碼可能被濫用",
"inviteSendToEmail": "發送到", "inviteSendToEmail": "發送到",
"login": "登錄",
"logout": "登出",
"create": "創建", "create": "創建",
"apply": "應用", "apply": "應用",
"delete": "刪除",
"add": "添加",
"select": "選擇", "select": "選擇",
"name": "帳戶名稱", "name": "帳戶名稱",
"date": "日期", "date": "日期",
"enabled": "已啟用",
"disabled": "已禁用",
"reEnable": "重新啟用",
"setExpiry": "設置到期時間", "setExpiry": "設置到期時間",
"disable": "禁用",
"admin": "管理員",
"updates": "更新", "updates": "更新",
"update": "更新", "update": "更新",
"download": "下載", "download": "下載",
@ -38,7 +29,6 @@
"lastActiveTime": "上次啟用時間", "lastActiveTime": "上次啟用時間",
"from": "從", "from": "從",
"user": "帳戶", "user": "帳戶",
"expiry": "到期",
"userExpiry": "帳戶到期", "userExpiry": "帳戶到期",
"userExpiryDescription": "每次註冊后指定的時間jfa-go 將刪除/禁用該帳戶。您可以在設定中更改此行為。", "userExpiryDescription": "每次註冊后指定的時間jfa-go 將刪除/禁用該帳戶。您可以在設定中更改此行為。",
"aboutProgram": "關於", "aboutProgram": "關於",
@ -57,7 +47,6 @@
"conditionals": "條件", "conditionals": "條件",
"preview": "預覽", "preview": "預覽",
"reset": "重設", "reset": "重設",
"edit": "編輯",
"donate": "捐贈", "donate": "捐贈",
"sendPWR": "發送密碼重置", "sendPWR": "發送密碼重置",
"contactThrough": "聯繫方式:", "contactThrough": "聯繫方式:",
@ -128,13 +117,9 @@
"updateAppliedRefresh": "更新已應用,請重新整理。", "updateAppliedRefresh": "更新已應用,請重新整理。",
"telegramVerified": "Telegram 帳戶已驗證。", "telegramVerified": "Telegram 帳戶已驗證。",
"accountConnected": "帳戶已連接。", "accountConnected": "帳戶已連接。",
"errorConnection": "無法連接到 jfa-go。",
"error401Unauthorized": "未經授權。嘗試重新整理頁面。",
"errorSettingsAppliedNoHomescreenLayout": "已應用設置,但應用主螢幕佈局可能失敗。", "errorSettingsAppliedNoHomescreenLayout": "已應用設置,但應用主螢幕佈局可能失敗。",
"errorHomescreenAppliedNoSettings": "已應用主螢幕佈局,但應用設置可能失敗。", "errorHomescreenAppliedNoSettings": "已應用主螢幕佈局,但應用設置可能失敗。",
"errorSettingsFailed": "應用失敗。", "errorSettingsFailed": "應用失敗。",
"errorLoginBlank": "帳戶名稱和/或密碼留空。",
"errorUnknown": "未知的錯誤。",
"errorSaveEmail": "無法儲存電子郵件。", "errorSaveEmail": "無法儲存電子郵件。",
"errorBlankFields": "欄位留空", "errorBlankFields": "欄位留空",
"errorDeleteProfile": "無法刪除設置文件 {n}", "errorDeleteProfile": "無法刪除設置文件 {n}",
@ -142,7 +127,6 @@
"errorCreateProfile": "無法創建設置文件 {n}", "errorCreateProfile": "無法創建設置文件 {n}",
"errorSetDefaultProfile": "無法設置預設設置文件。", "errorSetDefaultProfile": "無法設置預設設置文件。",
"errorLoadUsers": "無法讀取帳戶。", "errorLoadUsers": "無法讀取帳戶。",
"errorSaveSettings": "無法儲存設置。",
"errorLoadSettings": "無法讀取設置。", "errorLoadSettings": "無法讀取設置。",
"errorSetOmbiProfile": "無法儲存 ombi 設置文件。", "errorSetOmbiProfile": "無法儲存 ombi 設置文件。",
"errorLoadOmbiUsers": "無法讀取 ombi 帳戶。", "errorLoadOmbiUsers": "無法讀取 ombi 帳戶。",

8
lang/common/ar-aa.json Normal file
View File

@ -0,0 +1,8 @@
{
"meta": {
"name": "العربية (AR)"
},
"strings": {},
"notifications": {},
"quantityStrings": {}
}

View File

@ -24,6 +24,25 @@
"contactDiscord": "Kontakt gennem Discord", "contactDiscord": "Kontakt gennem Discord",
"theme": "Tema", "theme": "Tema",
"refresh": "Opdater", "refresh": "Opdater",
"required": "Påkrævet" "required": "Påkrævet",
} "login": "Log på",
"logout": "Log ud",
"admin": "Administrator",
"enabled": "Aktiveret",
"disabled": "Deaktiveret",
"reEnable": "Genaktiver",
"disable": "Deaktiver",
"expiry": "Udløb",
"add": "Tilføj",
"edit": "Rediger",
"delete": "Slet"
},
"notifications": {
"errorLoginBlank": "Brugernavnet og/eller adgangskoden blev efterladt tomme.",
"errorConnection": "Kunne ikke oprette forbindelse til jfa-go.",
"errorUnknown": "Ukendt fejl.",
"error401Unauthorized": "Adgang nægtet. Prøv at genindlæse siden.",
"errorSaveSettings": "Kunne ikke gemme indstillingerne."
},
"quantityStrings": {}
} }

View File

@ -4,26 +4,45 @@
}, },
"strings": { "strings": {
"username": "Benutzername", "username": "Benutzername",
"name": "Name",
"password": "Passwort", "password": "Passwort",
"emailAddress": "E-Mail Adresse", "emailAddress": "E-Mail Adresse",
"name": "Name",
"submit": "Absenden", "submit": "Absenden",
"send": "Senden",
"success": "Erfolgreich", "success": "Erfolgreich",
"continue": "Weiter", "continue": "Weiter",
"error": "Fehler", "error": "Fehler",
"copy": "Kopieren", "copy": "Kopieren",
"theme": "Thema", "copied": "Kopiert",
"time24h": "24h-Format", "time24h": "24h-Format",
"time12h": "12h-Format", "time12h": "12h-Format",
"copied": "Kopiert",
"linkTelegram": "Link Telegram", "linkTelegram": "Link Telegram",
"contactEmail": "Kontakt über E-Mail", "contactEmail": "Kontakt über E-Mail",
"contactTelegram": "Kontakt über Telegram", "contactTelegram": "Kontakt über Telegram",
"linkDiscord": "Link Discord", "linkDiscord": "Link Discord",
"linkMatrix": "Link Matrix", "linkMatrix": "Link Matrix",
"send": "Senden",
"contactDiscord": "Kontakt über Discord", "contactDiscord": "Kontakt über Discord",
"theme": "Thema",
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
"required": "Erforderlich" "required": "Erforderlich",
} "login": "Anmelden",
"logout": "Abmelden",
"admin": "Admin",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"reEnable": "Wieder aktivieren",
"disable": "Deaktivieren",
"expiry": "Ablaufdatum",
"add": "Hinzufügen",
"edit": "Bearbeiten",
"delete": "Löschen"
},
"notifications": {
"errorLoginBlank": "Der Benutzername und/oder das Passwort wurden nicht ausgefüllt.",
"errorConnection": "Konnte keine Verbindung zu jfa-go herstellen.",
"errorUnknown": "Unbekannter Fehler.",
"error401Unauthorized": "Unberechtigt. Versuch, die Seite zu aktualisieren.",
"errorSaveSettings": "Einstellungen konnten nicht gespeichert werden."
},
"quantityStrings": {}
} }

View File

@ -12,9 +12,27 @@
"continue": "Συνέχεια", "continue": "Συνέχεια",
"error": "Σφάλμα", "error": "Σφάλμα",
"copy": "Αντιγραφή", "copy": "Αντιγραφή",
"theme": "Θέμα", "copied": "Αντιγράφηκε",
"time24h": "24 Ώρες", "time24h": "24 Ώρες",
"time12h": "12 Ώρες", "time12h": "12 Ώρες",
"copied": "Αντιγράφηκε" "theme": "Θέμα",
} "login": "Σύνδεση",
"logout": "Αποσύνδεση",
"admin": "Διαχειριστής",
"enabled": "Ενεργοποιημένο",
"disabled": "Απενεργοποιημένο",
"reEnable": "Επανα-ενεργοποίηση",
"disable": "Απενεργοποίηση",
"expiry": "Λήξη",
"edit": "Επεξεργασία",
"delete": "Διαγραφή"
},
"notifications": {
"errorLoginBlank": "Το όνομα χρήστη και/ή ο κωδικός ήταν κενά.",
"errorConnection": "Δεν μπόρεσε να συνδεθεί με το jfa-go.",
"errorUnknown": "Άγνωστο σφάλμα.",
"error401Unauthorized": "Ανεξουσιοδότητος. Προσπαθήστε να κάνετε επαναφόρτωση την σελίδα.",
"errorSaveSettings": "Αποτυχία αποθήκευσης ρυθμίσεων."
},
"quantityStrings": {}
} }

View File

@ -3,27 +3,46 @@
"name": "English (GB)" "name": "English (GB)"
}, },
"strings": { "strings": {
"continue": "Continue",
"time24h": "24h Time",
"linkTelegram": "Link Telegram",
"send": "Send",
"linkDiscord": "Link Discord",
"linkMatrix": "Link Matrix",
"contactDiscord": "Contact through Discord",
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"emailAddress": "Email Address", "emailAddress": "Email Address",
"name": "Name",
"submit": "Submit",
"send": "Send",
"success": "Success",
"continue": "Continue",
"error": "Error",
"copy": "Copy", "copy": "Copy",
"copied": "Copied", "copied": "Copied",
"submit": "Submit", "time24h": "24h Time",
"success": "Success",
"error": "Error",
"time12h": "12h Time", "time12h": "12h Time",
"theme": "Theme", "linkTelegram": "Link Telegram",
"contactEmail": "Contact through Email", "contactEmail": "Contact through Email",
"contactTelegram": "Contact through Telegram", "contactTelegram": "Contact through Telegram",
"name": "Name", "linkDiscord": "Link Discord",
"linkMatrix": "Link Matrix",
"contactDiscord": "Contact through Discord",
"theme": "Theme",
"refresh": "Refresh", "refresh": "Refresh",
"required": "Required" "required": "Required",
} "login": "Login",
"logout": "Logout",
"admin": "Admin",
"enabled": "Enabled",
"disabled": "Disabled",
"reEnable": "Re-enable",
"disable": "Disable",
"expiry": "Expiry",
"add": "Add",
"edit": "Edit",
"delete": "Delete"
},
"notifications": {
"errorLoginBlank": "The username and/or password was left blank.",
"errorConnection": "Couldn't connect to jfa-go.",
"errorUnknown": "Unknown error.",
"error401Unauthorized": "Unauthorised. Try refreshing the page.",
"errorSaveSettings": "Couldn't save settings."
},
"quantityStrings": {}
} }

View File

@ -24,6 +24,42 @@
"contactDiscord": "Contact through Discord", "contactDiscord": "Contact through Discord",
"theme": "Theme", "theme": "Theme",
"refresh": "Refresh", "refresh": "Refresh",
"required": "Required" "required": "Required",
"login": "Login",
"logout": "Logout",
"admin": "Admin",
"enabled": "Enabled",
"disabled": "Disabled",
"reEnable": "Re-enable",
"disable": "Disable",
"contactMethods": "Contact Methods",
"accountStatus": "Account Status",
"notSet": "Not set",
"expiry": "Expiry",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"myAccount": "My Account"
},
"notifications": {
"errorLoginBlank": "The username and/or password were left blank.",
"errorConnection": "Couldn't connect to jfa-go.",
"errorUnknown": "Unknown error.",
"error401Unauthorized": "Unauthorized. Try refreshing the page.",
"errorSaveSettings": "Couldn't save settings."
},
"quantityStrings": {
"year": {
"singular": "{n} Year",
"plural": "{n} Years"
},
"month": {
"singular": "{n} Month",
"plural": "{n} Months"
},
"day": {
"singular": "{n} Day",
"plural": "{n} Days"
}
} }
} }

View File

@ -8,6 +8,7 @@
"emailAddress": "Correo electrónico", "emailAddress": "Correo electrónico",
"name": "Nombre", "name": "Nombre",
"submit": "Enviar", "submit": "Enviar",
"send": "Enviar",
"success": "Éxito", "success": "Éxito",
"continue": "Continuar", "continue": "Continuar",
"error": "Error", "error": "Error",
@ -15,15 +16,33 @@
"copied": "Copiado", "copied": "Copiado",
"time24h": "Formato de 24 horas", "time24h": "Formato de 24 horas",
"time12h": "Formato de 12 horas", "time12h": "Formato de 12 horas",
"theme": "Tema", "linkTelegram": "Enlace Telegram",
"send": "Enviar",
"contactDiscord": "Contactar por Discord",
"contactEmail": "Contactar por correo electrónico", "contactEmail": "Contactar por correo electrónico",
"contactTelegram": "Contactar por Telegram", "contactTelegram": "Contactar por Telegram",
"linkMatrix": "Enlace Matrix",
"linkDiscord": "Enlace Discord", "linkDiscord": "Enlace Discord",
"linkTelegram": "Enlace Telegram", "linkMatrix": "Enlace Matrix",
"contactDiscord": "Contactar por Discord",
"theme": "Tema",
"refresh": "Refrescar", "refresh": "Refrescar",
"required": "Requerido" "required": "Requerido",
} "login": "Acceso",
"logout": "Cerrar sesión",
"admin": "Administrador",
"enabled": "Activado",
"disabled": "Desactivado",
"reEnable": "Reactivar",
"disable": "Desactivar",
"expiry": "Expiración",
"add": "Agregar",
"edit": "Editar",
"delete": "Eliminar"
},
"notifications": {
"errorLoginBlank": "El nombre de usuario y/o la contraseña se dejaron en blanco.",
"errorConnection": "No se pudo conectar a jfa-go.",
"errorUnknown": "Error desconocido.",
"error401Unauthorized": "No autorizado. Intente actualizar la página.",
"errorSaveSettings": "No se pudo guardar la configuración."
},
"quantityStrings": {}
} }

View File

@ -23,5 +23,7 @@
"linkMatrix": "پیوند ماتریکس", "linkMatrix": "پیوند ماتریکس",
"contactDiscord": "از طریق دیسکورد تماس بگیرید", "contactDiscord": "از طریق دیسکورد تماس بگیرید",
"theme": "موضوع" "theme": "موضوع"
} },
"notifications": {},
"quantityStrings": {}
} }

View File

@ -1,30 +1,48 @@
{ {
"meta": { "meta": {
"name": "Français (FR)", "name": "Français (FR)"
"author": "https://github.com/Killianbe"
}, },
"strings": { "strings": {
"username": "Nom d'utilisateur", "username": "Nom d'utilisateur",
"name": "Nom",
"password": "Mot de passe", "password": "Mot de passe",
"emailAddress": "Adresse e-mail", "emailAddress": "Adresse e-mail",
"name": "Nom",
"submit": "Soumettre", "submit": "Soumettre",
"send": "Envoyer",
"success": "Succès", "success": "Succès",
"continue": "Continuer", "continue": "Continuer",
"error": "Erreur", "error": "Erreur",
"copy": "Copier", "copy": "Copier",
"copied": "Copié",
"time24h": "Temps 24h", "time24h": "Temps 24h",
"time12h": "Temps 12h", "time12h": "Temps 12h",
"theme": "Thème",
"copied": "Copié",
"linkTelegram": "Lien Telegram", "linkTelegram": "Lien Telegram",
"contactEmail": "Contact par e-mail", "contactEmail": "Contact par e-mail",
"contactTelegram": "Contact par Telegram", "contactTelegram": "Contact par Telegram",
"linkDiscord": "Lier Discord", "linkDiscord": "Lier Discord",
"linkMatrix": "Lier Matrix", "linkMatrix": "Lier Matrix",
"send": "Envoyer",
"contactDiscord": "Contacter par Discord", "contactDiscord": "Contacter par Discord",
"theme": "Thème",
"refresh": "Actualiser", "refresh": "Actualiser",
"required": "Requis" "required": "Requis",
} "login": "S'identifier",
"logout": "Se déconnecter",
"admin": "Administrateur",
"enabled": "Activé",
"disabled": "Désactivé",
"reEnable": "Ré-activé",
"disable": "Désactivé",
"expiry": "Expiration",
"add": "Ajouter",
"edit": "Éditer",
"delete": "Effacer"
},
"notifications": {
"errorLoginBlank": "Le nom d'utilisateur et/ou le mot de passe sont vides.",
"errorConnection": "Impossible de se connecter à jfa-go.",
"errorUnknown": "Erreur inconnue.",
"error401Unauthorized": "Non autorisé. Essayez d'actualiser la page.",
"errorSaveSettings": "Impossible d'enregistrer les paramètres."
},
"quantityStrings": {}
} }

20
lang/common/hu-hu.json Normal file
View File

@ -0,0 +1,20 @@
{
"meta": {
"name": "English (US)"
},
"strings": {
"login": "Belépés",
"logout": "Kijelentkezés",
"admin": "Adminisztrátor",
"enabled": "Engedélyezve",
"disabled": "Tiltva",
"reEnable": "Újra engedélyezés",
"disable": "Letiltás",
"expiry": "Lejárat",
"add": "Hozzáadás",
"edit": "Szerkesztés",
"delete": "Törlés"
},
"notifications": {},
"quantityStrings": {}
}

View File

@ -8,6 +8,7 @@
"emailAddress": "Alamat Email", "emailAddress": "Alamat Email",
"name": "Nama", "name": "Nama",
"submit": "Submit", "submit": "Submit",
"send": "Kirim",
"success": "Sukses", "success": "Sukses",
"continue": "Lanjut", "continue": "Lanjut",
"error": "Error", "error": "Error",
@ -15,6 +16,17 @@
"time24h": "Waktu 24 jam", "time24h": "Waktu 24 jam",
"time12h": "Waktu 12 jam", "time12h": "Waktu 12 jam",
"theme": "Tema", "theme": "Tema",
"send": "Kirim" "login": "Masuk",
} "logout": "Keluar",
"edit": "Edit",
"delete": "Hapus"
},
"notifications": {
"errorLoginBlank": "Nama pengguna dan / atau sandi kosong.",
"errorConnection": "Tidak dapat terhubung ke jfa-go.",
"errorUnknown": "Kesalahan yang tidak diketahui.",
"error401Unauthorized": "Tidak ter-otorisasi. Coba segarkan halaman.",
"errorSaveSettings": "Tidak dapat menyimpan pengaturan."
},
"quantityStrings": {}
} }

View File

@ -25,5 +25,7 @@
"theme": "Tema", "theme": "Tema",
"refresh": "Aggiorna", "refresh": "Aggiorna",
"required": "Richiesto" "required": "Richiesto"
} },
"notifications": {},
"quantityStrings": {}
} }

View File

@ -4,26 +4,45 @@
}, },
"strings": { "strings": {
"username": "Gebruikersnaam", "username": "Gebruikersnaam",
"name": "Naam",
"password": "Wachtwoord", "password": "Wachtwoord",
"emailAddress": "E-mailadres", "emailAddress": "E-mailadres",
"name": "Naam",
"submit": "Verstuur", "submit": "Verstuur",
"send": "Verstuur",
"success": "Succes", "success": "Succes",
"continue": "Doorgaan", "continue": "Doorgaan",
"error": "Fout", "error": "Fout",
"copy": "Kopiëer", "copy": "Kopiëer",
"theme": "Thema", "copied": "Gekopieerd",
"time24h": "24u-formaat", "time24h": "24u-formaat",
"time12h": "12u-formaat", "time12h": "12u-formaat",
"copied": "Gekopieerd",
"linkTelegram": "Koppel Telegram", "linkTelegram": "Koppel Telegram",
"contactEmail": "Stuur e-mailbericht", "contactEmail": "Stuur e-mailbericht",
"contactTelegram": "Stuur Telegram-bericht", "contactTelegram": "Stuur Telegram-bericht",
"send": "Verstuur",
"linkDiscord": "Koppel Discord", "linkDiscord": "Koppel Discord",
"linkMatrix": "Koppel Matrix", "linkMatrix": "Koppel Matrix",
"contactDiscord": "Stuur Discord bericht", "contactDiscord": "Stuur Discord bericht",
"theme": "Thema",
"refresh": "Ververs", "refresh": "Ververs",
"required": "Verplicht" "required": "Verplicht",
} "login": "Inloggen",
"logout": "Uitloggen",
"admin": "Beheerder",
"enabled": "Ingeschakeld",
"disabled": "Uitgeschakeld",
"reEnable": "Opnieuw inschakelen",
"disable": "Uitschakelen",
"expiry": "Verloop",
"add": "Voeg toe",
"edit": "Bewerken",
"delete": "Verwijderen"
},
"notifications": {
"errorLoginBlank": "De gebruikersnaam en/of wachtwoord is leeg.",
"errorConnection": "Kon geen verbinding maken met jfa-go.",
"errorUnknown": "Onbekende fout.",
"error401Unauthorized": "Geen toegang. Probeer de pagina te vernieuwen.",
"errorSaveSettings": "Opslaan van instellingen mislukt."
},
"quantityStrings": {}
} }

View File

@ -24,6 +24,18 @@
"contactDiscord": "Kontakt przez Discord", "contactDiscord": "Kontakt przez Discord",
"theme": "Motyw", "theme": "Motyw",
"refresh": "Odśwież", "refresh": "Odśwież",
"required": "Wymagane" "required": "Wymagane",
} "admin": "Admin",
"enabled": "Włączone",
"disabled": "Wyłączone",
"disable": "Wyłączone",
"expiry": "Wygasa",
"edit": "Edytuj"
},
"notifications": {
"errorConnection": "Nie udało się połączyć z jfa-go.",
"errorUnknown": "Nieznany błąd.",
"error401Unauthorized": "Nieautoryzowany. Spróbuj odświeżyć stronę."
},
"quantityStrings": {}
} }

View File

@ -4,26 +4,45 @@
}, },
"strings": { "strings": {
"username": "Nome do Usuário", "username": "Nome do Usuário",
"name": "Nome",
"password": "Senha", "password": "Senha",
"emailAddress": "Endereço de e-mail", "emailAddress": "Endereço de e-mail",
"name": "Nome",
"submit": "Enviar", "submit": "Enviar",
"send": "Enviar",
"success": "Sucesso", "success": "Sucesso",
"continue": "Continuar", "continue": "Continuar",
"error": "Erro", "error": "Erro",
"copy": "Copiar", "copy": "Copiar",
"theme": "Tema", "copied": "Copiado",
"time24h": "Horário 24h", "time24h": "Horário 24h",
"time12h": "Horário 12h", "time12h": "Horário 12h",
"copied": "Copiado",
"linkTelegram": "Link do Telegram", "linkTelegram": "Link do Telegram",
"contactEmail": "Contato por Email", "contactEmail": "Contato por Email",
"contactTelegram": "Contato pelo Telegram", "contactTelegram": "Contato pelo Telegram",
"send": "Enviar",
"linkDiscord": "Link do Discord", "linkDiscord": "Link do Discord",
"linkMatrix": "Link do Matrix", "linkMatrix": "Link do Matrix",
"contactDiscord": "Contato através do Discord", "contactDiscord": "Contato através do Discord",
"theme": "Tema",
"refresh": "Atualizar", "refresh": "Atualizar",
"required": "Requeridos" "required": "Requeridos",
} "login": "Login",
"logout": "Sair",
"admin": "Admin",
"enabled": "Habilitado",
"disabled": "Desativado",
"reEnable": "Reativar",
"disable": "Desativar",
"expiry": "Expira",
"add": "Adicionar",
"edit": "Editar",
"delete": "Deletar"
},
"notifications": {
"errorLoginBlank": "O nome de usuário e/ou senha foram deixados em branco.",
"errorConnection": "Não foi possível conectar ao jfa-go.",
"errorUnknown": "Erro desconhecido.",
"error401Unauthorized": "Não autorizado. Tente atualizar a página.",
"errorSaveSettings": "Não foi possível salvar as configurações."
},
"quantityStrings": {}
} }

8
lang/common/ro-ro.json Normal file
View File

@ -0,0 +1,8 @@
{
"meta": {
"name": "Română (ROU)"
},
"strings": {},
"notifications": {},
"quantityStrings": {}
}

View File

@ -25,5 +25,7 @@
"theme": "Tema", "theme": "Tema",
"refresh": "Osveži", "refresh": "Osveži",
"required": "Obvezno" "required": "Obvezno"
} },
"notifications": {},
"quantityStrings": {}
} }

View File

@ -14,6 +14,22 @@
"copy": "Kopiera", "copy": "Kopiera",
"time24h": "24 timmarsklocka", "time24h": "24 timmarsklocka",
"time12h": "12 timmarsklocka", "time12h": "12 timmarsklocka",
"theme": "Tema" "theme": "Tema",
} "login": "Logga in",
"logout": "Logga ut",
"admin": "Admin",
"enabled": "Aktiverad",
"disabled": "Inaktiverad",
"expiry": "Löper ut",
"edit": "Redigera",
"delete": "Radera"
},
"notifications": {
"errorLoginBlank": "Användarnamnet och/eller lösenordet lämnades tomt.",
"errorConnection": "Det gick inte att ansluta till jfa-go.",
"errorUnknown": "Okänt fel.",
"error401Unauthorized": "Obehörig. Prova att uppdatera sidan.",
"errorSaveSettings": "Det gick inte att spara inställningarna."
},
"quantityStrings": {}
} }

23
lang/common/vi-vn.json Normal file
View File

@ -0,0 +1,23 @@
{
"meta": {
"name": "Tiếng Anh (Mỹ)"
},
"strings": {
"login": "Đăng nhập",
"logout": "Đăng xuất",
"admin": "Admin",
"enabled": "Mở",
"disabled": "Tắt",
"reEnable": "Mở lại",
"disable": "Tắt",
"expiry": "Hết hạn",
"add": "Thêm",
"edit": "Chỉnh sửa",
"delete": "Xóa"
},
"notifications": {
"errorConnection": "Không thể kết nối với jfa-go.",
"error401Unauthorized": "Không được phép. Hãy thử làm mới trang."
},
"quantityStrings": {}
}

View File

@ -24,6 +24,25 @@
"contactDiscord": "通过Discord联系", "contactDiscord": "通过Discord联系",
"theme": "主题", "theme": "主题",
"refresh": "刷新", "refresh": "刷新",
"required": "必需的" "required": "必需的",
} "login": "登录",
"logout": "登出",
"admin": "管理员",
"enabled": "已启用",
"disabled": "已禁用",
"reEnable": "重新启用",
"disable": "禁用",
"expiry": "到期",
"add": "添加",
"edit": "编辑",
"delete": "删除"
},
"notifications": {
"errorLoginBlank": "用户名/密码留空。",
"errorConnection": "无法连接到 jfa-go。",
"errorUnknown": "未知错误。",
"error401Unauthorized": "无授权。尝试刷新页面。",
"errorSaveSettings": "无法保存设置。"
},
"quantityStrings": {}
} }

View File

@ -24,6 +24,25 @@
"contactDiscord": "通過 Discord 聯繫", "contactDiscord": "通過 Discord 聯繫",
"theme": "主題", "theme": "主題",
"refresh": "重新整理", "refresh": "重新整理",
"required": "必填" "required": "必填",
} "login": "登錄",
"logout": "登出",
"admin": "管理員",
"enabled": "已啟用",
"disabled": "已禁用",
"reEnable": "重新啟用",
"disable": "禁用",
"expiry": "到期",
"add": "添加",
"edit": "編輯",
"delete": "刪除"
},
"notifications": {
"errorLoginBlank": "帳戶名稱和/或密碼留空。",
"errorConnection": "無法連接到 jfa-go。",
"errorUnknown": "未知的錯誤。",
"error401Unauthorized": "未經授權。嘗試重新整理頁面。",
"errorSaveSettings": "無法儲存設置。"
},
"quantityStrings": {}
} }

View File

@ -8,6 +8,8 @@
"accountDetails": "Details", "accountDetails": "Details",
"emailAddress": "Email", "emailAddress": "Email",
"username": "Username", "username": "Username",
"oldPassword": "Old Password",
"newPassword": "New Password",
"password": "Password", "password": "Password",
"reEnterPassword": "Re-enter Password", "reEnterPassword": "Re-enter Password",
"reEnterPasswordInvalid": "Passwords are not the same.", "reEnterPasswordInvalid": "Passwords are not the same.",
@ -19,7 +21,20 @@
"yourAccountIsValidUntil": "Your account will be valid until {date}.", "yourAccountIsValidUntil": "Your account will be valid until {date}.",
"sendPIN": "Send the PIN below to the bot, then come back here to link your account.", "sendPIN": "Send the PIN below to the bot, then come back here to link your account.",
"sendPINDiscord": "Type {command} in {server_channel} on Discord, then send the PIN below.", "sendPINDiscord": "Type {command} in {server_channel} on Discord, then send the PIN below.",
"matrixEnterUser": "Enter your User ID, press submit, and a PIN will be sent to you. Enter it here to continue." "matrixEnterUser": "Enter your User ID, press submit, and a PIN will be sent to you. Enter it here to continue.",
"welcomeUser": "Welcome, {user}!",
"addContactMethod": "Add Contact Method",
"editContactMethod": "Edit Contact Method",
"joinTheServer": "Join the server:",
"customMessagePlaceholderHeader": "Customize this card",
"customMessagePlaceholderContent": "Click the user page edit button in settings to customize this card, or show one on the login screen, and don't worry, the user can't see this.",
"userPageSuccessMessage": "You can see and change details about your account later on the {myAccount} page.",
"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.",
"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"
}, },
"notifications": { "notifications": {
"errorUserExists": "User already exists.", "errorUserExists": "User already exists.",
@ -35,6 +50,8 @@
"errorCaptcha": "Captcha incorrect.", "errorCaptcha": "Captcha incorrect.",
"errorPassword": "Check password requirements.", "errorPassword": "Check password requirements.",
"errorNoMatch": "Passwords don't match.", "errorNoMatch": "Passwords don't match.",
"errorOldPassword": "Old password incorrect.",
"passwordChanged": "Password Changed.",
"verified": "Account verified." "verified": "Account verified."
}, },
"validationStrings": { "validationStrings": {

15
main.go
View File

@ -17,6 +17,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
@ -87,7 +88,7 @@ type appContext struct {
webFS httpFS webFS httpFS
cssClass string // Default theme, "light"|"dark". cssClass string // Default theme, "light"|"dark".
jellyfinLogin bool jellyfinLogin bool
users []User adminUsers []User
invalidTokens []string invalidTokens []string
// Keeping jf name because I can't think of a better one // Keeping jf name because I can't think of a better one
jf *mediabrowser.MediaBrowser jf *mediabrowser.MediaBrowser
@ -111,6 +112,8 @@ type appContext struct {
tag Tag tag Tag
update Update update Update
internalPWRs map[string]InternalPWR internalPWRs map[string]InternalPWR
ConfirmationKeys map[string]map[string]newUserDTO // Map of invite code to jwt to request
confirmationKeysLock sync.Mutex
} }
func generateSecret(length int) (string, error) { func generateSecret(length int) (string, error) {
@ -284,7 +287,7 @@ func start(asDaemon, firstCall bool) {
} }
app.storage.lang.CommonPath = "common" app.storage.lang.CommonPath = "common"
app.storage.lang.FormPath = "form" app.storage.lang.UserPath = "form"
app.storage.lang.AdminPath = "admin" app.storage.lang.AdminPath = "admin"
app.storage.lang.EmailPath = "email" app.storage.lang.EmailPath = "email"
app.storage.lang.TelegramPath = "telegram" app.storage.lang.TelegramPath = "telegram"
@ -450,7 +453,7 @@ func start(asDaemon, firstCall bool) {
user.UserID = shortuuid.New() user.UserID = shortuuid.New()
user.Username = app.config.Section("ui").Key("username").String() user.Username = app.config.Section("ui").Key("username").String()
user.Password = app.config.Section("ui").Key("password").String() user.Password = app.config.Section("ui").Key("password").String()
app.users = append(app.users, user) app.adminUsers = append(app.adminUsers, user)
} else { } else {
app.debug.Println("Using Jellyfin for authentication") app.debug.Println("Using Jellyfin for authentication")
app.authJf, _ = mediabrowser.NewServer(serverType, server, "jfa-go", app.version, "auth", "auth", timeoutHandler, cacheTimeout) app.authJf, _ = mediabrowser.NewServer(serverType, server, "jfa-go", app.version, "auth", "auth", timeoutHandler, cacheTimeout)
@ -645,9 +648,15 @@ func flagPassed(name string) (found bool) {
// @securityDefinitions.basic getTokenAuth // @securityDefinitions.basic getTokenAuth
// @name getTokenAuth // @name getTokenAuth
// @securityDefinitions.basic getUserTokenAuth
// @name getUserTokenAuth
// @tag.name Auth // @tag.name Auth
// @tag.description -Get a token here if running swagger UI locally.- // @tag.description -Get a token here if running swagger UI locally.-
// @tag.name User Page
// @tag.description User-page related routes.
// @tag.name Users // @tag.name Users
// @tag.description Jellyfin user related operations. // @tag.description Jellyfin user related operations.

View File

@ -83,7 +83,7 @@ func newMatrixDaemon(app *appContext) (d *MatrixDaemon, err error) {
// return // return
// } // }
// d.bot.Store.SaveFilterID(d.userID, resp.FilterID) // d.bot.Store.SaveFilterID(d.userID, resp.FilterID)
for _, user := range app.storage.matrix { for _, user := range app.storage.GetMatrix() {
if user.Lang != "" { if user.Lang != "" {
d.languages[id.RoomID(user.RoomID)] = user.Lang d.languages[id.RoomID(user.RoomID)] = user.Lang
} }
@ -176,12 +176,9 @@ func (d *MatrixDaemon) commandLang(evt *event.Event, code, lang string) {
return return
} }
d.languages[evt.RoomID] = code d.languages[evt.RoomID] = code
if u, ok := d.app.storage.matrix[string(evt.RoomID)]; ok { if u, ok := d.app.storage.GetMatrixKey(string(evt.RoomID)); ok {
u.Lang = code u.Lang = code
d.app.storage.matrix[string(evt.RoomID)] = u d.app.storage.SetMatrixKey(string(evt.RoomID), u)
if err := d.app.storage.storeMatrixUsers(); err != nil {
d.app.err.Printf("Matrix: Failed to store Matrix users: %v", err)
}
} }
} }
@ -252,7 +249,7 @@ func (d *MatrixDaemon) Send(message *Message, users ...MatrixUser) (err error) {
md := "" md := ""
if message.Markdown != "" { if message.Markdown != "" {
// Convert images to links // Convert images to links
md = string(markdown.ToHTML([]byte(strings.ReplaceAll(message.Markdown, "![", "[")), nil, renderer)) md = string(markdown.ToHTML([]byte(strings.ReplaceAll(message.Markdown, "![", "[")), nil, markdownRenderer))
} }
content := &event.MessageEventContent{ content := &event.MessageEventContent{
MsgType: "m.text", MsgType: "m.text",

View File

@ -139,7 +139,7 @@ func migrateNotificationMethods(app *appContext) error {
if !strings.Contains(address, "@") { if !strings.Contains(address, "@") {
continue continue
} }
for id, email := range app.storage.emails { for id, email := range app.storage.GetEmails() {
if email.Addr == address { if email.Addr == address {
invite.Notify[id] = notifyPrefs invite.Notify[id] = notifyPrefs
delete(invite.Notify, address) delete(invite.Notify, address)
@ -168,10 +168,10 @@ func linkExistingOmbiDiscordTelegram(app *appContext) error {
return nil return nil
} }
idList := map[string][2]string{} idList := map[string][2]string{}
for jfID, user := range app.storage.discord { for jfID, user := range app.storage.GetDiscord() {
idList[jfID] = [2]string{user.ID, ""} idList[jfID] = [2]string{user.ID, ""}
} }
for jfID, user := range app.storage.telegram { for jfID, user := range app.storage.GetTelegram() {
vals, ok := idList[jfID] vals, ok := idList[jfID]
if !ok { if !ok {
vals = [2]string{"", ""} vals = [2]string{"", ""}
@ -212,7 +212,7 @@ func linkExistingOmbiDiscordTelegram(app *appContext) error {
// app.jf.GetUsers(false) // app.jf.GetUsers(false)
// //
// noHyphens := true // noHyphens := true
// for id := range app.storage.emails { // for id := range app.storage.GetEmails() {
// if strings.Contains(id, "-") { // if strings.Contains(id, "-") {
// noHyphens = false // noHyphens = false
// break // break

View File

@ -215,6 +215,7 @@ type setting struct {
Options [][2]string `json:"options,omitempty"` Options [][2]string `json:"options,omitempty"`
DependsTrue string `json:"depends_true,omitempty"` // If specified, this field is enabled when the specified bool setting is enabled. DependsTrue string `json:"depends_true,omitempty"` // If specified, this field is enabled when the specified bool setting is enabled.
DependsFalse string `json:"depends_false,omitempty"` // If specified, opposite behaviour of DependsTrue. DependsFalse string `json:"depends_false,omitempty"` // If specified, opposite behaviour of DependsTrue.
Style string `json:"style,omitempty"`
} }
type section struct { type section struct {
@ -373,3 +374,42 @@ type ReCaptchaResponseDTO struct {
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
ErrorCodes []string `json:"error-codes"` ErrorCodes []string `json:"error-codes"`
} }
// MyDetailsDTO is sent to the user page to personalize it for the user.
type MyDetailsDTO struct {
Id string `json:"id"`
Username string `json:"username"`
Expiry int64 `json:"expiry"`
Admin bool `json:"admin"`
AccountsAdmin bool `json:"accounts_admin"`
Disabled bool `json:"disabled"`
Email *MyDetailsContactMethodsDTO `json:"email,omitempty"`
Discord *MyDetailsContactMethodsDTO `json:"discord,omitempty"`
Telegram *MyDetailsContactMethodsDTO `json:"telegram,omitempty"`
Matrix *MyDetailsContactMethodsDTO `json:"matrix,omitempty"`
}
type MyDetailsContactMethodsDTO struct {
Value string `json:"value"`
Enabled bool `json:"enabled"`
}
type ModifyMyEmailDTO struct {
Email string `json:"email"`
}
type ConfirmationTarget int
const (
UserEmailChange ConfirmationTarget = iota
NoOp
)
type GetMyPINDTO struct {
PIN string `json:"pin"`
}
type ChangeMyPasswordDTO struct {
Old string `json:"old"`
New string `json:"new"`
}

366
package-lock.json generated
View File

@ -15,7 +15,7 @@
"any-date-parser": "^1.5.4", "any-date-parser": "^1.5.4",
"browserslist": "^4.21.7", "browserslist": "^4.21.7",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"esbuild": "^0.18.3", "esbuild": "^0.18.6",
"fs-cheerio": "^3.0.0", "fs-cheerio": "^3.0.0",
"inline-source": "^8.0.2", "inline-source": "^8.0.2",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
@ -57,9 +57,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.6.tgz",
"integrity": "sha512-QOn3VIlL6Qv1eHBpQB/s7simaZgGss2ASyxDOwYSLmc6vD0uuizZkuYawHmuLjWEm5wPwp0JQWhbpaYwwGevYw==", "integrity": "sha512-J3lwhDSXBBppSzm/LC1uZ8yKSIpExc+5T8MxrYD9KNVZG81FOAu2VF2gXi/6A/LwDDQQ+b6DpQbYlo3VwxFepQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -72,9 +72,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.6.tgz",
"integrity": "sha512-PgabCsoaEEnnOiF6rUhOBXgYoLFIrHWP6mfLOzuQ1oZ1lwBdTL0hp5ivC4K3Kvz3BD8EipjeQo6l0aty3nr4qQ==", "integrity": "sha512-pL0Ci8P9q1sWbtPx8CXbc8JvPvvYdJJQ+LO09PLFsbz3aYNdFBGWJjiHU+CaObO4Ames+GOFpXRAJZS2L3ZK/A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -87,9 +87,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.6.tgz",
"integrity": "sha512-1OkJf8wNX1W5ucbp5HrK+z42b9DINb4ix59oJH/PIsh9cyFMqjgRKtCBXg0zEWhkmP1k3egdfrnS7cDTpLH43g==", "integrity": "sha512-hE2vZxOlJ05aY28lUpB0y0RokngtZtcUB+TVl9vnLEnY0z/8BicSvrkThg5/iI1rbf8TwXrbr2heEjl9fLf+EA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -102,9 +102,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.6.tgz",
"integrity": "sha512-57aofORpY7wDAuMs6DeqpmgSnVfZ63RgGbR/BHdOSTqJgYvHDCMY7/o1myFntl3k0YxtLE3WAm56nMf4qy3UDw==", "integrity": "sha512-/tuyl4R+QhhoROQtuQj9E/yfJtZNdv2HKaHwYhhHGQDN1Teziem2Kh7BWQMumfiY7Lu9g5rO7scWdGE4OsQ6MQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -117,9 +117,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.6.tgz",
"integrity": "sha512-NVBqMnxT9qvgu7Z322LUDlwjh4GDk6wEePyAQnHF9noxik/WvLFmr5v3Vgz5LSvqFducLCxsdmLztKhdpFW0Gg==", "integrity": "sha512-L7IQga2pDT+14Ti8HZwsVfbCjuKP4U213T3tuPggOzyK/p4KaUJxQFXJgfUFHKzU0zOXx8QcYRYZf0hSQtppkw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -132,9 +132,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.6.tgz",
"integrity": "sha512-XiLK1AsCk2wKxN7j8h9GXXCs8FPZhp07U0rnpwRkAVSVGgLaIWYSqpTRzKjAfqJiZlp+XKo1HwsmDdICEKB3Dg==", "integrity": "sha512-bq10jFv42V20Kk77NvmO+WEZaLHBKuXcvEowixnBOMkaBgS7kQaqTc77ZJDbsUpXU3KKNLQFZctfaeINmeTsZA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -147,9 +147,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.6.tgz",
"integrity": "sha512-xyITfrF0G3l1gwR79hvNCCWKQ/16uK14xNNPFgzjbIqF4EpBvhO6l3jrWxXFUW51z6dVIl2Szh3x3uIbBWzH1Q==", "integrity": "sha512-HbDLlkDZqUMBQaiday0pJzB6/8Xx/10dI3xRebJBReOEeDSeS+7GzTtW9h8ZnfB7/wBCqvtAjGtWQLTNPbR2+g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -162,9 +162,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.6.tgz",
"integrity": "sha512-fc/T0QHMzvmnlF+kfD6bHLB8u+17gg13260p/E86yYjVoKNFjonL/+Y0GGQjMbFUas9QijqOa7pcR00a9RNkwg==", "integrity": "sha512-C+5kb6rgsGMmvIdUI7v1PPgC98A6BMv233e97aXZ5AE03iMdlILFD/20HlHrOi0x2CzbspXn9HOnlE4/Ijn5Kw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -177,9 +177,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.6.tgz",
"integrity": "sha512-lsKUYVd8L/j2uNs8dhMjMsKC5MHYh77gR9EThu7YCeeFz1XpIkx1I4a7mhoVfPS2VPVD1pMCh+PgxuAHUcEmXw==", "integrity": "sha512-NMY9yg/88MskEZH2s4i6biz/3av+M8xY5ua4HE7CCz5DBz542cr7REe317+v7oKjnYBCijHpkzo5vU85bkXQmQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -192,9 +192,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.6.tgz",
"integrity": "sha512-EyfGWeOwRqK5Xj18vok0qv8IFBZ1/+hKV+cqD44oVhGsxHo9TmPtoSiDrWn8Sa2swq/VuO5Aiog6YPDj81oIkA==", "integrity": "sha512-AXazA0ljvQEp7cA9jscABNXsjodKbEcqPcAE3rDzKN82Vb3lYOq6INd+HOCA7hk8IegEyHW4T72Z7QGIhyCQEA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -207,9 +207,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.6.tgz",
"integrity": "sha512-PwXkcl3t0kSeYH5RuJIeh/fHOzKZd+ZdifAWzpVO+9TLWArutTFBJvOSkTZ3CcqQqNrTj1Qyo6nqE8MQj/a7cQ==", "integrity": "sha512-JjBf7TwY7ldcPgHYt9UcrjZB03+WZqg/jSwMAfzOzM5ZG+tu5umUqzy5ugH/crGI4eoDIhSOTDp1NL3Uo/05Fw==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -222,9 +222,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.6.tgz",
"integrity": "sha512-CRVkkSXf5GQcq7Am2a2tdIn85oqi/bkjuPvhNqcdeTgI0xgNbqLnEPRy2AEGkRuaJWB5uCX1IC4sqnY8ET14Yg==", "integrity": "sha512-kATNsslryVxcH1sO3KP2nnyUWtZZVkgyhAUnyTVVa0OQQ9pmDRjTpHaE+2EQHoCM5wt/uav2edrAUqbwn3tkKQ==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@ -237,9 +237,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.6.tgz",
"integrity": "sha512-t7zK1Cheh0xvzfZbimztiE0wGnpV+YRsBg3tefcEBN3O4GzgLu6fFpA5HxEyVm3hHZW1jAC4OhoGEp7C5Ii6Eg==", "integrity": "sha512-B+wTKz+8pi7mcWXFQV0LA79dJ+qhiut5uK9q0omoKnq8yRIwQJwfg3/vclXoqqcX89Ri5Y5538V0Se2v5qlcLA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -252,9 +252,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.6.tgz",
"integrity": "sha512-fUZPtyCYih6y4lDYdSM4Yoax4nS7aH0/XixJStys+9tfp5cAlIAZhEVKOOdeGXmQn0IEyiUtlIsPnfObbeDQfQ==", "integrity": "sha512-h44RBLVXFUSjvhOfseE+5UxQ/r9LVeqK2S8JziJKOm9W7SePYRPDyn7MhzhNCCFPkcjIy+soCxfhlJXHXXCR0A==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -267,9 +267,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.6.tgz",
"integrity": "sha512-oIcK2LqHWqfMERqjvaKJ3QJmycHn723HsXIv5gH4iGfmePfSj+gi0ZQv2h4bHUg2bs2gJtV0DlIjGhEuvdgxLw==", "integrity": "sha512-FlYpyr2Xc2AUePoAbc84NRV+mj7xpsISeQ36HGf9etrY5rTBEA+IU9HzWVmw5mDFtC62EQxzkLRj8h5Hq85yOQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -282,9 +282,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.6.tgz",
"integrity": "sha512-RW9lpfZ6XZ6f5to2DJPvt0f/4RXEW229Xf++quVoW+YbnPrcapIJChtD/AmZ8cK3hglO/hXxJjs21pV0/l7L5w==", "integrity": "sha512-Mc4EUSYwzLci77u0Kao6ajB2WbTe5fNc7+lHwS3a+vJISC/oprwURezUYu1SdWAYoczbsyOvKAJwuNftoAdjjg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -297,9 +297,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.6.tgz",
"integrity": "sha512-piZ2oBoaq58pKZvhgdV6PemlL30Uhd9GmmOkIGZYgChwNcyVSSl6iMEJxMzU7x44Lk9q+hJ6a343M/iVEMEvxA==", "integrity": "sha512-3hgZlp7NqIM5lNG3fpdhBI5rUnPmdahraSmwAi+YX/bp7iZ7mpTv2NkypGs/XngdMtpzljICxnUG3uPfqLFd3w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -312,9 +312,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.6.tgz",
"integrity": "sha512-vaMfouYTz/4tKdQsXDccqhV6wgPEr+hfuxdNU5Pl/vQxYTsqcXv5DYEa5Z1RAxCoua5aEB+Uj5V7VT/bM92wxw==", "integrity": "sha512-aEWTdZQHtSRROlDYn7ygB8yAqtnall/UnmoVIJVqccKitkAWVVSYocQUWrBOxLEFk8XdlRouVrLZe6WXszyviA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -327,9 +327,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.6.tgz",
"integrity": "sha512-Fa3rNQQ9q1qwy9u2cdDvuGKy3jmPnPPMDdyy/qbn5d395Pb9hjLYiPzX9BozXMPJDlCNofSY7jN3miM9gyAdHA==", "integrity": "sha512-uxk/5yAGpjKZUHOECtI9W+9IcLjKj+2m0qf+RG7f7eRBHr8wP6wsr3XbNbgtOD1qSpPapd6R2ZfSeXTkCcAo5g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -342,9 +342,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.6.tgz",
"integrity": "sha512-LPJC8ub+9uzyC6ygVmp00dAqet1q1DsZ/OldGIIBt+y+Ctd1OfnKNlzQgXK8nxwY1G8fAhklFSeSRRgAUJnR0w==", "integrity": "sha512-oXlXGS9zvNCGoAT/tLHAsFKrIKye1JaIIP0anCdpaI+Dc10ftaNZcqfLzEwyhdzFAYInXYH4V7kEdH4hPyo9GA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -357,9 +357,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.6.tgz",
"integrity": "sha512-WtUyRspyxZR6NTc2HG4xd9Wvz8lP4C6OUY1gAqisrf151HvXIxsK0mfAacFJNS7EN2wvPTgjP+SM8vgBOx5+zA==", "integrity": "sha512-qh7IcAHUvvmMBmoIG+V+BbE9ZWSR0ohF51e5g8JZvU08kZF58uDFL5tHs0eoYz31H6Finv17te3W3QB042GqVA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -372,9 +372,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.6.tgz",
"integrity": "sha512-Z8qCK4BkBm40j5KUM4NrkxYQS0R12cBO1NBVtI4vws6uwh1n/VaNu31Hm+n2cJUWdFbfH57PBghkhm9yLgmPfw==", "integrity": "sha512-9UDwkz7Wlm4N9jnv+4NL7F8vxLhSZfEkRArz2gD33HesAFfMLGIGNVXRoIHtWNw8feKsnGly9Hq1EUuRkWl0zA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1647,9 +1647,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.3.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.6.tgz",
"integrity": "sha512-eadWJC4CRpj93+miO5ZBlvCv+m2x6pzyNBznTvUeLFObMmxs1IMd8cCf6qiDVEZuDL6W8W7u+ZNW3GKEfOdDsA==", "integrity": "sha512-5QgxWaAhU/tPBpvkxUmnFv2YINHuZzjbk0LeUUnC2i3aJHjfi5yR49lgKgF7cb98bclOp/kans8M5TGbGFfJlQ==",
"hasInstallScript": true, "hasInstallScript": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
@ -1658,28 +1658,28 @@
"node": ">=12" "node": ">=12"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/android-arm": "0.18.3", "@esbuild/android-arm": "0.18.6",
"@esbuild/android-arm64": "0.18.3", "@esbuild/android-arm64": "0.18.6",
"@esbuild/android-x64": "0.18.3", "@esbuild/android-x64": "0.18.6",
"@esbuild/darwin-arm64": "0.18.3", "@esbuild/darwin-arm64": "0.18.6",
"@esbuild/darwin-x64": "0.18.3", "@esbuild/darwin-x64": "0.18.6",
"@esbuild/freebsd-arm64": "0.18.3", "@esbuild/freebsd-arm64": "0.18.6",
"@esbuild/freebsd-x64": "0.18.3", "@esbuild/freebsd-x64": "0.18.6",
"@esbuild/linux-arm": "0.18.3", "@esbuild/linux-arm": "0.18.6",
"@esbuild/linux-arm64": "0.18.3", "@esbuild/linux-arm64": "0.18.6",
"@esbuild/linux-ia32": "0.18.3", "@esbuild/linux-ia32": "0.18.6",
"@esbuild/linux-loong64": "0.18.3", "@esbuild/linux-loong64": "0.18.6",
"@esbuild/linux-mips64el": "0.18.3", "@esbuild/linux-mips64el": "0.18.6",
"@esbuild/linux-ppc64": "0.18.3", "@esbuild/linux-ppc64": "0.18.6",
"@esbuild/linux-riscv64": "0.18.3", "@esbuild/linux-riscv64": "0.18.6",
"@esbuild/linux-s390x": "0.18.3", "@esbuild/linux-s390x": "0.18.6",
"@esbuild/linux-x64": "0.18.3", "@esbuild/linux-x64": "0.18.6",
"@esbuild/netbsd-x64": "0.18.3", "@esbuild/netbsd-x64": "0.18.6",
"@esbuild/openbsd-x64": "0.18.3", "@esbuild/openbsd-x64": "0.18.6",
"@esbuild/sunos-x64": "0.18.3", "@esbuild/sunos-x64": "0.18.6",
"@esbuild/win32-arm64": "0.18.3", "@esbuild/win32-arm64": "0.18.6",
"@esbuild/win32-ia32": "0.18.3", "@esbuild/win32-ia32": "0.18.6",
"@esbuild/win32-x64": "0.18.3" "@esbuild/win32-x64": "0.18.6"
} }
}, },
"node_modules/escalade": { "node_modules/escalade": {
@ -6797,135 +6797,135 @@
} }
}, },
"@esbuild/android-arm": { "@esbuild/android-arm": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.6.tgz",
"integrity": "sha512-QOn3VIlL6Qv1eHBpQB/s7simaZgGss2ASyxDOwYSLmc6vD0uuizZkuYawHmuLjWEm5wPwp0JQWhbpaYwwGevYw==", "integrity": "sha512-J3lwhDSXBBppSzm/LC1uZ8yKSIpExc+5T8MxrYD9KNVZG81FOAu2VF2gXi/6A/LwDDQQ+b6DpQbYlo3VwxFepQ==",
"optional": true "optional": true
}, },
"@esbuild/android-arm64": { "@esbuild/android-arm64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.6.tgz",
"integrity": "sha512-PgabCsoaEEnnOiF6rUhOBXgYoLFIrHWP6mfLOzuQ1oZ1lwBdTL0hp5ivC4K3Kvz3BD8EipjeQo6l0aty3nr4qQ==", "integrity": "sha512-pL0Ci8P9q1sWbtPx8CXbc8JvPvvYdJJQ+LO09PLFsbz3aYNdFBGWJjiHU+CaObO4Ames+GOFpXRAJZS2L3ZK/A==",
"optional": true "optional": true
}, },
"@esbuild/android-x64": { "@esbuild/android-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.6.tgz",
"integrity": "sha512-1OkJf8wNX1W5ucbp5HrK+z42b9DINb4ix59oJH/PIsh9cyFMqjgRKtCBXg0zEWhkmP1k3egdfrnS7cDTpLH43g==", "integrity": "sha512-hE2vZxOlJ05aY28lUpB0y0RokngtZtcUB+TVl9vnLEnY0z/8BicSvrkThg5/iI1rbf8TwXrbr2heEjl9fLf+EA==",
"optional": true "optional": true
}, },
"@esbuild/darwin-arm64": { "@esbuild/darwin-arm64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.6.tgz",
"integrity": "sha512-57aofORpY7wDAuMs6DeqpmgSnVfZ63RgGbR/BHdOSTqJgYvHDCMY7/o1myFntl3k0YxtLE3WAm56nMf4qy3UDw==", "integrity": "sha512-/tuyl4R+QhhoROQtuQj9E/yfJtZNdv2HKaHwYhhHGQDN1Teziem2Kh7BWQMumfiY7Lu9g5rO7scWdGE4OsQ6MQ==",
"optional": true "optional": true
}, },
"@esbuild/darwin-x64": { "@esbuild/darwin-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.6.tgz",
"integrity": "sha512-NVBqMnxT9qvgu7Z322LUDlwjh4GDk6wEePyAQnHF9noxik/WvLFmr5v3Vgz5LSvqFducLCxsdmLztKhdpFW0Gg==", "integrity": "sha512-L7IQga2pDT+14Ti8HZwsVfbCjuKP4U213T3tuPggOzyK/p4KaUJxQFXJgfUFHKzU0zOXx8QcYRYZf0hSQtppkw==",
"optional": true "optional": true
}, },
"@esbuild/freebsd-arm64": { "@esbuild/freebsd-arm64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.6.tgz",
"integrity": "sha512-XiLK1AsCk2wKxN7j8h9GXXCs8FPZhp07U0rnpwRkAVSVGgLaIWYSqpTRzKjAfqJiZlp+XKo1HwsmDdICEKB3Dg==", "integrity": "sha512-bq10jFv42V20Kk77NvmO+WEZaLHBKuXcvEowixnBOMkaBgS7kQaqTc77ZJDbsUpXU3KKNLQFZctfaeINmeTsZA==",
"optional": true "optional": true
}, },
"@esbuild/freebsd-x64": { "@esbuild/freebsd-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.6.tgz",
"integrity": "sha512-xyITfrF0G3l1gwR79hvNCCWKQ/16uK14xNNPFgzjbIqF4EpBvhO6l3jrWxXFUW51z6dVIl2Szh3x3uIbBWzH1Q==", "integrity": "sha512-HbDLlkDZqUMBQaiday0pJzB6/8Xx/10dI3xRebJBReOEeDSeS+7GzTtW9h8ZnfB7/wBCqvtAjGtWQLTNPbR2+g==",
"optional": true "optional": true
}, },
"@esbuild/linux-arm": { "@esbuild/linux-arm": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.6.tgz",
"integrity": "sha512-fc/T0QHMzvmnlF+kfD6bHLB8u+17gg13260p/E86yYjVoKNFjonL/+Y0GGQjMbFUas9QijqOa7pcR00a9RNkwg==", "integrity": "sha512-C+5kb6rgsGMmvIdUI7v1PPgC98A6BMv233e97aXZ5AE03iMdlILFD/20HlHrOi0x2CzbspXn9HOnlE4/Ijn5Kw==",
"optional": true "optional": true
}, },
"@esbuild/linux-arm64": { "@esbuild/linux-arm64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.6.tgz",
"integrity": "sha512-lsKUYVd8L/j2uNs8dhMjMsKC5MHYh77gR9EThu7YCeeFz1XpIkx1I4a7mhoVfPS2VPVD1pMCh+PgxuAHUcEmXw==", "integrity": "sha512-NMY9yg/88MskEZH2s4i6biz/3av+M8xY5ua4HE7CCz5DBz542cr7REe317+v7oKjnYBCijHpkzo5vU85bkXQmQ==",
"optional": true "optional": true
}, },
"@esbuild/linux-ia32": { "@esbuild/linux-ia32": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.6.tgz",
"integrity": "sha512-EyfGWeOwRqK5Xj18vok0qv8IFBZ1/+hKV+cqD44oVhGsxHo9TmPtoSiDrWn8Sa2swq/VuO5Aiog6YPDj81oIkA==", "integrity": "sha512-AXazA0ljvQEp7cA9jscABNXsjodKbEcqPcAE3rDzKN82Vb3lYOq6INd+HOCA7hk8IegEyHW4T72Z7QGIhyCQEA==",
"optional": true "optional": true
}, },
"@esbuild/linux-loong64": { "@esbuild/linux-loong64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.6.tgz",
"integrity": "sha512-PwXkcl3t0kSeYH5RuJIeh/fHOzKZd+ZdifAWzpVO+9TLWArutTFBJvOSkTZ3CcqQqNrTj1Qyo6nqE8MQj/a7cQ==", "integrity": "sha512-JjBf7TwY7ldcPgHYt9UcrjZB03+WZqg/jSwMAfzOzM5ZG+tu5umUqzy5ugH/crGI4eoDIhSOTDp1NL3Uo/05Fw==",
"optional": true "optional": true
}, },
"@esbuild/linux-mips64el": { "@esbuild/linux-mips64el": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.6.tgz",
"integrity": "sha512-CRVkkSXf5GQcq7Am2a2tdIn85oqi/bkjuPvhNqcdeTgI0xgNbqLnEPRy2AEGkRuaJWB5uCX1IC4sqnY8ET14Yg==", "integrity": "sha512-kATNsslryVxcH1sO3KP2nnyUWtZZVkgyhAUnyTVVa0OQQ9pmDRjTpHaE+2EQHoCM5wt/uav2edrAUqbwn3tkKQ==",
"optional": true "optional": true
}, },
"@esbuild/linux-ppc64": { "@esbuild/linux-ppc64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.6.tgz",
"integrity": "sha512-t7zK1Cheh0xvzfZbimztiE0wGnpV+YRsBg3tefcEBN3O4GzgLu6fFpA5HxEyVm3hHZW1jAC4OhoGEp7C5Ii6Eg==", "integrity": "sha512-B+wTKz+8pi7mcWXFQV0LA79dJ+qhiut5uK9q0omoKnq8yRIwQJwfg3/vclXoqqcX89Ri5Y5538V0Se2v5qlcLA==",
"optional": true "optional": true
}, },
"@esbuild/linux-riscv64": { "@esbuild/linux-riscv64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.6.tgz",
"integrity": "sha512-fUZPtyCYih6y4lDYdSM4Yoax4nS7aH0/XixJStys+9tfp5cAlIAZhEVKOOdeGXmQn0IEyiUtlIsPnfObbeDQfQ==", "integrity": "sha512-h44RBLVXFUSjvhOfseE+5UxQ/r9LVeqK2S8JziJKOm9W7SePYRPDyn7MhzhNCCFPkcjIy+soCxfhlJXHXXCR0A==",
"optional": true "optional": true
}, },
"@esbuild/linux-s390x": { "@esbuild/linux-s390x": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.6.tgz",
"integrity": "sha512-oIcK2LqHWqfMERqjvaKJ3QJmycHn723HsXIv5gH4iGfmePfSj+gi0ZQv2h4bHUg2bs2gJtV0DlIjGhEuvdgxLw==", "integrity": "sha512-FlYpyr2Xc2AUePoAbc84NRV+mj7xpsISeQ36HGf9etrY5rTBEA+IU9HzWVmw5mDFtC62EQxzkLRj8h5Hq85yOQ==",
"optional": true "optional": true
}, },
"@esbuild/linux-x64": { "@esbuild/linux-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.6.tgz",
"integrity": "sha512-RW9lpfZ6XZ6f5to2DJPvt0f/4RXEW229Xf++quVoW+YbnPrcapIJChtD/AmZ8cK3hglO/hXxJjs21pV0/l7L5w==", "integrity": "sha512-Mc4EUSYwzLci77u0Kao6ajB2WbTe5fNc7+lHwS3a+vJISC/oprwURezUYu1SdWAYoczbsyOvKAJwuNftoAdjjg==",
"optional": true "optional": true
}, },
"@esbuild/netbsd-x64": { "@esbuild/netbsd-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.6.tgz",
"integrity": "sha512-piZ2oBoaq58pKZvhgdV6PemlL30Uhd9GmmOkIGZYgChwNcyVSSl6iMEJxMzU7x44Lk9q+hJ6a343M/iVEMEvxA==", "integrity": "sha512-3hgZlp7NqIM5lNG3fpdhBI5rUnPmdahraSmwAi+YX/bp7iZ7mpTv2NkypGs/XngdMtpzljICxnUG3uPfqLFd3w==",
"optional": true "optional": true
}, },
"@esbuild/openbsd-x64": { "@esbuild/openbsd-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.6.tgz",
"integrity": "sha512-vaMfouYTz/4tKdQsXDccqhV6wgPEr+hfuxdNU5Pl/vQxYTsqcXv5DYEa5Z1RAxCoua5aEB+Uj5V7VT/bM92wxw==", "integrity": "sha512-aEWTdZQHtSRROlDYn7ygB8yAqtnall/UnmoVIJVqccKitkAWVVSYocQUWrBOxLEFk8XdlRouVrLZe6WXszyviA==",
"optional": true "optional": true
}, },
"@esbuild/sunos-x64": { "@esbuild/sunos-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.6.tgz",
"integrity": "sha512-Fa3rNQQ9q1qwy9u2cdDvuGKy3jmPnPPMDdyy/qbn5d395Pb9hjLYiPzX9BozXMPJDlCNofSY7jN3miM9gyAdHA==", "integrity": "sha512-uxk/5yAGpjKZUHOECtI9W+9IcLjKj+2m0qf+RG7f7eRBHr8wP6wsr3XbNbgtOD1qSpPapd6R2ZfSeXTkCcAo5g==",
"optional": true "optional": true
}, },
"@esbuild/win32-arm64": { "@esbuild/win32-arm64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.6.tgz",
"integrity": "sha512-LPJC8ub+9uzyC6ygVmp00dAqet1q1DsZ/OldGIIBt+y+Ctd1OfnKNlzQgXK8nxwY1G8fAhklFSeSRRgAUJnR0w==", "integrity": "sha512-oXlXGS9zvNCGoAT/tLHAsFKrIKye1JaIIP0anCdpaI+Dc10ftaNZcqfLzEwyhdzFAYInXYH4V7kEdH4hPyo9GA==",
"optional": true "optional": true
}, },
"@esbuild/win32-ia32": { "@esbuild/win32-ia32": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.6.tgz",
"integrity": "sha512-WtUyRspyxZR6NTc2HG4xd9Wvz8lP4C6OUY1gAqisrf151HvXIxsK0mfAacFJNS7EN2wvPTgjP+SM8vgBOx5+zA==", "integrity": "sha512-qh7IcAHUvvmMBmoIG+V+BbE9ZWSR0ohF51e5g8JZvU08kZF58uDFL5tHs0eoYz31H6Finv17te3W3QB042GqVA==",
"optional": true "optional": true
}, },
"@esbuild/win32-x64": { "@esbuild/win32-x64": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.6.tgz",
"integrity": "sha512-Z8qCK4BkBm40j5KUM4NrkxYQS0R12cBO1NBVtI4vws6uwh1n/VaNu31Hm+n2cJUWdFbfH57PBghkhm9yLgmPfw==", "integrity": "sha512-9UDwkz7Wlm4N9jnv+4NL7F8vxLhSZfEkRArz2gD33HesAFfMLGIGNVXRoIHtWNw8feKsnGly9Hq1EUuRkWl0zA==",
"optional": true "optional": true
}, },
"@jridgewell/gen-mapping": { "@jridgewell/gen-mapping": {
@ -7896,32 +7896,32 @@
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
}, },
"esbuild": { "esbuild": {
"version": "0.18.3", "version": "0.18.6",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.3.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.6.tgz",
"integrity": "sha512-eadWJC4CRpj93+miO5ZBlvCv+m2x6pzyNBznTvUeLFObMmxs1IMd8cCf6qiDVEZuDL6W8W7u+ZNW3GKEfOdDsA==", "integrity": "sha512-5QgxWaAhU/tPBpvkxUmnFv2YINHuZzjbk0LeUUnC2i3aJHjfi5yR49lgKgF7cb98bclOp/kans8M5TGbGFfJlQ==",
"requires": { "requires": {
"@esbuild/android-arm": "0.18.3", "@esbuild/android-arm": "0.18.6",
"@esbuild/android-arm64": "0.18.3", "@esbuild/android-arm64": "0.18.6",
"@esbuild/android-x64": "0.18.3", "@esbuild/android-x64": "0.18.6",
"@esbuild/darwin-arm64": "0.18.3", "@esbuild/darwin-arm64": "0.18.6",
"@esbuild/darwin-x64": "0.18.3", "@esbuild/darwin-x64": "0.18.6",
"@esbuild/freebsd-arm64": "0.18.3", "@esbuild/freebsd-arm64": "0.18.6",
"@esbuild/freebsd-x64": "0.18.3", "@esbuild/freebsd-x64": "0.18.6",
"@esbuild/linux-arm": "0.18.3", "@esbuild/linux-arm": "0.18.6",
"@esbuild/linux-arm64": "0.18.3", "@esbuild/linux-arm64": "0.18.6",
"@esbuild/linux-ia32": "0.18.3", "@esbuild/linux-ia32": "0.18.6",
"@esbuild/linux-loong64": "0.18.3", "@esbuild/linux-loong64": "0.18.6",
"@esbuild/linux-mips64el": "0.18.3", "@esbuild/linux-mips64el": "0.18.6",
"@esbuild/linux-ppc64": "0.18.3", "@esbuild/linux-ppc64": "0.18.6",
"@esbuild/linux-riscv64": "0.18.3", "@esbuild/linux-riscv64": "0.18.6",
"@esbuild/linux-s390x": "0.18.3", "@esbuild/linux-s390x": "0.18.6",
"@esbuild/linux-x64": "0.18.3", "@esbuild/linux-x64": "0.18.6",
"@esbuild/netbsd-x64": "0.18.3", "@esbuild/netbsd-x64": "0.18.6",
"@esbuild/openbsd-x64": "0.18.3", "@esbuild/openbsd-x64": "0.18.6",
"@esbuild/sunos-x64": "0.18.3", "@esbuild/sunos-x64": "0.18.6",
"@esbuild/win32-arm64": "0.18.3", "@esbuild/win32-arm64": "0.18.6",
"@esbuild/win32-ia32": "0.18.3", "@esbuild/win32-ia32": "0.18.6",
"@esbuild/win32-x64": "0.18.3" "@esbuild/win32-x64": "0.18.6"
} }
}, },
"escalade": { "escalade": {

View File

@ -23,7 +23,7 @@
"any-date-parser": "^1.5.4", "any-date-parser": "^1.5.4",
"browserslist": "^4.21.7", "browserslist": "^4.21.7",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"esbuild": "^0.18.3", "esbuild": "^0.18.6",
"fs-cheerio": "^3.0.0", "fs-cheerio": "^3.0.0",
"inline-source": "^8.0.2", "inline-source": "^8.0.2",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",

View File

@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"strings" "strings"
"time" "time"
@ -25,6 +26,18 @@ func (app *appContext) GenInternalReset(userID string) (InternalPWR, error) {
return pwr, nil return pwr, nil
} }
// GenResetLink generates and returns a password reset link.
func (app *appContext) GenResetLink(pin string) (string, error) {
url := app.config.Section("password_resets").Key("url_base").String()
var pinLink string
if url == "" {
return pinLink, fmt.Errorf("disabled as no URL Base provided. Set in Settings > Password Resets.")
}
// Strip /invite from end of this URL, ik it's ugly.
pinLink = fmt.Sprintf("%s/reset?pin=%s", url, pin)
return pinLink, nil
}
func (app *appContext) StartPWR() { func (app *appContext) StartPWR() {
app.info.Println("Starting password reset daemon") app.info.Println("Starting password reset daemon")
path := app.config.Section("password_resets").Key("watch_directory").String() path := app.config.Section("password_resets").Key("watch_directory").String()

View File

@ -101,6 +101,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
if app.URLBase != "" { if app.URLBase != "" {
routePrefixes = append(routePrefixes, "") routePrefixes = append(routePrefixes, "")
} }
userPageEnabled := app.config.Section("user_page").Key("enabled").MustBool(true) && app.config.Section("ui").Key("jellyfin_login").MustBool(true)
for _, p := range routePrefixes { for _, p := range routePrefixes {
router.GET(p+"/lang/:page", app.GetLanguages) router.GET(p+"/lang/:page", app.GetLanguages)
router.Use(static.Serve(p+"/", app.webFS)) router.Use(static.Serve(p+"/", app.webFS))
@ -140,6 +143,13 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.POST(p+"/invite/:invCode/matrix/user", app.MatrixSendPIN) router.POST(p+"/invite/:invCode/matrix/user", app.MatrixSendPIN)
router.POST(p+"/users/matrix", app.MatrixConnect) router.POST(p+"/users/matrix", app.MatrixConnect)
} }
if userPageEnabled {
router.GET(p+"/my/account", app.MyUserPage)
router.GET(p+"/my/token/login", app.getUserTokenLogin)
router.GET(p+"/my/token/refresh", app.getUserTokenRefresh)
router.GET(p+"/my/confirm/:jwt", app.ConfirmMyAction)
router.POST(p+"/my/password/reset/:address", app.ResetMyPassword)
}
} }
if *SWAGGER { if *SWAGGER {
app.info.Print(warning("\n\nWARNING: Swagger should not be used on a public instance.\n\n")) app.info.Print(warning("\n\nWARNING: Swagger should not be used on a public instance.\n\n"))
@ -147,7 +157,14 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.GET(p+"/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) router.GET(p+"/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
} }
} }
api := router.Group("/", app.webAuth()) api := router.Group("/", app.webAuth())
var user *gin.RouterGroup
if userPageEnabled {
user = router.Group("/my", app.userAuth())
}
for _, p := range routePrefixes { for _, p := range routePrefixes {
router.POST(p+"/logout", app.Logout) router.POST(p+"/logout", app.Logout)
api.DELETE(p+"/users", app.DeleteUsers) api.DELETE(p+"/users", app.DeleteUsers)
@ -180,10 +197,10 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.GET(p+"/config/update", app.CheckUpdate) api.GET(p+"/config/update", app.CheckUpdate)
api.POST(p+"/config/update", app.ApplyUpdate) api.POST(p+"/config/update", app.ApplyUpdate)
api.GET(p+"/config/emails", app.GetCustomEmails) api.GET(p+"/config/emails", app.GetCustomContent)
api.GET(p+"/config/emails/:id", app.GetCustomEmailTemplate) api.GET(p+"/config/emails/:id", app.GetCustomMessageTemplate)
api.POST(p+"/config/emails/:id", app.SetCustomEmail) api.POST(p+"/config/emails/:id", app.SetCustomMessage)
api.POST(p+"/config/emails/:id/state/:state", app.SetCustomEmailState) api.POST(p+"/config/emails/:id/state/:state", app.SetCustomMessageState)
api.GET(p+"/config", app.GetConfig) api.GET(p+"/config", app.GetConfig)
api.POST(p+"/config", app.ModifyConfig) api.POST(p+"/config", app.ModifyConfig)
api.POST(p+"/restart", app.restart) api.POST(p+"/restart", app.restart)
@ -210,6 +227,22 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
} }
api.POST(p+"/matrix/login", app.MatrixLogin) api.POST(p+"/matrix/login", app.MatrixLogin)
if userPageEnabled {
user.GET(p+"/details", app.MyDetails)
user.POST(p+"/contact", app.SetMyContactMethods)
user.POST(p+"/logout", app.LogoutUser)
user.POST(p+"/email", app.ModifyMyEmail)
user.GET(p+"/discord/invite", app.MyDiscordServerInvite)
user.GET(p+"/pin/:service", app.GetMyPIN)
user.GET(p+"/discord/verified/:pin", app.MyDiscordVerifiedInvite)
user.GET(p+"/telegram/verified/:pin", app.MyTelegramVerifiedInvite)
user.POST(p+"/matrix/user", app.MatrixSendMyPIN)
user.GET(p+"/matrix/verified/:userID/:pin", app.MatrixCheckMyPIN)
user.DELETE(p+"/discord", app.UnlinkMyDiscord)
user.DELETE(p+"/telegram", app.UnlinkMyTelegram)
user.DELETE(p+"/matrix", app.UnlinkMyMatrix)
user.POST(p+"/password", app.ChangeMyPassword)
}
} }
} }

View File

@ -21,6 +21,8 @@ def generate_ini(base_file, ini_file):
if "meta" in config_base["sections"][section]: if "meta" in config_base["sections"][section]:
ini.set(section, fix_description(config_base["sections"][section]["meta"]["description"])) ini.set(section, fix_description(config_base["sections"][section]["meta"]["description"]))
for entry in config_base["sections"][section]["settings"]: for entry in config_base["sections"][section]["settings"]:
if config_base["sections"][section]["settings"][entry]["type"] == "note":
continue
if "description" in config_base["sections"][section]["settings"][entry]: if "description" in config_base["sections"][section]["settings"][entry]:
ini.set(section, fix_description(config_base["sections"][section]["settings"][entry]["description"])) ini.set(section, fix_description(config_base["sections"][section]["settings"][entry]["description"]))
value = config_base["sections"][section]["settings"][entry]["value"] value = config_base["sections"][section]["settings"][entry]["value"]

View File

@ -0,0 +1,33 @@
# *langmover*
* Makes moving strings between language files a little easier.
# Usage
You'll need to create a template file. See example `template.json`:
```json
{
"meta": {
"explanation": "values here can either be folder, folder:section, or folder:section:subkey, and then either nothing, or /keyname. It all depends on whether the sections and keys match up, or if you want to pull a plural/singular only or not."
},
"strings": {
"inviteInfiniteUsesWarning": "admin", // Resolves to admin/strings/inviteInfiniteUsesWarning
"emailAddress": "form:strings/emailAddress", // Resolves to form/strings/emailAddress
"modifySettingsFor": "admin:quantityStrings:plural/", // Resolves to admin/quantityStrings/modifySettingsFor/plural
"deleteNUsers": "admin:quantityStrings:singular/deleteNUsers" // Resolves to admin/quantityStrings/deleteNUsers/singular
},
"quantityStrings": {
"reEnableUsers": "admin" // Resolves to admin/quantityStrings/reEnableUsers
}
}
```
Args:
* `--source`: Source `lang/` directory. **Always run on a copy, to avoid data loss**
* `--template`: Template JSON file.
* `--output`: Output directory. Will be filled with lang files (e.g. "en-us.json", "fa-ir.json", ...).
* `--extract`: Passing will remove the templated strings from their source file. **Modifies the source directory**.

View File

@ -0,0 +1,55 @@
{
"meta": {
"name": "English (US)"
},
"strings": {
"username": "common",
"password": "common",
"emailAddress": "common",
"name": "common",
"submit": "common",
"send": "common",
"success": "common",
"continue": "common",
"error": "common",
"copy": "common",
"copied": "common",
"time24h": "common",
"time12h": "common",
"linkTelegram": "common",
"contactEmail": "common",
"contactTelegram": "common",
"linkDiscord": "common",
"linkMatrix": "common",
"contactDiscord": "common",
"theme": "common",
"refresh": "common",
"required": "common",
"login": "common",
"logout": "common",
"admin": "common",
"enabled": "common",
"disabled": "common",
"reEnable": "common",
"disable": "common",
"contactMethods": "common",
"accountStatus": "common",
"notSet": "common",
"expiry": "common",
"add": "common",
"edit": "common",
"delete": "admin"
},
"notifications": {
"errorLoginBlank": "common",
"errorConnection": "common",
"errorUnknown": "common",
"error401Unauthorized": "common",
"errorSaveSettings": "common"
},
"quantityStrings": {
"year": "common",
"month": "common",
"day": "common"
}
}

View File

@ -0,0 +1,138 @@
import json, argparse, os
from pathlib import Path
ROOT = "en-us.json"
# Tree structure: <lang-code.json>/<folder>/<json content>
def generateTree(src: Path):
tree = {}
langs = {}
directories = []
def readLangFile(path: Path):
with open(path, 'r') as f:
content = json.load(f)
return content
for directory in os.scandir(src):
if not directory.is_dir(): continue
directories.append(directory.name)
# tree[directory.name] = {}
for lang in os.scandir(directory.path):
if not lang.is_file(): continue
if not ".json" in lang.name: continue
if lang.name not in langs:
langs[lang.name] = True
if lang.name.lower() not in tree:
tree[lang.name.lower()] = {}
for lang in langs:
for directory in directories:
filepath = Path(src) / Path(directory) / Path(lang)
if not filepath.exists(): continue
tree[lang.lower()][directory] = readLangFile(filepath)
return tree
def parseKey(langTree, currentSection: str, fieldName: str, key: str, extract=False):
temp = key.split("/")
loc = temp[0]
k = ""
if len(temp) > 1:
k = temp[1]
sections = loc.split(":")
# folder, folder:section or folder:section:subkey
folder = sections[0]
section = currentSection
subkey = None
if len(sections) > 1:
section = sections[1]
if len(sections) > 2:
subkey = sections[2]
if k == '':
k = fieldName
value = ""
if folder in langTree and section in langTree[folder] and k in langTree[folder][section]:
value = langTree[folder][section][k]
if extract:
s = langTree[folder][section]
del s[k]
langTree[folder][section] = s
if subkey is not None and subkey in value:
value = value[subkey]
return (langTree, folder, value)
def generate(templ: Path, source: Path, output: Path, extract: str, tree):
with open(templ, "r") as f:
template = json.load(f)
if not output.exists():
output.mkdir()
for lang in tree:
out = {}
for section in template:
if section == "meta":
# grab a meta section from the first file we find
for file in tree[lang]:
out["meta"] = tree[lang][file]["meta"]
break
continue
folder = ""
out[section] = {}
for key in template[section]:
(tree[lang], folder, val) = parseKey(tree[lang], section, key, template[section][key], extract)
if val != "":
out[section][key] = val
# if extract and val != "":
# with open(source / folder / lang, "w") as f:
# json.dump(modifiedTree[folder], f, indent=4, ensure_ascii=False)
with open(output / Path(lang), "w") as f:
json.dump(out, f, indent=4, ensure_ascii=False)
if extract and extract != "":
ex = Path(extract)
if not ex.exists():
ex.mkdir()
for lang in tree:
for folder in tree[lang]:
if not (ex / folder).exists():
(ex / folder).mkdir()
with open(ex / folder / lang, "w") as f:
json.dump(tree[lang][folder], f, indent=4, ensure_ascii=False)
parser = argparse.ArgumentParser()
parser.add_argument("--source", help="source \"lang/\" folder.")
parser.add_argument("--template", help="template file. see template.json for an example of how it works.")
parser.add_argument("--output", help="output directory for new files.")
parser.add_argument("--extract", help="put copies of original files with strings removed in this directory")
args = parser.parse_args()
source = Path(args.source)
tree = generateTree(source)
generate(Path(args.template), source, Path(args.output), args.extract, tree)
# print(json.dumps(tree, sort_keys=True, indent=4))

View File

@ -0,0 +1,41 @@
{
"meta": {},
"strings": {
"username": "common",
"password": "common",
"emailAddress": "common",
"name": "common",
"submit": "common",
"send": "common",
"success": "common",
"continue": "common",
"error": "common",
"copy": "common",
"copied": "common",
"time24h": "common",
"time12h": "common",
"linkTelegram": "common",
"contactEmail": "common",
"contactTelegram": "common",
"linkDiscord": "common",
"linkMatrix": "common",
"contactDiscord": "common",
"theme": "common",
"refresh": "common",
"required": "common",
"login": "common",
"logout": "common",
"admin": "common",
"enabled": "common",
"disabled": "common",
"reEnable": "common",
"disable": "common"
},
"notifications": {
"errorLoginBlank": "common",
"errorConnection": "common",
"errorUnknown": "common",
"error401Unauthorized": "common",
"errorSaveSettings": "admin"
}
}

View File

@ -0,0 +1,15 @@
{
"meta": {
"explanation": "values here can either be folder, folder:section, or folder:section:subkey, and then either nothing, or /keyname. It all depends on whether the sections and keys match up, or if you want to pull a plural/singular only or not."
},
"strings": {
"inviteInfiniteUsesWarning": "admin",
"emailAddress": "form:strings/emailAddress",
"modifySettingsFor": "admin:quantityStrings:plural/",
"deleteNUsers": "admin:quantityStrings:singular/deleteNUsers"
},
"quantityStrings": {
"reEnableUsers": "admin"
}
}

View File

@ -111,7 +111,8 @@ func (st *Storage) loadLangSetup(filesystems ...fs.FS) error {
if err != nil { if err != nil {
return err return err
} }
st.lang.Common.patchCommon(&lang.Strings, index) st.lang.Common.patchCommonStrings(&lang.Strings, index)
st.lang.Common.patchCommonNotifications(&lang.Notifications, index)
if fname != "en-us.json" { if fname != "en-us.json" {
if lang.Meta.Fallback != "" { if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Setup[lang.Meta.Fallback] fallback, ok := st.lang.Setup[lang.Meta.Fallback]

View File

@ -15,24 +15,189 @@ import (
"github.com/steambap/captcha" "github.com/steambap/captcha"
) )
type discordStore map[string]DiscordUser
type telegramStore map[string]TelegramUser
type matrixStore map[string]MatrixUser
type emailStore map[string]EmailAddress
type Storage struct { type Storage struct {
timePattern string timePattern string
invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path, matrix_path, announcements_path, matrix_sql_path string invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path, matrix_path, announcements_path, matrix_sql_path, userPage_path string
users map[string]time.Time // Map of Jellyfin User IDs to their expiry times. users map[string]time.Time // Map of Jellyfin User IDs to their expiry times.
invites Invites invites Invites
profiles map[string]Profile profiles map[string]Profile
defaultProfile string defaultProfile string
displayprefs, ombi_template map[string]interface{} displayprefs, ombi_template map[string]interface{}
emails map[string]EmailAddress emails emailStore
telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users. telegram telegramStore // Map of Jellyfin User IDs to telegram users.
discord map[string]DiscordUser // Map of Jellyfin user IDs to discord users. discord discordStore // Map of Jellyfin user IDs to discord users.
matrix map[string]MatrixUser // Map of Jellyfin user IDs to Matrix users. matrix matrixStore // Map of Jellyfin user IDs to Matrix users.
customEmails customEmails customEmails customEmails
userPage userPageContent
policy mediabrowser.Policy policy mediabrowser.Policy
configuration mediabrowser.Configuration configuration mediabrowser.Configuration
lang Lang lang Lang
announcements map[string]announcementTemplate announcements map[string]announcementTemplate
invitesLock, usersLock sync.Mutex invitesLock, usersLock, discordLock, telegramLock, matrixLock, emailsLock sync.Mutex
}
// GetEmails returns a copy of the store.
func (st *Storage) GetEmails() emailStore {
return st.emails
}
// GetEmailsKey returns the value stored in the store's key.
func (st *Storage) GetEmailsKey(k string) (EmailAddress, bool) {
v, ok := st.emails[k]
return v, ok
}
// SetEmailsKey stores value v in key k.
func (st *Storage) SetEmailsKey(k string, v EmailAddress) {
st.emailsLock.Lock()
st.emails[k] = v
st.storeEmails()
st.emailsLock.Unlock()
}
// DeleteEmailKey deletes value at key k.
func (st *Storage) DeleteEmailsKey(k string) {
st.emailsLock.Lock()
delete(st.emails, k)
st.storeEmails()
st.emailsLock.Unlock()
}
// GetDiscord returns a copy of the store.
func (st *Storage) GetDiscord() discordStore {
if st.discord == nil {
st.discord = discordStore{}
}
return st.discord
}
// GetDiscordKey returns the value stored in the store's key.
func (st *Storage) GetDiscordKey(k string) (DiscordUser, bool) {
v, ok := st.discord[k]
return v, ok
}
// SetDiscordKey stores value v in key k.
func (st *Storage) SetDiscordKey(k string, v DiscordUser) {
st.discordLock.Lock()
if st.discord == nil {
st.discord = discordStore{}
}
st.discord[k] = v
st.storeDiscordUsers()
st.discordLock.Unlock()
}
// DeleteDiscordKey deletes value at key k.
func (st *Storage) DeleteDiscordKey(k string) {
st.discordLock.Lock()
delete(st.discord, k)
st.storeDiscordUsers()
st.discordLock.Unlock()
}
// GetTelegram returns a copy of the store.
func (st *Storage) GetTelegram() telegramStore {
if st.telegram == nil {
st.telegram = telegramStore{}
}
return st.telegram
}
// GetTelegramKey returns the value stored in the store's key.
func (st *Storage) GetTelegramKey(k string) (TelegramUser, bool) {
v, ok := st.telegram[k]
return v, ok
}
// SetTelegramKey stores value v in key k.
func (st *Storage) SetTelegramKey(k string, v TelegramUser) {
st.telegramLock.Lock()
if st.telegram == nil {
st.telegram = telegramStore{}
}
st.telegram[k] = v
st.storeTelegramUsers()
st.telegramLock.Unlock()
}
// DeleteTelegramKey deletes value at key k.
func (st *Storage) DeleteTelegramKey(k string) {
st.telegramLock.Lock()
delete(st.telegram, k)
st.storeTelegramUsers()
st.telegramLock.Unlock()
}
// GetMatrix returns a copy of the store.
func (st *Storage) GetMatrix() matrixStore {
if st.matrix == nil {
st.matrix = matrixStore{}
}
return st.matrix
}
// GetMatrixKey returns the value stored in the store's key.
func (st *Storage) GetMatrixKey(k string) (MatrixUser, bool) {
v, ok := st.matrix[k]
return v, ok
}
// SetMatrixKey stores value v in key k.
func (st *Storage) SetMatrixKey(k string, v MatrixUser) {
st.matrixLock.Lock()
if st.matrix == nil {
st.matrix = matrixStore{}
}
st.matrix[k] = v
st.storeMatrixUsers()
st.matrixLock.Unlock()
}
// DeleteMatrixKey deletes value at key k.
func (st *Storage) DeleteMatrixKey(k string) {
st.matrixLock.Lock()
delete(st.matrix, k)
st.storeMatrixUsers()
st.matrixLock.Unlock()
}
// GetInvites returns a copy of the store.
func (st *Storage) GetInvites() Invites {
if st.invites == nil {
st.invites = Invites{}
}
return st.invites
}
// GetInvitesKey returns the value stored in the store's key.
func (st *Storage) GetInvitesKey(k string) (Invite, bool) {
v, ok := st.invites[k]
return v, ok
}
// SetInvitesKey stores value v in key k.
func (st *Storage) SetInvitesKey(k string, v Invite) {
st.invitesLock.Lock()
if st.invites == nil {
st.invites = Invites{}
}
st.invites[k] = v
st.storeInvites()
st.invitesLock.Unlock()
}
// DeleteInvitesKey deletes value at key k.
func (st *Storage) DeleteInvitesKey(k string) {
st.invitesLock.Lock()
delete(st.invites, k)
st.storeInvites()
st.invitesLock.Unlock()
} }
type TelegramUser struct { type TelegramUser struct {
@ -49,6 +214,7 @@ type DiscordUser struct {
Discriminator string Discriminator string
Lang string Lang string
Contact bool Contact bool
JellyfinID string `json:"-"` // Used internally in discord.go
} }
type EmailAddress struct { type EmailAddress struct {
@ -59,25 +225,30 @@ type EmailAddress struct {
} }
type customEmails struct { type customEmails struct {
UserCreated customEmail `json:"userCreated"` UserCreated customContent `json:"userCreated"`
InviteExpiry customEmail `json:"inviteExpiry"` InviteExpiry customContent `json:"inviteExpiry"`
PasswordReset customEmail `json:"passwordReset"` PasswordReset customContent `json:"passwordReset"`
UserDeleted customEmail `json:"userDeleted"` UserDeleted customContent `json:"userDeleted"`
UserDisabled customEmail `json:"userDisabled"` UserDisabled customContent `json:"userDisabled"`
UserEnabled customEmail `json:"userEnabled"` UserEnabled customContent `json:"userEnabled"`
InviteEmail customEmail `json:"inviteEmail"` InviteEmail customContent `json:"inviteEmail"`
WelcomeEmail customEmail `json:"welcomeEmail"` WelcomeEmail customContent `json:"welcomeEmail"`
EmailConfirmation customEmail `json:"emailConfirmation"` EmailConfirmation customContent `json:"emailConfirmation"`
UserExpired customEmail `json:"userExpired"` UserExpired customContent `json:"userExpired"`
} }
type customEmail struct { type customContent struct {
Enabled bool `json:"enabled,omitempty"` Enabled bool `json:"enabled,omitempty"`
Content string `json:"content"` Content string `json:"content"`
Variables []string `json:"variables,omitempty"` Variables []string `json:"variables,omitempty"`
Conditionals []string `json:"conditionals,omitempty"` Conditionals []string `json:"conditionals,omitempty"`
} }
type userPageContent struct {
Login customContent `json:"login"`
Page customContent `json:"page"`
}
// timePattern: %Y-%m-%dT%H:%M:%S.%f // timePattern: %Y-%m-%dT%H:%M:%S.%f
type Profile struct { type Profile struct {
@ -107,7 +278,6 @@ type Invite struct {
Notify map[string]map[string]bool `json:"notify"` Notify map[string]map[string]bool `json:"notify"`
Profile string `json:"profile"` Profile string `json:"profile"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Keys []string `json:"keys,omitempty"`
Captchas map[string]*captcha.Data // Map of Captcha IDs to answers Captchas map[string]*captcha.Data // Map of Captcha IDs to answers
} }
@ -116,9 +286,9 @@ type Lang struct {
chosenAdminLang string chosenAdminLang string
Admin adminLangs Admin adminLangs
AdminJSON map[string]string AdminJSON map[string]string
FormPath string UserPath string
chosenFormLang string chosenUserLang string
Form formLangs User userLangs
PasswordResetPath string PasswordResetPath string
chosenPWRLang string chosenPWRLang string
PasswordReset pwrLangs PasswordReset pwrLangs
@ -144,7 +314,11 @@ func (st *Storage) loadLang(filesystems ...fs.FS) (err error) {
if err != nil { if err != nil {
return return
} }
err = st.loadLangForm(filesystems...) err = st.loadLangEmail(filesystems...)
if err != nil {
return
}
err = st.loadLangUser(filesystems...)
if err != nil { if err != nil {
return return
} }
@ -152,10 +326,6 @@ func (st *Storage) loadLang(filesystems ...fs.FS) (err error) {
if err != nil { if err != nil {
return return
} }
err = st.loadLangEmail(filesystems...)
if err != nil {
return
}
err = st.loadLangTelegram(filesystems...) err = st.loadLangTelegram(filesystems...)
return return
} }
@ -164,7 +334,7 @@ func (st *Storage) loadLang(filesystems ...fs.FS) (err error) {
// from a list of other sources in a preferred order. // from a list of other sources in a preferred order.
// languages to patch from should be in decreasing priority, // languages to patch from should be in decreasing priority,
// E.g: If to = fr-be, from = [fr-fr, en-us]. // E.g: If to = fr-be, from = [fr-fr, en-us].
func (common *commonLangs) patchCommon(to *langSection, from ...string) { func (common *commonLangs) patchCommonStrings(to *langSection, from ...string) {
if *to == nil { if *to == nil {
*to = langSection{} *to = langSection{}
} }
@ -183,6 +353,44 @@ func (common *commonLangs) patchCommon(to *langSection, from ...string) {
} }
} }
func (common *commonLangs) patchCommonNotifications(to *langSection, from ...string) {
if *to == nil {
*to = langSection{}
}
for n, ev := range (*common)[from[len(from)-1]].Notifications {
if v, ok := (*to)[n]; !ok || v == "" {
i := 0
for i < len(from)-1 {
ev, ok = (*common)[from[i]].Notifications[n]
if ok && ev != "" {
break
}
i++
}
(*to)[n] = ev
}
}
}
func (common *commonLangs) patchCommonQuantityStrings(to *map[string]quantityString, from ...string) {
if *to == nil {
*to = map[string]quantityString{}
}
for n, ev := range (*common)[from[len(from)-1]].QuantityStrings {
if v, ok := (*to)[n]; !ok || (v.Singular == "" && v.Plural == "") {
i := 0
for i < len(from)-1 {
ev, ok = (*common)[from[i]].QuantityStrings[n]
if ok && ev.Singular != "" && ev.Plural != "" {
break
}
i++
}
(*to)[n] = ev
}
}
}
func patchLang(to *langSection, from ...*langSection) { func patchLang(to *langSection, from ...*langSection) {
if *to == nil { if *to == nil {
*to = langSection{} *to = langSection{}
@ -264,10 +472,14 @@ func (st *Storage) loadLangCommon(filesystems ...fs.FS) error {
if err == nil { if err == nil {
loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true
patchLang(&lang.Strings, &fallback.Strings, &english.Strings) patchLang(&lang.Strings, &fallback.Strings, &english.Strings)
patchLang(&lang.Notifications, &fallback.Notifications, &english.Notifications)
patchQuantityStrings(&lang.QuantityStrings, &fallback.QuantityStrings, &english.QuantityStrings)
} }
} }
if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" { if (lang.Meta.Fallback != "" && err != nil) || lang.Meta.Fallback == "" {
patchLang(&lang.Strings, &english.Strings) patchLang(&lang.Strings, &english.Strings)
patchLang(&lang.Notifications, &english.Notifications)
patchQuantityStrings(&lang.QuantityStrings, &english.QuantityStrings)
} }
} }
st.lang.Common[index] = lang st.lang.Common[index] = lang
@ -329,7 +541,8 @@ func (st *Storage) loadLangAdmin(filesystems ...fs.FS) error {
if err != nil { if err != nil {
return err return err
} }
st.lang.Common.patchCommon(&lang.Strings, index) st.lang.Common.patchCommonStrings(&lang.Strings, index)
st.lang.Common.patchCommonNotifications(&lang.Notifications, index)
if fname != "en-us.json" { if fname != "en-us.json" {
if lang.Meta.Fallback != "" { if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Admin[lang.Meta.Fallback] fallback, ok := st.lang.Admin[lang.Meta.Fallback]
@ -395,16 +608,16 @@ func (st *Storage) loadLangAdmin(filesystems ...fs.FS) error {
return nil return nil
} }
func (st *Storage) loadLangForm(filesystems ...fs.FS) error { func (st *Storage) loadLangUser(filesystems ...fs.FS) error {
st.lang.Form = map[string]formLang{} st.lang.User = map[string]userLang{}
var english formLang var english userLang
loadedLangs := make([]map[string]bool, len(filesystems)) loadedLangs := make([]map[string]bool, len(filesystems))
var load loadLangFunc var load loadLangFunc
load = func(fsIndex int, fname string) error { load = func(fsIndex int, fname string) error {
filesystem := filesystems[fsIndex] filesystem := filesystems[fsIndex]
index := strings.TrimSuffix(fname, filepath.Ext(fname)) index := strings.TrimSuffix(fname, filepath.Ext(fname))
lang := formLang{} lang := userLang{}
f, err := fs.ReadFile(filesystem, FSJoin(st.lang.FormPath, fname)) f, err := fs.ReadFile(filesystem, FSJoin(st.lang.UserPath, fname))
if err != nil { if err != nil {
return err return err
} }
@ -415,14 +628,21 @@ func (st *Storage) loadLangForm(filesystems ...fs.FS) error {
if err != nil { if err != nil {
return err return err
} }
st.lang.Common.patchCommon(&lang.Strings, index) st.lang.Common.patchCommonStrings(&lang.Strings, index)
st.lang.Common.patchCommonNotifications(&lang.Notifications, index)
st.lang.Common.patchCommonQuantityStrings(&lang.QuantityStrings, index)
// turns out, a lot of email strings are useful on the user page.
emailLang := []langSection{st.lang.Email[index].WelcomeEmail, st.lang.Email[index].UserDisabled, st.lang.Email[index].UserExpired}
for _, v := range emailLang {
patchLang(&lang.Strings, &v)
}
if fname != "en-us.json" { if fname != "en-us.json" {
if lang.Meta.Fallback != "" { if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Form[lang.Meta.Fallback] fallback, ok := st.lang.User[lang.Meta.Fallback]
err = nil err = nil
if !ok { if !ok {
err = load(fsIndex, lang.Meta.Fallback+".json") err = load(fsIndex, lang.Meta.Fallback+".json")
fallback = st.lang.Form[lang.Meta.Fallback] fallback = st.lang.User[lang.Meta.Fallback]
} }
if err == nil { if err == nil {
loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true loadedLangs[fsIndex][lang.Meta.Fallback+".json"] = true
@ -445,9 +665,14 @@ func (st *Storage) loadLangForm(filesystems ...fs.FS) error {
if err != nil { if err != nil {
return err return err
} }
userJSON, err := json.Marshal(lang)
if err != nil {
return err
}
lang.notificationsJSON = string(notifications) lang.notificationsJSON = string(notifications)
lang.validationStringsJSON = string(validationStrings) lang.validationStringsJSON = string(validationStrings)
st.lang.Form[index] = lang lang.JSON = string(userJSON)
st.lang.User[index] = lang
return nil return nil
} }
engFound := false engFound := false
@ -463,10 +688,10 @@ func (st *Storage) loadLangForm(filesystems ...fs.FS) error {
if !engFound { if !engFound {
return err return err
} }
english = st.lang.Form["en-us"] english = st.lang.User["en-us"]
formLoaded := false userLoaded := false
for i := range filesystems { for i := range filesystems {
files, err := fs.ReadDir(filesystems[i], st.lang.FormPath) files, err := fs.ReadDir(filesystems[i], st.lang.UserPath)
if err != nil { if err != nil {
continue continue
} }
@ -474,13 +699,13 @@ func (st *Storage) loadLangForm(filesystems ...fs.FS) error {
if !loadedLangs[i][f.Name()] { if !loadedLangs[i][f.Name()] {
err = load(i, f.Name()) err = load(i, f.Name())
if err == nil { if err == nil {
formLoaded = true userLoaded = true
loadedLangs[i][f.Name()] = true loadedLangs[i][f.Name()] = true
} }
} }
} }
} }
if !formLoaded { if !userLoaded {
return err return err
} }
return nil return nil
@ -506,7 +731,7 @@ func (st *Storage) loadLangPWR(filesystems ...fs.FS) error {
if err != nil { if err != nil {
return err return err
} }
st.lang.Common.patchCommon(&lang.Strings, index) st.lang.Common.patchCommonStrings(&lang.Strings, index)
if fname != "en-us.json" { if fname != "en-us.json" {
if lang.Meta.Fallback != "" { if lang.Meta.Fallback != "" {
fallback, ok := st.lang.PasswordReset[lang.Meta.Fallback] fallback, ok := st.lang.PasswordReset[lang.Meta.Fallback]
@ -540,7 +765,7 @@ func (st *Storage) loadLangPWR(filesystems ...fs.FS) error {
return err return err
} }
english = st.lang.PasswordReset["en-us"] english = st.lang.PasswordReset["en-us"]
formLoaded := false userLoaded := false
for i := range filesystems { for i := range filesystems {
files, err := fs.ReadDir(filesystems[i], st.lang.PasswordResetPath) files, err := fs.ReadDir(filesystems[i], st.lang.PasswordResetPath)
if err != nil { if err != nil {
@ -550,13 +775,13 @@ func (st *Storage) loadLangPWR(filesystems ...fs.FS) error {
if !loadedLangs[i][f.Name()] { if !loadedLangs[i][f.Name()] {
err = load(i, f.Name()) err = load(i, f.Name())
if err == nil { if err == nil {
formLoaded = true userLoaded = true
loadedLangs[i][f.Name()] = true loadedLangs[i][f.Name()] = true
} }
} }
} }
} }
if !formLoaded { if !userLoaded {
return err return err
} }
return nil return nil
@ -582,7 +807,7 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error {
if err != nil { if err != nil {
return err return err
} }
st.lang.Common.patchCommon(&lang.Strings, index) st.lang.Common.patchCommonStrings(&lang.Strings, index)
if fname != "en-us.json" { if fname != "en-us.json" {
if lang.Meta.Fallback != "" { if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Email[lang.Meta.Fallback] fallback, ok := st.lang.Email[lang.Meta.Fallback]
@ -679,7 +904,7 @@ func (st *Storage) loadLangTelegram(filesystems ...fs.FS) error {
if err != nil { if err != nil {
return err return err
} }
st.lang.Common.patchCommon(&lang.Strings, index) st.lang.Common.patchCommonStrings(&lang.Strings, index)
if fname != "en-us.json" { if fname != "en-us.json" {
if lang.Meta.Fallback != "" { if lang.Meta.Fallback != "" {
fallback, ok := st.lang.Telegram[lang.Meta.Fallback] fallback, ok := st.lang.Telegram[lang.Meta.Fallback]
@ -739,14 +964,10 @@ func (st *Storage) loadLangTelegram(filesystems ...fs.FS) error {
type Invites map[string]Invite type Invites map[string]Invite
func (st *Storage) loadInvites() error { func (st *Storage) loadInvites() error {
st.invitesLock.Lock()
defer st.invitesLock.Unlock()
return loadJSON(st.invite_path, &st.invites) return loadJSON(st.invite_path, &st.invites)
} }
func (st *Storage) storeInvites() error { func (st *Storage) storeInvites() error {
st.invitesLock.Lock()
defer st.invitesLock.Unlock()
return storeJSON(st.invite_path, st.invites) return storeJSON(st.invite_path, st.invites)
} }
@ -813,6 +1034,14 @@ func (st *Storage) storeCustomEmails() error {
return storeJSON(st.customEmails_path, st.customEmails) return storeJSON(st.customEmails_path, st.customEmails)
} }
func (st *Storage) loadUserPageContent() error {
return loadJSON(st.userPage_path, &st.userPage)
}
func (st *Storage) storeUserPageContent() error {
return storeJSON(st.userPage_path, st.userPage)
}
func (st *Storage) loadPolicy() error { func (st *Storage) loadPolicy() error {
return loadJSON(st.policy_path, &st.policy) return loadJSON(st.policy_path, &st.policy)
} }

View File

@ -40,7 +40,8 @@ module.exports = {
d_urge: dark.d_urge, d_urge: dark.d_urge,
d_warning: dark.d_warning, d_warning: dark.d_warning,
d_info: dark.d_info, d_info: dark.d_info,
d_critical: dark.d_critical d_critical: dark.d_critical,
discord: "#5865F2"
} }
} }
}, },

View File

@ -9,10 +9,20 @@ import (
tg "github.com/go-telegram-bot-api/telegram-bot-api" tg "github.com/go-telegram-bot-api/telegram-bot-api"
) )
const (
VERIF_TOKEN_EXPIRY_SEC = 10 * 60
)
type TelegramVerifiedToken struct { type TelegramVerifiedToken struct {
Token string
ChatID int64 ChatID int64
Username string Username string
JellyfinID string // optional, for ensuring a user-requested change is only accessed by them.
}
// VerifToken stores details about a pending user verification token.
type VerifToken struct {
Expiry time.Time
JellyfinID string // optional, for ensuring a user-requested change is only accessed by them.
} }
type TelegramDaemon struct { type TelegramDaemon struct {
@ -20,8 +30,8 @@ type TelegramDaemon struct {
ShutdownChannel chan string ShutdownChannel chan string
bot *tg.BotAPI bot *tg.BotAPI
username string username string
tokens []string tokens map[string]VerifToken // Map of pins to tokens.
verifiedTokens []TelegramVerifiedToken verifiedTokens map[string]TelegramVerifiedToken // Map of token pins to the responsible ChatID+Username.
languages map[int64]string // Store of languages for chatIDs. Added to on first interaction, and loaded from app.storage.telegram on start. languages map[int64]string // Store of languages for chatIDs. Added to on first interaction, and loaded from app.storage.telegram on start.
link string link string
app *appContext app *appContext
@ -40,13 +50,13 @@ func newTelegramDaemon(app *appContext) (*TelegramDaemon, error) {
ShutdownChannel: make(chan string), ShutdownChannel: make(chan string),
bot: bot, bot: bot,
username: bot.Self.UserName, username: bot.Self.UserName,
tokens: []string{}, tokens: map[string]VerifToken{},
verifiedTokens: []TelegramVerifiedToken{}, verifiedTokens: map[string]TelegramVerifiedToken{},
languages: map[int64]string{}, languages: map[int64]string{},
link: "https://t.me/" + bot.Self.UserName, link: "https://t.me/" + bot.Self.UserName,
app: app, app: app,
} }
for _, user := range app.storage.telegram { for _, user := range app.storage.GetTelegram() {
if user.Lang != "" { if user.Lang != "" {
td.languages[user.ChatID] = user.Lang td.languages[user.ChatID] = user.Lang
} }
@ -72,7 +82,15 @@ var runes = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
// NewAuthToken generates an 8-character pin in the form "A1-2B-CD". // NewAuthToken generates an 8-character pin in the form "A1-2B-CD".
func (t *TelegramDaemon) NewAuthToken() string { func (t *TelegramDaemon) NewAuthToken() string {
pin := genAuthToken() pin := genAuthToken()
t.tokens = append(t.tokens, pin) t.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: ""}
return pin
}
// NewAssignedAuthToken generates an 8-character pin in the form "A1-2B-CD",
// and assigns it for access only with the given Jellyfin ID.
func (t *TelegramDaemon) NewAssignedAuthToken(id string) string {
pin := genAuthToken()
t.tokens[pin] = VerifToken{Expiry: time.Now().Add(VERIF_TOKEN_EXPIRY_SEC * time.Second), JellyfinID: id}
return pin return pin
} }
@ -198,10 +216,10 @@ func (t *TelegramDaemon) commandLang(upd *tg.Update, sects []string, lang string
} }
if _, ok := t.app.storage.lang.Telegram[sects[1]]; ok { if _, ok := t.app.storage.lang.Telegram[sects[1]]; ok {
t.languages[upd.Message.Chat.ID] = sects[1] t.languages[upd.Message.Chat.ID] = sects[1]
for jfID, user := range t.app.storage.telegram { for jfID, user := range t.app.storage.GetTelegram() {
if user.ChatID == upd.Message.Chat.ID { if user.ChatID == upd.Message.Chat.ID {
user.Lang = sects[1] user.Lang = sects[1]
t.app.storage.telegram[jfID] = user t.app.storage.SetTelegramKey(jfID, user)
if err := t.app.storage.storeTelegramUsers(); err != nil { if err := t.app.storage.storeTelegramUsers(); err != nil {
t.app.err.Printf("Failed to store Telegram users: %v", err) t.app.err.Printf("Failed to store Telegram users: %v", err)
} }
@ -212,29 +230,58 @@ func (t *TelegramDaemon) commandLang(upd *tg.Update, sects []string, lang string
} }
func (t *TelegramDaemon) commandPIN(upd *tg.Update, sects []string, lang string) { func (t *TelegramDaemon) commandPIN(upd *tg.Update, sects []string, lang string) {
tokenIndex := -1 token, ok := t.tokens[upd.Message.Text]
for i, token := range t.tokens { if !ok || time.Now().After(token.Expiry) {
if upd.Message.Text == token {
tokenIndex = i
break
}
}
if tokenIndex == -1 {
err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("invalidPIN")) err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("invalidPIN"))
if err != nil { if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err)
} }
delete(t.tokens, upd.Message.Text)
return return
} }
err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("pinSuccess")) err := t.QuoteReply(upd, t.app.storage.lang.Telegram[lang].Strings.get("pinSuccess"))
if err != nil { if err != nil {
t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err) t.app.err.Printf("Telegram: Failed to send message to \"%s\": %v", upd.Message.From.UserName, err)
} }
t.verifiedTokens = append(t.verifiedTokens, TelegramVerifiedToken{ t.verifiedTokens[upd.Message.Text] = TelegramVerifiedToken{
Token: upd.Message.Text,
ChatID: upd.Message.Chat.ID, ChatID: upd.Message.Chat.ID,
Username: upd.Message.Chat.UserName, Username: upd.Message.Chat.UserName,
}) JellyfinID: token.JellyfinID,
t.tokens[len(t.tokens)-1], t.tokens[tokenIndex] = t.tokens[tokenIndex], t.tokens[len(t.tokens)-1] }
t.tokens = t.tokens[:len(t.tokens)-1] delete(t.tokens, upd.Message.Text)
}
// TokenVerified returns whether or not a token with the given PIN has been verified, and the token itself.
func (t *TelegramDaemon) TokenVerified(pin string) (token TelegramVerifiedToken, ok bool) {
token, ok = t.verifiedTokens[pin]
// delete(t.verifiedTokens, pin)
return
}
// AssignedTokenVerified returns whether or not a token with the given PIN has been verified, and the token itself.
// Returns false if the given Jellyfin ID does not match the one in the token.
func (t *TelegramDaemon) AssignedTokenVerified(pin string, jfID string) (token TelegramVerifiedToken, ok bool) {
token, ok = t.verifiedTokens[pin]
if ok && token.JellyfinID != jfID {
ok = false
}
// delete(t.verifiedTokens, pin)
return
}
// UserExists returns whether or not a user with the given username exists.
func (t *TelegramDaemon) UserExists(username string) (ok bool) {
ok = false
for _, u := range t.app.storage.GetTelegram() {
if u.Username == username {
ok = true
break
}
}
return
}
// DeleteVerifiedToken removes the token with the given PIN.
func (t *TelegramDaemon) DeleteVerifiedToken(pin string) {
delete(t.verifiedTokens, pin)
} }

Some files were not shown because too many files have changed in this diff Show More