Compare commits

...

15 Commits

Author SHA1 Message Date
TwiN
8a4db600c9 test: Add tests for endpoint display name 2022-10-09 21:34:36 -04:00
TwiN
02879e2645 ci: Bump docker/build-push-action to v3 and add "stable" tag 2022-10-09 21:33:55 -04:00
TwiN
00b56ecefd feat: Bundle assets in binary using go:embed (#340)
Fixes #47
2022-10-09 21:33:31 -04:00
TwiN
47dd18a0b5 test(alerting): Add coverage for ntfy's request body 2022-10-09 16:45:01 -04:00
TwiN
1a708ebca2 test(alerting): Fix tests following change to defaults 2022-10-09 16:45:01 -04:00
TwiN
5f8e62dad0 fix(alerting): Make priority and url optional for ntfy 2022-10-09 16:45:01 -04:00
TwiN
b74f7758dc docs(alerting): Document how to configure ntfy alerts 2022-10-09 16:45:01 -04:00
TwiN
899c19b2d7 fix: Swap tag for resolved and triggered 2022-10-09 16:45:01 -04:00
TwiN
35038a63c4 feat(alerting): Implement ntfy provider
Closes #308

Work remaining:
- Add the documentation on the README.md
- Test it with an actual Ntfy instance (I've only used https://ntfy.sh/docs/examples/#gatus as a reference; I haven't actually tested it yet)
2022-10-09 16:45:01 -04:00
TwiN
7b2af3c514 chore: Fix alerting provider order 2022-10-09 16:45:01 -04:00
TwiN
4ab7428599 chore: Format code 2022-10-09 16:45:01 -04:00
TwiN
be88af5d48 chore: Update Go to 1.19 + Update dependencies 2022-09-21 20:16:00 -04:00
TwiN
5bb3f6d0a9 refactor: Use %w instead of %s for formatting errors 2022-09-20 21:54:59 -04:00
TwiN
17c14a7243 docs(alerting): Provide better Matrix examples 2022-09-19 22:08:39 -04:00
TwiN
f44d4055e6 refactor(alerting): Clean up Matrix code 2022-09-19 22:08:18 -04:00
35 changed files with 564 additions and 181 deletions

View File

@@ -23,10 +23,9 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push docker image
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
tags: ${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }},${{ env.IMAGE_REPOSITORY }}:stable

View File

@@ -13,7 +13,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus
FROM scratch
COPY --from=builder /app/gatus .
COPY --from=builder /app/config.yaml ./config/config.yaml
COPY --from=builder /app/web/static ./web/static
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENV PORT=8080
EXPOSE ${PORT}

View File

@@ -45,6 +45,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring Matrix alerts](#configuring-matrix-alerts)
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
- [Configuring Ntfy alerts](#configuring-ntfy-alerts)
- [Configuring Opsgenie alerts](#configuring-opsgenie-alerts)
- [Configuring PagerDuty alerts](#configuring-pagerduty-alerts)
- [Configuring Slack alerts](#configuring-slack-alerts)
@@ -349,6 +350,7 @@ endpoints:
- "[STATUS] == 200"
```
### Alerting
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
individual endpoints with configurable descriptions and thresholds.
@@ -361,8 +363,10 @@ ignored.
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
| `alerting.email` | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
@@ -371,6 +375,7 @@ ignored.
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
#### Configuring Discord alerts
| Parameter | Description | Default |
@@ -402,6 +407,7 @@ endpoints:
send-on-resolved: true
```
#### Configuring Email alerts
| Parameter | Description | Default |
@@ -463,6 +469,7 @@ endpoints:
**NOTE:** Some mail servers are painfully slow.
#### Configuring Google Chat alerts
| Parameter | Description | Default |
@@ -483,7 +490,7 @@ alerting:
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
@@ -495,25 +502,26 @@ endpoints:
send-on-resolved: true
```
#### Configuring Matrix alerts
| Parameter | Description | Default |
|:-----------------------------------|:-------------------------------------------------------------------------------------------------|:-----------------------------------|
| `alerting.matrix` | Settings for alerts of type `matrix` | `{}` |
| `alerting.matrix.server-url` | Homeserver URL | `https://matrix-client.matrix.org` |
| `alerting.matrix.access-token` | Bot user access token (see https://webapps.stackexchange.com/q/131056) | Required `""` |
| `alerting.matrix.internal-room-id` | Internal room ID of room to send alerts to (can be found in Element in Room Settings > Advanced) | Required `""` |
| `alerting.matrix.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
| Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:-----------------------------------|
| `alerting.matrix` | Configuration for alerts of type `matrix` | `{}` |
| `alerting.matrix.server-url` | Homeserver URL | `https://matrix-client.matrix.org` |
| `alerting.matrix.access-token` | Bot user access token (see https://webapps.stackexchange.com/q/131056) | Required `""` |
| `alerting.matrix.internal-room-id` | Internal room ID of room to send alerts to (can be found in Room Settings > Advanced) | Required `""` |
| `alerting.matrix.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
```yaml
alerting:
matrix:
server-url: "..."
access-token: "..."
internal-room-id: "..."
server-url: "https://matrix-client.matrix.org"
access-token: "123456"
internal-room-id: "!example:matrix.org"
endpoints:
- name: website
interval: 30s
interval: 5m
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
@@ -526,6 +534,7 @@ endpoints:
description: "healthcheck failed"
```
#### Configuring Mattermost alerts
| Parameter | Description | Default |
|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
@@ -547,7 +556,7 @@ alerting:
endpoints:
- name: website
url: "https://twin.sh/health"
interval: 30s
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
@@ -563,10 +572,11 @@ Here's an example of what the notifications look like:
![Mattermost notifications](.github/assets/mattermost-alerts.png)
#### Configuring Messagebird alerts
| Parameter | Description | Default |
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
| `alerting.messagebird` | Settings for alerts of type `messagebird` | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird` | `{}` |
| `alerting.messagebird.access-key` | Messagebird access key | Required `""` |
| `alerting.messagebird.originator` | The sender of the message | Required `""` |
| `alerting.messagebird.recipients` | The recipients of the message | Required `""` |
@@ -582,7 +592,7 @@ alerting:
endpoints:
- name: website
interval: 30s
interval: 5m
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
@@ -597,6 +607,42 @@ endpoints:
```
#### Configuring Ntfy alerts
| Parameter | Description | Default |
|:------------------------------|:-------------------------------------------------------------------------------------------|:------------------|
| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` |
| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` |
| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` |
| `alerting.ntfy.priority` | The priority of the alert | `3` |
| `alerting.ntfy.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
[ntfy](https://github.com/binwiederhier/ntfy) is an amazing project that allows you to subscribe to desktop
and mobile notifications, making it an awesome addition to Gatus.
Example:
```yaml
alerting:
ntfy:
topic: "gatus-test-topic"
priority: 2
default-alert:
enabled: true
failure-threshold: 3
send-on-resolved: true
endpoints:
- name: website
interval: 5m
url: "https://twin.sh/health"
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: ntfy
```
#### Configuring Opsgenie alerts
| Parameter | Description | Default |
|:----------------------------------|:-------------------------------------------------------------------------------------------|:---------------------|

View File

@@ -26,6 +26,12 @@ const (
// TypeMessagebird is the Type for the messagebird alerting provider
TypeMessagebird Type = "messagebird"
// TypeNtfy is the Type for the ntfy alerting provider
TypeNtfy Type = "ntfy"
// TypeOpsgenie is the Type for the opsgenie alerting provider
TypeOpsgenie Type = "opsgenie"
// TypePagerDuty is the Type for the pagerduty alerting provider
TypePagerDuty Type = "pagerduty"
@@ -40,7 +46,4 @@ const (
// TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio"
// TypeOpsgenie is the Type for the opsgenie alerting provider
TypeOpsgenie Type = "opsgenie"
)

View File

@@ -10,6 +10,7 @@ import (
"github.com/TwiN/gatus/v4/alerting/provider/matrix"
"github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v4/alerting/provider/ntfy"
"github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v4/alerting/provider/slack"
@@ -41,6 +42,12 @@ type Config struct {
// Messagebird is the configuration for the messagebird alerting provider
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
// Ntfy is the configuration for the ntfy alerting provider
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
// Opsgenie is the configuration for the opsgenie alerting provider
Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
// PagerDuty is the configuration for the pagerduty alerting provider
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
@@ -55,9 +62,6 @@ type Config struct {
// Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
// Opsgenie is the configuration for the opsgenie alerting provider
Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
}
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
@@ -105,6 +109,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid
return nil
}
return config.Messagebird
case alert.TypeNtfy:
if config.Ntfy == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil
return nil
}
return config.Ntfy
case alert.TypeOpsgenie:
if config.Opsgenie == nil {
// Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil

View File

@@ -159,31 +159,21 @@ func (provider *AlertProvider) getConfigForGroup(group string) MatrixProviderCon
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return MatrixProviderConfig{
ServerURL: override.ServerURL,
AccessToken: override.AccessToken,
InternalRoomID: override.InternalRoomID,
}
return override.MatrixProviderConfig
}
}
}
return MatrixProviderConfig{
ServerURL: provider.ServerURL,
AccessToken: provider.AccessToken,
InternalRoomID: provider.InternalRoomID,
}
return provider.MatrixProviderConfig
}
func randStringBytes(n int) string {
// All the compatible characters to use in a transaction ID
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
rand.Seed(time.Now().UnixNano())
for i := range b {
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
}
return string(b)
}

View File

@@ -0,0 +1,84 @@
package ntfy
import (
"bytes"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/core"
)
const (
DefaultURL = "https://ntfy.sh"
DefaultPriority = 3
)
// AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct {
Topic string `yaml:"topic"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if len(provider.URL) == 0 {
provider.URL = DefaultURL
}
if provider.Priority == 0 {
provider.Priority = DefaultPriority
}
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6
}
// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
var message, tag string
if len(alert.GetDescription()) > 0 {
message = endpoint.DisplayName() + " - " + alert.GetDescription()
} else {
message = endpoint.DisplayName()
}
if resolved {
tag = "white_check_mark"
} else {
tag = "x"
}
return fmt.Sprintf(`{
"topic": "%s",
"title": "Gatus",
"message": "%s",
"tags": ["%s"],
"priority": %d
}`, provider.Topic, message, tag, provider.Priority)
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}

View File

@@ -0,0 +1,104 @@
package ntfy
import (
"encoding/json"
"testing"
"github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/core"
)
func TestAlertDefaultProvider_IsValid(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
expected: true,
},
{
name: "no-url-should-use-default-value",
provider: AlertProvider{Topic: "example", Priority: 1},
expected: true,
},
{
name: "invalid-topic",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "", Priority: 1},
expected: false,
},
{
name: "invalid-priority-too-high",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 6},
expected: false,
},
{
name: "invalid-priority-too-low",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: -1},
expected: false,
},
{
name: "no-priority-should-use-default-value",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example"},
expected: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if scenario.provider.IsValid() != scenario.expected {
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\n \"topic\": \"example\",\n \"title\": \"Gatus\",\n \"message\": \"endpoint-name - description-1\",\n \"tags\": [\"x\"],\n \"priority\": 1\n}",
},
{
Name: "resolved",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\n \"topic\": \"example\",\n \"title\": \"Gatus\",\n \"message\": \"endpoint-name - description-2\",\n \"tags\": [\"white_check_mark\"],\n \"priority\": 2\n}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if body != scenario.ExpectedBody {
t.Errorf("expected %s, got %s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/TwiN/gatus/v4/alerting/provider/matrix"
"github.com/TwiN/gatus/v4/alerting/provider/mattermost"
"github.com/TwiN/gatus/v4/alerting/provider/messagebird"
"github.com/TwiN/gatus/v4/alerting/provider/ntfy"
"github.com/TwiN/gatus/v4/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v4/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v4/alerting/provider/slack"
@@ -61,6 +62,7 @@ var (
_ AlertProvider = (*matrix.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*ntfy.AlertProvider)(nil)
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil)

View File

@@ -20,8 +20,8 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
if !validProvider.IsValid() {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
@@ -58,6 +58,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"

View File

@@ -306,6 +306,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
alert.TypeMatrix,
alert.TypeMattermost,
alert.TypeMessagebird,
alert.TypeNtfy,
alert.TypeOpsgenie,
alert.TypePagerDuty,
alert.TypeSlack,

View File

@@ -17,7 +17,6 @@ import (
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage"
@@ -39,10 +38,6 @@ func TestLoadDefaultConfigurationFile(t *testing.T) {
func TestParseAndValidateConfigBytes(t *testing.T) {
file := t.TempDir() + "/test.db"
ui.StaticFolder = "../web/static"
defer func() {
ui.StaticFolder = "./web/static"
}()
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
storage:
type: sqlite

View File

@@ -4,6 +4,8 @@ import (
"bytes"
"errors"
"html/template"
"github.com/TwiN/gatus/v4/web"
)
const (
@@ -14,10 +16,6 @@ const (
)
var (
// StaticFolder is the path to the location of the static folder from the root path of the project
// The only reason this is exposed is to allow running tests from a different path than the root path of the project
StaticFolder = "./web/static"
ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link")
)
@@ -71,7 +69,7 @@ func (cfg *Config) ValidateAndSetDefaults() error {
}
}
// Validate that the template works
t, err := template.ParseFiles(StaticFolder + "/index.html")
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil {
return err
}

View File

@@ -6,10 +6,6 @@ import (
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
StaticFolder = "../../web/static"
defer func() {
StaticFolder = "./web/static"
}()
cfg := &Config{
Title: "",
Header: "",

View File

@@ -9,7 +9,6 @@ import (
"time"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/controller/handler"
)
@@ -21,7 +20,7 @@ var (
// Handle creates the router and starts the server
func Handle(cfg *config.Config) {
var router http.Handler = handler.CreateRouter(ui.StaticFolder, cfg)
var router http.Handler = handler.CreateRouter(cfg)
if os.Getenv("ENVIRONMENT") == "dev" {
router = handler.DevelopmentCORS(router)
}

View File

@@ -62,7 +62,7 @@ func TestBadge(t *testing.T) {
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg)
router := CreateRouter(cfg)
type Scenario struct {
Name string
Path string

View File

@@ -30,7 +30,7 @@ func TestResponseTimeChart(t *testing.T) {
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg)
router := CreateRouter(cfg)
type Scenario struct {
Name string
Path string

View File

@@ -97,7 +97,7 @@ func TestEndpointStatus(t *testing.T) {
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg)
router := CreateRouter(cfg)
type Scenario struct {
Name string
@@ -153,7 +153,7 @@ func TestEndpointStatuses(t *testing.T) {
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{}
router := CreateRouter("../../web/static", &config.Config{Metrics: true})
router := CreateRouter(&config.Config{Metrics: true})
type Scenario struct {
Name string

View File

@@ -1,12 +0,0 @@
package handler
import (
"net/http"
)
// FavIcon handles requests for /favicon.ico
func FavIcon(staticFolder string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
http.ServeFile(writer, request, staticFolder+"/favicon.ico")
}
}

View File

@@ -1,35 +0,0 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/TwiN/gatus/v4/config"
)
func TestFavIcon(t *testing.T) {
router := CreateRouter("../../web/static", &config.Config{})
type Scenario struct {
Name string
Path string
ExpectedCode int
}
scenarios := []Scenario{
{
Name: "favicon",
Path: "/favicon.ico",
ExpectedCode: http.StatusOK,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
responseRecorder := httptest.NewRecorder()
router.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != scenario.ExpectedCode {
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
}
})
}
}

View File

@@ -1,15 +1,17 @@
package handler
import (
"io/fs"
"net/http"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/web"
"github.com/TwiN/health"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func CreateRouter(staticFolder string, cfg *config.Config) *mux.Router {
func CreateRouter(cfg *config.Config) *mux.Router {
router := mux.NewRouter()
if cfg.Metrics {
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
@@ -35,11 +37,14 @@ func CreateRouter(staticFolder string, cfg *config.Config) *mux.Router {
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
// Misc
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET")
// SPA
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, cfg.UI)).Methods("GET")
router.HandleFunc("/", SinglePageApplication(staticFolder, cfg.UI)).Methods("GET")
router.HandleFunc("/endpoints/{name}", SinglePageApplication(cfg.UI)).Methods("GET")
router.HandleFunc("/", SinglePageApplication(cfg.UI)).Methods("GET")
// Everything else falls back on static content
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
staticFileSystem, err := fs.Sub(static.FileSystem, static.RootPath)
if err != nil {
panic(err)
}
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.FS(staticFileSystem))))
return router
}

View File

@@ -9,7 +9,7 @@ import (
)
func TestCreateRouter(t *testing.T) {
router := CreateRouter("../../web/static", &config.Config{Metrics: true})
router := CreateRouter(&config.Config{Metrics: true})
type Scenario struct {
Name string
Path string
@@ -28,16 +28,32 @@ func TestCreateRouter(t *testing.T) {
ExpectedCode: http.StatusOK,
},
{
Name: "scripts",
Name: "favicon.ico",
Path: "/favicon.ico",
ExpectedCode: http.StatusOK,
},
{
Name: "app.js",
Path: "/js/app.js",
ExpectedCode: http.StatusOK,
},
{
Name: "scripts-gzipped",
Name: "app.js-gzipped",
Path: "/js/app.js",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "chunk-vendors.js",
Path: "/js/chunk-vendors.js",
ExpectedCode: http.StatusOK,
},
{
Name: "chunk-vendors.js-gzipped",
Path: "/js/chunk-vendors.js",
ExpectedCode: http.StatusOK,
Gzip: true,
},
{
Name: "index-redirect",
Path: "/index.html",

View File

@@ -1,26 +1,30 @@
package handler
import (
_ "embed"
"html/template"
"log"
"net/http"
"github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/web"
)
func SinglePageApplication(staticFolder string, ui *ui.Config) http.HandlerFunc {
func SinglePageApplication(ui *ui.Config) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
t, err := template.ParseFiles(staticFolder + "/index.html")
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil {
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
http.ServeFile(writer, request, staticFolder+"/index.html")
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
log.Println("[handler][SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", err.Error())
http.Error(writer, "Failed to parse template. This should never happen, because the template is validated on start.", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "text/html")
err = t.Execute(writer, ui)
if err != nil {
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
http.ServeFile(writer, request, staticFolder+"/index.html")
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
log.Println("[handler][SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", err.Error())
http.Error(writer, "Failed to execute template. This should never happen, because the template is validated on start.", http.StatusInternalServerError)
return
}
}

View File

@@ -30,7 +30,7 @@ func TestSinglePageApplication(t *testing.T) {
}
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg)
router := CreateRouter(cfg)
type Scenario struct {
Name string
Path string

View File

@@ -682,6 +682,15 @@ func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
}
}
func TestEndpoint_DisplayName(t *testing.T) {
if endpoint := (Endpoint{Name: "n"}); endpoint.DisplayName() != "n" {
t.Error("endpoint.DisplayName() should've been 'n', but was", endpoint.DisplayName())
}
if endpoint := (Endpoint{Group: "g", Name: "n"}); endpoint.DisplayName() != "g/n" {
t.Error("endpoint.DisplayName() should've been 'g/n', but was", endpoint.DisplayName())
}
}
func TestEndpoint_getIP(t *testing.T) {
endpoint := Endpoint{
Name: "invalid-url-test",

6
go.mod
View File

@@ -1,10 +1,10 @@
module github.com/TwiN/gatus/v4
go 1.18
go 1.19
require (
github.com/TwiN/g8 v1.3.0
github.com/TwiN/gocache/v2 v2.1.0
github.com/TwiN/g8 v1.4.0
github.com/TwiN/gocache/v2 v2.1.1
github.com/TwiN/health v1.4.0
github.com/TwiN/whois v1.0.0
github.com/coreos/go-oidc/v3 v3.1.0

8
go.sum
View File

@@ -33,10 +33,10 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/TwiN/g8 v1.3.0 h1:mNv3R35GhDn1gEV0BKMl1oupZ1tDtOWPTHUKu+W/k3U=
github.com/TwiN/g8 v1.3.0/go.mod h1:SiIdItS0agSUloFqdQQt/RObB2jGSq+nnE9WfFv3RIo=
github.com/TwiN/gocache/v2 v2.1.0 h1:AJnSX7Sgz22fsO7rdXYQzMQ4zWpMjBKqk70ADeqtLDU=
github.com/TwiN/gocache/v2 v2.1.0/go.mod h1:AKHAFZSwLLmoLm1a2siDOWmZ2RjIKqentRGfOFWkllY=
github.com/TwiN/g8 v1.4.0 h1:RUk5xTtxKCdMo0GGSbBVyjtAAfi2nqVbA9E0C4u5Cxo=
github.com/TwiN/g8 v1.4.0/go.mod h1:ECyGJsoIb99klUfvVQoS1StgRLte9yvvPigGrHdy284=
github.com/TwiN/gocache/v2 v2.1.1 h1:W/GLImqa+pZVIH9pcWEn1cBgy1KU66fUcBjOnPhjuno=
github.com/TwiN/gocache/v2 v2.1.1/go.mod h1:SnUuBsrwGQeNcDG6vhkOMJnqErZM0JGjgIkuKryokYA=
github.com/TwiN/health v1.4.0 h1:Ts7lb4ihYDpVEbFSGAhSEZTSwuDOADnwJLFngFl4xzw=
github.com/TwiN/health v1.4.0/go.mod h1:CSUh+ryfD2POS2vKtc/yO4IxgR58lKvQ0/8qnoPqPqs=
github.com/TwiN/whois v1.0.0 h1:I+aQzXLPmhWovkFUzlPV2DdfLZUWDLrkMDlM6QwCv+Q=

View File

@@ -853,16 +853,16 @@ func extractKeyAndParamsFromCacheKey(cacheKey string) (string, *paging.EndpointS
params := &paging.EndpointStatusParams{}
var err error
if params.EventsPage, err = strconv.Atoi(parts[len(parts)-4]); err != nil {
return "", nil, fmt.Errorf("invalid cache key: %s", err.Error())
return "", nil, fmt.Errorf("invalid cache key: %w", err)
}
if params.EventsPageSize, err = strconv.Atoi(parts[len(parts)-3]); err != nil {
return "", nil, fmt.Errorf("invalid cache key: %s", err.Error())
return "", nil, fmt.Errorf("invalid cache key: %w", err)
}
if params.ResultsPage, err = strconv.Atoi(parts[len(parts)-2]); err != nil {
return "", nil, fmt.Errorf("invalid cache key: %s", err.Error())
return "", nil, fmt.Errorf("invalid cache key: %w", err)
}
if params.ResultsPageSize, err = strconv.Atoi(parts[len(parts)-1]); err != nil {
return "", nil, fmt.Errorf("invalid cache key: %s", err.Error())
return "", nil, fmt.Errorf("invalid cache key: %w", err)
}
return strings.Join(parts[:len(parts)-4], "-"), params, nil
}

21
vendor/github.com/TwiN/g8/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 TwiN
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,9 +0,0 @@
MIT License
Copyright (c) 2021 TwiN
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

55
vendor/github.com/TwiN/g8/README.md generated vendored
View File

@@ -1,6 +1,6 @@
# g8
![build](https://github.com/TwiN/g8/workflows/build/badge.svg?branch=master)
![test](https://github.com/TwiN/g8/workflows/test/badge.svg?branch=master)
[![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/g8)](https://goreportcard.com/report/github.com/TwiN/g8)
[![codecov](https://codecov.io/gh/TwiN/g8/branch/master/graph/badge.svg)](https://codecov.io/gh/TwiN/g8)
[![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/g8.svg)](https://github.com/TwiN/g8)
@@ -177,13 +177,42 @@ have the `backup` permission:
router.Handle("/backup", gate.ProtectWithPermissions(&testHandler{}, []string{"read", "backup"}))
```
If you're using an HTTP library that supports middlewares like [mux](https://github.com/gorilla/mux), you can protect
an entire group of handlers instead using `gate.Protect` or `gate.PermissionMiddleware()`:
```go
router := mux.NewRouter()
userRouter := router.PathPrefix("/").Subrouter()
userRouter.Use(gate.Protect)
userRouter.HandleFunc("/api/v1/users/me", getUserProfile).Methods("GET")
userRouter.HandleFunc("/api/v1/users/me/friends", getUserFriends).Methods("GET")
userRouter.HandleFunc("/api/v1/users/me/email", updateUserEmail).Methods("PATCH")
adminRouter := router.PathPrefix("/").Subrouter()
adminRouter.Use(gate.PermissionMiddleware("admin"))
adminRouter.HandleFunc("/api/v1/users/{id}/ban", banUserByID).Methods("POST")
adminRouter.HandleFunc("/api/v1/users/{id}/delete", deleteUserByID).Methods("DELETE")
```
## Rate limiting
To add a rate limit of 100 requests per second:
```
```go
gate := g8.New().WithRateLimit(100)
```
## Special use cases
## Accessing the token from the protected handlers
If you need to access the token from the handlers you are protecting with g8, you can retrieve it from the
request context by using the key `g8.TokenContextKey`:
```go
http.Handle("/handle", gate.ProtectFunc(func(w http.ResponseWriter, r *http.Request) {
token, _ := r.Context().Value(g8.TokenContextKey).(string)
// ...
}))
```
## Examples
### Protecting a handler using session cookie
If you want to only allow authenticated users to access a handler, you can use a custom token extractor function
combined with a client provider.
@@ -236,3 +265,23 @@ http.Handle("/handle", gate.ProtectFunc(func(w http.ResponseWriter, r *http.Requ
// ...
}))
```
### Using a custom header
The logic is the same as the example above:
```go
customTokenExtractorFunc := func(request *http.Request) string {
return request.Header.Get("X-API-Token")
}
clientProvider := g8.NewClientProvider(func(token string) *g8.Client {
// We'll assume that the following function calls your database and returns a struct "User" that
// has the user's token as well as the permissions granted to said user
user := database.GetUserByToken(token)
if user != nil {
return g8.NewClient(user.Token).WithPermissions(user.Permissions)
}
return nil
})
authorizationService := g8.NewAuthorizationService().WithClientProvider(clientProvider)
gate := g8.New().WithAuthorizationService(authorizationService).WithCustomTokenExtractor(customTokenExtractorFunc)
```

89
vendor/github.com/TwiN/g8/gate.go generated vendored
View File

@@ -66,15 +66,16 @@ func (gate *Gate) WithCustomUnauthorizedResponseBody(unauthorizedResponseBody []
// If a custom token extractor is not specified, the token will be extracted from the Authorization header.
//
// For instance, if you're using a session cookie, you can extract the token from the cookie like so:
// authorizationService := g8.NewAuthorizationService()
// customTokenExtractorFunc := func(request *http.Request) string {
// sessionCookie, err := request.Cookie("session")
// if err != nil {
// return ""
// }
// return sessionCookie.Value
// }
// gate := g8.New().WithAuthorizationService(authorizationService).WithCustomTokenExtractor(customTokenExtractorFunc)
//
// authorizationService := g8.NewAuthorizationService()
// customTokenExtractorFunc := func(request *http.Request) string {
// sessionCookie, err := request.Cookie("session")
// if err != nil {
// return ""
// }
// return sessionCookie.Value
// }
// gate := g8.New().WithAuthorizationService(authorizationService).WithCustomTokenExtractor(customTokenExtractorFunc)
//
// You would normally use this with a client provider that matches whatever need you have.
// For example, if you're using a session cookie, your client provider would retrieve the user from the session ID
@@ -90,8 +91,8 @@ func (gate *Gate) WithCustomTokenExtractor(customTokenExtractorFunc func(request
// WithRateLimit adds rate limiting to the Gate
//
// If you just want to use a gate for rate limiting purposes:
// gate := g8.New().WithRateLimit(50)
//
// gate := g8.New().WithRateLimit(50)
func (gate *Gate) WithRateLimit(maximumRequestsPerSecond int) *Gate {
gate.rateLimiter = NewRateLimiter(maximumRequestsPerSecond)
return gate
@@ -102,12 +103,13 @@ func (gate *Gate) WithRateLimit(maximumRequestsPerSecond int) *Gate {
// or lack thereof.
//
// Example:
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithToken("token"))
// router := http.NewServeMux()
// // Without protection
// router.Handle("/handle", yourHandler)
// // With protection
// router.Handle("/handle", gate.Protect(yourHandler))
//
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithToken("token"))
// router := http.NewServeMux()
// // Without protection
// router.Handle("/handle", yourHandler)
// // With protection
// router.Handle("/handle", gate.Protect(yourHandler))
//
// The token extracted from the request is passed to the handlerFunc request context under the key TokenContextKey
func (gate *Gate) Protect(handler http.Handler) http.Handler {
@@ -118,12 +120,13 @@ func (gate *Gate) Protect(handler http.Handler) http.Handler {
// as well as a slice of permissions that must be met.
//
// Example:
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClient(g8.NewClient("token").WithPermission("admin")))
// router := http.NewServeMux()
// // Without protection
// router.Handle("/handle", yourHandler)
// // With protection
// router.Handle("/handle", gate.ProtectWithPermissions(yourHandler, []string{"admin"}))
//
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClient(g8.NewClient("token").WithPermission("ADMIN")))
// router := http.NewServeMux()
// // Without protection
// router.Handle("/handle", yourHandler)
// // With protection
// router.Handle("/handle", gate.ProtectWithPermissions(yourHandler, []string{"admin"}))
//
// The token extracted from the request is passed to the handlerFunc request context under the key TokenContextKey
func (gate *Gate) ProtectWithPermissions(handler http.Handler, permissions []string) http.Handler {
@@ -147,12 +150,13 @@ func (gate *Gate) ProtectWithPermission(handler http.Handler, permission string)
// permissions or lack thereof.
//
// Example:
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithToken("token"))
// router := http.NewServeMux()
// // Without protection
// router.HandleFunc("/handle", yourHandlerFunc)
// // With protection
// router.HandleFunc("/handle", gate.ProtectFunc(yourHandlerFunc))
//
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithToken("token"))
// router := http.NewServeMux()
// // Without protection
// router.HandleFunc("/handle", yourHandlerFunc)
// // With protection
// router.HandleFunc("/handle", gate.ProtectFunc(yourHandlerFunc))
//
// The token extracted from the request is passed to the handlerFunc request context under the key TokenContextKey
func (gate *Gate) ProtectFunc(handlerFunc http.HandlerFunc) http.HandlerFunc {
@@ -163,12 +167,13 @@ func (gate *Gate) ProtectFunc(handlerFunc http.HandlerFunc) http.HandlerFunc {
// token as well as a slice of permissions that must be met.
//
// Example:
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClient(g8.NewClient("token").WithPermission("admin")))
// router := http.NewServeMux()
// // Without protection
// router.HandleFunc("/handle", yourHandlerFunc)
// // With protection
// router.HandleFunc("/handle", gate.ProtectFuncWithPermissions(yourHandlerFunc, []string{"admin"}))
//
// gate := g8.New().WithAuthorizationService(g8.NewAuthorizationService().WithClient(g8.NewClient("token").WithPermission("admin")))
// router := http.NewServeMux()
// // Without protection
// router.HandleFunc("/handle", yourHandlerFunc)
// // With protection
// router.HandleFunc("/handle", gate.ProtectFuncWithPermissions(yourHandlerFunc, []string{"admin"}))
//
// The token extracted from the request is passed to the handlerFunc request context under the key TokenContextKey
func (gate *Gate) ProtectFuncWithPermissions(handlerFunc http.HandlerFunc, permissions []string) http.HandlerFunc {
@@ -215,3 +220,19 @@ func (gate *Gate) ExtractTokenFromRequest(request *http.Request) string {
}
return strings.TrimPrefix(request.Header.Get(AuthorizationHeader), "Bearer ")
}
// PermissionMiddleware is a middleware that behaves like ProtectWithPermission, but it is meant to be used
// as a middleware for libraries that support such a feature.
//
// For instance, if you are using github.com/gorilla/mux, you can use PermissionMiddleware like so:
//
// router := mux.NewRouter()
// router.Use(gate.PermissionMiddleware("admin"))
// router.Handle("/admin/handle", adminHandler)
//
// If you do not want to protect a router with a specific permission, you can use Gate.Protect instead.
func (gate *Gate) PermissionMiddleware(permissions ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return gate.ProtectWithPermissions(next, permissions)
}
}

8
vendor/modules.txt vendored
View File

@@ -1,8 +1,8 @@
# github.com/TwiN/g8 v1.3.0
## explicit; go 1.17
# github.com/TwiN/g8 v1.4.0
## explicit; go 1.19
github.com/TwiN/g8
# github.com/TwiN/gocache/v2 v2.1.0
## explicit; go 1.18
# github.com/TwiN/gocache/v2 v2.1.1
## explicit; go 1.19
github.com/TwiN/gocache/v2
# github.com/TwiN/health v1.4.0
## explicit; go 1.18

13
web/static.go Normal file
View File

@@ -0,0 +1,13 @@
package static
import "embed"
var (
//go:embed static
FileSystem embed.FS
)
const (
RootPath = "static"
IndexPath = RootPath + "/index.html"
)

74
web/static_test.go Normal file
View File

@@ -0,0 +1,74 @@
package static
import (
"io/fs"
"strings"
"testing"
)
func TestEmbed(t *testing.T) {
scenarios := []struct {
path string
shouldExist bool
expectedContainString string
}{
{
path: "index.html",
shouldExist: true,
expectedContainString: "</body>",
},
{
path: "favicon.ico",
shouldExist: true,
expectedContainString: "", // not checking because it's an image
},
{
path: "img/logo.svg",
shouldExist: true,
expectedContainString: "</svg>",
},
{
path: "css/app.css",
shouldExist: true,
expectedContainString: "background-color",
},
{
path: "js/app.js",
shouldExist: true,
expectedContainString: "function",
},
{
path: "js/chunk-vendors.js",
shouldExist: true,
expectedContainString: "function",
},
{
path: "file-that-does-not-exist.html",
shouldExist: false,
},
}
staticFileSystem, err := fs.Sub(FileSystem, RootPath)
if err != nil {
t.Fatal(err)
}
for _, scenario := range scenarios {
t.Run(scenario.path, func(t *testing.T) {
content, err := fs.ReadFile(staticFileSystem, scenario.path)
if !scenario.shouldExist {
if err == nil {
t.Errorf("%s should not have existed", scenario.path)
}
} else {
if err != nil {
t.Errorf("opening %s should not have returned an error, got %s", scenario.path, err.Error())
}
if len(content) == 0 {
t.Errorf("%s should have existed in the static FileSystem, but was empty", scenario.path)
}
if !strings.Contains(string(content), scenario.expectedContainString) {
t.Errorf("%s should have contained %s, but did not", scenario.path, scenario.expectedContainString)
}
}
})
}
}