implement user expiry functionality

All works now, but i'll add a field on the accounts tab for users with
an expiry, as well as a 'disabled' badge.
This commit is contained in:
Harvey Tindall 2021-02-28 15:41:06 +00:00
parent 2934832a98
commit 1e9d184508
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
21 changed files with 500 additions and 132 deletions

82
api.go
View File

@ -445,6 +445,14 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
app.info.Printf("%s: Sent welcome email to %s", req.Username, req.Email)
}
}
if invite.UserExpiry {
expiry := time.Now().Add(time.Duration(60*(invite.UserDays*24+invite.UserHours)+invite.UserMinutes) * time.Minute)
app.storage.users[id] = expiry
err := app.storage.storeUsers()
if err != nil {
app.err.Printf("Failed to store user duration: %s", err)
}
}
success = true
return
}
@ -634,8 +642,8 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
} else {
invite.RemainingUses = 1
}
invite.UserDuration = req.UserDuration
if invite.UserDuration {
invite.UserExpiry = req.UserExpiry
if invite.UserExpiry {
invite.UserDays = req.UserDays
invite.UserHours = req.UserHours
invite.UserMinutes = req.UserMinutes
@ -819,18 +827,18 @@ func (app *appContext) GetInvites(gc *gin.Context) {
for code, inv := range app.storage.invites {
_, _, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
invite := inviteDTO{
Code: code,
Days: days,
Hours: hours,
Minutes: minutes,
UserDuration: inv.UserDuration,
UserDays: inv.UserDays,
UserHours: inv.UserHours,
UserMinutes: inv.UserMinutes,
Created: app.formatDatetime(inv.Created),
Profile: inv.Profile,
NoLimit: inv.NoLimit,
Label: inv.Label,
Code: code,
Days: days,
Hours: hours,
Minutes: minutes,
UserExpiry: inv.UserExpiry,
UserDays: inv.UserDays,
UserHours: inv.UserHours,
UserMinutes: inv.UserMinutes,
Created: app.formatDatetime(inv.Created),
Profile: inv.Profile,
NoLimit: inv.NoLimit,
Label: inv.Label,
}
if len(inv.UsedBy) != 0 {
invite.UsedBy = inv.UsedBy
@ -1281,18 +1289,24 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
// @Summary Get a list of email names and IDs.
// @Produce json
// @Param lang query string false "Language for email titles."
// @Success 200 {object} emailListDTO
// @Router /config/emails [get]
// @tags Configuration
func (app *appContext) GetEmails(gc *gin.Context) {
lang := gc.Query("lang")
if _, ok := app.storage.lang.Email[lang]; !ok {
lang = app.storage.lang.chosenEmailLang
}
gc.JSON(200, emailListDTO{
"UserCreated": {Name: app.storage.lang.Email["en-us"].UserCreated["name"], Enabled: app.storage.customEmails.UserCreated.Enabled},
"InviteExpiry": {Name: app.storage.lang.Email["en-us"].InviteExpiry["name"], Enabled: app.storage.customEmails.InviteExpiry.Enabled},
"PasswordReset": {Name: app.storage.lang.Email["en-us"].PasswordReset["name"], Enabled: app.storage.customEmails.PasswordReset.Enabled},
"UserDeleted": {Name: app.storage.lang.Email["en-us"].UserDeleted["name"], Enabled: app.storage.customEmails.UserDeleted.Enabled},
"InviteEmail": {Name: app.storage.lang.Email["en-us"].InviteEmail["name"], Enabled: app.storage.customEmails.InviteEmail.Enabled},
"WelcomeEmail": {Name: app.storage.lang.Email["en-us"].WelcomeEmail["name"], Enabled: app.storage.customEmails.WelcomeEmail.Enabled},
"EmailConfirmation": {Name: app.storage.lang.Email["en-us"].EmailConfirmation["name"], Enabled: app.storage.customEmails.EmailConfirmation.Enabled},
"UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.customEmails.UserCreated.Enabled},
"InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.customEmails.InviteExpiry.Enabled},
"PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.customEmails.PasswordReset.Enabled},
"UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.customEmails.UserDeleted.Enabled},
"InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.customEmails.InviteEmail.Enabled},
"WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.customEmails.WelcomeEmail.Enabled},
"EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.customEmails.EmailConfirmation.Enabled},
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.customEmails.UserExpired.Enabled},
})
}
@ -1333,6 +1347,9 @@ func (app *appContext) SetEmail(gc *gin.Context) {
} else if id == "EmailConfirmation" {
app.storage.customEmails.EmailConfirmation.Content = req.Content
app.storage.customEmails.EmailConfirmation.Enabled = true
} else if id == "UserExpired" {
app.storage.customEmails.UserExpired.Content = req.Content
app.storage.customEmails.UserExpired.Enabled = true
} else {
respondBool(400, false, gc)
return
@ -1374,6 +1391,8 @@ func (app *appContext) SetEmailState(gc *gin.Context) {
app.storage.customEmails.WelcomeEmail.Enabled = enabled
} else if id == "EmailConfirmation" {
app.storage.customEmails.EmailConfirmation.Enabled = enabled
} else if id == "UserExpired" {
app.storage.customEmails.UserExpired.Enabled = enabled
} else {
respondBool(400, false, gc)
return
@ -1393,6 +1412,7 @@ func (app *appContext) SetEmailState(gc *gin.Context) {
// @Router /config/emails/{id} [get]
// @tags Configuration
func (app *appContext) GetEmail(gc *gin.Context) {
lang := app.storage.lang.chosenEmailLang
id := gc.Param("id")
var content string
var err error
@ -1401,8 +1421,8 @@ func (app *appContext) GetEmail(gc *gin.Context) {
var values map[string]interface{}
var writeVars func(variables []string)
newEmail := false
username := app.storage.lang.Email[app.storage.lang.chosenEmailLang].Strings.get("username")
emailAddress := app.storage.lang.Email[app.storage.lang.chosenEmailLang].Strings.get("emailAddress")
username := app.storage.lang.Email[lang].Strings.get("username")
emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
if id == "UserCreated" {
content = app.storage.customEmails.UserCreated.Content
if content == "" {
@ -1449,7 +1469,7 @@ func (app *appContext) GetEmail(gc *gin.Context) {
variables = app.storage.customEmails.UserDeleted.Variables
}
writeVars = func(variables []string) { app.storage.customEmails.UserDeleted.Variables = variables }
values = app.email.deletedValues(app.storage.lang.Email[app.storage.lang.chosenEmailLang].UserDeleted.get("reason"), app, false)
values = app.email.deletedValues(app.storage.lang.Email[lang].UserDeleted.get("reason"), app, false)
// app.storage.customEmails.UserDeleted = content
} else if id == "InviteEmail" {
content = app.storage.customEmails.InviteEmail.Content
@ -1487,6 +1507,17 @@ func (app *appContext) GetEmail(gc *gin.Context) {
writeVars = func(variables []string) { app.storage.customEmails.EmailConfirmation.Variables = variables }
values = app.email.confirmationValues("xxxxxx", username, "xxxxxx", app, false)
// app.storage.customEmails.EmailConfirmation = content
} else if id == "UserExpired" {
content = app.storage.customEmails.UserExpired.Content
if content == "" {
newEmail = true
msg, err = app.email.constructUserExpired(app, true)
content = msg.Text
} else {
variables = app.storage.customEmails.UserExpired.Variables
}
writeVars = func(variables []string) { app.storage.customEmails.UserExpired.Variables = variables }
values = app.email.userExpiredValues(app, false)
} else {
respondBool(400, false, gc)
return
@ -1515,6 +1546,9 @@ func (app *appContext) GetEmail(gc *gin.Context) {
}
writeVars(variables)
}
if variables == nil {
variables = []string{}
}
if app.storage.storeCustomEmails() != nil {
respondBool(500, false, gc)
return

View File

@ -36,7 +36,7 @@ func (app *appContext) loadConfig() error {
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
}
}
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails"} {
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users"} {
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
}
app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")
@ -66,6 +66,10 @@ func (app *appContext) loadConfig() error {
app.config.Section("template_email").Key("email_html").SetValue(app.config.Section("template_email").Key("email_html").MustString("jfa-go:" + "template.html"))
app.config.Section("template_email").Key("email_text").SetValue(app.config.Section("template_email").Key("email_text").MustString("jfa-go:" + "template.txt"))
app.config.Section("user_expiry").Key("behaviour").SetValue(app.config.Section("user_expiry").Key("behaviour").MustString("disable_user"))
app.config.Section("user_expiry").Key("email_html").SetValue(app.config.Section("user_expiry").Key("email_html").MustString("jfa-go:" + "user-expired.html"))
app.config.Section("user_expiry").Key("email_text").SetValue(app.config.Section("user_expiry").Key("email_text").MustString("jfa-go:" + "user-expired.txt"))
app.config.Section("jellyfin").Key("version").SetValue(version)
app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))

View File

@ -765,6 +765,63 @@
}
}
},
"user_expiry": {
"order": [],
"meta": {
"name": "User Expiry",
"description": "When set on an invite, users will be deleted or disabled a specified amount of time after they create their account."
},
"settings": {
"behaviour": {
"name": "Behaviour",
"required": false,
"requires_restart": false,
"type": "select",
"options": [
["delete_user", "Delete user"],
["disable_user", "Disable user"]
],
"value": "disable_user",
"description": "Whether to delete or disable users on expiry."
},
"send_email": {
"name": "Send email",
"required": false,
"requires_restart": false,
"type": "bool",
"value": true,
"depends_true": "email|method",
"description": "Send an email when a user's account expires."
},
"subject": {
"name": "Email subject",
"required": false,
"requires_restart": false,
"depends_true": "email|method",
"type": "text",
"value": "",
"description": "Subject of user expiry emails."
},
"email_html": {
"name": "Custom email (HTML)",
"required": false,
"requires_restart": false,
"depends_true": "email|method",
"type": "text",
"value": "",
"description": "Path to custom email html"
},
"email_text": {
"name": "Custom email (plaintext)",
"required": false,
"requires_restart": false,
"depends_true": "email|method",
"type": "text",
"value": "",
"description": "Path to custom email in plain text"
}
}
},
"deletion": {
"order": [],
"meta": {
@ -822,6 +879,14 @@
"value": "",
"description": "Location of stored email addresses (json)."
},
"users": {
"name": "User storage",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Stores users temporarily when a user expiry is set."
},
"ombi_template": {
"name": "Ombi user template",
"required": false,

View File

@ -4,7 +4,7 @@ import "time"
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type repeater struct {
type inviteDaemon struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
@ -12,8 +12,8 @@ type repeater struct {
app *appContext
}
func newRepeater(interval time.Duration, app *appContext) *repeater {
return &repeater{
func newInviteDaemon(interval time.Duration, app *appContext) *inviteDaemon {
return &inviteDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
@ -22,7 +22,7 @@ func newRepeater(interval time.Duration, app *appContext) *repeater {
}
}
func (rt *repeater) run() {
func (rt *inviteDaemon) run() {
rt.app.info.Println("Invite daemon started")
for {
select {
@ -42,7 +42,7 @@ func (rt *repeater) run() {
}
}
func (rt *repeater) shutdown() {
func (rt *inviteDaemon) shutdown() {
rt.Stopped = true
rt.ShutdownChannel <- "Down"
<-rt.ShutdownChannel

View File

@ -569,6 +569,42 @@ func (emailer *Emailer) constructWelcome(username string, app *appContext, noSub
return email, nil
}
func (emailer *Emailer) userExpiredValues(app *appContext, noSub bool) map[string]interface{} {
template := map[string]interface{}{
"yourAccountHasExpired": emailer.lang.UserExpired.get("yourAccountHasExpired"),
"contactTheAdmin": emailer.lang.UserExpired.get("contactTheAdmin"),
"message": "",
}
if !noSub {
template["message"] = app.config.Section("email").Key("message").String()
}
return template
}
func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Email, error) {
email := &Email{
Subject: app.config.Section("user_expiry").Key("subject").MustString(emailer.lang.UserExpired.get("title")),
}
var err error
template := emailer.userExpiredValues(app, noSub)
if app.storage.customEmails.UserExpired.Enabled {
content := app.storage.customEmails.UserExpired.Content
for _, v := range app.storage.customEmails.UserExpired.Variables {
replaceWith, ok := template[v[1:len(v)-1]]
if ok {
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.HTML, email.Text, err = emailer.construct(app, "user_expiry", "email_", template)
}
if err != nil {
return nil, err
}
return email, nil
}
// calls the send method in the underlying emailClient.
func (emailer *Emailer) send(email *Email, address ...string) error {
return emailer.sender.send(emailer.fromName, emailer.fromAddr, email, address...)

View File

@ -9,6 +9,7 @@
window.ombiEnabled = {{ .ombiEnabled }};
window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }});
window.language = "{{ .langName }}";
</script>
{{ template "header.html" . }}
<title>Admin - jfa-go</title>
@ -132,7 +133,7 @@
<span class="heading"><span id="header-editor"></span> <span class="modal-close">&times;</span></span>
<div class="row">
<div class="col flex-col content mt-half">
<span class="label supra" for="editor-variables">{{ .strings.variables }}</span>
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
<div id="editor-variables"></div>
<label class="label supra" for="textarea-editor">{{ .strings.message }}</label>
<textarea id="textarea-editor" class="textarea full-width flex-auto ~neutral !normal mt-half monospace"></textarea>
@ -265,8 +266,8 @@
<span class="button ~neutral !high supra full-width center">{{ .strings.inviteDuration }}</span>
</label>
<label class="flex-row-group ml-1">
<input type="radio" name="duration" class="unfocused" id="radio-user-duration">
<span class="button ~neutral !normal supra full-width center">{{ .strings.userDuration }}</span>
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
<span class="button ~neutral !normal supra full-width center">{{ .strings.userExpiry }}</span>
</label>
</div>
<div id="inv-duration">
@ -289,11 +290,11 @@
</select>
</div>
</div>
<div id="user-duration" class="unfocused">
<p class="support">{{ .strings.userDurationDescription }}</p>
<div id="user-expiry" class="unfocused">
<p class="support">{{ .strings.userExpiryDescription }}</p>
<div class="mb-half">
<label for="create-user-duration-enabled" class="button ~neutral !normal">
<input type="checkbox" id="create-user-duration-enabled" aria-label="User duration enabled">
<label for="create-user-expiry-enabled" class="button ~neutral !normal">
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
<span class="ml-half">{{ .strings.enabled }} </span>
</label>
</div>

View File

@ -7,11 +7,11 @@
window.code = "{{ .code }}";
window.messages = JSON.parse({{ .notifications }});
window.confirmation = {{ .confirmation }};
window.userDurationEnabled = {{ .userDuration }};
window.userDurationDays = {{ .userDurationDays }};
window.userDurationHours = {{ .userDurationHours }};
window.userDurationMinutes = {{ .userDurationMinutes }};
window.userDurationMessage = {{ .userDurationMessage }};
window.userExpiryEnabled = {{ .userExpiry }};
window.userExpiryDays = {{ .userExpiryDays }};
window.userExpiryHours = {{ .userExpiryHours }};
window.userExpiryMinutes = {{ .userExpiryMinutes }};
window.userExpiryMessage = {{ .userExpiryMessage }};
</script>
<script src="js/form.js" type="module"></script>
{{ end }}

View File

@ -37,8 +37,8 @@
</div>
<div class="row">
<div class="col">
{{ if .userDuration }}
<aside class="col aside sm ~warning" id="user-duration-message"></aside>
{{ if .userExpiry }}
<aside class="col aside sm ~warning" id="user-expiry-message"></aside>
{{ end }}
<form class="card ~neutral !normal" id="form-create" href="">
<label class="label supra">

View File

@ -83,6 +83,7 @@ type emailLang struct {
InviteEmail langSection `json:"inviteEmail"`
WelcomeEmail langSection `json:"welcomeEmail"`
EmailConfirmation langSection `json:"emailConfirmation"`
UserExpired langSection `json:"userExpired"`
}
type setupLangs map[string]setupLang

View File

@ -22,11 +22,11 @@
"name": "Name",
"date": "Date",
"enabled": "Enabled",
"userDurationDescription": "A specified amount of time after each signup, jfa-go will delete/disable the account. You can change this behaviour in settings.",
"lastActiveTime": "Last Active",
"from": "From",
"user": "User",
"userDuration": "User Duration",
"userExpiry": "User Expiry",
"userExpiryDescription": "A specified amount of time after each signup, jfa-go will delete/disable the account. You can change this behaviour in settings.",
"aboutProgram": "About",
"version": "Version",
"commitNoun": "Commit",

View File

@ -55,5 +55,11 @@
"title": "Confirm your email - Jellyfin",
"clickBelow": "Click the link below to confirm your email address and start using Jellyfin.",
"confirmEmail": "Confirm Email"
},
"userExpired": {
"name": "User expiry",
"title": "Your account has expired - Jellyfin",
"yourAccountHasExpired": "Your account has expired.",
"contactTheAdmin": "Contact the administrator for more info."
}
}

76
mail/user-expired.mjml Normal file
View File

@ -0,0 +1,76 @@
<mjml>
<mj-head>
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> {{ .jellyfin }} </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>{{ .yourAccountHasExpired }}</h3>
<p>{{ .contactTheAdmin }}</p>
</mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

5
mail/user-expired.txt Normal file
View File

@ -0,0 +1,5 @@
{{ .yourAccountHasExpired }}
{{ .contactTheAdmin }}
{{ .message }}

View File

@ -338,6 +338,8 @@ func start(asDaemon, firstCall bool) {
app.storage.loadConfiguration()
app.storage.displayprefs_path = app.config.Section("files").Key("user_displayprefs").String()
app.storage.loadDisplayprefs()
app.storage.users_path = app.config.Section("files").Key("users").String()
app.storage.loadUsers()
app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String()
app.storage.loadProfiles()
@ -510,9 +512,12 @@ func start(asDaemon, firstCall bool) {
os.Exit(0)
}
inviteDaemon := newRepeater(time.Duration(60*time.Second), app)
inviteDaemon := newInviteDaemon(time.Duration(60*time.Second), app)
go inviteDaemon.run()
userDaemon := newUserDaemon(time.Duration(60*time.Second), app)
go userDaemon.run()
if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer {
go app.StartPWR()
}

View File

@ -33,7 +33,7 @@ type generateInviteDTO struct {
Days int `json:"days" example:"1"` // Number of days
Hours int `json:"hours" example:"2"` // Number of hours
Minutes int `json:"minutes" example:"3"` // Number of minutes
UserDuration bool `json:"user-duration"` // Whether or not user duration is enabled
UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
@ -76,7 +76,7 @@ type inviteDTO struct {
Days int `json:"days" example:"1"` // Number of days till expiry
Hours int `json:"hours" example:"2"` // Number of hours till expiry
Minutes int `json:"minutes" example:"3"` // Number of minutes till expiry
UserDuration bool `json:"user-duration"` // Whether or not user duration is enabled
UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
@ -205,3 +205,7 @@ type customEmailDTO struct {
HTML string `json:"html"`
Plaintext string `json:"plaintext"`
}
type getEmailDTO struct {
Lang string `json:"lang" example:"en-us"` // Language code. If not given, defaults ot one specified in settings.
}

View File

@ -14,16 +14,17 @@ import (
)
type Storage struct {
timePattern string
invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path string
invites Invites
profiles map[string]Profile
defaultProfile string
emails, displayprefs, ombi_template map[string]interface{}
customEmails customEmails
policy mediabrowser.Policy
configuration mediabrowser.Configuration
lang Lang
timePattern string
invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path string
users map[string]time.Time
invites Invites
profiles map[string]Profile
defaultProfile string
emails, displayprefs, ombi_template map[string]interface{}
customEmails customEmails
policy mediabrowser.Policy
configuration mediabrowser.Configuration
lang Lang
}
type customEmails struct {
@ -34,6 +35,7 @@ type customEmails struct {
InviteEmail customEmail `json:"inviteEmail"`
WelcomeEmail customEmail `json:"welcomeEmail"`
EmailConfirmation customEmail `json:"emailConfirmation"`
UserExpired customEmail `json:"userExpired"`
}
type customEmail struct {
@ -59,7 +61,7 @@ type Invite struct {
NoLimit bool `json:"no-limit"`
RemainingUses int `json:"remaining-uses"`
ValidTill time.Time `json:"valid_till"`
UserDuration bool `json:"user-duration"`
UserExpiry bool `json:"user-duration"`
UserDays int `json:"user-days,omitempty"`
UserHours int `json:"user-hours,omitempty"`
UserMinutes int `json:"user-minutes,omitempty"`
@ -407,6 +409,14 @@ func (st *Storage) storeInvites() error {
return storeJSON(st.invite_path, st.invites)
}
func (st *Storage) loadUsers() error {
return loadJSON(st.users_path, &st.users)
}
func (st *Storage) storeUsers() error {
return storeJSON(st.users_path, st.users)
}
func (st *Storage) loadEmails() error {
return loadJSON(st.emails_path, &st.emails)
}

View File

@ -10,11 +10,11 @@ interface formWindow extends Window {
messages: { [key: string]: string };
confirmation: boolean;
confirmationModal: Modal
userDurationEnabled: boolean;
userDurationDays: number;
userDurationHours: number;
userDurationMinutes: number;
userDurationMessage: string;
userExpiryEnabled: boolean;
userExpiryDays: number;
userExpiryHours: number;
userExpiryMinutes: number;
userExpiryMessage: string;
}
interface pwValString {
@ -39,14 +39,14 @@ if (window.confirmation) {
}
declare var window: formWindow;
if (window.userDurationEnabled) {
const messageEl = document.getElementById("user-duration-message") as HTMLElement;
if (window.userExpiryEnabled) {
const messageEl = document.getElementById("user-expiry-message") as HTMLElement;
const calculateTime = () => {
let time = new Date()
time.setDate(time.getDate() + window.userDurationDays);
time.setHours(time.getHours() + window.userDurationHours);
time.setMinutes(time.getMinutes() + window.userDurationMinutes);
messageEl.textContent = window.userDurationMessage.replace("{date}", time.toDateString() + " " + time.toLocaleTimeString());
time.setDate(time.getDate() + window.userExpiryDays);
time.setHours(time.getHours() + window.userExpiryHours);
time.setMinutes(time.getMinutes() + window.userExpiryMinutes);
messageEl.textContent = window.userExpiryMessage.replace("{date}", time.toDateString() + " " + time.toLocaleTimeString());
setTimeout(calculateTime, 1000);
};
calculateTime();

View File

@ -59,20 +59,20 @@ export class DOMInvite implements Invite {
get expiresIn(): string { return this._expiresIn }
set expiresIn(expiry: string) {
this._expiresIn = expiry;
this._infoArea.querySelector("span.inv-expiry").textContent = expiry;
this._infoArea.querySelector("span.inv-duration").textContent = expiry;
}
private _userDuration: string;
get userDurationTime(): string { return this._userDuration; }
set userDurationTime(d: string) {
const duration = this._middle.querySelector("span.user-duration") as HTMLSpanElement;
private _userExpiry: string;
get userExpiryTime(): string { return this._userExpiry; }
set userExpiryTime(d: string) {
const expiry = this._middle.querySelector("span.user-expiry") as HTMLSpanElement;
if (!d) {
duration.textContent = "";
expiry.textContent = "";
} else {
duration.textContent = window.lang.strings("userDuration");
expiry.textContent = window.lang.strings("userExpiry");
}
this._userDuration = d;
this._middle.querySelector("strong.user-duration-time").textContent = d;
this._userExpiry = d;
this._middle.querySelector("strong.user-expiry-time").textContent = d;
}
private _remainingUses: string = "1";
@ -278,7 +278,7 @@ export class DOMInvite implements Invite {
<span class="inv-email-chip"><i></i></span>
<span class="content sm"></span>
</div>
<span class="inv-expiry mr-1"></span>
<span class="inv-duration mr-1"></span>
<span class="button ~critical !normal inv-delete">${window.lang.strings("delete")}</span>
<label>
<i class="icon clickable ri-arrow-down-s-line not-rotated"></i>
@ -344,7 +344,7 @@ export class DOMInvite implements Invite {
this._middle.innerHTML = `
<p class="supra mb-1 top">${window.lang.strings("inviteDateCreated")} <strong class="inv-created"></strong></p>
<p class="supra mb-1">${window.lang.strings("inviteRemainingUses")} <strong class="inv-remaining"></strong></p>
<p class="supra mb-1"><span class="user-duration"></span> <strong class="user-duration-time"></strong></p>
<p class="supra mb-1"><span class="user-expiry"></span> <strong class="user-expiry-time"></strong></p>
`;
this._right = document.createElement('div') as HTMLDivElement;
@ -376,7 +376,7 @@ export class DOMInvite implements Invite {
if (invite.label) {
this.label = invite.label;
}
this.userDurationTime = invite.userDurationTime || "";
this.userExpiryTime = invite.userExpiryTime || "";
}
asElement = (): HTMLDivElement => { return this._container; }
@ -477,16 +477,16 @@ function parseInvite(invite: { [f: string]: string | number | string[][] | boole
parsed.email = invite["email"] as string || "";
parsed.label = invite["label"] as string || "";
let time = "";
let userDurationTime = "";
let userExpiryTime = "";
const fields = ["days", "hours", "minutes"];
let prefixes = [""];
if (invite["user-duration"] as boolean) { prefixes.push("user-"); }
if (invite["user-expiry"] as boolean) { prefixes.push("user-"); }
for (let i = 0; i < fields.length; i++) {
for (let j = 0; j < prefixes.length; j++) {
if (invite[prefixes[j]+fields[i]]) {
let text = `${invite[prefixes[j]+fields[i]]}${fields[i][0]} `;
if (prefixes[j] == "user-") {
userDurationTime += text;
userExpiryTime += text;
} else {
time += text;
}
@ -494,8 +494,8 @@ function parseInvite(invite: { [f: string]: string | number | string[][] | boole
}
}
parsed.expiresIn = window.lang.var("strings", "inviteExpiresInTime", time.slice(0, -1));
parsed.userDuration = invite["user-duration"] as boolean;
parsed.userDurationTime = userDurationTime.slice(0, -1);
parsed.userExpiry = invite["user-expiry"] as boolean;
parsed.userExpiryTime = userExpiryTime.slice(0, -1);
parsed.remainingUses = invite["no-limit"] ? "∞" : String(invite["remaining-uses"])
parsed.usedBy = invite["used-by"] as string[][] || [];
parsed.created = invite["created"] as string || window.lang.strings("unknown");
@ -508,7 +508,7 @@ function parseInvite(invite: { [f: string]: string | number | string[][] | boole
export class createInvite {
private _sendToEnabled = document.getElementById("create-send-to-enabled") as HTMLInputElement;
private _sendTo = document.getElementById("create-send-to") as HTMLInputElement;
private _userDurationToggle = document.getElementById("create-user-duration-enabled") as HTMLInputElement;
private _userExpiryToggle = document.getElementById("create-user-expiry-enabled") as HTMLInputElement;
private _uses = document.getElementById('create-uses') as HTMLInputElement;
private _infUses = document.getElementById("create-inf-uses") as HTMLInputElement;
private _infUsesWarning = document.getElementById('create-inf-uses-warning') as HTMLParagraphElement;
@ -524,9 +524,9 @@ export class createInvite {
private _userMinutes = document.getElementById("user-minutes") as HTMLSelectElement;
private _invDurationButton = document.getElementById('radio-inv-duration') as HTMLInputElement;
private _userDurationButton = document.getElementById('radio-user-duration') as HTMLInputElement;
private _userExpiryButton = document.getElementById('radio-user-expiry') as HTMLInputElement;
private _invDuration = document.getElementById('inv-duration');
private _userDuration = document.getElementById('user-duration');
private _userExpiry = document.getElementById('user-expiry');
// Broadcast when new invite created
private _newInviteEvent = new CustomEvent("newInviteEvent");
@ -619,11 +619,11 @@ export class createInvite {
this._minutes.value = ""+n;
this._checkDurationValidity();
}
get userDuration(): boolean {
return this._userDurationToggle.checked;
get userExpiry(): boolean {
return this._userExpiryToggle.checked;
}
set userDuration(enabled: boolean) {
this._userDurationToggle.checked = enabled;
set userExpiry(enabled: boolean) {
this._userExpiryToggle.checked = enabled;
this._userDays.disabled = !enabled;
this._userHours.disabled = !enabled;
this._userMinutes.disabled = !enabled;
@ -679,15 +679,15 @@ export class createInvite {
create = () => {
toggleLoader(this._createButton);
let userDuration = this.userDuration;
let userExpiry = this.userExpiry;
if (this.userDays == 0 && this.userHours == 0 && this.userMinutes == 0) {
userDuration = false;
userExpiry = false;
}
let send = {
"days": this.days,
"hours": this.hours,
"minutes": this.minutes,
"user-duration": userDuration,
"user-expiry": userExpiry,
"user-days": this.userDays,
"user-hours": this.userHours,
"user-minutes": this.userMinutes,
@ -716,8 +716,8 @@ export class createInvite {
this._infUses.onchange = () => { this.infiniteUses = this.infiniteUses; };
this.infiniteUses = false;
this._sendToEnabled.onchange = () => { this.sendToEnabled = this.sendToEnabled; };
this.userDuration = false;
this._userDurationToggle.onchange = () => { this.userDuration = this._userDurationToggle.checked; }
this.userExpiry = false;
this._userExpiryToggle.onchange = () => { this.userExpiry = this._userExpiryToggle.checked; }
this._userDays.disabled = true;
this._userHours.disabled = true;
this._userMinutes.disabled = true;
@ -728,18 +728,17 @@ export class createInvite {
this.label = "";
const checkDuration = () => {
console.log("bbbb")
const invSpan = this._invDurationButton.nextElementSibling as HTMLSpanElement;
const userSpan = this._userDurationButton.nextElementSibling as HTMLSpanElement;
const userSpan = this._userExpiryButton.nextElementSibling as HTMLSpanElement;
if (this._invDurationButton.checked) {
this._invDuration.classList.remove("unfocused");
this._userDuration.classList.add("unfocused");
this._userExpiry.classList.add("unfocused");
invSpan.classList.add("!high");
invSpan.classList.remove("!normal");
userSpan.classList.add("!normal");
userSpan.classList.remove("!high");
} else if (this._userDurationButton.checked) {
this._userDuration.classList.remove("unfocused");
} else if (this._userExpiryButton.checked) {
this._userExpiry.classList.remove("unfocused");
this._invDuration.classList.add("unfocused");
invSpan.classList.add("!normal");
invSpan.classList.remove("!high");
@ -748,9 +747,9 @@ export class createInvite {
}
};
this._userDurationButton.checked = false;
this._userExpiryButton.checked = false;
this._invDurationButton.checked = true;
this._userDurationButton.onchange = checkDuration;
this._userExpiryButton.onchange = checkDuration;
this._invDurationButton.onchange = checkDuration;
this._days.onchange = this._checkDurationValidity;

View File

@ -694,6 +694,7 @@ class EmailEditor {
private _form = document.getElementById("form-editor") as HTMLFormElement;
private _header = document.getElementById("header-editor") as HTMLSpanElement;
private _variables = document.getElementById("editor-variables") as HTMLDivElement;
private _variablesLabel = document.getElementById("label-editor-variables") as HTMLElement;
private _textArea = document.getElementById("textarea-editor") as HTMLTextAreaElement;
private _preview = document.getElementById("editor-preview") as HTMLDivElement;
private _previewContent: HTMLElement;
@ -745,6 +746,11 @@ class EmailEditor {
let ci = i % colors.length;
innerHTML += '<span class="button ~' + colors[ci] +' !normal mb-1" style="margin-left: 0.25rem; margin-right: 0.25rem;"></span>'
}
if (this._templ.variables.length == 0) {
this._variablesLabel.classList.add("unfocused");
} else {
this._variablesLabel.classList.remove("unfocused");
}
this._variables.innerHTML = innerHTML
const buttons = this._variables.querySelectorAll("span.button") as NodeListOf<HTMLSpanElement>;
for (let i = 0; i < this._templ.variables.length; i++) {
@ -761,10 +767,12 @@ class EmailEditor {
}
loadPreview = () => {
let content = this._textArea.value;
for (let variable of this._templ.variables) {
let value = this._templ.values[variable.slice(1, -1)];
if (value === undefined) { value = variable; }
content = content.replace(new RegExp(variable, "g"), value);
if (this._templ.variables) {
for (let variable of this._templ.variables) {
let value = this._templ.values[variable.slice(1, -1)];
if (value === undefined) { value = variable; }
content = content.replace(new RegExp(variable, "g"), value);
}
}
if (this._templ.html == "") {
content = stripMarkdown(content);
@ -785,7 +793,7 @@ class EmailEditor {
}
showList = () => {
_get("/config/emails", null, (req: XMLHttpRequest) => {
_get("/config/emails?lang=" + window.language, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200) {
window.notifications.customError("loadTemplateError", window.lang.notif("errorFailureCheckLogs"));

113
userdaemon.go Normal file
View File

@ -0,0 +1,113 @@
package main
import (
"time"
"github.com/hrfee/jfa-go/mediabrowser"
)
type userDaemon struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
period time.Duration
app *appContext
}
func newUserDaemon(interval time.Duration, app *appContext) *userDaemon {
return &userDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
}
func (rt *userDaemon) run() {
rt.app.info.Println("User daemon started")
for {
select {
case <-rt.ShutdownChannel:
rt.ShutdownChannel <- "Down"
return
case <-time.After(rt.period):
break
}
started := time.Now()
rt.app.storage.loadInvites()
rt.app.checkUsers()
finished := time.Now()
duration := finished.Sub(started)
rt.period = rt.Interval - duration
}
}
func (app *appContext) checkUsers() {
if len(app.storage.users) == 0 {
return
}
app.info.Println("Daemon: Checking for user expiry")
users, status, err := app.jf.GetUsers(false)
if err != nil || status != 200 {
app.err.Printf("Failed to get users (%d): %s", status, err)
return
}
mode := "disable"
termPlural := "Disabling"
if app.config.Section("user_expiry").Key("behaviour").MustString("disable_user") == "delete_user" {
mode = "delete"
termPlural = "Deleting"
}
email := false
if emailEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) {
email = true
}
for id, expiry := range app.storage.users {
if time.Now().After(expiry) {
found := false
var user mediabrowser.User
for _, u := range users {
if u.ID == id {
found = true
user = u
break
}
}
if !found {
app.info.Printf("Expired user already deleted, ignoring.")
continue
}
app.info.Printf("%s expired user \"%s\"", termPlural, user.Name)
if mode == "delete" {
status, err = app.jf.DeleteUser(id)
} else if mode == "disable" {
user.Policy.IsDisabled = true
status, err = app.jf.SetPolicy(id, user.Policy)
}
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to %s \"%s\" (%d): %s", mode, user.Name, status, err)
continue
}
delete(app.storage.users, id)
if email {
address, ok := app.storage.emails[id]
if !ok {
continue
}
msg, err := app.email.constructUserExpired(app, false)
if err != nil {
app.err.Printf("Failed to construct expiry email for \"%s\": %s", user.Name, err)
} else if err := app.email.send(msg, address.(string)); err != nil {
app.err.Printf("Failed to send expiry email to \"%s\": %s", user.Name, err)
} else {
app.info.Printf("Sent expiry notification to \"%s\"", address.(string))
}
}
}
}
err = app.storage.storeUsers()
if err != nil {
app.err.Printf("Failed to store user duration: %s", err)
}
}

View File

@ -81,6 +81,7 @@ func (app *appContext) AdminPage(gc *gin.Context) {
"strings": app.storage.lang.Admin[lang].Strings,
"quantityStrings": app.storage.lang.Admin[lang].QuantityStrings,
"language": app.storage.lang.Admin[lang].JSON,
"langName": lang,
})
}
@ -167,26 +168,26 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
email = ""
}
gcHTML(gc, http.StatusOK, "form-loader.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"contactMessage": app.config.Section("ui").Key("contact_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": !app.config.Section("email").Key("no_username").MustBool(false),
"strings": app.storage.lang.Form[lang].Strings,
"validationStrings": app.storage.lang.Form[lang].validationStringsJSON,
"notifications": app.storage.lang.Form[lang].notificationsJSON,
"code": code,
"confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false),
"userDuration": inv.UserDuration,
"userDurationDays": inv.UserDays,
"userDurationHours": inv.UserHours,
"userDurationMinutes": inv.UserMinutes,
"userDurationMessage": app.storage.lang.Form[lang].Strings.get("yourAccountIsValidUntil"),
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"contactMessage": app.config.Section("ui").Key("contact_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": !app.config.Section("email").Key("no_username").MustBool(false),
"strings": app.storage.lang.Form[lang].Strings,
"validationStrings": app.storage.lang.Form[lang].validationStringsJSON,
"notifications": app.storage.lang.Form[lang].notificationsJSON,
"code": code,
"confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false),
"userExpiry": inv.UserExpiry,
"userExpiryDays": inv.UserDays,
"userExpiryHours": inv.UserHours,
"userExpiryMinutes": inv.UserMinutes,
"userExpiryMessage": app.storage.lang.Form[lang].Strings.get("yourAccountIsValidUntil"),
})
}