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 0000000..95884f6 Binary files /dev/null and b/internal/static/fonts/Lexend-Bold.ttf differ diff --git a/internal/static/fonts/Lexend-Bold.woff2 b/internal/static/fonts/Lexend-Bold.woff2 new file mode 100644 index 0000000..3124219 Binary files /dev/null and b/internal/static/fonts/Lexend-Bold.woff2 differ diff --git a/internal/static/fonts/Lexend-Regular.ttf b/internal/static/fonts/Lexend-Regular.ttf new file mode 100644 index 0000000..b423d3a Binary files /dev/null and b/internal/static/fonts/Lexend-Regular.ttf differ diff --git a/internal/static/fonts/Lexend-Regular.woff2 b/internal/static/fonts/Lexend-Regular.woff2 new file mode 100644 index 0000000..dc430a0 Binary files /dev/null and b/internal/static/fonts/Lexend-Regular.woff2 differ 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 0000000..4df30bf Binary files /dev/null and b/internal/static/img/favicon.ico differ diff --git a/internal/static/img/github.svg b/internal/static/img/github.svg new file mode 100644 index 0000000..3905707 --- /dev/null +++ b/internal/static/img/github.svg @@ -0,0 +1,3 @@ + + + \ 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 + + + + + + +
+

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