use app identifier instead of ctx

changing this because ctx is commonly used with the context package.
pull/20/head
Harvey Tindall 4 years ago
parent fffb3471d6
commit fd766e7b1a
No known key found for this signature in database
GPG Key ID: BBC65952848FB1A2

349
api.go

@ -2,33 +2,34 @@ package main
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/knz/strtime"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/knz/strtime"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
func (ctx *appContext) loadStrftime() {
ctx.datePattern = ctx.config.Section("email").Key("date_format").String()
ctx.timePattern = `%H:%M`
if val, _ := ctx.config.Section("email").Key("use_24h").Bool(); !val {
ctx.timePattern = `%I:%M %p`
func (app *appContext) loadStrftime() {
app.datePattern = app.config.Section("email").Key("date_format").String()
app.timePattern = `%H:%M`
if val, _ := app.config.Section("email").Key("use_24h").Bool(); !val {
app.timePattern = `%I:%M %p`
}
return
}
func (ctx *appContext) prettyTime(dt time.Time) (date, time string) {
date, _ = strtime.Strftime(dt, ctx.datePattern)
time, _ = strtime.Strftime(dt, ctx.timePattern)
func (app *appContext) prettyTime(dt time.Time) (date, time string) {
date, _ = strtime.Strftime(dt, app.datePattern)
time, _ = strtime.Strftime(dt, app.timePattern)
return
}
func (ctx *appContext) formatDatetime(dt time.Time) string {
d, t := ctx.prettyTime(dt)
func (app *appContext) formatDatetime(dt time.Time) string {
d, t := app.prettyTime(dt)
return d + " " + t
}
@ -79,60 +80,60 @@ func timeDiff(a, b time.Time) (year, month, day, hour, min, sec int) {
return
}
func (ctx *appContext) checkInvites() {
func (app *appContext) checkInvites() {
current_time := time.Now()
ctx.storage.loadInvites()
app.storage.loadInvites()
changed := false
for code, data := range ctx.storage.invites {
for code, data := range app.storage.invites {
expiry := data.ValidTill
if current_time.After(expiry) {
ctx.debug.Printf("Housekeeping: Deleting old invite %s", code)
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
notify := data.Notify
if ctx.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
ctx.debug.Printf("%s: Expiry notification", code)
if app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
app.debug.Printf("%s: Expiry notification", code)
for address, settings := range notify {
if settings["notify-expiry"] {
go func() {
if ctx.email.constructExpiry(code, data, ctx) != nil {
ctx.err.Printf("%s: Failed to construct expiry notification", code)
} else if ctx.email.send(address, ctx) != nil {
ctx.err.Printf("%s: Failed to send expiry notification", code)
if app.email.constructExpiry(code, data, app) != nil {
app.err.Printf("%s: Failed to construct expiry notification", code)
} else if app.email.send(address, app) != nil {
app.err.Printf("%s: Failed to send expiry notification", code)
} else {
ctx.info.Printf("Sent expiry notification to %s", address)
app.info.Printf("Sent expiry notification to %s", address)
}
}()
}
}
}
changed = true
delete(ctx.storage.invites, code)
delete(app.storage.invites, code)
}
}
if changed {
ctx.storage.storeInvites()
app.storage.storeInvites()
}
}
func (ctx *appContext) checkInvite(code string, used bool, username string) bool {
func (app *appContext) checkInvite(code string, used bool, username string) bool {
current_time := time.Now()
ctx.storage.loadInvites()
app.storage.loadInvites()
changed := false
if inv, match := ctx.storage.invites[code]; match {
if inv, match := app.storage.invites[code]; match {
expiry := inv.ValidTill
if current_time.After(expiry) {
ctx.debug.Printf("Housekeeping: Deleting old invite %s", code)
app.debug.Printf("Housekeeping: Deleting old invite %s", code)
notify := inv.Notify
if ctx.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
ctx.debug.Printf("%s: Expiry notification", code)
if app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
app.debug.Printf("%s: Expiry notification", code)
for address, settings := range notify {
if settings["notify-expiry"] {
go func() {
if ctx.email.constructExpiry(code, inv, ctx) != nil {
ctx.err.Printf("%s: Failed to construct expiry notification", code)
} else if ctx.email.send(address, ctx) != nil {
ctx.err.Printf("%s: Failed to send expiry notification", code)
if app.email.constructExpiry(code, inv, app) != nil {
app.err.Printf("%s: Failed to construct expiry notification", code)
} else if app.email.send(address, app) != nil {
app.err.Printf("%s: Failed to send expiry notification", code)
} else {
ctx.info.Printf("Sent expiry notification to %s", address)
app.info.Printf("Sent expiry notification to %s", address)
}
}()
}
@ -140,25 +141,25 @@ func (ctx *appContext) checkInvite(code string, used bool, username string) bool
}
changed = true
match = false
delete(ctx.storage.invites, code)
delete(app.storage.invites, code)
} else if used {
changed = true
del := false
newInv := inv
if newInv.RemainingUses == 1 {
del = true
delete(ctx.storage.invites, code)
delete(app.storage.invites, code)
} else if newInv.RemainingUses != 0 {
// 0 means infinite i guess?
newInv.RemainingUses -= 1
}
newInv.UsedBy = append(newInv.UsedBy, []string{username, ctx.formatDatetime(current_time)})
newInv.UsedBy = append(newInv.UsedBy, []string{username, app.formatDatetime(current_time)})
if !del {
ctx.storage.invites[code] = newInv
app.storage.invites[code] = newInv
}
}
if changed {
ctx.storage.storeInvites()
app.storage.storeInvites()
}
return match
}
@ -174,17 +175,17 @@ type newUserReq struct {
Code string `json:"code"`
}
func (ctx *appContext) NewUser(gc *gin.Context) {
func (app *appContext) NewUser(gc *gin.Context) {
var req newUserReq
gc.BindJSON(&req)
ctx.debug.Printf("%s: New user attempt", req.Code)
if !ctx.checkInvite(req.Code, false, "") {
ctx.info.Printf("%s New user failed: invalid code", req.Code)
app.debug.Printf("%s: New user attempt", req.Code)
if !app.checkInvite(req.Code, false, "") {
app.info.Printf("%s New user failed: invalid code", req.Code)
gc.JSON(401, map[string]bool{"success": false})
gc.Abort()
return
}
validation := ctx.validator.validate(req.Password)
validation := app.validator.validate(req.Password)
valid := true
for _, val := range validation {
if !val {
@ -193,38 +194,38 @@ func (ctx *appContext) NewUser(gc *gin.Context) {
}
if !valid {
// 200 bcs idk what i did in js
ctx.info.Printf("%s New user failed: Invalid password", req.Code)
app.info.Printf("%s New user failed: Invalid password", req.Code)
gc.JSON(200, validation)
gc.Abort()
return
}
existingUser, _, _ := ctx.jf.userByName(req.Username, false)
existingUser, _, _ := app.jf.userByName(req.Username, false)
if existingUser != nil {
msg := fmt.Sprintf("User already exists named %s", req.Username)
ctx.info.Printf("%s New user failed: %s", req.Code, msg)
app.info.Printf("%s New user failed: %s", req.Code, msg)
respond(401, msg, gc)
return
}
user, status, err := ctx.jf.newUser(req.Username, req.Password)
user, status, err := app.jf.newUser(req.Username, req.Password)
if !(status == 200 || status == 204) || err != nil {
ctx.err.Printf("%s New user failed: Jellyfin responded with %d", req.Code, status)
app.err.Printf("%s New user failed: Jellyfin responded with %d", req.Code, status)
respond(401, "Unknown error", gc)
return
}
ctx.checkInvite(req.Code, true, req.Username)
invite := ctx.storage.invites[req.Code]
if ctx.config.Section("notifications").Key("enabled").MustBool(false) {
app.checkInvite(req.Code, true, req.Username)
invite := app.storage.invites[req.Code]
if app.config.Section("notifications").Key("enabled").MustBool(false) {
for address, settings := range invite.Notify {
if settings["notify-creation"] {
go func() {
if ctx.email.constructCreated(req.Code, req.Username, req.Email, invite, ctx) != nil {
ctx.err.Printf("%s: Failed to construct user creation notification", req.Code)
ctx.debug.Printf("%s: Error: %s", req.Code, err)
} else if ctx.email.send(address, ctx) != nil {
ctx.err.Printf("%s: Failed to send user creation notification", req.Code)
ctx.debug.Printf("%s: Error: %s", req.Code, err)
if app.email.constructCreated(req.Code, req.Username, req.Email, invite, app) != nil {
app.err.Printf("%s: Failed to construct user creation notification", req.Code)
app.debug.Printf("%s: Error: %s", req.Code, err)
} else if app.email.send(address, app) != nil {
app.err.Printf("%s: Failed to send user creation notification", req.Code)
app.debug.Printf("%s: Error: %s", req.Code, err)
} else {
ctx.info.Printf("%s: Sent user creation notification to %s", req.Code, address)
app.info.Printf("%s: Sent user creation notification to %s", req.Code, address)
}
}()
}
@ -234,23 +235,23 @@ func (ctx *appContext) NewUser(gc *gin.Context) {
if user["Id"] != nil {
id = user["Id"].(string)
}
if len(ctx.storage.policy) != 0 {
status, err = ctx.jf.setPolicy(id, ctx.storage.policy)
if len(app.storage.policy) != 0 {
status, err = app.jf.setPolicy(id, app.storage.policy)
if !(status == 200 || status == 204) {
ctx.err.Printf("%s: Failed to set user policy: Code %d", req.Code, status)
app.err.Printf("%s: Failed to set user policy: Code %d", req.Code, status)
}
}
if len(ctx.storage.configuration) != 0 && len(ctx.storage.displayprefs) != 0 {
status, err = ctx.jf.setConfiguration(id, ctx.storage.configuration)
if len(app.storage.configuration) != 0 && len(app.storage.displayprefs) != 0 {
status, err = app.jf.setConfiguration(id, app.storage.configuration)
if (status == 200 || status == 204) && err == nil {
status, err = ctx.jf.setDisplayPreferences(id, ctx.storage.displayprefs)
status, err = app.jf.setDisplayPreferences(id, app.storage.displayprefs)
} else {
ctx.err.Printf("%s: Failed to set configuration template: Code %d", req.Code, status)
app.err.Printf("%s: Failed to set configuration template: Code %d", req.Code, status)
}
}
if ctx.config.Section("password_resets").Key("enabled").MustBool(false) {
ctx.storage.emails[id] = req.Email
ctx.storage.storeEmails()
if app.config.Section("password_resets").Key("enabled").MustBool(false) {
app.storage.emails[id] = req.Email
app.storage.storeEmails()
}
gc.JSON(200, validation)
}
@ -265,10 +266,10 @@ type generateInviteReq struct {
RemainingUses int `json:"remaining-uses"`
}
func (ctx *appContext) GenerateInvite(gc *gin.Context) {
func (app *appContext) GenerateInvite(gc *gin.Context) {
var req generateInviteReq
ctx.debug.Println("Generating new invite")
ctx.storage.loadInvites()
app.debug.Println("Generating new invite")
app.storage.loadInvites()
gc.BindJSON(&req)
current_time := time.Now()
valid_till := current_time.AddDate(0, 0, req.Days)
@ -286,40 +287,40 @@ func (ctx *appContext) GenerateInvite(gc *gin.Context) {
invite.RemainingUses = 1
}
invite.ValidTill = valid_till
if req.Email != "" && ctx.config.Section("invite_emails").Key("enabled").MustBool(false) {
ctx.debug.Printf("%s: Sending invite email", invite_code)
if req.Email != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
app.debug.Printf("%s: Sending invite email", invite_code)
invite.Email = req.Email
if err := ctx.email.constructInvite(invite_code, invite, ctx); err != nil {
if err := app.email.constructInvite(invite_code, invite, app); err != nil {
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email)
ctx.err.Printf("%s: Failed to construct invite email", invite_code)
ctx.debug.Printf("%s: Error: %s", invite_code, err)
} else if err := ctx.email.send(req.Email, ctx); err != nil {
app.err.Printf("%s: Failed to construct invite email", invite_code)
app.debug.Printf("%s: Error: %s", invite_code, err)
} else if err := app.email.send(req.Email, app); err != nil {
invite.Email = fmt.Sprintf("Failed to send to %s", req.Email)
ctx.err.Printf("%s: %s", invite_code, invite.Email)
ctx.debug.Printf("%s: Error: %s", invite_code, err)
app.err.Printf("%s: %s", invite_code, invite.Email)
app.debug.Printf("%s: Error: %s", invite_code, err)
} else {
ctx.info.Printf("%s: Sent invite email to %s", invite_code, req.Email)
app.info.Printf("%s: Sent invite email to %s", invite_code, req.Email)
}
}
ctx.storage.invites[invite_code] = invite
ctx.storage.storeInvites()
app.storage.invites[invite_code] = invite
app.storage.storeInvites()
gc.JSON(200, map[string]bool{"success": true})
}
func (ctx *appContext) GetInvites(gc *gin.Context) {
ctx.debug.Println("Invites requested")
func (app *appContext) GetInvites(gc *gin.Context) {
app.debug.Println("Invites requested")
current_time := time.Now()
ctx.storage.loadInvites()
ctx.checkInvites()
app.storage.loadInvites()
app.checkInvites()
var invites []map[string]interface{}
for code, inv := range ctx.storage.invites {
for code, inv := range app.storage.invites {
_, _, days, hours, minutes, _ := timeDiff(inv.ValidTill, current_time)
invite := make(map[string]interface{})
invite["code"] = code
invite["days"] = days
invite["hours"] = hours
invite["minutes"] = minutes
invite["created"] = ctx.formatDatetime(inv.Created)
invite["created"] = app.formatDatetime(inv.Created)
if len(inv.UsedBy) != 0 {
invite["used-by"] = inv.UsedBy
}
@ -335,11 +336,11 @@ func (ctx *appContext) GetInvites(gc *gin.Context) {
}
if len(inv.Notify) != 0 {
var address string
if ctx.config.Section("ui").Key("jellyfin_login").MustBool(false) {
ctx.storage.loadEmails()
address = ctx.storage.emails[gc.GetString("jfId")].(string)
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
app.storage.loadEmails()
address = app.storage.emails[gc.GetString("jfId")].(string)
} else {
address = ctx.config.Section("ui").Key("email").String()
address = app.config.Section("ui").Key("email").String()
}
if _, ok := inv.Notify[address]; ok {
for _, notify_type := range []string{"notify-expiry", "notify-creation"} {
@ -362,34 +363,34 @@ type notifySetting struct {
NotifyCreation bool `json:"notify-creation"`
}
func (ctx *appContext) SetNotify(gc *gin.Context) {
func (app *appContext) SetNotify(gc *gin.Context) {
var req map[string]notifySetting
gc.BindJSON(&req)
changed := false
for code, settings := range req {
ctx.debug.Printf("%s: Notification settings change requested", code)
ctx.storage.loadInvites()
ctx.storage.loadEmails()
invite, ok := ctx.storage.invites[code]
app.debug.Printf("%s: Notification settings change requested", code)
app.storage.loadInvites()
app.storage.loadEmails()
invite, ok := app.storage.invites[code]
if !ok {
ctx.err.Printf("%s Notification setting change failed: Invalid code", code)
app.err.Printf("%s Notification setting change failed: Invalid code", code)
gc.JSON(400, map[string]string{"error": "Invalid invite code"})
gc.Abort()
return
}
var address string
if ctx.config.Section("ui").Key("jellyfin_login").MustBool(false) {
if app.config.Section("ui").Key("jellyfin_login").MustBool(false) {
var ok bool
address, ok = ctx.storage.emails[gc.GetString("jfId")].(string)
address, ok = app.storage.emails[gc.GetString("jfId")].(string)
if !ok {
ctx.err.Printf("%s: Couldn't find email address. Make sure it's set", code)
ctx.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId"))
app.err.Printf("%s: Couldn't find email address. Make sure it's set", code)
app.debug.Printf("%s: User ID \"%s\"", code, gc.GetString("jfId"))
gc.JSON(500, map[string]string{"error": "Missing user email"})
gc.Abort()
return
}
} else {
address = ctx.config.Section("ui").Key("email").String()
address = app.config.Section("ui").Key("email").String()
}
if invite.Notify == nil {
invite.Notify = map[string]map[string]bool{}
@ -401,20 +402,20 @@ func (ctx *appContext) SetNotify(gc *gin.Context) {
*/
if invite.Notify[address]["notify-expiry"] != settings.NotifyExpiry {
invite.Notify[address]["notify-expiry"] = settings.NotifyExpiry
ctx.debug.Printf("%s: Set \"notify-expiry\" to %t for %s", code, settings.NotifyExpiry, address)
app.debug.Printf("%s: Set \"notify-expiry\" to %t for %s", code, settings.NotifyExpiry, address)
changed = true
}
if invite.Notify[address]["notify-creation"] != settings.NotifyCreation {
invite.Notify[address]["notify-creation"] = settings.NotifyCreation
ctx.debug.Printf("%s: Set \"notify-creation\" to %t for %s", code, settings.NotifyExpiry, address)
app.debug.Printf("%s: Set \"notify-creation\" to %t for %s", code, settings.NotifyExpiry, address)
changed = true
}
if changed {
ctx.storage.invites[code] = invite
app.storage.invites[code] = invite
}
}
if changed {
ctx.storage.storeInvites()
app.storage.storeInvites()
}
}
@ -422,20 +423,20 @@ type deleteReq struct {
Code string `json:"code"`
}
func (ctx *appContext) DeleteInvite(gc *gin.Context) {
func (app *appContext) DeleteInvite(gc *gin.Context) {
var req deleteReq
gc.BindJSON(&req)
ctx.debug.Printf("%s: Deletion requested", req.Code)
app.debug.Printf("%s: Deletion requested", req.Code)
var ok bool
_, ok = ctx.storage.invites[req.Code]
_, ok = app.storage.invites[req.Code]
if ok {
delete(ctx.storage.invites, req.Code)
ctx.storage.storeInvites()
ctx.info.Printf("%s: Invite deleted", req.Code)
delete(app.storage.invites, req.Code)
app.storage.storeInvites()
app.info.Printf("%s: Invite deleted", req.Code)
gc.JSON(200, map[string]bool{"success": true})
return
}
ctx.err.Printf("%s: Deletion failed: Invalid code", req.Code)
app.err.Printf("%s: Deletion failed: Invalid code", req.Code)
respond(401, "Code doesn't exist", gc)
}
@ -448,21 +449,21 @@ type respUser struct {
Email string `json:"email,omitempty"`
}
func (ctx *appContext) GetUsers(gc *gin.Context) {
ctx.debug.Println("Users requested")
func (app *appContext) GetUsers(gc *gin.Context) {
app.debug.Println("Users requested")
var resp userResp
resp.UserList = []respUser{}
users, status, err := ctx.jf.getUsers(false)
users, status, err := app.jf.getUsers(false)
if !(status == 200 || status == 204) || err != nil {
ctx.err.Printf("Failed to get users from Jellyfin: Code %d", status)
ctx.debug.Printf("Error: %s", err)
app.err.Printf("Failed to get users from Jellyfin: Code %d", status)
app.debug.Printf("Error: %s", err)
respond(500, "Couldn't get users", gc)
return
}
for _, jfUser := range users {
var user respUser
user.Name = jfUser["Name"].(string)
if email, ok := ctx.storage.emails[jfUser["Id"].(string)]; ok {
if email, ok := app.storage.emails[jfUser["Id"].(string)]; ok {
user.Email = email.(string)
}
resp.UserList = append(resp.UserList, user)
@ -470,24 +471,24 @@ func (ctx *appContext) GetUsers(gc *gin.Context) {
gc.JSON(200, resp)
}
func (ctx *appContext) ModifyEmails(gc *gin.Context) {
func (app *appContext) ModifyEmails(gc *gin.Context) {
var req map[string]string
gc.BindJSON(&req)
ctx.debug.Println("Email modification requested")
users, status, err := ctx.jf.getUsers(false)
app.debug.Println("Email modification requested")
users, status, err := app.jf.getUsers(false)
if !(status == 200 || status == 204) || err != nil {
ctx.err.Printf("Failed to get users from Jellyfin: Code %d", status)
ctx.debug.Printf("Error: %s", err)
app.err.Printf("Failed to get users from Jellyfin: Code %d", status)
app.debug.Printf("Error: %s", err)
respond(500, "Couldn't get users", gc)
return
}
for _, jfUser := range users {
if address, ok := req[jfUser["Name"].(string)]; ok {
ctx.storage.emails[jfUser["Id"].(string)] = address
app.storage.emails[jfUser["Id"].(string)] = address
}
}
ctx.storage.storeEmails()
ctx.info.Println("Email list modified")
app.storage.storeEmails()
app.info.Println("Email list modified")
gc.JSON(200, map[string]bool{"success": true})
}
@ -496,46 +497,46 @@ type defaultsReq struct {
Homescreen bool `json:"homescreen"`
}
func (ctx *appContext) SetDefaults(gc *gin.Context) {
func (app *appContext) SetDefaults(gc *gin.Context) {
var req defaultsReq
gc.BindJSON(&req)
ctx.info.Printf("Getting user defaults from \"%s\"", req.Username)
user, status, err := ctx.jf.userByName(req.Username, false)
app.info.Printf("Getting user defaults from \"%s\"", req.Username)
user, status, err := app.jf.userByName(req.Username, false)
if !(status == 200 || status == 204) || err != nil {
ctx.err.Printf("Failed to get user from Jellyfin: Code %d", status)
ctx.debug.Printf("Error: %s", err)
app.err.Printf("Failed to get user from Jellyfin: Code %d", status)
app.debug.Printf("Error: %s", err)
respond(500, "Couldn't get user", gc)
return
}
userId := user["Id"].(string)
policy := user["Policy"].(map[string]interface{})
ctx.storage.policy = policy
ctx.storage.storePolicy()
ctx.debug.Println("User policy template stored")
app.storage.policy = policy
app.storage.storePolicy()
app.debug.Println("User policy template stored")
if req.Homescreen {
configuration := user["Configuration"].(map[string]interface{})
var displayprefs map[string]interface{}
displayprefs, status, err = ctx.jf.getDisplayPreferences(userId)
displayprefs, status, err = app.jf.getDisplayPreferences(userId)
if !(status == 200 || status == 204) || err != nil {
ctx.err.Printf("Failed to get DisplayPrefs: Code %d", status)
ctx.debug.Printf("Error: %s", err)
app.err.Printf("Failed to get DisplayPrefs: Code %d", status)
app.debug.Printf("Error: %s", err)
respond(500, "Couldn't get displayprefs", gc)
return
}
ctx.storage.configuration = configuration
ctx.storage.displayprefs = displayprefs
ctx.storage.storeConfiguration()
ctx.debug.Println("Configuration template stored")
ctx.storage.storeDisplayprefs()
ctx.debug.Println("DisplayPrefs template stored")
app.storage.configuration = configuration
app.storage.displayprefs = displayprefs
app.storage.storeConfiguration()
app.debug.Println("Configuration template stored")
app.storage.storeDisplayprefs()
app.debug.Println("DisplayPrefs template stored")
}
gc.JSON(200, map[string]bool{"success": true})
}
func (ctx *appContext) GetConfig(gc *gin.Context) {
ctx.info.Println("Config requested")
func (app *appContext) GetConfig(gc *gin.Context) {
app.info.Println("Config requested")
resp := map[string]interface{}{}
for section, settings := range ctx.configBase {
for section, settings := range app.configBase {
if section == "order" {
resp[section] = settings.([]interface{})
} else {
@ -547,7 +548,7 @@ func (ctx *appContext) GetConfig(gc *gin.Context) {
resp[section].(map[string]interface{})[key] = values.(map[string]interface{})
if key != "meta" {
dataType := resp[section].(map[string]interface{})[key].(map[string]interface{})["type"].(string)
configKey := ctx.config.Section(section).Key(key)
configKey := app.config.Section(section).Key(key)
if dataType == "number" {
if val, err := configKey.Int(); err == nil {
resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = val
@ -565,11 +566,11 @@ func (ctx *appContext) GetConfig(gc *gin.Context) {
gc.JSON(200, resp)
}
func (ctx *appContext) ModifyConfig(gc *gin.Context) {
ctx.info.Println("Config modification requested")
func (app *appContext) ModifyConfig(gc *gin.Context) {
app.info.Println("Config modification requested")
var req map[string]interface{}
gc.BindJSON(&req)
tempConfig, _ := ini.Load(ctx.config_path)
tempConfig, _ := ini.Load(app.config_path)
for section, settings := range req {
_, err := tempConfig.GetSection(section)
if section != "restart-program" && err == nil {
@ -578,33 +579,33 @@ func (ctx *appContext) ModifyConfig(gc *gin.Context) {
}
}
}
tempConfig.SaveTo(ctx.config_path)
ctx.debug.Println("Config saved")
tempConfig.SaveTo(app.config_path)
app.debug.Println("Config saved")
gc.JSON(200, map[string]bool{"success": true})
if req["restart-program"].(bool) {
ctx.info.Println("Restarting...")
err := ctx.Restart()
app.info.Println("Restarting...")
err := app.Restart()
if err != nil {
ctx.err.Printf("Couldn't restart, try restarting manually. (%s)", err)
app.err.Printf("Couldn't restart, try restarting manually. (%s)", err)
}
}
ctx.loadConfig()
app.loadConfig()
// Reinitialize password validator on config change, as opposed to every applicable request like in python.
if _, ok := req["password_validation"]; ok {
ctx.debug.Println("Reinitializing validator")
app.debug.Println("Reinitializing validator")
validatorConf := ValidatorConf{
"characters": ctx.config.Section("password_validation").Key("min_length").MustInt(0),
"uppercase characters": ctx.config.Section("password_validation").Key("upper").MustInt(0),
"lowercase characters": ctx.config.Section("password_validation").Key("lower").MustInt(0),
"numbers": ctx.config.Section("password_validation").Key("number").MustInt(0),
"special characters": ctx.config.Section("password_validation").Key("special").MustInt(0),
"characters": app.config.Section("password_validation").Key("min_length").MustInt(0),
"uppercase characters": app.config.Section("password_validation").Key("upper").MustInt(0),
"lowercase characters": app.config.Section("password_validation").Key("lower").MustInt(0),
"numbers": app.config.Section("password_validation").Key("number").MustInt(0),
"special characters": app.config.Section("password_validation").Key("special").MustInt(0),
}
if !ctx.config.Section("password_validation").Key("enabled").MustBool(false) {
if !app.config.Section("password_validation").Key("enabled").MustBool(false) {
for key := range validatorConf {
validatorConf[key] = 0
}
}
ctx.validator.init(validatorConf)
app.validator.init(validatorConf)
}
}
@ -634,11 +635,11 @@ func (ctx *appContext) ModifyConfig(gc *gin.Context) {
// panic(fmt.Errorf("restarting"))
// }
func (ctx *appContext) Restart() error {
func (app *appContext) Restart() error {
defer func() {
if r := recover(); r != nil {
signal.Notify(ctx.quit, os.Interrupt)
<-ctx.quit
signal.Notify(app.quit, os.Interrupt)
<-app.quit
}
}()
args := os.Args

@ -3,22 +3,23 @@ package main
import (
"encoding/base64"
"fmt"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
"github.com/lithammer/shortuuid/v3"
"os"
"strings"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
"github.com/lithammer/shortuuid/v3"
)
func (ctx *appContext) webAuth() gin.HandlerFunc {
return ctx.authenticate
func (app *appContext) webAuth() gin.HandlerFunc {
return app.authenticate
}
func (ctx *appContext) authenticate(gc *gin.Context) {
func (app *appContext) authenticate(gc *gin.Context) {
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
if header[0] != "Basic" {
ctx.debug.Println("Invalid authentication header")
app.debug.Println("Invalid authentication header")
respond(401, "Unauthorized", gc)
return
}
@ -26,13 +27,13 @@ func (ctx *appContext) authenticate(gc *gin.Context) {
creds := strings.SplitN(string(auth), ":", 2)
token, err := jwt.Parse(creds[0], func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
ctx.debug.Printf("Invalid JWT signing method %s", token.Header["alg"])
app.debug.Printf("Invalid JWT signing method %s", token.Header["alg"])
return nil, fmt.Errorf("Unexpected signing method %v", token.Header["alg"])
}
return []byte(os.Getenv("JFA_SECRET")), nil
})
if err != nil {
ctx.debug.Printf("Auth denied: %s", err)
app.debug.Printf("Auth denied: %s", err)
respond(401, "Unauthorized", gc)
return
}
@ -43,32 +44,32 @@ func (ctx *appContext) authenticate(gc *gin.Context) {
userId = claims["id"].(string)
jfId = claims["jfid"].(string)
} else {
ctx.debug.Printf("Invalid token")
app.debug.Printf("Invalid token")
respond(401, "Unauthorized", gc)
return
}
match := false
for _, user := range ctx.users {
for _, user := range app.users {
if user.UserID == userId {
match = true
}
}
if !match {
ctx.debug.Printf("Couldn't find user ID %s", userId)
app.debug.Printf("Couldn't find user ID %s", userId)
respond(401, "Unauthorized", gc)
return
}
gc.Set("jfId", jfId)
gc.Set("userId", userId)
ctx.debug.Println("Authentication successful")
app.debug.Println("Authentication successful")
gc.Next()
}
func (ctx *appContext) GetToken(gc *gin.Context) {
ctx.info.Println("Token requested (login attempt)")
func (app *appContext) GetToken(gc *gin.Context) {
app.info.Println("Token requested (login attempt)")
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
if header[0] != "Basic" {
ctx.debug.Println("Invalid authentication header")
app.debug.Println("Invalid authentication header")
respond(401, "Unauthorized", gc)
return
}
@ -76,7 +77,7 @@ func (ctx *appContext) GetToken(gc *gin.Context) {
creds := strings.SplitN(string(auth), ":", 2)
match := false
var userId string
for _, user := range ctx.users {
for _, user := range app.users {
if user.Username == creds[0] && user.Password == creds[1] {
match = true
userId = user.UserID
@ -84,29 +85,29 @@ func (ctx *appContext) GetToken(gc *gin.Context) {
}
jfId := ""
if !match {
if !ctx.jellyfinLogin {
ctx.info.Println("Auth failed: Invalid username and/or password")
if !app.jellyfinLogin {
app.info.Println("Auth failed: Invalid username and/or password")
respond(401, "Unauthorized", gc)
return
}
var status int
var err error
var user map[string]interface{}
user, status, err = ctx.authJf.authenticate(creds[0], creds[1])
user, status, err = app.authJf.authenticate(creds[0], creds[1])
jfId = user["Id"].(string)
if status != 200 || err != nil {
if status == 401 {
ctx.info.Println("Auth failed: Invalid username and/or password")
app.info.Println("Auth failed: Invalid username and/or password")
respond(401, "Unauthorized", gc)
return
}
ctx.err.Printf("Auth failed: Couldn't authenticate with Jellyfin: Code %d", status)
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin: Code %d", status)
respond(500, "Jellyfin error", gc)
return
} else {
if ctx.config.Section("ui").Key("admin_only").MustBool(true) {
if app.config.Section("ui").Key("admin_only").MustBool(true) {
if !user["Policy"].(map[string]interface{})["IsAdministrator"].(bool) {
ctx.debug.Printf("Auth failed: User \"%s\" isn't admin", creds[0])
app.debug.Printf("Auth failed: User \"%s\" isn't admin", creds[0])
respond(401, "Unauthorized", gc)
}
}
@ -114,8 +115,8 @@ func (ctx *appContext) GetToken(gc *gin.Context) {
newuser.UserID = shortuuid.New()
userId = newuser.UserID
// uuid, nothing else identifiable!
ctx.debug.Printf("Token generated for user \"%s\"", creds[0])
ctx.users = append(ctx.users, newuser)
app.debug.Printf("Token generated for user \"%s\"", creds[0])
app.users = append(app.users, newuser)
}
}
token, err := CreateToken(userId, jfId)

@ -1,9 +1,10 @@
package main
import (
"gopkg.in/ini.v1"
"path/filepath"
"strconv"
"gopkg.in/ini.v1"
)
/*var DeCamel ini.NameMapper = func(raw string) string {
@ -22,51 +23,51 @@ import (
return string(out)
}
func (ctx *appContext) loadDefaults() (err error) {
func (app *appContext) loadDefaults() (err error) {
var cfb []byte
cfb, err = ioutil.ReadFile(ctx.configBase_path)
cfb, err = ioutil.ReadFile(app.configBase_path)
if err != nil {
return
}
json.Unmarshal(cfb, ctx.defaults)
json.Unmarshal(cfb, app.defaults)
return
}*/
func (ctx *appContext) loadConfig() error {
func (app *appContext) loadConfig() error {
var err error
ctx.config, err = ini.Load(ctx.config_path)
app.config, err = ini.Load(app.config_path)
if err != nil {
return err
}
ctx.config.Section("jellyfin").Key("public_server").SetValue(ctx.config.Section("jellyfin").Key("public_server").MustString(ctx.config.Section("jellyfin").Key("server").String()))
app.config.Section("jellyfin").Key("public_server").SetValue(app.config.Section("jellyfin").Key("public_server").MustString(app.config.Section("jellyfin").Key("server").String()))
for _, key := range ctx.config.Section("files").Keys() {
for _, key := range app.config.Section("files").Keys() {
// if key.MustString("") == "" && key.Name() != "custom_css" {
// key.SetValue(filepath.Join(ctx.data_path, (key.Name() + ".json")))
// key.SetValue(filepath.Join(app.data_path, (key.Name() + ".json")))
// }
key.SetValue(key.MustString(filepath.Join(ctx.data_path, (key.Name() + ".json"))))
key.SetValue(key.MustString(filepath.Join(app.data_path, (key.Name() + ".json"))))
}
for _, key := range []string{"user_configuration", "user_displayprefs"} {
// if ctx.config.Section("files").Key(key).MustString("") == "" {
// key.SetValue(filepath.Join(ctx.data_path, (key.Name() + ".json")))
// if app.config.Section("files").Key(key).MustString("") == "" {
// key.SetValue(filepath.Join(app.data_path, (key.Name() + ".json")))
// }
ctx.config.Section("files").Key(key).SetValue(ctx.config.Section("files").Key(key).MustString(filepath.Join(ctx.data_path, (key + ".json"))))
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.data_path, (key + ".json"))))
}
ctx.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(ctx.config.Section("email").Key("no_username").MustBool(false)))
app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false)))
ctx.config.Section("password_resets").Key("email_html").SetValue(ctx.config.Section("password_resets").Key("email_html").MustString(filepath.Join(ctx.local_path, "email.html")))
ctx.config.Section("password_resets").Key("email_text").SetValue(ctx.config.Section("password_resets").Key("email_text").MustString(filepath.Join(ctx.local_path, "email.txt")))
app.config.Section("password_resets").Key("email_html").SetValue(app.config.Section("password_resets").Key("email_html").MustString(filepath.Join(app.local_path, "email.html")))
app.config.Section("password_resets").Key("email_text").SetValue(app.config.Section("password_resets").Key("email_text").MustString(filepath.Join(app.local_path, "email.txt")))
ctx.config.Section("invite_emails").Key("email_html").SetValue(ctx.config.Section("invite_emails").Key("email_html").MustString(filepath.Join(ctx.local_path, "invite-email.html")))
ctx.config.Section("invite_emails").Key("email_text").SetValue(ctx.config.Section("invite_emails").Key("email_text").MustString(filepath.Join(ctx.local_path, "invite-email.txt")))
app.config.Section("invite_emails").Key("email_html").SetValue(app.config.Section("invite_emails").Key("email_html").MustString(filepath.Join(app.local_path, "invite-email.html")))
app.config.Section("invite_emails").Key("email_text").SetValue(app.config.Section("invite_emails").Key("email_text").MustString(filepath.Join(app.local_path, "invite-email.txt")))
ctx.config.Section("notifications").Key("expiry_html").SetValue(ctx.config.Section("notifications").Key("expiry_html").MustString(filepath.Join(ctx.local_path, "expired.html")))
ctx.config.Section("notifications").Key("expiry_text").SetValue(ctx.config.Section("notifications").Key("expiry_text").MustString(filepath.Join(ctx.local_path, "expired.txt")))
app.config.Section("notifications").Key("expiry_html").SetValue(app.config.Section("notifications").Key("expiry_html").MustString(filepath.Join(app.local_path, "expired.html")))
app.config.Section("notifications").Key("expiry_text").SetValue(app.config.Section("notifications").Key("expiry_text").MustString(filepath.Join(app.local_path, "expired.txt")))
ctx.config.Section("notifications").Key("created_html").SetValue(ctx.config.Section("notifications").Key("created_html").MustString(filepath.Join(ctx.local_path, "created.html")))
ctx.config.Section("notifications").Key("created_text").SetValue(ctx.config.Section("notifications").Key("created_text").MustString(filepath.Join(ctx.local_path, "created.txt")))
app.config.Section("notifications").Key("created_html").SetValue(app.config.Section("notifications").Key("created_html").MustString(filepath.Join(app.local_path, "created.html")))
app.config.Section("notifications").Key("created_text").SetValue(app.config.Section("notifications").Key("created_text").MustString(filepath.Join(app.local_path, "created.txt")))
return nil
}

@ -9,21 +9,21 @@ type Repeater struct {
ShutdownChannel chan string
Interval time.Duration
period time.Duration
ctx *appContext
app *appContext
}
func NewRepeater(interval time.Duration, ctx *appContext) *Repeater {
func NewRepeater(interval time.Duration, app *appContext) *Repeater {
return &Repeater{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
ctx: ctx,
app: app,
}
}
func (rt *Repeater) Run() {
rt.ctx.info.Println("Invite daemon started")
rt.app.info.Println("Invite daemon started")
for {
select {
case <-rt.ShutdownChannel:
@ -33,9 +33,9 @@ func (rt *Repeater) Run() {
break
}
started := time.Now()
rt.ctx.storage.loadInvites()
rt.ctx.debug.Println("Daemon: Checking invites")
rt.ctx.checkInvites()
rt.app.storage.loadInvites()
rt.app.debug.Println("Daemon: Checking invites")
rt.app.checkInvites()
finished := time.Now()
duration := finished.Sub(started)
rt.period = rt.Interval - duration

@ -5,13 +5,14 @@ import (
"context"
"crypto/tls"
"fmt"
jEmail "github.com/jordan-wright/email"
"github.com/knz/strtime"
"github.com/mailgun/mailgun-go/v4"
"html/template"
"net/smtp"
"strings"
"time"
jEmail "github.com/jordan-wright/email"
"github.com/knz/strtime"
"github.com/mailgun/mailgun-go/v4"
)
type Emailer struct {
@ -49,13 +50,13 @@ func (email *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern,
return
}
func (email *Emailer) init(ctx *appContext) {
email.fromAddr = ctx.config.Section("email").Key("address").String()
email.fromName = ctx.config.Section("email").Key("from").String()
email.sendMethod = ctx.config.Section("email").Key("method").String()
func (email *Emailer) init(app *appContext) {
email.fromAddr = app.config.Section("email").Key("address").String()
email.fromName = app.config.Section("email").Key("from").String()
email.sendMethod = app.config.Section("email").Key("method").String()
if email.sendMethod == "mailgun" {
email.mg = mailgun.NewMailgun(strings.Split(email.fromAddr, "@")[1], ctx.config.Section("mailgun").Key("api_key").String())
api_url := ctx.config.Section("mailgun").Key("api_url").String()
email.mg = mailgun.NewMailgun(strings.Split(email.fromAddr, "@")[1], app.config.Section("mailgun").Key("api_key").String())
api_url := app.config.Section("mailgun").Key("api_url").String()
// Mailgun client takes the base url, so we need to trim off the end (e.g 'v3/messages'
if strings.Contains(api_url, "messages") {
api_url = api_url[0:strings.LastIndex(api_url, "/")]
@ -63,21 +64,21 @@ func (email *Emailer) init(ctx *appContext) {
}
email.mg.SetAPIBase(api_url)
} else if email.sendMethod == "smtp" {
ctx.host = ctx.config.Section("smtp").Key("server").String()
email.smtpAuth = smtp.PlainAuth("", email.fromAddr, ctx.config.Section("smtp").Key("password").String(), ctx.host)
app.host = app.config.Section("smtp").Key("server").String()
email.smtpAuth = smtp.PlainAuth("", email.fromAddr, app.config.Section("smtp").Key("password").String(), app.host)
}
}
func (email *Emailer) constructInvite(code string, invite Invite, ctx *appContext) error {
email.content.subject = ctx.config.Section("invite_emails").Key("subject").String()
func (email *Emailer) constructInvite(code string, invite Invite, app *appContext) error {
email.content.subject = app.config.Section("invite_emails").Key("subject").String()
expiry := invite.ValidTill
d, t, expires_in := email.formatExpiry(expiry, false, ctx.datePattern, ctx.timePattern)
message := ctx.config.Section("email").Key("message").String()
invite_link := ctx.config.Section("invite_emails").Key("url_base").String()
d, t, expires_in := email.formatExpiry(expiry, false, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String()
invite_link := app.config.Section("invite_emails").Key("url_base").String()
invite_link = fmt.Sprintf("%s/%s", invite_link, code)
for _, key := range []string{"html", "text"} {
fpath := ctx.config.Section("invite_emails").Key("email_" + key).String()
fpath := app.config.Section("invite_emails").Key("email_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return err
@ -103,11 +104,11 @@ func (email *Emailer) constructInvite(code string, invite Invite, ctx *appContex
return nil
}
func (email *Emailer) constructExpiry(code string, invite Invite, ctx *appContext) error {
func (email *Emailer) constructExpiry(code string, invite Invite, app *appContext) error {
email.content.subject = "Notice: Invite expired"
expiry := ctx.formatDatetime(invite.ValidTill)
expiry := app.formatDatetime(invite.ValidTill)
for _, key := range []string{"html", "text"} {
fpath := ctx.config.Section("notifications").Key("expiry_" + key).String()
fpath := app.config.Section("notifications").Key("expiry_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return err
@ -130,17 +131,17 @@ func (email *Emailer) constructExpiry(code string, invite Invite, ctx *appContex
return nil
}
func (email *Emailer) constructCreated(code, username, address string, invite Invite, ctx *appContext) error {
func (email *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext) error {
email.content.subject = "Notice: User created"
created := ctx.formatDatetime(invite.Created)
created := app.formatDatetime(invite.Created)
var tplAddress string
if ctx.config.Section("email").Key("no_username").MustBool(false) {
if app.config.Section("email").Key("no_username").MustBool(false) {
tplAddress = "n/a"
} else {
tplAddress = address
}
for _, key := range []string{"html", "text"} {
fpath := ctx.config.Section("notifications").Key("created_" + key).String()
fpath := app.config.Section("notifications").Key("created_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return err
@ -165,12 +166,12 @@ func (email *Emailer) constructCreated(code, username, address string, invite In
return nil
}
func (email *Emailer) constructReset(pwr Pwr, ctx *appContext) error {
email.content.subject = ctx.config.Section("password_resets").Key("subject").MustString("Password reset - Jellyfin")
d, t, expires_in := email.formatExpiry(pwr.Expiry, true, ctx.datePattern, ctx.timePattern)
message := ctx.config.Section("email").Key("message").String()
func (email *Emailer) constructReset(pwr Pwr, app *appContext) error {
email.content.subject = app.config.Section("password_resets").Key("subject").MustString("Password reset - Jellyfin")
d, t, expires_in := email.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String()
for _, key := range []string{"html", "text"} {
fpath := ctx.config.Section("password_resets").Key("email_" + key).String()
fpath := app.config.Section("password_resets").Key("email_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return err
@ -197,7 +198,7 @@ func (email *Emailer) constructReset(pwr Pwr, ctx *appContext) error {
return nil
}
func (email *Emailer) send(address string, ctx *appContext) error {
func (email *Emailer) send(address string, app *appContext) error {
if email.sendMethod == "mailgun" {
message := email.mg.NewMessage(
fmt.Sprintf("%s <%s>", email.fromName, email.fromAddr),
@ -205,9 +206,9 @@ func (email *Emailer) send(address string, ctx *appContext) error {
email.content.text,
address)
message.SetHtml(email.content.html)
mgctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
mgapp, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
_, _, err := email.mg.Send(mgctx, message)
_, _, err := email.mg.Send(mgapp, message)
if err != nil {
return err
}
@ -218,19 +219,19 @@ func (email *Emailer) send(address string, ctx *appContext) error {
e.To = []string{address}
e.Text = []byte(email.content.text)
e.HTML = []byte(email.content.html)
smtpType := ctx.config.Section("smtp").Key("encryption").String()
smtpType := app.config.Section("smtp").Key("encryption").String()
tlsConfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: ctx.host,
ServerName: app.host,
}
var err error
if smtpType == "ssl_tls" {
port := ctx.config.Section("smtp").Key("port").MustInt(465)
server := fmt.Sprintf("%s:%d", ctx.host, port)
port := app.config.Section("smtp").Key("port").MustInt(465)
server := fmt.Sprintf("%s:%d", app.host, port)
err = e.SendWithTLS(server, email.smtpAuth, tlsConfig)
} else if smtpType == "starttls" {
port := ctx.config.Section("smtp").Key("port").MustInt(587)
server := fmt.Sprintf("%s:%d", ctx.host, port)
port := app.config.Section("smtp").Key("port").MustInt(587)
server := fmt.Sprintf("%s:%d", app.host, port)
e.SendWithStartTLS(server, email.smtpAuth, tlsConfig)
}
return err

@ -7,11 +7,6 @@ import (
"encoding/json"
"flag"
"fmt"
"github.com/gin-contrib/pprof"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
"io"
"io/ioutil"
"log"
@ -20,6 +15,12 @@ import (
"os/signal"
"path/filepath"
"time"
"github.com/gin-contrib/pprof"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
"github.com/lithammer/shortuuid/v3"
"gopkg.in/ini.v1"
)
// Username is JWT!
@ -95,190 +96,190 @@ func setGinLogger(router *gin.Engine, debugMode bool) {
}
func main() {
ctx := new(appContext)
app := new(appContext)
userConfigDir, _ := os.UserConfigDir()
ctx.data_path = filepath.Join(userConfigDir, "jfa-go")
ctx.config_path = filepath.Join(ctx.data_path, "config.ini")
app.data_path = filepath.Join(userConfigDir, "jfa-go")
app.config_path = filepath.Join(app.data_path, "config.ini")
executable, _ := os.Executable()
ctx.local_path = filepath.Join(filepath.Dir(executable), "data")
app.local_path = filepath.Join(filepath.Dir(executable), "data")
ctx.info = log.New(os.Stdout, "[INFO] ", log.Ltime)
ctx.err = log.New(os.Stdout, "[ERROR] ", log.Ltime|log.Lshortfile)
app.info = log.New(os.Stdout, "[INFO] ", log.Ltime)
app.err = log.New(os.Stdout, "[ERROR] ", log.Ltime|log.Lshortfile)
dataPath := flag.String("data", ctx.data_path, "alternate path to data directory.")
configPath := flag.String("config", ctx.config_path, "alternate path to config file.")
dataPath := flag.String("data", app.data_path, "alternate path to data directory.")
configPath := flag.String("config", app.config_path, "alternate path to config file.")
host := flag.String("host", "", "alternate address to host web ui on.")
port := flag.Int("port", 0, "alternate port to host web ui on.")
flag.Parse()
if ctx.config_path == *configPath && ctx.data_path != *dataPath {
ctx.config_path = filepath.Join(*dataPath, "config.ini")
if app.config_path == *configPath && app.data_path != *dataPath {
app.config_path = filepath.Join(*dataPath, "config.ini")
} else {
ctx.config_path = *configPath
ctx.data_path = *dataPath
app.config_path = *configPath
app.data_path = *dataPath
}
// Env variables are necessary because syscall.Exec for self-restarts doesn't doesn't work with arguments for some reason.
if v := os.Getenv("JFA_CONFIGPATH"); v != "" {
ctx.config_path = v
app.config_path = v
}
if v := os.Getenv("JFA_DATAPATH"); v != "" {
ctx.data_path = v
app.data_path = v
}
os.Setenv("JFA_CONFIGPATH", ctx.config_path)
os.Setenv("JFA_DATAPATH", ctx.data_path)
os.Setenv("JFA_CONFIGPATH", app.config_path)
os.Setenv("JFA_DATAPATH", app.data_path)
var firstRun bool
if _, err := os.Stat(ctx.data_path); os.IsNotExist(err) {
os.Mkdir(ctx.data_path, 0700)
if _, err := os.Stat(app.data_path); os.IsNotExist(err) {
os.Mkdir(app.data_path, 0700)
}
if _, err := os.Stat(ctx.config_path); os.IsNotExist(err) {
if _, err := os.Stat(app.config_path); os.IsNotExist(err) {
firstRun = true
dConfigPath := filepath.Join(ctx.local_path, "config-default.ini")
dConfigPath := filepath.Join(app.local_path, "config-default.ini")
var dConfig *os.File
dConfig, err = os.Open(dConfigPath)
if err != nil {
ctx.err.Fatalf("Couldn't find default config file \"%s\"", dConfigPath)
app.err.Fatalf("Couldn't find default config file \"%s\"", dConfigPath)
}
defer dConfig.Close()
var nConfig *os.File
nConfig, err := os.Create(ctx.config_path)
nConfig, err := os.Create(app.config_path)
if err != nil {
ctx.err.Fatalf("Couldn't open config file for writing: \"%s\"", dConfigPath)
app.err.Fatalf("Couldn't open config file for writing: \"%s\"", dConfigPath)
}
defer nConfig.Close()
_, err = io.Copy(nConfig, dConfig)
if err != nil {
ctx.err.Fatalf("Couldn't copy default config. To do this manually, copy\n%s\nto\n%s", dConfigPath, ctx.config_path)
app.err.Fatalf("Couldn't copy default config. To do this manually, copy\n%s\nto\n%s", dConfigPath, app.config_path)
}
ctx.info.Printf("Copied default configuration to \"%s\"", ctx.config_path)
app.info.Printf("Copied default configuration to \"%s\"", app.config_path)
}
var debugMode bool
var address string
if ctx.loadConfig() != nil {
ctx.err.Fatalf("Failed to load config file \"%s\"", ctx.config_path)
if app.loadConfig() != nil {
app.err.Fatalf("Failed to load config file \"%s\"", app.config_path)
}
ctx.version = ctx.config.Section("jellyfin").Key("version").String()
app.version = app.config.Section("jellyfin").Key("version").String()
debugMode = ctx.config.Section("ui").Key("debug").MustBool(true)
debugMode = app.config.Section("ui").Key("debug").MustBool(true)
if debugMode {
ctx.debug = log.New(os.Stdout, "[DEBUG] ", log.Ltime|log.Lshortfile)
app.debug = log.New(os.Stdout, "[DEBUG] ", log.Ltime|log.Lshortfile)
} else {
ctx.debug = log.New(ioutil.Discard, "", 0)
app.debug = log.New(ioutil.Discard, "", 0)
}
if !firstRun {
ctx.host = ctx.config.Section("ui").Key("host").String()
ctx.port = ctx.config.Section("ui").Key("port").MustInt(8056)
app.host = app.config.Section("ui").Key("host").String()
app.port = app.config.Section("ui").Key("port").MustInt(8056)
if *host != ctx.host && *host != "" {
ctx.host = *host
if *host != app.host && *host != "" {
app.host = *host
}
if *port != ctx.port && *port > 0 {
ctx.port = *port
if *port != app.port && *port > 0 {
app.port = *port
}
if h := os.Getenv("JFA_HOST"); h != "" {
ctx.host = h
app.host = h
if p := os.Getenv("JFA_PORT"); p != "" {
var port int
_, err := fmt.Sscan(p, &port)
if err == nil {
ctx.port = port
app.port = port
}
}
}
address = fmt.Sprintf("%s:%d", ctx.host, ctx.port)
address = fmt.Sprintf("%s:%d", app.host, app.port)
ctx.debug.Printf("Loaded config file \"%s\"", ctx.config_path)
app.debug.Printf("Loaded config file \"%s\"", app.config_path)
if ctx.config.Section("ui").Key("bs5").MustBool(false) {
ctx.cssFile = "bs5-jf.css"
ctx.bsVersion = 5
if app.config.Section("ui").Key("bs5").MustBool(false) {
app.cssFile = "bs5-jf.css"
app.bsVersion = 5
} else {
ctx.cssFile = "bs4-jf.css"
ctx.bsVersion = 4
app.cssFile = "bs4-jf.css"
app.bsVersion = 4
}
ctx.debug.Println("Loading storage")
app.debug.Println("Loading storage")
ctx.storage.invite_path = filepath.Join(ctx.data_path, "invites.json")
ctx.storage.loadInvites()
ctx.storage.emails_path = filepath.Join(ctx.data_path, "emails.json")
ctx.storage.loadEmails()
ctx.storage.policy_path = filepath.Join(ctx.data_path, "user_template.json")
ctx.storage.loadPolicy()
ctx.storage.configuration_path = filepath.Join(ctx.data_path, "user_configuration.json")
ctx.storage.loadConfiguration()
ctx.storage.displayprefs_path = filepath.Join(ctx.data_path, "user_displayprefs.json")
ctx.storage.loadDisplayprefs()
app.storage.invite_path = filepath.Join(app.data_path, "invites.json")
app.storage.loadInvites()
app.storage.emails_path = filepath.Join(app.data_path, "emails.json")
app.storage.loadEmails()
app.storage.policy_path = filepath.Join(app.data_path, "user_template.json")
app.storage.loadPolicy()
app.storage.configuration_path = filepath.Join(app.data_path, "user_configuration.json")
app.storage.loadConfiguration()
app.storage.displayprefs_path = filepath.Join(app.data_path, "user_displayprefs.json")
app.storage.loadDisplayprefs()
ctx.configBase_path = filepath.Join(ctx.local_path, "config-base.json")
config_base, _ := ioutil.ReadFile(ctx.configBase_path)
json.Unmarshal(config_base, &ctx.configBase)
app.configBase_path = filepath.Join(app.local_path, "config-base.json")
config_base, _ := ioutil.ReadFile(app.configBase_path)
json.Unmarshal(config_base, &app.configBase)
themes := map[string]string{
"Jellyfin (Dark)": fmt.Sprintf("bs%d-jf.css", ctx.bsVersion),
"Bootstrap (Light)": fmt.Sprintf("bs%d.css", ctx.bsVersion),
"Jellyfin (Dark)": fmt.Sprintf("bs%d-jf.css", app.bsVersion),
"Bootstrap (Light)": fmt.Sprintf("bs%d.css", app.bsVersion),
"Custom CSS": "",
}
if val, ok := themes[ctx.config.Section("ui").Key("theme").String()]; ok {
ctx.cssFile = val
if val, ok := themes[app.config.Section("ui").Key("theme").String()]; ok {
app.cssFile = val
}
ctx.debug.Printf("Using css file \"%s\"", ctx.cssFile)
app.debug.Printf("Using css file \"%s\"", app.cssFile)
secret, err := GenerateSecret(16)
if err != nil {
ctx.err.Fatal(err)
app.err.Fatal(err)
}
os.Setenv("JFA_SECRET", secret)
ctx.jellyfinLogin = true
if val, _ := ctx.config.Section("ui").Key("jellyfin_login").Bool(); !val {
ctx.jellyfinLogin = false
app.jellyfinLogin = true
if val, _ := app.config.Section("ui").Key("jellyfin_login").Bool(); !val {
app.jellyfinLogin = false
user := User{}
user.UserID = shortuuid.New()
user.Username = ctx.config.Section("ui").Key("username").String()
user.Password = ctx.config.Section("ui").Key("password").String()
ctx.users = append(ctx.users, user)
user.Username = app.config.Section("ui").Key("username").String()
user.Password = app.config.Section("ui").Key("password").String()
app.users = append(app.users, user)
} else {
ctx.debug.Println("Using Jellyfin for authentication")
app.debug.Println("Using Jellyfin for authentication")
}
server := ctx.config.Section("jellyfin").Key("server").String()
ctx.jf.init(server, "jfa-go", ctx.version, "hrfee-arch", "hrfee-arch")
server := app.config.Section("jellyfin").Key("server").String()
app.jf.init(server, "jfa-go", app.version, "hrfee-arch", "hrfee-arch")
var status int
_, status, err = ctx.jf.authenticate(ctx.config.Section("jellyfin").Key("username").String(), ctx.config.Section("jellyfin").Key("password").String())
_, status, err = app.jf.authenticate(app.config.Section("jellyfin").Key("username").String(), app.config.Section("jellyfin").Key("password").String())
if status != 200 || err != nil {
ctx.err.Fatalf("Failed to authenticate with Jellyfin @ %s: Code %d", server, status)
app.err.Fatalf("Failed to authenticate with Jellyfin @ %s: Code %d", server, status)
}
ctx.info.Printf("Authenticated with %s", server)
ctx.authJf.init(server, "jfa-go", ctx.version, "auth", "auth")
app.info.Printf("Authenticated with %s", server)
app.authJf.init(server, "jfa-go", app.version, "auth", "auth")
ctx.loadStrftime()
app.loadStrftime()
validatorConf := ValidatorConf{
"characters": ctx.config.Section("password_validation").Key("min_length").MustInt(0),
"uppercase characters": ctx.config.Section("password_validation").Key("upper").MustInt(0),
"lowercase characters": ctx.config.Section("password_validation").Key("lower").MustInt(0),
"numbers": ctx.config.Section("password_validation").Key("number").MustInt(0),
"special characters": ctx.config.Section("password_validation").Key("special").MustInt(0),
"characters": app.config.Section("password_validation").Key("min_length").MustInt(0),
"uppercase characters": app.config.Section("password_validation").Key("upper").MustInt(0),
"lowercase characters": app.config.Section("password_validation").Key("lower").MustInt(0),
"numbers": app.config.Section("password_validation").Key("number").MustInt(0),
"special characters": app.config.Section("password_validation").Key("special").MustInt(0),
}
if !ctx.config.Section("password_validation").Key("enabled").MustBool(false) {
if !app.config.Section("password_validation").Key("enabled").MustBool(false) {
for key := range validatorConf {
validatorConf[key] = 0
}
}
ctx.validator.init(validatorConf)
app.validator.init(validatorConf)
ctx.email.init(ctx)
app.email.init(app)
inviteDaemon := NewRepeater(time.Duration(60*time.Second), ctx)
inviteDaemon := NewRepeater(time.Duration(60*time.Second), app)
go inviteDaemon.Run()
if ctx.config.Section("password_resets").Key("enabled").MustBool(false) {
go ctx.StartPWR()
if app.config.Section("password_resets").Key("enabled").MustBool(false) {
go app.StartPWR()
}
} else {
debugMode = false
@ -286,43 +287,43 @@ func main() {
address = "0.0.0.0:8056"
}
ctx.info.Println("Loading routes")
app.info.Println("Loading routes")
router := gin.New()
setGinLogger(router, debugMode)
router.Use(gin.Recovery())
router.Use(static.Serve("/", static.LocalFile(filepath.Join(ctx.local_path, "static"), false)))
router.LoadHTMLGlob(filepath.Join(ctx.local_path, "templates", "*"))
router.NoRoute(ctx.NoRouteHandler)
router.Use(static.Serve("/", static.LocalFile(filepath.Join(app.local_path, "static"), false)))
router.LoadHTMLGlob(filepath.Join(app.local_path, "templates", "*"))
router.NoRoute(app.NoRouteHandler)
if debugMode {
ctx.debug.Println("Loading pprof")
app.debug.Println("Loading pprof")
pprof.Register(router)
}
if !firstRun {
router.GET("/", ctx.AdminPage)
router.GET("/getToken", ctx.GetToken)
router.POST("/newUser", ctx.NewUser)
router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(ctx.local_path, "static"), false)))
router.GET("/invite/:invCode", ctx.InviteProxy)
api := router.Group("/", ctx.webAuth())
api.POST("/generateInvite", ctx.GenerateInvite)
api.GET("/getInvites", ctx.GetInvites)
api.POST("/setNotify", ctx.SetNotify)
api.POST("/deleteInvite", ctx.DeleteInvite)
api.GET("/getUsers", ctx.GetUsers)
api.POST("/modifyUsers", ctx.ModifyEmails)
api.POST("/setDefaults", ctx.SetDefaults)
api.GET("/getConfig", ctx.GetConfig)
api.POST("/modifyConfig", ctx.ModifyConfig)
ctx.info.Printf("Starting router @ %s", address)
router.GET("/", app.AdminPage)
router.GET("/getToken", app.GetToken)
router.POST("/newUser", app.NewUser)
router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.local_path, "static"), false)))
router.GET("/invite/:invCode", app.InviteProxy)
api := router.Group("/", app.webAuth())
api.POST("/generateInvite", app.GenerateInvite)
api.GET("/getInvites", app.GetInvites)
api.POST("/setNotify", app.SetNotify)
api.POST("/deleteInvite", app.DeleteInvite)
api.GET("/getUsers", app.GetUsers)
api.POST("/modifyUsers", app.ModifyEmails)
api.POST("/setDefaults", app.SetDefaults)
api.GET("/getConfig", app.GetConfig)
api.POST("/modifyConfig", app.ModifyConfig)
app.info.Printf("Starting router @ %s", address)
} else {
router.GET("/", func(gc *gin.Context) {
gc.HTML(200, "setup.html", gin.H{})
})
router.POST("/testJF", ctx.TestJF)
router.POST("/modifyConfig", ctx.ModifyConfig)
ctx.info.Printf("Loading setup @ %s", address)
router.POST("/testJF", app.TestJF)
router.POST("/modifyConfig", app.ModifyConfig)
app.info.Printf("Loading setup @ %s", address)
}
srv := &http.Server{
@ -331,17 +332,17 @@ func main() {
}
go func() {
if err := srv.ListenAndServe(); err != nil {
ctx.err.Printf("Failure serving: %s", err)
app.err.Printf("Failure serving: %s", err)
}
}()
ctx.quit = make(chan os.Signal)
signal.Notify(ctx.quit, os.Interrupt)
<-ctx.quit
ctx.info.Println("Shutting down...")
app.quit = make(chan os.Signal)
signal.Notify(app.quit, os.Interrupt)
<-app.quit
app.info.Println("Shutting down...")
cntx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
if err := srv.Shutdown(cntx); err != nil {
ctx.err.Fatalf("Server shutdown error: %s", err)
app.err.Fatalf("Server shutdown error: %s", err)
}
}

@ -2,33 +2,34 @@ package main
import (
"encoding/json"
"github.com/fsnotify/fsnotify"
"io/ioutil"
"os"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
func (ctx *appContext) StartPWR() {
ctx.info.Println("Starting password reset daemon")
path := ctx.config.Section("password_resets").Key("watch_directory").String()
func (app *appContext) StartPWR() {
app.info.Println("Starting password reset daemon")
path := app.config.Section("password_resets").Key("watch_directory").String()
if _, err := os.Stat(path); os.IsNotExist(err) {
ctx.err.Printf("Failed to start password reset daemon: Directory \"%s\" doesn't exist", path)
app.err.Printf("Failed to start password reset daemon: Directory \"%s\" doesn't exist", path)
return
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
ctx.err.Printf("Couldn't initialise password reset daemon")
app.err.Printf("Couldn't initialise password reset daemon")
return
}
defer watcher.Close()
done := make(chan bool)
go pwrMonitor(ctx, watcher)
go pwrMonitor(app, watcher)
err = watcher.Add(path)
if err != nil {
ctx.err.Printf("Failed to start password reset daemon: %s", err)
app.err.Printf("Failed to start password reset daemon: %s", err)
}
<-done
}
@ -39,7 +40,7 @@ type Pwr struct {
Expiry time.Time `json:"ExpirationDate"`
}
func pwrMonitor(ctx *appContext, watcher *fsnotify.Watcher) {
func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
for {
select {
case event, ok := <-watcher.Events:
@ -56,29 +57,29 @@ func pwrMonitor(ctx *appContext, watcher *fsnotify.Watcher) {
if len(pwr.Pin) == 0 || err != nil {
return
}
ctx.info.Printf("New password reset for user \"%s\"", pwr.Username)
app.info.Printf("New password reset for user \"%s\"", pwr.Username)
if ct := time.Now(); pwr.Expiry.After(ct) {
user, status, err := ctx.jf.userByName(pwr.Username, false)
user, status, err := app.jf.userByName(pwr.Username, false)
if !(status == 200 || status == 204) || err != nil {
ctx.err.Printf("Failed to get users from Jellyfin: Code %d", status)
ctx.debug.Printf("Error: %s", err)
app.err.Printf("Failed to get users from Jellyfin: Code %d", status)
app.debug.Printf("Error: %s", err)
return
}
ctx.storage.loadEmails()
address, ok := ctx.storage.emails[user["Id"].(string)].(string)
app.storage.loadEmails()
address, ok := app.storage.emails[user["Id"].(string)].(string)
if !ok {
ctx.err.Printf("Couldn't find email for user \"%s\". Make sure it's set", pwr.Username)
app.err.Printf("Couldn't find email for user \"%s\". Make sure it's set", pwr.Username)
return
}
if ctx.email.constructReset(pwr, ctx) != nil {
ctx.err.Printf("Failed to construct password reset email for %s", pwr.Username)
} else if ctx.email.send(address, ctx) != nil {
ctx.err.Printf("Failed to send password reset email to \"%s\"", address)
if app.email.constructReset(pwr, app) != nil {
app.err.Printf("Failed to construct password reset email for %s", pwr.Username)
} else if app.email.send(address, app) != nil {
app.err.Printf("Failed to send password reset email to \"%s\"", address)
} else {
ctx.info.Printf("Sent password reset email to \"%s\"", address)
app.info.Printf("Sent password reset email to \"%s\"", address)
}
} else {
ctx.err.Printf("Password reset for user \"%s\" has already expired (%s). Check your time settings.", pwr.Username, pwr.Expiry)
app.err.Printf("Password reset for user \"%s\" has already expired (%s). Check your time settings.", pwr.Username, pwr.Expiry)
}
}
@ -86,7 +87,7 @@ func pwrMonitor(ctx *appContext, watcher *fsnotify.Watcher) {
if !ok {
return
}
ctx.err.Printf("Password reset daemon: %s", err)
app.err.Printf("Password reset daemon: %s", err)
}
}
}

@ -10,14 +10,14 @@ type testReq struct {
Password string `json:"jfPassword"`
}
func (ctx *appContext) TestJF(gc *gin.Context) {
func (app *appContext) TestJF(gc *gin.Context) {
var req testReq
gc.BindJSON(&req)
tempjf := Jellyfin{}
tempjf.init(req.Host, "jfa-go-setup", ctx.version, "auth", "auth")
tempjf.init(req.Host, "jfa-go-setup", app.version, "auth", "auth")
_, status, err := tempjf.authenticate(req.Username, req.Password)
if !(status == 200 || status == 204) || err != nil {
ctx.info.Printf("Auth failed with code %d (%s)", status, err)
app.info.Printf("Auth failed with code %d (%s)", status, err)
gc.JSON(401, map[string]bool{"success": false})
return
}

@ -1,54 +1,55 @@
package main
import (
"github.com/gin-gonic/gin"
"net/http"
"github.com/gin-gonic/gin"
)
func (ctx *appContext) AdminPage(gc *gin.Context) {
bs5 := ctx.config.Section("ui").Key("bs5").MustBool(false)
emailEnabled, _ := ctx.config.Section("invite_emails").Key("enabled").Bool()
notificationsEnabled, _ := ctx.config.Section("notifications").Key("enabled").Bool()
func (app *appContext) AdminPage(gc *gin.Context) {
bs5 := app.config.Section("ui").Key("bs5").MustBool(false)
emailEnabled, _ := app.config.Section("invite_emails").Key("enabled").Bool()
notificationsEnabled, _ := app.config.Section("notifications").Key("enabled").Bool()
gc.HTML(http.StatusOK, "admin.html", gin.H{
"bs5": bs5,
"cssFile": ctx.cssFile,
"cssFile": app.cssFile,
"contactMessage": "",
"email_enabled": emailEnabled,
"notifications": notificationsEnabled,
})
}
func (ctx *appContext) InviteProxy(gc *gin.Context) {
func (app *appContext) InviteProxy(gc *gin.Context) {
code := gc.Param("invCode")
/* Don't actually check if the invite is valid, just if it exists, just so the page loads quicker. Invite is actually checked on submit anyway. */
// if ctx.checkInvite(code, false, "") {
if _, ok := ctx.storage.invites[code]; ok {
email := ctx.storage.invites[code].Email
// if app.checkInvite(code, false, "") {
if _, ok := app.storage.invites[code]; ok {
email := app.storage.invites[code].Email
gc.HTML(http.StatusOK, "form.html", gin.H{
"bs5": ctx.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": ctx.cssFile,
"contactMessage": ctx.config.Section("ui").Key("contac_message").String(),
"helpMessage": ctx.config.Section("ui").Key("help_message").String(),
"successMessage": ctx.config.Section("ui").Key("success_message").String(),
"jfLink": ctx.config.Section("jellyfin").Key("public_server").String(),
"validate": ctx.config.Section("password_validation").Key("enabled").MustBool(false),
"requirements": ctx.validator.getCriteria(),
"bs5": app.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": app.cssFile,
"contactMessage": app.config.Section("ui").Key("contac_message").String(),
"helpMessage": app.config.Section("ui").Key("help_message").String(),
"successMessage": app.config.Section("ui").Key("success_message").String(),
"jfLink": app.config.Section("jellyfin").Key("public_server").String(),
"validate": app.config.Section("password_validation").Key("enabled").MustBool(false),
"requirements": app.validator.getCriteria(),
"email": email,
"username": !ctx.config.Section("email").Key("no_username").MustBool(false),
"username": !app.config.Section("email").Key("no_username").MustBool(false),
})
} else {
gc.HTML(404, "invalidCode.html", gin.H{
"bs5": ctx.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": ctx.cssFile,
"contactMessage": ctx.config.Section("ui").Key("contac_message").String(),
"bs5": app.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": app.cssFile,
"contactMessage": app.config.Section("ui").Key("contac_message").String(),
})
}
}
func (ctx *appContext) NoRouteHandler(gc *gin.Context) {
func (app *appContext) NoRouteHandler(gc *gin.Context) {
gc.HTML(404, "404.html", gin.H{
"bs5": ctx.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": ctx.cssFile,
"contactMessage": ctx.config.Section("ui").Key("contact_message").String(),
"bs5": app.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": app.cssFile,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}

Loading…
Cancel
Save