mirror of
https://github.com/TwiN/gatus.git
synced 2024-11-21 15:33:17 +01:00
parent
47dd18a0b5
commit
00b56ecefd
@ -13,7 +13,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -mod vendor -a -installsuffix cgo -o gatus
|
|||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=builder /app/gatus .
|
COPY --from=builder /app/gatus .
|
||||||
COPY --from=builder /app/config.yaml ./config/config.yaml
|
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
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT}
|
||||||
|
@ -17,7 +17,6 @@ import (
|
|||||||
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
|
"github.com/TwiN/gatus/v4/alerting/provider/telegram"
|
||||||
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
|
"github.com/TwiN/gatus/v4/alerting/provider/twilio"
|
||||||
"github.com/TwiN/gatus/v4/client"
|
"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/config/web"
|
||||||
"github.com/TwiN/gatus/v4/core"
|
"github.com/TwiN/gatus/v4/core"
|
||||||
"github.com/TwiN/gatus/v4/storage"
|
"github.com/TwiN/gatus/v4/storage"
|
||||||
@ -39,10 +38,6 @@ func TestLoadDefaultConfigurationFile(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseAndValidateConfigBytes(t *testing.T) {
|
func TestParseAndValidateConfigBytes(t *testing.T) {
|
||||||
file := t.TempDir() + "/test.db"
|
file := t.TempDir() + "/test.db"
|
||||||
ui.StaticFolder = "../web/static"
|
|
||||||
defer func() {
|
|
||||||
ui.StaticFolder = "./web/static"
|
|
||||||
}()
|
|
||||||
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
|
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
|
||||||
storage:
|
storage:
|
||||||
type: sqlite
|
type: sqlite
|
||||||
|
@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v4/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -14,10 +16,6 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
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")
|
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
|
// Validate that the template works
|
||||||
t, err := template.ParseFiles(StaticFolder + "/index.html")
|
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -6,10 +6,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||||
StaticFolder = "../../web/static"
|
|
||||||
defer func() {
|
|
||||||
StaticFolder = "./web/static"
|
|
||||||
}()
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Title: "",
|
Title: "",
|
||||||
Header: "",
|
Header: "",
|
||||||
|
@ -9,7 +9,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v4/config"
|
"github.com/TwiN/gatus/v4/config"
|
||||||
"github.com/TwiN/gatus/v4/config/ui"
|
|
||||||
"github.com/TwiN/gatus/v4/controller/handler"
|
"github.com/TwiN/gatus/v4/controller/handler"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -21,7 +20,7 @@ var (
|
|||||||
|
|
||||||
// Handle creates the router and starts the server
|
// Handle creates the router and starts the server
|
||||||
func Handle(cfg *config.Config) {
|
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" {
|
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||||
router = handler.DevelopmentCORS(router)
|
router = handler.DevelopmentCORS(router)
|
||||||
}
|
}
|
||||||
|
@ -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[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()})
|
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 {
|
type Scenario struct {
|
||||||
Name string
|
Name string
|
||||||
Path string
|
Path string
|
||||||
|
@ -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[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()})
|
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 {
|
type Scenario struct {
|
||||||
Name string
|
Name string
|
||||||
Path string
|
Path string
|
||||||
|
@ -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[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()})
|
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 {
|
type Scenario struct {
|
||||||
Name string
|
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
|
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
||||||
firstResult.Timestamp = time.Time{}
|
firstResult.Timestamp = time.Time{}
|
||||||
secondResult.Timestamp = time.Time{}
|
secondResult.Timestamp = time.Time{}
|
||||||
router := CreateRouter("../../web/static", &config.Config{Metrics: true})
|
router := CreateRouter(&config.Config{Metrics: true})
|
||||||
|
|
||||||
type Scenario struct {
|
type Scenario struct {
|
||||||
Name string
|
Name string
|
||||||
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,15 +1,17 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v4/config"
|
"github.com/TwiN/gatus/v4/config"
|
||||||
|
"github.com/TwiN/gatus/v4/web"
|
||||||
"github.com/TwiN/health"
|
"github.com/TwiN/health"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"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()
|
router := mux.NewRouter()
|
||||||
if cfg.Metrics {
|
if cfg.Metrics {
|
||||||
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
|
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")
|
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
|
||||||
// Misc
|
// Misc
|
||||||
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
|
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
|
||||||
router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET")
|
|
||||||
// SPA
|
// SPA
|
||||||
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, cfg.UI)).Methods("GET")
|
router.HandleFunc("/endpoints/{name}", SinglePageApplication(cfg.UI)).Methods("GET")
|
||||||
router.HandleFunc("/", SinglePageApplication(staticFolder, cfg.UI)).Methods("GET")
|
router.HandleFunc("/", SinglePageApplication(cfg.UI)).Methods("GET")
|
||||||
// Everything else falls back on static content
|
// 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
|
return router
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCreateRouter(t *testing.T) {
|
func TestCreateRouter(t *testing.T) {
|
||||||
router := CreateRouter("../../web/static", &config.Config{Metrics: true})
|
router := CreateRouter(&config.Config{Metrics: true})
|
||||||
type Scenario struct {
|
type Scenario struct {
|
||||||
Name string
|
Name string
|
||||||
Path string
|
Path string
|
||||||
@ -28,16 +28,32 @@ func TestCreateRouter(t *testing.T) {
|
|||||||
ExpectedCode: http.StatusOK,
|
ExpectedCode: http.StatusOK,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "scripts",
|
Name: "favicon.ico",
|
||||||
|
Path: "/favicon.ico",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "app.js",
|
||||||
Path: "/js/app.js",
|
Path: "/js/app.js",
|
||||||
ExpectedCode: http.StatusOK,
|
ExpectedCode: http.StatusOK,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "scripts-gzipped",
|
Name: "app.js-gzipped",
|
||||||
Path: "/js/app.js",
|
Path: "/js/app.js",
|
||||||
ExpectedCode: http.StatusOK,
|
ExpectedCode: http.StatusOK,
|
||||||
Gzip: true,
|
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",
|
Name: "index-redirect",
|
||||||
Path: "/index.html",
|
Path: "/index.html",
|
||||||
|
@ -1,26 +1,30 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v4/config/ui"
|
"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) {
|
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 {
|
if err != nil {
|
||||||
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
|
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
||||||
http.ServeFile(writer, request, staticFolder+"/index.html")
|
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
|
return
|
||||||
}
|
}
|
||||||
writer.Header().Set("Content-Type", "text/html")
|
writer.Header().Set("Content-Type", "text/html")
|
||||||
err = t.Execute(writer, ui)
|
err = t.Execute(writer, ui)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("[handler][SinglePageApplication] Failed to parse template:", err.Error())
|
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
||||||
http.ServeFile(writer, request, staticFolder+"/index.html")
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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[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()})
|
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 {
|
type Scenario struct {
|
||||||
Name string
|
Name string
|
||||||
Path string
|
Path string
|
||||||
|
13
web/static.go
Normal file
13
web/static.go
Normal 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
74
web/static_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user