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

Compare commits

...

39 Commits

Author SHA1 Message Date
dependabot[bot]
49a7df4fe7
Merge 97506c0bcd into 8f2a28e650 2023-12-26 17:02:26 +01:00
undone37
8f2a28e650 translation from Weblate (German)
Currently translated at 71.7% (175 of 244 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/de/
2023-12-26 16:33:01 +01:00
nomadics9
8a6102b7b9 translation from Weblate (Arabic)
Currently translated at 29.9% (73 of 244 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2023-12-26 16:33:01 +01:00
nionionping
0ce5c9923d translation from Weblate (Chinese (Simplified))
Currently translated at 100.0% (244 of 244 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/zh_Hans/
2023-12-26 16:33:01 +01:00
nionionping
4073ebe534 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (12 of 12 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/zh_Hans/
2023-12-26 16:33:01 +01:00
nionionping
387fe082ef Translated using Weblate (Chinese (Simplified))
Currently translated at 97.6% (122 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/zh_Hans/
2023-12-26 16:33:01 +01:00
nionionping
ddc36ae897 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/zh_Hans/
2023-12-26 16:33:01 +01:00
nionionping
c62876ff3a translation from Weblate (Chinese (Simplified))
Currently translated at 100.0% (62 of 62 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/zh_Hans/
2023-12-26 16:33:01 +01:00
Richard de Boer
2fd71acbb2 Translated using Weblate (Dutch)
Currently translated at 99.2% (124 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/nl/
2023-12-26 16:33:01 +01:00
Richard de Boer
4c1d8ed2a1 translation from Weblate (Dutch)
Currently translated at 100.0% (244 of 244 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/nl/
2023-12-26 16:33:01 +01:00
7223981280
done: use sshkey from secret, not file
update to appleboy/drone-ssh requires special ownership of the ssh key
file, which I can't be bothered with, so we'll source it from a secret
instead. Probably better anyway, that's how the other key was already.
2023-12-26 15:01:33 +00:00
47536f3e63
readme: update latest compatible version 2023-12-26 14:51:53 +00:00
ac4fecd819
site: fix for new font 2023-12-26 14:36:13 +00:00
b75bd4d6c5
Crash on SSL cert/key error, describe issue in log
if serving ssl/tls fails, the cert/key files are checked to see if they
    are accessible, and any errors logged.
2023-12-26 14:19:22 +00:00
2be7baea4a
trim base css of most redundant classes 2023-12-24 18:55:58 +00:00
d56d45a404
userpage: rework dynamic layout, finally 2023-12-24 18:26:35 +00:00
b50d66d265
ui: more modal fixes 2023-12-24 15:16:11 +00:00
aec0a5349a
ui: fix remaining few modal sizes on mobile 2023-12-24 15:04:58 +00:00
20560332ed
invites: improve inv dropdown wrapping 2023-12-24 14:53:37 +00:00
202ee0977e
invites: improve inv dropdown layout 2023-12-24 14:34:04 +00:00
f460bfcfc6
logip: fix user logging 2023-12-24 13:24:18 +00:00
4f5d12f800
invites: ui adjustments, fix duration display > 1y 2023-12-24 02:29:14 +00:00
9092b98b28
accounts: hide previous date example in extend expiry 2023-12-24 01:52:16 +00:00
0f72a85724
accounts: allow extending expiry of more than one user 2023-12-24 01:45:11 +00:00
0840931fed
Merge (optional) IP logging 2023-12-24 01:06:07 +00:00
00379824df
Merge branch 'main' into kimboslice99-main 2023-12-23 21:53:39 +00:00
f823705e40
ips: log on activities, show on card 2023-12-23 21:47:41 +00:00
269836fc99
ips: add advanced settings for ip logging 2023-12-23 21:00:32 +00:00
49d8c6f8e4
pwr: add captcha daemon 2023-12-23 20:18:16 +00:00
278588ca39
pwr: functioning captcha/recaptcha 2023-12-23 20:10:48 +00:00
ab05c07469
form: modularize captcha somewhat 2023-12-23 18:20:09 +00:00
kimboslice99
04c94ba55a Log IPs 2023-12-23 13:09:49 -05:00
6e205760c3
ui: more invites page improvements/cleanup, fix tooltips on mobile 2023-12-23 17:45:18 +00:00
82032b98a8
invites: improve invite wrapping on mobile 2023-12-23 15:36:28 +00:00
e8666d5bf2
ui: general adjustments 2023-12-22 21:40:56 +00:00
d1affe271c
ui: wrap settings header 2023-12-22 18:36:21 +00:00
ea109c7b63
ui: wrap accounts/activity headers 2023-12-22 18:06:12 +00:00
cb5a8c1c23
accounts: position filter dropdown better for mobile 2023-12-22 17:46:57 +00:00
dependabot[bot]
97506c0bcd
build(deps): bump postcss from 8.4.24 to 8.4.31
Bumps [postcss](https://github.com/postcss/postcss) from 8.4.24 to 8.4.31.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.24...8.4.31)

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

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

View File

@ -45,9 +45,6 @@ type: docker
steps:
- name: build-deploy
image: appleboy/drone-ssh
volumes:
- name: ssh_key
path: /root/drone_rsa
settings:
host:
from_secret: ssh2_host
@ -55,9 +52,8 @@ steps:
from_secret: ssh2_username
port:
from_secret: ssh2_port
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
key_path: /root/drone_rsa
key:
from_secret: ssh2_key
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh stable
@ -128,9 +124,6 @@ type: docker
steps:
- name: build-deploy
image: appleboy/drone-ssh
volumes:
- name: ssh_key
path: /root/drone_rsa
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
@ -141,11 +134,10 @@ steps:
from_secret: ssh2_username
port:
from_secret: ssh2_port
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
envs:
- buildrone_key
key_path: /root/drone_rsa
key:
from_secret: ssh2_key
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh

View File

@ -13,7 +13,7 @@
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
#### Does/Will it still work?
jfa-go currently works on Jellyfin 10.8.9, the latest version. I should be able to maintain compatability in the future, unless any big changes occur.
jfa-go currently works on Jellyfin 10.8.13, the latest version as of 26/12/23. I should be able to maintain compatability in the future, unless any big changes occur.
#### Alternatives
If you want a bit more of a guarantee of support, I've seen these projects mentioned although haven't tried them myself.

View File

@ -138,6 +138,7 @@ func (app *appContext) GetActivities(gc *gin.Context) {
InviteCode: act.InviteCode,
Value: act.Value,
Time: act.Time.Unix(),
IP: act.IP,
}
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
resp.Activities[i].Username = act.Value

View File

@ -102,7 +102,7 @@ func (app *appContext) checkInvites() {
InviteCode: data.Code,
Value: data.Label,
Time: time.Now(),
})
}, nil, false)
}
}
@ -161,7 +161,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
})
}, nil, false)
} else if used {
del := false
newInv := inv
@ -174,7 +174,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
InviteCode: code,
Value: inv.Label,
Time: time.Now(),
})
}, nil, false)
} else if newInv.RemainingUses != 0 {
// 0 means infinite i guess?
newInv.RemainingUses--
@ -285,7 +285,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
InviteCode: invite.Code,
Value: invite.Label,
Time: time.Now(),
})
}, gc, false)
respondBool(200, true, gc)
}
@ -305,7 +305,8 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if inv.IsReferral {
continue
}
_, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
months += years * 12
invite := inviteDTO{
Code: inv.Code,
Months: months,
@ -492,7 +493,7 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
InviteCode: req.Code,
Value: inv.Label,
Time: time.Now(),
})
}, gc, false)
app.info.Printf("%s: Invite deleted", req.Code)
respondBool(200, true, gc)

View File

@ -573,6 +573,7 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param MatrixLoginDTO body MatrixLoginDTO true "Username & password."
// @Router /matrix/login [post]
// @Security Bearer
// @tags Other
func (app *appContext) MatrixLogin(gc *gin.Context) {
var req MatrixLoginDTO
@ -608,6 +609,7 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param MatrixConnectUserDTO body MatrixConnectUserDTO true "User's Jellyfin ID & Matrix user ID."
// @Router /users/matrix [post]
// @Security Bearer
// @tags Other
func (app *appContext) MatrixConnect(gc *gin.Context) {
var req MatrixConnectUserDTO
@ -639,6 +641,7 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param username path string true "username to search."
// @Router /users/discord/{username} [get]
// @Security Bearer
// @tags Other
func (app *appContext) DiscordGetUsers(gc *gin.Context) {
name := gc.Param("username")
@ -665,6 +668,7 @@ func (app *appContext) DiscordGetUsers(gc *gin.Context) {
// @Failure 500 {object} boolResponse
// @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID."
// @Router /users/discord [post]
// @Security Bearer
// @tags Other
func (app *appContext) DiscordConnect(gc *gin.Context) {
var req DiscordConnectUserDTO
@ -688,7 +692,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
}, gc, false)
linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc)
@ -699,6 +703,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
// @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/discord [delete]
// @Security Bearer
// @Tags Users
func (app *appContext) UnlinkDiscord(gc *gin.Context) {
var req forUserDTO
@ -717,7 +722,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
}, gc, false)
respondBool(200, true, gc)
}
@ -727,6 +732,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
// @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/telegram [delete]
// @Security Bearer
// @Tags Users
func (app *appContext) UnlinkTelegram(gc *gin.Context) {
var req forUserDTO
@ -745,7 +751,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
})
}, gc, false)
respondBool(200, true, gc)
}
@ -755,6 +761,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
// @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/matrix [delete]
// @Security Bearer
// @Tags Users
func (app *appContext) UnlinkMatrix(gc *gin.Context) {
var req forUserDTO
@ -773,7 +780,7 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
})
}, gc, false)
respondBool(200, true, gc)
}

View File

@ -216,7 +216,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
Source: gc.GetString("jfId"),
Value: "email",
Time: time.Now(),
})
}, gc, true)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
ombiUser, code, err := app.getOmbiUser(id)
@ -378,7 +378,7 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@ -426,7 +426,7 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@ -507,7 +507,7 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
})
}, gc, true)
delete(app.matrix.tokens, pin)
respondBool(200, true, gc)
@ -529,7 +529,7 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "discord",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@ -550,7 +550,7 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "telegram",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@ -571,7 +571,7 @@ func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "matrix",
Time: time.Now(),
})
}, gc, true)
respondBool(200, true, gc)
}
@ -701,7 +701,7 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
})
}, gc, true)
if app.config.Section("ombi").Key("enabled").MustBool(false) {
func() {

View File

@ -55,7 +55,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: user.Name,
Time: time.Now(),
})
}, gc, false)
profile := app.storage.GetDefaultProfile()
if req.Profile != "" && req.Profile != "none" {
@ -114,7 +114,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
type errorFunc func(gc *gin.Context)
// Used on the form & when a users email has been confirmed.
func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, success bool) {
func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context) (f errorFunc, success bool) {
existingUser, _, _ := app.jf.UserByName(req.Username, false)
if existingUser.Name != "" {
f = func(gc *gin.Context) {
@ -331,7 +331,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
InviteCode: invite.Code,
Value: user.Name,
Time: time.Now(),
})
}, gc, true)
emailStore := EmailAddress{
Addr: req.Email,
@ -503,7 +503,7 @@ func (app *appContext) NewUser(gc *gin.Context) {
var req newUserDTO
gc.BindJSON(&req)
app.debug.Printf("%s: New user attempt", req.Code)
if app.config.Section("captcha").Key("enabled").MustBool(false) && !app.verifyCaptcha(req.Code, req.CaptchaID, req.CaptchaText) {
if app.config.Section("captcha").Key("enabled").MustBool(false) && !app.verifyCaptcha(req.Code, req.CaptchaID, req.CaptchaText, false) {
app.info.Printf("%s: New user failed: Captcha Incorrect", req.Code)
respond(400, "errorCaptcha", gc)
return
@ -539,7 +539,7 @@ func (app *appContext) NewUser(gc *gin.Context) {
return
}
}
f, success := app.newUser(req, false)
f, success := app.newUser(req, false, gc)
if !success {
f(gc)
return
@ -609,7 +609,7 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Time: time.Now(),
})
}, gc, false)
if sendMail && req.Notify {
if err := app.sendByID(msg, userID); err != nil {
@ -687,7 +687,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: username,
Time: time.Now(),
})
}, gc, false)
if sendMail && req.Notify {
if err := app.sendByID(msg, userID); err != nil {
@ -1208,7 +1208,7 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "email",
Time: time.Now(),
})
}, gc, false)
if ombiEnabled {
ombiUser, code, err := app.getOmbiUser(id)

12
api.go
View File

@ -114,6 +114,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
var req ResetPasswordDTO
gc.BindJSON(&req)
validation := app.validator.validate(req.Password)
captcha := app.config.Section("captcha").Key("enabled").MustBool(false)
valid := true
for _, val := range validation {
if !val {
@ -121,12 +122,18 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
}
if !valid || req.PIN == "" {
// 200 bcs idk what i did in js
app.info.Printf("%s: Password reset failed: Invalid password", req.PIN)
gc.JSON(400, validation)
return
}
isInternal := false
if captcha && !app.verifyCaptcha(req.PIN, req.PIN, req.CaptchaText, true) {
app.info.Printf("%s: PWR Failed: Captcha Incorrect", req.PIN)
respond(400, "errorCaptcha", gc)
return
}
var userID, username string
if reset, ok := app.internalPWRs[req.PIN]; ok {
isInternal = true
@ -138,6 +145,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
userID = reset.ID
username = reset.Username
status, err := app.jf.ResetPasswordAdmin(userID)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Password Reset failed (%d): %v", status, err)
@ -179,7 +187,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
})
}, gc, true)
prevPassword := req.PIN
if isInternal {

45
auth.go
View File

@ -18,6 +18,25 @@ const (
REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24
)
func (app *appContext) logIpInfo(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.info.Println(out)
}
func (app *appContext) logIpDebug(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.debug.Println(out)
}
func (app *appContext) logIpErr(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.err.Println(out)
}
func (app *appContext) webAuth() gin.HandlerFunc {
return app.authenticate
}
@ -133,7 +152,7 @@ type getTokenDTO struct {
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) {
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool) (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)
@ -141,7 +160,7 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, pas
password = creds[1]
ok = false
if username == "" || password == "" {
app.debug.Println("Auth denied: blank username/password")
app.logIpDebug(gc, userpage, "Auth denied: blank username/password")
respond(401, "Unauthorized", gc)
return
}
@ -149,17 +168,17 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, pas
return
}
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context) (user mediabrowser.User, ok bool) {
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (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)")
app.logIpInfo(gc, userpage, "Auth denied: Invalid username/password (Jellyfin)")
respond(401, "Unauthorized", gc)
return
}
if status == 403 {
app.info.Println("Auth denied: Jellyfin account disabled")
app.logIpInfo(gc, userpage, "Auth denied: Jellyfin account disabled")
respond(403, "yourAccountWasDisabled", gc)
return
}
@ -180,8 +199,8 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
// @tags Auth
// @Security getTokenAuth
func (app *appContext) getTokenLogin(gc *gin.Context) {
app.info.Println("Token requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc)
app.logIpInfo(gc, false, "Token requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc, false)
if !ok {
return
}
@ -196,12 +215,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
}
}
if !app.jellyfinLogin && !match {
app.info.Println("Auth denied: Invalid username/password")
app.logIpInfo(gc, false, "Auth denied: Invalid username/password")
respond(401, "Unauthorized", gc)
return
}
if !match {
user, ok := app.validateJellyfinCredentials(username, password, gc)
user, ok := app.validateJellyfinCredentials(username, password, gc, false)
if !ok {
return
}
@ -233,7 +252,8 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
respond(500, "Couldn't generate token", gc)
return
}
gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true)
host := gc.Request.URL.Hostname()
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{token})
}
@ -284,7 +304,7 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
// @Router /token/refresh [get]
// @tags Auth
func (app *appContext) getTokenRefresh(gc *gin.Context) {
app.debug.Println("Token requested (refresh token)")
app.logIpInfo(gc, false, "Token requested (refresh token)")
claims, ok := app.decodeValidateRefreshCookie(gc, "refresh")
if !ok {
return
@ -297,6 +317,7 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
respond(500, "Couldn't generate token", gc)
return
}
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", gc.Request.URL.Hostname(), true, true)
host := gc.Request.URL.Hostname()
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{jwt})
}

View File

@ -120,6 +120,9 @@ func (app *appContext) loadConfig() error {
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
// These two settings are pretty much the same
url1 := app.config.Section("invite_emails").Key("url_base").String()
url2 := app.config.Section("password_resets").Key("url_base").String()

View File

@ -297,6 +297,29 @@
"advanced": true
},
"settings": {
"log_ips": {
"name": "Log IPs accessing Admin Page",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Log IP addresses of admins and admin page requests in console and in activities. See notice below on legality."
},
"log_ips_users": {
"name": "Log IPs accessing User Page",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Log IP addresses of users in console and in activities. See notice below on legality."
},
"ip_note": {
"name": "Logging IPs:",
"type": "note",
"value": "",
"required": "false",
"description": "Logging IP addresses through jfa-go may violate GDPR or other privacy regulations, as IPs are linked to account information. Enable at your own risk."
},
"tls": {
"name": "TLS/HTTP2",
"required": false,

View File

@ -106,48 +106,6 @@ div.card:contains(section.banner.footer) {
padding-bottom: 0px;
}
.tab-button {
font-size: 2rem;
}
.al {
text-align: left;
}
.ar {
text-align: right;
}
.ac {
text-align: center;
}
.w-100 {
width: 100%;
}
.h-100 {
height: 100%;
}
.inline-block {
display: inline-block;
}
.align-top {
align-items: top;
}
.flex-expand {
display: flex;
justify-content: space-between;
}
.flex-row-group {
display: block;
flex-grow: 1;
}
.row {
display: flex;
flex-wrap: wrap;
@ -172,23 +130,7 @@ span.sm:not(.heading) {
margin: .25rem;
}
.flex-col {
display: flex;
flex-direction: column;
}
.flex-form {
display: flex;
flex-direction: column;
}
@media screen and (min-width: 768px) {
.flex-form {
flex: 1;
margin: 0.5rem;
}
}
/* Who knows for half of these to be honest */
@media screen and (max-width: 400px) {
.row {
flex-direction: column;
@ -219,69 +161,6 @@ sup.\~critical, .text-critical {
font-size: 1rem;
}
.inv-created-users strong,p {
padding-left: 0.5rem;
padding-bottom: 0.2rem;
}
.inv-created-users.empty strong,p {
padding: 0;
}
.inv {
overflow: visible;
}
.inv-table {
font-size: 0.8rem;
}
.inv-profilearea {
min-width: 20%;
}
.inv-profileselect {
min-width: 100%;
}
.inv-codearea {
max-width: 40%;
min-width: 10rem;
display: flex;
justify-content: start;
align-items: center;
}
.inv-empty .inv-codearea {
justify-content: start;
}
.invite-link {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: auto;
}
.ellipsis {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.no-pad {
padding: 0px 0px 0px 0px;
}
.elem-pad > * {
margin: var(--spacing-4, 1rem);
}
.icon.clickable {
padding: 0.5rem 0.6rem;
}
.input {
box-sizing: border-box; /* fixes weird length issue with inputs */
}
@ -300,10 +179,6 @@ sup.\~critical, .text-critical {
width: 100%;
}
.flex-auto {
flex: auto;
}
.center {
justify-content: center;
}
@ -312,14 +187,6 @@ sup.\~critical, .text-critical {
align-items: center;
}
.no-lp {
padding-left: 0px;
}
.block {
display: block;
}
.focused {
display: block;
}
@ -416,7 +283,16 @@ table {
color: var(--color-content);
}
table.table.manual-pad th, table.table.manual-pad td {
padding: 0;
}
table.table-p-0 th, table.table-p-0 td {
padding-left: 0 !important;
padding-right: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
p.top {
margin-top: 0px;
@ -575,7 +451,6 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
cursor: pointer;
}
.g-recaptcha {
overflow: hidden;
width: 296px;
@ -587,3 +462,8 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
.g-recaptcha iframe {
margin: -2px 0px 0px -4px;
}
.dropdown-manual-toggle {
margin-bottom: -0.5rem;
padding-bottom: 0.5rem;
}

View File

@ -5,6 +5,7 @@
.tooltip .content {
visibility: hidden;
opacity: 0;
max-width: 10rem;
min-width: 6rem;
background-color: rgba(0, 0, 0, 0.6);
@ -13,12 +14,23 @@
border-radius: 6px;
overflow-wrap: break-word;
text-align: center;
transition: opacity 100ms;
position: absolute;
z-index: 1;
top: -1rem;
}
.tooltip.below .content {
top: 2.5rem;
left: 0;
right: 0;
}
.tooltip.darker .content {
background-color: rgba(0, 0, 0, 0.8);
}
.tooltip.right .content {
left: 120%;
}
@ -31,6 +43,10 @@
font-size: 0.8rem;
}
.tooltip:hover .content {
.tooltip:hover .content,
.tooltip:focus .content,
.tooltip:focus-within .content
{
visibility: visible;
opacity: 1;
}

View File

@ -74,6 +74,17 @@ func (app *appContext) clearTelegram() {
}
}
func (app *appContext) clearPWRCaptchas() {
app.debug.Println("Housekeeping: Clearing old PWR Captchas")
captchas := map[string]Captcha{}
for k, capt := range app.pwrCaptchas {
if capt.Generated.Add(CAPTCHA_VALIDITY * time.Second).After(time.Now()) {
captchas[k] = capt
}
}
app.pwrCaptchas = captchas
}
func (app *appContext) clearActivities() {
app.debug.Println("Housekeeping: Cleaning up Activity log...")
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
@ -136,6 +147,7 @@ func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemo
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false)
clearTelegram := app.config.Section("telegram").Key("require_unique").MustBool(false)
clearMatrix := app.config.Section("matrix").Key("require_unique").MustBool(false)
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
if clearEmail || clearDiscord || clearTelegram || clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() })
@ -153,6 +165,9 @@ func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemo
if clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() })
}
if clearPWR {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearPWRCaptchas() })
}
return &daemon
}

View File

@ -26,7 +26,7 @@
<body class="max-w-full overflow-x-hidden section">
{{ template "login-modal.html" . }}
<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-11/12 sm:w-4/5 lg:w-1/3" id="form-add-user" href="">
<span class="heading">{{ .strings.newUser }} <span class="modal-close">&times;</span></span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="add-user-user">
<input type="email" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.emailAddress }}">
@ -43,31 +43,31 @@
</form>
</div>
<div id="modal-about" class="modal">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-1/3 content card">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card">
<img src="{{ .urlBase }}/banner.svg" class="banner header" alt="jfa-go banner">
<span class="heading"><span class="modal-close">&times;</span></span>
<p>{{ .strings.version }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .version }}</span></p>
<p>{{ .strings.commitNoun }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .commit }}</span></p>
<p>{{ .strings.buildTime }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .buildTime }}</span></p>
<p>{{ .strings.builtBy }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .builtBy }}</span></p>
<div class="row col flex">
<a class="button ~neutral mr-2 mt-4 mb-4 lang-link" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line mr-2"></i>github</a>
<a class="button ~urge mt-4 mb-4 mr-2 lang-link" href="https://wiki.jfa-go.com">wiki/docs</a>
<a class="button ~positive mt-4 mb-4 mr-2 lang-link" href="https://weblate.jfa-go.com">translation</a>
<div class="dropdown mr-2" tabindex="0">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info mt-4 mb-4 dropdown-button lang-link">
<div class="flex flex-row flex-wrap gap-2 my-2">
<a class="button ~neutral lang-link" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line mr-2"></i>github</a>
<a class="button ~urge lang-link" href="https://wiki.jfa-go.com">wiki/docs</a>
<a class="button ~positive lang-link" href="https://weblate.jfa-go.com">translation</a>
<div class="dropdown" tabindex="0">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info dropdown-button lang-link">
<i class="ri-hand-heart-line mr-2"></i>
donate
<span class="ml-2 chev"></span>
</a>
<div class="dropdown-display">
<div class="card ~neutral @low">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-100 lang-link">GitHub</a>
<a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-100 lang-link">Ko-fi</a>
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">GitHub</a>
<a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">Ko-fi</a>
</div>
</div>
</div>
<a class="button ~urge mt-4 mb-4 @low discord lang-link" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line mr-2"></i>discord</a>
<a class="button ~urge @low discord lang-link" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line mr-2"></i>discord</a>
</div>
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License. Font "Hanken Grotesk" available under SIL OFL 1.1 License.</a></p>
<pre class="font-mono bg-inherit">{{ .license }}</pre>
@ -80,15 +80,15 @@
</div>
</div>
<div id="modal-modify-user" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-modify-user" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-modify-user" href="">
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.modifySettingsDescription }}</p>
<div class="flex flex-row mb-4">
<label class="flex-row-group mr-2">
<label class="grow mr-2">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label>
<label class="flex-row-group ml-2">
<label class="grow ml-2">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
<span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span>
</label>
@ -111,15 +111,15 @@
</div>
{{ if .referralsEnabled }}
<div id="modal-enable-referrals-user" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-user" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-user" href="">
<span class="heading"><span id="header-enable-referrals-user"></span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.enableReferralsDescription }}</p>
<div class="flex flex-row mb-4">
<label class="flex-row-group mr-2">
<label class="grow mr-2">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label>
<label class="flex-row-group ml-2">
<label class="grow ml-2">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-invite">
<span class="button ~neutral @low supra full-width center">{{ .strings.invite }}</span>
</label>
@ -142,7 +142,7 @@
</form>
</div>
<div id="modal-enable-referrals-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href="">
<span class="heading"><span id="header-enable-referrals-profile">{{ .strings.enableReferrals }}</span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.enableReferralsProfileDescription }}</p>
<label class="supra" for="enable-referrals-profile-invites">{{ .strings.invite }}</label>
@ -162,7 +162,7 @@
</div>
{{ end }}
<div id="modal-delete-user" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-delete-user" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-delete-user" href="">
<span class="heading"><span id="header-delete-user"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8">
<label class="switch mb-4">
@ -178,7 +178,7 @@
</form>
</div>
<div id="modal-extend-expiry" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8">
<aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside>
@ -242,7 +242,7 @@
<div id="modal-announce" class="modal">
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-announce" href="">
<span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span>
<div class="row">
<div class="flex flex-col md:flex-row">
<div class="col card ~neutral @low">
<div id="announce-details">
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
@ -259,7 +259,7 @@
<input type="text" class="input ~neutral @low mb-2 mt-4">
<p class="support">{{ .strings.templateEnterName }}</p>
</label>
<div class="row flex-expand">
<div class="flex flex-row justify-between">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low center supra submit">{{ .strings.send }}</span>
@ -275,10 +275,10 @@
</form>
</div>
<div id="modal-customize" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
<span class="heading">{{ .strings.customizeMessages }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.customizeMessagesDescription }}</p>
<div class="table-responsive">
<div class="">
<table class="table">
<thead>
<tr>
@ -319,7 +319,7 @@
</form>
</div>
<div id="modal-restart" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~critical @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~critical @low">
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.settingsRestartRequiredDescription }}</p>
<div class="float-right">
@ -329,7 +329,7 @@
</div>
</div>
<div id="modal-backups" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3">
<span class="heading">{{ .strings.backups }} <span class="modal-close">&times;</span></span>
<div class="content my-4">
{{ .strings.backupsDescription }}
@ -345,7 +345,7 @@
<input id="backups-file" name="backups-file" type="file" hidden>
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-sort-direction">{{ .strings.sortDirection }}</button>
</div>
<div class="overflow-x-auto">
<div class="overflow-x-auto text-xs md:text-sm">
<table class="table">
<thead>
<tr>
@ -360,30 +360,30 @@
</div>
</div>
<div id="modal-backed-up" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.backupCreated }} <span class="modal-close">&times;</span></span>
<p class="content my-4" id="settings-backed-up-location"></p>
<p class="content my-4">{{ .strings.backupCanDownload }}</p>
<div>
<button class="button flex w-100 ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
<button class="button flex w-full ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
</div>
</div>
</div>
<div id="modal-refresh" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.settingsApplied }}</span>
<p class="content">{{ .strings.settingsRefreshPage }}</p>
</div>
</div>
<div id="modal-send-pwr" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.sendPWR }}</span>
<p class="content my-2" id="send-pwr-note"></p>
<span class="button ~urge @low mt-2" id="send-pwr-link">{{ .strings.copy }}</span>
</div>
</div>
<div id="modal-ombi-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-ombi-defaults" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-ombi-defaults" href="">
<span class="heading">{{ .strings.ombiProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.ombiUserDefaultsDescription }}</p>
<div class="select ~neutral @low mb-4">
@ -396,7 +396,7 @@
</form>
</div>
<div id="modal-user-profiles" class="modal">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3 content card">
<span class="heading">{{ .strings.userProfiles }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.userProfilesDescription }}</p>
<div class="table-responsive">
@ -422,7 +422,7 @@
</div>
</div>
<div id="modal-add-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-profile" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-profile" href="">
<span class="heading">{{ .strings.addProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.addProfileDescription }}</p>
<label>
@ -445,7 +445,7 @@
</form>
</div>
<div id="modal-update" class="modal">
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card">
<span class="heading">{{ .strings.updates }} <span class="modal-close">&times;</span></span>
<p class="content">
<h2 class="mt-2">
@ -461,7 +461,7 @@
</div>
{{ if .telegramEnabled }}
<div id="modal-telegram" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p>
<h1 class="ac" id="telegram-pin"></h1>
@ -479,7 +479,7 @@
{{ end }}
{{ if .discordEnabled }}
<div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3">
<span class="heading mb-4"><span id="discord-header"></span><span class="modal-close">&times;</span></span>
<p class="content mb-4" id="discord-description"></p>
<div class="row">
@ -490,7 +490,7 @@
</div>
{{ end }}
<div id="modal-matrix" class="modal">
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-matrix" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-matrix" href="">
<span class="heading">{{ .strings.linkMatrix }}</span>
<p class="content my-4">{{ .strings.linkMatrixDescription }}</p>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.matrixHomeServer }}" id="matrix-homeserver">
@ -503,7 +503,7 @@
</form>
</div>
<div id="notification-box"></div>
<div class="top-4 left-4 absolute">
<div class="top-4 left-4 absolute flex flex-row gap-2">
<span class="dropdown z-[11]" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button">
<i class="ri-global-line"></i>
@ -532,8 +532,8 @@
{{ end }}
<div class="page-container">
<div class="mb-4">
<header class="flex flex-wrap items-center justify-between">
<div>
<header>
<div class="flex flex-row overflow-x-scroll items-center">
<span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.invites }}</span>
<span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.accounts }}</span>
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.activity }}</span>
@ -547,55 +547,55 @@
</div>
</div>
<div id="tab-invites">
<div class="card @low invites dark:~d_neutral mb-4">
<div class="card @low invites dark:~d_neutral mb-4 overflow-visible">
<span class="heading">{{ .strings.invites }}</span>
<div id="invites"></div>
<div id="invites" class="mt-2"></div>
</div>
<div class="card @low dark:~d_neutral">
<span class="heading">{{ .strings.create }}</span>
<div class="flex flex-col md:flex-row gap-3" id="create-inv">
<div class="card ~neutral @low col">
<div class="row mb-2">
<label class="col mr-2">
<div class="flex flex-col md:flex-row gap-3 mt-2" id="create-inv">
<div class="card ~neutral @low flex flex-col gap-2 w-1/2">
<div class="flex flex-row gap-2">
<label class="w-1/2">
<input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span>
</label>
<label class="col ml-2">
<label class="w-1/2">
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
<span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span>
</label>
</div>
<div id="inv-duration">
<div class="row">
<div class="col">
<div id="inv-duration" class="flex flex-col gap-2">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="create-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="create-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="create-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="create-days">
<option>0</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="create-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="create-hours">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="create-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="create-minutes">
<option>0</option>
</select>
@ -603,44 +603,46 @@
</div>
</div>
</div>
<div id="user-expiry" class="unfocused">
<p class="support mb-2">{{ .strings.userExpiryDescription }}</p>
<div class="mb-2">
<label for="create-user-expiry-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
<span class="ml-2">{{ .strings.enabled }} </span>
</label>
<div id="user-expiry" class="unfocused flex flex-col gap-2">
<div class="flex flex-row gap-2">
<p class="support">{{ .strings.userExpiryDescription }}</p>
<div>
<label for="create-user-expiry-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
<span class="ml-2">{{ .strings.enabled }} </span>
</label>
</div>
</div>
<div class="row">
<div class="col">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="user-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="user-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="user-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="user-days">
<option>0</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="user-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="user-hours">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="grow flex flex-col gap-4">
<label class="label supra" for="user-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="user-minutes">
<option>0</option>
</select>
@ -648,77 +650,91 @@
</div>
</div>
</div>
<div class="col">
<div class="flex flex-col gap-4">
<label class="label supra" for="create-label"> {{ .strings.label }}</label>
<input type="text" id="create-label" class="input ~neutral @low mb-2 mt-4">
<input type="text" id="create-label" class="input ~neutral @low">
</div>
<div class="col">
<label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label>
<p class="support">{{ .strings.userLabelDescription }}</p>
<input type="text" id="create-user-label" class="input ~neutral @low mb-2 mt-4">
<div class="flex flex-col gap-4">
<div>
<label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label>
<p class="support">{{ .strings.userLabelDescription }}</p>
</div>
<input type="text" id="create-user-label" class="input ~neutral @low">
</div>
</div>
<div class="card ~neutral @low col">
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
<div class="flex-expand mb-2 mt-4">
<input type="number" min="0" id="create-uses" class="input ~neutral @low mr-2" value=1>
<label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite">
<span>&infin;</span>
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
</label>
</div>
<p class="support unfocused my-2" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
<label class="label supra">{{ .strings.profile }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="create-profile">
</select>
</div>
<div id="create-send-to-container">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
<div class="flex-expand mb-2 mt-4">
{{ if .discordEnabled }}
<input type="text" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com | user#1234">
<span id="create-send-to-search" class="button ~neutral @low mr-2">
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
</span>
{{ else }}
<input type="email" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com">
{{ end }}
<label for="create-send-to-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
</label>
<div class="card ~neutral @low flex flex-col justify-between gap-2 w-1/2">
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-4">
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
<div class="flex flex-row gap-2">
<input type="number" min="0" id="create-uses" class="input ~neutral @low" value=1>
<label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite">
<span>&infin;</span>
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
</label>
</div>
</div>
<p class="support unfocused" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
<div class="flex flex-col gap-4">
<label class="label supra">{{ .strings.profile }}</label>
<div class="select ~neutral @low">
<select id="create-profile">
</select>
</div>
</div>
<div id="create-send-to-container" class="flex flex-col gap-4">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
<div class="flex flex-row gap-2">
{{ if .discordEnabled }}
<input type="text" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com | user#1234">
<span id="create-send-to-search" class="button ~neutral @low">
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
</span>
{{ else }}
<input type="email" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com">
{{ end }}
<label for="create-send-to-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
</label>
</div>
</div>
</div>
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
<div>
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
</div>
</div>
</div>
</div>
</div>
<div id="tab-accounts" class="unfocused">
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible">
<div class="flex-expand align-middle">
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
<div id="accounts-filter-dropdown" class="dropdown z-10" tabindex="0">
<span class="h-100 button ~neutral @low center" id="accounts-filter-button">{{ .strings.filters }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low mt-2" id="accounts-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
<div id="accounts-filter-dropdown" class="dropdown manual z-10 w-full">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="accounts-filter-button" tabindex="0">{{ .strings.filters }}</button></span>
</div>
<div class="flex flex-row align-middle w-full">
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
</div>
</div>
<div class="dropdown-display max-w-full">
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="accounts-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
</div>
<div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="accounts-filter-area"></span>
</div>
<div class="supra py-1 sm">{{ .strings.actions }}</div>
<div class="row -mx-2">
<span class="col button ~neutral @low center max-w-[20%]" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~info @low center" id="accounts-announce">{{ .strings.announce }}</span>
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div>
<div class="flex flex-row flex-wrap gap-3 mb-4">
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0">
<span class="w-full button ~info @low center items-baseline" id="accounts-announce">{{ .strings.announce }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="supra sm">{{ .strings.templates }}</span>
@ -726,12 +742,12 @@
</div>
</div>
</div>
<span class="col button ~urge @low center max-w-[20%]" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
<span class="button ~urge @low center " id="accounts-modify-user">{{ .strings.modifySettings }}</span>
{{ if .referralsEnabled }}
<span class="col button ~urge @low center max-w-[20%]" id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
<span class="button ~urge @low center " id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
{{ end }}
<div id="accounts-expiry-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~positive @low center" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span>
<div id="accounts-expiry-dropdown" class="dropdown pb-0i " tabindex="0">
<span class="w-full button ~positive @low center items-baseline" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="button ~warning full-width @low center" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
@ -739,16 +755,16 @@
</div>
</div>
</div>
<div id="accounts-disable-enable-dropdown" class="col dropdown manual pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<div id="accounts-disable-enable-dropdown" class="dropdown manual pb-0i " tabindex="0">
<span class="w-full button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="button ~urge full-width accounts-announce-template-button" id="accounts-enable-expiry">{{ .strings.setExpiry }}</span>
</div>
</div>
</div>
<span class="col button ~info @low center unfocused max-w-[20%]" id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="col button ~critical @low center max-w-[20%]" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div>
<div class="card @low accounts-header table-responsive mt-2">
<table class="table text-base leading-4">
@ -791,27 +807,33 @@
</div>
<div id="tab-activity" class="unfocused">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
<div class="flex-expand align-middle">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div id="activity-filter-dropdown" class="dropdown z-10" tabindex="0">
<span class="h-100 button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low mt-2" id="activity-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
<div id="activity-filter-dropdown" class="dropdown manual z-10 w-full" tabindex="0">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div class="flex flex-row align-middle">
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</button></span>
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
</div>
</div>
<div class="flex flex-row align-middle w-full">
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
</div>
<div class="dropdown-display max-w-full">
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="activity-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
<div class="flex flex-row justify-between py-2">
<div class="flex flex-row justify-between pt-3 pb-2">
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra sm">
<span id="activity-total-records" class="mx-2"></span>
<span id="activity-loaded-records" class="mx-2"></span>
<span id="activity-shown-records" class="mx-2"></span>
<div class="supra sm flex flex-row gap-2">
<span id="activity-total-records"></span>
<span id="activity-loaded-records"></span>
<span id="activity-shown-records"></span>
</div>
</div>
<div class="row -mx-2 mb-2">
@ -842,24 +864,24 @@
</div>
<div id="tab-settings" class="unfocused">
<div class="card @low dark:~d_neutral settings overflow">
<div class="flex-expand">
<div class="flex-row">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="heading">{{ .strings.settings }}</span>
<label for="settings-advanced-enabled" class="button ~neutral @low ml-2 my-2">
<label for="settings-advanced-enabled" class="button ~neutral @low ml-2">
<input type="checkbox" id="settings-advanced-enabled" aria-label="Advanced settings enabled">
<span class="ml-2">{{ .strings.advancedSettings }} </span>
</label>
</div>
<div>
<span class="button ~neutral @low my-1" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low my-1" id="settings-backups">{{ .strings.backups }}</span>
<span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
<div class="flex flex-row justify-start md:justify-end gap-2 w-full">
<span class="button ~neutral @low" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low" id="settings-backups">{{ .strings.backups }}</span>
<span class="button ~neutral @low" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused" id="settings-save">{{ .strings.settingsSave }}</span>
</div>
</div>
<div class="flex flex-col md:flex-row gap-3">
<div class="card @low dark:~d_neutral col" id="settings-sidebar">
<div class="flex-expand">
<div class="flex flex-row justify-between">
<input type="search" class="field ~neutral @low input settings-section-button justify-between mb-2" id="settings-search" placeholder="{{ .strings.search }}">
<button class="button ~neutral @low center -ml-10 rounded-s-none mb-2 settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button>
</div>

View File

@ -18,7 +18,7 @@
<a class="button ~critical mb-4" target="_blank" href="https://github.com/hrfee/jfa-go/issues/new/choose">Create an Issue</a>
</section>
<section class="section ~neutral @low">
<div class="flex-expand">
<div class="flex flex-row justify-between">
<span class="subheading">Full Log</span>
<span class="button ~urge ml-4" id="copy-log">Copy</span>
</div>

View File

@ -34,8 +34,12 @@
</script>
{{ if .passwordReset }}
<script src="js/pwr.js" type="module"></script>
<script>
window.pwrPIN = "{{ .pwrPIN }}";
</script>
{{ else }}
<script src="js/form.js" type="module"></script>
{{ end }}
{{ if .reCAPTCHA }}
<script>
var reCAPTCHACallback = () => {
@ -49,4 +53,3 @@
<script src="https://www.google.com/recaptcha/api.js?onload=reCAPTCHACallback&render=explicit" async defer></script>
{{ end }}
{{ end }}
{{ end }}

View File

@ -1,21 +1,21 @@
<div id="modal-login" class="modal">
<div class="my-[10%] row items-stretch relative mx-auto w-[40%] lg:w-[60%]">
<div class="my-[10%] row items-stretch relative mx-auto w-11/12 sm:w-4/5 lg:w-1/2">
{{ 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">
<div class="card mx-2 flex-initial w-[100%] lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content">
{{ .LoginMessageContent }}
</div>
{{ end }}
{{ end }}
{{ if index . "userPageEnabled" }}
{{ if and .userPageEnabled .showUserPageLink }}
<div class="card mx-2 flex-initial w-[100%] xl:w-[35%] mb-4 xl:mb-0 dark:~d_neutral @low content">
<div class="card mx-2 flex-initial w-[100%] lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content">
<span class="heading row">{{ .strings.loginNotAdmin }}</span>
<a class="button ~info h-12 w-100" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
<a class="button ~info h-12 w-full" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
</div>
{{ end }}
{{ end }}
<form class="card mx-2 flex-auto form-login w-[100%] xl:w-[55%] mb-0" href="">
<form class="card mx-2 form-login w-[100%] lg: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">

View File

@ -35,7 +35,7 @@
<aside class="aside ~warning">
{{ .strings.changeYourPassword }}
</aside>
<span class="button ~urge @low w-100 text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span>
<span class="button ~urge @low w-full text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span>
{{ end }}
</div>
<i class="content">{{ .contactMessage }}</i>

View File

@ -30,7 +30,7 @@
<div class="row col flex center">
<p class="content my-2">{{ .lang.StartPage.pressStart }}</p>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="support">{{ .lang.StartPage.httpsNotice }}</span>
<span class="button ~urge @low next">{{ .lang.StartPage.start }}</span>
</section>
@ -59,7 +59,7 @@
</select>
</div>
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
@ -150,7 +150,7 @@
</label>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
@ -189,7 +189,7 @@
<span class="support mb-2 mt-1">{{ .lang.Login.emailNotice }}</span>
</label>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
@ -235,7 +235,7 @@
</label>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low" id="jellyfin-test-connection">{{ .lang.JellyfinEmby.testConnection }}</span>
@ -258,7 +258,7 @@
<input type="text" class="input ~neutral @low mt-4" id="ombi-api_key">
<p class="support mb-2 mt-1">{{ .lang.Ombi.apiKeyNotice }}</p>
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -273,7 +273,7 @@
<input type="checkbox" class="mr-2" id="userpage-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label>
<p class="support mb-1 mt-1">{{ .lang.UserPage.requiredSettings }}</p>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -370,7 +370,7 @@
</div>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -392,7 +392,7 @@
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="welcome_email-subject" placeholder="{{ .emailLang.WelcomeEmail.title }}">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -413,7 +413,7 @@
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="invite_emails-subject" placeholder="{{ .emailLang.InviteEmail.title }}">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -450,7 +450,7 @@
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="password_resets-subject" placeholder="{{ .emailLang.PasswordReset.title }}">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -483,7 +483,7 @@
<span class="mt-4">{{ .lang.PasswordValidation.special }}</span>
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-special" value="0">
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -513,7 +513,7 @@
<input type="text" class="input ~neutral @low mt-4" id="email-message">
<p class="support mb-2 mt-1">{{ .lang.HelpMessages.emailMessageNotice }}</p>
</label>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>

View File

@ -109,10 +109,10 @@
<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">
<div class="columns-1 sm:columns-2 gap-4" id="user-cardlist">
{{ if index . "PageMessageEnabled" }}
{{ if .PageMessageEnabled }}
<div class="card @low dark:~d_neutral content" id="card-message">
<div class="card @low dark:~d_neutral content break-words" id="card-message">
{{ .PageMessageContent }}
</div>
{{ end }}
@ -162,7 +162,7 @@
<div class="card @low dark:~d_neutral unfocused" id="card-referrals">
<span class="heading mb-2">{{ .strings.referrals }}</span>
<aside class="aside ~neutral my-4 col user-referrals-description"></aside>
<div class="row flex-expand">
<div class="flex flex-row justify-between gap-2">
<div class="user-referrals-info"></div>
<div class="grid my-2">
<button type="button" class="user-referrals-button button ~info dark:~d_info @low" title="Copy">{{ .strings.copyReferral }}<i class="ri-file-copy-line ml-2"></i></button>

View File

@ -32,12 +32,12 @@
"before": "قبل",
"user": "مستخدم",
"userExpiry": "انتهاء صلاحية المستخدم",
"userExpiryDescription": "",
"userExpiryDescription": "بعد وقت محدد من تسجيل مستخدم جديد, jfa-go سوف يمسح\\يلغي تفعيل الحساب. بامكانك تغيير هذا السلوك في الاعدادات.",
"aboutProgram": "حول",
"version": "إصدار",
"commitNoun": "تعديل",
"commitNoun": "فرض",
"newUser": "مستخدم جديد",
"profile": "ملف",
"profile": "حساب تعريفي",
"unknown": "غير معروف",
"label": "وسم",
"logs": "السجلات",
@ -63,19 +63,19 @@
"markdownSupported": "",
"modifySettings": "",
"modifySettingsDescription": "",
"applyHomescreenLayout": "",
"sendDeleteNotificationEmail": "",
"sendDeleteNotifiationExample": "",
"settingsRestart": "",
"settingsRestarting": "",
"settingsRestartRequired": "",
"settingsRestartRequiredDescription": "",
"settingsApplyRestartLater": "",
"settingsApplyRestartNow": "",
"settingsApplied": "",
"settingsRefreshPage": "",
"settingsRequiredOrRestartMessage": "",
"settingsSave": "",
"applyHomescreenLayout": "تطبيق ترتيب الصفحه الرئيسيه",
"sendDeleteNotificationEmail": "ارسال رساله اشعار",
"sendDeleteNotifiationExample": "تم حذف حسابك.",
"settingsRestart": "اعاده تشغيل",
"settingsRestarting": "اعاده التشغيل…",
"settingsRestartRequired": "يجب اعاده التشغيل",
"settingsRestartRequiredDescription": "يجب اعاده التشغيل لتطبيق بعض الاعدادات التي تم تغييرها. اعاده التشغيل الان ام لاحقا؟",
"settingsApplyRestartLater": "تطبيق الاعدادات, اعاده التشغيل لاحقا",
"settingsApplyRestartNow": "تطبيق الاعدادات و اعاده التشغيل",
"settingsApplied": "تم تطبيق الاعدادات.",
"settingsRefreshPage": "اعد انعاش الصفحه بعد بضع ثوان.",
"settingsRequiredOrRestartMessage": "ملاحظه: {n} تشير الى حقل اجباري, {n} تشير ان التغييرات تحتاج لاعاده التشغيل.",
"settingsSave": "حفظ",
"ombiProfile": "",
"ombiUserDefaultsDescription": "",
"userProfiles": "",
@ -117,7 +117,15 @@
"userPageLogin": "",
"userPagePage": "",
"buildTime": "",
"builtBy": ""
"builtBy": "",
"activity": "الانشطه",
"userLabel": "وسم المستخدم",
"userLabelDescription": "الوسام للمستخدمين المفعلين من هذه الدعوه.",
"enableReferrals": "تفعيل الاحالات",
"disableReferrals": "ابطال الاحالات",
"invite": "دعوه",
"enableReferralsProfileDescription": "تمكين المستخدمين من هذا الحساب التعريفي للاحالات الخاصه, لارسالها للعائله\\الاصدقاء. انشاء دعوه بالاعدادات المطلوبه, ثم اختارها هنا. كل احاله سوف تكون مبنيه على اعدادات هذه الدعوه. بامكانك مسح الدعوه عند لانتهاء.",
"enableReferralsDescription": "تمكين المستخدمين لاستعمال احالات خاصه مثل الدعوه, لارسالها للعائله\\للاصدقاء. ممكن اصدارها من قوالب الاحالات في الحساب التعريفي, او من دعوه مفعله."
},
"notifications": {
"changedEmailAddress": "",

View File

@ -115,7 +115,8 @@
"after": "nach",
"before": "vor",
"unlink": "Account trennen",
"sortingBy": "Sortieren nach"
"sortingBy": "Sortieren nach",
"activity": "Aktivität"
},
"notifications": {
"changedEmailAddress": "E-Mail-Adresse von {n} geändert.",

View File

@ -150,7 +150,8 @@
"accountDisabled": "Account disabled: {user}",
"accountReEnabled": "Account re-enabled: {user}",
"accountExpired": "Account expired: {user}",
"accountWillExpire": "Account will expire on {date}",
"accountWillExpire": "Account will expire on {date}.",
"expirationBasedOn": "Given date based on 1st user.",
"userDeleted": "User was deleted.",
"userDisabled": "User was disabled",
"inviteCreated": "Invite created: {invite}",

View File

@ -130,7 +130,53 @@
"searchOptions": "Zoekopties",
"matchText": "Tekstovereenkomst",
"jellyfinID": "Jellyfin ID",
"userPagePage": "Gebruikerspagina: Pagina"
"userPagePage": "Gebruikerspagina: Pagina",
"activity": "Activiteit",
"deleted": "Verwijderd",
"disabled": "Uitgeschakeld",
"keepSearching": "Blijf zoeken",
"keepSearchingDescription": "Alleen momenteel ingeladen activiteiten zijn doorzocht. Klik hieronder om alle activiteiten te doorzoeken.",
"sortDirection": "Sorteerrichting",
"referrer": "Verwijzer",
"accountLinked": "{contactMethod} gekoppeld: {user}",
"accountUnlinked": "{contactMethod} verwijderd: {user}",
"accountResetPassword": "{user} heeft hun wachtwoord gereset",
"accountChangedPassword": "{user} heeft hun wachtwoord gewijzigd",
"accountDisabled": "Account uitgeschakeld: {user}",
"accountDeleted": "Account verwijderd: {user}",
"accountCreated": "Account aangemaakt: {user}",
"accountReEnabled": "Account opnieuw ingeschakeld: {user}",
"accountExpired": "Account verlopen: {user}",
"userDeleted": "Gebruiker is verwijderd.",
"userDisabled": "Gebruiker is uitgeschakeld",
"inviteCreated": "Uitnodiging aangemaakt: {invite}",
"inviteDeleted": "Uitnodiging verwijderd: {invite}",
"inviteExpired": "Uitnodiging verlopen: {invite}",
"fromInvite": "Via uitnodiging",
"byAdmin": "Door beheerder",
"byUser": "Door gebruiker",
"byJfaGo": "Door jfa-go",
"activityID": "Activiteit ID",
"title": "Titel",
"usersMentioned": "Genoemde gebruiker",
"actor": "Uitvoerder",
"actorDescription": "Wat deze actie veroorzaakt heeft. \"gebruiker\"/\"beheerder\"/\"daemon\" of een gebruikersnaam.",
"accountCreationFilter": "Aanmaken van account",
"accountDeletionFilter": "Verwijderen van account",
"accountDisabledFilter": "Account uitgeschakeld",
"accountEnabledFilter": "Account ingeschakeld",
"contactLinkedFilter": "Contact gekoppeld",
"contactUnlinkedFilter": "Contact ontkoppeld",
"passwordChangeFilter": "Wachtwoord gewijzigd",
"passwordResetFilter": "Wachtwoord gereset",
"inviteCreatedFilter": "Uitnodiging aangemaakt",
"inviteDeletedFilter": "Uitnodiging verwijderd/verlopen",
"loadMore": "Laad meer",
"loadAll": "Laad alles",
"noMoreResults": "Niet meer resultaten.",
"totalRecords": "{n} documenten totaal",
"loadedRecords": "{n} geladen",
"shownRecords": "{n} getoond"
},
"notifications": {
"changedEmailAddress": "E-mailadres van {n} gewijzigd.",
@ -170,7 +216,11 @@
"setOmbiProfile": "Opgeslagen ombi-profiel.",
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.",
"errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.",
"referralsEnabled": "Verwijzingen actief."
"referralsEnabled": "Verwijzingen actief.",
"activityDeleted": "Activiteit verwijderd.",
"errorInviteNoLongerExists": "Uitnodiging bestaat niet meer.",
"errorInviteNotFound": "Uitnodiging niet gevonden.",
"errorLoadActivities": "Laden van activiteiten mislukt."
},
"quantityStrings": {
"modifySettingsFor": {

View File

@ -69,7 +69,7 @@
"ombiUserDefaults": "Ombi 用户默认值",
"ombiUserDefaultsDescription": "创建并配置 Ombi 用户,然后在下面选择它。它的设置/权限将被存储并应用于由 jfa-go 创建的新 Ombi 用户。",
"userProfiles": "用户档案",
"userProfilesDescription": "配置文件在用户创建帐户时应用于用户。配置文件包括库访问权限和主屏幕布局。",
"userProfilesDescription": "个人资料在用户创建帐户时应用于他们。个人资料包括库访问权限和主屏幕布局。",
"userProfilesIsDefault": "默认",
"userProfilesLibraries": "库",
"addProfile": "添加档案",
@ -117,7 +117,66 @@
"before": "之前",
"unlink": "取消关联帐户",
"sortingBy": "排序方式",
"userPageLogin": "用户页面:登录"
"userPageLogin": "用户页面:登录",
"activity": "活动",
"userLabelDescription": "标签应用于使用此邀请创建的用户。",
"disabled": "禁用",
"keepSearchingDescription": "只有当前加载的活动被搜索了。如果您想搜索所有活动,请点击下方。",
"enableReferralsDescription": "为用户提供一个个人的推荐链接,类似于邀请,以便他们发送给朋友和家人。可以从个人资料中的推荐模板获取,或从现有的邀请中获取。",
"userDeleted": "用户已被删除。",
"inviteCreated": "邀请已创建:{invite}",
"usersMentioned": "用户提到的",
"actorDescription": "引起这个操作的事物。可以是“用户”、“管理员”、“守护程序”或用户名。",
"loginNotAdmin": "不是管理员?",
"invite": "邀请",
"noResultsFound": "没有发现任何结果",
"settingsHiddenDependency": "匹配设置被隐藏,因为它们取决于另一个设置的值:",
"settingsDependsOn": "{setting}:依赖于 {dependency}",
"settingsAdvancedMode": "{setting}:必须启用高级设置",
"settingsMaybeUnderAdvanced": "提示:通过启用高级设置,您可能会找到您正在寻找的内容。",
"userLabel": "用户标签",
"deleted": "删除",
"keepSearching": "继续搜索",
"enableReferrals": "启用推荐",
"disableReferrals": "禁用推荐",
"enableReferralsProfileDescription": "为使用该个人资料创建的用户提供一个类似邀请的个人推荐链接,以便他们发送给朋友和家人。创建一个具有所需设置的邀请,然后在此处进行选择。然后,每个推荐都将基于这个邀请。完成后,您可以删除邀请。",
"sortDirection": "排序方向",
"referrer": "推荐人",
"accountLinked": "{contactMethod} 已关联:{user}",
"accountUnlinked": "{contactMethod} 已移除:{user}",
"accountResetPassword": "{user} 重置了他们的密码",
"accountChangedPassword": "{user} 更改了他们的密码",
"accountCreated": "账户已创建:{user}",
"accountDeleted": "账户已删除:{user}",
"accountDisabled": "账户已禁用:{user}",
"accountReEnabled": "账户已重新启用:{user}",
"accountExpired": "账户已过期:{user}",
"userDisabled": "用户已被禁用",
"inviteDeleted": "邀请已删除:{invite}",
"inviteExpired": "邀请已过期:{invite}",
"fromInvite": "来自邀请",
"byAdmin": "由管理员发起的",
"byUser": "由用户发起的",
"byJfaGo": "由jfa-go发起的",
"activityID": "活动ID",
"title": "标题",
"actor": "角色",
"accountCreationFilter": "账户创建",
"accountDeletionFilter": "账户删除",
"accountDisabledFilter": "账户禁用",
"accountEnabledFilter": "账户启用",
"contactLinkedFilter": "联系方式已关联",
"contactUnlinkedFilter": "联系方式未关联",
"passwordChangeFilter": "密码已更改",
"passwordResetFilter": "密码重置",
"inviteCreatedFilter": "邀请已创建",
"inviteDeletedFilter": "邀请已删除/过期",
"loadMore": "加载更多",
"loadAll": "加载全部",
"noMoreResults": "没有更多结果了。",
"totalRecords": "{n} 总记录数",
"loadedRecords": "已加载{n}",
"shownRecords": "已显示{n}"
},
"notifications": {
"changedEmailAddress": "更改了 {n} 的电子邮件地址。",
@ -155,7 +214,13 @@
"updateAvailable": "有新更新可用,请检查设置。",
"noUpdatesAvailable": "没有可用的更新。",
"setOmbiProfile": "保存ombi配置文件。",
"errorSetOmbiProfile": "无法保存ombi配置文件。"
"errorSetOmbiProfile": "无法保存ombi配置文件。",
"activityDeleted": "活动已删除。",
"errorNoReferralTemplate": "个人资料不包含推荐模板,请在设置中添加一个。",
"referralsEnabled": "已启用推荐。",
"errorInviteNoLongerExists": "邀请已不存在。",
"errorInviteNotFound": "未找到邀请。",
"errorLoadActivities": "无法加载活动。"
},
"quantityStrings": {
"modifySettingsFor": {
@ -213,6 +278,10 @@
"setExpiry": {
"plural": "为{n}用户设置到期时间",
"singular": "为{n}用户设置到期时间"
},
"enableReferralsFor": {
"singular": "为{n}用户启用推荐功能",
"plural": "为{n}个用户启用推荐功能"
}
}
}

View File

@ -29,7 +29,7 @@
"logout": "登出",
"admin": "管理员",
"enabled": "已启用",
"disabled": "禁用",
"disabled": "禁用",
"reEnable": "重新启用",
"disable": "禁用",
"expiry": "到期",
@ -40,7 +40,8 @@
"contactMethods": "联系方式",
"accountStatus": "帐户状态",
"notSet": "未设置",
"myAccount": "我的帐户"
"myAccount": "我的帐户",
"referrals": "推荐"
},
"notifications": {
"errorLoginBlank": "用户名/密码留空。",

View File

@ -34,7 +34,10 @@
"welcomeUser": "欢迎,{user}",
"editContactMethod": "修改联系方式",
"joinTheServer": "加入服务器:",
"customMessagePlaceholderHeader": "自定义此卡"
"customMessagePlaceholderHeader": "自定义此卡",
"referralsDescription": "使用此链接邀请朋友和家人加入Jellyfin。如果链接过期请回到这里获取一个新的。",
"copyReferral": "复制链接",
"invitedBy": "您是由用户{user}邀请的。"
},
"notifications": {
"errorUserExists": "用户已经存在。",

View File

@ -22,7 +22,8 @@
"errorNotAdmin": "Gebruiker heeft geen beheersrechten.",
"errorConnectionRefused": "Verbinding geweigerd.",
"errorUnknown": "Onbekende fout, bekijk de logs.",
"error": "Fout"
"error": "Fout",
"errorProxy": "Proxy-instellingen onjuist."
},
"startPage": {
"welcome": "Welkom!",
@ -156,5 +157,11 @@
"title": "Gebruikerspagina",
"customizeMessages": "Gebruik de bewerken knop naast \"Gebruikerspagina\" in de instellingen om dit later in te stellen.",
"requiredSettings": "Inloggen bij jfa-go via Jellyfin moet ingesteld zijn. Controleer dat \"reset wachtwoord via link\" later wordt gekozen voor zelfservice wachtwoord-resets."
},
"proxy": {
"title": "Proxy",
"description": "Laat jfa-go alle verbindingen via een HTTP/SOCKS5 proxy maken. De verbinding met Jellyfin wordt hierdoorheen getest.",
"protocol": "Protocol",
"address": "Adres (inclusief poort)"
}
}

View File

@ -8,7 +8,7 @@
"back": "上一步",
"optional": "可选的",
"serverType": "服务类型",
"disabled": "禁用",
"disabled": "禁用",
"enabled": "已启用",
"port": "端口",
"message": "信息",
@ -17,12 +17,13 @@
"URL": "链接",
"apiKey": "API 密钥",
"errorInvalidUserPass": "无效的用户名/密码。",
"errorNotAdmin": "此用户不允许管理服务。",
"errorNotAdmin": "用户没有权限管理服务器。",
"errorUserDisabled": "此永固可能已被禁用。",
"error404": "404请检查内部URL。",
"errorConnectionRefused": "连接被拒绝。",
"error": "错误",
"errorUnknown": "未知错误,请检查应用程序日志。"
"errorUnknown": "未知错误,请检查应用程序日志。",
"errorProxy": "代理配置无效。"
},
"startPage": {
"welcome": "欢迎!",
@ -70,7 +71,8 @@
"adminOnly": "仅允许管理员账户(推荐)",
"emailNotice": "您的电子邮件地址可以用来接收通知。",
"allowAllDescription": "不建议,您应该允许单个用户在设置后登录。",
"allowAll": "允许所有Jellyfin用户登录"
"allowAll": "允许所有Jellyfin用户登录",
"authorizeManualUserPageNotice": "使用此选项将禁用“用户页面”功能。"
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
@ -126,7 +128,8 @@
"resetLinksNotice": "如果启用了 Ombi 集成,请使用它与 Ombi 同步 Jellyfin 密码重置。",
"resetLinksLanguage": "默认重置链接语言",
"setPassword": "通过链接设置密码",
"setPasswordNotice": "启用此功能意味着用户无需在重置后通过 PIN 更改其密码。并将强制执行密码验证。"
"setPasswordNotice": "启用此功能意味着用户无需在重置后通过 PIN 更改其密码。并将强制执行密码验证。",
"resetLinksRequiredForUserPage": "对于用户页面上的自助密码重置,这是必需的。"
},
"passwordValidation": {
"title": "密码验证",
@ -148,5 +151,17 @@
"successMessageNotice": "在用户创建账户时显示。",
"emailMessage": "电子邮件",
"emailMessageNotice": "显示在电子邮件的底部。"
},
"proxy": {
"description": "让jfa-go通过HTTP/SOCKS5代理进行所有连接。连接到Jellyfin将通过此代理进行测试。",
"title": "代理",
"protocol": "协议",
"address": "地址(包括端口)"
},
"userPage": {
"description": "用户页面(显示为“我的帐户”)允许用户访问有关他们帐户的信息,如其联系方式和账户过期日期。他们还可以更改密码、启动密码重置,并在无需询问您的情况下链接/更改联系方式。此外用户还可以在登录前后看到自定义的Markdown消息。",
"title": "用户页面",
"customizeMessages": "在设置中,单击“用户页面”旁边的编辑按钮以稍后进行设置。",
"requiredSettings": "通过Jellyfin登录到jfa-go必须设置。确保稍后选择“通过链接重置密码”以进行自助密码重置。"
}
}

View File

@ -11,6 +11,8 @@
"discordStartMessage": "您好!\n请输入 `/pin <PIN码>`以验证您的账户。",
"languageMessageDiscord": "提示:使用 `/lang <语言>` 设置语言。",
"languageSet": "语言改成 {language}。",
"discordDMs": "请检查您的DM找回答。"
"discordDMs": "请检查您的DM找回答。",
"sentInvite": "已发送邀请。",
"sentInviteFailure": "发送邀请失败,请检查日志。"
}
}
}

14
main.go
View File

@ -46,6 +46,8 @@ var (
SWAGGER *bool
QUIT = false
RUNNING = false
LOGIP = false // Log admin IPs
LOGIPU = false // Log user IPs
// Used to know how many times to re-broadcast restart signal.
RESTARTLISTENERCOUNT = 0
warning = color.New(color.FgYellow).SprintfFunc()
@ -120,6 +122,7 @@ type appContext struct {
proxyTransport *http.Transport
proxyConfig easyproxy.ProxyConfig
internalPWRs map[string]InternalPWR
pwrCaptchas map[string]Captcha
ConfirmationKeys map[string]map[string]newUserDTO // Map of invite code to jwt to request
confirmationKeysLock sync.Mutex
}
@ -554,7 +557,16 @@ func start(asDaemon, firstCall bool) {
cert := app.config.Section("advanced").Key("tls_cert").MustString("")
key := app.config.Section("advanced").Key("tls_key").MustString("")
if err := SRV.ListenAndServeTLS(cert, key); err != nil {
app.err.Printf("Failure serving: %s", err)
filesToCheck := []string{cert, key}
fileNames := []string{"Certificate", "Key"}
for i, v := range filesToCheck {
_, err := os.Stat(v)
if err != nil {
app.err.Printf("SSL/TLS %s: %v\n", fileNames[i], err)
}
}
app.err.Fatalf("Failure serving with SSL/TLS: %s", err)
}
} else {
if err := SRV.ListenAndServe(); err != nil {

View File

@ -332,8 +332,9 @@ type MatrixLoginDTO struct {
}
type ResetPasswordDTO struct {
PIN string `json:"pin"`
Password string `json:"password"`
PIN string `json:"pin"`
Password string `json:"password"`
CaptchaText string `json:"captcha_text"`
}
type AdminPasswordResetDTO struct {
@ -444,6 +445,7 @@ type ActivityDTO struct {
InviteCode string `json:"invite_code"`
Value string `json:"value"`
Time int64 `json:"time"`
IP string `json:"ip"`
}
type GetActivitiesDTO struct {

14
package-lock.json generated
View File

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

View File

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

View File

@ -7,11 +7,11 @@ all:
cp -r ts tempts
../scripts/dark-variant.sh tempts
npx esbuild --target=es6 --bundle tempts/main.ts --outfile=out/main.js --minify
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --external:modal.css --minify
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --external:modal.css --external:../fonts/hanken* --minify
npx tailwindcss -c tailwind.config.js -i out/bundle.css -o out/bundle.css
cd out && npx uncss index.html --stylesheets remixicon.css > _remixicon.css; cd ..
mv out/_remixicon.css out/remixicon.css
cp ../static/* out/
cp -r ../static/* out/
node inject.js
debug:
@ -22,10 +22,10 @@ debug:
-rm -r tempts
cp -r ts tempts
../scripts/dark-variant.sh tempts
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --minify
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --external:../fonts/hanken* --minify
npx esbuild --target=es6 --bundle ts/main.ts --sourcemap --outfile=out/main.js --minify
npx tailwindcss -c tailwind.config.js -i out/bundle.css -o out/bundle.css
cp ../static/* out/
cp -r ../static/* out/
monitor:
npx live-server --watch=out --open=out/index.html &

View File

@ -21,7 +21,8 @@
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<span class="heading"> Debian/Ubuntu (apt)</span>
<div class="mt-1">
<pre style="margin: 0; line-height: 125%">curl https://apt.hrfee.dev/hrfee.pubkey.gpg | sudo apt-key add -
<pre style="margin: 0; line-height: 125%">sudo apt-get update && sudo apt-get install curl apt-transport-https gnupg
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/apt.hrfee.dev.gpg
echo <span style="color: #aa5500">&quot;deb https://apt.hrfee.dev trusty<span id="deb-unstable" class="unfocused">-unstable</span> main&quot;</span> | sudo tee /etc/apt/sources.list.d/hrfee.list
@ -129,7 +130,7 @@ sudo apt-get install jfa-go-tray
<a class="button ~info mr-2 mb-2 lang-link" target="_blank" href="https://aur.archlinux.org/packages/jfa-go-git">arch (aur git)</a>
</div>
</div>
<section class="section ~neutral banner footer flex-expand middle">
<section class="section ~neutral banner footer flex flex-row justify-between middle">
<a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE" class="support">© 2023 Harvey Tindall</a>
</section>
</div>

View File

@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/logger"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
@ -55,6 +56,7 @@ type Activity struct {
InviteCode string // Set for ActivityCreation, create/deleteInvite
Value string // Used for ActivityContactLinked where it's "email/discord/telegram/matrix", Create/DeleteInvite, where it's the label, and Creation/Deletion, where it's the Username.
Time time.Time
IP string
}
type UserExpiry struct {
@ -563,8 +565,12 @@ func (st *Storage) GetActivityKey(k string) (Activity, bool) {
}
// SetActivityKey stores value v in key k.
func (st *Storage) SetActivityKey(k string, v Activity) {
// If the IP should be logged, pass "gc", and whether or not the action is of a user
func (st *Storage) SetActivityKey(k string, v Activity, gc *gin.Context, user bool) {
v.ID = k
if gc != nil && ((LOGIPU && user) || (LOGIP && !user)) {
v.IP = gc.ClientIP()
}
err := st.db.Upsert(k, v)
if err != nil {
// fmt.Printf("Failed to set custom content: %v\n", err)

View File

@ -7,7 +7,7 @@ import { accountsList } from "./modules/accounts.js";
import { settingsList } from "./modules/settings.js";
import { activityList } from "./modules/activity.js";
import { ProfileEditor } from "./modules/profiles.js";
import { _get, _post, notificationBox, whichAnimationEvent } from "./modules/common.js";
import { _get, _post, notificationBox, whichAnimationEvent, bindManualDropdowns } from "./modules/common.js";
import { Updater } from "./modules/update.js";
import { Login } from "./modules/login.js";
@ -216,6 +216,8 @@ login.onLogin = () => {
}
}
bindManualDropdowns();
login.bindLogout(document.getElementById("logout-button"));
login.login("", "");

View File

@ -4,6 +4,7 @@ import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from
import { loadLangSelector } from "./modules/lang.js";
import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js";
import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js";
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
interface formWindow extends Window {
invalidPassword: string;
@ -172,35 +173,7 @@ if (!window.usernameEnabled) { usernameField.parentElement.remove(); usernameFie
const passwordField = document.getElementById("create-password") as HTMLInputElement;
const rePasswordField = document.getElementById("create-reenter-password") as HTMLInputElement;
let captchaVerified = false;
let captchaID = "";
let captchaInput = document.getElementById("captcha-input") as HTMLInputElement;
const captchaCheckbox = document.getElementById("captcha-success") as HTMLSpanElement;
let prevCaptcha = "";
let baseValidator = (oncomplete: (valid: boolean) => void): void => {
if (window.captcha && !window.reCAPTCHA && (captchaInput.value != prevCaptcha)) {
prevCaptcha = captchaInput.value;
_post("/captcha/verify/" + window.code + "/" + captchaID + "/" + captchaInput.value, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 204) {
captchaCheckbox.innerHTML = `<i class="ri-check-line"></i>`;
captchaCheckbox.classList.add("~positive");
captchaCheckbox.classList.remove("~critical");
captchaVerified = true;
} else {
captchaCheckbox.innerHTML = `<i class="ri-close-line"></i>`;
captchaCheckbox.classList.add("~critical");
captchaCheckbox.classList.remove("~positive");
captchaVerified = false;
}
_baseValidator(oncomplete, captchaVerified);
}
});
} else {
_baseValidator(oncomplete, captchaVerified);
}
}
let captcha = new Captcha(window.code, window.captcha, window.reCAPTCHA, false);
function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: boolean): void {
if (window.emailRequired) {
@ -228,20 +201,9 @@ function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: bool
oncomplete(true);
}
interface GreCAPTCHA {
render: (container: HTMLDivElement, parameters: {
sitekey?: string,
theme?: string,
size?: string,
tabindex?: number,
"callback"?: () => void,
"expired-callback"?: () => void,
"error-callback"?: () => void
}) => void;
getResponse: (opt_widget_id?: HTMLDivElement) => string;
}
let baseValidator = captcha.baseValidatorWrapper(_baseValidator);
declare var grecaptcha: GreCAPTCHA
declare var grecaptcha: GreCAPTCHA;
let validatorConf: ValidatorConf = {
passwordField: passwordField,
@ -273,29 +235,15 @@ interface sendDTO {
captcha_text?: string;
}
const genCaptcha = () => {
_get("/captcha/gen/"+window.code, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200) {
captchaID = req.response["id"];
document.getElementById("captcha-img").innerHTML = `
<img class="w-100" src="${window.location.toString().substring(0, window.location.toString().lastIndexOf("/invite"))}/captcha/img/${window.code}/${captchaID}"></img>
`;
captchaInput.value = "";
}
}
});
};
if (window.captcha && !window.reCAPTCHA) {
genCaptcha();
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = genCaptcha;
captchaInput.onkeyup = validator.validate;
captcha.generate();
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = captcha.generate;
captcha.input.onkeyup = validator.validate;
}
const create = (event: SubmitEvent) => {
event.preventDefault();
if (window.captcha && !window.reCAPTCHA && !captchaVerified) {
if (window.captcha && !window.reCAPTCHA && !captcha.verified) {
}
addLoader(submitSpan);
@ -330,8 +278,8 @@ const create = (event: SubmitEvent) => {
if (window.reCAPTCHA) {
send.captcha_text = grecaptcha.getResponse();
} else {
send.captcha_id = captchaID;
send.captcha_text = captchaInput.value;
send.captcha_id = captcha.captchaID;
send.captcha_text = captcha.input.value;
}
}
_post("/newUser", send, (req: XMLHttpRequest) => {

View File

@ -188,7 +188,7 @@ class user implements User, SearchableItem {
if (!telegram && !discord && !matrix && !email) return;
let innerHTML = `
<i class="icon ri-settings-2-line ml-2 dropdown-button"></i>
<div class="dropdown over-top manual">
<div class="dropdown manual">
<div class="dropdown-display lg">
<div class="card ~neutral @low">
<div class="supra sm mb-2">${window.lang.strings("contactThrough")}</div>
@ -218,13 +218,13 @@ class user implements User, SearchableItem {
</div>
<div class="supra sm mb-2 accounts-unlink-header">${window.lang.strings("unlink")}:</div>
<div class="accounts-unlink-telegram">
<button class="button ~critical mb-2 w-100">Telegram</button>
<button class="button ~critical mb-2 w-full">Telegram</button>
</div>
<div class="accounts-unlink-discord">
<button class="button ~critical mb-2 w-100">Discord</button>
<button class="button ~critical mb-2 w-full">Discord</button>
</div>
<div class="accounts-unlink-matrix">
<button class="button ~critical mb-2 w-100">Matrix</button>
<button class="button ~critical mb-2 w-full">Matrix</button>
</div>
</div>
</div>
@ -1271,7 +1271,7 @@ export class accountsList {
dList.textContent = '';
for (let name of list) {
const el = document.createElement("div") as HTMLDivElement;
el.classList.add("flex-expand", "ellipsis", "mt-2");
el.classList.add("flex", "flex-row", "justify-between", "truncate", "mt-2");
el.innerHTML = `
<span class="button ~neutral sm full-width accounts-announce-template-button">${name}</span><span class="button ~critical fr ml-4 accounts-announce-template-delete">&times;</span>
`;
@ -1634,6 +1634,7 @@ export class accountsList {
_displayExpiryDate = () => {
let date: Date;
let invalid = false;
let users = this._collectUsers();
if (this._usingExtendExpiryTextInput) {
date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
invalid = "invalid" in (date as any);
@ -1645,7 +1646,7 @@ export class accountsList {
document.getElementById("extend-expiry-minutes") as HTMLSelectElement
];
invalid = fields[0].value == "0" && fields[1].value == "0" && fields[2].value == "0" && fields[3].value == "0";
let id = this._collectUsers().length == 1 ? this._collectUsers()[0] : "";
let id = users.length > 0 ? users[0] : "";
if (!id) invalid = true;
else {
date = new Date(this._users[id].expiry*1000);
@ -1665,7 +1666,12 @@ export class accountsList {
} else {
submit.disabled = false;
submitSpan.classList.remove("opacity-60");
this._extendExpiryDate.textContent = window.lang.strings("accountWillExpire").replace("{date}", toDateString(date));
this._extendExpiryDate.innerHTML = `
<div class="flex flex-col">
<span>${window.lang.strings("accountWillExpire").replace("{date}", toDateString(date))}</span>
${users.length > 1 ? "<span>"+window.lang.strings("expirationBasedOn")+"</span>" : ""}
</div>
`;
this._extendExpiryDate.classList.remove("unfocused");
}
}
@ -1740,6 +1746,9 @@ export class accountsList {
}
}
this._extendExpiryTextInput.value = "";
this._usingExtendExpiryTextInput = false;
this._extendExpiryDate.classList.add("unfocused");
this._displayExpiryDate();
window.modals.extendExpiry.show();
}

View File

@ -14,6 +14,7 @@ export interface activity {
time: number;
username: string;
source_username: string;
ip: string;
}
var activityTypeMoods = {
@ -43,6 +44,7 @@ export class Activity implements activity, SearchableItem {
private _referrer: HTMLElement;
private _expiryTypeBadge: HTMLElement;
private _delete: HTMLElement;
private _ip: HTMLElement;
private _act: activity;
private _urlBase: string = ((): string => {
let link = window.location.href;
@ -205,6 +207,16 @@ export class Activity implements activity, SearchableItem {
}
}
get ip(): string { return this._act.ip; }
set ip(v: string) {
this._act.ip = v;
if (v) {
this._ip.innerHTML = `<span class="supra mr-2">IP</span><span class="font-mono bg-inherit">${v}</span>`;
} else {
this._ip.textContent = ``;
}
}
get invite_code(): string { return this._act.invite_code; }
set invite_code(v: string) {
this._act.invite_code = v;
@ -260,12 +272,13 @@ export class Activity implements activity, SearchableItem {
<span class="activity-expiry-type badge self-start md:self-end mt-1"></span>
</div>
</div>
<div class="flex flex-col md:flex-row justify-between">
<div>
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
</div>
<div>
<div class="flex flex-row justify-between items-end">
<div class="flex flex-col md:flex-row gap-2">
<div>
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
</div>
<span class="content activity-referrer"></span>
<span class="content activity-ip"></span>
</div>
<div>
<button class="button @low hover:~critical rounded-full px-1 py-px activity-delete" aria-label="${window.lang.strings("delete")}"><i class="ri-close-line"></i></button>
@ -277,6 +290,7 @@ export class Activity implements activity, SearchableItem {
this._time = this._card.querySelector(".activity-time");
this._sourceType = this._card.querySelector(".activity-source-type");
this._source = this._card.querySelector(".activity-source");
this._ip = this._card.querySelector(".activity-ip");
this._referrer = this._card.querySelector(".activity-referrer");
this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type");
this._delete = this._card.querySelector(".activity-delete");
@ -324,6 +338,7 @@ export class Activity implements activity, SearchableItem {
this.source = act.source;
this.value = act.value;
this.type = act.type;
this.ip = act.ip;
}
delete = () => _delete("/activity/" + this._act.id, null, (req: XMLHttpRequest) => {

81
ts/modules/captcha.ts Normal file
View File

@ -0,0 +1,81 @@
import { _get, _post } from "./common.js";
export class Captcha {
isPWR = false;
enabled = true;
verified = false;
captchaID = "";
input = document.getElementById("captcha-input") as HTMLInputElement;
checkbox = document.getElementById("captcha-success") as HTMLSpanElement;
previous = "";
reCAPTCHA = false;
code = "";
get value(): string { return this.input.value; }
hasChanged = (): boolean => { return this.value != this.previous; }
baseValidatorWrapper = (_baseValidator: (oncomplete: (valid: boolean) => void, captchaValid: boolean) => void) => {
return (oncomplete: (valid: boolean) => void): void => {
if (this.enabled && !this.reCAPTCHA && this.hasChanged()) {
this.previous = this.value;
this.verify(() => {
_baseValidator(oncomplete, this.verified);
});
} else {
_baseValidator(oncomplete, this.verified);
}
};
};
verify = (callback: () => void) => _post("/captcha/verify/" + this.code + "/" + this.captchaID + "/" + this.input.value + (this.isPWR ? "?pwr=true" : ""), null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 204) {
this.checkbox.innerHTML = `<i class="ri-check-line"></i>`;
this.checkbox.classList.add("~positive");
this.checkbox.classList.remove("~critical");
this.verified = true;
} else {
this.checkbox.innerHTML = `<i class="ri-close-line"></i>`;
this.checkbox.classList.add("~critical");
this.checkbox.classList.remove("~positive");
this.verified = false;
}
callback();
}
});
generate = () => _get("/captcha/gen/"+this.code+(this.isPWR ? "?pwr=true" : ""), null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200) {
this.captchaID = this.isPWR ? this.code : req.response["id"];
// the Math.random() appearance below is used for PWRs, since they don't have a unique captchaID. The parameter is ignored by the server, but tells the browser to reload the image.
document.getElementById("captcha-img").innerHTML = `
<img class="w-full" src="${window.location.toString().substring(0, window.location.toString().lastIndexOf("/invite"))}/captcha/img/${this.code}/${this.isPWR ? Math.random() : this.captchaID}${this.isPWR ? "?pwr=true" : ""}"></img>
`;
this.input.value = "";
}
}
});
constructor(code: string, enabled: boolean, reCAPTCHA: boolean, isPWR: boolean) {
this.code = code;
this.enabled = enabled;
this.reCAPTCHA = reCAPTCHA;
this.isPWR = isPWR;
}
}
export interface GreCAPTCHA {
render: (container: HTMLDivElement, parameters: {
sitekey?: string,
theme?: string,
size?: string,
tabindex?: number,
"callback"?: () => void,
"expired-callback"?: () => void,
"error-callback"?: () => void
}) => void;
getResponse: (opt_widget_id?: HTMLDivElement) => string;
}

View File

@ -263,3 +263,31 @@ export function insertText(textarea: HTMLTextAreaElement, text: string) {
textarea.focus();
}
}
export function bindManualDropdowns() {
const buttons = Array.from(document.getElementsByClassName("dropdown-manual-toggle") as HTMLCollectionOf<HTMLSpanElement>);
for (let button of buttons) {
const parent = button.closest(".dropdown.manual");
const display = parent.querySelector(".dropdown-display");
const mousein = () => parent.classList.add("selected");
const mouseout = () => parent.classList.remove("selected");
button.addEventListener("mouseover", mousein);
button.addEventListener("mouseout", mouseout);
display.addEventListener("mouseover", mousein);
display.addEventListener("mouseout", mouseout);
button.onclick = () => {
parent.classList.add("selected");
document.addEventListener("click", outerClickListener);
button.removeEventListener("mouseout", mouseout);
display.removeEventListener("mouseout", mouseout);
};
const outerClickListener = (event: Event) => {
if (!(event.target instanceof HTMLElement && (display.contains(event.target) || button.contains(event.target)))) {
parent.classList.remove("selected");
document.removeEventListener("click", outerClickListener);
button.addEventListener("mouseout", mouseout);
display.addEventListener("mouseout", mouseout);
}
};
}
}

View File

@ -78,7 +78,7 @@ class DOMInvite implements Invite {
get expiresIn(): string { return this._expiresIn }
set expiresIn(expiry: string) {
this._expiresIn = expiry;
this._infoArea.querySelector("span.inv-duration").textContent = expiry;
this._codeArea.querySelector("span.inv-duration").textContent = expiry;
}
private _userExpiry: string;
@ -110,15 +110,15 @@ class DOMInvite implements Invite {
const chip = container.querySelector("span.inv-email-chip");
const tooltip = container.querySelector("span.content") as HTMLSpanElement;
if (address == "") {
container.classList.remove("mr-4");
icon.classList.remove("ri-mail-line");
icon.classList.remove("ri-mail-close-line");
chip.classList.remove("~neutral");
chip.classList.remove("~critical");
chip.classList.remove("chip");
chip.classList.remove("button");
chip.parentElement.classList.remove("h-full");
} else {
container.classList.add("mr-4");
chip.classList.add("chip");
chip.classList.add("button");
chip.parentElement.classList.add("h-full");
if (address.includes("Failed")) {
icon.classList.remove("ri-mail-line");
icon.classList.add("ri-mail-close-line");
@ -146,10 +146,11 @@ class DOMInvite implements Invite {
}
this._right.classList.remove("empty");
let innerHTML = `
<table class="table inv-table">
<table class="table inv-table table-p-0">
<thead>
<tr>
<th>${window.lang.strings("name")}</th>
<th class="w-2"></th>
<th>${window.lang.strings("date")}</th>
</tr>
</thead>
@ -159,6 +160,7 @@ class DOMInvite implements Invite {
innerHTML += `
<tr>
<td>${username}</td>
<td class="w-2"></td>
<td>${toDateString(new Date(uB[username] * 1000))}</td>
</tr>
`;
@ -266,18 +268,21 @@ class DOMInvite implements Invite {
constructor(invite: Invite) {
// first create the invite structure, then use our setter methods to fill in the data.
this._container = document.createElement('div') as HTMLDivElement;
this._container.classList.add("inv");
this._container.classList.add("inv", "overflow-visible");
this._header = document.createElement('div') as HTMLDivElement;
this._container.appendChild(this._header);
this._header.classList.add("card", "dark:~d_neutral", "@low", "inv-header", "elem-pad", "no-pad", "flex-expand", "row", "mt-2", "overflow-y");
this._header.classList.add("card", "dark:~d_neutral", "@low", "inv-header", "flex", "flex-row", "justify-between", "mt-2", "overflow-visible", "gap-2");
this._codeArea = document.createElement('div') as HTMLDivElement;
this._header.appendChild(this._codeArea);
this._codeArea.classList.add("inv-codearea");
this._codeArea.classList.add("flex", "flex-row", "flex-wrap", "justify-between", "w-full", "items-baseline", "gap-2", "truncate");
this._codeArea.innerHTML = `
<a class="invite-link text-black dark:text-white font-mono bg-inherit mr-4" href=""></a>
<span class="button ~info @low" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span>
<div class="flex items-baseline gap-x-4 gap-y-2 truncate">
<a class="invite-link text-black dark:text-white font-mono bg-inherit truncate" href=""></a>
<span class="button ~info @low" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span>
</div>
<span class="inv-duration"></span>
`;
const copyButton = this._codeArea.querySelector("span.button") as HTMLSpanElement;
copyButton.onclick = () => {
@ -297,16 +302,15 @@ class DOMInvite implements Invite {
this._infoArea = document.createElement('div') as HTMLDivElement;
this._header.appendChild(this._infoArea);
this._infoArea.classList.add("inv-infoarea");
this._infoArea.classList.add("inv-infoarea", "flex", "flex-row", "items-baseline", "gap-2");
this._infoArea.innerHTML = `
<div class="tooltip left">
<span class="inv-email-chip"><i></i></span>
<span class="content sm"></span>
<div class="tooltip below darker" tabindex="0">
<span class="inv-email-chip h-full"><i></i></span>
<span class="content sm p-1"></span>
</div>
<span class="inv-duration mr-4"></span>
<span class="button ~critical @low inv-delete">${window.lang.strings("delete")}</span>
<span class="button ~critical @low inv-delete h-full">${window.lang.strings("delete")}</span>
<label>
<i class="icon clickable ri-arrow-down-s-line not-rotated"></i>
<i class="icon px-2.5 py-2 ri-arrow-down-s-line not-rotated"></i>
<input class="inv-toggle-details unfocused" type="checkbox">
</label>
`;
@ -315,25 +319,30 @@ class DOMInvite implements Invite {
const toggle = (this._infoArea.querySelector("input.inv-toggle-details") as HTMLInputElement);
toggle.onchange = () => { this.expanded = !this.expanded; };
this._header.onclick = (event: Event) => {
if (event.target == this._header) {
const toggleDetails = (event: Event) => {
if (event.target == this._header || event.target == this._codeArea || event.target == this._infoArea) {
this.expanded = !this.expanded;
}
};
this._header.onclick = toggleDetails;
this._details = document.createElement('div') as HTMLDivElement;
this._container.appendChild(this._details);
this._details.classList.add("card", "~neutral", "@low", "mt-2", "no-pad", "inv-details");
this._details.classList.add("card", "~neutral", "@low", "mt-2", "inv-details");
const detailsInner = document.createElement('div') as HTMLDivElement;
this._details.appendChild(detailsInner);
detailsInner.classList.add("inv-row", "flex-expand", "row", "elem-pad", "align-top");
detailsInner.classList.add("inv-row", "flex", "flex-row", "flex-wrap", "justify-between", "gap-4");
this._left = document.createElement('div') as HTMLDivElement;
this._left.classList.add("flex", "flex-row", "flex-wrap", "gap-4", "min-w-full", "sm:min-w-fit", "whitespace-nowrap");
detailsInner.appendChild(this._left);
this._left.classList.add("inv-profilearea");
const leftLeft = document.createElement("div") as HTMLDivElement;
this._left.appendChild(leftLeft);
leftLeft.classList.add("inv-profilearea", "min-w-full", "sm:min-w-fit");
let innerHTML = `
<p class="supra mb-2 top">${window.lang.strings("profile")}</p>
<div class="select ~neutral @low inv-profileselect inline-block mb-2">
<div class="select ~neutral @low inv-profileselect min-w-full inline-block mb-2">
<select>
<option value="noProfile" selected>${window.lang.strings("inviteNoProfile")}</option>
</select>
@ -352,7 +361,7 @@ class DOMInvite implements Invite {
</label>
`;
}
this._left.innerHTML = innerHTML;
leftLeft.innerHTML = innerHTML;
(this._left.querySelector("select") as HTMLSelectElement).onchange = this.updateProfile;
if (window.notificationsEnabled) {
@ -364,20 +373,21 @@ class DOMInvite implements Invite {
}
this._middle = document.createElement('div') as HTMLDivElement;
detailsInner.appendChild(this._middle);
this._middle.classList.add("block");
this._left.appendChild(this._middle);
this._middle.classList.add("flex", "flex-col", "justify-between");
this._middle.innerHTML = `
<p class="supra mb-4 top">${window.lang.strings("inviteDateCreated")} <strong class="inv-created"></strong></p>
<p class="supra mb-4">${window.lang.strings("inviteRemainingUses")} <strong class="inv-remaining"></strong></p>
<p class="supra mb-4"><span class="user-expiry"></span> <strong class="user-expiry-time"></strong></p>
<p class="mb-4 flex items-center"><span class="user-label-label supra mr-2"></span> <span class="user-label chip ~blue unfocused"></span></p>
<p class="supra top">${window.lang.strings("inviteDateCreated")} <strong class="inv-created"></strong></p>
<p class="supra">${window.lang.strings("inviteRemainingUses")} <strong class="inv-remaining"></strong></p>
<p class="supra"><span class="user-expiry"></span> <strong class="user-expiry-time"></strong></p>
<p class="flex items-center"><span class="user-label-label supra mr-2"></span> <span class="user-label chip ~blue unfocused"></span></p>
`;
this._right = document.createElement('div') as HTMLDivElement;
detailsInner.appendChild(this._right);
this._right.classList.add("card", "~neutral", "@low", "inv-created-users");
this._right.innerHTML = `<strong class="supra table-header">${window.lang.strings("inviteUsersCreated")}</strong>`;
this._right.classList.add("card", "~neutral", "@low", "inv-created-users", "min-w-full", "sm:min-w-fit", "whitespace-nowrap");
this._right.innerHTML = `<span class="supra table-header">${window.lang.strings("inviteUsersCreated")}</span>`;
this._userTable = document.createElement('div') as HTMLDivElement;
this._userTable.classList.add("text-sm", "mt-1", );
this._right.appendChild(this._userTable);
@ -470,8 +480,8 @@ export class inviteList implements inviteList {
this._list.classList.add("empty");
this._list.innerHTML = `
<div class="inv inv-empty">
<div class="card dark:~d_neutral @low inv-header flex-expand mt-2">
<div class="inv-codearea">
<div class="card dark:~d_neutral @low inv-header mt-2">
<div class="justify-start">
<span class="text-black dark:text-white font-mono bg-inherit">${window.lang.strings("inviteNoInvites")}</span>
</div>
</div>

View File

@ -83,7 +83,7 @@ export const loadLangSelector = (page: string) => {
let innerHTML = '';
for (let code in req.response) {
queryString.set("lang", code);
innerHTML += `<a href="?${queryString.toString()}" class="button w-100 al justify-start ~neutral mb-2 lang-link">${req.response[code]}</a>`;
innerHTML += `<a href="?${queryString.toString()}" class="button w-full text-left justify-start ~neutral mb-2 lang-link">${req.response[code]}</a>`;
queryString.delete("lang");
}
list.innerHTML = innerHTML;

View File

@ -86,7 +86,7 @@ class profile implements Profile {
<td><span class="button @low profile-referrals"></span></td>
`;
innerHTML += `
<td class="profile-from ellipsis"></td>
<td class="profile-from truncate"></td>
<td class="profile-libraries"></td>
<td><span class="button ~critical @low">${window.lang.strings("delete")}</span></td>
`;

View File

@ -311,7 +311,7 @@ export class Search {
}
const container = document.createElement("span") as HTMLSpanElement;
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2");
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2", "align-bottom");
container.innerHTML = `
<div class="flex flex-col mr-2">
<span>${query.name}</span>

View File

@ -22,13 +22,18 @@ export class Tabs implements Tabs {
switch = (tabID: string, noRun: boolean = false, keepURL: boolean = false) => {
this._current = tabID;
let baseOffset = -1;
for (let t of this.tabs) {
if (baseOffset == -1) baseOffset = t.buttonEl.offsetLeft;
if (t.tabID == tabID) {
t.buttonEl.classList.add("active", "~urge");
if (t.preFunc && !noRun) { t.preFunc(); }
t.tabEl.classList.remove("unfocused");
if (t.postFunc && !noRun) { t.postFunc(); }
document.dispatchEvent(new CustomEvent("tab-change", { detail: keepURL ? "" : tabID }));
// t.buttonEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' })
t.buttonEl.parentElement.scrollTo(t.buttonEl.offsetLeft-baseOffset, 0);
} else {
t.buttonEl.classList.remove("active");
t.buttonEl.classList.remove("~urge");

View File

@ -2,6 +2,7 @@ import { Modal } from "./modules/modal.js";
import { Validator, ValidatorConf } from "./modules/validator.js";
import { _post, addLoader, removeLoader } from "./modules/common.js";
import { loadLangSelector } from "./modules/lang.js";
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
interface formWindow extends Window {
invalidPassword: string;
@ -28,6 +29,10 @@ interface formWindow extends Window {
userExpiryHours: number;
userExpiryMinutes: number;
userExpiryMessage: string;
captcha: boolean;
reCAPTCHA: boolean;
reCAPTCHASiteKey: string;
pwrPIN: string;
}
loadLangSelector("pwr");
@ -42,11 +47,26 @@ const rePasswordField = document.getElementById("create-reenter-password") as HT
window.successModal = new Modal(document.getElementById("modal-success"), true);
function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: boolean): void {
if (window.captcha && !window.reCAPTCHA && !captchaValid) {
oncomplete(false);
return;
}
oncomplete(true);
}
let captcha = new Captcha(window.pwrPIN, window.captcha, window.reCAPTCHA, true);
declare var grecaptcha: GreCAPTCHA;
let baseValidator = captcha.baseValidatorWrapper(_baseValidator);
let validatorConf: ValidatorConf = {
passwordField: passwordField,
rePasswordField: rePasswordField,
submitInput: submitInput,
submitButton: submitSpan
submitButton: submitSpan,
validatorFunc: baseValidator
};
var validator = new Validator(validatorConf);
@ -55,6 +75,13 @@ var requirements = validator.requirements;
interface sendDTO {
pin: string;
password: string;
captcha_text?: string;
}
if (window.captcha && !window.reCAPTCHA) {
captcha.generate();
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = captcha.generate;
captcha.input.onkeyup = validator.validate;
}
form.onsubmit = (event: Event) => {
@ -65,12 +92,31 @@ form.onsubmit = (event: Event) => {
pin: params.get("pin"),
password: passwordField.value
};
if (window.captcha) {
if (window.reCAPTCHA) {
send.captcha_text = grecaptcha.getResponse();
} else {
send.captcha_text = captcha.input.value;
}
}
_post("/reset", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
removeLoader(submitSpan);
if (req.status == 400) {
for (let type in req.response) {
if (requirements[type]) { requirements[type].valid = req.response[type] as boolean; }
if (req.response["error"] as string) {
const old = submitSpan.textContent;
submitSpan.textContent = window.messages[req.response["error"]];
submitSpan.classList.add("~critical");
submitSpan.classList.remove("~urge");
setTimeout(() => {
submitSpan.classList.add("~urge");
submitSpan.classList.remove("~critical");
submitSpan.textContent = old;
}, 2000);
} else {
for (let type in req.response) {
if (requirements[type]) { requirements[type].valid = req.response[type] as boolean; }
}
}
return;
} else if (req.status != 200) {

View File

@ -144,9 +144,9 @@ class ContactMethods {
append = (name: string, details: MyDetailsContactMethod, icon: string, addEditFunc?: (add: boolean) => void, required?: boolean) => {
const row = document.createElement("div");
row.classList.add("flex", "flex-expand", "my-2", "flex-nowrap");
row.classList.add("flex", "flex-row", "justify-between", "my-2", "flex-nowrap");
let innerHTML = `
<div class="flex items-baseline flex-nowrap ellipsis">
<div class="flex items-baseline flex-nowrap truncate">
<span class="shield ~urge" alt="${name}">
<span class="icon">
${icon}
@ -637,10 +637,10 @@ document.addEventListener("details-reload", () => {
if (typeof(messageCard) != "undefined" && messageCard != null) {
messageCard.innerHTML = messageCard.innerHTML.replace(new RegExp("{username}", "g"), details.username);
setBestRowSpan(messageCard, false);
// setBestRowSpan(messageCard, false);
// contactCard.querySelector(".content").classList.add("h-100");
} else if (!statusCard.classList.contains("unfocused")) {
setBestRowSpan(passwordCard, true);
// setBestRowSpan(passwordCard, true);
}
if (window.referralsEnabled) {
@ -649,15 +649,69 @@ document.addEventListener("details-reload", () => {
if (req.readyState != 4 || req.status != 200) return;
const referral: MyReferral = req.response as MyReferral;
referralCard.update(referral);
setCardOrder(messageCard);
});
} else {
referralCard.hide();
setCardOrder(messageCard);
}
} else {
setCardOrder(messageCard);
}
}
});
});
const setCardOrder = (messageCard: HTMLElement) => {
const cards = document.getElementById("user-cardlist");
const children = Array.from(cards.children);
const idxs = [...Array(cards.childElementCount).keys()]
// The message card is the first element and should always be so, so remove it from the list.
const hasMessageCard = !(typeof(messageCard) == "undefined" || messageCard == null);
if (hasMessageCard) idxs.shift();
const perms = generatePermutations(idxs);
let minHeight = 999999;
let minHeightPerm: [number[], number[]];
for (let perm of perms) {
let leftHeight = 0;
for (let idx of perm[0]) {
leftHeight += (cards.children[idx] as HTMLElement).offsetHeight;
}
if (hasMessageCard) leftHeight += (cards.children[0] as HTMLElement).offsetHeight;
let rightHeight = 0;
for (let idx of perm[1]) {
rightHeight += (cards.children[idx] as HTMLElement).offsetHeight;
}
let height = Math.max(leftHeight, rightHeight);
// console.log("got height", leftHeight, rightHeight, height, "for", perm);
if (height < minHeight) {
minHeight = height;
minHeightPerm = perm;
}
}
const gapDiv = () => {
const g = document.createElement("div");
g.classList.add("my-4");
return g;
};
let addValue = hasMessageCard ? 1 : 0;
// if (hasMessageCard) cards.appendChild(children[0]);
if (hasMessageCard) cards.appendChild(gapDiv());
for (let side of minHeightPerm) {
for (let i = 0; i < side.length; i++) {
// (cards.children[side[i]] as HTMLElement).style.order = (i+addValue).toString();
children[side[i]].remove();
cards.appendChild(children[side[i]]);
cards.appendChild(gapDiv());
}
// addValue += side.length;
}
console.log("Shortest order:", minHeightPerm);
};
const login = new Login(window.modals.login as Modal, "/my/", "opaque");
login.onLogin = () => {
console.log("Logged in.");
@ -699,6 +753,24 @@ const computeRealHeight = (el: HTMLElement): number => {
return total;
}
const generatePermutations = (xs: number[]): [number[], number[]][] => {
const l = xs.length;
let out: [number[], number[]][] = [];
for (let i = 0; i < (l << 1); i++) {
let incl = [];
let excl = [];
for (let j = 0; j < l; j++) {
if (i & (1 << j)) {
incl.push(xs[j]);
} else {
excl.push(xs[j]);
}
}
out.push([incl, excl]);
}
return out;
}
login.bindLogout(document.getElementById("logout-button"));
login.login("", "");

View File

@ -45,13 +45,13 @@ func (app *appContext) getUserTokenLogin(gc *gin.Context) {
respond(500, "Contact Admin", gc)
return
}
app.info.Println("UserToken requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc)
app.logIpInfo(gc, true, "UserToken requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc, true)
if !ok {
return
}
user, ok := app.validateJellyfinCredentials(username, password, gc)
user, ok := app.validateJellyfinCredentials(username, password, gc, true)
if !ok {
return
}
@ -86,7 +86,7 @@ func (app *appContext) getUserTokenRefresh(gc *gin.Context) {
return
}
app.info.Println("UserToken request (refresh token)")
app.logIpInfo(gc, true, "UserToken request (refresh token)")
claims, ok := app.decodeValidateRefreshCookie(gc, "user-refresh")
if !ok {
return

View File

@ -120,7 +120,7 @@ func (app *appContext) checkUsers() {
continue
}
app.storage.SetActivityKey(shortuuid.New(), activity)
app.storage.SetActivityKey(shortuuid.New(), activity, nil, false)
app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
app.jf.CacheExpiry = time.Now()

122
views.go
View File

@ -296,6 +296,10 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
data["telegramEnabled"] = false
data["discordEnabled"] = false
data["matrixEnabled"] = false
data["captcha"] = app.config.Section("captcha").Key("enabled").MustBool(false)
data["reCAPTCHA"] = app.config.Section("captcha").Key("recaptcha").MustBool(false)
data["reCAPTCHASiteKey"] = app.config.Section("captcha").Key("recaptcha_site_key").MustString("")
data["pwrPIN"] = pin
gcHTML(gc, http.StatusOK, "form-loader.html", data)
return
}
@ -361,7 +365,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
SourceType: ActivityUser,
Source: jfUser.ID,
Time: time.Now(),
})
}, gc, true)
}
}
@ -393,20 +397,28 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
// @Router /captcha/img/{code}/{captchaID} [get]
func (app *appContext) GetCaptcha(gc *gin.Context) {
code := gc.Param("invCode")
isPWR := gc.Query("pwr") == "true"
captchaID := gc.Param("captchaID")
inv, ok := app.storage.GetInvitesKey(code)
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
var inv Invite
var capt Captcha
ok = true
if inv.Captchas != nil {
capt, ok = inv.Captchas[captchaID]
ok := true
if !isPWR {
inv, ok = app.storage.GetInvitesKey(code)
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
if inv.Captchas != nil {
capt, ok = inv.Captchas[captchaID]
} else {
ok = false
}
} else {
capt, ok = app.pwrCaptchas[code]
}
if !ok {
respondBool(400, false, gc)
@ -425,7 +437,13 @@ func (app *appContext) GetCaptcha(gc *gin.Context) {
// @tags Users
func (app *appContext) GenCaptcha(gc *gin.Context) {
code := gc.Param("invCode")
inv, ok := app.storage.GetInvitesKey(code)
isPWR := gc.Query("pwr") == "true"
var inv Invite
ok := true
if !isPWR {
inv, ok = app.storage.GetInvitesKey(code)
}
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc),
@ -440,7 +458,7 @@ func (app *appContext) GenCaptcha(gc *gin.Context) {
respondBool(500, false, gc)
return
}
if inv.Captchas == nil {
if !isPWR && inv.Captchas == nil {
inv.Captchas = map[string]Captcha{}
}
captchaID := genAuthToken()
@ -450,26 +468,43 @@ func (app *appContext) GenCaptcha(gc *gin.Context) {
respondBool(500, false, gc)
return
}
inv.Captchas[captchaID] = Captcha{
Answer: capt.Text,
Image: buf.Bytes(),
Generated: time.Now(),
if isPWR {
if app.pwrCaptchas == nil {
app.pwrCaptchas = map[string]Captcha{}
}
app.pwrCaptchas[code] = Captcha{
Answer: capt.Text,
Image: buf.Bytes(),
Generated: time.Now(),
}
} else {
inv.Captchas[captchaID] = Captcha{
Answer: capt.Text,
Image: buf.Bytes(),
Generated: time.Now(),
}
app.storage.SetInvitesKey(code, inv)
}
app.storage.SetInvitesKey(code, inv)
gc.JSON(200, genCaptchaDTO{captchaID})
return
}
func (app *appContext) verifyCaptcha(code, id, text string) bool {
func (app *appContext) verifyCaptcha(code, id, text string, isPWR bool) bool {
reCAPTCHA := app.config.Section("captcha").Key("recaptcha").MustBool(false)
if !reCAPTCHA {
// internal CAPTCHA
inv, ok := app.storage.GetInvitesKey(code)
if !ok || inv.Captchas == nil {
app.debug.Printf("Couldn't find invite \"%s\"", code)
return false
var c Captcha
ok := true
if !isPWR {
inv, ok := app.storage.GetInvitesKey(code)
if !ok || (!isPWR && inv.Captchas == nil) {
app.debug.Printf("Couldn't find invite \"%s\"", code)
return false
}
c, ok = inv.Captchas[id]
} else {
c, ok = app.pwrCaptchas[code]
}
c, ok := inv.Captchas[id]
if !ok {
app.debug.Printf("Couldn't find Captcha \"%s\"", id)
return false
@ -529,21 +564,30 @@ func (app *appContext) verifyCaptcha(code, id, text string) bool {
// @Router /captcha/verify/{code}/{captchaID}/{text} [get]
func (app *appContext) VerifyCaptcha(gc *gin.Context) {
code := gc.Param("invCode")
isPWR := gc.Query("pwr") == "true"
captchaID := gc.Param("captchaID")
text := gc.Param("text")
inv, ok := app.storage.GetInvitesKey(code)
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
return
}
var inv Invite
var capt Captcha
if inv.Captchas != nil {
capt, ok = inv.Captchas[captchaID]
var ok bool
if !isPWR {
inv, ok = app.storage.GetInvitesKey(code)
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
return
}
if inv.Captchas != nil {
capt, ok = inv.Captchas[captchaID]
} else {
ok = false
}
} else {
capt, ok = app.pwrCaptchas[code]
}
if !ok {
respondBool(400, false, gc)
@ -611,7 +655,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
app.debug.Printf("Invalid key")
return
}
f, success := app.newUser(req, true)
f, success := app.newUser(req, true, gc)
if !success {
app.err.Printf("Failed to create new user")
// Not meant for us. Calling this will be a mess, but at least it might give us some information.