mirror of
https://github.com/hrfee/jfa-go.git
synced 2025-01-17 13:50:11 +00:00
Compare commits
39 Commits
a383292846
...
ec41e63c95
Author | SHA1 | Date | |
---|---|---|---|
|
ec41e63c95 | ||
|
8f2a28e650 | ||
|
8a6102b7b9 | ||
|
0ce5c9923d | ||
|
4073ebe534 | ||
|
387fe082ef | ||
|
ddc36ae897 | ||
|
c62876ff3a | ||
|
2fd71acbb2 | ||
|
4c1d8ed2a1 | ||
7223981280 | |||
47536f3e63 | |||
ac4fecd819 | |||
b75bd4d6c5 | |||
2be7baea4a | |||
d56d45a404 | |||
b50d66d265 | |||
aec0a5349a | |||
20560332ed | |||
202ee0977e | |||
f460bfcfc6 | |||
4f5d12f800 | |||
9092b98b28 | |||
0f72a85724 | |||
0840931fed | |||
00379824df | |||
f823705e40 | |||
269836fc99 | |||
49d8c6f8e4 | |||
278588ca39 | |||
ab05c07469 | |||
|
04c94ba55a | ||
6e205760c3 | |||
82032b98a8 | |||
e8666d5bf2 | |||
d1affe271c | |||
ea109c7b63 | |||
cb5a8c1c23 | |||
|
6ede094034 |
16
.drone.yml
16
.drone.yml
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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() {
|
||||
|
16
api-users.go
16
api-users.go
@ -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
12
api.go
@ -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
45
auth.go
@ -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})
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
|
150
css/base.css
150
css/base.css
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
15
daemon.go
15
daemon.go
@ -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
|
||||
}
|
||||
|
342
html/admin.html
342
html/admin.html
@ -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">×</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">×</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">×</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">×</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">×</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">×</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">×</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">×</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">×</span></span>
|
||||
<p class="content my-4">{{ .strings.customizeMessagesDescription }}</p>
|
||||
<div class="table-responsive">
|
||||
<div class="">
|
||||
<table class="table" |