From 272af858d8848d938f008324e3e93a02ac8ca9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Sat, 17 Aug 2024 19:46:53 +0200 Subject: [PATCH] Initial commit --- .air.toml | 37 + .dockerignore | 4 + .github/workflows/audit.yml | 38 + .github/workflows/check-build-health.yml | 31 + .github/workflows/release-on-tag-push.yml | 66 ++ .gitignore | 3 + .vscode/extensions.json | 7 + .vscode/settings.json | 18 + Dockerfile | 17 + LICENSE.md | 21 + Makefile | 132 ++++ README.md | 30 + bin/.gitkeep | 0 cmd/server/main.go | 157 ++++ deploy/.env.local | 1 + deploy/.env.production | 2 + deploy/Caddyfile | 34 + deploy/config-loki.yml | 36 + deploy/config-prometheus.yml | 19 + deploy/config-promtail.yml | 25 + deploy/docker-compose.monitoring.yml | 92 +++ deploy/docker-compose.yml | 35 + deploy/scripts/backup-list.sh | 39 + deploy/scripts/backup-restore.sh | 49 ++ deploy/scripts/backup.sh | 49 ++ deploy/scripts/start-services.sh | 20 + deploy/scripts/vps-system-setup.sh | 47 ++ deploy/scripts/vps-tooling-setup.sh | 59 ++ deploy/scripts/vps-user-setup.sh | 53 ++ docs/deploy.md | 104 +++ docs/develop.md | 68 ++ docs/release.md | 7 + go.mod | 36 + go.sum | 83 ++ internal/api/api.go | 57 ++ internal/api/api_test.go | 707 ++++++++++++++++++ internal/api/handle_get_health.go | 18 + internal/api/handle_get_metrics.go | 92 +++ internal/api/handle_get_protected_{slug}.go | 59 ++ internal/api/handle_get_{slug}.go | 71 ++ internal/api/handle_post_shortlink.go | 122 +++ internal/api/metrics.go | 27 + internal/api/middleware.go | 236 ++++++ internal/api/responses.go | 120 +++ internal/config/config.go | 122 +++ internal/db/db.go | 165 ++++ internal/db/entities/shortlink_entity.go | 46 ++ internal/db/entities/visit_entity.go | 10 + .../000001_create_shortlinks_table.down.sql | 1 + .../000001_create_shortlinks_table.up.sql | 10 + .../000002_create_visits_table.down.sql | 3 + .../000002_create_visits_table.up.sql | 9 + internal/env/getters.go | 72 ++ internal/errors/errors.go | 61 ++ internal/log/logger.go | 160 ++++ internal/log/pretty_print.go | 12 + internal/services/shortlink_service.go | 190 +++++ internal/services/visit_service.go | 44 ++ internal/static.go | 19 + internal/static/404.html | 28 + internal/static/canvas.tmpl.html | 59 ++ internal/static/challenge.html | 36 + internal/static/fonts/Lexend-Bold.ttf | Bin 0 -> 78632 bytes internal/static/fonts/Lexend-Bold.woff2 | Bin 0 -> 29408 bytes internal/static/fonts/Lexend-Regular.ttf | Bin 0 -> 78360 bytes internal/static/fonts/Lexend-Regular.woff2 | Bin 0 -> 28160 bytes internal/static/img/background.svg | 1 + internal/static/img/favicon.ico | Bin 0 -> 15086 bytes internal/static/img/github.svg | 3 + internal/static/index.html | 109 +++ internal/static/js/challenge.js | 46 ++ internal/static/js/confetti.js | 10 + internal/static/js/index.js | 279 +++++++ internal/static/styles/404.css | 8 + internal/static/styles/challenge.css | 76 ++ internal/static/styles/index.css | 330 ++++++++ internal/static/swagger.html | 49 ++ openapi.yml | 205 +++++ 78 files changed, 5091 insertions(+) create mode 100644 .air.toml create mode 100644 .dockerignore create mode 100644 .github/workflows/audit.yml create mode 100644 .github/workflows/check-build-health.yml create mode 100644 .github/workflows/release-on-tag-push.yml create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 Dockerfile create mode 100644 LICENSE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 bin/.gitkeep create mode 100644 cmd/server/main.go create mode 100644 deploy/.env.local create mode 100644 deploy/.env.production create mode 100644 deploy/Caddyfile create mode 100644 deploy/config-loki.yml create mode 100644 deploy/config-prometheus.yml create mode 100644 deploy/config-promtail.yml create mode 100644 deploy/docker-compose.monitoring.yml create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/scripts/backup-list.sh create mode 100644 deploy/scripts/backup-restore.sh create mode 100644 deploy/scripts/backup.sh create mode 100644 deploy/scripts/start-services.sh create mode 100644 deploy/scripts/vps-system-setup.sh create mode 100644 deploy/scripts/vps-tooling-setup.sh create mode 100644 deploy/scripts/vps-user-setup.sh create mode 100644 docs/deploy.md create mode 100644 docs/develop.md create mode 100644 docs/release.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/api.go create mode 100644 internal/api/api_test.go create mode 100644 internal/api/handle_get_health.go create mode 100644 internal/api/handle_get_metrics.go create mode 100644 internal/api/handle_get_protected_{slug}.go create mode 100644 internal/api/handle_get_{slug}.go create mode 100644 internal/api/handle_post_shortlink.go create mode 100644 internal/api/metrics.go create mode 100644 internal/api/middleware.go create mode 100644 internal/api/responses.go create mode 100644 internal/config/config.go create mode 100644 internal/db/db.go create mode 100644 internal/db/entities/shortlink_entity.go create mode 100644 internal/db/entities/visit_entity.go create mode 100644 internal/db/migrations/000001_create_shortlinks_table.down.sql create mode 100644 internal/db/migrations/000001_create_shortlinks_table.up.sql create mode 100644 internal/db/migrations/000002_create_visits_table.down.sql create mode 100644 internal/db/migrations/000002_create_visits_table.up.sql create mode 100644 internal/env/getters.go create mode 100644 internal/errors/errors.go create mode 100644 internal/log/logger.go create mode 100644 internal/log/pretty_print.go create mode 100644 internal/services/shortlink_service.go create mode 100644 internal/services/visit_service.go create mode 100644 internal/static.go create mode 100644 internal/static/404.html create mode 100644 internal/static/canvas.tmpl.html create mode 100644 internal/static/challenge.html create mode 100644 internal/static/fonts/Lexend-Bold.ttf create mode 100644 internal/static/fonts/Lexend-Bold.woff2 create mode 100644 internal/static/fonts/Lexend-Regular.ttf create mode 100644 internal/static/fonts/Lexend-Regular.woff2 create mode 100644 internal/static/img/background.svg create mode 100644 internal/static/img/favicon.ico create mode 100644 internal/static/img/github.svg create mode 100644 internal/static/index.html create mode 100644 internal/static/js/challenge.js create mode 100644 internal/static/js/confetti.js create mode 100644 internal/static/js/index.js create mode 100644 internal/static/styles/404.css create mode 100644 internal/static/styles/challenge.css create mode 100644 internal/static/styles/index.css create mode 100644 internal/static/swagger.html create mode 100644 openapi.yml diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..92f3cba --- /dev/null +++ b/.air.toml @@ -0,0 +1,37 @@ +# ref: https://github.com/air-verse/air/blob/master/air_example.toml + +root = "." +tmp_dir = "bin/tmp" + +[build] + bin = "bin/tmp/main" + cmd = "go build -o ./bin/tmp cmd/server/main.go" + delay = 0 + exclude_dir = [".github", ".vscode", "bin", "deploy", "docs"] + exclude_regex = ["_test\\.go"] + exclude_unchanged = false + follow_symlink = false + include_dir = ["internal"] + kill_delay = 0 + log = "air.log" + rerun = false + rerun_delay = 500 + send_interrupt = true + stop_on_error = true + +[color] + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = true + +[misc] + clean_on_exit = true + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..740dd07 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.DS_Store +bin/* +.air.toml +Makefile \ No newline at end of file diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..56b3dbd --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,38 @@ +name: Lint, test, vet + +on: + push: + pull_request: + +jobs: + audit: + name: Audit + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 1.22 + + - name: Cache modules + uses: actions/cache@v3 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Install dependencies + run: | + go mod download + go install golang.org/x/lint/golint@latest + + - name: Lint, test and vet + run: | + golint ./... + go test -v ./... + go vet ./... \ No newline at end of file diff --git a/.github/workflows/check-build-health.yml b/.github/workflows/check-build-health.yml new file mode 100644 index 0000000..bffced1 --- /dev/null +++ b/.github/workflows/check-build-health.yml @@ -0,0 +1,31 @@ +name: Check build health + +on: + workflow_dispatch: + +jobs: + build-health: + name: Check Docker container health + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Build Docker image + run: docker build --tag n8n-shortlink:ci . + + - name: Run Docker container + run: | + docker run --detach --publish 3001:3001 --name n8n-shortlink-ci n8n-shortlink:ci + sleep 10 # wait for container to start up + + - name: Check health endpoint + run: | + response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3001/health) + if [ $response -eq 200 ]; then + echo "Health check passed" + exit 0 + else + echo "Health check failed with status code: $response" + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/release-on-tag-push.yml b/.github/workflows/release-on-tag-push.yml new file mode 100644 index 0000000..f2fadc5 --- /dev/null +++ b/.github/workflows/release-on-tag-push.yml @@ -0,0 +1,66 @@ +name: Release on tag push + +on: + push: + tags: + - 'v*.*.*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the GHCR registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker image + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image to GHCR + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + # - name: Create GitHub release + # uses: softprops/action-gh-release@v1 + # if: startsWith(github.ref, 'refs/tags/') + # with: + # files: | + # LICENSE + # README.md + # body: | + # Docker image for this release: `ghcr.io/${{ github.repository }}:${{ github.ref_name }}` + + # You can pull this image with: + # ``` + # docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }} + # ``` + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2d82cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +bin/* +!**/.gitkeep \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..dc81b2c --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "golang.Go", + "ms-vscode-remote.remote-ssh", + "ms-vscode-remote.remote-ssh-edit" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..867ee60 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "go.lintTool": "golint", + "go.lintOnSave": "package", + "workbench.colorCustomizations": { + "commandCenter.border": "#15202b99", + "sash.hoverBorder": "#abedd5", + "statusBar.background": "#81e4c0", + "statusBar.foreground": "#15202b", + "statusBarItem.hoverBackground": "#57dbab", + "statusBarItem.remoteBackground": "#81e4c0", + "statusBarItem.remoteForeground": "#15202b", + "titleBar.activeBackground": "#81e4c0", + "titleBar.activeForeground": "#15202b", + "titleBar.inactiveBackground": "#81e4c099", + "titleBar.inactiveForeground": "#15202b99" + }, + "peacock.color": "#81e4c0" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..620a339 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.22-alpine AS builder +WORKDIR /builder-dir +COPY . ./ +ENV CGO_ENABLED=1 +RUN apk add --no-cache git build-base sqlite +RUN go mod download +RUN go build -o bin cmd/server/main.go + +FROM alpine:latest +RUN mkdir /root/.n8n-shortlink +WORKDIR /root/n8n-shortlink +COPY --from=builder /builder-dir/bin bin +COPY --from=builder /builder-dir/internal/db/migrations internal/db/migrations +EXPOSE 3001 +CMD ["./bin/main"] \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..752e7fe --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Iván Ovejero + +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. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..34efc91 --- /dev/null +++ b/Makefile @@ -0,0 +1,132 @@ +.DEFAULT_GOAL := run + +HOME_DIR := $(shell echo $$HOME) +DB_PATH := $(HOME_DIR)/.n8n-shortlink/n8n-shortlink.sqlite + +help: + @echo "Commands:" + @sed -n 's/^##//p' $(MAKEFILE_LIST) + +# ------------ +# develop +# ------------ + +## run: Build and run binary +run: + go run cmd/server/main.go +.PHONY: run + +## live: Run binary with live reload +live: + air +.PHONY: live + +# ------------ +# audit +# ------------ + +## audit: Tun `go mod tidy`, `go fmt`, `golint`, `go test`, and `go vet` +audit: + echo 'Tidying...' + go mod tidy + echo 'Formatting...' + go fmt ./... + echo 'Linting...' + golint ./... + echo 'Testing...' + go test -v ./... + echo 'Vetting...' + go vet ./... +.PHONY: audit + +## test: Run tests +test: + gotestsum --format testname +.PHONY: test + +## test/watch: Run tests in watch mode +test/watch: + gotestsum --format testname --watch +.PHONY: test + +# ------------ +# build +# ------------ + +CURRENT_TIME = $(shell date +"%Y-%m-%dT%H:%M:%S%z") +GIT_DESCRIPTION = $(shell git describe --always --dirty) +LINKER_FLAGS = '-s -X main.commitSha=${GIT_DESCRIPTION} -X main.buildTime=${CURRENT_TIME}' + +## build: Build binary, burning in commit SHA and build time +build: + go build -ldflags=${LINKER_FLAGS} -o bin cmd/server/main.go +.PHONY:build + +## build/meta: Display commit SHA and build time burned into binary +build/meta: + ./bin/main -metadata-mode +.PHONY: build/meta + +# ------------ +# db +# ------------ + +## db/create: Create new database and apply up migrations +db/create: + @if [ -f ${DB_PATH} ]; then \ + echo "\033[0;33mWarning:\033[0m This will overwrite the existing database.\nAre you sure? (y/n)"; \ + read confirm && if [ "$$confirm" = "y" ]; then \ + rm -f ${DB_PATH}; \ + sqlite3 ${DB_PATH} ""; \ + make db/mig/up; \ + echo "\033[0;34mOK Created new empty database\033[0m"; \ + else \ + echo "\033[0;31mAborted database creation\033[0m"; \ + fi \ + else \ + sqlite3 ${DB_PATH} ""; \ + make db/mig/up; \ + echo "\033[0;34mOK Created new empty database\033[0m"; \ + fi +.PHONY: db/create + +## db/connect: Connect to database +db/connect: + sqlite3 ${DB_PATH} +.PHONY: db/connect + +## db/mig/new name=$1: Create new migration, e.g. `db/mig/new name=create_users_table` +db/mig/new: + migrate create -seq -ext=.sql -dir=./internal/db/migrations ${name} + echo "\033[0;34mOK Created migration files\033[0m" +.PHONY: db/mig/new + +## db/mig/up: Apply up migrations +db/mig/up: + migrate -path="./internal/db/migrations" -database "sqlite3://$(DB_PATH)" up + @echo "\033[0;34mOK Applied up migrations\033[0m" +.PHONY: db/mig/up + +# ------------ +# docker +# ------------ + +## docker/build: Build Docker image `n8n-shortlink:local` +docker/build: + docker build --tag n8n-shortlink:local . +.PHONY: docker/build + +## docker/run: Run Docker container off image `n8n-shortlink:local` +docker/run: + docker compose --file deploy/docker-compose.yml --profile local up +.PHONY: docker/run + +## docker/stop: Stop Docker container +docker/stop: + docker stop n8n-shortlink +.PHONY: docker/run + +## docker/connect: Connect to running Docker container `n8n-shortlink` +docker/connect: + docker exec -it n8n-shortlink sh +.PHONY: docker/connect \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c5ad13 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# n8n-shortlink + +Golang app for creating and resolving shortlinks for n8n workflows and URLs. + +Small learning project to get familiar with deployment best practices. + +Features: + +- Create + resolve shortlinks for n8n workflows and URLs +- Optionally render n8n workflow shortlinks on canvas +- Vanity URLs and password protection supported +- OpenAPI 3.0 spec + Swagger UI playground +- Extensive integration test coverage +- IP-address-based rate limiting + +Deployment stack: + +- Metrics with expvar, Prometheus, node exporter, cAdvisor +- Logging with zap, Promtail, Loki +- Monitoring with Grafana +- Caddy as reverse proxy +- Error tracking with Sentry +- Backups with AWS S3 + cronjob +- Bash scripts to automate VPS setup +- Uptime monitoring with UptimeRobot +- Releases with GitHub Actions, GHCR, Docker + +## Author + +© 2024 Iván Ovejero diff --git a/bin/.gitkeep b/bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..5007b07 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,157 @@ +package main + +import ( + "context" + "errors" + "fmt" + nativeLog "log" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/getsentry/sentry-go" + "github.com/ivov/n8n-shortlink/internal/api" + "github.com/ivov/n8n-shortlink/internal/config" + "github.com/ivov/n8n-shortlink/internal/db" + "github.com/ivov/n8n-shortlink/internal/log" + "github.com/ivov/n8n-shortlink/internal/services" +) + +var ( + // SHA and buildTime are set at compile time + commitSha string + buildTime string +) + +var startTime = time.Now() + +func main() { + cfg := config.NewConfig(commitSha) + + if *cfg.MetadataMode { + fmt.Println(commitSha) + fmt.Printf("Commit SHA:\t%s\n", commitSha) + fmt.Printf("Build time:\t%s\n", buildTime) + os.Exit(0) + } + + config.SetupDotDir() + + // ------------ + // logger + // ------------ + + logger, err := log.NewLogger(cfg.Env) + if err != nil { + nativeLog.Fatalf("failed to init logger: %v", err) + } + + logger.ReportEnvs() + + // ------------ + // sentry + // ------------ + + if cfg.Env == "production" { + err = sentry.Init(sentry.ClientOptions{ + Dsn: cfg.Sentry.DSN, + AttachStacktrace: true, + }) + if err != nil { + logger.Fatal(err) + os.Exit(1) + } + + defer sentry.Flush(2 * time.Second) + } else { + logger.Info("sentry disabled in non-production environment") + } + + // ------------ + // DB + // ------------ + + db, err := db.Setup(cfg.Env) + if err != nil { + logger.Fatal(err) + os.Exit(1) + } + + defer db.Close() + + // ------------ + // setup + // ------------ + + api := &api.API{ + Config: &cfg, + Logger: &logger, + ShortlinkService: &services.ShortlinkService{DB: db, Logger: &logger}, + VisitService: &services.VisitService{DB: db, Logger: &logger}, + } + + api.InitMetrics(commitSha) + + server := &http.Server{ + Addr: cfg.Host + ":" + strconv.Itoa(cfg.Port), + Handler: api.Routes(), + IdleTimeout: time.Minute, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + } + + // ------------ + // shutdown + // ------------ + + // On SIGINT or SIGTERM, give a 5-second grace period for any + // background tasks to complete before the server shuts down. + + shutdownErrorCh := make(chan error) + + go func() { + signalCh := make(chan os.Signal, 1) + + signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) + + s := <-signalCh + + api.Logger.Info("caught signal", log.Str("signal", s.String())) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := server.Shutdown(ctx) + if err != nil { + shutdownErrorCh <- err + } + + api.Logger.Info("waiting for bkg tasks to complete") + + api.WaitGroup.Wait() + shutdownErrorCh <- nil + }() + + // ------------ + // start + // ------------ + + api.Logger.Info("starting server", log.Str("addr", server.Addr)) + + err = server.ListenAndServe() + if !errors.Is(err, http.ErrServerClosed) { + api.Logger.Fatal(err) + os.Exit(1) + } + + err = <-shutdownErrorCh + if err != nil { + api.Logger.Fatal(err) + os.Exit(1) + } + + api.Logger.Info("stopped server", log.Str("addr", server.Addr)) +} diff --git a/deploy/.env.local b/deploy/.env.local new file mode 100644 index 0000000..df646d8 --- /dev/null +++ b/deploy/.env.local @@ -0,0 +1 @@ +N8N_SHORTLINK_HOST=0.0.0.0 \ No newline at end of file diff --git a/deploy/.env.production b/deploy/.env.production new file mode 100644 index 0000000..33d062b --- /dev/null +++ b/deploy/.env.production @@ -0,0 +1,2 @@ +N8N_SHORTLINK_ENVIRONMENT=production +N8N_SHORTLINK_HOST=0.0.0.0 \ No newline at end of file diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..f4b4c0a --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,34 @@ +{ + servers { + header_up -Server + } +} + +n8n.to { + encode gzip + + reverse_proxy localhost:3001 + + respond /debug/* "Forbidden" 403 + respond /metrics "Forbidden" 403 + respond /static/canvas.tmpl.html "Forbidden" 403 + respond /static/swagger.html "Forbidden" 403 + + log { + output file /var/log/caddy/access.log { + roll_size 5MB + roll_keep 5 + roll_keep_for 720h + } + } +} + +www.n8n.to { + redir https://n8n.to{uri} +} + +grafana.n8n.to { + encode gzip + + reverse_proxy localhost:3100 +} \ No newline at end of file diff --git a/deploy/config-loki.yml b/deploy/config-loki.yml new file mode 100644 index 0000000..13131ac --- /dev/null +++ b/deploy/config-loki.yml @@ -0,0 +1,36 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /tmp/loki + storage: + filesystem: + chunks_directory: /tmp/loki/chunks + rules_directory: /tmp/loki/rules + replication_factor: 1 + ring: + instance_addr: 127.0.0.1 + kvstore: + store: inmemory + +query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + +schema_config: + configs: + - from: 2020-10-24 + store: boltdb-shipper + object_store: filesystem + schema: v11 + index: + prefix: index_ + period: 24h + +analytics: + reporting_enabled: false \ No newline at end of file diff --git a/deploy/config-prometheus.yml b/deploy/config-prometheus.yml new file mode 100644 index 0000000..cf842d1 --- /dev/null +++ b/deploy/config-prometheus.yml @@ -0,0 +1,19 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: ['prometheus:9090'] + + - job_name: n8n_shortlink + static_configs: + - targets: ['n8n-shortlink:3001'] + + - job_name: node_exporter + static_configs: + - targets: ['node_exporter:9100'] + + - job_name: cadvisor + static_configs: + - targets: ['cadvisor:8080'] \ No newline at end of file diff --git a/deploy/config-promtail.yml b/deploy/config-promtail.yml new file mode 100644 index 0000000..0adb4a4 --- /dev/null +++ b/deploy/config-promtail.yml @@ -0,0 +1,25 @@ +server: + http_listen_port: 9080 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: n8n-shortlink + static_configs: + - targets: + - localhost + labels: + job: n8n-shortlink + __path__: /home/ivov/.n8n-shortlink/n8n-shortlink.log + + - job_name: n8n-shortlink-backups + static_configs: + - targets: + - localhost + labels: + job: n8n-shortlink-backups + __path__: /home/ivov/deploy/backups.log \ No newline at end of file diff --git a/deploy/docker-compose.monitoring.yml b/deploy/docker-compose.monitoring.yml new file mode 100644 index 0000000..7ca51f3 --- /dev/null +++ b/deploy/docker-compose.monitoring.yml @@ -0,0 +1,92 @@ +version: '3.8' +services: + grafana: + image: grafana/grafana-oss:latest + container_name: grafana + ports: + - 3000:3000 + volumes: + - grafana_data:/var/lib/grafana + restart: unless-stopped + networks: + - n8n-shortlink-network + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - 9090:9090 + volumes: + - ./config-prometheus.yml:/etc/prometheus/config-prometheus.yml + - prometheus_data:/prometheus + restart: unless-stopped + command: + - --config.file=/etc/prometheus/config-prometheus.yml + networks: + - n8n-shortlink-network + + node_exporter: + image: quay.io/prometheus/node-exporter:latest + container_name: node_exporter + command: + - --path.rootfs=/host + pid: host + restart: unless-stopped + volumes: + - /:/host:ro,rslave + user: root + security_opt: + - no-new-privileges:true + networks: + - n8n-shortlink-network + + cadvisor: + image: gcr.io/cadvisor/cadvisor:latest + container_name: cadvisor + ports: + - 9092:8080 + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + devices: + - /dev/kmsg + restart: unless-stopped + privileged: true + networks: + - n8n-shortlink-network + + loki: + image: grafana/loki:latest + container_name: loki + ports: + - 3100:3100 + volumes: + - ./config-loki.yml:/etc/loki/local-config.yml + restart: unless-stopped + command: -config.file=/etc/loki/local-config.yml + networks: + - n8n-shortlink-network + + promtail: + image: grafana/promtail:latest + container_name: promtail + volumes: + - ./config-promtail.yml:/etc/promtail/config.yml + - ${HOME}/.n8n-shortlink:/n8n-shortlink/data + - ${HOME}/deploy:/deploy + command: -config.file=/etc/promtail/config.yml + restart: unless-stopped + networks: + - n8n-shortlink-network + +volumes: + grafana_data: + prometheus_data: + +networks: + n8n-shortlink-network: + name: n8n-shortlink-network + external: true diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..f3b4895 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.8' +name: n8n-shortlink +services: + n8n-shortlink: + image: ghcr.io/ivov/n8n-shortlink:latest + container_name: n8n-shortlink + profiles: ["production"] + ports: + - 3001:3001 + env_file: + - .env.production + volumes: + - ${HOME}/.n8n-shortlink:/root/.n8n-shortlink + restart: unless-stopped + networks: + - n8n-shortlink-network + + n8n-shortlink-local: + image: n8n-shortlink:local + container_name: n8n-shortlink + profiles: ["local"] + ports: + - 3001:3001 + env_file: + - .env.local + volumes: + - ${HOME}/.n8n-shortlink:/root/.n8n-shortlink + restart: unless-stopped + networks: + - n8n-shortlink-network + +networks: + n8n-shortlink-network: + name: n8n-shortlink-network + external: true \ No newline at end of file diff --git a/deploy/scripts/backup-list.sh b/deploy/scripts/backup-list.sh new file mode 100644 index 0000000..c76d1a2 --- /dev/null +++ b/deploy/scripts/backup-list.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# List sqlite DB backups at AWS S3: deploy/scripts/backup-list.sh + +CONFIG_FILEPATH="$HOME/deploy/.config" + +if [ ! -f "$CONFIG_FILEPATH" ]; then + echo "Error: Config file $CONFIG_FILEPATH not found." + exit 1 +fi + +BUCKET_NAME=$(grep BUCKET_NAME $CONFIG_FILEPATH | cut -d'=' -f2 | tr -d '"*') +BACKUP_PREFIX="n8n-shortlink-backups/" + +human_readable_size() { + local size=$1 + local units=("B" "KiB" "MiB" "GiB" "TiB") + local unit=0 + + while (( $(echo "$size > 1024" | bc -l) )); do + size=$(echo "scale=2; $size / 1024" | bc -l) + ((unit++)) + done + + printf "%.2f %s" $size "${units[$unit]}" +} + +aws s3 ls "s3://$BUCKET_NAME/$BACKUP_PREFIX" | sort -r | while read -r line; do + date=$(echo $line | awk '{print $1}') + time=$(echo $line | awk '{print $2}') + size=$(echo $line | awk '{print $3}') + filename=$(echo $line | awk '{print $4}') + + hr_size=$(human_readable_size $size) + + short_filename=${filename#$BACKUP_PREFIX} # remove prefix + + printf "%-12s %-12s %-12s %s\n" "$date" "$time" "$hr_size" "$short_filename" +done diff --git a/deploy/scripts/backup-restore.sh b/deploy/scripts/backup-restore.sh new file mode 100644 index 0000000..acc93d2 --- /dev/null +++ b/deploy/scripts/backup-restore.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# Restore a sqlite BB backup from AWS S3: deploy/scripts/backup-restore.sh + +if [ $# -eq 0 ]; then + echo -e "Error: No backup name provided.\nUsage: $0 \nExample: $0 2020-01-03-17:34:35+0200.sql.gz.enc" + exit 1 +fi + +CONFIG_FILEPATH="$HOME/deploy/.config" + +if [ ! -f "$CONFIG_FILEPATH" ]; then + echo "Error: Config file $CONFIG_FILEPATH not found." + exit 1 +fi + +BUCKET_NAME=$(grep BUCKET_NAME $CONFIG_FILEPATH | cut -d'=' -f2 | tr -d '"*') +BUCKET_PREFIX="n8n-shortlink-backups/" +ENCRYPTION_KEY_PATH="$HOME/.keys/n8n-shortlink-backup-secret.key" +RESTORED_DB=".n8n-shortlink/restored.sqlite" +LOG_FILE="$HOME/deploy/restore.log" +BACKUP_NAME="$1" +BUCKET_PATH="s3://$BUCKET_NAME/$BUCKET_PREFIX$BACKUP_NAME" + +bold='\033[1m' +unbold='\033[0m' + +rm -f $RESTORED_DB + +echo -e "Selected backup: ${bold}$BACKUP_NAME${unbold}" +echo "Downloading backup..." +aws s3 cp "$BUCKET_PATH" "./$BACKUP_NAME" > /dev/null 2>&1 +if [ $? -ne 0 ]; then + echo "Failed to download backup from S3" + exit 1 +fi + +echo "Decrypting and restoring backup..." +openssl enc -d -aes-256-cbc -in "./$BACKUP_NAME" -pass "file:$ENCRYPTION_KEY_PATH" -pbkdf2 | gunzip | sqlite3 "$RESTORED_DB" +if [ $? -ne 0 ]; then + echo "Failed to decrypt and restore backup" + rm -f "./$BACKUP_NAME" + exit 1 +fi + +rm -f "./$BACKUP_NAME" + +echo -e "Backup ${bold}$BACKUP_NAME${unbold} restored as ${bold}$RESTORED_DB${unbold}" +echo -e "To use this backup, rename it to ${bold}.n8n-shortlink/n8n-shortlink.sqlite${unbold}" diff --git a/deploy/scripts/backup.sh b/deploy/scripts/backup.sh new file mode 100644 index 0000000..ea6d5c9 --- /dev/null +++ b/deploy/scripts/backup.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# Back up sqlite DB to AWS S3: deploy/scripts/backup.sh (via cronjob) + +CONFIG_FILEPATH="$HOME/deploy/.config" + +if [ ! -f "$CONFIG_FILEPATH" ]; then + echo "Error: Config file $CONFIG_FILEPATH not found." + exit 1 +fi + +DB_PATH=".n8n-shortlink/n8n-shortlink.sqlite" +BUCKET_NAME=$(grep BUCKET_NAME $CONFIG_FILEPATH | cut -d'=' -f2 | tr -d '"*') +PLAINTEXT_BACKUP_FILENAME="$(date +%Y-%m-%d-%H:%M:%S%z).sql.gz" +ENCRYPTED_BACKUP_FILENAME="$PLAINTEXT_BACKUP_FILENAME.enc" +ENCRYPTION_KEY_PATH="$HOME/.keys/n8n-shortlink-backup-secret.key" +LOG_FILE="$HOME/deploy/backups.log" +BUCKET_PATH="s3://$BUCKET_NAME/n8n-shortlink-backups/$ENCRYPTED_BACKUP_FILENAME" + +if [ ! -f "$DB_PATH" ]; then + echo "❌ Backup failed to start because of missing DB file at $DB_PATH" | tee -a $LOG_FILE + exit 1 +fi + +# ================================== +# compress + encrypt +# ================================== + +sqlite3 $DB_PATH .dump | gzip > "./$PLAINTEXT_BACKUP_FILENAME" +openssl enc -aes-256-cbc -salt -in "./$PLAINTEXT_BACKUP_FILENAME" -out "./$ENCRYPTED_BACKUP_FILENAME" -pass "file:$ENCRYPTION_KEY_PATH" -pbkdf2 + +# ================================== +# upload +# ================================== + +log_message() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE" +} + +UPLOAD_OUTPUT=$(aws s3 cp "./$ENCRYPTED_BACKUP_FILENAME" $BUCKET_PATH 2>&1) +EXIT_STATUS=$? +if [ $EXIT_STATUS -eq 0 ]; then + log_message "Backup uploaded: $ENCRYPTED_BACKUP_FILENAME" +else + log_message "Backup upload failed: $ENCRYPTED_BACKUP_FILENAME. Details: $UPLOAD_OUTPUT" +fi + +rm ./$ENCRYPTED_BACKUP_FILENAME +rm ./$PLAINTEXT_BACKUP_FILENAME diff --git a/deploy/scripts/start-services.sh b/deploy/scripts/start-services.sh new file mode 100644 index 0000000..25b8ebb --- /dev/null +++ b/deploy/scripts/start-services.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Script to start server and monitoring stack: deploy/scripts/start-server.sh + +GITHUB_USER=$(grep GITHUB_USER $HOME/deploy/.config | cut -d'=' -f2 | tr -d '"*') +GITHUB_REPO=$(grep GITHUB_REPO $HOME/deploy/.config | cut -d'=' -f2 | tr -d '"*') + +RAW_REPO_ROOT="https://raw.githubusercontent.com/$GITHUB_USER/$GITHUB_REPO" + +# update docker-compose files +curl -o deploy/docker-compose.monitoring.yml $RAW_REPO_ROOT/main/deploy/docker-compose.monitoring.yml +curl -o deploy/docker-compose.yml $RAW_REPO_ROOT/main/deploy/docker-compose.yml + +COMPOSE_PROJECT_NAME=n8n_shortlink docker compose --file deploy/docker-compose.monitoring.yml pull +COMPOSE_PROJECT_NAME=n8n_shortlink docker compose --file deploy/docker-compose.yml pull + +COMPOSE_PROJECT_NAME=n8n_shortlink docker compose --file deploy/docker-compose.monitoring.yml up -d +COMPOSE_PROJECT_NAME=n8n_shortlink docker compose --file deploy/docker-compose.yml --profile production up -d + +# Adding `COMPOSE_PROJECT_NAME=n8n_shortlink` prevents Docker from prefixing volume names with this dir name. \ No newline at end of file diff --git a/deploy/scripts/vps-system-setup.sh b/deploy/scripts/vps-system-setup.sh new file mode 100644 index 0000000..c5b31c9 --- /dev/null +++ b/deploy/scripts/vps-system-setup.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +# Upgrade system, enable firewall, install fail2ban, set up unattended upgrades. + +set -euo pipefail + +# ================================== +# admin +# ================================== + +sudo timedatectl set-timezone Europe/Berlin + +# ================================== +# upgrades +# ================================== + +export DEBIAN_FRONTEND=noninteractive +sudo apt update +sudo apt upgrade -y + +# ================================== +# ufw +# ================================== + +sudo ufw allow OpenSSH +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +echo "y" | sudo ufw enable + +# ================================== +# fail2ban +# ================================== + +sudo apt-get install fail2ban -y +sudo systemctl enable fail2ban +sudo systemctl start fail2ban + +# ================================== +# unattended upgrades +# ================================== + +sudo apt install unattended-upgrades -y +sudo dpkg-reconfigure -f noninteractive unattended-upgrades + +echo "System setup complete. Rebooting..." + +sudo reboot \ No newline at end of file diff --git a/deploy/scripts/vps-tooling-setup.sh b/deploy/scripts/vps-tooling-setup.sh new file mode 100644 index 0000000..fa4c94c --- /dev/null +++ b/deploy/scripts/vps-tooling-setup.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# Set up Caddy, Docker, sqlite3, AWS CLI. + +set -euo pipefail + +# ================================== +# caddy +# ================================== + +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo apt update +sudo apt install caddy +systemctl status caddy +# mv deploy/Caddyfile /etc/caddy/Caddyfile +systemctl reload caddy + +echo "Caddy installed and running as systemd service, Caddyfile copied over" + +# ================================== +# docker +# ================================== + +sudo apt install -y docker.io +sudo usermod --append --groups docker ${USER} + +echo "Docker installed and running as systemd service" + +sudo apt install -y docker-compose + +echo "docker-compose installed" + +# ================================== +# sqlite3 + DB +# ================================== + +sudo apt install -y sqlite3 +DOT_DIR=$HOME/.n8n-shortlink +DB_PATH=$DOT_DIR/n8n-shortlink.sqlite +mkdir -p $DOT_DIR +[ ! -f "$DB_PATH" ] && sqlite3 "$DB_PATH" "" && echo "Created empty database at $DB_PATH" + +echo "App setup complete" + +# ================================== +# aws cli +# ================================== + +echo "Installing AWS CLI. Please have your AWS S3 credentials ready..." + +curl "https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" -o "awscliv2.zip" +sudo apt install -y unzip +unzip awscliv2.zip +sudo ./aws/install +rm awscliv2.zip +aws --version +aws configure # enter AWS S3 credentials diff --git a/deploy/scripts/vps-user-setup.sh b/deploy/scripts/vps-user-setup.sh new file mode 100644 index 0000000..18a0fec --- /dev/null +++ b/deploy/scripts/vps-user-setup.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# Create sudo user, grant them SSH access, harden SSH config. + +set -euo pipefail + +read -p "Enter username: " USERNAME +echo + +read -sp "Enter password: " PASSWORD +echo + +read -sp "Confirm password: " PASSWORD_CONFIRM +echo + +if [ "$PASSWORD" != "$PASSWORD_CONFIRM" ]; then + echo "Error: Passwords do not match. Please try again." + exit 1 +fi + +# ================================== +# non-root user +# ================================== + +sudo adduser --disabled-password --gecos "" "$USERNAME" +echo "${USERNAME}:${PASSWORD}" | sudo chpasswd +sudo usermod --append --groups sudo "$USERNAME" + +echo "Created user $USERNAME and added them to sudo group" + +# ================================== +# copy SSH public key to user +# ================================== + +mkdir -p /home/$USERNAME/.ssh +chmod 700 /home/$USERNAME/.ssh +cp /root/.ssh/authorized_keys /home/$USERNAME/.ssh/authorized_keys +chown -R $USERNAME:$USERNAME /home/$USERNAME/.ssh +chmod 600 /home/$USERNAME/.ssh/authorized_keys + +echo "Copied SSH public key for user $USERNAME" + +# ================================== +# harden auth +# ================================== + +sudo sed -i '/^#?PermitRootLogin/c\PermitRootLogin no' /etc/ssh/sshd_config +sudo sed -i '/^#?PasswordAuthentication/c\PasswordAuthentication no' /etc/ssh/sshd_config +sudo systemctl restart ssh +echo "Disabled SSH login for root user" +echo "Disabled password auth for SSH" + +echo "User setup complete. Please exit and SSH back in as $USERNAME" \ No newline at end of file diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..7a4fac4 --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,104 @@ +# Deployment + +## 1. Initial setup + +Create SSH key pair and AES encryption key. + +```sh +ssh-keygen -t ed25519 -C "" -f ~/.ssh/id_ed25519_shortlink +openssl rand -out ~/.keys/n8n-shortlink-backup-secret.key 32 +``` + +Deploy an Ubuntu ARM64 VPS. Export IP address locally. + +```sh +export VPS_IP_ADDRESS= +``` + +## 2. VPS user setup + +Ensure public key is present in VPS, usually added during creation. + +```sh +# check if present +ssh -i ~/.ssh/id_ed25519_shortlink root@$VPS_IP_ADDRESS "cat ~/.ssh/authorized_keys" + +# else copy over +ssh-copy-id -i ~/.ssh/id_ed25519_shortlink.pub root@$VPS_IP_ADDRESS +``` + +Run user setup script: + +```sh +scp -i ~/.ssh/id_ed25519_shortlink deploy/scripts/vps-user-setup.sh root@$VPS_IP_ADDRESS:/root +ssh -i ~/.ssh/id_ed25519_shortlink root@$VPS_IP_ADDRESS +bash vps-user-setup.sh +``` + +Configure SSH access: + +```sh +export VPS_USER= + +echo "Host shortlink_vps + HostName $VPS_IP_ADDRESS + User $VPS_USER + IdentityFile ~/.ssh/id_ed25519_shortlink" >> ~/.ssh/config + +ssh-add ~/.ssh/id_ed25519_shortlink +``` + +## 3. VPS system setup + +Run system setup script: + +```sh +scp -r deploy/. shortlink_vps:~/deploy +ssh shortlink_vps +chmod +x deploy/scripts/*.sh +deploy/scripts/vps-system-setup.sh +``` + +## 4. Third-party services setup + +- **Sentry**: Create a project and note down the **DSN**. +- **AWS S3**: Create a bucket and note down the **bucket name**, **region**, **access key** and **secret**. Set a bucket lifecycle rule to delete files prefixed with `n8n-shortlink-backups` older than 10 days. +- **DNS**: Add an A record pointing domain to IP address. +- **GitHub**: Set up repository. @TODO: Token needed by GitHub Actions. +- **Docker**: @TODO +- **UptimeRobot**: Set up a monitor for the domain. Pause it. @TODO: Use Checkly +@TODO: Add Makefile command to tail logs + +## 5. VPS tooling setup + +Run tooling setup script and set up backup cron job: + +```sh +ssh shortlink_vps 'mkdir -p ~/.keys' +scp ~/.keys/n8n-shortlink-backup-secret.key shortlink_vps:~/.keys +ssh shortlink_vps + +export BUCKET_NAME= + +echo "BUCKET_NAME=$BUCKET_NAME" >> deploy/.config +echo "30 23 * * * $HOME/deploy/scripts/backup.sh" | crontab - +deploy/scripts/vps-tooling-setup.sh +``` + +## 6. Start services + +Run server start script: + +```sh +export GITHUB_USER= +export GITHUB_REPO= +export SENTRY_DSN= + +echo "GITHUB_USER=$GITHUB_USER" >> deploy/.config +echo "GITHUB_REPO=$GITHUB_REPO" >> deploy/.config +echo "N8N_SHORTLINK_SENTRY_DSN=$SENTRY_DSN" >> deploy/.env.production + +deploy/scripts/start-services.sh +``` + +Unpause UptimeRobot monitor. \ No newline at end of file diff --git a/docs/develop.md b/docs/develop.md new file mode 100644 index 0000000..1981abc --- /dev/null +++ b/docs/develop.md @@ -0,0 +1,68 @@ +# Development + +Install Go 1.22.5: + +```sh +brew install go@1.22.5 +``` + +Install Go tooling: + +```sh +go install gotest.tools/gotestsum@latest +go install golang.org/x/lint/golint@latest +go install github.com/air-verse/air@latest +go install -tags 'sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest +``` + +Clone repository: + +```sh +git clone git@github.com:ivov/n8n-shortlink.git && cd n8n-shortlink +``` + +Create an alias: + +```sh +echo "alias s.go='cd $(pwd)'" >> ~/.zshrc && source ~/.zshrc +``` + +Refer to the [Makefile](../Makefile): + +```sh +make help +``` + +> [!IMPORTANT] +> HTML files are embedded in the binary at compile time, so if you change them, you need to rebuild the binary, else use `make live` to start the server with live reload. + +## Sample requests + +Sample requests to create URL shortlink: + +```sh +curl -X POST http://localhost:3001/shortlink -d '{ "content": "https://ivov.dev" }' +curl -X POST http://localhost:3001/shortlink -d '{ "content": "https://ivov.dev", "slug": "my-url" }' +``` + +Sample request to create workflow shortlink: + +```sh +curl -X POST http://localhost:3001/shortlink -H "Content-Type: application/json" -d '{ "slug": "my-workflow", "content": "{\"nodes\":[{\"parameters\":{},\"id\":\"f6c01408-2371-4542-b4fa-abbfa61b0ef2\",\"name\":\"When clicking \u2018Test workflow\u2019\",\"type\":\"n8n-nodes-base.manualTrigger\",\"typeVersion\":1,\"position\":[580,300]},{\"parameters\":{\"options\":{}},\"id\":\"0cf6ba0e-b33e-4a8d-9dd0-10f4fdcc42c2\",\"name\":\"Edit Fields\",\"type\":\"n8n-nodes-base.set\",\"typeVersion\":3.4,\"position\":[800,300]}],\"connections\":{\"When clicking \u2018Test workflow\u2019\":{\"main\":[[{\"node\":\"Edit Fields\",\"type\":\"main\",\"index\":0}]]}},\"pinData\":{}}" }' +``` + +Sample requests to resolve shortlinks: + +```sh +curl http://localhost:3001/my-url +curl http://localhost:3001/my-workflow +curl http://localhost:3001/my-workflow/view +``` + +Sample requests for health and metrics: + +```sh +curl http://localhost:3001/health +curl http://localhost:3001/metrics +curl http://localhost:3001/debug/vars +``` \ No newline at end of file diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..c009715 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,7 @@ +- pull +- stop +- start + +fetch the latest docker compose + docker compose for monitoring + caddy from raw.githubusercontent + +sudo curl -o /etc/caddy/Caddyfile https://raw.githubusercontent.com/ivov/n8n-shortlink/main/deploy/Caddyfile \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..69592f0 --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module github.com/ivov/n8n-shortlink + +go 1.22.5 + +require ( + github.com/felixge/httpsnoop v1.0.4 + github.com/getsentry/sentry-go v0.28.1 + github.com/go-chi/chi/v5 v5.1.0 + github.com/golang-migrate/migrate/v4 v4.17.1 + github.com/jmoiron/sqlx v1.4.0 + github.com/mattn/go-sqlite3 v1.14.22 + github.com/prometheus/client_golang v1.19.1 + github.com/stretchr/testify v1.9.0 + github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce + go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.25.0 + golang.org/x/time v0.5.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bdedc39 --- /dev/null +++ b/go.sum @@ -0,0 +1,83 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/getsentry/sentry-go v0.28.1 h1:zzaSm/vHmGllRM6Tpx1492r0YDzauArdBfkJRtY6P5k= +github.com/getsentry/sentry-go v0.28.1/go.mod h1:1fQZ+7l7eeJ3wYi82q5Hg8GqAPgefRq+FP/QhafYVgg= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= +github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= +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/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..1b30f32 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,57 @@ +package api + +import ( + "expvar" + "net/http" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/ivov/n8n-shortlink/internal" + "github.com/ivov/n8n-shortlink/internal/config" + "github.com/ivov/n8n-shortlink/internal/log" + "github.com/ivov/n8n-shortlink/internal/services" +) + +// API encapsulates all routes and middleware. +type API struct { + Config *config.Config + Logger *log.Logger + // WaitGroup tracks background tasks to wait for during server shutdown. + WaitGroup sync.WaitGroup + ShortlinkService *services.ShortlinkService + VisitService *services.VisitService +} + +// Routes sets up middleware and routes on the API. +func (api *API) Routes() http.Handler { + r := chi.NewRouter() + + api.SetupMiddleware(r) + + static := internal.Static() + + fileServer := http.FileServer(http.FS(static)) + r.Handle("/static/*", http.StripPrefix("/static", fileServer)) + + // /static/canvas.tmpl.html and /static/swagger.html blocked by reverse proxy + + r.Get("/health", api.HandleGetHealth) + r.Get("/debug/vars", expvar.Handler().ServeHTTP) // blocked by reverse proxy + r.Get("/metrics", api.HandleGetMetrics) // blocked by reverse proxy + r.Get("/docs", func(w http.ResponseWriter, r *http.Request) { + http.ServeFileFS(w, r, static, "swagger.html") + }) + r.Get("/spec", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "openapi.yml") + }) + + r.Post("/shortlink", api.HandlePostShortlink) + r.Get("/{slug}/view", api.HandleGetSlug) + r.Get("/{slug}", api.HandleGetSlug) + + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + http.ServeFileFS(w, r, static, "index.html") + }) + + return r +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..eb5ab1c --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,707 @@ +package api_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ivov/n8n-shortlink/internal/api" + "github.com/ivov/n8n-shortlink/internal/config" + "github.com/ivov/n8n-shortlink/internal/db" + "github.com/ivov/n8n-shortlink/internal/db/entities" + "github.com/ivov/n8n-shortlink/internal/errors" + "github.com/ivov/n8n-shortlink/internal/log" + "github.com/ivov/n8n-shortlink/internal/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPI(t *testing.T) { + // ------------------------ + // setup + // ------------------------ + + cfg := config.Config{ + DB: struct{ FilePath string }{FilePath: ":memory:"}, + Env: "testing", + } + + config.SetupDotDir() + + logger, err := log.NewLogger(cfg.Env) + require.NoError(t, err) + + dbConn, err := db.SetupTestDB() + require.NoError(t, err) + defer dbConn.Close() + + api := &api.API{ + Config: &cfg, + Logger: &logger, + ShortlinkService: &services.ShortlinkService{DB: dbConn, Logger: &logger}, + VisitService: &services.VisitService{DB: dbConn, Logger: &logger}, + } + + api.InitMetrics("test-commit-sha") + + server := httptest.NewServer(api.Routes()) + defer server.Close() + + // ------------------------ + // utils + // ------------------------ + + storeShortlink := func(candidate entities.Shortlink) entities.Shortlink { + body, err := json.Marshal(candidate) + require.NoError(t, err) + + resp, err := http.Post(server.URL+"/shortlink", "application/json", bytes.NewBuffer(body)) + + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var response struct { + Data json.RawMessage `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&response) + require.NoError(t, err) + + var result entities.Shortlink + err = json.Unmarshal(response.Data, &result) + require.NoError(t, err) + assert.NotEmpty(t, result.Slug) + + return result + } + + noFollowRedirectClient := http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse // do not follow redirect so we can inspect it + }, + } + + type ErrorResponse struct { + Error struct { + Message string `json:"message"` + Doc string `json:"doc"` + Code string `json:"code"` + Trace string `json:"trace"` + } `json:"error"` + } + + toErrorResponse := func(body io.ReadCloser) ErrorResponse { + var errorResponse ErrorResponse + err := json.NewDecoder(body).Decode(&errorResponse) + require.NoError(t, err) + + return errorResponse + } + + assertChallengeShown := func(resp *http.Response) { + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + bodyString := string(bodyBytes) + + assert.Contains(t, bodyString, " map + var workflowMap map[string]interface{} + err = json.Unmarshal([]byte(workflowStr), &workflowMap) + require.NoError(t, err) + + // validate content + assert.Contains(t, workflowMap, "nodes") + nodes, ok := workflowMap["nodes"].([]interface{}) + assert.True(t, ok) + assert.Len(t, nodes, 1) + node := nodes[0].(map[string]interface{}) + assert.Equal(t, "n8n-nodes-base.start", node["type"]) + assert.Equal(t, float64(1), node["typeVersion"]) + position, ok := node["position"].([]interface{}) + assert.True(t, ok) + assert.Equal(t, []interface{}{float64(250), float64(300)}, position) + }) + + t.Run("should return 404 header + page on retrieval of inexistent slug", func(t *testing.T) { + resp, err := http.Get(server.URL + "/" + "inexistent") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + bodyString := string(bodyBytes) + + assert.Contains(t, bodyString, "Page not found") + }) + + t.Run("should record visit on retrieval", func(t *testing.T) { + candidate := entities.Shortlink{ + Kind: "url", + Content: "https://example.com/visit-test", + } + + result := storeShortlink(candidate) + + const referer = "https://test-referer.com" + const userAgent = "TestUserAgent/1.0" + req, err := http.NewRequest("GET", server.URL+"/"+result.Slug, nil) + require.NoError(t, err) + req.Header.Set("Referer", referer) + req.Header.Set("User-Agent", userAgent) + + resp, err := noFollowRedirectClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusMovedPermanently, resp.StatusCode) + + var visit entities.Visit + err = dbConn.Get(&visit, "SELECT * FROM visits WHERE slug = ? ORDER BY ts DESC LIMIT 1;", result.Slug) + require.NoError(t, err) + + assert.Equal(t, result.Slug, visit.Slug) + assert.Equal(t, referer, visit.Referer) + assert.Equal(t, userAgent, visit.UserAgent) + assert.NotZero(t, visit.TS) + }) + }) + + // ------------------------ + // custom slug + // ------------------------ + + t.Run("custom slug", func(t *testing.T) { + + t.Run("should create custom-slug shortlink and redirect on retrieval", func(t *testing.T) { + candidate := entities.Shortlink{ + Slug: "my-custom-slug", + Kind: "url", + Content: "https://example.org", + } + + result := storeShortlink(candidate) + + assert.Equal(t, candidate.Slug, result.Slug) + assert.Equal(t, candidate.Kind, result.Kind) + assert.Equal(t, candidate.Content, result.Content) + + resp, err := noFollowRedirectClient.Get(server.URL + "/" + result.Slug) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusMovedPermanently, resp.StatusCode) + assert.Equal(t, candidate.Content, resp.Header.Get("Location")) + }) + }) + + // ------------------------ + // creation validation + // ------------------------ + + t.Run("creation payload validation", func(t *testing.T) { + + t.Run("should reject on invalid creation payload", func(t *testing.T) { + testCases := []struct { + name string + shortlink entities.Shortlink + expectedStatusCode int + expectedErrorCode string + }{ + { + name: "Content as empty string", + shortlink: entities.Shortlink{}, // content defaults to empty string + expectedStatusCode: http.StatusBadRequest, + expectedErrorCode: errors.ToCode[errors.ErrContentMalformed], + }, + { + name: "Content as neither URL nor JSON", + shortlink: entities.Shortlink{Content: "not-a-url"}, + expectedErrorCode: errors.ToCode[errors.ErrContentMalformed], + }, + { + name: "Password too short", + shortlink: entities.Shortlink{Content: "https://example.com", Password: "1234567"}, + expectedErrorCode: errors.ToCode[errors.ErrPasswordTooShort], + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + body, err := json.Marshal(tc.shortlink) + require.NoError(t, err) + + resp, err := http.Post(server.URL+"/shortlink", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + errorResponse := toErrorResponse(resp.Body) + + assert.Equal(t, errorResponse.Error.Code, tc.expectedErrorCode) + }) + } + }) + + t.Run("should reject on duplicate custom slug in payload", func(t *testing.T) { + candidate := entities.Shortlink{ + Slug: "some-custom-slug", + Kind: "url", + Content: "https://example.com", + } + + result := storeShortlink(candidate) + + duplicate := entities.Shortlink{ + Slug: result.Slug, // already exists + Content: "https://other-example.com", + } + + body, err := json.Marshal(duplicate) + require.NoError(t, err) + resp, err := http.Post(server.URL+"/shortlink", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + errorResponse := toErrorResponse(resp.Body) + assert.Equal(t, errors.ToCode[errors.ErrSlugTaken], errorResponse.Error.Code) + + // verify that original shortlink is still intact + resp, err = noFollowRedirectClient.Get(server.URL + "/" + candidate.Slug) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusMovedPermanently, resp.StatusCode) + assert.Equal(t, "https://example.com", resp.Header.Get("Location")) + }) + + t.Run("should reject on invalid custom slug", func(t *testing.T) { + testCases := []struct { + errorCode string + slug string + }{ + {errors.ToCode[errors.ErrSlugTooShort], "abc"}, + {errors.ToCode[errors.ErrSlugTooLong], strings.Repeat("a", 513)}, + {errors.ToCode[errors.ErrSlugMisformatted], "abc+def"}, + {errors.ToCode[errors.ErrSlugReserved], "health"}, + } + + for _, tc := range testCases { + shortlink := entities.Shortlink{ + Slug: tc.slug, + Content: "https://example.com", + } + + body, _ := json.Marshal(shortlink) + resp, err := http.Post(server.URL+"/shortlink", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + errorResponse := toErrorResponse(resp.Body) + + assert.Equal(t, errorResponse.Error.Code, tc.errorCode) + } + }) + + t.Run("should reject payload >= 5 MB", func(t *testing.T) { + tooBig := strings.Repeat("a", 5*1024*1024) // 5 MB + tooBigCandidate := entities.Shortlink{ + Content: "https://example.com?" + tooBig, + } + + body, err := json.Marshal(tooBigCandidate) + require.NoError(t, err) + + resp, err := http.Post(server.URL+"/shortlink", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + errorResponse := toErrorResponse(resp.Body) + assert.Equal(t, errorResponse.Error.Code, errors.ToCode[errors.ErrPayloadTooLarge]) + + // retry with smaller payload + + rightSized := strings.Repeat("a", 4*1024*1024) // 4 MB + rightSizedCandidate := entities.Shortlink{ + Kind: "url", + Content: "https://example.com?" + rightSized, + } + + result := storeShortlink(rightSizedCandidate) + + assert.NotEmpty(t, result.Slug) + assert.Equal(t, rightSizedCandidate.Kind, result.Kind) + assert.Equal(t, rightSizedCandidate.Content, result.Content) + }) + }) + + t.Run("rate limiting", func(t *testing.T) { + + t.Run("should enforce rate limiting", func(t *testing.T) { + // enable rate limiting only for this test + originalConfig := *api.Config + api.Config.RateLimiter.Enabled = true + api.Config.RateLimiter.RPS = 2 + api.Config.RateLimiter.Burst = 2 + defer func() { + api.Config = &originalConfig + }() + + shortlink := entities.Shortlink{ + Content: "https://example.com", + } + body, err := json.Marshal(shortlink) + require.NoError(t, err) + + // util to make a request + makeRequest := func() (*http.Response, error) { + return http.Post(server.URL+"/shortlink", "application/json", bytes.NewBuffer(body)) + } + + // make requests up to the limit + for i := 0; i < api.Config.RateLimiter.Burst; i++ { + resp, err := makeRequest() + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode) + } + + resp, err := makeRequest() // should be rate limited + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + + errorResponse := toErrorResponse(resp.Body) + + assert.Equal(t, "You have exceeded the rate limit. Please wait and retry later.", errorResponse.Error.Message) + + time.Sleep(time.Second) // wait for rate limit to reset + + resp, err = makeRequest() // should succeed + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode) + }) + }) + + t.Run("password protection", func(t *testing.T) { + + t.Run("should store password-protected shortlink", func(t *testing.T) { + candidate := entities.Shortlink{ + Kind: "url", + Content: "https://example.com", + Password: "securepass123", + } + + result := storeShortlink(candidate) + + var storedPassword string + err = dbConn.Get(&storedPassword, "SELECT password FROM shortlinks WHERE slug = ?", result.Slug) + require.NoError(t, err) + assert.NotEmpty(t, storedPassword) + assert.NotContains(t, storedPassword, "securepass123") // has been hashed + + assert.NotEmpty(t, result.Slug) + assert.Equal(t, candidate.Kind, result.Kind) + assert.Equal(t, candidate.Content, result.Content) + assert.Empty(t, result.Password) // not returned in response + }) + + t.Run("should show challenge for password-protected shortlink", func(t *testing.T) { + result := storeShortlink(entities.Shortlink{ + Kind: "url", + Content: "https://example.com/protected", + Password: "securepass123", + }) + + resp, err := http.Get(server.URL + "/" + result.Slug) + require.NoError(t, err) + defer resp.Body.Close() + + assertChallengeShown(resp) + }) + + t.Run("should return original URL if correct password", func(t *testing.T) { + plainPassword := "securepass123" + candidate := entities.Shortlink{ + Kind: "url", + Content: "https://example.com/protected", + Password: plainPassword, + } + result := storeShortlink(candidate) + + req, err := http.NewRequest("GET", server.URL+"/"+result.Slug, nil) + require.NoError(t, err) + + auth := base64.StdEncoding.EncodeToString([]byte(plainPassword)) + req.Header.Add("Authorization", "Basic "+auth) + + resp, err := noFollowRedirectClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + bodyString := string(bodyBytes) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + assert.Equal(t, "{\"url\":\"https://example.com/protected\"}\n", bodyString) + }) + + t.Run("should deny access with incorrect password", func(t *testing.T) { + candidate := entities.Shortlink{ + Kind: "url", + Content: "https://example.com/protected", + Password: "securepass123", + } + result := storeShortlink(candidate) + + req, err := http.NewRequest("GET", server.URL+"/"+result.Slug, nil) + require.NoError(t, err) + + auth := base64.StdEncoding.EncodeToString([]byte("wrongpass")) + req.Header.Add("Authorization", "Basic "+auth) + + resp, err := noFollowRedirectClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("should show challenge on missing Authorization header", func(t *testing.T) { + candidate := entities.Shortlink{ + Kind: "url", + Content: "https://example.com/protected", + Password: "securepass123", + } + result := storeShortlink(candidate) + + req, err := http.NewRequest("GET", server.URL+"/"+result.Slug, nil) + require.NoError(t, err) + + resp, err := noFollowRedirectClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assertChallengeShown(resp) + }) + + t.Run("should reject on malformed Authorization header", func(t *testing.T) { + candidate := entities.Shortlink{ + Kind: "url", + Content: "https://example.com/protected", + Password: "securepass123", + } + result := storeShortlink(candidate) + + req, err := http.NewRequest("GET", server.URL+"/"+result.Slug, nil) + require.NoError(t, err) + + auth := base64.StdEncoding.EncodeToString([]byte("wrongpass")) + req.Header.Add("Authorization", "B_a_s_i_c "+auth) + + resp, err := noFollowRedirectClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("should reject on missing password in Authorization header", func(t *testing.T) { + candidate := entities.Shortlink{ + Kind: "url", + Content: "https://example.com/protected", + Password: "securepass123", + } + result := storeShortlink(candidate) + + req, err := http.NewRequest("GET", server.URL+"/"+result.Slug, nil) + require.NoError(t, err) + + auth := base64.StdEncoding.EncodeToString([]byte("")) + req.Header.Add("Authorization", "B_a_s_i_c "+auth) + + resp, err := noFollowRedirectClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + }) +} diff --git a/internal/api/handle_get_health.go b/internal/api/handle_get_health.go new file mode 100644 index 0000000..181308a --- /dev/null +++ b/internal/api/handle_get_health.go @@ -0,0 +1,18 @@ +package api + +import ( + "fmt" + "net/http" +) + +// HandleGetHealth returns the health status of the API. +func (api *API) HandleGetHealth(w http.ResponseWriter, r *http.Request) { + health := fmt.Sprintf( + `{"status": "ok", "environment": %q, "version": %q}`, + api.Config.Env, + api.Config.Build.CommitSha, + ) + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(health)) +} diff --git a/internal/api/handle_get_metrics.go b/internal/api/handle_get_metrics.go new file mode 100644 index 0000000..86b95e5 --- /dev/null +++ b/internal/api/handle_get_metrics.go @@ -0,0 +1,92 @@ +package api + +import ( + "expvar" + "net/http" + "strconv" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// HandleGetMetrics handles the /metrics endpoint exposing metrics for Prometheus. +func (api *API) HandleGetMetrics(w http.ResponseWriter, r *http.Request) { + updatePrometheusMetrics() // on every scrape + + promhttp.HandlerFor( + prometheus.DefaultGatherer, + promhttp.HandlerOpts{ + EnableOpenMetrics: true, + }, + ).ServeHTTP(w, r) +} + +var ( + totalRequestsReceived = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "total_requests_received", + Help: "Total number of requests received", + }) + totalResponsesSent = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "total_responses_sent", + Help: "Total number of responses sent", + }) + totalProcessingTimeMs = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "total_processing_time_ms", + Help: "Total processing time in milliseconds", + }) + inFlightRequests = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "in_flight_requests", + Help: "Number of requests currently being processed", + }) + responsesSentByStatus = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "responses_sent_by_status", + Help: "Total responses sent by HTTP status code", + }, + []string{"status"}, + ) +) + +var ( + lastTotalRequestsReceived float64 + lastTotalResponsesSent float64 + lastTotalProcessingTimeMs float64 +) + +func init() { + prometheus.MustRegister(totalRequestsReceived) + prometheus.MustRegister(totalResponsesSent) + prometheus.MustRegister(totalProcessingTimeMs) + prometheus.MustRegister(inFlightRequests) + prometheus.MustRegister(responsesSentByStatus) +} + +func updatePrometheusMetrics() { + expvar.Do(func(kv expvar.KeyValue) { + switch kv.Key { + case "total_requests_received": + if v, err := strconv.ParseFloat(kv.Value.String(), 64); err == nil { + totalRequestsReceived.Add(v) + } + case "total_responses_sent": + if v, err := strconv.ParseFloat(kv.Value.String(), 64); err == nil { + totalResponsesSent.Add(v) + } + case "total_processing_time_ms": + if v, err := strconv.ParseFloat(kv.Value.String(), 64); err == nil { + totalProcessingTimeMs.Add(v) + } + case "in_flight_requests": + if v, err := strconv.ParseFloat(kv.Value.String(), 64); err == nil { + inFlightRequests.Set(v) + } + case "total_responses_sent_by_status": + m := kv.Value.(*expvar.Map) + m.Do(func(kv expvar.KeyValue) { + if v, err := strconv.ParseFloat(kv.Value.String(), 64); err == nil { + responsesSentByStatus.WithLabelValues(kv.Key).Add(v) + } + }) + } + }) +} diff --git a/internal/api/handle_get_protected_{slug}.go b/internal/api/handle_get_protected_{slug}.go new file mode 100644 index 0000000..dd3f16f --- /dev/null +++ b/internal/api/handle_get_protected_{slug}.go @@ -0,0 +1,59 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "strings" + + "github.com/ivov/n8n-shortlink/internal" + "github.com/ivov/n8n-shortlink/internal/db/entities" + "github.com/ivov/n8n-shortlink/internal/errors" + "github.com/ivov/n8n-shortlink/internal/log" +) + +// HandleGetProtectedSlug handles a GET /p/{slug} request by resolving a password-protected shortlink. +func (api *API) HandleGetProtectedSlug(w http.ResponseWriter, r *http.Request, slug string, shortlink *entities.Shortlink) { + authHeader := r.Header.Get("Authorization") + + if authHeader == "" { + http.ServeFileFS(w, r, internal.Static(), "challenge.html") + return + } + + if !strings.HasPrefix(authHeader, "Basic ") { + api.Unauthorized(errors.ErrAuthHeaderMalformed, "MALFORMED_AUTHORIZATION_HEADER", w) + return + } + + encodedPassword := strings.TrimPrefix(authHeader, "Basic ") + decodedBytes, err := base64.StdEncoding.DecodeString(encodedPassword) + if err != nil { + api.Unauthorized(errors.ErrAuthHeaderMalformed, "MALFORMED_AUTHORIZATION_HEADER", w) + return + } + + decodedPassword := string(decodedBytes) + if !api.ShortlinkService.VerifyPassword(shortlink.Password, decodedPassword) { + api.Unauthorized(errors.ErrPasswordInvalid, "INVALID_PASSWORD", w) + return + } + + api.Logger.Info("password verified", log.Str("slug", slug)) + + err = api.VisitService.SaveVisit(slug, shortlink.Kind, r.Referer(), r.UserAgent()) + if err != nil { + api.Logger.Error(err) // log and move on + } + + switch shortlink.Kind { + case "workflow": + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(shortlink.Content)) + case "url": + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"url": shortlink.Content}) + default: + api.BadRequest(errors.ErrKindUnsupported, w) + } +} diff --git a/internal/api/handle_get_{slug}.go b/internal/api/handle_get_{slug}.go new file mode 100644 index 0000000..26d00f4 --- /dev/null +++ b/internal/api/handle_get_{slug}.go @@ -0,0 +1,71 @@ +package api + +import ( + stdErrors "errors" + "html/template" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/ivov/n8n-shortlink/internal" + "github.com/ivov/n8n-shortlink/internal/errors" +) + +// HandleGetSlug handles a GET /{slug} request by resolving a regular shortlink. +func (api *API) HandleGetSlug(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + + shortlink, err := api.ShortlinkService.GetBySlug(slug) + if err != nil { + if stdErrors.Is(err, errors.ErrShortlinkNotFound) { + w.WriteHeader(http.StatusNotFound) + http.ServeFileFS(w, r, internal.Static(), "404.html") + } else { + api.InternalServerError(err, w) + } + return + } + + if shortlink.Password != "" { + api.HandleGetProtectedSlug(w, r, slug, shortlink) + return + } + + err = api.VisitService.SaveVisit(slug, shortlink.Kind, r.Referer(), r.UserAgent()) + if err != nil { + api.Logger.Error(err) // log and move on + } + + switch shortlink.Kind { + case "workflow": + if strings.HasSuffix(r.URL.Path, "/view") { + tmpl, err := template.ParseFS(internal.Static(), "canvas.tmpl.html") + if err != nil { + api.InternalServerError(err, w) + return + } + + data := struct { + Workflow string + WorkflowSlug string + }{ + Workflow: shortlink.Content, + WorkflowSlug: slug, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + err = tmpl.Execute(w, data) + if err != nil { + api.InternalServerError(err, w) + } + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(shortlink.Content)) + case "url": + http.Redirect(w, r, shortlink.Content, http.StatusMovedPermanently) + default: + api.BadRequest(errors.ErrKindUnsupported, w) + } +} diff --git a/internal/api/handle_post_shortlink.go b/internal/api/handle_post_shortlink.go new file mode 100644 index 0000000..142238a --- /dev/null +++ b/internal/api/handle_post_shortlink.go @@ -0,0 +1,122 @@ +package api + +import ( + "encoding/json" + stdErrors "errors" + "fmt" + "net/http" + "net/url" + + "github.com/ivov/n8n-shortlink/internal/db/entities" + "github.com/ivov/n8n-shortlink/internal/errors" + "github.com/tomasen/realip" +) + +// HandlePostShortlink handles a POST /shortlink request by creating a shortlink. +func (api *API) HandlePostShortlink(w http.ResponseWriter, r *http.Request) { + + // check size limit + + const maxPayloadSize = 5 * 1024 * 1024 // 5 MB + + if r.ContentLength > maxPayloadSize { + api.BadRequest(errors.ErrPayloadTooLarge, w) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxPayloadSize) + + var candidate entities.Shortlink + + err := json.NewDecoder(r.Body).Decode(&candidate) + if err != nil { + if err.Error() == "http: request body too large" { + api.BadRequest(errors.ErrPayloadTooLarge, w) + } else { + api.InternalServerError(fmt.Errorf("failed to decode JSON: %w", err), w) + } + return + } + + // check kind + + _, err = url.ParseRequestURI(candidate.Content) + if err == nil { + candidate.Kind = "url" + } else { + var unmarshaled interface{} + err = json.Unmarshal([]byte(candidate.Content), &unmarshaled) + if err == nil { + candidate.Kind = "workflow" + } else { + api.BadRequest(errors.ErrContentMalformed, w) + return + } + } + + if err := api.ShortlinkService.ValidateKind(candidate.Kind); err != nil { + api.BadRequest(errors.ErrKindUnsupported, w) + return + } + + // check or generate slug + + if candidate.Slug != "" { + err = api.ShortlinkService.ValidateUserSlug(candidate.Slug) + if err != nil { + switch { + case stdErrors.Is(err, errors.ErrSlugTooShort): + api.BadRequest(errors.ErrSlugTooShort, w) + case stdErrors.Is(err, errors.ErrSlugTooLong): + api.BadRequest(errors.ErrSlugTooLong, w) + case stdErrors.Is(err, errors.ErrSlugMisformatted): + api.BadRequest(errors.ErrSlugMisformatted, w) + case stdErrors.Is(err, errors.ErrSlugTaken): + api.BadRequest(errors.ErrSlugTaken, w) + case stdErrors.Is(err, errors.ErrSlugReserved): + api.BadRequest(errors.ErrSlugReserved, w) + default: + api.InternalServerError(err, w) + } + return + } + } else { + generatedSlug, err := api.ShortlinkService.GenerateSlug() + if err != nil { + api.InternalServerError(err, w) + return + } + candidate.Slug = generatedSlug + } + + // check and hash password if provided + + if candidate.Password != "" { + if err := api.ShortlinkService.ValidatePasswordLength(candidate.Password); err != nil { + api.BadRequest(err, w) + return + } + + hash, err := api.ShortlinkService.HashPassword(candidate.Password) + if err != nil { + api.InternalServerError(err, w) + return + } + candidate.Password = hash + } + + candidate.AllowedVisits = -1 // unlimited, not yet implemented + // candidate.ExpiresAt = &entities.CustomTime{Time: time.Now()} // not yet implemented + + candidate.CreatorIP = realip.FromRequest(r) + + shortlink, err := api.ShortlinkService.SaveShortlink(&candidate) + if err != nil { + api.InternalServerError(err, w) + return + } + + shortlink.Password = "" // do not return the password + + api.CreatedSuccesfully(w, shortlink) +} diff --git a/internal/api/metrics.go b/internal/api/metrics.go new file mode 100644 index 0000000..9d1b9dc --- /dev/null +++ b/internal/api/metrics.go @@ -0,0 +1,27 @@ +package api + +import ( + "expvar" + "runtime" + "time" +) + +var startTime = time.Now() + +// InitMetrics initializes server-level expvar metrics, exposed via GET /debug/vars. +// Request-level expvar metrics are collected in middleware. +// +// Prometheus metrics are based on expvar metrics and exposed via GET /metrics. +// commit_sha, uptime_seconds, timestamp, goroutines are excluded from Prometheus metrics. +func (api *API) InitMetrics(commitSha string) { + expvar.NewString("commit_sha").Set(commitSha) + expvar.Publish("uptime_seconds", expvar.Func(func() interface{} { + return int64(time.Since(startTime).Seconds()) + })) + expvar.Publish("timestamp", expvar.Func(func() interface{} { + return time.Now().Unix() + })) + expvar.Publish("goroutines", expvar.Func(func() interface{} { + return runtime.NumGoroutine() + })) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..6a0af88 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,236 @@ +package api + +import ( + "expvar" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/felixge/httpsnoop" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/ivov/n8n-shortlink/internal/log" + "github.com/tomasen/realip" + "golang.org/x/time/rate" +) + +// SetupMiddleware sets up all middleware on the API. +func (api *API) SetupMiddleware(r *chi.Mux) { + r.Use(api.recoverPanic) + r.Use(middleware.RequestID) + r.Use(middleware.CleanPath) + r.Use(middleware.StripSlashes) + r.Use(api.addRelaxedCorsHeaders) + r.Use(api.addSecurityHeaders) + r.Use(api.addCacheHeadersForStaticFiles) + r.Use(api.rateLimit) + r.Use(api.logRequest) + r.Use(api.metrics) +} + +func (api *API) addCacheHeadersForStaticFiles(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/static/") { + w.Header().Set("Cache-Control", "public, max-age=2592000") // 1 month + } + next.ServeHTTP(w, r) + }) +} + +func (api *API) metrics(next http.Handler) http.Handler { + totalRequestsReceived := expvar.NewInt("total_requests_received") + totalResponsesSent := expvar.NewInt("total_responses_sent") + totalProcessingTimeMs := expvar.NewInt("total_processing_time_ms") + totalResponsesSentbyStatus := expvar.NewMap("total_responses_sent_by_status") + inFlightRequests := expvar.NewInt("in_flight_requests") + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + totalRequestsReceived.Add(1) + metrics := httpsnoop.CaptureMetrics(next, w, r) + totalResponsesSent.Add(1) + totalProcessingTimeMs.Add(metrics.Duration.Milliseconds()) + totalResponsesSentbyStatus.Add(strconv.Itoa(metrics.Code), 1) + inFlightRequests.Set(totalRequestsReceived.Value() - totalResponsesSent.Value()) + }) +} + +func (api *API) addSecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Strict-Transport-Security", "max-age=31536000") + + next.ServeHTTP(w, r) + }) +} + +func (api *API) recoverPanic(next http.Handler) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + w.Header().Set("Connection", "close") + err := fmt.Errorf("%s", err) + api.InternalServerError(err, w) + } + }() + + next.ServeHTTP(w, r) + }, + ) +} + +func isLogIgnored(path string) bool { + for _, denyExact := range []string{"/health", "/favicon.ico", "/metrics"} { + if path == denyExact || strings.HasPrefix(path, "/static/") { + return true + } + } + return false +} + +func (api *API) logRequest(next http.Handler) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if isLogIgnored(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + wrappedWriter := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + start := time.Now() + + defer func() { + requestData := struct { + Protocol string `json:"protocol"` + Method string `json:"method"` + Path string `json:"path"` + QueryString string `json:"query_string"` + Latency int `json:"latency_ms"` + Status int `json:"status"` + SizeBytes int `json:"size_bytes"` + RequestID string `json:"request_id"` + }{ + Protocol: r.Proto, + Method: r.Method, + Path: r.URL.Path, + QueryString: r.URL.Query().Encode(), + Latency: int(time.Duration(time.Since(start).Milliseconds())), + Status: wrappedWriter.Status(), + SizeBytes: wrappedWriter.BytesWritten(), + RequestID: middleware.GetReqID(r.Context()), + } + + api.Logger.Info( + "Served request", + log.Str("protocol", requestData.Protocol), + log.Str("method", requestData.Method), + log.Str("path", requestData.Path), + log.Str("query_string", requestData.QueryString), + log.Int("latency_ms", requestData.Latency), + log.Int("status", requestData.Status), + log.Int("size_bytes", requestData.SizeBytes), + log.Str("request_id", requestData.RequestID), + ) + }() + + next.ServeHTTP(wrappedWriter, r) + }, + ) +} + +func (api *API) addRelaxedCorsHeaders(next http.Handler) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Referer, User-Agent") + + next.ServeHTTP(w, r) + }, + ) +} + +var exemptedFromRateLimiting = []string{"/", "/docs"} + +func isExemptedFromRateLimiting(path string) bool { + if strings.HasPrefix(path, "/static/") { + return true + } + + for _, item := range exemptedFromRateLimiting { + if path == item { + return true + } + } + + return false +} + +func (api *API) rateLimit(next http.Handler) http.Handler { + type RateLimiterPerClient struct { + limiter *rate.Limiter + lastSeen time.Time + } + + type IPAddress = string + + var ( + mutex sync.Mutex + clients = make(map[IPAddress]*RateLimiterPerClient) + ) + + // every minute clear out inactive clients in the background + go func() { + for { + time.Sleep(time.Minute) + + mutex.Lock() + + for ip, client := range clients { + if time.Since(client.lastSeen) > api.Config.RateLimiter.Inactivity { + delete(clients, ip) + } + } + + mutex.Unlock() + } + }() + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isExemptedFromRateLimiting(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + if api.Config.RateLimiter.Enabled { + ip := realip.FromRequest(r) + + mutex.Lock() + + if _, ok := clients[ip]; !ok { + clients[ip] = &RateLimiterPerClient{ + limiter: rate.NewLimiter( + rate.Limit(api.Config.RateLimiter.RPS), + api.Config.RateLimiter.Burst), + lastSeen: time.Now(), + } + } + + if !clients[ip].limiter.Allow() { + mutex.Unlock() + api.RateLimitExceeded(w, ip) + return + } + + mutex.Unlock() + } + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/api/responses.go b/internal/api/responses.go new file mode 100644 index 0000000..57e0b27 --- /dev/null +++ b/internal/api/responses.go @@ -0,0 +1,120 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/getsentry/sentry-go" + "github.com/ivov/n8n-shortlink/internal/errors" + "github.com/ivov/n8n-shortlink/internal/log" +) + +func (api *API) jsonResponse(w http.ResponseWriter, status int, payload interface{}) { + json, err := json.Marshal(payload) + if err != nil { + api.InternalServerError(err, w) + } + + w.WriteHeader(status) + w.Header().Set("Content-Type", "application/json") + w.Write(json) +} + +// CreatedSuccesfully responds with a 201. +func (api *API) CreatedSuccesfully(w http.ResponseWriter, payload interface{}) { + api.jsonResponse(w, http.StatusCreated, SuccessResponse{Data: payload}) +} + +// InternalServerError responds with a 500. +func (api *API) InternalServerError(err error, w http.ResponseWriter) { + api.Logger.Error(err) + + sentry.CaptureException(err) + + errorResponse := ErrorResponse{ + Error: ErrorField{ + Message: "The server encountered an unexpected issue. Please contact the administrator.", + Code: "INTERNAL_SERVER_ERROR", + Doc: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500", + Trace: err.Error(), + }, + } + + api.jsonResponse(w, http.StatusInternalServerError, errorResponse) +} + +// BadRequest responds with a 400. +func (api *API) BadRequest(err error, w http.ResponseWriter) { + api.Logger.Info(err.Error()) + + errorResponse := ErrorResponse{ + Error: ErrorField{ + Message: "Your request is invalid. Please correct the request and retry.", + Code: errors.ToCode[err], + Doc: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400", + Trace: err.Error(), + }, + } + + api.jsonResponse(w, http.StatusBadRequest, errorResponse) +} + +// NotFound responds with a 404. +func (api *API) NotFound(w http.ResponseWriter) { + errorResponse := ErrorResponse{ + Error: ErrorField{ + Message: "The requested resource could not be found.", + Doc: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404", + Trace: "n/a", + }, + } + + api.jsonResponse(w, http.StatusNotFound, errorResponse) +} + +// RateLimitExceeded responds with a 429. +func (api *API) RateLimitExceeded(w http.ResponseWriter, ip string) { + api.Logger.Info("client exceeded rate limit", log.Str("ip", ip)) + + errorResponse := ErrorResponse{ + Error: ErrorField{ + Message: "You have exceeded the rate limit. Please wait and retry later.", + Doc: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429", + Trace: "n/a", + }, + } + + api.jsonResponse(w, http.StatusTooManyRequests, errorResponse) +} + +// Unauthorized responds with a 401. +func (api *API) Unauthorized(err error, code string, w http.ResponseWriter) { + payload := ErrorResponse{ + Error: ErrorField{ + Message: "Missing valid authentication credentials.", + Code: errors.ToCode[err], + Doc: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401", + Trace: "n/a", + }, + } + + api.jsonResponse(w, http.StatusUnauthorized, payload) +} + +// ErrorResponse is a generic error response. +type ErrorResponse struct { + Error ErrorField `json:"error"` +} + +// SuccessResponse is a generic success response. +type SuccessResponse struct { + Data interface{} `json:"data"` +} + +// ErrorField contains error details. +type ErrorField struct { + Message string `json:"message"` + Code string `json:"code"` + Doc string `json:"doc"` + Trace string `json:"trace"` +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2d7ef2c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,122 @@ +package config + +import ( + "flag" + "os" + "path/filepath" + "time" + + "github.com/ivov/n8n-shortlink/internal/env" +) + +// Config holds all configuration for the API. +type Config struct { + Env string + Host string + Port int + DB struct { + FilePath string + } + Log struct { + FilePath string + } + RateLimiter struct { + Enabled bool + RPS int + Burst int + Inactivity time.Duration + } + Sentry struct { + DSN string + } + MetadataMode *bool // whether to display binary metadata and exit + Build struct { + CommitSha string + } +} + +// NewConfig instantiates a new Config from environment variables. +func NewConfig(commitSha string) Config { + config := Config{} + config.Build.CommitSha = commitSha + + flag.StringVar( + &config.Env, + "environment", + env.GetStr("N8N_SHORTLINK_ENVIRONMENT", "development"), + "Environment to run in (development, production, testing)", + ) + + flag.StringVar( + &config.Host, + "host", + env.GetStr("N8N_SHORTLINK_HOST", "localhost"), + "Host to listen on", + ) + + flag.IntVar( + &config.Port, + "port", + env.GetInt("N8N_SHORTLINK_PORT", 3001), + "Port to listen on", + ) + + flag.BoolVar( + &config.RateLimiter.Enabled, + "rate-limiter-enabled", + env.GetBool("N8N_SHORTLINK_RATE_LIMITER_ENABLED", true), + "Whether to enable rate limiter", + ) + + flag.IntVar( + &config.RateLimiter.RPS, + "rate-limiter-rps", + env.GetInt("N8N_SHORTLINK_RATE_LIMITER_RPS", 2), + "Max requests per second per client allowed by rate limiter", + ) + + flag.IntVar( + &config.RateLimiter.Burst, + "rate-limiter-burst", + env.GetInt("N8N_SHORTLINK_RATE_LIMITER_BURST", 4), + "Max burst per client allowed by rate limiter", + ) + + flag.DurationVar( + &config.RateLimiter.Inactivity, + "rate-limiter-inactivity", + env.GetDuration("N8N_SHORTLINK_RATE_LIMITER_INACTIVITY", "3m"), + "Duration after which inactive rate limiter clients are cleared", + ) + + const defaultSentryDSN = "https://f53e747195fcd00533f1f118ce69b44f@o4504685792460800.ingest.us.sentry.io/4507658952638464" + + flag.StringVar( + &config.Sentry.DSN, + "sentry-dsn", + env.GetStr("N8N_SHORTLINK_SENTRY_DSN", defaultSentryDSN), + "Sentry DSN", + ) + + config.MetadataMode = flag.Bool("metadata-mode", false, "Display binary metadata and exit") + + flag.Parse() + + return config +} + +// SetupDotDir creates the .n8n-shortlink dir in the user's home dir. +func SetupDotDir() { + home, err := os.UserHomeDir() + if err != nil { + panic(err) + } + + dotDirPath := filepath.Join(home, ".n8n-shortlink") + + if _, err := os.Stat(dotDirPath); os.IsNotExist(err) { + if err := os.MkdirAll(dotDirPath, 0755); err != nil { + panic(err) + } + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..331b9e7 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,165 @@ +package db + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/sqlite3" + _ "github.com/golang-migrate/migrate/v4/source/file" // file source + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" // SQLite driver +) + +// Setup connects to the DB, sets PRAGMAs, and runs migrations. +func Setup( + env string, +) (*sqlx.DB, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + home, err := os.UserHomeDir() + if err != nil { + panic(err) + } + + dbFilePath := filepath.Join(home, ".n8n-shortlink", "n8n-shortlink.sqlite") + + db, connErr := connect(ctx, dbFilePath) + if connErr != nil { + return nil, connErr + } + + pragmaErr := setPragmas(ctx, db) + if pragmaErr != nil { + return nil, pragmaErr + } + + if err := RunMigrations(db, env); err != nil { + return nil, err + } + + var tableExists bool + err = db.QueryRow(` + SELECT EXISTS (SELECT name FROM sqlite_master WHERE type='table' AND name='shortlinks'); + `).Scan(&tableExists) + if err != nil { + return nil, fmt.Errorf("error checking for shortlinks table: %w", err) + } + + if !tableExists { + return nil, fmt.Errorf("failed to find shortlinks table, did you forget to run migrations?") + } + + return db, nil +} + +func connect( + ctx context.Context, + filePath string, +) (*sqlx.DB, error) { + db, err := sqlx.Open("sqlite3", filePath) + if err != nil { + return nil, fmt.Errorf("failed to connect to SQLite DB: %s", err) + } + + err = db.PingContext(ctx) + if err != nil { + return nil, err + } + + return db, nil +} + +func setPragmas( + ctx context.Context, + db *sqlx.DB, +) error { + pragmas := []string{ + "PRAGMA foreign_keys = ON;", + "PRAGMA journal_mode = WAL;", + "PRAGMA busy_timeout = 5000;", + "PRAGMA synchronous = NORMAL;", + "PRAGMA cache_size = -20000;", + "PRAGMA temp_store = memory;", + } + + for _, pragma := range pragmas { + _, err := db.ExecContext(ctx, pragma) + if err != nil { + return fmt.Errorf("failed to set PRAGMA: %s", err) + } + } + + return nil +} + +// RunMigrations applies up migrations to the DB. +func RunMigrations(dbConn *sqlx.DB, env string) error { + driver, err := sqlite3.WithInstance(dbConn.DB, &sqlite3.Config{}) + if err != nil { + return fmt.Errorf("failed to create sqlite driver: %w", err) + } + + migrationsDirPath, err := getMigrationsDirPath(env) + if err != nil { + return fmt.Errorf("failed to get migrations path: %w", err) + } + migrationsURL := fmt.Sprintf("file://%s", migrationsDirPath) + + m, err := migrate.NewWithDatabaseInstance(migrationsURL, "sqlite3", driver) + if err != nil { + return fmt.Errorf("failed to create `migrate` instance: %w", err) + } + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("failed to run up migrations: %w", err) + } + + return nil +} + +// SetupTestDB creates an in-memory SQLite DB and runs migrations. +func SetupTestDB() (*sqlx.DB, error) { + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + return nil, fmt.Errorf("failed to open in-memory database: %w", err) + } + + err = RunMigrations(db, "testing") + if err != nil { + db.Close() + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + + return db, nil +} + +func getMigrationsDirPath(env string) (string, error) { + var basePath string + + if _, err := os.Stat("/.dockerenv"); err == nil { + return "/root/n8n-shortlink/internal/db/migrations", nil + } + + basePath, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current working dir: %w", err) + } + + if env == "testing" { + basePath = filepath.Join(basePath, "..", "..") // root dir + } + + migrationsDirPath := filepath.Join(basePath, "internal", "db", "migrations") + + if _, err := os.Stat(migrationsDirPath); os.IsNotExist(err) { + return "", fmt.Errorf("migrations dir not found at %s", migrationsDirPath) + } + + return migrationsDirPath, nil +} diff --git a/internal/db/entities/shortlink_entity.go b/internal/db/entities/shortlink_entity.go new file mode 100644 index 0000000..32c0bf1 --- /dev/null +++ b/internal/db/entities/shortlink_entity.go @@ -0,0 +1,46 @@ +package entities + +import ( + "database/sql/driver" + "fmt" + "time" +) + +// Shortlink represents a shortlink to a workflow JSON or URL. +type Shortlink struct { + Slug string `json:"slug,omitempty" db:"slug"` // added by API + Kind string `json:"kind" db:"kind"` // required, 'workflow' or 'url' + Content string `json:"content" db:"content"` // required, JSON or URL + CreatorIP string `json:"creator_ip,omitempty" db:"creator_ip"` // added by API + CreatedAt CustomTime `json:"created_at,omitempty" db:"created_at"` // added by DB + ExpiresAt *CustomTime `json:"expires_at,omitempty" db:"expires_at"` // optional + Password string `json:"password,omitempty" db:"password"` // optional + AllowedVisits int `json:"allowed_visits,omitempty" db:"allowed_visits"` // optional, -1 for unlimited +} + +// CustomTime handles timestamp conversion between Go's time.Time and sqlite's TEXT. +type CustomTime struct { + time.Time +} + +const timeLayout = "2006-01-02 15:04:05" + +// Scan converts a sqlite TEXT timestamp into a CustomTime. +func (ct *CustomTime) Scan(value interface{}) error { + switch v := value.(type) { + case string: + parsedTime, err := time.Parse(timeLayout, v) + if err != nil { + return fmt.Errorf("parsing time for CustomTime: %w", err) + } + ct.Time = parsedTime + default: + return fmt.Errorf("unsupported scan type for CustomTime: %T", v) + } + return nil +} + +// Value converts a CustomTime into a sqlite TEXT timestamp +func (ct CustomTime) Value() (driver.Value, error) { + return ct.Time.Format(timeLayout), nil +} diff --git a/internal/db/entities/visit_entity.go b/internal/db/entities/visit_entity.go new file mode 100644 index 0000000..a723457 --- /dev/null +++ b/internal/db/entities/visit_entity.go @@ -0,0 +1,10 @@ +package entities + +// Visit represents an access to a shortlink. +type Visit struct { + ID int `json:"id" db:"id"` + Slug string `json:"slug" db:"slug"` + TS CustomTime `json:"ts" db:"ts"` + Referer string `json:"referer" db:"referer"` + UserAgent string `json:"user_agent" db:"user_agent"` +} diff --git a/internal/db/migrations/000001_create_shortlinks_table.down.sql b/internal/db/migrations/000001_create_shortlinks_table.down.sql new file mode 100644 index 0000000..35f01e8 --- /dev/null +++ b/internal/db/migrations/000001_create_shortlinks_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS shortlinks; \ No newline at end of file diff --git a/internal/db/migrations/000001_create_shortlinks_table.up.sql b/internal/db/migrations/000001_create_shortlinks_table.up.sql new file mode 100644 index 0000000..f51f37c --- /dev/null +++ b/internal/db/migrations/000001_create_shortlinks_table.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS shortlinks ( + slug TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('workflow', 'url')), + content TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + creator_ip TEXT DEFAULT 'unknown', + expires_at TEXT, + password TEXT CHECK (LENGTH(password) = 0 OR LENGTH(password) >= 8), + allowed_visits INTEGER DEFAULT -1 CHECK (allowed_visits = -1 OR allowed_visits > 0) +) STRICT; \ No newline at end of file diff --git a/internal/db/migrations/000002_create_visits_table.down.sql b/internal/db/migrations/000002_create_visits_table.down.sql new file mode 100644 index 0000000..4fd5fb1 --- /dev/null +++ b/internal/db/migrations/000002_create_visits_table.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS visits; + +DROP INDEX IF EXISTS idx_visits_slug_ts; \ No newline at end of file diff --git a/internal/db/migrations/000002_create_visits_table.up.sql b/internal/db/migrations/000002_create_visits_table.up.sql new file mode 100644 index 0000000..99daeb0 --- /dev/null +++ b/internal/db/migrations/000002_create_visits_table.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS visits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT REFERENCES shortlinks(slug), + ts TEXT DEFAULT CURRENT_TIMESTAMP, + referer TEXT, + user_agent TEXT +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_visits_slug_ts ON visits(slug, ts); \ No newline at end of file diff --git a/internal/env/getters.go b/internal/env/getters.go new file mode 100644 index 0000000..84910f0 --- /dev/null +++ b/internal/env/getters.go @@ -0,0 +1,72 @@ +package env + +import ( + "fmt" + "os" + "strconv" + "time" +) + +// GetStr retrieves the value of a string env var, else returns a default. +func GetStr(key string, defaultValue string) string { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + return value +} + +// GetInt retrieves the value of an integer env var, else returns a default. +func GetInt(key string, defaultValue int) int { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + return strToInt(value) +} + +// GetDuration retrieves the value of a Go-parseable duration string env var, +// else returns a default. +func GetDuration(key string, defaultValue string) time.Duration { + value := os.Getenv(key) + if value == "" { + return strToDuration(defaultValue) + } + return strToDuration(value) +} + +// GetBool retrieves the value of a boolean env var, else returns a default. +func GetBool(key string, defaultValue bool) bool { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + return strToBool(value) +} + +func strToInt(varStr string) int { + varInt, err := strconv.Atoi(varStr) + if err != nil { + panic(fmt.Errorf("failed to convert var %s to int", varStr)) + } + + return varInt +} + +func strToDuration(varStr string) time.Duration { + duration, err := time.ParseDuration(varStr) + if err != nil { + panic(fmt.Errorf("failed to convert var %s to duration", varStr)) + } + + return duration +} + +func strToBool(varStr string) bool { + varBool, err := strconv.ParseBool(varStr) + if err != nil { + panic(fmt.Errorf("failed to convert var %s to bool", varStr)) + } + + return varBool +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..45cb47c --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,61 @@ +package errors + +import stdErrors "errors" + +var ( + // ErrShortlinkNotFound is returned when a shortlink is not found. + ErrShortlinkNotFound = stdErrors.New("shortlink not found") + + // ErrSlugTaken is returned when a slug is already taken. + ErrSlugTaken = stdErrors.New("custom slug is already taken") + + // ErrSlugMisformatted is returned when a slug is invalid. + ErrSlugMisformatted = stdErrors.New("custom slug is misformatted - must contain only A-Z, a-z, 0-9, -, _") + + // ErrSlugTooShort is returned when a slug is too short. + ErrSlugTooShort = stdErrors.New("custom slug is too short - min 4 chars") + + // ErrSlugTooLong is returned when a slug is too long. + ErrSlugTooLong = stdErrors.New("custom slug is too long - max 512 chars") + + // ErrSlugReserved is returned when a slug is reserved for internal use. + ErrSlugReserved = stdErrors.New("custom slug is reserved for internal use") + + // ErrAuthHeaderMissing is returned when the authorization header is missing. + ErrAuthHeaderMissing = stdErrors.New("authorization header is missing") + + // ErrAuthHeaderMalformed is returned when the Authorization header is invalid. + ErrAuthHeaderMalformed = stdErrors.New("authorization header is malformed") + + // ErrPasswordInvalid is returned when the password is invalid. + ErrPasswordInvalid = stdErrors.New("password is invalid") + + // ErrKindUnsupported is returned when the shortlink kind is unsupported. + ErrKindUnsupported = stdErrors.New("shortlink kind is unsupported - neither \"url\" nor \"workflow\"") + + // ErrContentMalformed is returned when the content is malformed. + ErrContentMalformed = stdErrors.New("content is malformed - neither URL nor JSON") + + // ErrPasswordTooShort is returned when the password is too short. + ErrPasswordTooShort = stdErrors.New("password is too short - must be at least 8 chars") + + // ErrPayloadTooLarge is returned when the payload is too large. + ErrPayloadTooLarge = stdErrors.New("payload is too large - max size is 5 MB") +) + +// ToCode maps errors to error codes. +var ToCode = map[error]string{ + ErrShortlinkNotFound: "SHORTLINK_NOT_FOUND", + ErrKindUnsupported: "KIND_UNSUPPORTED", + ErrSlugTaken: "SLUG_TAKEN", + ErrSlugMisformatted: "SLUG_MISFORMATTED", + ErrSlugTooShort: "SLUG_TOO_SHORT", + ErrSlugTooLong: "SLUG_TOO_LONG", + ErrSlugReserved: "SLUG_RESERVED", + ErrAuthHeaderMissing: "AUTHORIZATION_HEADER_MISSING", + ErrAuthHeaderMalformed: "AUTHORIZATION_HEADER_MALFORMED", + ErrContentMalformed: "CONTENT_MALFORMED", + ErrPasswordTooShort: "PASSWORD_TOO_SHORT", + ErrPayloadTooLarge: "PAYLOAD_TOO_LARGE", + ErrPasswordInvalid: "PASSWORD_INVALID", +} diff --git a/internal/log/logger.go b/internal/log/logger.go new file mode 100644 index 0000000..a2c2797 --- /dev/null +++ b/internal/log/logger.go @@ -0,0 +1,160 @@ +package log + +import ( + "log" + "os" + "path/filepath" + "strings" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// Logger is a structured logger for the API. +type Logger struct { + logger *zap.Logger +} + +// NewLogger instantiates a new testing, development or production Logger. +func NewLogger(env string) (Logger, error) { + var config zap.Config + + switch env { + case "testing": + config = zap.NewDevelopmentConfig() + config.Level = zap.NewAtomicLevelAt(zapcore.FatalLevel) // mute + case "development": + config = zap.NewDevelopmentConfig() + config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + config.EncoderConfig.EncodeTime = shortTimeEncoder + case "production": + logFilePath, err := setupLogFile() + if err != nil { + return Logger{}, err + } + + config = zap.Config{ + Level: zap.NewAtomicLevelAt(zapcore.InfoLevel), + Development: false, + Encoding: "json", + OutputPaths: []string{"stdout", logFilePath}, + EncoderConfig: zapcore.EncoderConfig{ + LevelKey: "level", + TimeKey: "ts", + NameKey: "logger", + CallerKey: "caller", + MessageKey: "msg", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.LowercaseLevelEncoder, + EncodeTime: utcTimeEncoder, + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + }, + } + } + + logger, err := config.Build(zap.AddCallerSkip(1)) // skip log/logger.go + if err != nil { + return Logger{}, err + } + + if len(config.OutputPaths) == 2 { + absLogFilePath, err := filepath.Abs(config.OutputPaths[1]) + if err != nil { + log.Fatalf("Failed to resolve filepath: %v", err) + } + logger.Info("logger initialized", zap.String("logFilePath", absLogFilePath)) + } + + return Logger{logger: logger}, nil +} + +// Info logs an informational message. +func (l *Logger) Info(msg string, props ...zap.Field) { + l.logger.Info(msg, props...) +} + +// Error logs an error message. +func (l *Logger) Error(err error, props ...zap.Field) { + fields := append(props, zap.Error(err)) + l.logger.Error(err.Error(), fields...) +} + +// Fatal logs a fatal error message. +func (l *Logger) Fatal(err error, props ...zap.Field) { + fields := append(props, zap.Error(err)) + l.logger.Fatal(err.Error(), fields...) +} + +// Str creates a zap.Field with a map having a string value. +func Str(key, value string) zap.Field { + return zap.String(key, value) +} + +// Int creates a zap.Field with a map having an int value. +func Int(key string, value int) zap.Field { + return zap.Int(key, value) +} + +// https://github.com/uber-go/zap/issues/661#issuecomment-520686037 +func utcTimeEncoder(t time.Time, enc zapcore.PrimitiveArrayEncoder) { + enc.AppendString(t.UTC().Format("2006-01-02T15:04:05Z0700")) // e.g. 2019-08-13T04:39:11Z +} + +func shortTimeEncoder(t time.Time, enc zapcore.PrimitiveArrayEncoder) { + enc.AppendString(t.Format("15:04:05")) +} + +func setupLogFile() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + panic(err) + } + + logFilePath := filepath.Join(home, ".n8n-shortlink", "n8n-shortlink.log") + + if _, err := os.Stat(logFilePath); os.IsNotExist(err) { + file, err := os.Create(logFilePath) + if err != nil { + return "", err + } + file.Close() + } + + return logFilePath, nil +} + +// ReportEnvs reports source for environment variables +func (l *Logger) ReportEnvs() { + envs := []string{ + "N8N_SHORTLINK_ENVIRONMENT", + "N8N_SHORTLINK_HOST", + "N8N_SHORTLINK_PORT", + "N8N_SHORTLINK_RATE_LIMITER_ENABLED", + "N8N_SHORTLINK_RATE_LIMITER_RPS", + "N8N_SHORTLINK_RATE_LIMITER_BURST", + "N8N_SHORTLINK_RATE_LIMITER_INACTIVITY", + } + + availableEnvs := []string{} + missingEnvs := []string{} + + for _, env := range envs { + if value := os.Getenv(env); value == "" { + missingEnvs = append(missingEnvs, env) + } else { + availableEnvs = append(availableEnvs, env) + } + } + + if len(missingEnvs) > 0 { + l.Info("missing env vars, falling back to defaults", Str("missingEnvs", strings.Join(missingEnvs, ", "))) + if len(availableEnvs) > 0 { + l.Info("available env vars", Str("availableEnvs", strings.Join(availableEnvs, ", "))) + } + } else { + l.Info("all env vars are available") + } +} diff --git a/internal/log/pretty_print.go b/internal/log/pretty_print.go new file mode 100644 index 0000000..a9f30e0 --- /dev/null +++ b/internal/log/pretty_print.go @@ -0,0 +1,12 @@ +package log + +import ( + "encoding/json" + "fmt" +) + +// PrettyPrint prints any struct in a human-readable format. +func PrettyPrint(i interface{}) { + marshaled, _ := json.MarshalIndent(i, "", " ") + fmt.Println(string(marshaled)) +} diff --git a/internal/services/shortlink_service.go b/internal/services/shortlink_service.go new file mode 100644 index 0000000..8630e51 --- /dev/null +++ b/internal/services/shortlink_service.go @@ -0,0 +1,190 @@ +package services + +import ( + "crypto/rand" + "database/sql" + "encoding/base64" + "fmt" + "regexp" + + "github.com/ivov/n8n-shortlink/internal/db/entities" + "github.com/ivov/n8n-shortlink/internal/errors" + "github.com/ivov/n8n-shortlink/internal/log" + "github.com/jmoiron/sqlx" + "golang.org/x/crypto/bcrypt" +) + +// ShortlinkService manages shortlinks. +type ShortlinkService struct { + DB *sqlx.DB + Logger *log.Logger +} + +// SaveShortlink writes a shortlink to the DB. +func (ss *ShortlinkService) SaveShortlink(shortlink *entities.Shortlink) (*entities.Shortlink, error) { + query := ` + INSERT INTO shortlinks (slug, kind, content, creator_ip, expires_at, password, allowed_visits) + VALUES (:slug, :kind, :content, :creator_ip, :expires_at, :password, :allowed_visits) + RETURNING slug, kind, content, creator_ip, created_at, expires_at, password, allowed_visits; + ` + + rows, err := ss.DB.NamedQuery(query, shortlink) + if err != nil { + return nil, fmt.Errorf("failed to save shortlink: %w", err) + } + defer rows.Close() + + if rows.Next() { + err := rows.StructScan(shortlink) + if err != nil { + return nil, err + } + } else { + return nil, errors.ErrShortlinkNotFound + } + + ss.Logger.Info( + "user created shortlink", + log.Str("slug", shortlink.Slug), + log.Str("kind", shortlink.Kind), + log.Str("creator_ip", shortlink.CreatorIP), + log.Str("with_password", fmt.Sprint(shortlink.Password != "")), + ) + + return shortlink, nil +} + +const defaultSlugLength = 4 // 64^4 = ~16.7 million possible slugs +const maxUserSlugLength = 512 + +// GenerateSlug generates a random URL-encoded shortlink slug, ensuring uniqueness with DB. +func (ss *ShortlinkService) GenerateSlug() (string, error) { + for { + bytes := make([]byte, defaultSlugLength) + + _, err := rand.Read(bytes[:]) + if err != nil { + return "", err + } + + slug := base64.RawURLEncoding.EncodeToString(bytes[:])[:defaultSlugLength] + + isUnique, err := ss.isSlugUnique(slug) + if err != nil { + return "", err + } + + if isUnique && !isReserved(slug) { + return slug, nil + } + } +} + +func (ss *ShortlinkService) isSlugUnique(slug string) (bool, error) { + var exists bool + query := "SELECT EXISTS(SELECT 1 FROM shortlinks WHERE slug = $1);" + + err := ss.DB.Get(&exists, query, slug) + if err != nil { + return false, err + } + + return !exists, nil +} + +// GetBySlug retrieves the main parts of a shortlink by its slug. +func (ss *ShortlinkService) GetBySlug(slug string) (*entities.Shortlink, error) { + var shortlink entities.Shortlink + query := "SELECT kind, content, password FROM shortlinks WHERE slug = $1;" + + err := ss.DB.Get(&shortlink, query, slug) + if err != nil { + if err == sql.ErrNoRows { + return nil, errors.ErrShortlinkNotFound + } + + return nil, err + } + + return &shortlink, nil +} + +// ValidateUserSlug checks if a user-provided slug meets all requirements. +func (ss *ShortlinkService) ValidateUserSlug(slug string) error { + if len(slug) < defaultSlugLength { + return errors.ErrSlugTooShort + } + + if len(slug) > maxUserSlugLength { + return errors.ErrSlugTooLong + } + + if !regexp.MustCompile(`^[A-Za-z0-9_-]+$`).MatchString(slug) { + return errors.ErrSlugMisformatted + } + + isUnique, err := ss.isSlugUnique(slug) + if err != nil { + return fmt.Errorf("error checking for slug uniqueness: %w", err) + } + + if !isUnique { + return errors.ErrSlugTaken + } + + if isReserved(slug) { + return errors.ErrSlugReserved + } + + return nil +} + +var reservedSlugs = []string{"static", "health", "metrics", "docs", "spec", "challenge"} + +func isReserved(path string) bool { + for _, deny := range reservedSlugs { + if path == deny { + return true + } + } + + return false +} + +// HashPassword generates a bcrypt hash of a plaintext password. +func (ss *ShortlinkService) HashPassword(plaintextPassword string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), bcrypt.DefaultCost) + if err != nil { + ss.Logger.Error(err) + return "", fmt.Errorf("failed to hash password: %w", err) + } + + return string(hash), nil +} + +// ValidateKind checks if a kind is supported. +func (ss *ShortlinkService) ValidateKind(kind string) error { + switch kind { + case "workflow", "url": + return nil + default: + return fmt.Errorf("found unsupported kind: %s", kind) + } +} + +const passwordMinLength = 8 + +// ValidatePasswordLength checks if a password's length is valid. +func (ss *ShortlinkService) ValidatePasswordLength(password string) error { + if len(password) < passwordMinLength { + return errors.ErrPasswordTooShort + } + + return nil +} + +// VerifyPassword compares a bcrypt hash with a plaintext password. +func (ss *ShortlinkService) VerifyPassword(hashedPassword, plaintextPassword string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plaintextPassword)) + return err == nil +} diff --git a/internal/services/visit_service.go b/internal/services/visit_service.go new file mode 100644 index 0000000..30e52e9 --- /dev/null +++ b/internal/services/visit_service.go @@ -0,0 +1,44 @@ +package services + +import ( + "fmt" + + "github.com/ivov/n8n-shortlink/internal/db/entities" + "github.com/ivov/n8n-shortlink/internal/log" + "github.com/jmoiron/sqlx" +) + +// VisitService manages visits. +type VisitService struct { + DB *sqlx.DB + Logger *log.Logger +} + +// SaveVisit writes a visit to the DB. +func (vs *VisitService) SaveVisit(slug string, kind string, referer string, userAgent string) error { + visit := entities.Visit{ + Slug: slug, + Referer: referer, + UserAgent: userAgent, + } + + query := ` + INSERT INTO visits (slug, referer, user_agent) + VALUES (:slug, :referer, :user_agent); + ` + + _, err := vs.DB.NamedExec(query, visit) + if err != nil { + return fmt.Errorf("failed to save visit: %w", err) + } + + vs.Logger.Info( + "user visited shortlink", + log.Str("kind", kind), + log.Str("slug", slug), + log.Str("referer", referer), + log.Str("user_agent", userAgent), + ) + + return nil +} diff --git a/internal/static.go b/internal/static.go new file mode 100644 index 0000000..6edb138 --- /dev/null +++ b/internal/static.go @@ -0,0 +1,19 @@ +package internal + +import ( + "embed" + "io/fs" +) + +//go:embed all:static +var embeddedFs embed.FS + +// Static is an embedded-in-binary static files dir for the API to serve. +func Static() fs.FS { + subtree, err := fs.Sub(embeddedFs, "static") + if err != nil { + panic(err) + } + + return subtree +} diff --git a/internal/static/404.html b/internal/static/404.html new file mode 100644 index 0000000..06a5cb7 --- /dev/null +++ b/internal/static/404.html @@ -0,0 +1,28 @@ + + + + + + + n8n shortlinks + + + + + +
+

404
(^-^*)

+

Page not found! A bit embarrassing to admit.

+

← Back to homepage

+
+ + + GitHub + + + diff --git a/internal/static/canvas.tmpl.html b/internal/static/canvas.tmpl.html new file mode 100644 index 0000000..091c19a --- /dev/null +++ b/internal/static/canvas.tmpl.html @@ -0,0 +1,59 @@ + + + + + + n8n workflow: {{ .WorkflowSlug }} + + + + + + + + + + diff --git a/internal/static/challenge.html b/internal/static/challenge.html new file mode 100644 index 0000000..9ce1be6 --- /dev/null +++ b/internal/static/challenge.html @@ -0,0 +1,36 @@ + + + + + + + n8n shortlinks + + + + + + +
+ + + + + + diff --git a/internal/static/fonts/Lexend-Bold.ttf b/internal/static/fonts/Lexend-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..95884f6e576d23fe61e0d2a3d19faf5cd0db7d90 GIT binary patch literal 78632 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

KjRYql^x;$`}v};y^q|09`;L=n9fRGDrd4Kq^QB z=^z7Sf-H~?azHNV4)Q<`-~mOTC!mc;T9E7m`htF-KNtW8f?_~xq5EZ_oBzzw2742T8PwW6*Sb*-pt zMO`cET2a@Ex~5gq#%Ps3TFfq53~yki<-89*V7+1wJ;7enhIi45cIiHWe!$0D!v*tY z9jX)Qc2YZxR329+J%9(~g91k5&1xDnC}`$Ey5Tl^?6}V^w~v z%8ym~u_`}S<;SZ0Sd|~E@?%wgtjdp7`LQZLR^`X4{8*JAtMX%2eyqxmRr#?hKUU?( zs{B}$AFJ|XRer3>k5&1xDnC}`$Ey5Tl^?6}V^w~v%HPfcc8NU3$sWK1@<9P81VvyJ zXau9d7;qdI3&w%zjFin_IrODq1-J}c4z2(z!IfYYxC*QWSA%Q7wO|dn4y*;&gB!q& z;3jZ0SO;za>%pyH1Go)r1h<1t;0|ynxC`73?g3lCvy86vJGAuH|KGRt9-STGKZ z2NS^Y-~=$6Yxg;Tw^49)m-qiUt1xQ|Tzmc(t%K{u_XD5MFMVXzH1-6(6mMc9K5WE? zjrgz;A2#B{MtsZTPSaAGYDcHhkEI58Loz8$N8qhi&+<4Ij4Q!!~@_h7a5D zVH-Yd!-sA7unix!;lnn3*oF_=@L?N1Y(tNWyO;`)oZ9f5@g3rL`zz@Cve*^o#mw~M^n(OTt5DQq{mGK|}bODKg6>Z5r0AElSG6i%4 z>^SM~yU29FS0?zPgOGelgKs(rnFDeGYj-ja^Z*`E1bTvApf_NrLG}gxKz}eGu#XiH zA&bEv-~}as6-8MF$^m!n$VyNJ1_SOfltaNVz}mGu28;m5f{}oGnE0BzkhP!=)Pn{v z3N(VzU^d^XnFG!Nmx7h>t^}+d^L;EKSA(m;HGn&IIt8@a;R$Q8y$t}r%og~5&;hzALv3rGZAK@vy?DWDrj z1!*81WPnVN1+qaNEw%^nfP7E@3PBMV1(|#kOLxtr2s&RxGy_%WcJS zTd~|$EVmWQZN+k1vD{WHw-w86#d2G*+*T~N70YeKa$B+7RxGy_%WcJSTd~|$EVmWQ zZN+k1vD{WHw-w86#d2G*+*T~N70YeKa$B+7RxGy_%WcJSTd~|$EVq?WEjWMkF@N(h zfAcYa^D%$(F@N(hfAcYa^D%$(F@N(hfAcYa^D%$(F@N(hfAcYa^D%$(F@N(hfAcYa z^D%$(F@N(hfAcYa^D%$(F@N(hfAcYa^D%$(F@N(hfAcYa^D%$(F@Mw7z`L2h`Ix`? zn7{d$zxkNI`Ix`?n7{d$zxkNI`Ix`?n7{d$zxkNI`Ix`?n7{d$zxkNI`Ix`?n6_by-osYSlkGY+Xxt))>osYSlkGY+Xxt))>osYSl zkGY+Xxt))>osYSlkGY+Xxt))>osYSlkGY+Xxt))>osYSlkGY+Xxt))>osYSlkGY*s z(z`OZ^D(#cF}L$ExAQT#^D(#cF}L$EdimrN%t)RDPl2bwGhiFgGnMV&Iq*Dq0qh8T zhjj+9&O^)zeas1c%n5zW34P27eas1c%n5zW34P27eas1c%n5zW34P27eas1c%n5xe z8pMEDKpSCB=wnXkV@~K}PUvG!=wnXkV@~MfN^2u4CqCwed%1?vt0%kE0M3_UwcWAW z|H`@mt;x^+fZJU6Nk$_5UFi2I4ZfAFOT(OfjnrPs=X>EnuU2}KR(g|GdXrXqlU90@ zR(g|GdXrXqlU90@R(g|GdXrXqlU90@R(g|GdXrXqlU90@R(g|GdXrXqlU90@R(g|G zdXrXqlU8~Y-77r7T=xmA?+NCVPoO~?IhuGS23QhVmw{Nc)E(5wo)7Zf18TF zCjP%qMN#8EMVhR!v9`rGMtYOiK7svO2U^5_R^s-n*@3Nmmun-ktJK>@ev6Uk`{nRj z38~lRbf|&gV`dea?>8|jV%pEwy7XP|Z<9_iMZI=6hjC&lzW0%G6s3GbwFC~q2{3vb zf_p^E5Pwi67Ma6_vyi=_a|7S0#k3ynyNobb1imyev0fQCqSx0{3;9`$?@}a}1%5>0 zBUa%2>Ke*d5jItaH8a2iCdDhzaW(6$iZqVE*-I*m`4*Z^0Y2Z>>_a{#1=>vP=fbs9 zXhY%?NwZ3ExquS0bDgCY>9V0r?a=#8&L8G}M;EQd4xw{InQ(6> z^D*|^1pA{tf_w^E`Ts9AkCMjuMs!+pH*>~rP|v8bRTxx6gD zG%Y!`uxEa0WpCNb4c#-n5`JNRfji^4!b162Vd2s7@^(w9qfN>spH!}>qfvP3i(V=@2W&T3e>wQKSRp2 zwX)m8m&YU}b&bk%ckP-Ib9qsPtSML9kJQO&X}tS0O=fuq4az#YY31_e+=*+)Xg%uY z*)4M8w))Ntb8iiCOPfMm*O(Bu+~x*6>M1KI+sjI9((!a2qAVhw388o@Be>NeZbg`D ztqXBWBDgn)xC6pm*GPvOZ54|2`c!*Evew2Bx6qc=+NkXzF6GrpJ@lC#=Drn@9T_1TEOED06T!V8Bs=_U z6GLENL~7AigTDF-r5Dz%%9b{{Ug*Tp%Yw^yFZ;4%|6(Z2notUZ@s-RNgmsWM$ra}` znV%N=SL9cfC19g?Q}fTP9R1ckcZ#IEmf5{r`aOViWSQr*$L^Hf@DqAKiNR$5tEQQoUpVPSU$N{omO?nt+~C^Gx~ME>7wkO-3AO;rndJTS2=uQVz;=`(Zv-L zDzn$56lP_3k}pZ_k?!e9u0((OjXT-r=A^!$SXGpdP-Uc?5nZeb3ld2>kMqjPs*FS> z*G;%$YQ^AbR~}z__N2U7X@drP`i!c`%c~qW$TPI0+r+F%&2be?R~$e7@}|n{vchHQ z`6=Bi#ttezt}-{JN9u~)0a;j~Nzde;t8r82TwR9vy8YYy5^vQUrt0$lE9IZSeH5njgT+p{kDyp6j>4)bvgf$v zXOxznv3%UGGOq1cGblTIP))zJ*=l>)najtGyY!5*#Rc8V#|#`ero6jHYe}q8JlW;a z_mGfVzHs-h(cbW_bq>F`%}4tZi?sz>L!qOM#eGLTgqT#3pC)M$o#|948;*YcGkVFw zrAv=~_^-56e>HuM(atL28*o}{Cv~@5w?v%_ESa*Q%xt+tM{Y`BXghvtkS~PmKseX( zTfPeKl!ohol@iRg&1d8}V&yV-u9+s&6#CdPE4_hU^dn zP7lU&t?7L=SNFcRg-U6R6_lp;WjV)fg~N<->E73lY2eaeOusUHuN{+@@8kvfV?rs= z_nLHBIYLw2_u6q(_YcXMzSrg!hIAZb=~ixY|4(c00Uud$PuDV88|+$(((ToH0HJI2`Xd8*|wBFlXaH@&d-I zr{A}_XGWuC7kr=p|MS_E^y<~CuCA`GuCA``j+W}`mmuE?6dVd2og>5e=2<9NWI_Y7(318?K1&EhSr`7Ecf<~aFxmMgN% zqNE~d3ftKYy^+hBlQYZIV@PuzTF>?>x3HVJXQ02~2IjtnLSozwyaKxiIw!Y#==gbO zC?630j@`N9nynp$ogZ7)y=z7DE?2%cn4T;E=UGW^G3U$y{Fe&> zyQF<+`o4#DGxkFgB^Rgp$PvtTf@lKT4XHy;p%r;)uvR%I#zcE47ErS?1++zQ{X%x# ziqrPaS2j2|rv&DZB@LOe<4#S|R`~hQ94pR$F6+QQ^L{4a=`tKv zsv6%waJ2Lke!R^2Q|-^^%xF$~$egoJ9XP-~bMWAe?5-PcRJPtol&5Cislnb`*r(-G zqG~&C^yYA)z^x3yVP%4|Q0fZ;n)S>=spSN;y$VY0CZK5!g(nKS)T0G-=ry7+2I#B2 zM+<0(<8o-t#TDFe6gTB*#Z9Gt~ixs>w_hm}00zZe&d+1O27ToXTEk1gXxF;Wh>`X0< zPM`$q!irGa7jB^%uYvZ!--Y(M&hV2~oksemvW2m`rbk~`chf|N@^hBz9QpVbc*Epb5H0Y3Hg7uvZkR1g z`o|~()&ym+SW8Wwx-N5iYluEOBT!pcE3^A=Qx4^7)wL2s5&GIdZ+Gu_;$bvpjk;~5 zBbji$#>;r8_F~>H=ZJQZV--8{W8N+t4)6c&Dme9E0dL1GwY&|W6X2-=A1%WnzaR6l zR^Ug=aM+`2d;`HTkNEKnZoNm{nsJ4DEQ{GP)lV+hPqx|N_19TO1_!8`b*NmqmU3?A zvlsXFNH?RZ$$3-DIz&z5Bi)^O@4rv1PjfD3T53|f?KwAWPr0^e`)Y1%VxGr!`o}W$ zQ^9?a;MB_Cl3E~uo*E+$IPYWyNZ$ev#qzEJ)?1CT2c{%qM(@)^@)k`g{==ycMq>jLUc6N$tvmJF?OI`X2)VN z=JKM+`7X^x*k_hpv9+^v>lKR^U$Hfx$M3{guBj6Ujt(|v! z3sdRzRKd%C&+qh-lz5rE?Jq-ZY35fMcX6Ep**yWzs_x)zFYrkx1iXC?ob0-Qr>o)I zgA?!;WKD{4{h(jQkI4qYNBMKVBR^4mm%*(F6@Iy=DBx`+e~9x{RZeSl`Fvy{{5pAP zUxkW%M}>N*cUAB;}$3cZ*+UcZt|I%BGyZd!QPg}M33*o z)TFMarmndw6`$%3Ne=(&y}kc{&S0H&j?PezNIaFSN~Yj(IfKb8nO69J2{L2FWv8%M4yx7`YlGQUZ3-}{P3guIp_J;;Q4p>i@1ipWlr`HEPiZ2S~#kFjY1vytyg}) z%p`@m3FM`pwoVUzBon+{!2|CFcS7!DYwn(H4{mu$-fDSHdsnp+hnMtiww=~h+Nu1W zdhP7{+1^}!&wDG`Tlnk8VA*YC+qaaZQ3+?+8HIc>*uvtng9|QP)tcRKc!_kD@&t1z zud#*4e%07AlDhO@W~|MfJ>}4%MVD>Ne&W*WKJf{*v2`lf#BOTpz>X0g(Gze1&|5BkaJxGo)|!?j9Q5*Z&&3)Dd;L!?r3~2CQO{ zKVYW_6^)XhX6BYbP0MktrgFroQm33;u(GOpv7V9B`uk5E zz4f2jNgJWl`3hv78==7u;jqY&V z-o2`;DK)vdV{F@mYmdFX(Cke$+JfoE!rGzG#D&_xK+3+~&!Bg@kBs-8KmK(XbRhyWLy=$+(o$sse32Rjk|g_UimnWQX=90;*BiKC|@APd>WF~SR?B-f?_rBqa3R35I$R2NQ9S)D2u60Hm zPxE+#ZfkSE*_h4+LOp4>6xEr6BO7~_2ieB69^7bm9<1|3?AGQcgR^D0v$z3nfQZ!m z7fwrqv&iR+`G7M()5cO%Hdu!kIWZ|l9Cm2*{8PHH0;o~a8h@(86YXzxdD=&lvB56y zRC?)#?um;xb>=r+sBK$wU~K!f-qxVilV8wIQEG$Nd08N7D{ebHfBxZZMU?GFKY&R> zEC6;#L=N8giM04XrRXiUOusdGoO^$!7Wp93fVSHRP%E2{9x-VhxA>I$mApicg(zz$Wg{Zy+8 z+%ShaMK}b0ycCZXbjaTn@Ij&@q$4}wZ(@bugVMpwEC&Ip3&)G8uO)d&V63zCgVGx6 z&9CdOYgNvH&`SJ+KJoM1aZ>YBxfOU7|8wxG_y?T$7j!uPLOy_#$0*>%ihR)O++!5= zO_5er!@I?OL36<`d z>_~HdJRWLH_^l?r&Im7X_~0BxJfY9`+=puCtezCA`RDQx^_n9e?qP{~ad=6qpM+l2 zpksd*y#~35TUgHOm7O6ty4D=dB~^a^_|eag+{Mq3Ltha*vO|D!^tRd13=gO}9G0>fN%NAd=Kjva*V72>ZNjU>;%S+_C_F_rb zrg@tSIQ2LIPpjUkf|JYyJTwPRIxFCD6|y^#AvFV;&>3H~h!QnGE;0ne0?a8}V-U>S zzf@lP@qaMSbRW}Q`{TE-`R>2r>c7LvGs*?*;>F6(*w_LFx0BHc0n%4N1-5bv)=UvS z!p8+OYfvk{d+HZ!p8kgNrjeb}&TdkkK>^B+QRQ~5newlA-F^#R;LniXQG!y<1T?*I z7E1LJ&`=eWMos~Z3Ml*;a8rRAs&GjH0yj=0eyKF-g918Og-bn0KznIDq=ZZTQb3ER zkWTcX?sV=T>b^hEoB@m#EEcf$vh#YBU6M(;u9to5EcVy2JI+*e{htOM@ddBK3QNM-TyR=c_FON;MSFsDPq|D6;}Ja43&+%a4}g+);{WBlg0h-P8*tvL;Nm7C&V0 zi%ebk+}Ui-ymqBMiD=#QD0o|osLdFUs=WnlHTHn6=XynX(nZcQ!O>PAES{|A?Oy$uz7wi(5+FM|UI5vm! zXt{uf2}7yJ21r zf^L>cHw11k=OKn@d1IwCtZdEP&+G9zu=jCWC9sQ60D#=2ci3xa1B~CRF-JkXoUg|7 zaoiwH$Hxju!w!o-WOGFw4+IBRYt8(~uDN9T&zG^L zY}9dea3GZ$2!-e;pL_6<PnV!-=`X5VkatS8Ar8yfWe#V* zSy=g|PI`S;x&Hg*A54wzn~TfX3*}T9Dr+sX&|n!Vy;b57yhz?M zl%!RWceo6d-tC>G&|QYgno2Df%Ek&*Hw%@1SAo`+pqZ-D)UrkOUtB*$`;JwxRkJi| z3MFV%1{7HY@2Qp`AHAd#(oE$WhPR}-@5no$xr3^e)R*9v&}1{9&QokzXe!)dN^=R7 zUwS3AI>6)3!eyqHR-OqGoSkympU*98^YyOnL6lo^r#$gOQ+z5LF8W7(FKpA8ohGBpVD{UW2HIRqT48IlXqtl?eXVYLdMI-a zmjLR@`wAjFTrxlA`4tm0N2N2RSIWJY?1>uTd~>WH(sz=DKGGAj4ptHQ(iP+ds# z>3afo!H)jG*uMV0y{Cmco0~hsyc6qe!Azt6Lg{~$$Mo(@$To5DlBE|E9LzfqzR(!KXp7j z+!>Uk_k3F8mjfLmH4&0@BtnK#2hlFMc%CEL?$)qX(@p2sx>`c^E5n1S^k6tVm`)9X zF&Sn5P0C5HVOG;pjE}5OcXxf#a#l(`esC(kaWavd-{DJ7bY((@9mcL@doGz= zedYXXLcP&&Pbk8b7rl+Lbu0lMQ@E9z;~D8j4vq z$30qei1L~^Zj8N!EMuzGwDfuOJE8j-=sw0!q5C3Sp;iZCCo({6bj{&;@p;>Nil>Z* zC8n%(vR<>~NjJp`O_7QAVBR<1H$JJIxNtEUh4{R!-Klj}n=l#ap|GuSuP10O_lFGE z=In9(A;ZT(0iCJnaYm+IBA~OqWEL0wfpVIp>My82h%aXQ!#RAM641X~DEgpZ%J7km zpcvVZlu;+mevzsA;`N2)Ub~i$Y=X`kXO^KEKCTJqn|XqoU|TYLT;ot#``#=rjcWq; zEsjgpAR|+M6;SloQfs4y%248uLkX8gJAwOdaF#BOcA_**mHcS56HwhOl#g}-T00As ze_6q;KOt0IvLrc)bjPmhE6uSxxs@!hxfrBadAx!fraqZUUE0~#vA);S-G}M6Ni(Jm z=hputO7y= z2L&8UqbiL=SbK0!oi)hV=bg%T&5}FQ6z^<`jAu#t{bQ5iwTGyO=*BKs2;H|g(r}qf z4@HFTPh7a97d-=Z*n^?xcIj2Dbu`i}rab8I!jP>V3I-`rYml_{XTVJNoL+zLxX3Idkd`*XHB5)NNoPzfmoN%*A{d+#|^)oAl z))JKZw}4JpL8%W5=&>s3(lS)mS3#-g2;8HyP_E$|D%Y;0ww}Obp%-?%iO(I)YBG3e zRwNIgq1EJ5k?be#^y|8oq5quziK;jbqwVff&|=_gNK4rDli+%xcxx-KIb%GOb`2fSAD zw<^%|Xq@Bv=inMfiPJ%RHzNlyk{N{zWQvbru0#u0OYUBh$#tYN*_>pZ{oyPK{+a1NqZA7?*~o1<)?3J@o8fZ?KK~uztu5d)CHQ{;e*tfA z0iQ0xe**XtZixl_SP6b6;2qqCa=5IWSwl04nWq5XB$E{t_-~c)(HxlFzE*+1J@Xkp z^JoWmZ*co6@Q(_76>dhsB{HNvScstU^Z`hepGEn1Zea!XyO`CJwqm}^v@+RP!HuR= zD$VOh_?$w(btUS9GcD2xWxa6fYMelb6>8A`J@AWMI|Y7i316DYsNSw{8Iw4dh$>iS zF5}<*3g3-!3nso}^c`}gUYKcNHE2~Gt!h&C4yEr8s9RE>MGFvYN%p!_eA!pz(Xo1=ren zcT-VlN3y>``M*%g8N8Il{nTTC&0gi+A3@>$AzQT#aJs4IJ682B3u=l9m z=eci%8NcfNDr|$mh9*S0CUDx&gdlIxnVD|ZUWIK#0EF)|Vi%O8SIK>A$H|8|<*mD? zFS+Sr1-Ir*H=%~uZzf-X(F^*Geu*G1pE5aPh0@$fm^Qv>R{iJrky^Vx(wcL}2HOm- z`i4Zd(>1Unb&nwLMZP7 zYDek4tz;WM2N7;Wlqe?MJ#&!Y?1B*2Ex>!Z?Gf11gsqx@#`SU^i$i5ol}#nPAaIWg zTtq^uaml7K>H*ROMqjij`X1K*&=7p-@3^7GAUXSjElv6(wH8xhJi^D9a1)I$tYgd1 zF4DVGKK8dW9L;G@`{s!`gUokFH}aX}1TrWu*5FR*8|-GK5%BIKvL)9W0M8xUpS`%Ni`i#KK*>7n z2-#?0d~9$frbjf-d`H@ZGK|N~t9f<=nWtE%@O7MZh{O#oKQw>-q2L!&Glx4j+B4674@hEh?ZCQ4`RaDkymx z0y@k5!;oa^Ihz6bZYY+y5Zie-pQL1jl><3J8l=`DRn)8$`lRDf6<2&ld!x zH&lm8lprBKV-V%OIZoIV58cE^I}Vk#^<^4jtP{9zaa>W$ua`KFN`Rur)uK=Qsv?8g zw?`-DzC}H_&WmzTkIFYRuMp+Do0~12#yo+mK{=v@&z7!4wkRUf)sYh(lPWDy&c<#| z)ZS*lL1$4Xk{5cqTN_$BPuljil2p^v)v~0ZX$+q}0%e$^1|`1F=34NXIa=Vy-C|dQ zZw{rls^C%`1@3Ge&*tO0z(pO)xV--eD62rL+DLl83QF@VAtl|cPVjMFKx=2Ae4Zts z^(Ta?(P9*N$nlFYM^et?O~*dW|}*xqe(1&aL~9F8AC(Iyq4E zrx@;P^I|} zT8*VO2ep{&@~NXpPpOLF#&Vb$pTq#REgN$M8v7Tfx;Bo68B?y5m?Pt9^Lf)v4c?J_ zsOVqdo0u2QtsZyz9V5$IPi!h(ZNXrhON3uy5l`G<3%7bwBT>vj-66B9Jrrtl&*?#^ z)5J^|5!hzzppK&bs)jEN10l@gV+FJIxEbTbkvgxsb9u|?-oCzlW0_?|P2`*~R!{N~ z9)5jMdi|gVGsB4sDODrb9W*HSuyvvS6sCj}eeH*$l*@euUTch(i;yoDAq4_YR_;9k zDmJ%jAaufVurrGn#A+xtGGwv>1+QGCQuu?4eLiFgF++ zKefMa-$-<{Gbn}YjwpXDtr<~3^hLIz-qjkmBO!p6j>4_(x=W_bY+Y&9h@#GL68Jon zeG8>yK0#iaa2A~EQg)Mh;)vR-FH9vZzJ|0eVsk{@M#=hcAT=7Vt9e|lF*ZdTm3R2K z4`ScqJ>X%{1N3CKFetI_a6d;tr#ZAk`4yE3t>#eKbSo&(srsNmYcB$-{Si^X?R9f- z4TQ_-ev8j?1g?(g&b$u~wwkVumPB#>$U8V$o_YzbYKVk7%8ZIZnfSj-?67aa_3@hu7&9L9Q!CK+fQbJ!fAo`s3@k}=N;OQy|cbeigQT5USoI%+eSy0&Yx z$rZllI-OB#)72U@`hdfp^GABqPFvWu(d;qR;tiIT>WsPuzr&ny=N4zER-98V!!HXN zDkK2`?S)(f^lye)DESZqI$eePbOoxEpc;}u87iCEY-!|g2nxppF50FV*Cfs2k`F3y zkCy2A=inMjxPbDRBjLg`k;o$A3-mG%yoxKNm#|7)C`E_FAdUSCv3x($8!5)cgCMg8 z8~eLOf@3~sXJ1SXxdtp=lP~N@d+LMZby*<9SC%z!>AIuxW4e6e#bl>J8JAm!75}#FHfYa`2 zpCq5BQSfX3-3r?w-8#LCU48xaGzYXpt;$%KnH~fVh204^#Kw*i`qKvwK3)Cx72Y1`2T)lk!OfYSX8>{IgoGOqO2v#U7;{~TNc z;eu}W9H{L%>Ip-LFVc;#F5VMnbKUqn0c$P=gLJHCdFCElyopb}K7+nrT%^lw9>IS7 zANgBc23Nqw`lkPUnm*dY=P{fj*VrEljpgH>fRfiMpu+1FP|{cd6<)7^k`@YR*@FeA z+!k=Ctksat3tWn{2;8?yrO^&AYZ+Sdt^_W5R{~dfR{}~Nq@b&TXCvftCp<{<7|8Te zz=HIWP;DH~?Q9T2+TTdF)5v&eq{h?jZ_X)S)DsJGrkRpsWI|lB4u~6_h+gAqC+f za@lYXQ9y-G zKxN)0dJM`-p{vvnksqc;KG=V;}CY=VJ+zC9Rz(cka$EDn{ zZ#??oR{QB2&v9;j@XH7G>-Xck&(|=$^0vg*ATZM`DZe9LIR)M#zvX@NJ!ENfzZr2!X#uYZ?f+G6iN)lc(`gM@F|<=G>V~sAe)jFt zx_&(Uz2{e*aqr4K-}>St^L=iS@)Aq#disBNevfKdJL8rp>STh3S}7}x&Y!?C2c)9D z?tE!R_2n})rW!|`QM2_czuoXEYhEU`C`WBA_6B3dO`)h)sivF?uPMbs;HCCaJq48N zDWJ!ypj1l%eT(NY2wWNm1oY@EF4qErBG$qrt_1>{BqFfiP0W@@eUL+C%`9cEeF9or zMVT@c1eEnsf5n;!Zd2z`LF9?q%Xwm1=2l*1UsaCQu*MB+Z~pLR<(`(qs3FJa^?pm# z+eLf`DA^+covwmX4F&Y5fWiwwiDjs4aFIMMJf<2#OqF7wF%fZA#c&5}faHLDi)Se& zs*1t`QQ(1?u-=TUK0e5)W2F(!TuUa0QY~_7v4?Lqmx67}B%}S!8{00NKfJ9|c_Fu? z)!VbS`*WIGY<{QC)MRnDMRZN}-Z3_wj3L9JwJW}CK)GFHNhIvs>J4^-(b0fRi1A?F z3ywJ_ToUgINd!p}0!sE;Kxe9;lp`ddua%&FrtoMwK{2`qNs~_e#UHEa{C9=UpQ@n$ zR)PLGFv|(Gi70cr3YRogK#!H7(qi`{0;Uvk6X zc`E~Lp6+aGx4X^a2-)o+ha+gW2VdK`xVb&9uh(Qe&Y)Aad9$sa?6P)b3)i37WNU70 z$gRwp8jPkjt2!1Z&o#PCZlBL>av9ktv02^fvsipqTfl;L;@JPj*E4-lV@dA6#z@I)ibm-_{tm!s`0i zwd_pnq~ej^s?sWvUw%|nsi5z-El8&qw6!fvrxvxFU4{mi#ngzO?ApX+OY3ARHPwQ` zR@g#LXV78|IvPV(NSf}nlYS)qx~@)onM(w#xzabVgQ-sX4S$}R*(en;Zq#9iC5_|A z?ZO@u;jNhI+!XW^EQUDobB}qwv}C#F7VQHK?B#XWU*DmOv7b)iCq7ZdF>ksMJA91P z_SKP|Ql3(2;$(BMr5mptAHQG^RmUtYw*nd+s<<9`|K_4h2G%93#c3?YMhY1Q_7;PWD$rg2&6$2oDpnu5~C;;i$wz8 z%1b*gaI|8xduy|+HDC-zZIOpslo#0}zWH!(l?Ct(;#O#(*^PT*c|jesf=EGZ#1g?#=3aa#xjr$T@@Jcy+x^aX z#y2m;KB@d`I?!t^4El%n^p77<5B6@)1l+C7-qxmit7S9G-&lOfW_FwVP8}aRV+d`C zmVl(b58Y}7x21iWg#uRKuopb`A&HC8&Q0S9Q**+#_0F1W+)=O5uTA$1`g*qYj+{H` zZ*M}>668mE6 z&zF%)O-~uq9~d`M^UfW&C3>RzrWU_>VBhf2zCn|}#j6Q+C!OO5<~60=u1phty37A zxp5+{cZZy7R`}XHo>reXy$Eoym$oet&pwKsX% z{hn6x=t`CWBM0;jWB4A`PZ{k94#QJ9fVEE}t#pcP-jc(Ku2Pbx7=?e-4x_gqmtBxd zEo`&68@wJ*z^~1&E*96cx34J{S7&!R<4uWdHj&7Zp1V=%tL!wK%Zm7{uo%cxImg`B za_;IbLla}+M0EMM0{4S%1VoMnJD*PSAZq~+VQvx=c8?*xY42^=X==6_8mtk+B70jQ zfb^bdS5v3-wH3>C9-pDXn`+pNRrAuUz0~2#B@|#?(FKH; zP3Gb8rq+}*V-8!LO>SpHv%z^|{e8dEo>!-JsatxZ16s9FzeuaKTa1?4n!at#>sPK) z*QvW&!v!Do6JFOuce?_yn_|(+bOg9Ic=b!I;-1gr~Id6BNcX)!zN8(JP+pSsp&H8$sb@g6b-TrSJd@^pI{l~w9c zBBl#@&rt)x0s7RpCsrmF7J~haX=B(BiL-9YSTa)dM~4#@ccazjc4;>)>)q68wNB}@ zx$ID3z}FFs_5_x@tQNQ1Y(q#1XBXi|5!n*;Xzjd}H-OxCb>PWAMXuJcWOCb(@}H1k za^@&o1^&F)uZra1l5Up$$nwgO8Xffb!41wqh2N)=qlr{f4#Xk@iP&Jo?02$ACO(?5 zH8D;ox7gfw+f8Pt!(y^S zdelyf!Cf=%Hmho>&O$+~1C*qBJZtxI$%V{~0ZUnQv9a{%vc$qTWQTGZwOn-0On_vU z4?uQYp#Md}BO!9{5^+e?*Xz_0awuko0ddb1RIjS;2LxSjrU*!MrpOkk9tR{>f`kF- z=8%rkSFV`?J6rW2aPlRb05W4nI2}lw)1j}H@v;z!YHDXQ+nSH%)e5_DE^9K_B! zP5?NIIi$M;38O6!NWZ{W;F)bEp4o2ItJ3p;kh$1HJhKgKf%Kn%fM>P=5c&$I?EnIv z`ByG>3Ok!az%$zjoB;gE5$Q+30ncm;|7s0eCcOX%cxGDxiLvd-Uit|j;F)a)B*|8? zRh%F2%qa&@)?y9;&m0nFcd!Gp7he(258x{|dqnlB+yn^m{3sw%b}zESJb)0-_X0v+ zb<1`@h-doB#XiT*<`ClfK0pHOPGn!EcJU@WU4)Qd&V4ssrXRGG0ne6AEP|~HHX6W`fBkFl%nPhQm z=pI#6TDyV9f*gAmtwdP}GqU*v@AK?#{@#iAEz*zieaFn(Y%_g-34Nbu-{S9kXO7CL z()U3@e0=6;U^EPDTQ{@#!GOQj!yK2GFfTM#K(tKu^WV7y-`p&omQmIXEg`(3`k^T4X|$SzA{+MoZYwN{L0Z`oZ_`+urcAiG)zg^j`9YVZVAI$e%mJG(>@P-w1BE2jikC?l zL>N=4Hu%ZTYH!_@{PWaMSR9^s`@MHM-hWL*C2EQfM2)<| zcM!8>tY^T~tL3T+?XEXzAJm#OSyU>9%FXm0zN}}_qTa)YdX#r6m6TM&GxJriW0jQC zS`Po$9wNh3u=3=|5U2PIr)93sCNrECv-TZ2M8qz?tcPhU#K2i2Eb<%Dvtq4;+wUt(WwaOY<^bn!T&}h{nnN~Q zv#GUX$Jm1WDczBI18oh_dqqQ$qlj|nquhWfcgShLt_vRfqH}Uk3L33n8jx)Cw`e~$ zih@&qvo+)h4mF+La?u!kWN%@9T6;k&i7T4OuqZD%Xt#PRB}XUbluT`vp_eZ1skx2h9!u=8cM>K-GAADzKTiE9@+jC+@ zTOeC<+uUqjc})L^HFN_v#{cAA+{)EJRoOnB_I_)Y6f?9B5%ex8z()=mEgf82wV zYiv5b<`Zje&%dxLfDMeuo?C2?$0V)Z;+>seTClIWXwx0p5p8^zeL;o}p7I z)jjN`8pwF&S;|M8N#I74=LJNW!M#8X>wwR*kHeyX2UWJ4a)r*pw?I`1Qv6o-G_F4g#PpyK>Il%FK4)`{<1K(fvKJ{fh%e(mVq0%!im*}H! zGI-I0($zCh6aCfTBQ9=M?R+q7(xW53=R53cB5 zWHs6sb*=D699RrtSiQ}%D@2*Akto26vZI-LhAAEn z=N7c%KJ8>YnM|;)Kl)$+=WBy=nV=Gv%nY;hp`#kK3j%ZqlOVLXge5**r@i$_`GRZf zr?1eH9wdRcqJ)>vsd;F{oV=w@dmBqWd2PLPk^b6aSE2plGv}dw-hhr?tIFb8gXfL> z`8xg_o7oPj{uS-{G5+kq^A-I0dj7m*W-s`81@Au&T4eM0qFj2$O%-@9jXikA04Sc> z^Qyx%RzT0x_$lHUjM;J2z?o%uWP-MHKPr_x^lzm4PHruv=GPxcrx zdEcZlO+>QjSTUhlTv2ZJwl_6qdP6PAoYCmCWY}XE$ozfzgr;c8TXc4f*>Cl6dxa8k zmiAvbr4x;g^U5T`f+Tymnu4#3s8UU}xEH>G{Rt!XovL@iV;z1|jU%{0KFMAO{4Rbj zLLGinO9G!c7;aHOPmkrfZT$LUkFCGpLicYQ{%7DD!LI`c-?BFLy1*&eVL|POfU)7x zWPNb#-#S)_#V+*O@oD%tnW?w#&G8b!5=nX=yTgy>;E@ zy=St+K9lLvdbK&=VvE+VvRE(H)d&5`_grl9A*p&IKZNC?(8`EGIY)uox7kC~$v?I3=sa?ppn^ExF}4KYhlM z%Qm#f#!u;t_6B6;Hswa*h=zto)^zyVeX_6lrXE>q)it$+Y(p2U+;#ss{jsUj$6H7H zqmiB!^DOT6aGg}s;IaF1UeCSxE!QkN_ZydwWmfDTAG>%<(cS7Zg)FxA(Qw;}V*BqDrxTmDv`VvKq>|uK9k1k*YsJuB>N{K%g`A;A8XfPwIo9bjbnO_Nqh@Zu~j zYoRA^T{`ffws-g7MSOi&OSpN^64o2RT z6KPC{1Ij2RhA{=+bTE#ZRo5E&~ccrhlwb@fx-P5*wsByWwEo}?M z>|y81$q6R4uDG^+?WAi-YKcD*cf}f4j*q98_h5IK$s3sHSe$9wam5nZ>@r#!_00`| zOkX_Mk8RiSrgcu2uE}9?Su}pn;$nI@s=U^ibZ-j#-Hj%vRpWCnDJDje@(Kx?x2+z% z%ho#FmR;P65!pTS7wPZPk07rs?J+oB+r{R-QYSlZt2(@E%HOCy9ra_2uH4bpwd2Y~ z3$CKySM~L5?&{jy6CU-q8{JNwi(-8)OU$12PjvL{Thh|9WM5yu_-!ffxN70Tt9BI2 zzwhZhrKf9C(dFEuZK!ib8XKdI2Gc%=V|8XJ|0t7Lg1zATmS%7!3~n}H?@KQt8z6|N zSeEuG?%+*dPBRqAm?h7fFMc39fMO`Zr4JSCdAi^N2)Jf})lBRkp}1^z?Px4Ix;B?x z-rl}En_rd7ty&csExdSsv@IJh7#f0lQ$s`4_^08s#yZxGMx$eEJLvad`-)s{MK-%4 z*Ri5)WUXn>>&aj+t>1E)TCdUS>ml_Jo0R*YrKJ!rDN{iVg8GWG|HRo!DjP zA1)p~?}ECKj}PBGrB*Jv=w~d+CfWX<{6zUU&Pjajt)TLIwqCiPsZTwXdg?Z;$~=y_ z249sKDl30gx^T4wn6`BRVv}uc3sUI?tw@|RdCcY}qsharO--g-uq-o~$xNm;TbeDF zX0zE#KVh3oV+3sTXZhH(d}cuPN3r(?8KvgsN1l@JKenE@Svk{*c@*uY#%MzCWc*)w z+ikZ!!>&^fND+3e0%sQXR`ndaN&2b4I|v-|#c#MnQP}S1q?+ktZ(fO;toSb5i1c=8 z=OYiiA?7I>u#QLLVPW}_3E5t?fsO^GE{T4K<%?Y3hJ1t5E1-_#db zFn`?V3K%_cSM$OZi+z#qfZnP(KR}VpxatvhNcs<66Rd7fU1F#U)#vIv{?67apJ!_y zk)qQteCGV~A3Ohi)V&Au+RLOdtl43DEh6<|TIh7tg|I(W3?YS1QBO4nINGz#`y56Q zwqx}*^QW#@z4Vg#wX8v_?AE#x9qzHM5w%v+Aj@rM{Vf=+D(Q3>)A$oVj~86$+br zdNy`;Zv5G{RaxuGks%Fl~$FU&bL^m^S=onfhN zm$t9_aCckf@KAI2>K-~fw4-n!5kLOq(539B0R@L92Iv~y8u+e9ae>))k>T%#2Lp|( zj<+~tFi2Qap}^P)IxvMko=MKnWENxsosqiGjW-4(eNVE_zig&%YqofdMo$AvezJX8 zwsld)-5xZtw&`D-u65;tckbND49M`X`jO;;g=Y(E+_Z@#i2Q2gBtHZD7N-+6X8jB5 zdmnU@>CP0gmh6X%uf}oO+pbz18t83a>h1C-Mzc*#I7ibFjoKD8_x39Z_5-EJe!#DM zT73DIPHm(0O1sh5wXmgianV<2GG1eGYMDGWb?MX;Nrtim==KECd>-|om`E)I!XrNT z+lLL?Zmx<_>1IDHt5J`Jm}oaAW@&N+*H6^) zD=1p#HQi~93}oYmYpkQ|b}L7N?1r$iE68pPYxNrG@#zO#Mqish-nqQ9>7kyNyZgFd zhJg_0PCtV4^f~qNi4WzI2l&j-TtIB1eRZ@*Dti$lxI&ilA?cLy+xu^-{|%)WW{^yTT#eee`*_D@hM zgp7_qOS^{CewEk73EhIFuda`}>gFD~d;=Yl+^Wy)&hKt!>thc+8d2_R+OemZZHwQ1 zPgFV5WMC~ryZicf56zzC&7PXlXr?@#_@u`(SznJ+4nM#U>;l#Hc5u|dbpoqB`>RSO zuYoi-XD?J;fLFM>T;;FjkHM?^!VPtA`LAPsuPyAuAoh4ZvfIE{V4^W_%AxmY0^L7W4!o zgR66ke)-6sans+Ge_xQw#hc>J8xAhkcrydZmPOrugnk#el-tcyv_7sph? zG`qv<1D~()f$O6>@dx@a@$>chm;}`63~F@Y{xcR#>~Wp1b^9&2TMu2>)R=!EG~s9n zMy-LdNLPAi-rJtCXPX^`=E=}zWtcsfw`=3=PD41EvY)&ITh!>lHp|%_pXk4P{C+mN zaU=Bv%p>Ig;o5_dty*)eA9Bj6;f|m*7W?84)6?%o8-M!nZk%{p%N%NrPP?mg;;D?C zr==55ZMU)EQ;fz_8XA-*|Dm%_aW?7Sz?wK!lzf;uCyNTZSBo=5s!kYHo@Y-e3)vIL zoiuvk#Jr0p7_1b-9Os)X!0v}nG-v!KBl}1o@Au~ez7D^?i*T#-2Pu}pt<(EIf{-8KPBdU5u zYDS&`Xb?`6z5B{F*Whc+tJz=J-^JHEFs{_nZ3Z_eM>ezF%8`w1*QPw%y-_)`WeeNA z8Ca>AKOoi@!(V*2C@=5087T!$N}~xMKvHZ*QE-Ml?pSJ~pG~uWC%%9AvZ*`^rOyUcgth^6)nMGbBgz*jU6KDb(8c46)_P!$a(f zhD)9p`_iQ^Z6Wz5fcLt<8;j&ImgH;M!^%bMiecrS*ouaiw!C!dm&Tr;wiv>XELOio zZeT2;CYsd4PP4MpCFK(i_HT-jy?NJpXZP(t_qHv{55UK9U+v%}#A@j)sZQx(|K?CW zA+ghyIKI1W%WdcG?>k$0jn{ev=WzE)gXLOti;k}%7>P#M-xLjdPx+Nf`wYFGi-?HWgOMPvFrqNlGpNbl+>Y4_P%_-;RMSjs> z#f$nzM@`3641bFk9TQQsgkSYtNzXn;ULVaR1aI~bEwj?0{uGyuksr)1HOHc6W1vZE zwe6o;r1v&8>l&IoTC+pi?;m(ZV^FL0^?u*xopoAWZLPi@LE`~B`v7gN+AEz4ryEOX zJMwg#u9>|wq-doDd;c?eo$@R&^fxShT@MD;V8|B)QEye_jWALUfoz| zvcVJg+nZaP4hMVsLtU%e+g2%tu2pUAtGmKIJ>kQitjEq#JRP2^0$qLKSnf!Z+Zu8q z`PS|IWGrsXZyO$_OGt)?x8;qA=qLS+)=;A>Wc7HCL}CW$QgG&NzGwaaupfNuAbYb< zdGkZ|gEtJb-wrE*lkNvcw#NU?Tqqral@n_Ll_^=lI%h71oiftZj*hP8<;JMR++?j? z+)!9n=vdU;=4i26z4qG4hMsj-8r=44^+p_n!XBO2(BgJqWiVM2Ugebe+uPBCrHo_B zq{7{O>hgR~q{Lz!8oOiQI93NiF|F~+PJ8z2>9(n4cEbhpCN5l`O-!{X{zZA)?Dt^L zh}P9M5Qz@7x=l@1jm2WDyV0N7wzqTW^re~1($j}J_ioGhZ@kqU^ckF)Tyv;5<#MHZ zafo4u%iy#aYHx+Ulry8DImiav##jVFAsH(*JK4VJ7dqJQd;fQZ^y}%s;Ls@hPx3lK zGezks?$L33Ge$E5{O2>DXFjV~KKrh8*AL{y#~zm_kNu1E=FK-#55gMrAEifW3|fuc zOS*$GP_Wu5U3WqHCHdtv`>b+T`se=uaw&)>4h&0PGf-|HzkUEZjw4h3wihsvc;c# zb0D`d1?SXV*09GOavSdYRGrb*+8fo|jWve4d!;Wc&j!*?qbFoz#pyq9*M?Fsi#m1w z73yJhD&-(frS#M4|8a9SoJomhBlq@$VPcjpZpP`76YI0_`J>(;sWH*JsI_D4+;p)s zF}b5V+!YLVg@XmZzo1S|ZR_e-KM`*l>oo^Q`t!+#!=QUPX+0>MDI_alF z8ShwKdR17{vCEmDSB_LuQv&U#MqS>mbs_3JTbJdkZU6t&r}iW2`%k>S+fP>Crt12d z8uj`{6R$5jl3UV}S<;bPoX#xHnw$*?4dVqQM1mNt2x7LjB#4AlSx|We8SUiK5i)Y_ zJn0)~Uv%avX}9!4NC;!Wm`18C#MEp;`8AxpfbyFI2M-LetJxvt@FNdBa6f75gtUM? zEI-HRB(x@7iaOCunxbIuk8L(=)u&yqw0^5$bML^$9nyjY!+NjV?bQ!2cr0_@y#FJO zP2*Q>-y|(qIIa&gH3jrziypmh-hCAszW^VI6&}Zj$iU!(4~DAQTwuhg?Kaz79ye{) zXtXAs3AbmZaKB)GuzRf8VzRO7A|KJ%by(-DH>kB(Bw499W|Mxu25DAOtwb^zgG?Ti zzYSjE$MaHvgv1Ikf)t39m*Wlw>F?v4jN2fo#lt;nBxYJPM@=014xlB!i&&YnHdH=Ek7+;m0jr@Xgc3CS<-+_$fDbj5Y^ zc&L3pT^N*pX<#Ikk-aVj4JX~L|)tWG(8Ei@1h1nnI9`{;|cCgObT0la!Y3mL3 z8m&&VQfJC0eSU4dQ76^lQYy%J1!VjUl#F?BrR0-J_WnQ4tDGA+vg(jWLQot;iZ zTdV)0XQiagp!Eu;MNx|?3c0Gkss9`TF{1Os61|&8*6x)ySJR`vA5VXtauWoPUq+_l zTUhU+lb2Mg=P_E{E*)TNIq!4IdLO4M@{<)0;H16QR6IiWEH4A@y)w}_I)}zZ(mZw^ zB8oEnYw*P;pPPI_ns@r{8$kaeX`=iNE#drP>l2gG#N-V~sgqP4(hl~N+==fnI@6c1 zle6*~8rir*c{bV7nn<*^NjuW_PhOP>2a~Z#Fokbj(inSDjtE=Czon8BgSW0zE*tsy zCTU*o!F5-*J+^{I(rI{m%nK%x5NtZ?bFm4)F@Qe z1UPyaTf31H~%Afa@X773bQk&6i!+9n);${rG`Yt*N*0d< z)8*ekI3ZtTX;5SNMIHzHkUCRu{jhqkhpL~j?q5vbKaVn|Mp4eAbk~T?W>k{sy+47( zmq{0}d!=7f&!gG!GPXpzUKglz&=>(-^3)@?QUCwEZ&aO!Q%~w#)k*EupZQ zOLuI12Pc}!*G%63uOM(79?_;cfd0M2>X|!s+>HK6ZGM?F#m<#GXokA{Lv6dm`LbHy zU@@4?4YoAKwxM9x2o_}Q5|gGhc720WuWit3aDmSXy`??r59%~lt*l1HPS)3NeQ47S z%IjZRcSIVye#K*%!|NUtJ>nGj9Q<~j<)vX%%~j%I^n~s zi(my8a0@|wkjK&Kt~TyZ*By)YxKf=>@rIzwpflUsrA2KW&8^X9U7-eNF=^{->mAO3 zAJVvyeTVIoZlU?i@;o{coeFYeYs&>~o7i`@c6Ie`rgVPQ5u6mTVdfcVGwmL!^!WE% zI`SF(|4k-N!$O1-d=Y>0vmbI?IH`#9SFk^PZ21j+FNOl~o65Z%XU43Z`Q=C2!M1Rs z2s^0!s!(HbG-x!n^)9BZ@-NEf0FPHHjvIHI*@d``c~ zJhrT*BNywR_s)EHM0$hTaOy~1KH^KbF_~hj!??rcY^?1yaw${`ov*eSr1ygbpFK5{ ziZ``*jC!rHeoPZ?`MEdibEm>yT}z$IXMvUA$7vHioHooJ#!0)dg^+5MulfGy7TCgM zV`CDwaLXS>tNYRZ^wk!ef^+lCW7LCIL(8|Y?Dq~F0DQITHSE)%2q2Fiu4dz}9iR;U z(pZn|P>iE^E{&gf#+Zob(pZS+cT3NF{KNBm{JAu4;rV_3TpCmGe2hPrMh`qs^XHO< z$MXz-F4^vyIxt4%m257a8GkNW1U#eP<9(^E@N5>}3#sGT0s@HHNN>TjyMtTSliF2w zPAc7!NWyn{C6&%3lj)4~Rwf>9ul^57aauJO<1aC?kkzZw*lPdf-pEf2%CQo$XG+xN zuPxnwuJT$nRr(vX5R`EnD{f6A@1++%pF}I&F8vk#_BQHC)t=J(E$!J1{!4#N$3kuN zA2cR_`$$a@HZ;n}hL&j@kp6_bj~=C#=HAQN|0(=b`qQFw_k(`bI~t>J2iMY{mh3-Q zcrS0jdwGi2_M^O)H;(gO?9%J(*Yfx9t?(s~P*!kGc6?@GOZLu%(wpNqZasJ2$9Ix` zj-h=Xl|NnWa~x>DX5BUIH+!YGy7unv;{JI?`V0Gh%`mV?5@lZ-G}mRWT6b0E)85<2 zKmSW%=gz{=P{}{9$2V`)41VyNeCeC}X&zAeCN1Ux!}#V2{>?w&S;O|7QzA9uNZ726 zTC+voz=pE+pu^ohiV4Q{-Eb{bILt2N``@&7P~ z=xw>0*ZG=yt;SrQt=ao9Tl1P}wkAsK)7W%ci>|&wP1$UG4&n7`H6{&?riR={rMEG% z-%^sh@Zzq@-t4_OdzJL|zT)_JaUZZIq@x&ZZYBQ}`ZsHIV7xL@In7RO(&b8OcWQEz z($VNxM(+*=-TKT}l=Npp`U88oW(xKSJ#()2#%IT{T3htADOry{#;N`OFn>fJaJd4Q z9p6}<+5Q7GGA==*ryuS42h6a`G+Ou!dl-B?Q8V;^nUSJp$*6A8mr3hvQAbt#M(jqH zXx~=p50gWBpVR5n4^949k0ouprM7#rL950eHQ_#V=dvUggM$;!&pSH{_*dEn%N$>NbmUXp}t6@HyrB4PweV+A@T5Z|7y)?0uGCrK!kr5PF4{3WJw91 zG+DN@N8}603X-=s!FI`Z;YlX&MwHMyr-X69ALbn+@0klv~EU92e6{HK%v^)Nc@RHkLJ+<~Qc^cm#Ej%M_(U=TIAk=+L` z@4C5OUM77>ZWFt2&p`jkZloHLxAg+NtzUekw^f>TDleR5)=4Ay&XapvpV8Pdet27! zv1OT#Z1lsutt>xrpGPArE-v@B(4paNInqeSccpNs33*$FfuDQYeXiEHPnWH6`OFP; zcS#^cT36aNfh@(lxmOVvdr$C;z&`38lj>D>!$T_BMVen0?BbB0yL|;@ga(XuyHTq5 zclZMxezweNHu4!{2fll_^d0%n==7yCf;vo?W->p`M5TJnI!(4^%-<34<^5FNz|3jT l;(sIUrI`D`r8l!R$`|O(2s=}CpY#K+*LX9sg`Fv0{XdqP?nM9q literal 0 HcmV?d00001 diff --git a/internal/static/fonts/Lexend-Bold.woff2 b/internal/static/fonts/Lexend-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..31242196bff12c08e00fac9623b9cd199a62758d GIT binary patch literal 29408 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 A2LJ#7 literal 0 HcmV?d00001 diff --git a/internal/static/fonts/Lexend-Regular.woff2 b/internal/static/fonts/Lexend-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..dc430a02be9d8462389283fff118524fe5b8847f GIT binary patch literal 28160 zcmZ^}Q;;r7&?Wk{&E2+b+qP}nwr$(CZQFM5w(Z{C_xv*xH)0+pU#cQ2BdRiEtwL71 z%Zo7qfB^rA9RPs%UjxKC0|40O{BPa==>KoQ3Sz(x%y+@b^Z+;DS5^^Jp#vnKAwfb1 z)hYzOYr(|g0D#zlNkFogKncL2jUYO$VIi~&|0Dv@j&tbcoYv6f8f$+HCu2mau3()S z-au4^GuF0--0SjiD-4eT1x&bSU;q9m%gLfR)^2f|y+;8FRqDJWpo6A66imRPf!B42 zT%bD?*?dt&1yqQ^NK@y;B(lvAXzbK?A&DKzkx)iXpxvD!Uhi$&xVaj3vCXJMpqnQH zx`rW8<#!W53&-~h2WGGo_?+P)xjf1GGS=GgG&a%|vf@gnOo{ew5tTslz2z>H!<|`7 z>VL|}j!hP`nzjpH?7fzX<&+OLPg{-sZ>V`g`ud54k$Xg~L9qFbpXjv|AUUF_7y6az`?Uy#M;SNaIJVbOaUhBd?%>kJM8Jqgw<`A^1kiFD z)`YP0@VDa;B4b0^1UJ(B6m1y9n1TeufyZnK6rmK3GBaFGMKOQz)KFJ9T-I>)TJS;| z#F_{ggh&8zPSjjoC#29Z`GhrC zFRGv?p<@Q&&ys$~?pV{m^dJY4`^{u;uD?|Z8D;e>1=a4}FT18kjlZElEY#my$E!Xg za0G!A!QYTf1nvIGp6$D4Vf0V0a4BxP-R1jrCzcqTm4k^%_>4^@Xz~L;cj3lTSHlx{ z&yTGv13fZUliS-rxZk}F6`24Tye%|QzrlpC8m?uo!5!loimWNWy;&O*e4g>)YsR?VtTWWr^g7XH6_j5#`UtqO6 z(=S*3>SrZ@)0(`l3%e1f!2-4b>hDX@&~N&K$z-fODe8uXV?Nk~&N3-*oe;J$37niu zvk~~#6tIHJh)SEPo#HL(DI2|^q^iHDr>M7TtElUFk58xUReZ)(w?w!NNNgQ*{mqk3 zp=vBO1fm$Di2zMtK+D(plzjV$6{A~!wF0Vf-cP5;1-`T5&1x;0IMEWc9Hl7jQn_iB z3x+QR$~mg!2^e5N0e z9p~|#(EUOM69O4V%()=B5-$74DT#_}wCOAJqOOm6=P#>VdP%2J*VUV=dF#V4 zbp;B~7)-@&tUkf$LiYmuE0?c6huWC_8>W8PHOL_bd02TJXwmnQc{%5J!-?^LC=^kK z6pJ{98DczhefkZ%30F-9?Rt0NXG$SVNu-CENI(dhxc7E*)H}^&4tx{KZ8V!=MiY!u ze|5&d#@pW)?QI?)2q^`bk^M`j$~$q{;Ps8Erwqkv-eEPHvC^iWE_rhompnEDFt+4eTNIDJK5@{P6(>HYdkrI!}Ab}i_poSVQz&B|V zuv~*RPu>Y$Kc@qGCIfqB18I&%`I>~h;2|D!@mtg-8srdda7IF5aANa3?@#JIeuek{ zu_=SVK@G$JgTgwtbHl=Q?L*?e@JiVsa0(#Nz+{BI^S0No9$Hhuk$T5?uv{OpDH$Mo z<+6rg0bu~yC@jGSH_b3$a6FIVPvyl`!w|?7i;;b1Z4|W7J5q=YMF6qc!cq|Jbc|Db z7F!$-FYPfb!T$JCT#ClO2M@yJJsKcJpLHK@r30hqC=0mdq4y^Ri+=-o^Ge{Dd55aO zF1pSNZwTu#`oKIr%^lL+VU(Ggt6J~Dy{NhgPswQKgm1HJ)eJN}9Z+H{IjkA40MMqv zh3BKdAo#xrydT_p7JIw&UX-?Z_0jVt?~QBK(LWfX<>}OS^&F&kFxE#`cTuf%wscvJ zzD4cm|E7tNQF_ijkZ;o{gK|rz>kV9p$vbY34 z$U0HK9E1b!{!Fdim0?M&*%P+u^`s7KQBM>K?jj_x#SaI-!_l3P@{k@PFX0F?>p!fP zR#%S)PaRCYPGY{==Bk?>(9C?!iGI{n2mLemIxxYBUcFjz-*~hCn(6B&`{onj2P8c> z+3nL$i!b)w@qMo^CmZaa4U#_?_MqFl+t)l;y)DI@p?~y5$YW)e(C{wLJaL5ec4b=# zCDqB{?_z2HgVv1||G95}^V^FEYW=z+Q0V)@Hh9a81#IkQja}CwffTZKn*RV%p?WC5 zIF(3K;Dno1ehx*B!YU3BuD^@VPD-YBL*x^@t(E!dC)ytgdDmbV4=~;;C6iZ zVO`MtXk&xIj%E)42XXVkS1&wefUJz0R7IV*1OYWC)&wK|E{%ND|WftAk;bntSGn##$w}TBShgStwGM z9*-CvmH~=J+Si>MyhBitD0D_GUH#Tq&9=*onF@N(D3Byx1{pp+E>etNf9XnM(l(C)+H5iV|B0 zRx8H@K9c-I2!kqdFPJdj<;kWe^{k4x^H1$+9H~BH*>i2)q&2)o^v&6rOVP!ddr#RY zgQi7P=TA29y_vGrdxjOisjJixSRl>V8<4?NYy^xdP((zq$FO+6^Tk^qV|fQQI}GP} zlIp+53|MB*0hsFY1&g2bvigdp0>gGi%3|bbP`6N+dm8%!Vz7vaVj&PDq|1e|Nl%LY zEAK<`O~mA?dcjPSHo~QsKsn1RIlapC(-MaYuEe5}?RClF6m z8qdbZ1SHblBx8&=9ED{VI`nHBLX1%^wQeM6L%sOjQD? zjP%r#=5|-~ytDV|t>xS%hxm@B9`iA9Ir!i*f9{j_E~J2vEHHz0iYaadK|qa}Y=KOS z3~M+|!hu4Xc-gTd1F+yTAB)5ctj_4(pM>1z@d|0$ZLv*mb+tJ zn-iCAfrHX3;@mcqmTou$CRGkLH0r(8JohD(bO%Z@o6Ui& z-(wQHacY&|I0SC|hRs}`9E3=fdT+H`K0p}=sZ_RXXujAXibxSgg$0Af6MQ#j{#uE~ z=xkMPSN$1aY>>ICb`t)WkrJ!_np}`nDAywh=H~ZT5^q&udTodkzag$|f5KRll*%~FS+2r%!VB~fV-^Vy@X~KHI8$T#@ih!CbrBE*AUDWay5V82TMjz}W?jgzUktYS3(C^7tgfIh7){fDfXL@tCJ#NNtzp^<2fFqfFs*#dw5Ny9j3j z#n^rAXzVPPV8nLO`(7|!9uEeojR1>xFTi+4Ihr$)Za9V{EEC1aNTPp}Bbt2$&)3-h zIl_2no@Ny>x2O-iW7~K%9g@;26LAg|ykiP6q?S~HxjCR`BT!J9S?cMWe-@LLq!`6b zUNo7(epQ8Cka{}JXWFnD1C-Sh8AU!Cct4fH3S!0}%nZ&foNwm|UJ$xSgR+{HMtPL> zJ{727bc*5F7_I%LR2{Un+V|(nyxF_rPXYS%eOr?W0GwQmJ1jovyp1|c= zK9Ed0FHz1ck|{Iw)J5y|na;&U%`~C%j`FK;UJ10I1OTHrFJ$$~HgP63!r{&=uShjt zz!#hdxWu;_CsnY}C^w|FYT*ZF{;NsXWZ^IWn7xZxarpb%Q`1jzQe%Pl7>uH%F{Y_m zLPGMLJv@OkWnJ0+L&ib@eibYFbvRt5E5eeb!z++lJlcHkqtb>AP|YGqO(ZsmPt^n1f`LINfJu_(XfweT>w}_U<+C#f^sX;eI`tU}mTATqtHW3r|ECvR&Y$f70R+9_ zJ<^Ey9UD0S-fu^WKpp@XusCMMq_~p%+|y*|DlBK6)2ROan!T$6phG9ivhGD$o_IM2 zht7;ff=fSY>ZVSGMuq{uv&mn#zW_r&%;>-r z6u+VgUzljObtt5uE_hx5Qsf)SvdO)oscLfnTKuv;9_vjo5KgGf+*BH77>cb@EIXg! znkCTL)h>g|P-CPl#J_lZk!cn6NJRJ;p<;!uSGwTGr!o#qVya|Dft7Pa3&1FCDwjd0 zADKd0QeDQYAii=TSv6$6Wu_%ln}n+6K2=Jryw+fjD3=;Y&)jcWju!GUzzG(Z-dLc> z?hdgAYcHhS!F|>-;F?y9GAaupD6z+*KdqC}(x49~j#d2#mc4Se2{^mmCgY*1ITw-@5EANdR1M1A zH}UG=eIZEO3!XY(gxvI$ZQVUC0VUh!PCqZ6*$&dx4B>g-r@^M&pO>K_y^t)C{#Slk zGNDPQK#*nLb})4v8PjFT81`C8wM`c9X4hf}6LpqHHW+gT1R1f%cmxa85vO6_Vjg^% z-r!oePFth^?_BS%4nuZYMfcZa{G?i)_l~x2$8nPpxpo1}qN)+c)j^mRn z*u_cg3qo=jd2t_-;oKC4>hf^y?94U$3fgzO$OV?FXyRF$%NJTg$XGW)EO0lu_ft&i zocFSrG^WvBYbtUUjQ;2U1NxyG86nsKf@63}^0e0-Z``z`V!iRfw*Rl(NrUv_M|-sy zQDXSXB6XogD!yk)dA^5v_H)NQ{!8gdF}`fiC%zPD1{4xqLdQ`!lKa+R#Y6Bw7H39< z8EJ+IhHMcBBbk(hCKWFEh$hI{FMv`hq+kOzquTCBu_`5i`<9@=BOoHYCr}nuwL@tZ zqGUxO7`wG)s63XA$!0J>m;jl?kdV#5E-aWubfE}kI=T}Qie^ye%nk>^zHuoAp-|Ag zaqY^oMkd(t7&sX_Xv{DTrjsCl3oW#RjxtPVE#|08GR5g+aObT>WZuUj28$JmcLigL zSvHZ!Ty~<2o}9{E07@bi5Au2Cj}m>aL8cilUYT}0_ZhLnnqnqIc=QJjReb3IFj=I3 zRKY|7ygg)A7Q&Rgat?|JAdmxT1ck)$@B4qZ2$n*HAc3kM7_7A3nvj<6a)~!MsA*-e znua71b`&tM7juF+A|fRLu|OFvsfSXKLs%Hzx_6;p1*237S}az{6pJv-$dAIPe#egS zfk0#ff$RZLd?iTV0~F)GhUQu`2A-7kQ=>D|N>ztuP}u?rrBE>NV4Yd_6OSzfQ&S5V z3uMH()Q-Mn!_#Q6kWH-Hk}PLD9HcCPxiFoOexE7$q{Sg9WydJtObT`?0}74J{Qn?h zsCrx$RgGP-Z$|6b5i{r?nd#tDAJFTBYvcj4pVZ*EG2Br|(!IoTGS(lwW{nB6J0P zdXwc+fVXPvhg)fisDVlYx0`y;LdTbE3pR0X{8iL*Md_#%9&#*}0c?s=W{A}CH1xL&eatdwku)0Va} z3Y)P$Sn1|^!C|p}Rftxz-Eg^FQmx^4b)yNP8n36Agc1T45Bp`A$amz@Jti%z_21JLW&N8SC&V%QNOE zh7Tf1pkM`$9zu=0Lw?EFmcj>58i5dmPanaA5H*TWB~zzRwTe|QW5aqOx`nHE(1J<3hON7rN6j;?=ldefXH1>!zYsfyMsr_oKyK0=f-e~ zmPebe<(s!yRk>pN5OEM8GeJ`k|6zuq-d^#e{{OT61D1%By8jz(eM+MXFpU3e1Y?K; z00~?e!(yZXpFM-NCuI|)`@ftwM@PWp^*rrF_l49<4KmY+Ll^lUD6q?zMX#EBjo6wlV-50M|u0e2pUpt;{IE02~<_8yp=z zL{1>`2PwH_Ewum_k$52Pe^6=wOGzAykv2@$9NLbYZGc`J_e6mLE>56m0gDz+j%eRA z*QxP89(a6Qn#lqGA1?zy{&(lUXi5Jc6#$@(_SGcyVApYxwk@@5=%}9Qee>8=O<7QO z4Swr{C>*xQ8G}*=jTrNPbHimQFOuKb@LBQM-w8Ph>$=>?7Ha2LM!)WbG9;f>It-#3jHCI*}VS4KG_VuS3b(8g!767W3 zrSqZPY-jhW-fs9?rN{dd`Ro`?_%0CEm}UB4;~+v9%2tcsV&}zT1G`#--bdo$MIqRc z+~F*eP^%n4$1o0|+$%>%$WJx}kcMBru=sRyT_EzGz^*zg11mwgwsVU%G5SI9wGbBS z70$RFfUotRtD*o&gbdQ^fCL=u)1^{b6(chX6%a*3I$FZ7fBOQ5CliwnDnaQK=@u@l z?Q}2GLGVHY>4iuGbzODXxg^Txts9ZH%;4k-ZxLRch~L%g5fx?y~J@-$#)q?avYTdl%aCm zM>Rz5NgD|Uw_Y_cS?4Z5(vazp<1!uCou22sVSTF@?%JF*Lv+_f?#J_u<0Ii;*(vU> z*rYb|xilW>ofn3bG#xvqzYR#~UsX%h|70|l9Am9_5A*b&F1_o{zmmh2i14ZBN7Qj!f}6~bL8LZ&wonq^bh}0cldw2(YyXW-eRY7`Eeu~PLTzE z_zg}@ZNIOQNZ@ldM-KGsYPbV$_I(`L>7P_0+IN|2!z}viq0kHSXMH|@w-|~-Of1XL zu6|ET=RKYOrvIy}gs}T(Dgw9uvz!$I^INTE&GF{VjjPyjaoQ8kVv9pUg}W5&+-tx$B#tf9utOeJW6w&oMt@mrUf0J`-XV+1^cel~!+WixW`|eW~&g@v)-pmt((DPkXLFhQgf*1OTJU zghASLy_~O>?*|C}FG?B!Lk~p=kQfoNVNDo>G2wXQU_u2C4r2Vt7@?UN!w*x;Q7nMqm>xx7lWZXtqyhD(!d%lzk?6!^1m5Ucmr9RtTnP}E<@|UuXdTs3P z8^$v2U&9O|dRzB5THQEpK-&aSNgGFqOgDUwNrbV0Iw4`mLKHPAY?5fakFAUsE|J-? zm9K)@A1-)noK}MwOYp=$@Jz$ti1ZYYh`&~Q5P(5hMu@v+QtVJ-9DgVS;HF$r@m z=4FrX;Ust6y`nvr)%T5cBcmpPA`XZWY$?Enz2Xl3F>f1*f_m%tjq5S*UT!4lcXC>xs?SE}0Qcp_TB z#4oh-c~Z`2ol9PFi92~hcMD$4=HEDB%-nO!)|LL=hGa@eV>PXaiOgg2D1etzE`iNPPNphU?iQ{-iI7d&N%^X2G(;FdXa9k|@SG1}9GG zC@MVAjfGNlOi1per#47m!~CX{dPoFU<<5svJQu9U7;P*g#W{AGn16mmj&VfAQP#HH zfaK4^91B{;oZLy0D1)Wng!gA-p`MuuAMHR{7QAE23$l}RECy`GrXJkcl7)TysDEah zRETrvSAqorTVoIR$&cch2fzcb4R{p}V5tEr&&$3B=RcR*Bln*CrQg|97d6YZ41=5jE2+cM4FFuWl;t}`7m+u;T_#{nr+18V5$}!zis2B zp-__flt#1NbYBril#~g)G2%C7hER7-b51O2`P)Qv?OC216B8UKJj`HBK?5!eM0d8B zrHph9_QITjL!730=%Y)Zrr?;-vbx(M;TZnVl8~2-hQr}7436KFg&|UqqvQ{Ehw~Yj zbz2HoGcJ4+)UwUsR`Dy&5WWXQ`Jrn@0KUPUFAsu%FJK_1aD{pNCYR}mxZebM?OG|oMYY)PA)BWE<-xj(Iv;-CaU>ffD z@%`xI+4!9a0ujpmwOLwJO;0CJjEb+XllK*TdNO>`*Y7Il z(gUaWBLC|adL`IVJeE!HSB7<3hfJC_*{~Jo429AybyT>jqAW~nYv+vXOzYFGW`}R6k&H7(6O8zfdq^cf*vE#K& z+qA15o9lCH$}Mnt?8PL1NIi#s&)ZkjObjS=5(-9A6=R4q7{-!}`b=QXCm(ZlwsV@w zX`YRzzgqFR7Ax^u=Tf9-kcp*&#fJ*Wl0++$=l}0?B){@Vt{@oTw_X!W8 zg+k0ZAT1Obh>pfZ;K86^v$N^~|3!IRVk9G(97>F&#a36F+ktkTs+a00*y{OKg;uTV z0A^jq(3dDKyKG%4sD03~UeT+bBfGRr`r&%HupL-={IC^tN!ixyoTGLZY(-A3vvl7r z(0~4tZ1+yXZ9H%AWbeJJs`7ibX790H`R%^7eOr0cke2r|v^j}{4q3;Oz zrJX;X_`?jZm-%jqxwJq|G&`3Y&Ym75m9kz^jP}QRT{S(h3ogx4Ds(9~?V_W>IEb#& zU9>q{Y?tX)JlAjSs!j5Z_kvr#`$TwMn} zyU|6o<_~)D@MD7c^+Lq@!7|YjR4}yopkB`ZZ9?3&HqJC)73cDyvKI7z`ve9o1<8~b zawOiGp7{$ay?GgfrXNY>;i-F!fr$xBAOz>!O;<9jqnSBG?t2~$bVK_5W~lSzB_bIV zRrh2FGs~x#utUjKLbCA-v!v*^sSuwdrRptXUu@04OoykX#yko{Yp8HikgR_)1J#uiuF!Yv7>XKU58z!%woa!Vi~c-2Jag(#rMd9p@u*zDUo zQbFvd%n&I{8Wa=}_0d&CWu4;*I3lANSp=T3jTYDDd4^cazoB;26gWHtX8-9^1-+;@&TC75eKQb7?fX|E)H)iLwx=mCPEhLj zgJ#D|#hK&{w)sNMx<~_@GL^(65XUi%-f@U{ACt zmS{t{BS0>Kp8ukCzM@fQe>H65t%92g%HJe_KQ_g+xA24yX4FsSs8H0F{9UftUF*QR z&GmP@Pmc9t~d^LEb?jPA|{<;|eAyH-$f2nu{}!ZrWMV}}&0kd*-e zcooM)!0P8_dPZK^gFaDU0oPpLQ8!);2kZ!z4E`uSjINC~rk@DUU8oawi0Zu{<6`2{B4XJFVDwVdNy%-2BMc7Gz?KG;K!MeV{Jb z=KthPpv*@Se-cX5{I&qsSz39hVobi-{H!Bt6MA|GgbGnztO(FK9v-IYcD-m4u&=8b z#OqOb-~=O%nQ%3120d0=yde7t>iE?KO-kFEH935DkEE`=i2m$*Y1F^YT<1yH4V)<#Cl;?V+#`Qt zA78n&DyS60M+JNI?J+H1@WpfT;@|bSJ|1ykZUm=x)f4XD+C}2%FooaZ#b?#&4HATD zzfJ3?8(W~9v!T}k_faBV1Ek|9lRCS;D{*er{{wQ60vk0pB#nI|SOT!2KI>ec=2$Fe zYV0-uhg|PAKRFP<1Ak)R-5F|*xdFXe{cYq1#v>VGd5A{%)0BopSh)8KgF|rq%B9_ku!{x@;uNNHAzJKHVv-@CK>MF)IQP2Dfn)3~e;=)ZmMm6!L<`qa=AT>m8g z3pP;Zg3wAN7kP-Y&lXULeqi3(WMI+MbIXvw3Ib`&8>A>`Yv4f@$_YyOnoty<6k|oz z%nGQCr6L;KlK~+$rP}fkPEVVu&Uv2{TG6-Q91IE-Ae?>~C^a@6UecT4dR__Mva@fC z6-GHaHbXsM9WyOVH9)%Q%(@uifh^k^6K+33S6FjJ-iTu=4PHe6YR}K=IqV$q*$0FW_Xy#@ z=mY8G`%>^VRg}p2iWs=^V?gbI94rifz(AP<@ix0kzEDb8on2s+qx8(I z3yd`@@&*}96KdLoF4-;4)+IBb#Y7J=sKet^z;$t|sH9Y*Y%?B^U89N{dPpm)NeU~$ zwt3Oud9z-PPGspb1sq&Wx`)s~6AdA2!otVsA|>kSxcl)!CAns?e18RVSlp~=?f!{G zs;=ESnQaU3fGX98>m`8D#AL@JU$JjMoEkn4JSkwh zQJ%K8BFGbr2J$f+}Y%ntG31W=*7kZCk;}c{iLNxATfs1N}uX(cN zsFo!t%YPc-HzoQWBRv1vO8MwvqJ{>&I@=8DW)9}t81<(lu7Z77yC329 z!x#TE46vGDQh5+t9J;Q{ylgY;^YG%&>AcdViwn7u;&ea^Adeby4@y55ZNmj#4Z9 zI!Ltr%P9Y*8m|(9Wf_EHG=)4TK?FUjd zrj$$D|L0)%FRe>=bhJ<;t@Cx<0Rr8e`*Bwr=PF!E3vDvAT13f=q7bn%HtuT?M(K(rNWUoGN>XG}X|pnIEczZYb`8Bx{BA zHY9Rk#%nBc@sZWLPZ>OdEAbceqfG(ywZBzGBt}NCm-|73g3@HaF3H{8DB=JF90q$N z`5WLaQ;18^e`td!#U;sLb>ztXXQoWCEsI1NPHB@NZ#}9ivr_A^4T>$QC1C{b*u8?L zwq3q7+ab7Xtt5K}*0}ci%1=GUmQ8B%iMYQlH{j0?1aGbU(A2<-09g+%)3Hg-$k87* zrv$z|bCfoCA7)7g!m*dWjdF?JA0X1uoEVfv0aR*L23vYDMm6?Uh0IymM@4_0>y(`? z)lKb6AEM{tnFKU79l-|U!O(ls=BGcQG;dlPv_nq=ALGsDnwgYFl65i1Uq$fseF{bf zmzFN;Avb=}dX(dN>%l}_kA=2El2GL2H>N3l^E>AY@Lj$4k6Xm#CR#5QaOZ8QZkXPCf| zR3Rm4KP%*ZxzK%!Vvk%PkI~sRHf@uwvnC^>DTO&6~Di-3z+p3 zC^r0RqRT%lNPDww3j0glDt*jrwk@VM6PKjPaz2y&%@mDZg2A>kTCcNAvY^5FRZF|C zAzOH6D|(ME_VOA7eV6dwAxbocBgS(}9DFd#V!IyC_17u=Hk*yTI8jYXLWd_sPx>cflJIhz0s}b6piEv1Ep9V>&eu3i$De))fXiQrKQ<-4CtRNxC0L^s z4x)`pI@D_8w#FeT3Xn0LY)u3PT;Y_%I_zL=v~h}QISKN2C4o{fs@g*bT*pqO&G-*O z^d;J0LOnNMLoqe>!r>ar`$>>cUz!j@-4F9@h6Tkg(;aXb}8iJV=6b(Ti6l6}jQnIV<*X~{)9OOMJd1^1~GmQg7F zQHO+RU*}6XDq*c%x2k-4}e z0aDTq(SPYP`L+I7Mmm`D*>b47pc-e>mJI}9V2cAS@-N_SN;`{5&HRmM6#F8oZYnYw zVYKQ-NlqaSyZ*4bFFZ$d*q-lI(l)N;L?$(yy zgBa8~ZKw!Ht0qo2Gvz!^ASNT*mOyXy}rSr_Hz~k0|T6Pen_E9(&T&$mDq!bUc z`jqTTQi@!0*ioQ0di4Qbs`uuH@Mz(X&jn5h|Hm#n%=*H`V@i`paoVFI!5keoQfe z>e*!!m12wGeo>%S?<#0IVPkBsrdevzv`Uqyqqdm~mN)xF_$~kL0;FN{Jp3&l;?KX| z+Oz`#u3YbIJ`9dDe{%2;LWeg`x`nuP!SVu+5fzs>XN{OH6>K9K(=wpD&Zt-qu^%=wzpPSj0LzLENLvQ6x& zuYp5!m{TY3=Dvg7=+|pMRi_JZ6OPN_;h~z$*h1g0cFYO)Tdb_zZ-xNyDI_x5pLdy@ za~s876j3CKT-mW>V3j|U zCSs&d3daliL_Wof>EJYHm>)CnB0&)~Jnx;r-Z6Ze6?aWjm!?)vfzi~@4|d2$TvPtt>E#kG7oMzO)S$}TEF$WXlBSE5+z4}uzhjrPGF!SOfN?r; zZt4RgW^%oxU<1wg5$Vc=Izfdd$+H)asN6di*{GTGY%pF8zp54@gZ0DQ3O>Fd!D<|t7J{WWNTi%nazVgQ_S-f_^x@ux`6PS9$DQeUv z9Sr%u@U}^l1nqMGDf>!9Yj%uCno{TJh;?eBCJFCiTKPn6r4^zKG7AJvBRuIlq+TqN z!w!xHlK=sgllplC5`IJgZV_A{g8yNHpcO?j-O$NBR}^k=u^|Yd6KJ@zb$#UbzwQL{ z!t?&Tjh8=Q4FFR=6F+d5!>8Os# z$;>n?3?RI$R>5Vce9)p~TFfc+%U>SKlTIz!?;j;vNLPqKDLbFxJLy-tSx zv@1_Wq&I9AY-Sx`cGj$$poqyP#G3~eAaTl^@)r`kpqKZ<7=AYQCP>NTD3(iEwj1M9 z)Rhn6rGdOVBre~tE+iW#BR6@ic&no2pBd|(@RDb}3s-^r?eF*O@DSAt;^P+4DM>y1 z^Nx%!TU(kkMp6^$q@H`SAVkFz%;gI&G>Xi~Yo}M&*zm`7rJdiBa*qO`7v|zbFQ}mO zHb#{`P4<(TVNvw7Lry$>x$t_f^bAc2fsK}4pXy+*q#=h0Y57p-L`I@H$7i;~csfTM zN|I67xJczxM;CIunoqdIpGK)%F=iHAwS75rebn;h}?$hJE< ziJtV;>`sF$gBZLkoG&lNuEtpr^k0O3AneNc^?m5)@)XSkXrUW9HD*zP-=ZtwsUZc0 zXEAu2Z%&H#V=TPx**D;k34IxS$CfM{-_hH$9}U{O8_rUjGqBTF_``$D`vrv#49p$K z6gs6@yd1>b%VjtQ_BwTe)lhdnHEh{h{F`NDZ>&5!gkBE9_j8zt2_EkroC@pO56C{r zX>n`%2zz~sLtka?>aHJ-$_Es(H^QeKV^c)bqY?WW94yb2`}}(MAYg-fMiGI}r>)+% zkMqN3Ml7EubgRn9 zyRPY*!&vDZ7~33)ECneA@}O^~r7Xp@v^=A4doW4!ZeJSf#|b3P&hUhWN{W&Chie9O z_hF_tUVy{98!LmmJ;5=o&u9d-M|PkbIj0HKDDo|XH-Vgzznc$1!dcNkjNcTHf*Ch`+j;B0?{&8wq%}b%44$Wqk7CzYjFAbbl z9<%hk(J zL9O1-N);%kBcn=5*;h%X2>k9C<^KynEx*!V@Gq{DH-3GYTS9QPq}6hd@CNYN70T?8 z(yHhfuhmyEejsZ&G>Yl~jrDsTgH6#~11+OEk5*Rig6mKd0WnftFU>2F5n>=@V!`6s zS6?Y5*@cy&+}ui0cA+Hw)t+seiMPk((Qxv-l6r}{infJ7Af;@h(Uzx>@PuVl1(9(Q zgE_?{5}Btkn3D|fIO@W|NDpos(S?{&cUYrieDA`zA(R={~Ocb&Pl;pF%$ZVm`C$?CVJH=cFt-4W(FXzgs#^ zx8a-yEa9T}Db26#2)rGQ`jC-)U>*at=8XQUuM&G7{BfL!hwmXW{8|Foz{%4GObUum zCEyVCgs}$`QVE|)7r%c-Cu6tc=d*Ha_*9WrV;VoDk|FAL*jzYwzrT>FK!BMLa`;(IoAdu@*lJ|qf z93pEY$8xDLy#wppNuk4@L-G5G$4Dt@Wcvh&%Oh2zBcPQcNvR|s;o~a`6#W#S>`9GZ z_)d5*DIJSJ&#ollv+{li-ma$jX=A437#a-9G1KEesU!Z2v*NQgc^BWYKUK5qZk+w2 zQGt8oBQq`1)fOdNJ<+7js}g1VWTfqB8cyFhU-Lp_;bN->9L>}Dvh@{h)J~)4UZK;k z*y%L;C|I#>Y}=SxJyq2#K{QLsLwdE0T4Vb=Z*HeWn$RqDg4Hq5yl%vQOx?6KUz`8m zRyeq>AM!Q%AbAS9zDfCq$ zl6BjXgsh0NY_Sy|NfcY<5oIhv(ssZ)p+3a_HR`*=eQTN-2srO{M>sbZy<5873DkFg z&RRq_nE4)ib|ERlJzj7l5X2?J^QXI?!PBuoe(UeZf@u<_V8jA)POvFt#reqPqp#O) zjz3m|P{=oVeW8EQb1*HU!x;fXMND)>0I!2zf64k>oss+jqkZ;8a`OBLqj3@3o^Lgi z=P$Tl6qCq2*<<(=Znc|zGxA$NJ}f_7G*etrA)M}+;Q40Z(096K*Fa`TKEKVNqj&4e zN~KbY={6xLm^}K{sMeC091TQsOL?U7JZF(WLV!P^bfY)b2u0+LC2)Ke&44TsS@Y^A z$qH-N$vPU8ORMzsdb72WZs2KQxALi)ZTWa(0v;m2%+zQ}a#)5~i)Q>qE9P-jAkSa% ze(SMWw^)XBk=RuV*r2P_mZWFa4=`=*ulGDC`dM|L?L{E^*8mKI3(r3!Ucd9_$qc&OpC|F3jr15Nf} z(be*lv^T6l+R&8!A0YZlt_3ZisgtY6%hVAwZll><&K<^H~X$xX0w78YDv|om( z)+<`1*pgGrvDrAaISNjV&0fpS0g25hs*0QxX-F>&A|q9yoZOl`9>x9IL+0}+@`mE# z32?{sk!xXRnjr`_X5nyoRt`MhtZZi{waU(E-TQMBX*!X(kzh;@4gMiWE`!0L6e$B# z&JF7q{wW(T5^Ff00rlL_OnxBeNnJMAR-7f(0^;Uo8kkHyCzEN=Goe|7%HbeNi*Ph) zSw-pTMbfl1gAvWIxj<9CF1QXDp3M)=ufWd>&I6lY{T=-KZd!gxJ|M;k?OIN!jSml_ zYjG=8MRsoHPJ>O!r!^T{k#%LZ@tTpp9JFbbW`x8>wWb&em^CPbk5AOUS}{(Opejxi zW8DxrQH5ClFWx}}OYf%yQ_gSCm*r>8?Q;NSyt~(?pIYdp9~|^r-Hu6GV|TPG?p*v= zhVNoNKRKVMDVNCV-jTmj%l!b_B$MNIPZ{_ ztKECOOC!82i(4qy-IJ7@_NiX@Ox)rZc$dNjuGOHY$!V=;1BoX|Da1-A0cJtAM3V2ax=7%Zvi z7MpP0x+cD7a6V34wBO2<$p@*_7g43iqP%!P{@jA@ub-7IhzKlLc>FgC1b{N&hxyM9 z9MOq;S~$!5EXjTC_{$;e=qcY8Rc6jR3UkBu=av8e%@1J~LG=cJz6Zup48 zwtOp6z4GF=+%H^I_+{ta#Q|RPhy`CAs+_TqZb;s}b6W{onr-sSnn~vm(zW?VIGP5+ zR{&J*>n?zM*Y^{{D3I=ytI_h++ zVawbPy1te+llARbVV`4~k){0-{cn|d9L*_wurK3ehacvTUmOSpD!g;PHjP+|@9yil zU?qE+pdoV}?{vXMFeSV{e55iDa$=5=H4F~fHsT^dlVcv_G^PJm_-dCQ<{yNk-m?j# zxI~WU%<`-8M9VnGvzMm}aDT(14_W|H2Rh*;xvha2@Qe>y^)7kl&G6WtKJU_qqdey$ z2k5RxMDh`-{3yyFMNCByBY~nmV@kP)&To+U(*L`kckEHxBOv(0_rM1RX9i0C4V0Ft zvsER5qd;)Y|K9)MM9L*>N($~G(HG$n*rnv^Gms1}flDVm8G8u`EdI;>ONV@yeW216 z_HQ8=_uo*fK!MjxpoQRBYR_qp=-v=M?*>Z6*zNY|9Pj;Zsp<{it_KQsLA z{ovj3j^%2B5~N5D-FN&}`uq}L{SW;=;YuHtQszw$PX{wyQ}DmU>~)qTCL5#7(Tw8( zM#{&BfAsHgR(Q%7qB(-KgiGC@|LOv&J|vCoczo6Wd29-Vwdj=Q9N5#BZ_ZswI2oIq zjW9>D7eOGh<22`SFq;6d&6jJdFW0s&i50F$0Q&+VJ!#99yZ>0e{#D8li{n~y!TuL4g>{x z>UTdW)R=qQXQ#U&03Cuo9ZD zS5QMbM=3{A44L_Ck4VIJn|Tmq;<4RuqCF-)nEYZSG&({uK{`IYAS;XR)$<{ELcCcL zMxkE7wjf_cQYcZcP{{vSkNo$-vdne~7zI)!lmw;;BT8M-+0%YaPB)b{=0M267J?3W$Q%l z1>W4lK^t6?s^6Z}>6720h~yEVSUYJ_q0+OU;PZ!feO+5z+`50_&y0;53m@kj`%w{( zkl2XmhHL4t_-JG#&c&PX28fERJ67A>(t{#8o`X03qxD~FF5-$S`iU!&?BjZK9IdHd$gbf;%n8({cZiKGD2kbntO}OlUV^ zR}v~4M><7GP5pS2dL5ik37#r}-N z-9K`8?0wuB#oj`mPDP$VBELbTo(51YV#h0DW-MHx(8i6+HVVeu$-~7aLOBOa(t&pX z`3NVR6`#1TQkxp}o4yoHE-*gVinW5+mmQrebmqPIg+M7>=bj_GuE3*2t;^ikp0i>P zJQsddgVmpQdoGnMzGA?K0EWuU_@B|KX4~s5daH~7^ zN6dFA<0wf-(}T@-R*mCc6A0HgniJaZsHw`yQF@K%^a+oSX$TKSrzD*78kJKib!D$P`oJTYHTCx%O~NIu zK1JVqMZdZyof0dYbdc^AYg#GyROWqy>kTr9{~6PSFhAoT$!Wa3wCf1ODdaawI!5@` z7}FUKk4&{o_1T%oKE~Azaf!bs*>LR_z?l40tJMk~_1eTSoM`{5!Dt;>KX&2V}1z3631AQ$oRj`YkJaEFM(_4V|ca$4+WYg+(03JvD)up&W4)`{nawPTn@^*4nl z^l_}s(PR5@aSoauy|G@ob=;SA3QeTAu7~?l0MzQ@Y))widq1u^<&l)@JlWYdut!T> zU$m6o9HGs7X4^&2Rb;$pLLS~XsW*4DOJ}<}9;)*n)6Y}e%1*P)yG#Q6!Zp5q8L;GS#mm=&C4rM22bwY=@ zbCYJvIoL#Ea%^7;Z6o`#A+~|N8p!jf+ofHT?S23yVh3~gR*<-rpqu>K*Rn4((Mt5K zBaJn5ZvUHRwe@UmCX8wy*m3(nPxioC2ilQ)v;BG4jvdU=l-orZ**ek)!*ZIFHXn<~ zMy?qdY-uVr zBbH1lL=D%0C%0I~G%f2f!&o{YYj6rqMmm)rX&xTsy*=LfON<_@7@~#;p%X6;Yi*`c z{XMBB^tCGoaitq8vC^wV7IWJET*25|(T;L0ZW&~KVI`v{gq3R@O(aL8di^;PK7ehY z-_|m)Q%^@-y9RVSRl~JV?X^K2(yG5AJfV-HF_GLh#-kzE26YiVy0^;pO?3~pZ!fpI zr=F%=+Bgsbh1IrDdS6!KtHF?2!>fGfwVgPNtaY##sAF1H|3Y-at2p*ojvi#k%~~1R zd=TOpb$)UC2r+ga#Khfjr02lpGBQbvJ6~H)GPqT+4cpN%ZU4(nJJ=!??b>-`wClkv zSubmqg2d(ya!l0zb6MrZGT)o9a@i*3E$^nGTvMQo&tDl&<~!Iw2W<2v8*knog( zI_O%eUH4+%dJ#v}k7vA(dL<7HqWx=_7eRc^E{nM^Cs#-Y(|a=0K?m5o6|}I|u&Ozz z?A)M+bLlIZI(ZFxlSPqbvW|>>dnsmYTWn+4-ILw)QYVdt16Kc}UsE2FnU)_dgQRPd z78J7vRol_-EOe)BHz=r1KeWNK%~z@TK(#y{@_o^n;XXNh;S02Oh#!xCNuHrByaPJQ zj&{H!k6Xz4>M!bzE4^{e`V$LZ$MB%Dv?7PgZWx_gus5QG^uNxTA8({{X^LjD`2=~8 zK?mCXd5O(12F++jGn%K(9$C95^$EssyI!r=?XU2MvxH_ox+6a7#iOen4PwkUhjKWR zXht)dTW1e)V%*s!ZeiV6=bQFcU+$NGjuaeL#gA3$I!w2i?@i^8sjtV;-&F@!zz(Gm zdX@Yiw5xbB*gH*^Yl0#7hwK=>Pvk@83nMz>FWPGRq9|QKCHL0Pc0d>qil$#_Fk4F z#k;IMYunk+7HM9G&Z1~XThi+Jr6*AAU1oz6Adbw!q}Ct9UmfGrOSE$#mw z^ZJCY~gBl=xKnulLDfp9gR34pFwJ7w*WE^z>Tn{qB<~S!0>JJ2~-8cGeYf z1aPFMFWb)a|6rxGoCdc07Sc^uIrF(4Y4Cyz8?V|C)#9}P)+J$ijA;jA!PXRUKEsFd`F)1tV?Y!8+nz6-A5u_lS#P`RLgGSzzw8=ja^zwo9X%SREQuNxYh-h*TT9H+|@kFWtl4{rP;*fEf-Xj zlHBe^C3JXz z7G-6QIRCDBiE=5^-Q+EbB;{NxT-S;g(}sh47;9NEDp@LE0fCm9@o>d%%GeT^ZUxP~ zZ+qujsr{l^;?&Ligo;e^$gtf^EK%$r6}iR`&r(O-$V7#1rniWgL{_x0DXAqk5DvF% z0rxahe8kg2N;tN2@<#4$9_3OdZK7gqV65fc9;eheM5F#dLX?$Q8C5vSO{rK!v@zrD zS>!8$en}?U)W4y{s4<@kZ8~TJALCkDJXA_46Dx_P;pSaLeStw2J(Qn$K9fxHR=GRI z0NL-I0nUpzclyptG;@Z|i+0l-IM3DM`51S_c5#c0`6@pEL63+Q+#=^fpDT@m zf96Xy1>QtbS7GKAm%Z}YcRp$8Eo0%(I-3e9og%wSlpKnLZn@`A)mrmeSM~!!1oY1Fc8B z;>L0kKpwtJVG+$!0r-GhL=8OTZ@AE&WhEHv%u2kN;c}Bi?d^G*c?x)8Eh?kaGpiJj zAY9rO)ypyIH7!RxHNLb8LdaU=*{fCt8c*XfUP5HQImup95=~B)wvHmZ_>APRm6AM> z61S+HxvtG@%&!vqDU~p7r4{42ymDSzHKPZkl&znyV>)M6IJ=kYQeCe|t701yp^Ahn zNvG0UVRwnOf0QKvsR6=*mT|(QmYXrv z0Vx?-%a^0bVJiiD&id-3-5}NT{52Fnk0$6;j;&(^sW(~U;snZlU(skA1;HtikoFu0 zzQhRRL#4~L@)CS_i$V}jl!nr;CZnk)vb#jdp-AYKd;S!ym-XqX9JZE@gc?x)er;wNC zxxkBgjt^D<3WB+11mK^1*fKLaA%C{rGNR|-x-vj_!F(>(MN3QDwROPsxLN1gnAd5l zgW1Dz@EjV9KkWV+smi#tBAqppZ>|N;hFS*aJ7b#$CI~+1UWC({`EjpnPh}DZ8?>v| zU=%{HR%UFI7guq$-nkUN?~n_Iy$!RFDR!VlGZKBbkBNnLTjRvMWsR33d+Sq`OLij1 z_v3tw5znhMco*z!v*)M)+zaU5>ZAGqFYKImO8<`pf2NMDQXY%LE+BR51=z(iR!O3Jd@N{@WAk#>*1=|Gu3; ze2n+%npp80z3MTc&tSdGEFrZU2)Wk8TDLv#I<(OC3uOS$L%A*$%CYx zk0>qYc5;vSrvj4!dTgVabic;mazckNmR6Griu}(_B{chY@u-Y0Rtq zCf|jQQQi77b1m^rLk~B?ad6+gY^0ZzN1O>z*M9z3b`c==o1UFZJQNIzzoVdt(+$MT z{)P4M4NQl>gO>rJ9%go%!mmdOT1Kp~QFu&kI82R9G?IA09&cEJrj96;hUN2;sPuI+ zD~>suPaLk9wB9+FG}*1-Jau8cRfN!^==gDk?%+h%5id<-n>j>0!04S9hKxRl%xB)C z=cO`_faKs-;mr2Ah$wg7k%UL5Lr@(s3smJ)jG=x|M!K-GbzAfZ}n%k+A zfJIMlgwTtS?s>*jVA~a(uMs5`xlf=yi^>ENTZ`V$2}}=3r4p(HxnH3+5+4K5t0vk> zgzID2!^i&>85KcUfL}f(DrN$*6l0RmCXVnd#*mq4$zuxgN{Fb+iSw#g5?$|k!al*L zAwQ4y>AB;$BXcvv5Z9*70U1%3ufOoc{wg?XckfH>)$d`4Z z*`Y@H>c2N1KUa1W{-DK6)RcsJ9zV4s`3Ai?yGROh4k9Ah+F#42Wy$PxmuMFc}7u=QeO<(aQCh25q zz9vvtR5=Wud)MH<lH5UM{TO>#kn51nZ`iXIR)nR z`XrXg)=2B`=Wo!%s1MG5LV%Oc*o4;kr;T{j2w?5UoFR40NjVrRcPrhZf+~7Xt?)81NnJ#y&LALb$-gPLxtD zij#ACt*^EXrEO5VSW%3fScQGoEm&mdSZInn&~LzP*hdS&Lup)#7J_0h$M>O)paF|S zZ_*)++o#Yp)42%vsS)`=iZ&}Yhi$D@A;)5Y#rp!RveTEfQ#5BS<=Rf(1)`yH)&7B2 zNg<%uz=XqBxF9~_QvT{d4nlwy9ssYsE14q%J;O0{4|4)zQ#pxS<2VbK8ZhC$6rwhprJ$3!$ldLn%gZs&|y)&}>u0lIL zOvvZ;bOKqSsO}7lDp8SSNK_myCPi_q5+|`SV|&C|S<6N;d%!4B&_a%jU!N9(NYN#M z$Rr#M8OL$n1>+N=AR?k~dH_|yL_+uHt@uy;1<;WZyGQgipB zx0l!7ie%LU5p+N|I~Lfp%Xz9D!#LIGM&}42(`D1-&AFJ z+}g$2)wJ8Gkr4kig@d^81P@V&j|51FXh@X=m+ecIBDE@rwzuC&wCH_^DWRlEj{z;C z7hGZC;1LjGR)d6$(n(%u=opw-o#uzr83qt89zFqKtl0e}l$eB6Rva>N*>cLH=&TsZ z&Kci%6Z*%*E{LV2qh|>(xqFEUVZwL8ZcPB z+=gudL!dA?qS1y?XbcutWLJDg=pP4KW?}z2M4{5?3?_@s;qv$bVNqpUBOxi(*c81| z)EyaFIe7)eVr;9?6%|!Abq!4|Z5>@beFH-yV-r*NJ@C*YkJDA>xF?=^=DA<})_CW< z@Vl4(@MjH1t9Qje{`IORH)?E^uk;!E2;kjPr8;9Ty@k*_?|sNri=Wi0tIcB#8Z~M5 zv34)D*6Ou(9XfTfvgvMGmj2eGSD$_ZO&?({sTp0bV~{zuwLNfhOTilFgWYvAoeyTY zWxywHHjgAmaE~_k9ThzzCN|EbcphB|iF%TfQ&Q8?^=4#d>CVoX8OqJeABRr0x(D>R zUz>KB>#cbT=TywCoL5!NTT|;vo&Qd0Xl!aWEUBfnt-YhOOR@g$9)stae4c@Yy^9w2 z^)FdEux$B?!Ii7{_*bu4JETOZ*TcSFTMQAtFQg>Pk;W)>udes&r3mjyF zK8fR)SfK$jSn!DmV^nJlLWUG}nf%9T)f}SV>i^1Uyv&2t`?;+uVWNH=aQ?5Wm$iL( zK&QQHOSTeqPFNfkB zy-sfME5PcR&dL@>JTvYdMB#tcE?C$U3-`YOmqF~ni6)d~tv53;Mr#Qe;+u?%d1p9C z*nU3oplNYdrXugaDj&A2v;rbz8n7`dNaEjqyG=b{tGYZf-cFprg9I(C%ISbleU`@? z@=a>r_EA28{uF0dLy$#Hn#UiD>Ur2R^D2qWs%%~*Pua`BHlno3yY}i6yNJ!gU-%-8 zIzqDL1H4`ed(t{!v2y7_PzKicQ zpYIC5*SGJ@4*W`K$>JAnVTN2*GW}FuzDm{4(CeG9=Lsv(|J7@rF!CHrQ~a?*Y>N~s z#XiN??RL5B5&UcPgsDoyOIQX?$|m1PYm|I$e*?fIK?4T@6c{WpP{2We1Ox#}O#uKx zFauy~lwy5R1?6#K>twQp%oVCbCsJWT0|x>Wn2zmSL4ee5M+FNwBCT-f>{ySKCwlA@c;MC6NyA9bW`52aKOYDb_}_UGr!GNn&T+5OKX7! z^Lc%98+3Xy+^ z68aY7-i4+<=MeIH9LO=US+2gZr{604#E_@(dwFUi$=EI!bRCY|h06~c)I~;Ba3|;J z(hpfXmwrZvAk{6NB&$~fg1`_oC=8y4y!s8sHR!HEV30?kZvufpMHm_k`XLY)uARhd z27wG?_gw{{dP2zr;~~Bf#<;TYh8U{7n5ni;b?Rg+6Zn z9Wy1JZeHt?bnb|5ZTk)Sau-QH6)dB6Qb!?;cma*&;Ru#jS%nJTnf3R-L7Y_*7HSKrZh7s%(!R`;*+^cSULDk6!t2(d#*J&19uS zH}~1=g4~Re4jC!8DvYpz`)lPH5@)4;!($+M2KX;hQ^YUCN6(CkYu!r>W9g4P^>ue;`sFOr;G-4#L2~?qFCyu z{kYhm!+ZTb;hB1i4nrVMN{1YIvc4X~M~YBnTBrl2Qo1QX1f9=IiAo2G6mnV;Kw)ri z1&p>`dX!g0aiCWWxgt}qBv!^!>z@z4@bN}YLgdiai<&QFD6o21UN0W2K6m@}K&;O_NV}Nl9fKLl SL4NwXbFCiZ4*xG3;0*vgFbe1Z literal 0 HcmV?d00001 diff --git a/internal/static/img/background.svg b/internal/static/img/background.svg new file mode 100644 index 0000000..fec0bd1 --- /dev/null +++ b/internal/static/img/background.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/static/img/favicon.ico b/internal/static/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4df30bfedac76030e187377589832346b680bf46 GIT binary patch literal 15086 zcmeHOX>3(R6uuSr4UCE#N=1o#L@^o_X;DPpz3-tU7MB=hQA7}W-zx}448CwXhEp+B|cbw}T$0;l{`A&|r9=uMVEFW>S<4l3TrI0}tXD|}^>UXgw z@!!}3u?J!g#2$z}5PKl@Ku9`lF|6W46uEjPKp4OeMJJZ8^z#zVw%G5iN#TVdp-|OZ9WlW^aq10+E0K# zxH^i>_kyvMLpxSS`R)0f@x%;`J=jD4`fhk1@|(zm|6jJ)R6AX8YntHSDqzLl|lCLemfg`QVH*D36t zs4I1=Z?LjW(emOA$73#g*VL_lzZq97J=9!={`)v&cjd@$Ds@HgTw9rhxW>wjbH&-V zyu4f<1z){s%OZ4N#tk2}a?PO_|F1^sR}eSyb&7}gSzas=9ne2NDeeYejXlt6`VMrb zlJ&$6u0>Xs6d0eZ=W+>*az=#I%MXili_n^iJHr8S#v1;i@FLc-oZ1r&-jTg zw}QWqw&@SGMsl6>#82cQ&9i+bb7#%@!0@}_&(iB@_6lJ0!PE0$QcP_Qo_uBeRB)HE z`&ZP@)C2x!rmVhC*}R_&Zj9Pe<3_jZAX&6+)!2U3Cq?C3>KQ&7U@+B=POLpHsuz9Z z3yU|G=Js`9oR}*U$*-N@Z9grlyQQAtm(H-W*VvkA-+}4p`^o3JNc&7?Y^XWIVq%{) zYm#JNCf~m!JO^|8gQ&0e2=K+UmcpE?2k+o{gP-p{MEWg~U(C6}*6|Y}figX|Ic}5U z7v$AA0TtM{bg{CenWS2Csdsaf-`KMnj~FZF-Oo#VVOdjsk3NU~nMlgtpt=uZk1}fD zrqgq6VVpmJFM@zZl*ixnj1)8Pr7AImo9Bewpvu&{FNwdx&s>#nA-) zprKuUpK)Epd%@b-u&nKJYQNx$!5Hr!0?YD4q$*-WT4C2vqT@UUo*I{2Q`-)4ewNJl zm>RQK3upNKvxVqqJ%If?)2@*FwEVQTt#r;lEIR)jK-v9miza_-B_*;0=BNqqx0;9O zcg#iWIM3y;t^I0ntc*(p7jZssKNPX$*aNW#Vh_X~h&>Q{AojqK(zBs zdb72NW}$hNJF?nNfu?na%GxE0FKe~DJkV9=slT=T!h2eUNX1C?_q1@AEB?hEh&>Q{ zptU`~m@Z;TL(*&+2<+U_LtoD1xmG!wu@H+gx zhi3_l)xtKd}a(b{2ub#VdDin>sT)QM!A2^u);5OIpxH1tm5kY*_;E)==lQAx`1;K z$`;@jvki%l^#| z7$4xj2OGh|8NTubV!uHp`7GDxrRYzdSO^)$PZ9qO7g&E#CcmF7FuHb{ z80RABWSja^52?!I&W0)H8O$PUgSe3g{w=)1e$Y?NMV=VOGtkDy!01HwOfzmbqb+N4 zi1Nb~^!QBPu>}(D3nfQ2T!163*-(-wa;nQOizH0fo2>QPvHk>~WhW@_w$;?G% zDbdULVJ>7_d@dTBs=g31~db}nj-HG$)UV~KOHO?W4-!56rf5_+67 z;G@rhaVLFYF|{V;`J@@6)*n1eeyBBV=IZA86z64*Tdoti<#9gtK + + \ No newline at end of file diff --git a/internal/static/index.html b/internal/static/index.html new file mode 100644 index 0000000..f7d93e1 --- /dev/null +++ b/internal/static/index.html @@ -0,0 +1,109 @@ + + + + + + + n8n shortlinks + + + + +

This application requires JavaScript.

+ + +
+

n8n shortlinks

+

Create shortlinks to your n8n workflows

+ +
+ +
+
+ +
Malformed JSON!
+
+
+ + +
+
+ +
+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+ +
+
+
+
+
+ + + +
+ GitHub + + + + + + diff --git a/internal/static/js/challenge.js b/internal/static/js/challenge.js new file mode 100644 index 0000000..223da68 --- /dev/null +++ b/internal/static/js/challenge.js @@ -0,0 +1,46 @@ +const modal = document.getElementById("challenge-modal"); + +modal.style.display = "flex"; +void modal.offsetWidth; +modal.classList.add("show"); + +const base64 = btoa; + +const passwordInput = document.getElementById("required-password-input"); + +passwordInput.focus(); + +passwordInput.addEventListener("keypress", async (event) => { + if (event.key === "Enter") { + event.preventDefault(); + + const slug = window.location.pathname.split("/").pop(); + const plaintextPassword = passwordInput.value; + + const response = await fetch(`/${slug}`, { + method: "GET", + headers: { Authorization: `Basic ${base64(plaintextPassword)}` }, + }); + + if (response.status === 200) { + const data = await response.json(); + + if (data.url) { + window.location.href = data.url; + } else { + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + window.open(url, "_blank"); + URL.revokeObjectURL(url); + } + return; + } + + if (response.status === 401) { + document.querySelector(".invalid-password").style.display = "block"; + document.querySelector(".password-tip").style.display = "none"; + return; + } + } +}); diff --git a/internal/static/js/confetti.js b/internal/static/js/confetti.js new file mode 100644 index 0000000..4c13eb2 --- /dev/null +++ b/internal/static/js/confetti.js @@ -0,0 +1,10 @@ +// From https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js + +/** + * Minified by jsDelivr using Terser v5.19.2. + * Original file: /npm/canvas-confetti@1.9.3/dist/confetti.browser.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +!function(t,e){!function t(e,a,n,r){var o=!!(e.Worker&&e.Blob&&e.Promise&&e.OffscreenCanvas&&e.OffscreenCanvasRenderingContext2D&&e.HTMLCanvasElement&&e.HTMLCanvasElement.prototype.transferControlToOffscreen&&e.URL&&e.URL.createObjectURL),i="function"==typeof Path2D&&"function"==typeof DOMMatrix,l=function(){if(!e.OffscreenCanvas)return!1;var t=new OffscreenCanvas(1,1),a=t.getContext("2d");a.fillRect(0,0,1,1);var n=t.transferToImageBitmap();try{a.createPattern(n,"no-repeat")}catch(t){return!1}return!0}();function s(){}function c(t){var n=a.exports.Promise,r=void 0!==n?n:e.Promise;return"function"==typeof r?new r(t):(t(s,s),null)}var h,f,u,d,m,g,p,b,M,v,y,w=(h=l,f=new Map,{transform:function(t){if(h)return t;if(f.has(t))return f.get(t);var e=new OffscreenCanvas(t.width,t.height);return e.getContext("2d").drawImage(t,0,0),f.set(t,e),e},clear:function(){f.clear()}}),x=(m=Math.floor(1e3/60),g={},p=0,"function"==typeof requestAnimationFrame&&"function"==typeof cancelAnimationFrame?(u=function(t){var e=Math.random();return g[e]=requestAnimationFrame((function a(n){p===n||p+m-1 { + resetForm(); + + workflowInput.addEventListener("input", () => { + workflowErrorMsg.style.visibility = "hidden"; + updateShortenButtonState(); + }); + + vanityUrlInput.addEventListener("input", () => { + vanityUrlErrorMsg.style.visibility = "hidden"; + }); + + passwordInput.addEventListener("input", () => { + passwordErrorMsg.style.visibility = "hidden"; + }); +}); + +function updateShortenButtonState() { + const isDisabled = workflowInput.value.trim() === ""; + + shortenButton.disabled = isDisabled; + + if (isDisabled) { + shortenButton.setAttribute( + "title", + "Please enter a workflow or URL to shorten" + ); + } else { + shortenButton.removeAttribute("title"); + } +} + +function toggleVanityUrl() { + vanityUrlInput.classList.toggle("hidden"); + if (vanityUrlInput.classList.contains("hidden")) { + vanityUrlInput.value = ""; + vanityUrlErrorMsg.textContent = ""; + } +} + +function togglePassword() { + passwordInput.classList.toggle("hidden"); + if (passwordInput.classList.contains("hidden")) { + passwordInput.value = ""; + passwordErrorMsg.textContent = ""; + } +} + +function throwConfetti() { + confetti({ + particleCount: 400, + spread: 90, + origin: { y: 0.6 }, + zIndex: 2, + }); + + const duration = 0.9 * 1000; + const end = Date.now() + duration; + + (function frame() { + confetti({ + particleCount: 7, + angle: 60, + spread: 55, + origin: { x: 0 }, + zIndex: 2, + }); + confetti({ + particleCount: 7, + angle: 120, + spread: 55, + origin: { x: 1 }, + zIndex: 2, + }); + + if (Date.now() < end) requestAnimationFrame(frame); + })(); +} + +async function handleSubmit(event) { + event.preventDefault(); + + const form = event.target; + const formData = new FormData(form); + + const jsonData = {}; + formData.forEach((value, key) => { + if (value !== "") jsonData[key] = value; + }); + + let kind = ""; + + if (isUrl(jsonData.content)) { + kind = "url"; + } else if (isJson(jsonData.content)) { + kind = "workflow"; + } else { + workflowErrorMsg.textContent = "Malformed content!"; + workflowErrorMsg.style.visibility = "visible"; + return; + } + + if (jsonData.password?.length < 8) { + passwordErrorMsg.textContent = "Too short! Min 8 chars"; + passwordErrorMsg.style.visibility = "visible"; + return; + } + + const response = await fetch(form.action, { + method: form.method, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(jsonData), + }); + + const jsonResponse = await response.json(); + + if (response.ok) { + // console.log("Success:", jsonResponse); // @TODO: Remove + showModal(jsonResponse.data.slug, kind, jsonData.password !== undefined); + throwConfetti(); + return; + } + + // console.error("Error:", jsonResponse.error); // @TODO: Remove + + const errorCode = jsonResponse.error.code; + const errorMsg = ERROR_CODES_TO_MESSAGES[errorCode]; + + if (errorCode.startsWith("SLUG") && errorMsg !== undefined) { + vanityUrlErrorMsg.textContent = errorMsg; + vanityUrlErrorMsg.style.visibility = "visible"; + return; + } + + if ( + (errorCode.startsWith("CONTENT") || errorCode.startsWith("PAYLOAD")) && + errorMsg !== undefined + ) { + workflowErrorMsg.textContent = errorMsg; + workflowErrorMsg.style.visibility = "visible"; + return; + } +} + +const isUrl = (str) => { + try { + new URL(str); + return true; + } catch (_) { + return false; + } +}; + +const isJson = (str) => { + try { + JSON.parse(str); + return true; + } catch (e) { + return false; + } +}; + +function showModal(shortlink, kind, hasPassword = false) { + const modal = document.getElementById("success-modal"); + const shortlinkText = document.getElementById("shortlinkText"); + const successMessage = document.getElementById("success-message"); + const successTip = document.getElementById("success-tip"); + + if (kind === "url" && !hasPassword) { + successMessage.textContent = "Your shortlink has been created."; + successTip.innerHTML = "This will permanently redirect to your URL."; + } else if (kind === "workflow" && !hasPassword) { + successMessage.textContent = + "This shortlink will serve your workflow JSON."; + successTip.innerHTML = "Append /view to display on canvas."; + } else if (kind === "url" && hasPassword) { + successMessage.textContent = + "Your password-protected shortlink has been created."; + successTip.innerHTML = "Visiting this URL will require your password."; + } else if (kind === "workflow" && hasPassword) { + successMessage.textContent = + "Your password-protected shortlink has been created."; + successTip.innerHTML = + "Accessing this workflow will require your password."; + } + + shortlinkText.textContent = `https://n8n.to/${shortlink}`; + + shortlinkText.addEventListener("click", copyToClipboard); + + modal.style.display = "flex"; + // Trigger reflow + void modal.offsetWidth; + modal.classList.add("show"); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") closeModal(); + }); +} + +function copyToClipboard(event) { + const shortlink = event.target.textContent; + navigator.clipboard + .writeText(shortlink) + .then(() => { + const originalText = event.target.textContent; + const originalBgColor = event.target.style.backgroundColor; + + event.target.textContent = "Copied to clipboard!"; + event.target.style.backgroundColor = "#10b981"; + + setTimeout(() => { + event.target.textContent = originalText; + event.target.style.backgroundColor = originalBgColor; + }, 1500); + }) + .catch((err) => { + console.error("Failed to copy text: ", err); + }); +} + +function closeModal() { + const modal = document.getElementById("success-modal"); + modal.classList.remove("show"); + setTimeout(() => { + modal.style.display = "none"; + }, 300); + + resetForm(); +} + +document + .getElementById("success-modal") + .addEventListener("click", function (event) { + if (event.target === this) closeModal(); + }); diff --git a/internal/static/styles/404.css b/internal/static/styles/404.css new file mode 100644 index 0000000..48bb52d --- /dev/null +++ b/internal/static/styles/404.css @@ -0,0 +1,8 @@ +h1.not-found { + font-size: 6.5em; +} + +.bashful { + color: #10b981; + display: inline-block; +} \ No newline at end of file diff --git a/internal/static/styles/challenge.css b/internal/static/styles/challenge.css new file mode 100644 index 0000000..901ffec --- /dev/null +++ b/internal/static/styles/challenge.css @@ -0,0 +1,76 @@ +.modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + justify-content: center; + align-items: center; + z-index: 2; + opacity: 0; + transition: opacity 0.3s ease; +} + +.modal-overlay.show { + opacity: 1; +} + +.modal-content { + background-color: white; + padding: 2em 2em 2em 2em; + border-radius: 8px; + text-align: center; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transform: translateY(-20px); + transition: transform 0.3s ease; +} + +.modal-overlay.show .modal-content { + transform: translateY(0); +} + +.modal-content h2 { + margin-bottom: 0.5em; + color: rgb(16, 19, 46); +} + +/* password */ + +input[type="password"] { + margin-top: 0.5em; + margin-left: 0.4em; + border: 2px solid #e0e0e0; + border-radius: 8px; + width: 13em; + padding: 6px; + resize: none; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + color: rgb(51, 51, 51); + font-family: monospace; + transition: opacity 0.2s ease, transform 0.2s ease; + opacity: 1; + transform: translateY(0); +} + +.password-input-container { + display: flex; + flex-direction: column; + align-items: center; + /* margin-bottom: 1em; */ +} + +.password-tip { + margin-top: 1.3em; + font-size: 0.9em; + color: rgb(16, 19, 46); +} + +.invalid-password { + margin-top: 1.3em; + font-size: 0.9em; + color: red; + display: none; +} \ No newline at end of file diff --git a/internal/static/styles/index.css b/internal/static/styles/index.css new file mode 100644 index 0000000..d70181f --- /dev/null +++ b/internal/static/styles/index.css @@ -0,0 +1,330 @@ +/* Reset */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* Fonts */ + +@font-face { + font-family: "Lexend"; + src: url("/static/fonts/Lexend-Regular.woff2") format("woff2"), + url("/static/fonts/Lexend-Regular.ttf") format("truetype"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Lexend"; + src: url("/static/fonts/Lexend-Bold.woff2") format("woff2"), + url("/static/fonts/Lexend-Bold.ttf") format("truetype"); + font-weight: bold; + font-style: normal; + font-display: swap; +} + +/* Base layout */ + +body { + font-family: "Lexend", sans-serif; + background-color: rgb(16, 19, 46); + background-image: url("/static/img/background.svg"); + background-size: 75%; + background-repeat: no-repeat; + background-position: right; +} + +.overlay { + display: flex; + flex-direction: column; + justify-content: center; + width: 45%; + height: 100vh; + background-color: white; + padding-left: 50px; + clip-path: ellipse(100% 100% at 0% 50%); + /* padding-bottom: 50px; */ +} + +/* header and subtitle */ + +h1 { + font-size: 2.5em; +} + +.subtitle { + font-size: 1.2em; + margin-top: 0.3em; + margin-bottom: 2em; +} + +/* form: main section */ + +.content-and-submit { + display: flex; + flex-direction: row; +} + +.main-section textarea { + margin: 0.8em 1em 0.8em 0; + width: 70%; + height: 82px; + border: 2px solid #e0e0e0; + border-radius: 8px; + font-size: 1.1em; + padding: 12px; + resize: none; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-bottom: 1.1em; + overflow-y: scroll; + scrollbar-width: thin; + scrollbar-color: #c1c1c1 transparent; +} + +.main-section textarea:focus { + border-color: #3498db; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2); + outline: none; +} + +.main-section textarea::placeholder { + color: #999; +} + +.main-section input[type="submit"] { + width: 6em; + padding: 4px; + margin: 0.8em 0.4em 0.9em 0.4em; + background-color: #10b981; + color: white; + font-weight: 700; + border: none; + border-radius: 6px; + font-size: 1em; + cursor: pointer; + transition: background-color 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: background-color 0.3s ease, opacity 0.3s ease; +} + +.main-section input[type="submit"]:hover:not(:disabled) { + background-color: #059669; +} + +.main-section input[type="submit"]:disabled { + background-color: #b0b0b0; + cursor: not-allowed; + opacity: 0.7; +} + +.main-section input[type="submit"]:focus:not(:disabled) { + outline: none; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.5); +} + +.main-section input[type="submit"]:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.5); +} + +.label-and-error-message { + width: 70%; + display: flex; + flex-direction: row; + justify-content: space-between; +} + +#error-message-workflow { + margin-top: 0.1em; + color: red; + font-size: 0.9em; + visibility: hidden; +} + +/* form: optional settings */ + +.settings-section { + display: flex; + flex-direction: row; +} + +.setting { + display: flex; + flex-direction: column; + margin-bottom: 0.5em; +} + +.setting label { + font-size: 0.9em; +} + +.setting .checkbox-and-label { + width: 9em; + height: 32px; + display: flex; + align-items: center; + margin-right: 5em; + margin-bottom: 0.2em; +} + +.setting input[type="checkbox"] { + margin-right: 0.6em; + margin-left: 0.8em; +} + +.setting input[type="text"], +.setting input[type="password"] { + border: 2px solid #e0e0e0; + margin-left: 0.4em; + border-radius: 8px; + width: 13em; + padding: 6px; + resize: none; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + color: rgb(51, 51, 51); + font-family: monospace; + transition: opacity 0.2s ease, transform 0.2s ease; + opacity: 1; + transform: translateY(0); +} + +.setting input[type="text"].hidden, +.setting input[type="password"].hidden { + opacity: 0; + transform: translateY(-10px); + pointer-events: none; +} + +.setting input::placeholder { + color: #999; +} + +.error-message { + color: red; + font-size: 0.9em; + margin-top: 0.6em; + margin-left: 0.4em; + visibility: hidden; + height: 1em; +} + +/* modal */ + +.modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + justify-content: center; + align-items: center; + z-index: 2; + opacity: 0; + transition: opacity 0.3s ease; +} + +.modal-overlay.show { + opacity: 1; +} + +.modal-content { + background-color: white; + padding: 2em 2em 1em 2em; + border-radius: 8px; + text-align: center; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transform: translateY(-20px); + transition: transform 0.3s ease; +} + +.modal-overlay.show .modal-content { + transform: translateY(0); +} + +.modal-content h2 { + margin-bottom: 0.5em; + color: #10b981; +} + +.modal-content p { + margin-bottom: 1em; +} + +.modal-content button { + background-color: #10b981; + color: white; + border: none; + padding: 0.5em 1em; + border-radius: 4px; + cursor: pointer; + font-size: 1em; +} + +.modal-content .shortlink-container { + margin: 1em 0 1.5em 0; +} + +.modal-content .shortlink { + display: inline-block; + background-color: rgb(16, 19, 46); + color: white; + font-weight: 700; + padding: 0.5em 1em; + border-radius: 4px; + max-width: 100%; + box-sizing: border-box; + cursor: pointer; + transition: background-color 0.3s ease, transform 0.3s ease; +} + +.modal-content .shortlink:hover { + background-color: rgb(26, 29, 56); +} + +.modal-content .shortlink:active { + transform: scale(0.95); +} + +.modal-content button:hover { + background-color: #059669; +} + +.modal-content code { + font-family: monospace; + background-color: rgb(16, 19, 46); + color: white; + padding: 0.25em 0.5em; + border-radius: 4px; + font-size: 0.85em; + display: inline-block; + vertical-align: baseline; + position: relative; + top: -1.5px; +} + +.modal-content #success-tip { + margin-bottom: 1em; +} + +/* github icon */ + +.github-icon { + color: white; + position: fixed; + bottom: 20px; + right: 20px; + z-index: 2; + opacity: 0.8; +} + +.github-icon:hover { + opacity: 1; + cursor: pointer; +} diff --git a/internal/static/swagger.html b/internal/static/swagger.html new file mode 100644 index 0000000..e643f29 --- /dev/null +++ b/internal/static/swagger.html @@ -0,0 +1,49 @@ + + + + + Shortlink API - Swagger UI + + + + + +
+ + + + + diff --git a/openapi.yml b/openapi.yml new file mode 100644 index 0000000..fea4027 --- /dev/null +++ b/openapi.yml @@ -0,0 +1,205 @@ +openapi: "3.0.0" +info: + title: Shortlink API + description: API for creating and visiting shortlinks for n8n workflows and URLs + version: 1.0.0 +servers: + - url: https://n8n.to +paths: + /shortlink: + post: + summary: Create a new shortlink + description: Creates a new shortlink for an n8n workflow or URL + operationId: createShortlink + tags: + - Shortlinks + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShortlinkCreationRequest' + responses: + '201': + description: Shortlink created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ShortlinkCreationResponse' + '400': + $ref: '#/components/responses/BadRequest' + '500': + $ref: '#/components/responses/InternalServerError' + /{slug}: + get: + summary: Resolve a shortlink + description: Returns a workflow JSON and redirects to a URL. Basic auth required for password-protected shortlinks. + operationId: resolveShortlink + tags: + - Shortlinks + parameters: + - name: slug + in: path + required: true + schema: + type: string + - name: Authorization + in: header + description: Base64-encoded password for protected shortlinks (Basic Auth) + schema: + type: string + responses: + '200': + description: Successful response for workflow shortlink + content: + application/json: + schema: + type: object + '301': + description: Redirect for URL shortlink + headers: + Location: + schema: + type: string + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /spec: + get: + summary: OpenAPI specification + description: Returns the OpenAPI specification in YAML format. + operationId: getApiSpec + tags: + - System + responses: + '200': + description: Successful response + content: + application/yaml: + schema: + type: string + /health: + get: + summary: Get API health status + description: Returns the current API health status. + operationId: getHealth + tags: + - System + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + environment: + type: string + enum: [development, production, testing] + example: production + version: + type: string + example: f0f13a2 + description: Git commit SHA + /metrics: + get: + summary: Get API metrics + description: Returns Prometheus-formatted metrics about API performance. + operationId: getMetrics + tags: + - System + responses: + '200': + description: Successful response + content: + text/plain: + schema: + type: string + +components: + schemas: + ShortlinkCreationRequest: + type: object + required: + - content + properties: + content: + type: string + description: Workflow JSON or URL to shorten + slug: + type: string + description: Custom slug for the shortlink (optional). If not provided, a random slug will be generated. + password: + type: string + description: Password to protect the shortlink with (optional) + ShortlinkCreationResponse: + type: object + properties: + slug: + type: string + description: Generated or custom slug for the shortlink + kind: + type: string + enum: [url, workflow] + description: Kind of content that was shortened + content: + type: string + description: Workflow JSON or URL that was shortened + creatorIP: + type: string + description: IP address of the shortlink creator + ErrorResponse: + type: object + properties: + error: + type: object + properties: + message: + type: string + code: + type: string + doc: + type: string + trace: + type: string + responses: + BadRequest: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Unauthorized: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Forbidden: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + NotFound: + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' \ No newline at end of file