Skip to content

Commit 87ae5fe

Browse files
committed
add webhooks
previously, a subscription would always cause an email. now you can instead select delivery via a webhook config. which you can create/add/remove. it will POST a JSON object to an URL of your choice, with configurable headers (and our User-Agent and Content-Type). we'll retry delivery of hooks, depending on response code. and we recognize some failure modes that disable the webhook config. rough first version, with a few todo's sprinkled around.
1 parent 00028a3 commit 87ae5fe

12 files changed

+1768
-65
lines changed

api.go

+213-13
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"log/slog"
1717
"net"
1818
"net/http"
19+
"net/url"
1920
"os"
2021
"runtime"
2122
"strings"
@@ -130,7 +131,7 @@ func xaddUserLogf(tx *bstore.Tx, userID int64, format string, args ...any) {
130131
type API struct{}
131132

132133
func xrandomID(n int) string {
133-
return base64.RawURLEncoding.EncodeToString(xrandom(16))
134+
return base64.RawURLEncoding.EncodeToString(xrandom(n))
134135
}
135136

136137
func xrandom(n int) []byte {
@@ -446,6 +447,12 @@ func (API) UserRemove(ctx context.Context) {
446447
_, err = bstore.QueryTx[Message](tx).FilterNonzero(Message{UserID: user.ID}).Delete()
447448
xcheckf(err, "removing user messages")
448449

450+
_, err = bstore.QueryTx[Hook](tx).FilterNonzero(Hook{UserID: user.ID}).Delete()
451+
xcheckf(err, "removing user webhook calls")
452+
453+
_, err = bstore.QueryTx[HookConfig](tx).FilterNonzero(HookConfig{UserID: user.ID}).Delete()
454+
xcheckf(err, "removing user webhook configs")
455+
449456
err = tx.Delete(&user)
450457
xcheckf(err, "removing user")
451458

@@ -809,9 +816,16 @@ type Overview struct {
809816

810817
Subscriptions []Subscription
811818
ModuleUpdates []ModuleUpdateURLs
819+
HookConfigs []HookConfig
820+
RecentHooks []UpdateHook
812821
UserLogs []UserLog
813822
}
814823

824+
type UpdateHook struct {
825+
Update ModuleUpdate
826+
Hook Hook
827+
}
828+
815829
type ModuleUpdateURLs struct {
816830
ModuleUpdate
817831
RepoURL string
@@ -836,18 +850,28 @@ func (API) Overview(ctx context.Context) (overview Overview) {
836850
overview.MetaUnsubscribed = u.MetaUnsubscribed
837851
overview.UpdatesUnsubscribed = u.UpdatesUnsubscribed
838852

839-
overview.Subscriptions, err = bstore.QueryDB[Subscription](ctx, database).FilterNonzero(Subscription{UserID: reqInfo.UserID}).SortAsc("ID").List()
853+
overview.Subscriptions, err = bstore.QueryTx[Subscription](tx).FilterNonzero(Subscription{UserID: reqInfo.UserID}).SortAsc("ID").List()
840854
xcheckf(err, "listing subscriptions")
841855

842-
modups, err := bstore.QueryDB[ModuleUpdate](ctx, database).FilterNonzero(ModuleUpdate{UserID: reqInfo.UserID}).SortDesc("ID").Limit(50).List()
856+
modups, err := bstore.QueryTx[ModuleUpdate](tx).FilterNonzero(ModuleUpdate{UserID: reqInfo.UserID}).SortDesc("ID").Limit(50).List()
843857
xcheckf(err, "listing module updates")
844858
overview.ModuleUpdates = make([]ModuleUpdateURLs, len(modups))
845859
for i, modup := range modups {
846860
repoURL, tagURL, docURL := guessURLs(modup.Module, modup.Version)
847861
overview.ModuleUpdates[i] = ModuleUpdateURLs{modup, repoURL, tagURL, docURL}
848862
}
849863

850-
overview.UserLogs, err = bstore.QueryDB[UserLog](ctx, database).FilterNonzero(UserLog{UserID: reqInfo.UserID}).SortDesc("ID").Limit(50).List()
864+
overview.HookConfigs, err = bstore.QueryTx[HookConfig](tx).FilterNonzero(HookConfig{UserID: reqInfo.UserID}).SortAsc("ID").List()
865+
xcheckf(err, "listing hook configs")
866+
867+
err = bstore.QueryTx[Hook](tx).FilterNonzero(Hook{UserID: reqInfo.UserID}).SortDesc("NextAttempt").Limit(100).ForEach(func(h Hook) error {
868+
mu, err := bstore.QueryTx[ModuleUpdate](tx).FilterNonzero(ModuleUpdate{HookID: h.ID}).Get()
869+
overview.RecentHooks = append(overview.RecentHooks, UpdateHook{mu, h})
870+
return err
871+
})
872+
xcheckf(err, "listing recent hooks")
873+
874+
overview.UserLogs, err = bstore.QueryTx[UserLog](tx).FilterNonzero(UserLog{UserID: reqInfo.UserID}).SortDesc("ID").Limit(50).List()
851875
xcheckf(err, "listing userlogs")
852876

853877
return nil
@@ -930,6 +954,16 @@ func xcheckModule(m string) {
930954
}
931955
}
932956

957+
// Check whether hookConfigID is ok for user.
958+
func xcheckhookconfig(tx *bstore.Tx, userID, hookConfigID int64) {
959+
// Verify it's the user's.
960+
exists, err := bstore.QueryTx[HookConfig](tx).FilterNonzero(HookConfig{UserID: userID, ID: hookConfigID}).Exists()
961+
xcheckf(err, "looking up hook config")
962+
if !exists {
963+
xusererrorf("no such webhook config")
964+
}
965+
}
966+
933967
// SubscriptionCreate adds a new subscription to a module.
934968
func (API) SubscriptionCreate(ctx context.Context, sub Subscription) Subscription {
935969
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
@@ -938,8 +972,13 @@ func (API) SubscriptionCreate(ctx context.Context, sub Subscription) Subscriptio
938972

939973
sub.ID = 0
940974
sub.UserID = reqInfo.UserID
941-
err := database.Insert(ctx, &sub)
942-
xusercheckf(err, "inserting new subscription")
975+
err := database.Write(ctx, func(tx *bstore.Tx) error {
976+
if sub.HookConfigID != 0 {
977+
xcheckhookconfig(tx, reqInfo.UserID, sub.HookConfigID)
978+
}
979+
return tx.Insert(&sub)
980+
})
981+
xcheckf(err, "inserting new subscription")
943982
return sub
944983
}
945984

@@ -950,6 +989,7 @@ type SubscriptionImport struct {
950989
Prerelease bool
951990
Pseudo bool
952991
Comment string
992+
HookConfigID int64
953993
Indirect bool
954994
}
955995

@@ -962,6 +1002,10 @@ func (API) SubscriptionImport(ctx context.Context, imp SubscriptionImport) (subs
9621002
xusercheckf(err, "parsing go.mod")
9631003

9641004
err = database.Write(ctx, func(tx *bstore.Tx) error {
1005+
if imp.HookConfigID != 0 {
1006+
xcheckhookconfig(tx, reqInfo.UserID, imp.HookConfigID)
1007+
}
1008+
9651009
for _, r := range f.Require {
9661010
if r.Indirect && !imp.Indirect {
9671011
continue
@@ -976,12 +1020,13 @@ func (API) SubscriptionImport(ctx context.Context, imp SubscriptionImport) (subs
9761020
}
9771021

9781022
sub := Subscription{
979-
UserID: reqInfo.UserID,
980-
Module: r.Mod.Path,
981-
BelowModule: imp.BelowModule,
982-
Prerelease: imp.Prerelease,
983-
Pseudo: imp.Pseudo,
984-
Comment: imp.Comment,
1023+
UserID: reqInfo.UserID,
1024+
Module: r.Mod.Path,
1025+
BelowModule: imp.BelowModule,
1026+
Prerelease: imp.Prerelease,
1027+
Pseudo: imp.Pseudo,
1028+
Comment: imp.Comment,
1029+
HookConfigID: imp.HookConfigID,
9851030
}
9861031
err = tx.Insert(&sub)
9871032
xusercheckf(err, "inserting new subscription")
@@ -1005,12 +1050,16 @@ func (API) SubscriptionSave(ctx context.Context, sub Subscription) {
10051050
xcheckModule(sub.Module)
10061051

10071052
err := database.Write(ctx, func(tx *bstore.Tx) error {
1008-
exists, err := bstore.QueryTx[Subscription](tx).FilterNonzero(Subscription{ID: sub.ID, UserID: reqInfo.UserID}).Limit(1).Exists()
1053+
exists, err := bstore.QueryTx[Subscription](tx).FilterNonzero(Subscription{ID: sub.ID, UserID: reqInfo.UserID}).Exists()
10091054
xcheckf(err, "get subscription")
10101055
if !exists {
10111056
xusererrorf("no such subscription")
10121057
}
10131058

1059+
if sub.HookConfigID != 0 {
1060+
xcheckhookconfig(tx, reqInfo.UserID, sub.HookConfigID)
1061+
}
1062+
10141063
sub.UserID = reqInfo.UserID
10151064
err = tx.Update(&sub)
10161065
xcheckf(err, "updating subscription")
@@ -1219,3 +1268,154 @@ func (API) TestSend(ctx context.Context, secret, kind, email string) {
12191268
err = smtpSubmit(ctx, smtpconn, false, mailFrom, u.Email, msg, eightbit, smtputf8)
12201269
xcheckf(err, "submit message")
12211270
}
1271+
1272+
func xcheckhookurl(s string) {
1273+
u, err := url.Parse(s)
1274+
xusercheckf(err, "parsing url")
1275+
if u.Scheme != "http" && u.Scheme != "https" {
1276+
xusererrorf("scheme %q not allowed, use https or http", u.Scheme)
1277+
}
1278+
}
1279+
1280+
// todo: should we require an opt-in before we start making requests? e.g. require that an endpoint returns certain data we specify.
1281+
1282+
func (API) HookConfigAdd(ctx context.Context, hc HookConfig) (nhc HookConfig) {
1283+
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1284+
1285+
err := database.Write(ctx, func(tx *bstore.Tx) error {
1286+
hc.ID = 0
1287+
hc.UserID = reqInfo.UserID
1288+
xcheckhookurl(hc.URL)
1289+
if err := tx.Insert(&hc); err != nil {
1290+
return err
1291+
}
1292+
nhc = hc
1293+
return nil
1294+
})
1295+
if err != nil && errors.Is(err, bstore.ErrUnique) {
1296+
xusererrorf("config not unique")
1297+
}
1298+
xcheckf(err, "add hook config")
1299+
return
1300+
}
1301+
1302+
func (API) HookConfigSave(ctx context.Context, hc HookConfig) {
1303+
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1304+
1305+
err := database.Write(ctx, func(tx *bstore.Tx) error {
1306+
if hc.ID == 0 {
1307+
xusererrorf("missing hook config id")
1308+
}
1309+
xcheckhookurl(hc.URL)
1310+
1311+
ohc, err := bstore.QueryTx[HookConfig](tx).FilterNonzero(HookConfig{ID: hc.ID, UserID: reqInfo.UserID}).Get()
1312+
if err == bstore.ErrAbsent {
1313+
xusererrorf("no such hook config")
1314+
}
1315+
xcheckf(err, "get current hook config")
1316+
ohc.Name = hc.Name
1317+
ohc.URL = hc.URL
1318+
ohc.Headers = hc.Headers
1319+
ohc.Disabled = hc.Disabled
1320+
if err := tx.Update(&ohc); err != nil {
1321+
return err
1322+
}
1323+
return nil
1324+
})
1325+
if err != nil && errors.Is(err, bstore.ErrUnique) {
1326+
xusererrorf("config not unique")
1327+
}
1328+
xcheckf(err, "save hook config")
1329+
}
1330+
1331+
func (API) HookConfigRemove(ctx context.Context, hcID int64) {
1332+
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1333+
1334+
err := database.Write(ctx, func(tx *bstore.Tx) error {
1335+
if hcID == 0 {
1336+
xusererrorf("missing hook config id")
1337+
}
1338+
1339+
ohc, err := bstore.QueryTx[HookConfig](tx).FilterNonzero(HookConfig{ID: hcID, UserID: reqInfo.UserID}).Get()
1340+
if err == bstore.ErrAbsent {
1341+
xusererrorf("no such hook config")
1342+
}
1343+
xcheckf(err, "get current hook config")
1344+
1345+
// First remove hooks referencing config.
1346+
_, err = bstore.QueryTx[Hook](tx).FilterNonzero(Hook{HookConfigID: ohc.ID}).Delete()
1347+
xcheckf(err, "removing hooks for config")
1348+
1349+
if err := tx.Delete(&ohc); err != nil {
1350+
if errors.Is(err, bstore.ErrReference) {
1351+
xusererrorf("webhook config still in use with subscription")
1352+
}
1353+
return err
1354+
}
1355+
1356+
return nil
1357+
})
1358+
xcheckf(err, "remove hook config")
1359+
}
1360+
1361+
func (API) HookCancel(ctx context.Context, hID int64) (nh Hook) {
1362+
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1363+
1364+
err := database.Write(ctx, func(tx *bstore.Tx) error {
1365+
if hID == 0 {
1366+
xusererrorf("missing hook id")
1367+
}
1368+
1369+
oh, err := bstore.QueryTx[Hook](tx).FilterNonzero(Hook{ID: hID, UserID: reqInfo.UserID}).Get()
1370+
if err == bstore.ErrAbsent {
1371+
xusererrorf("no such hook")
1372+
}
1373+
xcheckf(err, "get current hook")
1374+
1375+
if oh.Done {
1376+
xusererrorf("hook already done")
1377+
}
1378+
1379+
oh.Done = true
1380+
oh.Results = append(oh.Results, HookResult{Error: "Canceled by user", Start: time.Now()})
1381+
oh.NextAttempt = time.Now()
1382+
if err := tx.Update(&oh); err != nil {
1383+
return err
1384+
}
1385+
nh = oh
1386+
return nil
1387+
})
1388+
xcheckf(err, "remove hook")
1389+
return
1390+
}
1391+
1392+
func (API) HookKick(ctx context.Context, hID int64) (nh Hook) {
1393+
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
1394+
1395+
err := database.Write(ctx, func(tx *bstore.Tx) error {
1396+
if hID == 0 {
1397+
xusererrorf("missing hook id")
1398+
}
1399+
1400+
h, err := bstore.QueryTx[Hook](tx).FilterNonzero(Hook{ID: hID, UserID: reqInfo.UserID}).Get()
1401+
if err == bstore.ErrAbsent {
1402+
xusererrorf("no such hook")
1403+
}
1404+
xcheckf(err, "get current hook")
1405+
1406+
if h.Done {
1407+
xusererrorf("hook already done")
1408+
}
1409+
1410+
h.NextAttempt = time.Now()
1411+
if err := tx.Update(&h); err != nil {
1412+
return err
1413+
}
1414+
1415+
nh = h
1416+
return nil
1417+
})
1418+
xcheckf(err, "update hook")
1419+
kickHooksQueue()
1420+
return
1421+
}

0 commit comments

Comments
 (0)