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

Compare commits

...

4 Commits

Author SHA1 Message Date
e5f79c60ae
webhooks: add "user created" webhook
Webhooks send a POST to an admin-supplied URL when something happens,
with relevant information sent in JSON. One has been added for creating
users in Settings > Webhooks > User Created.

Lazily, the portion of GetUsers which generates a respUser has been
factored out, and is called to send the JSON payload.

A stripped-down common.Req method has been added, which is used by the
barebones WebhookSender struct.
2024-08-20 21:45:31 +01:00
8307d3da90
proxy: use for updater
don't know how I missed before.
2024-08-20 20:38:18 +01:00
6bad293f74
config: add support for "list" type
"list" is a list of strings, represented in the .ini as repeated entries
for a field, e.g.
url = myurl1
url = myurl2
Shown in the UI as multiple inputs with delete buttons.
2024-08-20 20:19:32 +01:00
b2771e6cc5
auth: source cookie hostname from jfa_url
instead of just applying the cookie to the hostname you accessed jfa-go
on, it is applied to the one you set in jfa-go. The result is you'll
have to login twice if you access on localhost:8056 instead
of accounts.jellyf.in.
2024-08-13 20:39:06 +01:00
21 changed files with 1821 additions and 769 deletions

View File

@ -585,7 +585,7 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
tempConfig, _ := ini.Load(app.configPath) tempConfig, _ := ini.ShadowLoad(app.configPath)
matrix := tempConfig.Section("matrix") matrix := tempConfig.Section("matrix")
matrix.Key("enabled").SetValue("true") matrix.Key("enabled").SetValue("true")
matrix.Key("homeserver").SetValue(req.Homeserver) matrix.Key("homeserver").SetValue(req.Homeserver)

View File

@ -42,7 +42,7 @@ func (app *appContext) NewUserFromAdmin(gc *gin.Context) {
profile = p profile = p
} }
} }
nu := app.NewUserPostVerification(NewUserParams{ nu /*wg*/, _ := app.NewUserPostVerification(NewUserParams{
Req: req, Req: req,
SourceType: ActivityAdmin, SourceType: ActivityAdmin,
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
@ -59,6 +59,8 @@ func (app *appContext) NewUserFromAdmin(gc *gin.Context) {
} }
respondUser(nu.Status, nu.Created, welcomeMessageSentIfNecessary, nu.Message, gc) respondUser(nu.Status, nu.Created, welcomeMessageSentIfNecessary, nu.Message, gc)
// These don't need to complete anytime soon
// wg.Wait()
} }
// @Summary Creates a new Jellyfin user via invite code // @Summary Creates a new Jellyfin user via invite code
@ -205,8 +207,7 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) {
profile = &p profile = &p
} }
// FIXME: Use NewUserPostVerification nu /*wg*/, _ := app.NewUserPostVerification(NewUserParams{
nu := app.NewUserPostVerification(NewUserParams{
Req: req, Req: req,
SourceType: sourceType, SourceType: sourceType,
Source: source, Source: source,
@ -341,7 +342,10 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) {
code = 400 code = 400
} }
} }
gc.JSON(code, validation) gc.JSON(code, validation)
// These don't need to complete anytime soon
// wg.Wait()
} }
// @Summary Enable/Disable a list of users, optionally notifying them why. // @Summary Enable/Disable a list of users, optionally notifying them why.
@ -822,6 +826,60 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) {
respondBool(204, true, gc) respondBool(204, true, gc)
} }
func (app *appContext) userSummary(jfUser mediabrowser.User) respUser {
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
allowAll := app.config.Section("ui").Key("allow_all").MustBool(false)
referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false)
user := respUser{
ID: jfUser.ID,
Name: jfUser.Name,
Admin: jfUser.Policy.IsAdministrator,
Disabled: jfUser.Policy.IsDisabled,
ReferralsEnabled: false,
}
if !jfUser.LastActivityDate.IsZero() {
user.LastActive = jfUser.LastActivityDate.Unix()
}
if email, ok := app.storage.GetEmailsKey(jfUser.ID); ok {
user.Email = email.Addr
user.NotifyThroughEmail = email.Contact
user.Label = email.Label
user.AccountsAdmin = (app.jellyfinLogin) && (email.Admin || (adminOnly && jfUser.Policy.IsAdministrator) || allowAll)
}
expiry, ok := app.storage.GetUserExpiryKey(jfUser.ID)
if ok {
user.Expiry = expiry.Expiry.Unix()
}
if tgUser, ok := app.storage.GetTelegramKey(jfUser.ID); ok {
user.Telegram = tgUser.Username
user.NotifyThroughTelegram = tgUser.Contact
}
if mxUser, ok := app.storage.GetMatrixKey(jfUser.ID); ok {
user.Matrix = mxUser.UserID
user.NotifyThroughMatrix = mxUser.Contact
}
if dcUser, ok := app.storage.GetDiscordKey(jfUser.ID); ok {
user.Discord = RenderDiscordUsername(dcUser)
// user.Discord = dcUser.Username + "#" + dcUser.Discriminator
user.DiscordID = dcUser.ID
user.NotifyThroughDiscord = dcUser.Contact
}
// FIXME: Send referral data
referrerInv := Invite{}
if referralsEnabled {
// 1. Directly attached invite.
err := app.storage.db.FindOne(&referrerInv, badgerhold.Where("ReferrerJellyfinID").Eq(jfUser.ID))
if err == nil {
user.ReferralsEnabled = true
// 2. Referrals via profile template. Shallow check, doesn't look for the thing in the database.
} else if email, ok := app.storage.GetEmailsKey(jfUser.ID); ok && email.ReferralTemplateKey != "" {
user.ReferralsEnabled = true
}
}
return user
}
// @Summary Get a list of Jellyfin users. // @Summary Get a list of Jellyfin users.
// @Produce json // @Produce json
// @Success 200 {object} getUsersDTO // @Success 200 {object} getUsersDTO
@ -838,57 +896,9 @@ func (app *appContext) GetUsers(gc *gin.Context) {
respond(500, "Couldn't get users", gc) respond(500, "Couldn't get users", gc)
return return
} }
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
allowAll := app.config.Section("ui").Key("allow_all").MustBool(false)
referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false)
i := 0 i := 0
for _, jfUser := range users { for _, jfUser := range users {
user := respUser{ user := app.userSummary(jfUser)
ID: jfUser.ID,
Name: jfUser.Name,
Admin: jfUser.Policy.IsAdministrator,
Disabled: jfUser.Policy.IsDisabled,
ReferralsEnabled: false,
}
if !jfUser.LastActivityDate.IsZero() {
user.LastActive = jfUser.LastActivityDate.Unix()
}
if email, ok := app.storage.GetEmailsKey(jfUser.ID); ok {
user.Email = email.Addr
user.NotifyThroughEmail = email.Contact
user.Label = email.Label
user.AccountsAdmin = (app.jellyfinLogin) && (email.Admin || (adminOnly && jfUser.Policy.IsAdministrator) || allowAll)
}
expiry, ok := app.storage.GetUserExpiryKey(jfUser.ID)
if ok {
user.Expiry = expiry.Expiry.Unix()
}
if tgUser, ok := app.storage.GetTelegramKey(jfUser.ID); ok {
user.Telegram = tgUser.Username
user.NotifyThroughTelegram = tgUser.Contact
}
if mxUser, ok := app.storage.GetMatrixKey(jfUser.ID); ok {
user.Matrix = mxUser.UserID
user.NotifyThroughMatrix = mxUser.Contact
}
if dcUser, ok := app.storage.GetDiscordKey(jfUser.ID); ok {
user.Discord = RenderDiscordUsername(dcUser)
// user.Discord = dcUser.Username + "#" + dcUser.Discriminator
user.DiscordID = dcUser.ID
user.NotifyThroughDiscord = dcUser.Contact
}
// FIXME: Send referral data
referrerInv := Invite{}
if referralsEnabled {
// 1. Directly attached invite.
err := app.storage.db.FindOne(&referrerInv, badgerhold.Where("ReferrerJellyfinID").Eq(jfUser.ID))
if err == nil {
user.ReferralsEnabled = true
// 2. Referrals via profile template. Shallow check, doesn't look for the thing in the database.
} else if email, ok := app.storage.GetEmailsKey(jfUser.ID); ok && email.ReferralTemplateKey != "" {
user.ReferralsEnabled = true
}
}
resp.UserList[i] = user resp.UserList[i] = user
i++ i++
} }

17
api.go
View File

@ -290,6 +290,8 @@ func (app *appContext) GetConfig(gc *gin.Context) {
val := app.config.Section(sectName).Key(settingName) val := app.config.Section(sectName).Key(settingName)
s := resp.Sections[sectName].Settings[settingName] s := resp.Sections[sectName].Settings[settingName]
switch setting.Type { switch setting.Type {
case "list":
s.Value = val.StringsWithShadows("|")
case "text", "email", "select", "password", "note": case "text", "email", "select", "password", "note":
s.Value = val.MustString("") s.Value = val.MustString("")
case "number": case "number":
@ -327,7 +329,7 @@ func (app *appContext) GetConfig(gc *gin.Context) {
// @Summary Modify app config. // @Summary Modify app config.
// @Produce json // @Produce json
// @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings." // @Param appConfig body configDTO true "Config split into sections as in config.ini, all values as strings (lists split with | delimiter)."
// @Success 200 {object} boolResponse // @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse // @Failure 500 {object} stringResponse
// @Router /config [post] // @Router /config [post]
@ -337,7 +339,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
var req configDTO var req configDTO
gc.BindJSON(&req) gc.BindJSON(&req)
// Load a new config, as we set various default values in app.config that shouldn't be stored. // Load a new config, as we set various default values in app.config that shouldn't be stored.
tempConfig, _ := ini.Load(app.configPath) tempConfig, _ := ini.ShadowLoad(app.configPath)
for section, settings := range req { for section, settings := range req {
if section != "restart-program" { if section != "restart-program" {
_, err := tempConfig.GetSection(section) _, err := tempConfig.GetSection(section)
@ -350,6 +352,17 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
} }
if (section == "discord" || section == "matrix") && setting == "language" { if (section == "discord" || section == "matrix") && setting == "language" {
tempConfig.Section("telegram").Key("language").SetValue(value.(string)) tempConfig.Section("telegram").Key("language").SetValue(value.(string))
} else if app.configBase.Sections[section].Settings[setting].Type == "list" {
splitValues := strings.Split(value.(string), "|")
// Delete the key first to get rid of any shadow values
tempConfig.Section(section).DeleteKey(setting)
for i, v := range splitValues {
if i == 0 {
tempConfig.Section(section).Key(setting).SetValue(v)
} else {
tempConfig.Section(section).Key(setting).AddShadow(v)
}
}
} else if value.(string) != app.config.Section(section).Key(setting).MustString("") { } else if value.(string) != app.config.Section(section).Key(setting).MustString("") {
tempConfig.Section(section).Key(setting).SetValue(value.(string)) tempConfig.Section(section).Key(setting).SetValue(value.(string))
} }

View File

@ -248,7 +248,9 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
respond(500, "Couldn't generate token", gc) respond(500, "Couldn't generate token", gc)
return return
} }
host := gc.Request.URL.Hostname() // host := gc.Request.URL.Hostname()
host := app.ExternalDomain
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true) gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{token}) gc.JSON(200, getTokenDTO{token})
} }
@ -307,7 +309,8 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
respond(500, "Couldn't generate token", gc) respond(500, "Couldn't generate token", gc)
return return
} }
host := gc.Request.URL.Hostname() // host := gc.Request.URL.Hostname()
host := app.ExternalDomain
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true) gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{jwt}) gc.JSON(200, getTokenDTO{jwt})
} }

View File

@ -1,10 +1,16 @@
package common package common
import ( import (
"bytes"
"compress/gzip"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"net/url"
"strings"
lm "github.com/hrfee/jfa-go/logmessages" lm "github.com/hrfee/jfa-go/logmessages"
) )
@ -77,3 +83,68 @@ type ConfigurableTransport interface {
// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy. // SetTransport sets the http.Transport to use for requests. Can be used to set a proxy.
SetTransport(t *http.Transport) SetTransport(t *http.Transport)
} }
// Stripped down-ish version of rough http request function used in most of the API clients.
func Req(httpClient *http.Client, timeoutHandler TimeoutHandler, mode string, uri string, data any, queryParams url.Values, headers map[string]string, response bool) (string, int, error) {
var params []byte
if data != nil {
params, _ = json.Marshal(data)
}
if qp := queryParams.Encode(); qp != "" {
uri += "?" + qp
}
var req *http.Request
if data != nil {
req, _ = http.NewRequest(mode, uri, bytes.NewBuffer(params))
} else {
req, _ = http.NewRequest(mode, uri, nil)
}
req.Header.Add("Content-Type", "application/json")
for name, value := range headers {
req.Header.Add(name, value)
}
resp, err := httpClient.Do(req)
if resp == nil {
return "", 0, err
}
err = GenericErr(resp.StatusCode, err)
if timeoutHandler != nil {
defer timeoutHandler()
}
var responseText string
defer resp.Body.Close()
if response || err != nil {
responseText, err = decodeResp(resp)
if err != nil {
return responseText, resp.StatusCode, err
}
}
if err != nil {
var msg any
err = json.Unmarshal([]byte(responseText), &msg)
if err != nil {
return responseText, resp.StatusCode, err
}
if msg != nil {
err = fmt.Errorf("got %d: %+v", resp.StatusCode, msg)
}
return responseText, resp.StatusCode, err
}
return responseText, resp.StatusCode, err
}
func decodeResp(resp *http.Response) (string, error) {
var out io.Reader
switch resp.Header.Get("Content-Encoding") {
case "gzip":
out, _ = gzip.NewReader(resp.Body)
default:
out = resp.Body
}
buf := new(strings.Builder)
_, err := io.Copy(buf, out)
if err != nil {
return "", err
}
return buf.String(), nil
}

View File

@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"io/fs" "io/fs"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@ -35,7 +36,7 @@ func (app *appContext) MustSetValue(section, key, val string) {
func (app *appContext) loadConfig() error { func (app *appContext) loadConfig() error {
var err error var err error
app.config, err = ini.Load(app.configPath) app.config, err = ini.ShadowLoad(app.configPath)
if err != nil { if err != nil {
return err return err
} }
@ -60,10 +61,17 @@ func (app *appContext) loadConfig() error {
if app.URLBase == "/invite" || app.URLBase == "/accounts" || app.URLBase == "/settings" || app.URLBase == "/activity" { if app.URLBase == "/invite" || app.URLBase == "/accounts" || app.URLBase == "/settings" || app.URLBase == "/activity" {
app.err.Printf(lm.BadURLBase, app.URLBase) app.err.Printf(lm.BadURLBase, app.URLBase)
} }
app.ExternalHost = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/") app.ExternalURI = strings.TrimSuffix(strings.TrimSuffix(app.config.Section("ui").Key("jfa_url").MustString(""), "/invite"), "/")
if !strings.HasSuffix(app.ExternalHost, app.URLBase) { if !strings.HasSuffix(app.ExternalURI, app.URLBase) {
app.err.Println(lm.NoURLSuffix) app.err.Println(lm.NoURLSuffix)
} }
if app.ExternalURI == "" {
app.err.Println(lm.NoExternalHost + lm.LoginWontSave)
}
u, err := url.Parse(app.ExternalURI)
if err == nil {
app.ExternalDomain = u.Hostname()
}
app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.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)))

View File

@ -2017,6 +2017,24 @@
} }
} }
}, },
"webhooks": {
"order": [],
"meta": {
"name": "Webhooks",
"description": "jfa-go will send a POST request to these URLs when an event occurs, with relevant information. Request information is logged when debug logging is enabled.",
"wiki_link": "https://wiki.jfa-go.com/docs/webhooks/"
},
"settings": {
"created": {
"name": "User Created",
"required": false,
"requires_restart": false,
"type": "list",
"value": "",
"description": "URLs to hit when an account is created through jfa-go. Sends a `respUser` object."
}
}
},
"files": { "files": {
"order": [], "order": [],
"meta": { "meta": {

View File

@ -325,7 +325,7 @@ func (emailer *Emailer) confirmationValues(code, username, key string, app *appC
} }
} else { } else {
message := app.config.Section("messages").Key("message").String() message := app.config.Section("messages").Key("message").String()
inviteLink := app.ExternalHost inviteLink := app.ExternalURI
if code == "" { // Personal email change if code == "" { // Personal email change
inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key)) inviteLink = fmt.Sprintf("%s/my/confirm/%s", inviteLink, url.PathEscape(key))
} else { // Invite email confirmation } else { // Invite email confirmation
@ -393,7 +393,7 @@ func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext
expiry := invite.ValidTill expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern) d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
message := app.config.Section("messages").Key("message").String() message := app.config.Section("messages").Key("message").String()
inviteLink := fmt.Sprintf("%s/invite/%s", app.ExternalHost, code) inviteLink := fmt.Sprintf("%s/invite/%s", app.ExternalURI, code)
template := map[string]interface{}{ template := map[string]interface{}{
"hello": emailer.lang.InviteEmail.get("hello"), "hello": emailer.lang.InviteEmail.get("hello"),
"youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"), "youHaveBeenInvited": emailer.lang.InviteEmail.get("youHaveBeenInvited"),

View File

@ -1,3 +1,4 @@
<!DOCTYPE html>
<html lang="en" class="light"> <html lang="en" class="light">
<head> <head>
<link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css"> <link rel="stylesheet" type="text/css" href="{{ .urlBase }}/css/{{ .cssVersion }}bundle.css">

View File

@ -210,6 +210,7 @@ const (
NoURLSuffix = `Warning: Given "jfa_url"/"External jfa-go URL" value does not include "url_base" value!` NoURLSuffix = `Warning: Given "jfa_url"/"External jfa-go URL" value does not include "url_base" value!`
BadURLBase = `Warning: Given URL Base "%s" may conflict with the applications subpaths.` BadURLBase = `Warning: Given URL Base "%s" may conflict with the applications subpaths.`
NoExternalHost = `No "External jfa-go URL" provided, set one in Settings > General.` NoExternalHost = `No "External jfa-go URL" provided, set one in Settings > General.`
LoginWontSave = ` Your login won't save until you do.`
// discord.go // discord.go
StartDaemon = "Started %s daemon" StartDaemon = "Started %s daemon"
@ -306,6 +307,9 @@ const (
CaptchaNotFound = "Captcha \"%s\" not found in invite \"%s\"" CaptchaNotFound = "Captcha \"%s\" not found in invite \"%s\""
FailedVerifyReCAPTCHA = "Failed to verify reCAPTCHA: %v" FailedVerifyReCAPTCHA = "Failed to verify reCAPTCHA: %v"
InvalidHostname = "invalid hostname (wanted \"%s\", got \"%s\")" InvalidHostname = "invalid hostname (wanted \"%s\", got \"%s\")"
// webhooks.go
WebhookRequest = "Webhook request send to \"%s\" (%d): %v"
) )
const ( const (

72
main.go
View File

@ -101,36 +101,37 @@ type appContext struct {
adminUsers []User adminUsers []User
invalidTokens []string invalidTokens []string
// Keeping jf name because I can't think of a better one // Keeping jf name because I can't think of a better one
jf *mediabrowser.MediaBrowser jf *mediabrowser.MediaBrowser
authJf *mediabrowser.MediaBrowser authJf *mediabrowser.MediaBrowser
ombi *OmbiWrapper ombi *OmbiWrapper
js *JellyseerrWrapper js *JellyseerrWrapper
thirdPartyServices []ThirdPartyService thirdPartyServices []ThirdPartyService
datePattern string datePattern string
timePattern string timePattern string
storage Storage storage Storage
validator Validator validator Validator
email *Emailer email *Emailer
telegram *TelegramDaemon telegram *TelegramDaemon
discord *DiscordDaemon discord *DiscordDaemon
matrix *MatrixDaemon matrix *MatrixDaemon
contactMethods []ContactMethodLinker contactMethods []ContactMethodLinker
info, debug, err *logger.Logger info, debug, err *logger.Logger
host string host string
port int port int
version string version string
URLBase, ExternalHost string URLBase, ExternalURI, ExternalDomain string
updater *Updater updater *Updater
newUpdate bool // Whether whatever's in update is new. webhooks *WebhookSender
tag Tag newUpdate bool // Whether whatever's in update is new.
update Update tag Tag
proxyEnabled bool update Update
proxyTransport *http.Transport proxyEnabled bool
proxyConfig easyproxy.ProxyConfig proxyTransport *http.Transport
internalPWRs map[string]InternalPWR proxyConfig easyproxy.ProxyConfig
pwrCaptchas map[string]Captcha internalPWRs map[string]InternalPWR
ConfirmationKeys map[string]map[string]newUserDTO // Map of invite code to jwt to request pwrCaptchas map[string]Captcha
confirmationKeysLock sync.Mutex ConfirmationKeys map[string]map[string]newUserDTO // Map of invite code to jwt to request
confirmationKeysLock sync.Mutex
} }
func generateSecret(length int) (string, error) { func generateSecret(length int) (string, error) {
@ -232,7 +233,7 @@ func start(asDaemon, firstCall bool) {
app.err.Fatalf(lm.FailedCopyConfig, app.configPath, err) app.err.Fatalf(lm.FailedCopyConfig, app.configPath, err)
} }
app.info.Printf(lm.CopyConfig, app.configPath) app.info.Printf(lm.CopyConfig, app.configPath)
tempConfig, _ := ini.Load(app.configPath) tempConfig, _ := ini.ShadowLoad(app.configPath)
tempConfig.Section("").Key("first_run").SetValue("true") tempConfig.Section("").Key("first_run").SetValue("true")
tempConfig.SaveTo(app.configPath) tempConfig.SaveTo(app.configPath)
} }
@ -556,6 +557,13 @@ func start(asDaemon, firstCall bool) {
} }
} }
// Non-consequential if we don't need it
app.webhooks = NewWebhookSender(
common.NewTimeoutHandler("Webhook", "?", true),
app.debug,
)
// Updater proxy set in config.go, don't worry!
if app.proxyEnabled { if app.proxyEnabled {
app.jf.SetTransport(app.proxyTransport) app.jf.SetTransport(app.proxyTransport)
for _, c := range app.thirdPartyServices { for _, c := range app.thirdPartyServices {
@ -682,7 +690,7 @@ func flagPassed(name string) (found bool) {
} }
// @title jfa-go internal API // @title jfa-go internal API
// @version 0.5.1 // @version 0.5.2
// @description API for the jfa-go frontend // @description API for the jfa-go frontend
// @contact.name Harvey Tindall // @contact.name Harvey Tindall
// @contact.email hrfee@hrfee.dev // @contact.email hrfee@hrfee.dev

View File

@ -60,7 +60,7 @@ func migrateBootstrap(app *appContext) {
} }
func migrateEmailConfig(app *appContext) { func migrateEmailConfig(app *appContext) {
tempConfig, _ := ini.Load(app.configPath) tempConfig, _ := ini.ShadowLoad(app.configPath)
fmt.Println(warning("Part of your email configuration will be migrated to the new \"messages\" section.\nA backup will be made.")) fmt.Println(warning("Part of your email configuration will be migrated to the new \"messages\" section.\nA backup will be made."))
err := tempConfig.SaveTo(app.configPath + "_" + commit + ".bak") err := tempConfig.SaveTo(app.configPath + "_" + commit + ".bak")
if err != nil { if err != nil {
@ -111,7 +111,7 @@ func migrateEmailStorage(app *appContext) error {
return fmt.Errorf("email address was type %T, not string: \"%+v\"\n", addr, addr) return fmt.Errorf("email address was type %T, not string: \"%+v\"\n", addr, addr)
} }
} }
config, err := ini.Load(app.configPath) config, err := ini.ShadowLoad(app.configPath)
if err != nil { if err != nil {
return err return err
} }
@ -467,7 +467,7 @@ func intialiseCustomContent(app *appContext) {
// Migrate poorly-named and duplicate "url_base" settings to the single "external jfa-go URL" setting. // Migrate poorly-named and duplicate "url_base" settings to the single "external jfa-go URL" setting.
func migrateExternalURL(app *appContext) { func migrateExternalURL(app *appContext) {
tempConfig, _ := ini.Load(app.configPath) tempConfig, _ := ini.ShadowLoad(app.configPath)
err := tempConfig.SaveTo(app.configPath + "_" + commit + ".bak") err := tempConfig.SaveTo(app.configPath + "_" + commit + ".bak")
if err != nil { if err != nil {
app.err.Fatalf("Failed to backup config: %v", err) app.err.Fatalf("Failed to backup config: %v", err)

View File

@ -1,6 +1,8 @@
package main package main
import "time" import (
"time"
)
type stringResponse struct { type stringResponse struct {
Response string `json:"response" example:"message"` Response string `json:"response" example:"message"`

1999
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,7 @@
"inline-source": "^8.0.2", "inline-source": "^8.0.2",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mjml": "^4.14.1", "mjml": "^4.15.3",
"nightwind": "^1.1.13", "nightwind": "^1.1.13",
"perl-regex": "^1.0.4", "perl-regex": "^1.0.4",
"postcss": "^8.4.24", "postcss": "^8.4.24",

View File

@ -30,7 +30,7 @@ func (app *appContext) GenInternalReset(userID string) (InternalPWR, error) {
// GenResetLink generates and returns a password reset link. // GenResetLink generates and returns a password reset link.
func (app *appContext) GenResetLink(pin string) (string, error) { func (app *appContext) GenResetLink(pin string) (string, error) {
url := app.ExternalHost url := app.ExternalURI
var pinLink string var pinLink string
if url == "" { if url == "" {
return pinLink, errors.New(lm.NoExternalHost) return pinLink, errors.New(lm.NoExternalHost)

View File

@ -29,7 +29,7 @@ interface Setting {
requires_restart: boolean; requires_restart: boolean;
advanced?: boolean; advanced?: boolean;
type: string; type: string;
value: string | boolean | number; value: string | boolean | number | string[];
depends_true?: string; depends_true?: string;
depends_false?: string; depends_false?: string;
wiki_link?: string; wiki_link?: string;
@ -37,6 +37,11 @@ interface Setting {
asElement: () => HTMLElement; asElement: () => HTMLElement;
update: (s: Setting) => void; update: (s: Setting) => void;
hide: () => void;
show: () => void;
valueAsString: () => string;
} }
const splitDependant = (section: string, dep: string): string[] => { const splitDependant = (section: string, dep: string): string[] => {
@ -49,17 +54,22 @@ const splitDependant = (section: string, dep: string): string[] => {
class DOMInput { class DOMInput {
protected _input: HTMLInputElement; protected _input: HTMLInputElement;
private _container: HTMLDivElement; protected _container: HTMLDivElement;
private _tooltip: HTMLDivElement; protected _tooltip: HTMLDivElement;
private _required: HTMLSpanElement; protected _required: HTMLSpanElement;
private _restart: HTMLSpanElement; protected _restart: HTMLSpanElement;
private _advanced: boolean; protected _advanced: boolean;
protected _section: string;
protected _name: string;
hide = () => this._input.parentElement.classList.add("unfocused");
show = () => this._input.parentElement.classList.remove("unfocused");
private _advancedListener = (event: settingsBoolEvent) => { private _advancedListener = (event: settingsBoolEvent) => {
if (!Boolean(event.detail)) { if (!Boolean(event.detail)) {
this._input.parentElement.classList.add("unfocused"); this.hide();
} else { } else {
this._input.parentElement.classList.remove("unfocused"); this.show();
} }
} }
@ -109,10 +119,23 @@ class DOMInput {
} }
} }
constructor(inputType: string, setting: Setting, section: string, name: string) { valueAsString = (): string => { return ""+this.value; };
onValueChange = () => {
const event = new CustomEvent(`settings-${this._section}-${this._name}`, { "detail": this.valueAsString() })
document.dispatchEvent(event);
if (this.requires_restart) { document.dispatchEvent(new CustomEvent("settings-requires-restart")); }
};
constructor(inputType: string, setting: Setting, section: string, name: string, customInput?: string) {
this._section = section;
this._name = name;
this._container = document.createElement("div"); this._container = document.createElement("div");
this._container.classList.add("setting"); this._container.classList.add("setting");
this._container.setAttribute("data-name", name) this._container.setAttribute("data-name", name)
const defaultInput = `
<input type="${inputType}" class="input setting-input ~neutral @low mt-2 mb-2">
`;
this._container.innerHTML = ` this._container.innerHTML = `
<label class="label"> <label class="label">
<span class="setting-label"></span> <span class="setting-required"></span> <span class="setting-restart"></span> <span class="setting-label"></span> <span class="setting-required"></span> <span class="setting-restart"></span>
@ -120,31 +143,26 @@ class DOMInput {
<i class="icon ri-information-line"></i> <i class="icon ri-information-line"></i>
<span class="content sm"></span> <span class="content sm"></span>
</div> </div>
<input type="${inputType}" class="input ~neutral @low mt-2 mb-2"> ${customInput ? customInput : defaultInput}
</label> </label>
`; `;
this._tooltip = this._container.querySelector("div.setting-tooltip") as HTMLDivElement; this._tooltip = this._container.querySelector("div.setting-tooltip") as HTMLDivElement;
this._required = this._container.querySelector("span.setting-required") as HTMLSpanElement; this._required = this._container.querySelector("span.setting-required") as HTMLSpanElement;
this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement; this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement;
this._input = this._container.querySelector("input[type=" + inputType + "]") as HTMLInputElement; this._input = this._container.querySelector(".setting-input") as HTMLInputElement;
if (setting.depends_false || setting.depends_true) { if (setting.depends_false || setting.depends_true) {
let dependant = splitDependant(section, setting.depends_true || setting.depends_false); let dependant = splitDependant(section, setting.depends_true || setting.depends_false);
let state = true; let state = true;
if (setting.depends_false) { state = false; } if (setting.depends_false) { state = false; }
document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => { document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => {
if (Boolean(event.detail) !== state) { if (Boolean(event.detail) !== state) {
this._input.parentElement.classList.add("unfocused"); this.hide();
} else { } else {
this._input.parentElement.classList.remove("unfocused"); this.show();
} }
}); });
} }
const onValueChange = () => { this._input.onchange = this.onValueChange;
const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.value })
document.dispatchEvent(event);
if (this.requires_restart) { document.dispatchEvent(new CustomEvent("settings-requires-restart")); }
};
this._input.onchange = onValueChange;
this.update(setting); this.update(setting);
} }
@ -173,6 +191,85 @@ class DOMText extends DOMInput implements SText {
set value(v: string) { this._input.value = v; } set value(v: string) { this._input.value = v; }
} }
interface SList extends Setting {
value: string[];
}
class DOMList extends DOMInput implements SList {
protected _inputs: HTMLDivElement;
type: string = "list";
valueAsString = (): string => { return this.value.join("|"); };
get value(): string[] {
let values = [];
const inputs = this._input.querySelectorAll("input") as NodeListOf<HTMLInputElement>;
for (let i in inputs) {
if (inputs[i].value) values.push(inputs[i].value);
}
return values;
}
set value(v: string[]) {
this._input.textContent = ``;
for (let val of v) {
let input = this.inputRow(val);
this._input.appendChild(input);
}
const addDummy = () => {
const dummyRow = this.inputRow();
const input = dummyRow.querySelector("input") as HTMLInputElement;
input.placeholder = window.lang.strings("add");
const onDummyChange = () => {
if (!(input.value)) return;
addDummy();
input.removeEventListener("change", onDummyChange);
input.placeholder = ``;
}
input.addEventListener("change", onDummyChange);
this._input.appendChild(dummyRow);
};
addDummy();
}
private inputRow(v: string = ""): HTMLDivElement {
let container = document.createElement("div") as HTMLDivElement;
container.classList.add("flex", "flex-row", "justify-between");
container.innerHTML = `
<input type="text" class="input ~neutral @low">
<button class="button ~neutral @low center -ml-10 rounded-s-none aria-label="${window.lang.strings("delete")}" title="${window.lang.strings("delete")}">
<i class="ri-close-line"></i>
</button>
`;
const input = container.querySelector("input") as HTMLInputElement;
input.value = v;
input.onchange = this.onValueChange;
const removeRow = container.querySelector("button") as HTMLButtonElement;
removeRow.onclick = () => {
if (!(container.nextElementSibling)) return;
container.remove();
this.onValueChange();
}
return container;
}
update = (s: Setting) => {
this.name = s.name;
this.description = s.description;
this.required = s.required;
this.requires_restart = s.requires_restart;
this.value = s.value as string[];
this.advanced = s.advanced;
}
asElement = (): HTMLDivElement => { return this._container; }
constructor(setting: Setting, section: string, name: string) {
super("list", setting, section, name,
`<div class="setting-input flex flex-col gap-2 mt-2 mb-2"></div>`
);
}
}
interface SPassword extends Setting { interface SPassword extends Setting {
value: string; value: string;
} }
@ -215,11 +312,14 @@ class DOMBool implements SBool {
type: string = "bool"; type: string = "bool";
private _advanced: boolean; private _advanced: boolean;
hide = () => this._input.parentElement.classList.add("unfocused");
show = () => this._input.parentElement.classList.remove("unfocused");
private _advancedListener = (event: settingsBoolEvent) => { private _advancedListener = (event: settingsBoolEvent) => {
if (!Boolean(event.detail)) { if (!Boolean(event.detail)) {
this._input.parentElement.classList.add("unfocused"); this.hide();
} else { } else {
this._input.parentElement.classList.remove("unfocused"); this.show();
} }
} }
@ -268,8 +368,12 @@ class DOMBool implements SBool {
this._restart.textContent = ""; this._restart.textContent = "";
} }
} }
valueAsString = (): string => { return ""+this.value; };
get value(): boolean { return this._input.checked; } get value(): boolean { return this._input.checked; }
set value(state: boolean) { this._input.checked = state; } set value(state: boolean) { this._input.checked = state; }
constructor(setting: SBool, section: string, name: string) { constructor(setting: SBool, section: string, name: string) {
this._container = document.createElement("div"); this._container = document.createElement("div");
this._container.classList.add("setting"); this._container.classList.add("setting");
@ -289,7 +393,7 @@ class DOMBool implements SBool {
this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement; this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement;
this._input = this._container.querySelector("input[type=checkbox]") as HTMLInputElement; this._input = this._container.querySelector("input[type=checkbox]") as HTMLInputElement;
const onValueChange = () => { const onValueChange = () => {
const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.value }) const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.valueAsString() })
document.dispatchEvent(event); document.dispatchEvent(event);
}; };
this._input.onchange = () => { this._input.onchange = () => {
@ -304,9 +408,9 @@ class DOMBool implements SBool {
if (setting.depends_false) { state = false; } if (setting.depends_false) { state = false; }
document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => { document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => {
if (Boolean(event.detail) !== state) { if (Boolean(event.detail) !== state) {
this._input.parentElement.classList.add("unfocused"); this.hide();
} else { } else {
this._input.parentElement.classList.remove("unfocused"); this.show();
} }
}); });
} }
@ -338,11 +442,14 @@ class DOMSelect implements SSelect {
type: string = "bool"; type: string = "bool";
private _advanced: boolean; private _advanced: boolean;
hide = () => this._container.classList.add("unfocused");
show = () => this._container.classList.remove("unfocused");
private _advancedListener = (event: settingsBoolEvent) => { private _advancedListener = (event: settingsBoolEvent) => {
if (!Boolean(event.detail)) { if (!Boolean(event.detail)) {
this._container.classList.add("unfocused"); this.hide();
} else { } else {
this._container.classList.remove("unfocused"); this.show();
} }
} }
@ -391,6 +498,9 @@ class DOMSelect implements SSelect {
this._restart.textContent = ""; this._restart.textContent = "";
} }
} }
valueAsString = (): string => { return ""+this.value; };
get value(): string { return this._select.value; } get value(): string { return this._select.value; }
set value(v: string) { this._select.value = v; } set value(v: string) { this._select.value = v; }
@ -431,14 +541,14 @@ class DOMSelect implements SSelect {
if (setting.depends_false) { state = false; } if (setting.depends_false) { state = false; }
document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => { document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => {
if (Boolean(event.detail) !== state) { if (Boolean(event.detail) !== state) {
this._container.classList.add("unfocused"); this.hide();
} else { } else {
this._container.classList.remove("unfocused"); this.show();
} }
}); });
} }
const onValueChange = () => { const onValueChange = () => {
const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.value }) const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.valueAsString() })
document.dispatchEvent(event); document.dispatchEvent(event);
if (this.requires_restart) { document.dispatchEvent(new CustomEvent("settings-requires-restart")); } if (this.requires_restart) { document.dispatchEvent(new CustomEvent("settings-requires-restart")); }
}; };
@ -478,6 +588,9 @@ class DOMNote implements SNote {
type: string = "note"; type: string = "note";
private _style: string; private _style: string;
hide = () => this._container.classList.add("unfocused");
show = () => this._container.classList.remove("unfocused");
get name(): string { return this._name.textContent; } get name(): string { return this._name.textContent; }
set name(n: string) { this._name.textContent = n; } set name(n: string) { this._name.textContent = n; }
@ -486,6 +599,8 @@ class DOMNote implements SNote {
this._description.innerHTML = d; this._description.innerHTML = d;
} }
valueAsString = (): string => { return ""; };
get value(): string { return ""; } get value(): string { return ""; }
set value(v: string) { return; } set value(v: string) { return; }
@ -521,9 +636,9 @@ class DOMNote implements SNote {
if (setting.depends_false) { state = false; } if (setting.depends_false) { state = false; }
document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => { document.addEventListener(`settings-${dependant[0]}-${dependant[1]}`, (event: settingsBoolEvent) => {
if (Boolean(event.detail) !== state) { if (Boolean(event.detail) !== state) {
this._container.classList.add("unfocused"); this.hide();
} else { } else {
this._container.classList.remove("unfocused"); this.show();
} }
}); });
} }
@ -603,12 +718,15 @@ class sectionPanel {
case "note": case "note":
setting = new DOMNote(setting as SNote, this._sectionName); setting = new DOMNote(setting as SNote, this._sectionName);
break; break;
case "list":
setting = new DOMList(setting as SList, this._sectionName, name);
break;
} }
if (setting.type != "note") { if (setting.type != "note") {
this.values[name] = ""+setting.value; this.values[name] = ""+setting.value;
document.addEventListener(`settings-${this._sectionName}-${name}`, (event: CustomEvent) => { document.addEventListener(`settings-${this._sectionName}-${name}`, (event: CustomEvent) => {
// const oldValue = this.values[name]; // const oldValue = this.values[name];
this.values[name] = ""+event.detail; this.values[name] = event.detail;
document.dispatchEvent(new CustomEvent("settings-section-changed")); document.dispatchEvent(new CustomEvent("settings-section-changed"));
}); });
} }

View File

@ -64,11 +64,13 @@ func (app *appContext) getUserTokenLogin(gc *gin.Context) {
return return
} }
// host := gc.Request.URL.Hostname()
host := app.ExternalDomain
uri := "/my" uri := "/my"
if strings.HasPrefix(gc.Request.RequestURI, app.URLBase) { if strings.HasPrefix(gc.Request.RequestURI, app.URLBase) {
uri = "/accounts/my" uri = "/accounts/my"
} }
gc.SetCookie("user-refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, uri, gc.Request.URL.Hostname(), true, true) gc.SetCookie("user-refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, uri, host, true, true)
gc.JSON(200, getTokenDTO{token}) gc.JSON(200, getTokenDTO{token})
} }
@ -101,6 +103,8 @@ func (app *appContext) getUserTokenRefresh(gc *gin.Context) {
return return
} }
gc.SetCookie("user-refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/my", gc.Request.URL.Hostname(), true, true) // host := gc.Request.URL.Hostname()
host := app.ExternalDomain
gc.SetCookie("user-refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/my", host, true, true)
gc.JSON(200, getTokenDTO{jwt}) gc.JSON(200, getTokenDTO{jwt})
} }

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -49,7 +50,8 @@ type NewUserData struct {
} }
// Called after a new-user-creating route has done pre-steps (veryfing contact methods for example). // Called after a new-user-creating route has done pre-steps (veryfing contact methods for example).
func (app *appContext) NewUserPostVerification(p NewUserParams) (out NewUserData) { func (app *appContext) NewUserPostVerification(p NewUserParams) (out NewUserData, pendingTasks *sync.WaitGroup) {
pendingTasks = &sync.WaitGroup{}
// Some helper functions which will behave as our app.info/error/debug // Some helper functions which will behave as our app.info/error/debug
deferLogInfo := func(s string, args ...any) { deferLogInfo := func(s string, args ...any) {
out.Log = func() { out.Log = func() {
@ -124,7 +126,19 @@ func (app *appContext) NewUserPostVerification(p NewUserParams) (out NewUserData
} }
} }
// Welcome email is sent by each user of this method separately.. webhookURIs := app.config.Section("webhooks").Key("created").StringsWithShadows("|")
if len(webhookURIs) != 0 {
summary := app.userSummary(out.User)
for _, uri := range webhookURIs {
go func() {
pendingTasks.Add(1)
app.webhooks.Send(uri, summary)
pendingTasks.Done()
}()
}
}
// Welcome email is sent by each user of this method separately.
out.Status = 200 out.Status = 200
out.Success = true out.Success = true

View File

@ -666,7 +666,7 @@ func (app *appContext) NewUserFromConfirmationKey(invite Invite, key string, lan
profile = &p profile = &p
} }
nu := app.NewUserPostVerification(NewUserParams{ nu /*wg*/, _ := app.NewUserPostVerification(NewUserParams{
Req: req, Req: req,
SourceType: sourceType, SourceType: sourceType,
Source: source, Source: source,
@ -707,6 +707,9 @@ func (app *appContext) NewUserFromConfirmationKey(invite Invite, key string, lan
delete(invKeys, key) delete(invKeys, key)
app.ConfirmationKeys[invite.Code] = invKeys app.ConfirmationKeys[invite.Code] = invKeys
app.confirmationKeysLock.Unlock() app.confirmationKeysLock.Unlock()
// These don't need to complete anytime soon
// wg.Wait()
return return
} }
@ -740,7 +743,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
discord := discordEnabled && app.config.Section("discord").Key("show_on_reg").MustBool(true) discord := discordEnabled && app.config.Section("discord").Key("show_on_reg").MustBool(true)
matrix := matrixEnabled && app.config.Section("matrix").Key("show_on_reg").MustBool(true) matrix := matrixEnabled && app.config.Section("matrix").Key("show_on_reg").MustBool(true)
userPageAddress := fmt.Sprintf("%s/my/account", app.ExternalHost) userPageAddress := fmt.Sprintf("%s/my/account", app.ExternalURI)
fromUser := "" fromUser := ""
if invite.ReferrerJellyfinID != "" { if invite.ReferrerJellyfinID != "" {

38
webhooks.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"net/http"
"net/url"
"time"
"github.com/hrfee/jfa-go/common"
"github.com/hrfee/jfa-go/logger"
lm "github.com/hrfee/jfa-go/logmessages"
)
type WebhookSender struct {
httpClient *http.Client
timeoutHandler common.TimeoutHandler
log *logger.Logger
}
// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy.
func (ws *WebhookSender) SetTransport(t *http.Transport) {
ws.httpClient.Transport = t
}
func NewWebhookSender(timeoutHandler common.TimeoutHandler, log *logger.Logger) *WebhookSender {
return &WebhookSender{
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
timeoutHandler: timeoutHandler,
log: log,
}
}
func (ws *WebhookSender) Send(uri string, payload any) (int, error) {
_, status, err := common.Req(ws.httpClient, ws.timeoutHandler, http.MethodPost, uri, payload, url.Values{}, nil, true)
ws.log.Printf(lm.WebhookRequest, uri, status, err)
return status, err
}