1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-09-19 19:00:11 +00:00

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.
This commit is contained in:
Harvey Tindall 2024-08-20 21:33:43 +01:00
parent 8307d3da90
commit e5f79c60ae
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
12 changed files with 226 additions and 63 deletions

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++
} }

2
api.go
View File

@ -354,6 +354,8 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
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" { } else if app.configBase.Sections[section].Settings[setting].Type == "list" {
splitValues := strings.Split(value.(string), "|") 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 { for i, v := range splitValues {
if i == 0 { if i == 0 {
tempConfig.Section(section).Key(setting).SetValue(v) tempConfig.Section(section).Key(setting).SetValue(v)

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

@ -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

@ -307,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 (

View File

@ -121,6 +121,7 @@ type appContext struct {
version string version string
URLBase, ExternalURI, ExternalDomain string URLBase, ExternalURI, ExternalDomain string
updater *Updater updater *Updater
webhooks *WebhookSender
newUpdate bool // Whether whatever's in update is new. newUpdate bool // Whether whatever's in update is new.
tag Tag tag Tag
update Update 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 { if app.proxyEnabled {
app.updater.SetTransport(app.proxyTransport)
app.jf.SetTransport(app.proxyTransport) app.jf.SetTransport(app.proxyTransport)
for _, c := range app.thirdPartyServices { for _, c := range app.thirdPartyServices {
c.SetTransport(app.proxyTransport) c.SetTransport(app.proxyTransport)

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"`

View File

@ -218,7 +218,7 @@ class DOMList extends DOMInput implements SList {
const addDummy = () => { const addDummy = () => {
const dummyRow = this.inputRow(); const dummyRow = this.inputRow();
const input = dummyRow.querySelector("input") as HTMLInputElement; const input = dummyRow.querySelector("input") as HTMLInputElement;
input.placeholder = window.lang.strings("Add"); input.placeholder = window.lang.strings("add");
const onDummyChange = () => { const onDummyChange = () => {
if (!(input.value)) return; if (!(input.value)) return;
addDummy(); addDummy();

View File

@ -130,11 +130,6 @@ type Updater struct {
binary string 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 { 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) // fmt.Printf(`Updater intializing with "%s", "%s", "%s", "%s", "%s", "%s"\n`, buildroneURL, namespace, repo, version, commit, buildType)
bType := off bType := off

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
} }

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
}