diff --git a/README.md b/README.md index 9f08cc4..d64498d 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ gobin is a simple lightweight haste-server alternative written in Go, HTML, JS a - [Update a document](#update-a-document) - [Delete a document](#delete-a-document) - [Delete a document version](#delete-a-document-version) + - [Document webhooks](#document-webhooks) + - [Create a document webhook](#create-a-document-webhook) + - [Update a document webhook](#update-a-document-webhook) + - [Delete a document webhook](#delete-a-document-webhook) - [Other endpoints](#other-endpoints) - [Errors](#errors) - [License](#license) @@ -46,6 +50,7 @@ gobin is a simple lightweight haste-server alternative written in Go, HTML, JS a - Easy to deploy and use - Built-in rate-limiting - Create, update and delete documents +- Document update/delete webhooks - Syntax highlighting - Social Media PNG previews - Document expiration @@ -65,7 +70,7 @@ The easiest way to deploy gobin is using docker with [Docker Compose](https://do Create a new `docker-compose.yml` file with the following content: -> **Note** +> [!Note] > You should change the password in the `docker-compose.yml` and `gobin.json` file. ```yaml @@ -139,7 +144,7 @@ The database schema is automatically created when you start gobin and there is n Create a new `gobin.json` file with the following content: -> **Note** +> [!Note] > Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". ```json5 @@ -223,6 +228,19 @@ Create a new `gobin.json` file with the following content: "listen_addr": ":9100" } }, + // settings for webhooks, omit to disable + "webhook": { + // webhook reqauest timeout + "timeout": "10s", + // max number of tries to send a webhook + "max_tries": 3, + // how long to wait before retrying a webhook + "backoff": "1s", + // how much the backoff should be increased after each retry + "backoff_factor": 2, + // max backoff time + "max_backoff": "5m" + }, // load custom chroma xml or base16 yaml themes from this directory, omit to disable "custom_styles": "custom_styles", "default_style": "snazzy" @@ -269,6 +287,12 @@ GOBIN_PREVIEW_DPI=96 GOBIN_PREVIEW_CACHE_SIZE=1024 GOBIN_PREVIEW_CACHE_TTL=1h +GOBIN_WEBHOOK_TIMEOUT=10s +GOBIN_WEBHOOK_MAX_TRIES=3 +GOBIN_WEBHOOK_BACKOFF=1s +GOBIN_WEBHOOK_BACKOFF_FACTOR=2 +GOBIN_WEBHOOK_MAX_BACKOFF=5m + GOBIN_CUSTOM_STYLES=custom_styles GOBIN_DEFAULT_STYLE=snazzy ``` @@ -509,7 +533,7 @@ func main() { A successful request will return a `200 OK` response with a JSON body containing the document key and token to update the document. -> **Note** +> [!Note] > The update token will not change after updating the document. You can use the same token to update the document again. ```json5 @@ -566,6 +590,160 @@ A successful request will return a `204 No Content` response with an empty body. --- +### Document webhooks + +You can listen for document changes using webhooks. The webhook will send a `POST` request to the specified url with the following JSON body: + +```json5 +{ + // the id of the webhook + "webhook_id": "hocwr6i6", + // the event which triggered the webhook (update or delete) + "event": "update", + // when the event was created + "created_at": "2021-08-01T12:00:00Z", + // the updated or deleted document + "document": { + // the key of the document + "key": "hocwr6i6", + // the version of the document + "version": 2, + // the language of the document + "language": "go", + // the content of the document + "data": "package main\n\nfunc main() {\n println(\"Hello World Updated!\")\n}" + } +} +``` + +Gobin will include the webhook secret in the `Authorization` header in the following format: `Secret {secret}`. + +When sending an event to a webhook fails gobin will retry it up to x times with an exponential backoff. The retry settings can be configured in the config file. +When an event fails to be sent after x retries, the webhook will be dropped. + +> [!Important] +> Authorizing for the following webhook endpoints is done using the `Authorization` header in the following format: `Secret {secret}`. + +#### Create a document webhook + +To create a webhook you have to send a `POST` request to `/documents/{key}/webhooks` with the following JSON body: + +```json5 +{ + // the url to send a request to + "url": "https://example.com/webhook", + // the secret to include in the request + "secret": "secret", + // the events you want to receive + "events": [ + // update event is sent when a document is updated. This includes content and language changes + "update", + // delete event is sent when a document is deleted + "delete" + ] +} +``` + +A successful request will return a `200 OK` response with a JSON body containing the webhook. + +```json5 +{ + // the id of the webhook + "id": 1, + // the url to send a request to + "url": "https://example.com/webhook", + // the secret to include in the request + "secret": "secret", + // the events you want to receive + "events": [ + // update event is sent when a document is updated. This includes content and language changes + "update", + // delete event is sent when a document is deleted + "delete" + ] +} +``` + +--- + +#### Get a document webhook + +To get a webhook you have to send a `GET` request to `/documents/{key}/webhooks/{id}` with the `Authorization` header. + +A successful request will return a `200 OK` response with a JSON body containing the webhook. + +```json5 +{ + // the id of the webhook + "id": 1, + // the url to send a request to + "url": "https://example.com/webhook", + // the secret to include in the request + "secret": "secret", + // the events you want to receive + "events": [ + // update event is sent when a document is updated. This includes content and language changes + "update", + // delete event is sent when a document is deleted + "delete" + ] +} +``` + +--- + +#### Update a document webhook + +To update a webhook you have to send a `PATCH` request to `/documents/{key}/webhooks/{id}` with the `Authorization` header and the following JSON body: + +> [!Note] +> All fields are optional, but at least one field is required. +```json5 +{ + // the url to send a request to + "url": "https://example.com/webhook", + // the secret to include in the request + "secret": "secret", + // the events you want to receive + "events": [ + // update event is sent when a document is updated. This includes content and language changes + "update", + // delete event is sent when a document is deleted + "delete" + ] +} +``` + +A successful request will return a `200 OK` response with a JSON body containing the webhook. + +```json5 +{ + // the id of the webhook + "id": 1, + // the url to send a request to + "url": "https://example.com/webhook", + // the secret to include in the request + "secret": "secret", + // the events you want to receive + "events": [ + // update event is sent when a document is updated. This includes content and language changes + "update", + // delete event is sent when a document is deleted + "delete" + ] +} +``` + +--- + +#### Delete a document webhook + +To delete a webhook you have to send a `DELETE` request to `/documents/{key}/webhooks/{id}` with the `Authorization` header. + +A successful request will return a `204 No Content` response with an empty body. + +--- + ### Other endpoints - `GET`/`HEAD` `/{key}/preview` - Get the preview of a document, query parameters are the same as for `GET /documents/{key}` diff --git a/example.gobin.json b/example.gobin.json index 399def8..87c8461 100644 --- a/example.gobin.json +++ b/example.gobin.json @@ -31,12 +31,14 @@ }, "max_document_size": 0, "max_highlight_size": 0, + // omit or set values to 0 or "0" to disable rate limit "rate_limit": { "requests": 10, "duration": "1m", "whitelist": ["127.0.0.1"], "blacklist": ["123.456.789.0"] }, + // settings for social media previews, omit to disable "preview": { "inkscape_path": "inkscape.exe", "max_lines": 0, @@ -44,6 +46,25 @@ "cache_size": 1024, "cache_ttl": "1h" }, + // open telemetry settings, omit to disable + "otel": { + "instance_id": "1", + "trace": { + "endpoint": "tempo:4318", + "insecure": true + }, + "metrics": { + "listen_addr": ":9100" + } + }, + // settings for webhooks, omit to disable + "webhook": { + "timeout": "10s", + "max_tries": 3, + "backoff": "1s", + "backoff_factor": 2, + "max_backoff": "5m" + }, // load custom chroma xml or base16 yaml themes from this directory, omit to disable "custom_styles": "custom_styles", "default_style": "snazzy" diff --git a/go.mod b/go.mod index 9a90251..3cd1c38 100644 --- a/go.mod +++ b/go.mod @@ -10,13 +10,13 @@ replace ( require ( github.com/XSAM/otelsql v0.26.0 - github.com/a-h/templ v0.2.432 - github.com/alecthomas/chroma/v2 v2.10.0 + github.com/a-h/templ v0.2.476 + github.com/alecthomas/chroma/v2 v2.11.1 github.com/dustin/go-humanize v1.0.1 github.com/go-chi/chi/v5 v5.0.10 github.com/go-chi/httprate v0.7.4 github.com/go-chi/stampede v0.5.1 - github.com/go-jose/go-jose/v3 v3.0.0 + github.com/go-jose/go-jose/v3 v3.0.1 github.com/jackc/pgx/v5 v5.5.0 github.com/jmoiron/sqlx v1.3.5 github.com/mattn/go-colorable v0.1.13 @@ -27,13 +27,15 @@ require ( github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.17.0 github.com/topi314/tint v0.0.0-20231106205902-77268b701ca6 - go.opentelemetry.io/otel v1.19.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 - go.opentelemetry.io/otel/exporters/prometheus v0.42.0 - go.opentelemetry.io/otel/metric v1.19.0 - go.opentelemetry.io/otel/sdk v1.19.0 - go.opentelemetry.io/otel/sdk/metric v1.19.0 - go.opentelemetry.io/otel/trace v1.19.0 + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 + go.opentelemetry.io/otel v1.21.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 + go.opentelemetry.io/otel/exporters/prometheus v0.44.0 + go.opentelemetry.io/otel/metric v1.21.0 + go.opentelemetry.io/otel/sdk v1.21.0 + go.opentelemetry.io/otel/sdk/metric v1.21.0 + go.opentelemetry.io/otel/trace v1.21.0 modernc.org/sqlite v1.27.0 ) @@ -48,7 +50,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/uuid v1.4.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -72,20 +74,20 @@ require ( github.com/spf13/cast v1.5.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.opentelemetry.io/contrib v1.20.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect + go.opentelemetry.io/contrib v1.21.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect go.opentelemetry.io/proto/otlp v1.0.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.14.0 // indirect - golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/crypto v0.15.0 // indirect + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect golang.org/x/mod v0.14.0 // indirect - golang.org/x/net v0.17.0 // indirect + golang.org/x/net v0.18.0 // indirect golang.org/x/sync v0.5.0 // indirect golang.org/x/sys v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect - golang.org/x/tools v0.14.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20231030173426-d783a09b4405 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect + golang.org/x/tools v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect google.golang.org/grpc v1.59.0 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect @@ -93,7 +95,7 @@ require ( lukechampine.com/uint128 v1.3.0 // indirect modernc.org/cc/v3 v3.41.0 // indirect modernc.org/ccgo/v3 v3.16.15 // indirect - modernc.org/libc v1.30.0 // indirect + modernc.org/libc v1.34.4 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.7.2 // indirect modernc.org/opt v0.1.3 // indirect diff --git a/go.sum b/go.sum index e1668d7..b9a25c3 100644 --- a/go.sum +++ b/go.sum @@ -40,8 +40,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/XSAM/otelsql v0.26.0 h1:UhAGVBD34Ctbh2aYcm/JAdL+6T6ybrP+YMWYkHqCdmo= github.com/XSAM/otelsql v0.26.0/go.mod h1:5ciw61eMSh+RtTPN8spvPEPLJpAErZw8mFFPNfYiaxA= -github.com/a-h/templ v0.2.432 h1:/8sSs0janzx/DvXlYi+3KUkZABvm7s3lejbvhPZ1rSg= -github.com/a-h/templ v0.2.432/go.mod h1:6Lfhsl3Z4/vXl7jjEjkJRCqoWDGjDnuKgzjYMDSddas= +github.com/a-h/templ v0.2.476 h1:+H4hP4CwK4kfJwXsE6kHeFWMGtcVOVoOm/I64uzARBk= +github.com/a-h/templ v0.2.476/go.mod h1:zQ95mSyadNTGHv6k5Fm+wQU8zkBMMbHCHg7eAvUZKNM= github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink= github.com/alecthomas/assert/v2 v2.2.1/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= @@ -94,8 +94,8 @@ github.com/go-chi/stampede v0.5.1/go.mod h1:lrMOBraJLDgizaoAD+LQoC/sVB2t9mYsGwqD github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= -github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= +github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= +github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= @@ -105,8 +105,6 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo= -github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -146,8 +144,9 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -170,8 +169,8 @@ github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0 h1:RtRsiaGvWxcwd8y3BiRZxsylPT8hLWZ5SPcfI+3IDNk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0/go.mod h1:TzP6duP4Py2pHLVPPQp42aoYI92+PCrVotyR5e8Vqlk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 h1:6UKoz5ujsI55KNpsJH3UwCq3T8kKbZwNZBNPuTTje8U= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1/go.mod h1:YvJ2f6MplWDhfxiUC3KpyTy76kYUZA4W3pTv/wdKQ9Y= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.5-0.20200511160909-eb529947af53/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= @@ -295,24 +294,33 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opentelemetry.io/contrib v1.20.0 h1:oXUiIQLlkbi9uZB/bt5B1WRLsrTKqb7bPpAQ+6htn2w= go.opentelemetry.io/contrib v1.20.0/go.mod h1:gIzjwWFoGazJmtCaDgViqOSJPde2mCWzv60o0bWPcZs= -go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs= +go.opentelemetry.io/contrib v1.21.1 h1:/U05KZ31iqMqAowhtW10cDPAViNY0tnpAacUgYBmuj8= +go.opentelemetry.io/contrib v1.21.1/go.mod h1:usW9bPlrjHiJFbK0a6yK/M5wNHs3nLmtrT3vzhoD3co= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 h1:gbhw/u49SS3gkPWiYweQNJGm/uJN5GkI/FrosxSHT7A= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1/go.mod h1:GnOaBaFQ2we3b9AGWJpsBa7v1S5RlQzlC3O7dRMxZhM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/exporters/prometheus v0.42.0 h1:jwV9iQdvp38fxXi8ZC+lNpxjK16MRcZlpDYvbuO1FiA= -go.opentelemetry.io/otel/exporters/prometheus v0.42.0/go.mod h1:f3bYiqNqhoPxkvI2LrXqQVC546K7BuRDL/kKuxkujhA= -go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE= +go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= +go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 h1:cl5P5/GIfFh4t6xyruOgJP5QiA1pw4fYYdv6nc6CBWw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0/go.mod h1:zgBdWWAu7oEEMC06MMKc5NLbA/1YDXV1sMpSqEeLQLg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkEZCJWobwBqMwC0cwCq8/wkkRy/OowZg5OArWZrM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0/go.mod h1:/OpE/y70qVkndM0TrxT4KBoN3RsFZP0QaofcfYrj76I= +go.opentelemetry.io/otel/exporters/prometheus v0.44.0 h1:08qeJgaPC0YEBu2PQMbqU3rogTlyzpjhCI2b58Yn00w= +go.opentelemetry.io/otel/exporters/prometheus v0.44.0/go.mod h1:ERL2uIeBtg4TxZdojHUwzZfIFlUIjZtxubT5p4h1Gjg= go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= -go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= +go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= +go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= -go.opentelemetry.io/otel/sdk/metric v1.19.0 h1:EJoTO5qysMsYCa+w4UghwFV/ptQgqSL/8Ni+hx+8i1k= -go.opentelemetry.io/otel/sdk/metric v1.19.0/go.mod h1:XjG0jQyFJrv2PbMvwND7LwCEhsJzCzV5210euduKcKY= -go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= +go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel/sdk/metric v1.21.0 h1:smhI5oD714d6jHE6Tie36fPx4WDFIg+Y6RfAY4ICcR0= +go.opentelemetry.io/otel/sdk/metric v1.21.0/go.mod h1:FJ8RAsoPGv/wYMgBdUJXOm+6pzFY3YdljnXtv1SBE8Q= go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= +go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= +go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -325,8 +333,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -337,8 +345,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -396,8 +404,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= +golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -520,8 +528,8 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= -golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= +golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -588,12 +596,12 @@ google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA= -google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI= -google.golang.org/genproto/googleapis/api v0.0.0-20231030173426-d783a09b4405 h1:HJMDndgxest5n2y77fnErkM62iUsptE/H8p0dC2Huo4= -google.golang.org/genproto/googleapis/api v0.0.0-20231030173426-d783a09b4405/go.mod h1:oT32Z4o8Zv2xPQTg0pbVaPr0MPOH6f14RgXt7zfIpwg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 h1:AB/lmRny7e2pLhFEYIbl5qkDAUt2h0ZRO4wGPhZf+ik= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405/go.mod h1:67X1fPuzjcrkymZzZV1vvkFeTn2Rvc6lYF9MYFGCcwE= +google.golang.org/genproto v0.0.0-20231030173426-d783a09b4405 h1:I6WNifs6pF9tNdSob2W24JtyxIYjzFB9qDlpUC76q+U= +google.golang.org/genproto v0.0.0-20231030173426-d783a09b4405/go.mod h1:3WDQMjmJk36UQhjQ89emUzb1mdaHcPeeAh4SCBKznB4= +google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo= +google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -654,8 +662,8 @@ modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= -modernc.org/libc v1.30.0 h1:tw+o+UObwSE4Bfu3+Ztz9NW/Olqp7nTL/vcaEY/x4rc= -modernc.org/libc v1.30.0/go.mod h1:SUKVISl2sU6aasM35Y0v4SsSBTt89uDKrvxgXkvsC/4= +modernc.org/libc v1.34.4 h1:r9+5s4wNeoCsB8CuJE67UB4N07ernbvrcry9O3MLWtQ= +modernc.org/libc v1.34.4/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= diff --git a/gobin/config.go b/gobin/config.go index 7312410..0435691 100644 --- a/gobin/config.go +++ b/gobin/config.go @@ -20,12 +20,13 @@ type Config struct { JWTSecret string `cfg:"jwt_secret"` Preview *PreviewConfig `cfg:"preview"` Otel *OtelConfig `cfg:"otel"` + Webhook *WebhookConfig `cfg:"webhook"` CustomStyles string `cfg:"custom_styles"` DefaultStyle string `cfg:"default_style"` } func (c Config) String() string { - return fmt.Sprintf("\n Log: %s\n Debug: %t\n DevMode: %t\n ListenAddr: %s\n HTTPTimeout: %s\n Database: %s\n MaxDocumentSize: %d\n MaxHighlightSize: %d\n RateLimit: %s\n JWTSecret: %s\n Preview: %s\n Otel: %s\n CustomStyles: %s\n DefaultStyle: %s\n", + return fmt.Sprintf("\n Log: %s\n Debug: %t\n DevMode: %t\n ListenAddr: %s\n HTTPTimeout: %s\n Database: %s\n MaxDocumentSize: %d\n MaxHighlightSize: %d\n RateLimit: %s\n JWTSecret: %s\n Preview: %s\n Otel: %s\n Webhook: %s\n CustomStyles: %s\n DefaultStyle: %s\n", c.Log, c.Debug, c.DevMode, @@ -37,6 +38,7 @@ func (c Config) String() string { c.RateLimit, strings.Repeat("*", len(c.JWTSecret)), c.Preview, c.Otel, + c.Webhook, c.CustomStyles, c.DefaultStyle, ) @@ -181,3 +183,21 @@ func (c MetricsConfig) String() string { c.ListenAddr, ) } + +type WebhookConfig struct { + Timeout time.Duration `cfg:"timeout"` + MaxTries int `cfg:"max_tries"` + Backoff time.Duration `cfg:"backoff"` + BackoffFactor float64 `cfg:"backoff_factor"` + MaxBackoff time.Duration `cfg:"max_backoff"` +} + +func (c WebhookConfig) String() string { + return fmt.Sprintf("\n Timeout: %s\n MaxTries: %d\n Backoff: %s\n BackoffFactor: %f\n MaxBackoff: %s", + c.Timeout, + c.MaxTries, + c.Backoff, + c.BackoffFactor, + c.MaxBackoff, + ) +} diff --git a/gobin/database.go b/gobin/database.go index 8320e5e..46697db 100644 --- a/gobin/database.go +++ b/gobin/database.go @@ -6,8 +6,10 @@ import ( "database/sql/driver" _ "embed" "errors" + "fmt" "log/slog" "math/rand" + "strings" "time" "github.com/XSAM/otelsql" @@ -20,7 +22,7 @@ import ( "github.com/topi314/tint" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/semconv/v1.18.0" + "go.opentelemetry.io/otel/semconv/v1.21.0" "go.opentelemetry.io/otel/trace" "modernc.org/sqlite" _ "modernc.org/sqlite" @@ -28,6 +30,31 @@ import ( var chars = []rune("abcdefghijklmnopqrstuvwxyz0123456789") +type Document struct { + ID string `db:"id"` + Version int64 `db:"version"` + Content string `db:"content"` + Language string `db:"language"` +} + +type Webhook struct { + ID string `db:"id"` + DocumentID string `db:"document_id"` + URL string `db:"url"` + Secret string `db:"secret"` + Events string `db:"events"` +} + +type WebhookUpdate struct { + ID string `db:"id"` + DocumentID string `db:"document_id"` + Secret string `db:"secret"` + + NewURL string `db:"new_url"` + NewSecret string `db:"new_secret"` + NewEvents string `db:"new_events"` +} + func NewDB(ctx context.Context, cfg DatabaseConfig, schema string) (*DB, error) { var ( driverName string @@ -75,20 +102,20 @@ func NewDB(ctx context.Context, cfg DatabaseConfig, schema string) (*DB, error) }), ) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to open database: %w", err) } if err = otelsql.RegisterDBStatsMetrics(sqlDB, otelsql.WithAttributes(dbSystem)); err != nil { - return nil, err + return nil, fmt.Errorf("failed to register database stats metrics: %w", err) } dbx := sqlx.NewDb(sqlDB, driverName) if err = dbx.PingContext(ctx); err != nil { - return nil, err + return nil, fmt.Errorf("failed to ping database: %w", err) } // execute schema if _, err = dbx.ExecContext(ctx, schema); err != nil { - return nil, err + return nil, fmt.Errorf("failed to execute schema: %w", err) } cleanupContext, cancel := context.WithCancel(context.Background()) @@ -103,13 +130,6 @@ func NewDB(ctx context.Context, cfg DatabaseConfig, schema string) (*DB, error) return db, nil } -type Document struct { - ID string `db:"id"` - Version int64 `db:"version"` - Content string `db:"content"` - Language string `db:"language"` -} - type DB struct { dbx *sqlx.DB cleanupCancel context.CancelFunc @@ -147,21 +167,6 @@ func (d *DB) GetDocumentVersions(ctx context.Context, documentID string, withCon return docs, err } -func (d *DB) DeleteDocumentByVersion(ctx context.Context, documentID string, version int64) error { - res, err := d.dbx.ExecContext(ctx, "DELETE FROM documents WHERE id = $1 AND version = $2", documentID, version) - if err != nil { - return err - } - rows, err := res.RowsAffected() - if err != nil { - return err - } - if rows == 0 { - return sql.ErrNoRows - } - return nil -} - func (d *DB) GetVersionCount(ctx context.Context, documentID string) (int, error) { var count int err := d.dbx.GetContext(ctx, &count, "SELECT COUNT(*) FROM documents WHERE id = $1", documentID) @@ -222,8 +227,104 @@ func (d *DB) UpdateDocument(ctx context.Context, documentID string, content stri return doc, nil } -func (d *DB) DeleteDocument(ctx context.Context, documentID string) error { - res, err := d.dbx.ExecContext(ctx, "DELETE FROM documents WHERE id = $1", documentID) +func (d *DB) DeleteDocument(ctx context.Context, documentID string) (Document, error) { + var document Document + if err := d.dbx.GetContext(ctx, &document, "DELETE FROM documents WHERE id = $1 RETURNING *", documentID); err != nil { + return Document{}, err + } + + return document, nil +} + +func (d *DB) DeleteDocumentByVersion(ctx context.Context, documentID string, version int64) (Document, error) { + var document Document + if err := d.dbx.GetContext(ctx, "DELETE FROM documents WHERE id = $1 AND version = $2 returning *", documentID, version); err != nil { + return Document{}, err + } + + return document, nil +} + +func (d *DB) DeleteExpiredDocuments(ctx context.Context, expireAfter time.Duration) error { + _, err := d.dbx.ExecContext(ctx, "DELETE FROM documents WHERE version < $1", time.Now().Add(expireAfter).Unix()) + return err +} + +func (d *DB) GetWebhook(ctx context.Context, documentID string, webhookID string, secret string) (*Webhook, error) { + var webhook Webhook + err := d.dbx.GetContext(ctx, &webhook, "SELECT * FROM webhooks WHERE document_id = $1 AND id = $2 AND secret = $3", documentID, webhookID, secret) + if err != nil { + return nil, err + } + + return &webhook, nil +} + +func (d *DB) GetWebhooksByDocumentID(ctx context.Context, documentID string) ([]Webhook, error) { + var webhooks []Webhook + err := d.dbx.SelectContext(ctx, &webhooks, "SELECT * FROM webhooks WHERE document_id = $1", documentID) + if err != nil { + return nil, err + } + + return webhooks, nil +} + +func (d *DB) GetAndDeleteWebhooksByDocumentID(ctx context.Context, documentID string) ([]Webhook, error) { + var webhooks []Webhook + err := d.dbx.SelectContext(ctx, &webhooks, "DELETE FROM webhooks WHERE document_id = $1 RETURNING *", documentID) + if err != nil { + return nil, err + } + + return webhooks, nil +} + +func (d *DB) CreateWebhook(ctx context.Context, documentID string, url string, secret string, events []string) (*Webhook, error) { + webhook := Webhook{ + ID: d.randomString(8), + DocumentID: documentID, + URL: url, + Secret: secret, + Events: strings.Join(events, ","), + } + + if _, err := d.dbx.NamedExecContext(ctx, "INSERT INTO webhooks (id, document_id, url, secret, events) VALUES (:id, :document_id, :url, :secret, :events)", webhook); err != nil { + return nil, fmt.Errorf("failed to insert webhook: %w", err) + } + + return &webhook, nil +} + +func (d *DB) UpdateWebhook(ctx context.Context, documentID string, webhookID string, secret string, newURL string, newSecret string, newEvents []string) (*Webhook, error) { + webhookUpdate := WebhookUpdate{ + ID: webhookID, + DocumentID: documentID, + Secret: secret, + NewURL: newURL, + NewSecret: newSecret, + NewEvents: strings.Join(newEvents, ","), + } + + query, args, err := sqlx.Named(`UPDATE webhooks SET + url = CASE WHEN :new_url = '' THEN url ELSE :new_url END, + secret = CASE WHEN :new_secret = '' THEN secret ELSE :new_secret END, + events = CASE WHEN :new_events = '' THEN events ELSE :new_events END + WHERE document_id = :document_id AND id = :id AND secret = :secret returning *`, webhookUpdate) + if err != nil { + return nil, err + } + + var webhook Webhook + if err = d.dbx.GetContext(ctx, webhook, query, args...); err != nil { + return nil, err + } + + return &webhook, nil +} + +func (d *DB) DeleteWebhook(ctx context.Context, documentID string, webhookID string, secret string) error { + res, err := d.dbx.ExecContext(ctx, "DELETE FROM webhooks WHERE document_id = $1 AND id = $2 AND secret = $3", documentID, webhookID, secret) if err != nil { return err } @@ -238,11 +339,6 @@ func (d *DB) DeleteDocument(ctx context.Context, documentID string) error { return nil } -func (d *DB) DeleteExpiredDocuments(ctx context.Context, expireAfter time.Duration) error { - _, err := d.dbx.ExecContext(ctx, "DELETE FROM documents WHERE version < $1", time.Now().Add(expireAfter).Unix()) - return err -} - func (d *DB) cleanup(ctx context.Context, cleanUpInterval time.Duration, expireAfter time.Duration) { if expireAfter <= 0 { return @@ -252,8 +348,10 @@ func (d *DB) cleanup(ctx context.Context, cleanUpInterval time.Duration, expireA } slog.Info("Starting document cleanup...") ticker := time.NewTicker(cleanUpInterval) - defer ticker.Stop() - defer slog.Info("document cleanup stopped") + defer func() { + ticker.Stop() + slog.Info("document cleanup stopped") + }() for { select { diff --git a/gobin/handlers.go b/gobin/handlers.go index 9b32094..a5962f5 100644 --- a/gobin/handlers.go +++ b/gobin/handlers.go @@ -123,7 +123,7 @@ func (s *Server) GetPrettyDocument(w http.ResponseWriter, r *http.Request) { return } - versions := make([]templates.Version, 0, len(documents)) + versions := make([]templates.DocumentVersion, 0, len(documents)) for i, documentVersion := range documents { versionTime := time.Unix(documentVersion.Version, 0) versionLabel := humanize.Time(versionTime) @@ -132,7 +132,7 @@ func (s *Server) GetPrettyDocument(w http.ResponseWriter, r *http.Request) { } else if i == len(documents)-1 { versionLabel += " (original)" } - versions = append(versions, templates.Version{ + versions = append(versions, templates.DocumentVersion{ Version: documentVersion.Version, Label: versionLabel, Time: versionTime.Format(VersionTimeFormat), @@ -372,7 +372,7 @@ func (s *Server) PatchDocument(w http.ResponseWriter, r *http.Request) { documentID, extension := parseDocumentID(r) language := r.URL.Query().Get("language") - claims := s.GetClaims(r) + claims := GetClaims(r) if claims.Subject != documentID || !slices.Contains(claims.Permissions, PermissionWrite) { s.documentNotFound(w, r) return @@ -425,6 +425,13 @@ func (s *Server) PatchDocument(w http.ResponseWriter, r *http.Request) { } } + s.ExecuteWebhooks(r.Context(), WebhookEventUpdate, WebhookDocument{ + Key: document.ID, + Version: document.Version, + Language: finalLanguage, + Data: document.Content, + }) + versionTime := time.Unix(document.Version, 0) s.ok(w, r, DocumentResponse{ Key: document.ID, @@ -445,17 +452,20 @@ func (s *Server) DeleteDocument(w http.ResponseWriter, r *http.Request) { return } - claims := s.GetClaims(r) + claims := GetClaims(r) if claims.Subject != documentID || !slices.Contains(claims.Permissions, PermissionDelete) { s.documentNotFound(w, r) return } - var err error + var ( + document Document + err error + ) if version == 0 { - err = s.db.DeleteDocument(r.Context(), documentID) + document, err = s.db.DeleteDocument(r.Context(), documentID) } else { - err = s.db.DeleteDocumentByVersion(r.Context(), documentID, version) + document, err = s.db.DeleteDocumentByVersion(r.Context(), documentID, version) } if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -474,6 +484,14 @@ func (s *Server) DeleteDocument(w http.ResponseWriter, r *http.Request) { s.error(w, r, err, http.StatusInternalServerError) return } + + s.ExecuteWebhooks(r.Context(), WebhookEventDelete, WebhookDocument{ + Key: document.ID, + Version: document.Version, + Language: document.Language, + Data: document.Content, + }) + s.ok(w, r, DeleteResponse{ Versions: count, }) @@ -500,7 +518,7 @@ func (s *Server) PostDocumentShare(w http.ResponseWriter, r *http.Request) { } } - claims := s.GetClaims(r) + claims := GetClaims(r) if claims.Subject != documentID || !slices.Contains(claims.Permissions, PermissionShare) { s.documentNotFound(w, r) return @@ -508,7 +526,7 @@ func (s *Server) PostDocumentShare(w http.ResponseWriter, r *http.Request) { for _, permission := range shareRequest.Permissions { if !slices.Contains(claims.Permissions, permission) { - s.error(w, r, ErrPermissionDenied(permission), http.StatusForbidden) + s.error(w, r, ErrPermissionDenied(permission), http.StatusBadRequest) return } } diff --git a/gobin/inkscape.go b/gobin/inkscape.go index e34d2af..cda6de4 100644 --- a/gobin/inkscape.go +++ b/gobin/inkscape.go @@ -10,10 +10,11 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" ) func (s *Server) convertSVG2PNG(ctx context.Context, svg string) ([]byte, error) { - ctx, span := s.tracer.Start(ctx, "convertSVG2PNG") + ctx, span := s.tracer.Start(ctx, "convertSVG2PNG", trace.WithAttributes(attribute.String("inkscape", s.cfg.Preview.InkscapePath))) defer span.End() stdout := new(bytes.Buffer) @@ -24,7 +25,6 @@ func (s *Server) convertSVG2PNG(ctx context.Context, svg string) ([]byte, error) dpi = s.cfg.Preview.DPI } span.SetAttributes(attribute.Int("dpi", dpi)) - span.SetAttributes(attribute.String("inkscape", s.cfg.Preview.InkscapePath)) cmd := exec.CommandContext(ctx, s.cfg.Preview.InkscapePath, "-p", "-d", strconv.Itoa(dpi), "--convert-dpi-method=scale-viewbox", "--export-filename=-", "--export-type=png") cmd.Stdin = bytes.NewReader([]byte(svg)) diff --git a/gobin/jwt.go b/gobin/jwt.go index 850c3f7..f71198b 100644 --- a/gobin/jwt.go +++ b/gobin/jwt.go @@ -25,19 +25,21 @@ var ( type Permission string const ( - PermissionWrite Permission = "write" - PermissionDelete Permission = "delete" - PermissionShare Permission = "share" + PermissionWrite Permission = "write" + PermissionDelete Permission = "delete" + PermissionShare Permission = "share" + PermissionWebhook Permission = "webhook" ) var AllPermissions = []Permission{ PermissionWrite, PermissionDelete, PermissionShare, + PermissionWebhook, } func (p Permission) IsValid() bool { - return p == PermissionWrite || p == PermissionDelete || p == PermissionShare + return p == PermissionWrite || p == PermissionDelete || p == PermissionShare || p == PermissionWebhook } type Claims struct { @@ -73,13 +75,13 @@ func (s *Server) JWTMiddleware(next http.Handler) http.Handler { } } - ctx := context.WithValue(r.Context(), claimsContextKey, &claims) + ctx := context.WithValue(r.Context(), claimsContextKey, claims) next.ServeHTTP(w, r.WithContext(ctx)) }) } -func (s *Server) GetClaims(r *http.Request) *Claims { - return r.Context().Value(claimsContextKey).(*Claims) +func GetClaims(r *http.Request) Claims { + return r.Context().Value(claimsContextKey).(Claims) } func (s *Server) NewToken(documentID string, permissions []Permission) (string, error) { @@ -96,3 +98,11 @@ func newClaims(documentID string, permissions []Permission) Claims { Permissions: permissions, } } + +func GetWebhookSecret(r *http.Request) string { + secretStr := r.Header.Get("Authorization") + if len(secretStr) > 7 && strings.ToUpper(secretStr[0:6]) == "SECRET" { + return secretStr[7:] + } + return "" +} diff --git a/gobin/routes.go b/gobin/routes.go index eaab83f..0343f80 100644 --- a/gobin/routes.go +++ b/gobin/routes.go @@ -3,6 +3,7 @@ package gobin import ( "bytes" "database/sql" + "encoding/json" "errors" "fmt" "io" @@ -22,6 +23,8 @@ import ( "github.com/go-chi/stampede" "github.com/riandyrn/otelchi" "github.com/samber/slog-chi" + "github.com/topi314/gobin/templates" + "github.com/topi314/tint" ) const maxUnix = int(^int32(0)) @@ -108,6 +111,16 @@ func (s *Server) Routes() http.Handler { r.Patch("/", s.PatchDocument) r.Delete("/", s.DeleteDocument) r.Post("/share", s.PostDocumentShare) + + r.Route("/webhooks", func(r chi.Router) { + r.Post("/", s.PostDocumentWebhook) + r.Route("/{webhookID}", func(r chi.Router) { + r.Get("/", s.GetDocumentWebhook) + r.Patch("/", s.PatchDocumentWebhook) + r.Delete("/", s.DeleteDocumentWebhook) + }) + }) + previewHandler(r) r.Route("/versions", func(r chi.Router) { r.Get("/", s.DocumentVersions) @@ -314,3 +327,89 @@ func (s *Server) documentNotFound(w http.ResponseWriter, r *http.Request) { func (s *Server) rateLimit(w http.ResponseWriter, r *http.Request) { s.error(w, r, ErrRateLimit, http.StatusTooManyRequests) } + +func (s *Server) prettyError(w http.ResponseWriter, r *http.Request, err error, status int) { + w.WriteHeader(status) + + vars := templates.ErrorVars{ + Error: err.Error(), + Status: status, + RequestID: middleware.GetReqID(r.Context()), + Path: r.URL.Path, + } + if tmplErr := templates.Error(vars).Render(r.Context(), w); tmplErr != nil && !errors.Is(tmplErr, http.ErrHandlerTimeout) { + slog.ErrorContext(r.Context(), "failed to execute error template", tint.Err(tmplErr)) + } +} + +func (s *Server) error(w http.ResponseWriter, r *http.Request, err error, status int) { + if errors.Is(err, http.ErrHandlerTimeout) { + return + } + if status == http.StatusInternalServerError { + slog.ErrorContext(r.Context(), "internal server error", tint.Err(err)) + } + s.json(w, r, ErrorResponse{ + Message: err.Error(), + Status: status, + Path: r.URL.Path, + RequestID: middleware.GetReqID(r.Context()), + }, status) +} + +func (s *Server) ok(w http.ResponseWriter, r *http.Request, v any) { + if v == nil { + w.WriteHeader(http.StatusNoContent) + return + } + s.json(w, r, v, http.StatusOK) +} + +func (s *Server) json(w http.ResponseWriter, r *http.Request, v any, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if r.Method == http.MethodHead { + return + } + + if err := json.NewEncoder(w).Encode(v); err != nil && !errors.Is(err, http.ErrHandlerTimeout) { + slog.ErrorContext(r.Context(), "failed to encode json", tint.Err(err)) + } +} + +func (s *Server) exceedsMaxDocumentSize(w http.ResponseWriter, r *http.Request, content string) bool { + if s.cfg.MaxDocumentSize > 0 && len([]rune(content)) > s.cfg.MaxDocumentSize { + s.error(w, r, ErrContentTooLarge(s.cfg.MaxDocumentSize), http.StatusBadRequest) + return true + } + return false +} + +func (s *Server) file(path string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + file, err := s.assets.Open(path) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer file.Close() + _, _ = io.Copy(w, file) + } +} + +func (s *Server) shortContent(content string) string { + if s.cfg.Preview != nil && s.cfg.Preview.MaxLines > 0 { + var newLines int + maxNewLineIndex := strings.IndexFunc(content, func(r rune) bool { + if r == '\n' { + newLines++ + } + return newLines == s.cfg.Preview.MaxLines + }) + + if maxNewLineIndex > 0 { + content = content[:maxNewLineIndex] + } + } + return content +} diff --git a/gobin/server.go b/gobin/server.go index 7701a5a..0ec7aa9 100644 --- a/gobin/server.go +++ b/gobin/server.go @@ -1,24 +1,25 @@ package gobin import ( - "encoding/json" + "context" "errors" "fmt" - "io" "log/slog" "net/http" + "net/http/httptrace" "os" "runtime" - "strings" + "sync" "time" "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/styles" - "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/httprate" "github.com/go-jose/go-jose/v3" "github.com/topi314/gobin/templates" "github.com/topi314/tint" + "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" ) @@ -33,10 +34,19 @@ func NewServer(version string, debug bool, cfg Config, db *DB, signer jose.Signe } s := &Server{ - version: version, - debug: debug, - cfg: cfg, - db: db, + version: version, + debug: debug, + cfg: cfg, + db: db, + client: &http.Client{ + Transport: otelhttp.NewTransport( + http.DefaultTransport, + otelhttp.WithClientTrace(func(ctx context.Context) *httptrace.ClientTrace { + return otelhttptrace.NewClientTrace(ctx) + }), + ), + Timeout: cfg.Webhook.Timeout, + }, signer: signer, tracer: tracer, meter: meter, @@ -71,6 +81,7 @@ type Server struct { cfg Config db *DB server *http.Server + client *http.Client signer jose.Signer tracer trace.Tracer meter metric.Meter @@ -78,6 +89,7 @@ type Server struct { htmlFormatter *html.Formatter styles []templates.Style rateLimitHandler func(http.Handler) http.Handler + webhookWaitGroup sync.WaitGroup } func (s *Server) Start() { @@ -92,93 +104,13 @@ func (s *Server) Close() { slog.Error("Error while closing server", tint.Err(err)) } + s.webhookWaitGroup.Wait() + if err := s.db.Close(); err != nil { slog.Error("Error while closing database", tint.Err(err)) } } -func (s *Server) prettyError(w http.ResponseWriter, r *http.Request, err error, status int) { - w.WriteHeader(status) - - vars := templates.ErrorVars{ - Error: err.Error(), - Status: status, - RequestID: middleware.GetReqID(r.Context()), - Path: r.URL.Path, - } - if tmplErr := templates.Error(vars).Render(r.Context(), w); tmplErr != nil && !errors.Is(tmplErr, http.ErrHandlerTimeout) { - slog.ErrorContext(r.Context(), "failed to execute error template", tint.Err(tmplErr)) - } -} - -func (s *Server) error(w http.ResponseWriter, r *http.Request, err error, status int) { - if errors.Is(err, http.ErrHandlerTimeout) { - return - } - if status == http.StatusInternalServerError { - slog.ErrorContext(r.Context(), "internal server error", tint.Err(err)) - } - s.json(w, r, ErrorResponse{ - Message: err.Error(), - Status: status, - Path: r.URL.Path, - RequestID: middleware.GetReqID(r.Context()), - }, status) -} - -func (s *Server) ok(w http.ResponseWriter, r *http.Request, v any) { - s.json(w, r, v, http.StatusOK) -} - -func (s *Server) json(w http.ResponseWriter, r *http.Request, v any, status int) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - if r.Method == http.MethodHead { - return - } - - if err := json.NewEncoder(w).Encode(v); err != nil && !errors.Is(err, http.ErrHandlerTimeout) { - slog.ErrorContext(r.Context(), "failed to encode json", tint.Err(err)) - } -} - -func (s *Server) exceedsMaxDocumentSize(w http.ResponseWriter, r *http.Request, content string) bool { - if s.cfg.MaxDocumentSize > 0 && len([]rune(content)) > s.cfg.MaxDocumentSize { - s.error(w, r, ErrContentTooLarge(s.cfg.MaxDocumentSize), http.StatusBadRequest) - return true - } - return false -} - -func (s *Server) file(path string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - file, err := s.assets.Open(path) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer file.Close() - _, _ = io.Copy(w, file) - } -} - -func (s *Server) shortContent(content string) string { - if s.cfg.Preview != nil && s.cfg.Preview.MaxLines > 0 { - var newLines int - maxNewLineIndex := strings.IndexFunc(content, func(r rune) bool { - if r == '\n' { - newLines++ - } - return newLines == s.cfg.Preview.MaxLines - }) - - if maxNewLineIndex > 0 { - content = content[:maxNewLineIndex] - } - } - return content -} - func FormatBuildVersion(version string, commit string, buildTime time.Time) string { if len(commit) > 7 { commit = commit[:7] diff --git a/gobin/webhook.go b/gobin/webhook.go new file mode 100644 index 0000000..172f2b4 --- /dev/null +++ b/gobin/webhook.go @@ -0,0 +1,327 @@ +package gobin + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "log/slog" + "net/http" + "slices" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5" + "github.com/topi314/tint" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +var ( + ErrWebhookNotFound = errors.New("webhook not found") + ErrMissingWebhookSecret = errors.New("missing webhook secret") + ErrMissingWebhookURL = errors.New("missing webhook url") + ErrMissingWebhookEvents = errors.New("missing webhook events") + ErrMissingURLOrSecretOrEvents = errors.New("missing url, secret or events") +) + +type ( + WebhookCreateRequest struct { + URL string `json:"url"` + Secret string `json:"secret"` + Events []string `json:"events"` + } + + WebhookUpdateRequest struct { + URL string `json:"url"` + Secret string `json:"secret"` + Events []string `json:"events"` + } + + WebhookResponse struct { + ID string `json:"id"` + DocumentKey string `json:"document_key"` + URL string `json:"url"` + Secret string `json:"secret"` + Events []string `json:"events"` + } + + WebhookEventRequest struct { + WebhookID string `json:"webhook_id"` + Event string `json:"event"` + CreatedAt time.Time `json:"created_at"` + Document WebhookDocument `json:"document"` + } + + WebhookDocument struct { + Key string `json:"key"` + Version int64 `json:"version"` + Language string `json:"language"` + Data string `json:"data"` + } +) + +const ( + WebhookEventUpdate string = "update" + WebhookEventDelete string = "delete" +) + +func (s *Server) ExecuteWebhooks(ctx context.Context, event string, document WebhookDocument) { + s.webhookWaitGroup.Add(1) + ctx, span := s.tracer.Start(context.WithoutCancel(ctx), "executeWebhooks", trace.WithAttributes( + attribute.String("event", event), + attribute.String("document_id", document.Key), + )) + go func() { + defer span.End() + s.executeWebhooks(ctx, event, document) + }() +} + +func (s *Server) executeWebhooks(ctx context.Context, event string, document WebhookDocument) { + defer s.webhookWaitGroup.Done() + + dbCtx, cancel := context.WithTimeout(ctx, s.cfg.Webhook.Timeout) + defer cancel() + + var ( + webhooks []Webhook + err error + ) + if event == "delete" { + webhooks, err = s.db.GetAndDeleteWebhooksByDocumentID(dbCtx, document.Key) + } else { + webhooks, err = s.db.GetWebhooksByDocumentID(dbCtx, document.Key) + } + if err != nil { + slog.ErrorContext(dbCtx, "failed to get webhooks by document id", tint.Err(err)) + return + } + + if len(webhooks) == 0 { + return + } + + now := time.Now() + var wg sync.WaitGroup + for _, webhook := range webhooks { + if !slices.Contains(strings.Split(webhook.Events, ","), event) { + continue + } + + wg.Add(1) + go func(webhook Webhook) { + defer wg.Done() + s.executeWebhook(ctx, webhook.URL, webhook.Secret, WebhookEventRequest{ + WebhookID: webhook.ID, + Event: event, + CreatedAt: now, + Document: document, + }) + }(webhook) + } + wg.Wait() + + slog.DebugContext(ctx, "finished emitting webhooks", slog.String("event", event), slog.Any("document_id", document.Key)) +} + +func (s *Server) executeWebhook(ctx context.Context, url string, secret string, request WebhookEventRequest) { + ctx, span := s.tracer.Start(ctx, "executeWebhook", trace.WithAttributes( + attribute.String("url", url), + attribute.String("event", request.Event), + attribute.String("document_id", request.Document.Key), + )) + defer span.End() + + logger := slog.Default().With(slog.String("event", request.Event), slog.Any("webhook_id", request.WebhookID), slog.Any("document_id", request.Document.Key)) + logger.DebugContext(ctx, "emitting webhook", slog.String("url", url)) + + buff := new(bytes.Buffer) + if err := json.NewEncoder(buff).Encode(request); err != nil { + span.SetStatus(codes.Error, "failed to encode document") + span.RecordError(err) + logger.ErrorContext(ctx, "failed to encode document", tint.Err(err)) + return + } + + rq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, buff) + if err != nil { + span.SetStatus(codes.Error, "failed to create request") + span.RecordError(err) + logger.ErrorContext(ctx, "failed to create request", tint.Err(err)) + return + } + rq.Header.Add("Content-Type", "application/json") + rq.Header.Add("User-Agent", "gobin") + rq.Header.Add("Authorization", "Secret "+secret) + + for i := 0; i < s.cfg.Webhook.MaxTries; i++ { + backoff := time.Duration(s.cfg.Webhook.BackoffFactor * float64(s.cfg.Webhook.Backoff) * float64(i)) + if backoff > time.Nanosecond { + if backoff > s.cfg.Webhook.MaxBackoff { + backoff = s.cfg.Webhook.MaxBackoff + } + logger.DebugContext(ctx, "sleeping backoff", slog.Duration("backoff", backoff)) + time.Sleep(backoff) + } + + rs, err := s.client.Do(rq) + if err != nil { + logger.DebugContext(ctx, "failed to execute request", tint.Err(err)) + continue + } + + if rs.StatusCode < 200 || rs.StatusCode >= 300 { + logger.DebugContext(ctx, "invalid status code", slog.Int("status", rs.StatusCode)) + continue + } + + logger.DebugContext(ctx, "successfully executed webhook", slog.String("status", rs.Status)) + return + } + + err = errors.New("max tries reached") + span.SetStatus(codes.Error, "failed to execute webhook") + span.RecordError(err) + logger.ErrorContext(ctx, "failed to execute webhook", tint.Err(err)) +} + +func (s *Server) PostDocumentWebhook(w http.ResponseWriter, r *http.Request) { + documentID := chi.URLParam(r, "documentID") + + var webhookCreate WebhookCreateRequest + if err := json.NewDecoder(r.Body).Decode(&webhookCreate); err != nil { + s.error(w, r, err, http.StatusBadRequest) + return + } + + if webhookCreate.URL == "" { + s.error(w, r, ErrMissingWebhookURL, http.StatusBadRequest) + return + } + + if webhookCreate.Secret == "" { + s.error(w, r, ErrMissingWebhookSecret, http.StatusBadRequest) + return + } + + if len(webhookCreate.Events) == 0 { + s.error(w, r, ErrMissingWebhookEvents, http.StatusBadRequest) + return + } + + claims := GetClaims(r) + if !slices.Contains(claims.Permissions, PermissionWebhook) { + s.error(w, r, ErrPermissionDenied(PermissionWebhook), http.StatusForbidden) + return + } + + webhook, err := s.db.CreateWebhook(r.Context(), documentID, webhookCreate.URL, webhookCreate.Secret, webhookCreate.Events) + if err != nil { + s.error(w, r, err, http.StatusInternalServerError) + return + } + + s.ok(w, r, WebhookResponse{ + ID: webhook.ID, + DocumentKey: webhook.DocumentID, + URL: webhook.URL, + Secret: webhook.Secret, + Events: strings.Split(webhook.Events, ","), + }) +} + +func (s *Server) GetDocumentWebhook(w http.ResponseWriter, r *http.Request) { + documentID := chi.URLParam(r, "documentID") + webhookID := chi.URLParam(r, "webhookID") + secret := GetWebhookSecret(r) + if secret == "" { + s.error(w, r, ErrMissingWebhookSecret, http.StatusBadRequest) + return + } + + webhook, err := s.db.GetWebhook(r.Context(), documentID, webhookID, secret) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + s.webhookNotFound(w, r) + return + } + s.error(w, r, err, http.StatusInternalServerError) + return + } + + s.ok(w, r, WebhookResponse{ + ID: webhook.ID, + DocumentKey: webhook.DocumentID, + URL: webhook.URL, + Secret: webhook.Secret, + Events: strings.Split(webhook.Events, ","), + }) +} + +func (s *Server) PatchDocumentWebhook(w http.ResponseWriter, r *http.Request) { + documentID := chi.URLParam(r, "documentID") + webhookID := chi.URLParam(r, "webhookID") + secret := GetWebhookSecret(r) + if secret == "" { + s.error(w, r, ErrMissingWebhookSecret, http.StatusBadRequest) + return + } + + var webhookUpdate WebhookUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&webhookUpdate); err != nil { + s.error(w, r, err, http.StatusBadRequest) + return + } + + if webhookUpdate.URL == "" && webhookUpdate.Secret == "" && len(webhookUpdate.Events) == 0 { + s.error(w, r, ErrMissingURLOrSecretOrEvents, http.StatusBadRequest) + return + } + + webhook, err := s.db.UpdateWebhook(r.Context(), documentID, webhookID, secret, webhookUpdate.URL, webhookUpdate.Secret, webhookUpdate.Events) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + s.webhookNotFound(w, r) + return + } + s.error(w, r, err, http.StatusInternalServerError) + return + } + + s.ok(w, r, WebhookResponse{ + ID: webhook.ID, + DocumentKey: webhook.DocumentID, + URL: webhook.URL, + Secret: webhook.Secret, + Events: strings.Split(webhook.Events, ","), + }) +} + +func (s *Server) DeleteDocumentWebhook(w http.ResponseWriter, r *http.Request) { + documentID := chi.URLParam(r, "documentID") + webhookID := chi.URLParam(r, "webhookID") + secret := GetWebhookSecret(r) + if secret == "" { + s.error(w, r, ErrMissingWebhookSecret, http.StatusBadRequest) + return + } + + if err := s.db.DeleteWebhook(r.Context(), documentID, webhookID, secret); err != nil { + if errors.Is(err, sql.ErrNoRows) { + s.webhookNotFound(w, r) + return + } + s.error(w, r, err, http.StatusInternalServerError) + return + } + + s.ok(w, r, nil) +} + +func (s *Server) webhookNotFound(w http.ResponseWriter, r *http.Request) { + s.error(w, r, ErrWebhookNotFound, http.StatusNotFound) +} diff --git a/otel.go b/otel.go index 82513f0..a9c0139 100644 --- a/otel.go +++ b/otel.go @@ -18,7 +18,7 @@ import ( sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" - "go.opentelemetry.io/otel/semconv/v1.18.0" + "go.opentelemetry.io/otel/semconv/v1.21.0" "go.opentelemetry.io/otel/trace" ) diff --git a/sql/migration.sql b/sql/migration.sql index c0dd721..b581ea3 100644 --- a/sql/migration.sql +++ b/sql/migration.sql @@ -1,3 +1,13 @@ +--- v1.3.0 -> v1.4.0 +CREATE TABLE IF NOT EXISTS webhooks +( + id VARCHAR NOT NULL, + document_id VARCHAR NOT NULL, + url VARCHAR NOT NULL, + secret VARCHAR NOT NULL, + events VARCHAR NOT NULL, + PRIMARY KEY (id) +); --- v1.2.0 -> v1.3.0 ALTER TABLE documents DROP COLUMN update_token; --- v1.1.0 -> v1.2.0 diff --git a/sql/schema.sql b/sql/schema.sql index 329260c..e1e76f4 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -6,3 +6,13 @@ CREATE TABLE IF NOT EXISTS documents language VARCHAR NOT NULL, PRIMARY KEY (id, version) ); + +CREATE TABLE IF NOT EXISTS webhooks +( + id VARCHAR NOT NULL, + document_id VARCHAR NOT NULL, + url VARCHAR NOT NULL, + secret VARCHAR NOT NULL, + events VARCHAR NOT NULL, + PRIMARY KEY (id) +); diff --git a/templates/document.templ b/templates/document.templ index d6e74af..431c9ab 100644 --- a/templates/document.templ +++ b/templates/document.templ @@ -1,77 +1,9 @@ package templates import ( - "fmt" "strconv" ) -func WriteUnsafe(str string) templ.Component { - return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { - _, err := w.Write([]byte(str)) - return err - }) -} - -type DocumentVars struct { - ID string - Version int64 - Content string - Formatted string - CSS string - ThemeCSS string - Language string - - Versions []Version - Lexers []string - Styles []Style - Style string - Theme string - - Max int - Host string - Preview bool - PreviewAlt string -} - -func (v DocumentVars) GetThemeCSS() string { - return fmt.Sprintf(` - - `, v.ThemeCSS) -} - -func (v DocumentVars) GetCSS() string { - return fmt.Sprintf(` - - `, v.CSS) -} - -type Version struct { - Version int64 - Label string - Time string -} - -type Style struct { - Name string - Theme string -} - -func (v DocumentVars) PreviewURL() string { - url := "https://" + v.Host + "/" + v.ID - if v.Version > 0 { - url += "/" + strconv.FormatInt(v.Version, 10) - } - return url + "/preview" -} - -func (v DocumentVars) URL() string { - return "https://" + v.Host -} - templ Document(vars DocumentVars) { @@ -141,4 +73,4 @@ templ Document(vars DocumentVars) { -} \ No newline at end of file +} diff --git a/templates/document_templ.go b/templates/document_templ.go index fa08ff6..dcb9195 100644 --- a/templates/document_templ.go +++ b/templates/document_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: 0.2.432 +// templ: version: 0.2.476 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -11,77 +11,9 @@ import "io" import "bytes" import ( - "fmt" "strconv" ) -func WriteUnsafe(str string) templ.Component { - return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { - _, err := w.Write([]byte(str)) - return err - }) -} - -type DocumentVars struct { - ID string - Version int64 - Content string - Formatted string - CSS string - ThemeCSS string - Language string - - Versions []Version - Lexers []string - Styles []Style - Style string - Theme string - - Max int - Host string - Preview bool - PreviewAlt string -} - -func (v DocumentVars) GetThemeCSS() string { - return fmt.Sprintf(` - - `, v.ThemeCSS) -} - -func (v DocumentVars) GetCSS() string { - return fmt.Sprintf(` - - `, v.CSS) -} - -type Version struct { - Version int64 - Label string - Time string -} - -type Style struct { - Name string - Theme string -} - -func (v DocumentVars) PreviewURL() string { - url := "https://" + v.Host + "/" + v.ID - if v.Version > 0 { - url += "/" + strconv.FormatInt(v.Version, 10) - } - return url + "/preview" -} - -func (v DocumentVars) URL() string { - return "https://" + v.Host -} - func Document(vars DocumentVars) templ.Component { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) diff --git a/templates/error.templ b/templates/error.templ index 563ec77..89b569b 100644 --- a/templates/error.templ +++ b/templates/error.templ @@ -4,13 +4,6 @@ import ( "strconv" ) -type ErrorVars struct { - Error string - Status int - Path string - RequestID string -} - templ Error(vars ErrorVars) { @@ -51,4 +44,4 @@ templ Error(vars ErrorVars) { -} \ No newline at end of file +} diff --git a/templates/error_templ.go b/templates/error_templ.go index 78b03b5..ebfcad7 100644 --- a/templates/error_templ.go +++ b/templates/error_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: 0.2.432 +// templ: version: 0.2.476 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -14,13 +14,6 @@ import ( "strconv" ) -type ErrorVars struct { - Error string - Status int - Path string - RequestID string -} - func Error(vars ErrorVars) templ.Component { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) diff --git a/templates/head_templ.go b/templates/head_templ.go index 53859af..d87e7c6 100644 --- a/templates/head_templ.go +++ b/templates/head_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: 0.2.432 +// templ: version: 0.2.476 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/templates/header_templ.go b/templates/header_templ.go index 2319e0f..204a7be 100644 --- a/templates/header_templ.go +++ b/templates/header_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: 0.2.432 +// templ: version: 0.2.476 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/templates/models.go b/templates/models.go new file mode 100644 index 0000000..d069f1d --- /dev/null +++ b/templates/models.go @@ -0,0 +1,84 @@ +package templates + +import ( + "context" + "fmt" + "io" + "strconv" + + "github.com/a-h/templ" +) + +func WriteUnsafe(str string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { + _, err := w.Write([]byte(str)) + return err + }) +} + +type DocumentVars struct { + ID string + Version int64 + Content string + Formatted string + CSS string + ThemeCSS string + Language string + + Versions []DocumentVersion + Lexers []string + Styles []Style + Style string + Theme string + + Max int + Host string + Preview bool + PreviewAlt string +} + +func (v DocumentVars) GetThemeCSS() string { + return fmt.Sprintf(` + + `, v.ThemeCSS) +} + +func (v DocumentVars) GetCSS() string { + return fmt.Sprintf(` + + `, v.CSS) +} + +func (v DocumentVars) PreviewURL() string { + url := "https://" + v.Host + "/" + v.ID + if v.Version > 0 { + url += "/" + strconv.FormatInt(v.Version, 10) + } + return url + "/preview" +} + +func (v DocumentVars) URL() string { + return "https://" + v.Host +} + +type DocumentVersion struct { + Version int64 + Label string + Time string +} + +type Style struct { + Name string + Theme string +} + +type ErrorVars struct { + Error string + Status int + Path string + RequestID string +}