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) {