From e5f79c60aefaaf50662e4c8c4054915b3c8d7c41 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Tue, 20 Aug 2024 21:33:43 +0100 Subject: [PATCH] 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. --- api-users.go | 114 ++++++++++++++++++++----------------- api.go | 2 + common/common.go | 71 +++++++++++++++++++++++ config/config-base.json | 18 ++++++ logmessages/logmessages.go | 3 + main.go | 9 ++- models.go | 4 +- ts/modules/settings.ts | 2 +- updater.go | 5 -- users.go | 18 +++++- views.go | 5 +- webhooks.go | 38 +++++++++++++ 12 files changed, 226 insertions(+), 63 deletions(-) create mode 100644 webhooks.go diff --git a/api-users.go b/api-users.go index 74ecda1..010c1ab 100644 --- a/api-users.go +++ b/api-users.go @@ -42,7 +42,7 @@ func (app *appContext) NewUserFromAdmin(gc *gin.Context) { profile = p } } - nu := app.NewUserPostVerification(NewUserParams{ + nu /*wg*/, _ := app.NewUserPostVerification(NewUserParams{ Req: req, SourceType: ActivityAdmin, Source: gc.GetString("jfId"), @@ -59,6 +59,8 @@ func (app *appContext) NewUserFromAdmin(gc *gin.Context) { } 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 @@ -205,8 +207,7 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) { profile = &p } - // FIXME: Use NewUserPostVerification - nu := app.NewUserPostVerification(NewUserParams{ + nu /*wg*/, _ := app.NewUserPostVerification(NewUserParams{ Req: req, SourceType: sourceType, Source: source, @@ -341,7 +342,10 @@ func (app *appContext) NewUserFromInvite(gc *gin.Context) { code = 400 } } + 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. @@ -822,6 +826,60 @@ func (app *appContext) AdminPasswordReset(gc *gin.Context) { 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. // @Produce json // @Success 200 {object} getUsersDTO @@ -838,57 +896,9 @@ func (app *appContext) GetUsers(gc *gin.Context) { respond(500, "Couldn't get users", gc) 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 for _, jfUser := range users { - 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 - } - } + user := app.userSummary(jfUser) resp.UserList[i] = user i++ } diff --git a/api.go b/api.go index d8f10d5..c560f5c 100644 --- a/api.go +++ b/api.go @@ -354,6 +354,8 @@ func (app *appContext) ModifyConfig(gc *gin.Context) { 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) diff --git a/common/common.go b/common/common.go index c037711..58d0424 100644 --- a/common/common.go +++ b/common/common.go @@ -1,10 +1,16 @@ package common import ( + "bytes" + "compress/gzip" + "encoding/json" "errors" "fmt" + "io" "log" "net/http" + "net/url" + "strings" 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(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 +} diff --git a/config/config-base.json b/config/config-base.json index 8454126..013b685 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -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": { "order": [], "meta": { diff --git a/logmessages/logmessages.go b/logmessages/logmessages.go index eaeb9d3..4f47590 100644 --- a/logmessages/logmessages.go +++ b/logmessages/logmessages.go @@ -307,6 +307,9 @@ const ( CaptchaNotFound = "Captcha \"%s\" not found in invite \"%s\"" FailedVerifyReCAPTCHA = "Failed to verify reCAPTCHA: %v" InvalidHostname = "invalid hostname (wanted \"%s\", got \"%s\")" + + // webhooks.go + WebhookRequest = "Webhook request send to \"%s\" (%d): %v" ) const ( diff --git a/main.go b/main.go index 94c38cc..cf0b4f5 100644 --- a/main.go +++ b/main.go @@ -121,6 +121,7 @@ type appContext struct { version string URLBase, ExternalURI, ExternalDomain string updater *Updater + webhooks *WebhookSender newUpdate bool // Whether whatever's in update is new. tag Tag update Update @@ -556,8 +557,14 @@ 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 { - app.updater.SetTransport(app.proxyTransport) app.jf.SetTransport(app.proxyTransport) for _, c := range app.thirdPartyServices { c.SetTransport(app.proxyTransport) diff --git a/models.go b/models.go index 2dcbe76..157a41b 100644 --- a/models.go +++ b/models.go @@ -1,6 +1,8 @@ package main -import "time" +import ( + "time" +) type stringResponse struct { Response string `json:"response" example:"message"` diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 839cc46..795ad31 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -218,7 +218,7 @@ class DOMList extends DOMInput implements SList { const addDummy = () => { const dummyRow = this.inputRow(); const input = dummyRow.querySelector("input") as HTMLInputElement; - input.placeholder = window.lang.strings("Add"); + input.placeholder = window.lang.strings("add"); const onDummyChange = () => { if (!(input.value)) return; addDummy(); diff --git a/updater.go b/updater.go index 8320e2a..41bc19f 100644 --- a/updater.go +++ b/updater.go @@ -130,11 +130,6 @@ type Updater struct { binary string } -// SetTransport sets the http.Transport to use for requests. Can be used to set a proxy. -func (ud *Updater) SetTransport(t *http.Transport) { - ud.httpClient.Transport = t -} - func newUpdater(buildroneURL, namespace, repo, version, commit, buildType string) *Updater { // fmt.Printf(`Updater intializing with "%s", "%s", "%s", "%s", "%s", "%s"\n`, buildroneURL, namespace, repo, version, commit, buildType) bType := off diff --git a/users.go b/users.go index d52afc3..1e4e205 100644 --- a/users.go +++ b/users.go @@ -1,6 +1,7 @@ package main import ( + "sync" "time" "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). -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 deferLogInfo := func(s string, args ...any) { 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.Success = true diff --git a/views.go b/views.go index 5b83571..16027a1 100644 --- a/views.go +++ b/views.go @@ -666,7 +666,7 @@ func (app *appContext) NewUserFromConfirmationKey(invite Invite, key string, lan profile = &p } - nu := app.NewUserPostVerification(NewUserParams{ + nu /*wg*/, _ := app.NewUserPostVerification(NewUserParams{ Req: req, SourceType: sourceType, Source: source, @@ -707,6 +707,9 @@ func (app *appContext) NewUserFromConfirmationKey(invite Invite, key string, lan delete(invKeys, key) app.ConfirmationKeys[invite.Code] = invKeys app.confirmationKeysLock.Unlock() + + // These don't need to complete anytime soon + // wg.Wait() return } diff --git a/webhooks.go b/webhooks.go new file mode 100644 index 0000000..6ecb7b5 --- /dev/null +++ b/webhooks.go @@ -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 +}