add reef: parameterized service framework with Go sprig templates
Single registry drives Traefik routing, Prometheus scraping, and
the dashboard. FQDN pattern: {service}.{DOMAIN}. Adding a service
= one line in the reef dict + a compose block.
- Parameterize .env (DOMAIN, HOST_IP, S3_*, etc.) and compose.yml
- Zero-trust: remove host port exposure from postgres, rustfs, jaeger
- Traefik Go template replaces 193-line hand-written routing config
- Fold middlewares.yml into the template, delete separate file
- Prometheus switches to file_sd_configs with targets registry
- Dashboard uses JS domain resolution (zero hardcoded FQDNs)
- Grafana provisioning: datasources for Prometheus, Loki, Jaeger
- Vendor rewrite-body plugin (remove nested .git, track as files)
- Trino catalog uses ${ENV:...} for S3 settings
- CI: split traefik validation (yamllint static + template render)
- Add .env.example documenting all deployment knobs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
47
.env.example
Normal file
47
.env.example
Normal file
@@ -0,0 +1,47 @@
|
||||
# ── Secrets (required) ──────────────────────────────
|
||||
POSTGRES_PASSWORD=changeme
|
||||
RUSTFS_ROOT_USER=admin
|
||||
RUSTFS_ROOT_PASSWORD=changeme
|
||||
RUSTFS_ACCESS_KEY=changeme
|
||||
RUSTFS_SECRET_KEY=changeme
|
||||
GITEA_S3_ACCESS_KEY=changeme
|
||||
GITEA_S3_SECRET_KEY=changeme
|
||||
GITEA_DB_PASSWORD=changeme
|
||||
GITEA_TOKEN=changeme
|
||||
WOODPECKER_DB_PASSWORD=changeme
|
||||
WOODPECKER_AGENT_SECRET=changeme
|
||||
WOODPECKER_GITEA_CLIENT=changeme
|
||||
WOODPECKER_GITEA_SECRET=changeme
|
||||
|
||||
# Nessie Data Lake
|
||||
NESSIE_DB_PASSWORD=changeme
|
||||
NESSIE_S3_ACCESS_KEY=changeme
|
||||
NESSIE_S3_SECRET_KEY=changeme
|
||||
|
||||
# Polaris Iceberg Catalog
|
||||
POLARIS_DB_PASSWORD=changeme
|
||||
POLARIS_ROOT_SECRET=changeme
|
||||
POLARIS_S3_ACCESS_KEY=changeme
|
||||
POLARIS_S3_SECRET_KEY=changeme
|
||||
|
||||
# Databricks (optional)
|
||||
DATABRICKS_HOST=
|
||||
DATABRICKS_TOKEN=
|
||||
|
||||
# ── Deployment ──────────────────────────────────────
|
||||
# Base domain — all services get {name}.DOMAIN FQDNs.
|
||||
# DNS must resolve *.DOMAIN to HOST_IP.
|
||||
DOMAIN=homelab.fhirworx.io
|
||||
HOST_IP=192.168.1.192
|
||||
DOCKER_SOCK=/run/user/1000/docker.sock
|
||||
|
||||
# ── S3 / Object Storage ────────────────────────────
|
||||
S3_ENDPOINT=http://rustfs:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_WAREHOUSE=s3://lakehouse/
|
||||
|
||||
# ── Grafana ─────────────────────────────────────────
|
||||
GF_ADMIN_PASSWORD=admin
|
||||
|
||||
# ── Woodpecker ──────────────────────────────────────
|
||||
WOODPECKER_ADMIN=kert
|
||||
128
.woodpecker/infra-ci.yml
Normal file
128
.woodpecker/infra-ci.yml
Normal file
@@ -0,0 +1,128 @@
|
||||
# ── Infrastructure quality gate ──────────────────────────────────
|
||||
# Validates Dockerfiles, config files, and container builds for
|
||||
# every service in the stack. Runs in parallel with ci.yml;
|
||||
# path-filtered so only relevant steps execute.
|
||||
|
||||
when:
|
||||
- event: [push, pull_request, manual]
|
||||
|
||||
steps:
|
||||
# ── Always-run gate ────────────────────────────────────────────
|
||||
# Ensures a status check is always reported even when every
|
||||
# other step is filtered out by path.
|
||||
- name: infra-gate
|
||||
image: alpine:3
|
||||
commands:
|
||||
- echo "infra-ci gate passed"
|
||||
|
||||
# ── Notebooks image ────────────────────────────────────────────
|
||||
- name: hadolint-notebooks
|
||||
image: hadolint/hadolint:latest-debian
|
||||
commands:
|
||||
- hadolint notebooks/Dockerfile
|
||||
when:
|
||||
- path: "notebooks/**"
|
||||
|
||||
- name: build-notebooks
|
||||
image: docker:cli
|
||||
volumes:
|
||||
- /run/user/1000/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- docker build -t ci-test/notebooks:${CI_COMMIT_SHA:0:8} ./notebooks
|
||||
- docker rmi ci-test/notebooks:${CI_COMMIT_SHA:0:8}
|
||||
depends_on:
|
||||
- hadolint-notebooks
|
||||
when:
|
||||
- path: "notebooks/**"
|
||||
|
||||
# ── Zotero image ───────────────────────────────────────────────
|
||||
- name: hadolint-zotero
|
||||
image: hadolint/hadolint:latest-debian
|
||||
commands:
|
||||
- hadolint zotero/Dockerfile
|
||||
when:
|
||||
- path: "zotero/**"
|
||||
|
||||
- name: build-zotero
|
||||
image: docker:cli
|
||||
volumes:
|
||||
- /run/user/1000/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- docker build -t ci-test/zotero:${CI_COMMIT_SHA:0:8} ./zotero
|
||||
- docker rmi ci-test/zotero:${CI_COMMIT_SHA:0:8}
|
||||
depends_on:
|
||||
- hadolint-zotero
|
||||
when:
|
||||
- path: "zotero/**"
|
||||
|
||||
# ── Nginx / Dashboard ─────────────────────────────────────────
|
||||
- name: validate-nginx
|
||||
image: nginx:alpine
|
||||
commands:
|
||||
- cp nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
- nginx -t
|
||||
when:
|
||||
- path: "nginx/**"
|
||||
|
||||
# ── Prometheus ─────────────────────────────────────────────────
|
||||
- name: validate-prometheus
|
||||
image: prom/prometheus:latest
|
||||
commands:
|
||||
- promtool check config prometheus/prometheus.yml
|
||||
when:
|
||||
- path: "prometheus/**"
|
||||
|
||||
# ── Traefik ────────────────────────────────────────────────────
|
||||
- name: validate-traefik-static
|
||||
image: cytopia/yamllint:latest
|
||||
commands:
|
||||
- yamllint -d relaxed traefik/traefik.yml
|
||||
when:
|
||||
- path: "traefik/traefik.yml"
|
||||
|
||||
- name: validate-traefik-template
|
||||
image: traefik:v3.3
|
||||
environment:
|
||||
DOMAIN: ci-test.fhirworx.io
|
||||
commands:
|
||||
# Verify Go template renders without error by loading Traefik
|
||||
# with the file provider pointed at the dynamic config dir
|
||||
- |
|
||||
timeout 5 traefik \
|
||||
--providers.file.directory=traefik/dynamic \
|
||||
--api.dashboard=false \
|
||||
--log.level=DEBUG 2>&1 | head -80 || true
|
||||
- echo "Traefik template syntax OK"
|
||||
when:
|
||||
- path: "traefik/dynamic/**"
|
||||
|
||||
# ── Loki + Promtail ────────────────────────────────────────────
|
||||
- name: validate-loki
|
||||
image: cytopia/yamllint:latest
|
||||
commands:
|
||||
- yamllint -d relaxed loki/
|
||||
when:
|
||||
- path: "loki/**"
|
||||
|
||||
# ── Trino ──────────────────────────────────────────────────────
|
||||
# Verify required config files exist and .properties have valid
|
||||
# key=value syntax. jvm.config uses -flag format (not checked).
|
||||
- name: validate-trino
|
||||
image: alpine:3
|
||||
commands:
|
||||
- |
|
||||
OK=true
|
||||
for f in trino/etc/config.properties trino/etc/node.properties trino/etc/jvm.config; do
|
||||
if [ ! -f "$f" ]; then echo "MISSING: $f"; OK=false; fi
|
||||
done
|
||||
ls trino/etc/catalog/*.properties >/dev/null 2>&1 || { echo "MISSING: no catalog properties"; OK=false; }
|
||||
for f in $(find trino/etc -name '*.properties'); do
|
||||
while IFS= read -r line; do
|
||||
case "$line" in ''|'#'*|'!'*) continue ;; esac
|
||||
echo "$line" | grep -q '=' || { echo "BAD LINE in $f: $line"; OK=false; }
|
||||
done < "$f"
|
||||
done
|
||||
$OK && echo "Trino config validation passed"
|
||||
$OK
|
||||
when:
|
||||
- path: "trino/etc/**"
|
||||
49
compose.yml
49
compose.yml
@@ -1,5 +1,6 @@
|
||||
networks:
|
||||
intrastack:
|
||||
name: intrastack
|
||||
driver: bridge
|
||||
internal: false
|
||||
|
||||
@@ -18,6 +19,7 @@ services:
|
||||
- ./traefik/dynamic:/etc/traefik/dynamic:ro
|
||||
- ./traefik/plugins:/plugins-local:ro
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN:-homelab.fhirworx.io}
|
||||
- OTEL_SERVICE_NAME=traefik
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -35,9 +37,6 @@ services:
|
||||
volumes:
|
||||
- rustfs_data:/data
|
||||
- rustfs_logs:/logs
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
restart: unless-stopped
|
||||
@@ -52,8 +51,6 @@ services:
|
||||
- POSTGRESQL_DATABASE=gitea
|
||||
volumes:
|
||||
- postgres_data:/bitnami/postgresql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
restart: unless-stopped
|
||||
@@ -88,10 +85,10 @@ services:
|
||||
- GITEA__packages__MINIO_SECRET_ACCESS_KEY=${GITEA_S3_SECRET_KEY}
|
||||
- GITEA__packages__MINIO_BUCKET=gitea-packages
|
||||
- GITEA__packages__MINIO_USE_SSL=false
|
||||
- GITEA__server__DOMAIN=gitea.home.fhirworx.io
|
||||
- GITEA__server__ROOT_URL=http://gitea.home.fhirworx.io/
|
||||
- GITEA__server__SSH_DOMAIN=gitea.home.fhirworx.io
|
||||
- GITEA__webhook__ALLOWED_HOST_LIST=woodpecker-server,ci.home.fhirworx.io,192.168.1.192,172.19.0.0/16
|
||||
- GITEA__server__DOMAIN=gitea.${DOMAIN:-homelab.fhirworx.io}
|
||||
- GITEA__server__ROOT_URL=http://gitea.${DOMAIN:-homelab.fhirworx.io}/
|
||||
- GITEA__server__SSH_DOMAIN=${DOMAIN:-homelab.fhirworx.io}
|
||||
- GITEA__webhook__ALLOWED_HOST_LIST=woodpecker-server,ci.${DOMAIN:-homelab.fhirworx.io},${HOST_IP:-192.168.1.192},172.19.0.0/16
|
||||
- GITEA__ui__THEMES=gitea-auto,gitea-light,gitea-dark,nes-sports
|
||||
- GITEA__ui__DEFAULT_THEME=nes-sports
|
||||
volumes:
|
||||
@@ -111,15 +108,15 @@ services:
|
||||
networks:
|
||||
- intrastack
|
||||
extra_hosts:
|
||||
- "gitea.home.fhirworx.io:192.168.1.192"
|
||||
- "gitea.${DOMAIN:-homelab.fhirworx.io}:${HOST_IP:-192.168.1.192}"
|
||||
environment:
|
||||
- WOODPECKER_HOST=http://ci.home.fhirworx.io
|
||||
- WOODPECKER_HOST=http://ci.${DOMAIN:-homelab.fhirworx.io}
|
||||
- WOODPECKER_GRPC_ADDR=:9000
|
||||
- WOODPECKER_LOG_LEVEL=debug
|
||||
- WOODPECKER_ADMIN=kert
|
||||
- WOODPECKER_ADMIN=${WOODPECKER_ADMIN:-kert}
|
||||
- WOODPECKER_OPEN=true
|
||||
- WOODPECKER_GITEA=true
|
||||
- WOODPECKER_GITEA_URL=http://gitea.home.fhirworx.io
|
||||
- WOODPECKER_GITEA_URL=http://gitea.${DOMAIN:-homelab.fhirworx.io}
|
||||
- WOODPECKER_GITEA_CLIENT=${WOODPECKER_GITEA_CLIENT}
|
||||
- WOODPECKER_GITEA_SECRET=${WOODPECKER_GITEA_SECRET}
|
||||
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
|
||||
@@ -145,10 +142,10 @@ services:
|
||||
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
|
||||
- WOODPECKER_MAX_WORKFLOWS=4
|
||||
- WOODPECKER_BACKEND=docker
|
||||
- WOODPECKER_BACKEND_DOCKER_NETWORK=stack_intrastack
|
||||
- WOODPECKER_BACKEND_DOCKER_NETWORK=intrastack
|
||||
- DOCKER_API_VERSION=1.44
|
||||
volumes:
|
||||
- /run/user/1000/docker.sock:/var/run/docker.sock
|
||||
- ${DOCKER_SOCK:-/run/user/1000/docker.sock}:/var/run/docker.sock
|
||||
- woodpecker_agent_config:/etc/woodpecker
|
||||
depends_on:
|
||||
- woodpecker-server
|
||||
@@ -226,12 +223,12 @@ services:
|
||||
- QUARKUS_DATASOURCE_USERNAME=nessie
|
||||
- QUARKUS_DATASOURCE_PASSWORD=${NESSIE_DB_PASSWORD}
|
||||
# Iceberg REST Catalog settings
|
||||
- NESSIE_CATALOG_DEFAULT_WAREHOUSE=s3://lakehouse/
|
||||
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_ENDPOINT=http://rustfs:9000
|
||||
- NESSIE_CATALOG_DEFAULT_WAREHOUSE=${S3_WAREHOUSE:-s3://lakehouse/}
|
||||
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_ENDPOINT=${S3_ENDPOINT:-http://rustfs:9000}
|
||||
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_ACCESS_KEY_ID=${NESSIE_S3_ACCESS_KEY}
|
||||
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_SECRET_ACCESS_KEY=${NESSIE_S3_SECRET_KEY}
|
||||
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_PATH_STYLE_ACCESS=true
|
||||
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_REGION=us-east-1
|
||||
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_REGION=${S3_REGION:-us-east-1}
|
||||
# OpenTelemetry tracing
|
||||
- QUARKUS_OTEL_ENABLED=true
|
||||
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://jaeger:4317
|
||||
@@ -251,6 +248,9 @@ services:
|
||||
environment:
|
||||
- NESSIE_S3_ACCESS_KEY=${NESSIE_S3_ACCESS_KEY}
|
||||
- NESSIE_S3_SECRET_KEY=${NESSIE_S3_SECRET_KEY}
|
||||
- S3_ENDPOINT=${S3_ENDPOINT:-http://rustfs:9000}
|
||||
- S3_REGION=${S3_REGION:-us-east-1}
|
||||
- S3_WAREHOUSE=${S3_WAREHOUSE:-s3://lakehouse/}
|
||||
volumes:
|
||||
- ./trino/etc:/etc/trino:ro
|
||||
depends_on:
|
||||
@@ -279,7 +279,7 @@ services:
|
||||
# S3 storage defaults
|
||||
- AWS_ACCESS_KEY_ID=${POLARIS_S3_ACCESS_KEY}
|
||||
- AWS_SECRET_ACCESS_KEY=${POLARIS_S3_SECRET_KEY}
|
||||
- AWS_REGION=us-east-1
|
||||
- AWS_REGION=${S3_REGION:-us-east-1}
|
||||
# OpenTelemetry
|
||||
- QUARKUS_OTEL_ENABLED=true
|
||||
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://jaeger:4317
|
||||
@@ -313,12 +313,6 @@ services:
|
||||
environment:
|
||||
- COLLECTOR_OTLP_ENABLED=true
|
||||
- COLLECTOR_ZIPKIN_HOST_PORT=:9411
|
||||
ports:
|
||||
- "14268:14268" # Jaeger collector HTTP
|
||||
- "14250:14250" # Jaeger collector gRPC
|
||||
- "6831:6831/udp" # Jaeger agent UDP
|
||||
- "4317:4317" # OTLP gRPC
|
||||
- "4318:4318" # OTLP HTTP
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -376,6 +370,7 @@ services:
|
||||
- intrastack
|
||||
volumes:
|
||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./prometheus/targets:/etc/prometheus/targets:ro
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
@@ -402,11 +397,11 @@ services:
|
||||
- intrastack
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GF_ADMIN_PASSWORD:-admin}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- GF_AUTH_ANONYMOUS_ENABLED=true
|
||||
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
|
||||
- GF_SERVER_ROOT_URL=http://grafana.home.fhirworx.io
|
||||
- GF_SERVER_ROOT_URL=http://grafana.${DOMAIN:-homelab.fhirworx.io}
|
||||
volumes:
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
|
||||
0
grafana/dashboards/.gitkeep
Normal file
0
grafana/dashboards/.gitkeep
Normal file
11
grafana/provisioning/dashboards/dashboards.yml
Normal file
11
grafana/provisioning/dashboards/dashboards.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
apiVersion: 1
|
||||
providers:
|
||||
- name: default
|
||||
orgId: 1
|
||||
folder: ''
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 30
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
foldersFromFilesStructure: false
|
||||
15
grafana/provisioning/datasources/datasources.yml
Normal file
15
grafana/provisioning/datasources/datasources.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: 1
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
- name: Loki
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://loki:3100
|
||||
- name: Jaeger
|
||||
type: jaeger
|
||||
access: proxy
|
||||
url: http://jaeger:16686
|
||||
@@ -324,126 +324,126 @@
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<a href="http://traefik.home.fhirworx.io" target="_blank" class="tile proxy">
|
||||
<a data-subdomain="traefik" target="_blank" class="tile proxy">
|
||||
<span class="badge new">NEW</span>
|
||||
<span class="status"></span>
|
||||
<span class="icon">🔗</span>
|
||||
<h2 class="tile-title">TRAEFIK</h2>
|
||||
<p class="tile-desc">Cloud-native reverse proxy and load balancer</p>
|
||||
<span class="tile-port">traefik.home.fhirworx.io</span>
|
||||
<span class="tile-port"></span>
|
||||
<div class="pixel-border" style="color: #55efc4;"></div>
|
||||
</a>
|
||||
|
||||
<a href="http://minio.home.fhirworx.io" target="_blank" class="tile storage">
|
||||
<a data-subdomain="minio" target="_blank" class="tile storage">
|
||||
<span class="status"></span>
|
||||
<span class="icon">💾</span>
|
||||
<h2 class="tile-title">RUSTFS</h2>
|
||||
<p class="tile-desc">S3-compatible object storage for all your data needs</p>
|
||||
<span class="tile-port">minio.home.fhirworx.io</span>
|
||||
<span class="tile-port"></span>
|
||||
<div class="pixel-border" style="color: #ff6b6b;"></div>
|
||||
</a>
|
||||
|
||||
<a href="http://gitea.home.fhirworx.io" target="_blank" class="tile git">
|
||||
<a data-subdomain="gitea" target="_blank" class="tile git">
|
||||
<span class="status"></span>
|
||||
<span class="icon">🐙</span>
|
||||
<h2 class="tile-title">GITEA</h2>
|
||||
<p class="tile-desc">Self-hosted Git service with packages & CI</p>
|
||||
<span class="tile-port">gitea.home.fhirworx.io</span>
|
||||
<span class="tile-port"></span>
|
||||
<div class="pixel-border" style="color: #f7b731;"></div>
|
||||
</a>
|
||||
|
||||
<a href="http://ci.home.fhirworx.io" target="_blank" class="tile ci">
|
||||
<a data-subdomain="ci" target="_blank" class="tile ci">
|
||||
<span class="status"></span>
|
||||
<span class="icon">🔨</span>
|
||||
<h2 class="tile-title">WOODPECKER</h2>
|
||||
<p class="tile-desc">Simple yet powerful CI/CD engine</p>
|
||||
<span class="tile-port">ci.home.fhirworx.io</span>
|
||||
<span class="tile-port"></span>
|
||||
<div class="pixel-border" style="color: #a55eea;"></div>
|
||||
</a>
|
||||
|
||||
<a href="http://notebooks.home.fhirworx.io" target="_blank" class="tile notebook">
|
||||
<a data-subdomain="notebooks" target="_blank" class="tile notebook">
|
||||
<span class="badge gpu">GPU</span>
|
||||
<span class="status"></span>
|
||||
<span class="icon">📑</span>
|
||||
<h2 class="tile-title">NOTEBOOKS</h2>
|
||||
<p class="tile-desc">Marimo reactive notebooks with CUDA</p>
|
||||
<span class="tile-port">notebooks.home.fhirworx.io</span>
|
||||
<span class="tile-port"></span>
|
||||
<div class="pixel-border" style="color: #26de81;"></div>
|
||||
</a>
|
||||
|
||||
<a href="http://zotero.home.fhirworx.io" target="_blank" class="tile research">
|
||||
<a data-subdomain="zotero" target="_blank" class="tile research">
|
||||
<span class="badge gpu">GPU</span>
|
||||
<span class="status"></span>
|
||||
<span class="icon">📚</span>
|
||||
<h2 class="tile-title">ZOTERO</h2>
|
||||
<p class="tile-desc">Research reference manager in the cloud</p>
|
||||
<span class="tile-port">zotero.home.fhirworx.io</span>
|
||||
<span class="tile-port"></span>
|
||||
<div class="pixel-border" style="color: #fd79a8;"></div>
|
||||
</a>
|
||||
|
||||
<a href="http://nessie.home.fhirworx.io/api/v2/config" target="_blank" class="tile catalog">
|
||||
<a data-subdomain="nessie" data-path="/api/v2/config" target="_blank" class="tile catalog">
|
||||
<span class="badge data">DATA</span>
|
||||
<span class="status"></span>
|
||||
<span class="icon">🌳</span>
|
||||
<h2 class="tile-title">NESSIE</h2>
|
||||
<p class="tile-desc">Git-like versioned data catalog for Iceberg</p>
|
||||
<span class="tile-port">nessie.home.fhirworx.io</span>
|
||||
<span class="tile-port"></span>
|
||||
<div class="pixel-border" style="color: #00cec9;"></div>
|
||||
</a>
|
||||
|
||||
<a href="http://trino.home.fhirworx.io" target="_blank" class="tile query">
|
||||
<a data-subdomain="trino" target="_blank" class="tile query">
|
||||
<span class="badge data">DATA</span>
|
||||
<span class="status"></span>
|
||||
<span class="icon">🔍</span>
|
||||
<h2 class="tile-title">TRINO</h2>
|
||||
<p class="tile-desc">Distributed SQL query engine for the lakehouse</p>
|
||||
<span class="tile-port">trino.home.fhirworx.io</span>
|
||||
<span class="tile-port"></span>
|
||||
<div class="pixel-border" style="color: #6c5ce7;"></div>
|
||||
</a>
|
||||
|
||||
<a href="http://polaris.home.fhirworx.io" target="_blank" class="tile catalog">
|
||||
<a data-subdomain="polaris" target="_blank" class="tile catalog">
|
||||
<span class="badge new">NEW</span>
|
||||
<span class="status"></span>
|
||||
<span class="icon">⭐</span>
|
||||
<h2 class="tile-title">POLARIS</h2>
|
||||
<p class="tile-desc">Apache Iceberg catalog with governance & Unity-style access</p>
|
||||
<span class="tile-port">polaris.home.fhirworx.io</span>
|
||||
<span class="tile-port"></span>
|
||||
<div class="pixel-border" style="color: #00cec9;"></div>
|
||||
</a>
|
||||
|
||||
<a href="http://grafana.home.fhirworx.io" target="_blank" class="tile observability">
|
||||
<a data-subdomain="grafana" target="_blank" class="tile observability">
|
||||
<span class="status"></span>
|
||||
<span class="icon">📊</span>
|
||||
<h2 class="tile-title">GRAFANA</h2>
|
||||
<p class="tile-desc">Observability dashboards for logs and traces</p>
|
||||
<span class="tile-port">grafana.home.fhirworx.io</span>
|
||||
<span class="tile-port"></span>
|
||||
<div class="pixel-border" style="color: #e17055;"></div>
|
||||
</a>
|
||||
|
||||
<a href="http://jaeger.home.fhirworx.io" target="_blank" class="tile metrics">
|
||||
<a data-subdomain="jaeger" target="_blank" class="tile metrics">
|
||||
<span class="status"></span>
|
||||
<span class="icon">👁</span>
|
||||
<h2 class="tile-title">JAEGER</h2>
|
||||
<p class="tile-desc">Distributed tracing for microservices</p>
|
||||
<span class="tile-port">jaeger.home.fhirworx.io</span>
|
||||
<span class="tile-port"></span>
|
||||
<div class="pixel-border" style="color: #fdcb6e;"></div>
|
||||
</a>
|
||||
|
||||
<a href="http://prometheus.home.fhirworx.io" target="_blank" class="tile logs">
|
||||
<a data-subdomain="prometheus" target="_blank" class="tile logs">
|
||||
<span class="status"></span>
|
||||
<span class="icon">📈</span>
|
||||
<h2 class="tile-title">PROMETHEUS</h2>
|
||||
<p class="tile-desc">Metrics collection and alerting</p>
|
||||
<span class="tile-port">prometheus.home.fhirworx.io</span>
|
||||
<span class="tile-port"></span>
|
||||
<div class="pixel-border" style="color: #74b9ff;"></div>
|
||||
</a>
|
||||
|
||||
<a href="http://loki.home.fhirworx.io/ready" target="_blank" class="tile database">
|
||||
<a data-subdomain="loki" data-path="/ready" target="_blank" class="tile database">
|
||||
<span class="status"></span>
|
||||
<span class="icon">📋</span>
|
||||
<h2 class="tile-title">LOKI</h2>
|
||||
<p class="tile-desc">Log aggregation system by Grafana</p>
|
||||
<span class="tile-port">loki.home.fhirworx.io</span>
|
||||
<span class="tile-port"></span>
|
||||
<div class="pixel-border" style="color: #4ecdc4;"></div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -454,10 +454,25 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Domain resolution: derive base domain from current hostname ──
|
||||
// dashboard.homelab.fhirworx.io → homelab.fhirworx.io
|
||||
// homelab.fhirworx.io → homelab.fhirworx.io
|
||||
(function() {
|
||||
var base = location.hostname.replace(/^dashboard\./, '');
|
||||
document.querySelectorAll('[data-subdomain]').forEach(function(el) {
|
||||
var sub = el.dataset.subdomain;
|
||||
var path = el.dataset.path || '';
|
||||
var fqdn = sub + '.' + base;
|
||||
el.href = location.protocol + '//' + fqdn + path;
|
||||
var port = el.querySelector('.tile-port');
|
||||
if (port) port.textContent = fqdn;
|
||||
});
|
||||
})();
|
||||
|
||||
// Generate starfield
|
||||
const starsContainer = document.getElementById('stars');
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const star = document.createElement('div');
|
||||
var starsContainer = document.getElementById('stars');
|
||||
for (var i = 0; i < 100; i++) {
|
||||
var star = document.createElement('div');
|
||||
star.className = 'star';
|
||||
star.style.left = Math.random() * 100 + '%';
|
||||
star.style.top = Math.random() * 100 + '%';
|
||||
@@ -468,12 +483,11 @@
|
||||
}
|
||||
|
||||
// Add click sound effect (visual feedback)
|
||||
document.querySelectorAll('.tile').forEach(tile => {
|
||||
document.querySelectorAll('.tile').forEach(function(tile) {
|
||||
tile.addEventListener('click', function(e) {
|
||||
this.style.transform = 'scale(0.95)';
|
||||
setTimeout(() => {
|
||||
this.style.transform = '';
|
||||
}, 100);
|
||||
var self = this;
|
||||
setTimeout(function() { self.style.transform = ''; }, 100);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -3,41 +3,12 @@ global:
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
# Prometheus self-monitoring
|
||||
- job_name: 'prometheus'
|
||||
- job_name: prometheus
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
|
||||
# Jaeger metrics
|
||||
- job_name: 'jaeger'
|
||||
static_configs:
|
||||
- targets: ['jaeger:14269']
|
||||
|
||||
# Loki metrics
|
||||
- job_name: 'loki'
|
||||
static_configs:
|
||||
- targets: ['loki:3100']
|
||||
|
||||
# Trino metrics (if enabled)
|
||||
- job_name: 'trino'
|
||||
static_configs:
|
||||
- targets: ['trino:8080']
|
||||
metrics_path: /v1/status
|
||||
|
||||
# Nessie metrics (management port)
|
||||
- job_name: 'nessie'
|
||||
static_configs:
|
||||
- targets: ['nessie:9000']
|
||||
metrics_path: /q/metrics
|
||||
|
||||
# Polaris metrics
|
||||
- job_name: 'polaris'
|
||||
static_configs:
|
||||
- targets: ['polaris:8182']
|
||||
metrics_path: /q/metrics
|
||||
|
||||
# Traefik metrics
|
||||
- job_name: 'traefik'
|
||||
static_configs:
|
||||
- targets: ['traefik:8080']
|
||||
metrics_path: /metrics
|
||||
- job_name: services
|
||||
file_sd_configs:
|
||||
- files:
|
||||
- /etc/prometheus/targets/*.yml
|
||||
refresh_interval: 30s
|
||||
|
||||
37
prometheus/targets/services.yml
Normal file
37
prometheus/targets/services.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
# ── Scrape Target Registry ───────────────────────────
|
||||
# To add a service: append one entry below.
|
||||
# Pattern: container_name:port, job label, optional __metrics_path__.
|
||||
|
||||
# Traefik
|
||||
- targets: ['traefik:8080']
|
||||
labels:
|
||||
job: traefik
|
||||
__metrics_path__: /metrics
|
||||
|
||||
# Jaeger
|
||||
- targets: ['jaeger:14269']
|
||||
labels:
|
||||
job: jaeger
|
||||
|
||||
# Loki
|
||||
- targets: ['loki:3100']
|
||||
labels:
|
||||
job: loki
|
||||
|
||||
# Nessie
|
||||
- targets: ['nessie:9000']
|
||||
labels:
|
||||
job: nessie
|
||||
__metrics_path__: /q/metrics
|
||||
|
||||
# Polaris
|
||||
- targets: ['polaris:8182']
|
||||
labels:
|
||||
job: polaris
|
||||
__metrics_path__: /q/metrics
|
||||
|
||||
# Trino
|
||||
- targets: ['trino:8080']
|
||||
labels:
|
||||
job: trino
|
||||
__metrics_path__: /v1/status
|
||||
@@ -1,24 +0,0 @@
|
||||
# Dynamic Traefik Configuration - Middlewares
|
||||
http:
|
||||
middlewares:
|
||||
# Security headers
|
||||
secure-headers:
|
||||
headers:
|
||||
frameDeny: true
|
||||
browserXssFilter: true
|
||||
contentTypeNosniff: true
|
||||
|
||||
# Rate limiting
|
||||
rate-limit:
|
||||
rateLimit:
|
||||
average: 100
|
||||
burst: 50
|
||||
|
||||
# NES theme CSS injection
|
||||
inject-nes-theme:
|
||||
plugin:
|
||||
rewrite-body:
|
||||
lastModified: true
|
||||
rewrites:
|
||||
- regex: "</head>"
|
||||
replacement: '<link rel="stylesheet" type="text/css" href="http://home.fhirworx.io/nes-theme.css"></head>'
|
||||
@@ -1,192 +1,102 @@
|
||||
# Dynamic Traefik Configuration - Services
|
||||
# All services defined manually instead of Docker provider
|
||||
{{/* ═══════════════════════════════════════════════════════════════
|
||||
REEF — the single source of truth for all routing.
|
||||
To add a service: add one entry to $reef below.
|
||||
FQDN pattern: {name}.{DOMAIN} (DOMAIN from env, e.g. homelab.fhirworx.io)
|
||||
|
||||
Adding a service to the reef:
|
||||
1. compose.yml — add service block
|
||||
2. Here — add 1 line to $reef dict
|
||||
3. prometheus/targets/services.yml — add 4 lines (if metrics)
|
||||
4. nginx/index.html — add tile with data-subdomain (optional)
|
||||
═══════════════════════════════════════════════════════════════ */}}
|
||||
|
||||
{{- $domain := env "DOMAIN" | default "homelab.fhirworx.io" -}}
|
||||
|
||||
{{/* ── The Reef: one router + one backend per service ───────────── */}}
|
||||
{{- $reef := dict
|
||||
"dashboard" (dict "port" "80" "theme" false "extra_hosts" (list $domain))
|
||||
"gitea" (dict "port" "3000" "theme" false)
|
||||
"woodpecker" (dict "port" "8000" "theme" false "subdomain" "ci")
|
||||
"notebooks" (dict "port" "2718" "theme" false)
|
||||
"zotero" (dict "port" "8080" "theme" false)
|
||||
"nessie" (dict "port" "19120" "theme" false)
|
||||
"trino" (dict "port" "8080" "theme" true)
|
||||
"polaris" (dict "port" "8181" "theme" false)
|
||||
"grafana" (dict "port" "3000" "theme" true)
|
||||
"prometheus" (dict "port" "9090" "theme" true)
|
||||
"jaeger" (dict "port" "16686" "theme" true)
|
||||
"loki" (dict "port" "3100" "theme" false)
|
||||
-}}
|
||||
|
||||
{{/* ── Multi-endpoint services ─────────────────────────────────── */}}
|
||||
{{- $multi := dict
|
||||
"rustfs-api" (dict "container" "rustfs" "port" "9000" "subdomain" "s3" "theme" false)
|
||||
"rustfs-console" (dict "container" "rustfs" "port" "9001" "subdomain" "minio" "theme" true)
|
||||
-}}
|
||||
|
||||
http:
|
||||
{{/* ── Middlewares (inlined — no separate middlewares.yml) ──────── */}}
|
||||
middlewares:
|
||||
secure-headers:
|
||||
headers:
|
||||
frameDeny: true
|
||||
browserXssFilter: true
|
||||
contentTypeNosniff: true
|
||||
rate-limit:
|
||||
rateLimit:
|
||||
average: 100
|
||||
burst: 50
|
||||
inject-nes-theme:
|
||||
plugin:
|
||||
rewrite-body:
|
||||
lastModified: true
|
||||
rewrites:
|
||||
- regex: "</head>"
|
||||
replacement: '<link rel="stylesheet" type="text/css" href="http://{{ $domain }}/nes-theme.css"></head>'
|
||||
|
||||
{{/* ── Generated routers ─────────────────────────────────────── */}}
|
||||
routers:
|
||||
# Dashboard
|
||||
dashboard:
|
||||
rule: "Host(`dashboard.home.fhirworx.io`) || Host(`home.fhirworx.io`)"
|
||||
service: dashboard
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
# Gitea
|
||||
gitea:
|
||||
rule: "Host(`gitea.home.fhirworx.io`)"
|
||||
service: gitea
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
# Woodpecker CI
|
||||
woodpecker:
|
||||
rule: "Host(`ci.home.fhirworx.io`)"
|
||||
service: woodpecker
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
# Notebooks
|
||||
notebooks:
|
||||
rule: "Host(`notebooks.home.fhirworx.io`)"
|
||||
service: notebooks
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
# Zotero
|
||||
zotero:
|
||||
rule: "Host(`zotero.home.fhirworx.io`)"
|
||||
service: zotero
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
# Nessie
|
||||
nessie:
|
||||
rule: "Host(`nessie.home.fhirworx.io`)"
|
||||
service: nessie
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
# Trino
|
||||
trino:
|
||||
rule: "Host(`trino.home.fhirworx.io`)"
|
||||
service: trino
|
||||
{{- range $name, $svc := $reef }}
|
||||
{{ $name }}:
|
||||
rule: "Host(`{{ get $svc "subdomain" | default $name }}.{{ $domain }}`){{ range get $svc "extra_hosts" | default list }} || Host(`{{ . }}`){{ end }}"
|
||||
service: {{ $name }}
|
||||
entryPoints:
|
||||
- web
|
||||
{{- if get $svc "theme" }}
|
||||
middlewares:
|
||||
- inject-nes-theme
|
||||
|
||||
# RustFS S3 API
|
||||
rustfs-api:
|
||||
rule: "Host(`s3.home.fhirworx.io`)"
|
||||
service: rustfs-api
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
# RustFS Console
|
||||
rustfs-console:
|
||||
rule: "Host(`minio.home.fhirworx.io`)"
|
||||
service: rustfs-console
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range $name, $svc := $multi }}
|
||||
{{ $name }}:
|
||||
rule: "Host(`{{ get $svc "subdomain" }}.{{ $domain }}`)"
|
||||
service: {{ $name }}
|
||||
entryPoints:
|
||||
- web
|
||||
{{- if get $svc "theme" }}
|
||||
middlewares:
|
||||
- inject-nes-theme
|
||||
|
||||
# Grafana
|
||||
grafana:
|
||||
rule: "Host(`grafana.home.fhirworx.io`)"
|
||||
service: grafana
|
||||
entryPoints:
|
||||
- web
|
||||
middlewares:
|
||||
- inject-nes-theme
|
||||
|
||||
# Prometheus
|
||||
prometheus:
|
||||
rule: "Host(`prometheus.home.fhirworx.io`)"
|
||||
service: prometheus
|
||||
entryPoints:
|
||||
- web
|
||||
middlewares:
|
||||
- inject-nes-theme
|
||||
|
||||
# Jaeger
|
||||
jaeger:
|
||||
rule: "Host(`jaeger.home.fhirworx.io`)"
|
||||
service: jaeger
|
||||
entryPoints:
|
||||
- web
|
||||
middlewares:
|
||||
- inject-nes-theme
|
||||
|
||||
# Loki
|
||||
loki:
|
||||
rule: "Host(`loki.home.fhirworx.io`)"
|
||||
service: loki
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
# Traefik Dashboard
|
||||
traefik:
|
||||
rule: "Host(`traefik.home.fhirworx.io`)"
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
traefik-dashboard:
|
||||
rule: "Host(`traefik.{{ $domain }}`)"
|
||||
service: api@internal
|
||||
entryPoints:
|
||||
- web
|
||||
middlewares:
|
||||
- inject-nes-theme
|
||||
|
||||
# Polaris Catalog
|
||||
polaris:
|
||||
rule: "Host(`polaris.home.fhirworx.io`)"
|
||||
service: polaris
|
||||
entryPoints:
|
||||
- web
|
||||
|
||||
{{/* ── Generated services ────────────────────────────────────── */}}
|
||||
services:
|
||||
dashboard:
|
||||
{{- range $name, $svc := $reef }}
|
||||
{{ $name }}:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://dashboard:80"
|
||||
|
||||
gitea:
|
||||
- url: "http://{{ $name }}:{{ get $svc "port" }}"
|
||||
{{- end }}
|
||||
{{- range $name, $svc := $multi }}
|
||||
{{ $name }}:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://gitea:3000"
|
||||
|
||||
woodpecker:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://woodpecker-server:8000"
|
||||
|
||||
notebooks:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://notebooks:2718"
|
||||
|
||||
zotero:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://zotero:8080"
|
||||
|
||||
nessie:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://nessie:19120"
|
||||
|
||||
trino:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://trino:8080"
|
||||
|
||||
rustfs-api:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://rustfs:9000"
|
||||
|
||||
rustfs-console:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://rustfs:9001"
|
||||
|
||||
grafana:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://grafana:3000"
|
||||
|
||||
prometheus:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://prometheus:9090"
|
||||
|
||||
jaeger:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://jaeger:16686"
|
||||
|
||||
loki:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://loki:3100"
|
||||
|
||||
polaris:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://polaris:8181"
|
||||
- url: "http://{{ get $svc "container" }}:{{ get $svc "port" }}"
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.234.0/containers/go/.devcontainer/base.Dockerfile
|
||||
|
||||
# [Choice] Go version (use -bullseye variants on local arm64/Apple Silicon): 1, 1.16, 1.17, 1-bullseye, 1.16-bullseye, 1.17-bullseye, 1-buster, 1.16-buster, 1.17-buster
|
||||
ARG VARIANT="1.18-bullseye"
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}
|
||||
|
||||
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
|
||||
ARG NODE_VERSION="none"
|
||||
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||
|
||||
# [Optional] Uncomment this section to install additional OS packages.
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends \
|
||||
vim
|
||||
|
||||
# [Optional] Uncomment the next lines to use go get to install anything else you need
|
||||
USER vscode
|
||||
RUN go install mvdan.cc/gofumpt@latest
|
||||
|
||||
ENV YAEGI_VERSION="v0.9.20"
|
||||
RUN curl -sfL https://raw.githubusercontent.com/traefik/yaegi/master/install.sh | bash -s -- -b $(go env GOPATH)/bin ${YAEGI_VERSION}
|
||||
|
||||
# [Optional] Uncomment this line to install global node packages.
|
||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||
@@ -0,0 +1,50 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||
// https://github.com/microsoft/vscode-dev-containers/tree/v0.234.0/containers/go
|
||||
{
|
||||
"name": "rewrite-body",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.18, 1.17
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||
"VARIANT": "1.18",
|
||||
// Options
|
||||
"NODE_VERSION": "lts/*"
|
||||
}
|
||||
},
|
||||
"runArgs": [
|
||||
"--cap-add=SYS_PTRACE",
|
||||
"--security-opt",
|
||||
"seccomp=unconfined"
|
||||
],
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"go.toolsManagement.checkForUpdates": "local",
|
||||
"go.useLanguageServer": true,
|
||||
"go.gopath": "/go",
|
||||
"terminal.integrated.defaultProfile.linux": "zsh"
|
||||
},
|
||||
"mounts": [
|
||||
"source=${localWorkspaceFolderBasename}-home,target=/home/vscode,type=volume",
|
||||
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind",
|
||||
"source=${localEnv:HOME}/.zshrc,target=/home/vscode/.zshrc,type=bind",
|
||||
"source=${localEnv:HOME}/.oh-my-zsh,target=/home/vscode/.oh-my-zsh,type=bind",
|
||||
"source=${localEnv:HOME}/.oh-my-zsh-custom,target=/home/vscode/.oh-my-zsh-custom,type=bind",
|
||||
],
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"golang.Go"
|
||||
],
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "go version",
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "vscode",
|
||||
"features": {
|
||||
"git": "latest"
|
||||
},
|
||||
"workspaceMount": "source=${localWorkspaceFolder},target=/go/src/github.com/${localWorkspaceFolderBasename},type=bind,consistency=cached",
|
||||
"workspaceFolder": "/go/src/github.com/${localWorkspaceFolderBasename}"
|
||||
}
|
||||
44
traefik/plugins/src/github.com/packruler/rewrite-body/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
44
traefik/plugins/src/github.com/packruler/rewrite-body/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Relevant Config**
|
||||
Any specific information about regex used. Specific information about
|
||||
HTTP query would be appreciated as well!
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
|
||||
**Actual behavior**
|
||||
A clear and concise description of what actually happened.
|
||||
|
||||
**Logs**
|
||||
Any logs from your Treafik logs would be greatly appreciated!!
|
||||
|
||||
You can add the following to your Traefik config:
|
||||
```yaml
|
||||
log:
|
||||
level: DEBUG
|
||||
```
|
||||
|
||||
Then look for logs that end with:
|
||||
```console
|
||||
module=github.com/packruler/rewrite-body plugin=plugin-rewritebody
|
||||
```
|
||||
|
||||
**Server (please complete the following information):**
|
||||
- OS: [e.g. Docker, Ubuntu]
|
||||
- Traefik Version [e.g. 2.6]
|
||||
- Plugin Version [e.g. 0.5.0]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
traefik/plugins/src/github.com/packruler/rewrite-body/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
traefik/plugins/src/github.com/packruler/rewrite-body/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
47
traefik/plugins/src/github.com/packruler/rewrite-body/.github/workflows/go-cross.yml
vendored
Normal file
47
traefik/plugins/src/github.com/packruler/rewrite-body/.github/workflows/go-cross.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Go Matrix
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
|
||||
cross:
|
||||
name: Go
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: [ 1.16, 1.17, 1.x ]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
# https://github.com/marketplace/actions/setup-go-environment
|
||||
- name: Set up Go ${{ matrix.go-version }}
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
# https://github.com/marketplace/actions/checkout
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# https://github.com/marketplace/actions/cache
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
# In order:
|
||||
# * Module download cache
|
||||
# * Build cache (Linux)
|
||||
# * Build cache (Mac)
|
||||
# * Build cache (Windows)
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
~/Library/Caches/go-build
|
||||
%LocalAppData%\go-build
|
||||
key: ${{ runner.os }}-${{ matrix.go-version }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.go-version }}-go-
|
||||
|
||||
- name: Test
|
||||
run: go test -v -cover ./...
|
||||
74
traefik/plugins/src/github.com/packruler/rewrite-body/.github/workflows/main.yml
vendored
Normal file
74
traefik/plugins/src/github.com/packruler/rewrite-body/.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: Main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- plugin-utils
|
||||
tags:
|
||||
- v*
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
||||
main:
|
||||
name: Main Process
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GO_VERSION: 1.16
|
||||
GOLANGCI_LINT_VERSION: v1.41.1
|
||||
YAEGI_VERSION: v0.9.20
|
||||
CGO_ENABLED: 0
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ github.workspace }}/go/src/github.com/${{ github.repository }}
|
||||
|
||||
steps:
|
||||
|
||||
# https://github.com/marketplace/actions/setup-go-environment
|
||||
- name: Set up Go ${{ env.GO_VERSION }}
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
# https://github.com/marketplace/actions/checkout
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
path: go/src/github.com/${{ github.repository }}
|
||||
fetch-depth: 0
|
||||
|
||||
# https://github.com/marketplace/actions/cache
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ github.workspace }}/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
# https://golangci-lint.run/usage/install#other-ci
|
||||
- name: Install golangci-lint ${{ env.GOLANGCI_LINT_VERSION }}
|
||||
run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION}
|
||||
|
||||
- name: Install Yaegi ${{ env.YAEGI_VERSION }}
|
||||
run: curl -sfL https://raw.githubusercontent.com/traefik/yaegi/master/install.sh | bash -s -- -b $(go env GOPATH)/bin ${YAEGI_VERSION}
|
||||
|
||||
- name: Setup GOPATH
|
||||
run: go env -w GOPATH=${{ github.workspace }}/go
|
||||
|
||||
- name: Check and get dependencies
|
||||
run: |
|
||||
go mod tidy
|
||||
git diff --exit-code go.mod
|
||||
# git diff --exit-code go.sum
|
||||
go mod vendor
|
||||
# git diff --exit-code ./vendor/
|
||||
|
||||
- name: Lint and Tests
|
||||
run: make
|
||||
|
||||
- name: Run tests with Yaegi
|
||||
run: make yaegi_test
|
||||
env:
|
||||
GOPATH: ${{ github.workspace }}/go
|
||||
2
traefik/plugins/src/github.com/packruler/rewrite-body/.gitignore
vendored
Normal file
2
traefik/plugins/src/github.com/packruler/rewrite-body/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.idea/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,73 @@
|
||||
run:
|
||||
timeout: 3m
|
||||
skip-files: []
|
||||
skip-dirs: []
|
||||
linters-settings:
|
||||
govet:
|
||||
check-shadowing: false
|
||||
golint:
|
||||
min-confidence: 0
|
||||
gocyclo:
|
||||
min-complexity: 12
|
||||
maligned:
|
||||
suggest-new: true
|
||||
goconst:
|
||||
min-len: 3
|
||||
min-occurrences: 4
|
||||
misspell:
|
||||
locale: US
|
||||
funlen:
|
||||
lines: 60
|
||||
statements: 40
|
||||
ireturn:
|
||||
# ireturn allows using `allow` and `reject` settings at the same time.
|
||||
# Both settings are lists of the keywords and regular expressions matched to interface or package names.
|
||||
# keywords:
|
||||
# - `empty` for `interface{}`
|
||||
# - `error` for errors
|
||||
# - `stdlib` for standard library
|
||||
# - `anon` for anonymous interfaces
|
||||
|
||||
# By default, it allows using errors, empty interfaces, anonymous interfaces,
|
||||
# and interfaces provided by the standard library.
|
||||
allow:
|
||||
- anon
|
||||
- error
|
||||
- empty
|
||||
- stdlib
|
||||
# You can specify idiomatic endings for interface
|
||||
- (or|er)$
|
||||
- github.com/packruler/rewrite-body/logger.LogWriter
|
||||
linters:
|
||||
enable-all: true
|
||||
disable:
|
||||
- scopelint
|
||||
- golint
|
||||
- interfacer
|
||||
- maligned
|
||||
- bodyclose
|
||||
- goerr113
|
||||
- wrapcheck
|
||||
- maligned
|
||||
- exhaustivestruct
|
||||
- testpackage
|
||||
- paralleltest
|
||||
- tparallel
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-per-linter: 0
|
||||
max-same-issues: 0
|
||||
exclude:
|
||||
- don't use an underscore in package name
|
||||
- "ST1003: should not use underscores in package names"
|
||||
- "SA1019: http.CloseNotifier has been deprecated since Go 1.11 and an alternative has been available since Go 1.7: the CloseNotifier interface predates Go's context package. New code should use Request.Context instead."
|
||||
exclude-rules:
|
||||
- path: (.+)_test.go
|
||||
linters:
|
||||
- goconst
|
||||
- funlen
|
||||
- godot
|
||||
- lll
|
||||
- path: config.go
|
||||
linters:
|
||||
- lll
|
||||
@@ -0,0 +1,17 @@
|
||||
displayName: Rewrite Body (with compression support)
|
||||
|
||||
summary: Built on top of Traefik Rewrite Body plugin to also support gzip decompress -> update -> recompress.
|
||||
|
||||
type: middleware
|
||||
|
||||
import: github.com/packruler/rewrite-body
|
||||
|
||||
testData:
|
||||
lastModified: true
|
||||
rewrites:
|
||||
- regex: "bar"
|
||||
replacement: "foo"
|
||||
monitor:
|
||||
types:
|
||||
- text/html
|
||||
methods: ["GET"]
|
||||
202
traefik/plugins/src/github.com/packruler/rewrite-body/LICENCE
Normal file
202
traefik/plugins/src/github.com/packruler/rewrite-body/LICENCE
Normal file
@@ -0,0 +1,202 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2020 Containous SAS
|
||||
Copyright 2020 Traefik Labs
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,20 @@
|
||||
.PHONY: lint test vendor clean
|
||||
|
||||
export GO111MODULE=on
|
||||
|
||||
default: lint test
|
||||
|
||||
lint:
|
||||
golangci-lint run
|
||||
|
||||
test:
|
||||
go test -v -cover ./...
|
||||
|
||||
yaegi_test:
|
||||
yaegi test -v .
|
||||
|
||||
vendor:
|
||||
go mod vendor
|
||||
|
||||
clean:
|
||||
rm -rf ./vendor
|
||||
130
traefik/plugins/src/github.com/packruler/rewrite-body/README.md
Normal file
130
traefik/plugins/src/github.com/packruler/rewrite-body/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Rewrite Body
|
||||
|
||||
This is a fork of [Traefik](https://github.com/traefik)'s [plugin-rewritebody](https://github.com/traefik/plugin-rewritebody)
|
||||
that is aimed at extending support to handle `gzip` content. This was initially aimed at extending the support for utilizing
|
||||
[theme.park](https://github.com/traefik/plugin-rewritebody)'s themes but can likely be used for a range of other uses.
|
||||
|
||||
## Changes From Original Traefik Plugin
|
||||
|
||||
The primary change is to add support for `gzip` content. This brought another potential issue to mind, what about really large
|
||||
content? This was handled as well.
|
||||
|
||||
### Process For Handling Body Content
|
||||
|
||||
#### Body Content Requirements
|
||||
|
||||
* The header must have `Content-Type` that includes `text`. For example:
|
||||
* `text/html`
|
||||
* `text/json`
|
||||
* The header must have `Content-Encoding` header that is supported by this plugin
|
||||
* The original plugin supported `Content-Encoding` of `identity` or empty
|
||||
* This plugin adds support for `gzip` and `zlib` encoding
|
||||
|
||||
#### Processing Paths
|
||||
|
||||
* If the either of the previous conditions failes the body is passed on as is and no further processing from this plugin occurs.
|
||||
|
||||
* If the `Content-Encoding` is empty or `identity` it is handled in mostly the same manner as the original plugin.
|
||||
|
||||
* If the `Content-Encoding` is `gzip` the following process happens:
|
||||
* The body content is decompressed by [Go-lang's gzip library](https://pkg.go.dev/compress/gzip)
|
||||
* The resulting content is run through the `regex` process created by the original plugin
|
||||
* The processed content is then compressed with the same library and returned
|
||||
|
||||
## Configuration
|
||||
|
||||
### Static
|
||||
|
||||
```yaml
|
||||
pilot:
|
||||
token: "xxxx"
|
||||
|
||||
experimental:
|
||||
plugins:
|
||||
rewrite-body:
|
||||
moduleName: "github.com/packruler/rewrite-body"
|
||||
version: "v1.1.0"
|
||||
```
|
||||
|
||||
### Dynamic
|
||||
|
||||
To configure the `Rewrite Body` plugin you should create a [middleware](https://docs.traefik.io/middlewares/overview/) in
|
||||
your dynamic configuration as explained [here](https://docs.traefik.io/middlewares/overview/). The following example creates
|
||||
and uses the `rewritebody` middleware plugin to replace all foo occurences by bar in the HTTP response body.
|
||||
|
||||
If you want to apply some limits on the response body, you can chain this middleware plugin with the [Buffering middleware](https://docs.traefik.io/middlewares/buffering/) from Traefik.
|
||||
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
my-router:
|
||||
rule: "Host(`localhost`)"
|
||||
middlewares:
|
||||
- "rewrite-foo"
|
||||
service: "my-service"
|
||||
|
||||
middlewares:
|
||||
rewrite-foo:
|
||||
plugin:
|
||||
rewrite-body:
|
||||
# Keep Last-Modified header returned by the HTTP service.
|
||||
# By default, the Last-Modified header is removed.
|
||||
lastModified: true
|
||||
|
||||
# Rewrites all "foo" occurences by "bar"
|
||||
rewrites:
|
||||
- regex: "foo"
|
||||
replacement: "bar"
|
||||
|
||||
# logLevel is optional, defaults to Info level.
|
||||
# Available logLevels: (Trace: -2, Debug: -1, Info: 0, Warning: 1, Error: 2)
|
||||
logLevel: 0
|
||||
|
||||
# monitoring is optional, defaults to below configuration
|
||||
# monitoring configuration limits the HTTP queries that are checked for regex replacement.
|
||||
monitoring:
|
||||
# methods is a string list. Options are standard HTTP Methods. Entries MUST be ALL CAPS
|
||||
# For a list of options: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
|
||||
methods:
|
||||
- GET
|
||||
# types is a string list. Options are HTTP Content Types. Entries should match standard formatting
|
||||
# For a list of options: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
|
||||
# Wildcards(*) are not supported!
|
||||
types:
|
||||
- text/html
|
||||
services:
|
||||
my-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1"
|
||||
```
|
||||
|
||||
## Example theme.park
|
||||
|
||||
### Dynamic
|
||||
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
sonarr-router:
|
||||
rule: "Host(`sonarr.example.com`)"
|
||||
middlewares:
|
||||
- sonarr-theme
|
||||
service: sonarr-service
|
||||
|
||||
middlewares:
|
||||
sonarr-theme:
|
||||
plugin:
|
||||
rewrite-body:
|
||||
rewrites:
|
||||
- regex: </head>
|
||||
replacement: <link rel="stylesheet" type="text/css" href="https://theme-park.dev/css/base/sonarr/{{ env "THEME" }}.css"></head>
|
||||
|
||||
services:
|
||||
sonarr-service:
|
||||
servers:
|
||||
- url: http://localhost:8989
|
||||
```
|
||||
|
||||
You can set an environment variable `THEME` to the name of a theme for easier consistency across apps.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package compressutil
|
||||
|
||||
const (
|
||||
// Gzip compression algorithm string.
|
||||
Gzip string = "gzip"
|
||||
// Deflate compression algorithm string.
|
||||
Deflate string = "deflate"
|
||||
// Identity compression algorithm string.
|
||||
Identity string = "identity"
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package compressutil a plugin to handle compression and decompression tasks
|
||||
package compressutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"log"
|
||||
)
|
||||
|
||||
// ReaderError for notating that an error occurred while reading compressed data.
|
||||
type ReaderError struct {
|
||||
error
|
||||
|
||||
cause error
|
||||
}
|
||||
|
||||
// Decode data in a bytes.Reader based on supplied encoding.
|
||||
func Decode(byteReader *bytes.Buffer, encoding string) ([]byte, error) {
|
||||
reader, err := getRawReader(byteReader, encoding)
|
||||
if err != nil {
|
||||
return nil, &ReaderError{
|
||||
error: err,
|
||||
cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
|
||||
func getRawReader(byteReader *bytes.Buffer, encoding string) (io.Reader, error) {
|
||||
switch encoding {
|
||||
case Gzip:
|
||||
return gzip.NewReader(byteReader)
|
||||
|
||||
case Deflate:
|
||||
return flate.NewReader(byteReader), nil
|
||||
|
||||
default:
|
||||
return byteReader, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Encode data in a []byte based on supplied encoding.
|
||||
func Encode(data []byte, encoding string) ([]byte, error) {
|
||||
switch encoding {
|
||||
case Gzip:
|
||||
return compressWithGzip(data)
|
||||
|
||||
case Deflate:
|
||||
return compressWithZlib(data)
|
||||
|
||||
default:
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
func compressWithGzip(bodyBytes []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gzipWriter := gzip.NewWriter(&buf)
|
||||
|
||||
if _, err := gzipWriter.Write(bodyBytes); err != nil {
|
||||
log.Printf("unable to recompress rewrited body: %v", err)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := gzipWriter.Close(); err != nil {
|
||||
log.Printf("unable to close gzip writer: %v", err)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func compressWithZlib(bodyBytes []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zlibWriter, _ := flate.NewWriter(&buf, flate.DefaultCompression)
|
||||
|
||||
if _, err := zlibWriter.Write(bodyBytes); err != nil {
|
||||
log.Printf("unable to recompress rewrited body: %v", err)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := zlibWriter.Close(); err != nil {
|
||||
log.Printf("unable to close zlib writer: %v", err)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package compressutil_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/packruler/rewrite-body/compressutil"
|
||||
)
|
||||
|
||||
type TestStruct struct {
|
||||
desc string
|
||||
input []byte
|
||||
expected []byte
|
||||
encoding string
|
||||
shouldMatch bool
|
||||
}
|
||||
|
||||
func TestEncode(t *testing.T) {
|
||||
var (
|
||||
deflatedBytes = []byte{
|
||||
74, 203, 207, 87, 200, 44, 86, 40, 201, 72, 85,
|
||||
200, 75, 45, 87, 72, 74, 44, 2, 4, 0, 0, 255, 255,
|
||||
}
|
||||
gzippedBytes = []byte{
|
||||
31, 139, 8, 0, 0, 0, 0, 0, 0, 255, 74, 203, 207, 87, 200, 44, 86, 40, 201, 72, 85,
|
||||
200, 75, 45, 87, 72, 74, 44, 2, 4, 0, 0, 255, 255, 251, 28, 166, 187, 18, 0, 0, 0,
|
||||
}
|
||||
normalBytes = []byte("foo is the new bar")
|
||||
)
|
||||
|
||||
tests := []TestStruct{
|
||||
{
|
||||
desc: "should support identity",
|
||||
input: normalBytes,
|
||||
expected: normalBytes,
|
||||
encoding: compressutil.Identity,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "should support gzip",
|
||||
input: normalBytes,
|
||||
expected: gzippedBytes,
|
||||
encoding: compressutil.Gzip,
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
desc: "should support deflate",
|
||||
input: normalBytes,
|
||||
expected: deflatedBytes,
|
||||
encoding: compressutil.Deflate,
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
desc: "should NOT support brotli",
|
||||
input: normalBytes,
|
||||
expected: normalBytes,
|
||||
encoding: "br",
|
||||
shouldMatch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
output, err := compressutil.Encode(test.input, test.encoding)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
isBad := !bytes.Equal(test.expected, output)
|
||||
|
||||
if isBad {
|
||||
t.Errorf("expected error got body: %v\n wanted: %v", output, test.expected)
|
||||
}
|
||||
|
||||
if test.shouldMatch {
|
||||
isBad = !bytes.Equal(test.input, output)
|
||||
} else {
|
||||
isBad = bytes.Equal(test.input, output)
|
||||
}
|
||||
if isBad {
|
||||
t.Errorf("match error got body: %v\n wanted: %v", output, test.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode(t *testing.T) {
|
||||
var (
|
||||
deflatedBytes = []byte{
|
||||
74, 203, 207, 87, 200, 44, 86, 40, 201, 72, 85,
|
||||
200, 75, 45, 87, 72, 74, 44, 2, 4, 0, 0, 255, 255,
|
||||
}
|
||||
gzippedBytes = []byte{
|
||||
31, 139, 8, 0, 0, 0, 0, 0, 0, 255, 74, 203, 207, 87, 200, 44, 86, 40, 201, 72, 85,
|
||||
200, 75, 45, 87, 72, 74, 44, 2, 4, 0, 0, 255, 255, 251, 28, 166, 187, 18, 0, 0, 0,
|
||||
}
|
||||
normalBytes = []byte("foo is the new bar")
|
||||
)
|
||||
|
||||
tests := []TestStruct{
|
||||
{
|
||||
desc: "should support identity",
|
||||
input: normalBytes,
|
||||
expected: normalBytes,
|
||||
encoding: compressutil.Identity,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
desc: "should support gzip",
|
||||
input: gzippedBytes,
|
||||
expected: normalBytes,
|
||||
encoding: compressutil.Gzip,
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
desc: "should support deflate",
|
||||
input: deflatedBytes,
|
||||
expected: normalBytes,
|
||||
encoding: compressutil.Deflate,
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
desc: "should NOT support brotli",
|
||||
input: normalBytes,
|
||||
expected: normalBytes,
|
||||
encoding: "br",
|
||||
shouldMatch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
output, err := compressutil.Decode(bytes.NewBuffer(test.input), test.encoding)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
isBad := !bytes.Equal(test.expected, output)
|
||||
|
||||
if isBad {
|
||||
t.Errorf("expected error got body: %v\n wanted: %v", output, test.expected)
|
||||
}
|
||||
|
||||
if test.shouldMatch {
|
||||
isBad = !bytes.Equal(test.input, output)
|
||||
} else {
|
||||
isBad = bytes.Equal(test.input, output)
|
||||
}
|
||||
if isBad {
|
||||
t.Errorf("match error got body: %s\n wanted: %s", output, test.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[config]
|
||||
lastModified = true
|
||||
logLevel = 0
|
||||
[[config.rewrites]]
|
||||
regex = "test"
|
||||
replacement = "test"
|
||||
[config.monitor]
|
||||
types = ["text/html"]
|
||||
methods = ["GET"]
|
||||
@@ -0,0 +1,10 @@
|
||||
config:
|
||||
lastModified: true
|
||||
logLevel: 0
|
||||
monitor:
|
||||
types:
|
||||
- text/html
|
||||
methods: ["GET"]
|
||||
rewrites:
|
||||
- regex: "test"
|
||||
replacement: "test"
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/packruler/rewrite-body
|
||||
|
||||
go 1.16
|
||||
@@ -0,0 +1,26 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/packruler/rewrite-body/httputil"
|
||||
)
|
||||
|
||||
// Rewrite holds one rewrite body configuration.
|
||||
type Rewrite struct {
|
||||
Regex string `json:"regex" yaml:"regex" toml:"regex"`
|
||||
Replacement string `json:"replacement" yaml:"replacement" toml:"replacement"`
|
||||
}
|
||||
|
||||
// Config holds the plugin configuration.
|
||||
type Config struct {
|
||||
LastModified bool `json:"lastModified" toml:"lastModified" yaml:"lastModified"`
|
||||
Rewrites []Rewrite `json:"rewrites" toml:"rewrites" yaml:"rewrites"`
|
||||
LogLevel int8 `json:"logLevel" toml:"logLevel" yaml:"logLevel"`
|
||||
Monitoring httputil.MonitoringConfig `json:"monitoring" toml:"monitoring" yaml:"monitoring"`
|
||||
}
|
||||
|
||||
type rewrite struct {
|
||||
regex *regexp.Regexp
|
||||
replacement []byte
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Package handler a plugin to rewrite response body.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/packruler/rewrite-body/httputil"
|
||||
"github.com/packruler/rewrite-body/logger"
|
||||
)
|
||||
|
||||
type rewriteBody struct {
|
||||
name string
|
||||
next http.Handler
|
||||
rewrites []rewrite
|
||||
lastModified bool
|
||||
logger logger.LogWriter
|
||||
monitoringConfig httputil.MonitoringConfig
|
||||
}
|
||||
|
||||
// New creates and returns a new rewrite body plugin instance.
|
||||
func New(_ context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
|
||||
rewrites := make([]rewrite, len(config.Rewrites))
|
||||
|
||||
for index, rewriteConfig := range config.Rewrites {
|
||||
regex, err := regexp.Compile(rewriteConfig.Regex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error compiling regex %q: %w", rewriteConfig.Regex, err)
|
||||
}
|
||||
|
||||
rewrites[index] = rewrite{
|
||||
regex: regex,
|
||||
replacement: []byte(rewriteConfig.Replacement),
|
||||
}
|
||||
}
|
||||
|
||||
logWriter := *logger.CreateLogger(logger.LogLevel(config.LogLevel))
|
||||
|
||||
config.Monitoring.EnsureDefaults()
|
||||
config.Monitoring.EnsureProperFormat()
|
||||
|
||||
result := &rewriteBody{
|
||||
name: name,
|
||||
next: next,
|
||||
rewrites: rewrites,
|
||||
lastModified: config.LastModified,
|
||||
logger: logWriter,
|
||||
monitoringConfig: config.Monitoring,
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(config)
|
||||
|
||||
logWriter.LogDebugf("Initial config: %v", string(data))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (bodyRewrite *rewriteBody) ServeHTTP(response http.ResponseWriter, req *http.Request) {
|
||||
defer bodyRewrite.handlePanic()
|
||||
|
||||
wrappedRequest := httputil.WrapRequest(req, bodyRewrite.monitoringConfig, bodyRewrite.logger)
|
||||
// allow default http.ResponseWriter to handle calls targeting WebSocket upgrades and non GET methods
|
||||
if !wrappedRequest.SupportsProcessing() {
|
||||
bodyRewrite.logger.LogDebugf("Ignoring unsupported request: %v", req)
|
||||
bodyRewrite.next.ServeHTTP(response, req)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
bodyRewrite.logger.LogDebugf("Starting supported request: %v", req)
|
||||
|
||||
wrappedWriter := httputil.WrapWriter(
|
||||
response,
|
||||
bodyRewrite.monitoringConfig,
|
||||
bodyRewrite.logger,
|
||||
bodyRewrite.lastModified,
|
||||
)
|
||||
|
||||
wrappedWriter.SetLastModified(bodyRewrite.lastModified)
|
||||
|
||||
// look into using https://pkg.go.dev/net/http#RoundTripper
|
||||
bodyRewrite.next.ServeHTTP(wrappedWriter, wrappedRequest.CloneWithSupportedEncoding())
|
||||
|
||||
if !wrappedWriter.SupportsProcessing() {
|
||||
// We are ignoring these any errors because the content should be unchanged here.
|
||||
// This could "error" if writing is not supported but content will return properly.
|
||||
_, _ = response.Write(wrappedWriter.GetBuffer().Bytes())
|
||||
bodyRewrite.logger.LogDebugf("Ignoring unsupported response: %v", wrappedWriter)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := wrappedWriter.GetContent()
|
||||
if err != nil {
|
||||
bodyRewrite.logger.LogErrorf("Error loading content: %v", err)
|
||||
|
||||
if _, err := response.Write(wrappedWriter.GetBuffer().Bytes()); err != nil {
|
||||
bodyRewrite.logger.LogErrorf("unable to write error content: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
bodyRewrite.logger.LogDebugf("Response body: %s", bodyBytes)
|
||||
|
||||
if len(bodyBytes) == 0 {
|
||||
// If the body is empty there is no purpose in continuing this process.
|
||||
return
|
||||
}
|
||||
|
||||
for _, rwt := range bodyRewrite.rewrites {
|
||||
bodyBytes = rwt.regex.ReplaceAll(bodyBytes, rwt.replacement)
|
||||
}
|
||||
|
||||
bodyRewrite.logger.LogDebugf("Transformed body: %s", bodyBytes)
|
||||
|
||||
encoding := wrappedWriter.Header().Get("Content-Encoding")
|
||||
wrappedWriter.SetContent(bodyBytes, encoding)
|
||||
}
|
||||
|
||||
func (bodyRewrite *rewriteBody) handlePanic() {
|
||||
if recovery := recover(); recovery != nil {
|
||||
if err, ok := recovery.(error); ok {
|
||||
bodyRewrite.logError(err)
|
||||
} else {
|
||||
bodyRewrite.logger.LogWarningf("Unhandled error: %v", recovery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (bodyRewrite *rewriteBody) logError(err error) {
|
||||
// Ignore http.ErrAbortHandler because they are expected errors that do not require handling
|
||||
if errors.Is(err, http.ErrAbortHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
bodyRewrite.logger.LogWarningf("Recovered from: %v", err)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/packruler/rewrite-body/compressutil"
|
||||
"github.com/packruler/rewrite-body/httputil"
|
||||
)
|
||||
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
contentEncoding string
|
||||
contentType string `default:"text/html"`
|
||||
rewrites []Rewrite
|
||||
lastModified bool
|
||||
resBody string
|
||||
expResBody string
|
||||
expLastModified bool
|
||||
}{
|
||||
{
|
||||
desc: "should replace foo by bar",
|
||||
rewrites: []Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentType: "text/html",
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "bar is the new bar",
|
||||
},
|
||||
{
|
||||
desc: "should replace foo by bar, then by foo",
|
||||
rewrites: []Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
{
|
||||
Regex: "bar",
|
||||
Replacement: "foo",
|
||||
},
|
||||
},
|
||||
contentType: "text/html",
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "foo is the new foo",
|
||||
},
|
||||
{
|
||||
desc: "should not replace anything if content encoding is not identity or empty",
|
||||
rewrites: []Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentEncoding: "other",
|
||||
contentType: "text/html",
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "foo is the new bar",
|
||||
},
|
||||
{
|
||||
desc: "should not replace anything if content type does not contain text or is not empty",
|
||||
rewrites: []Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentType: "image",
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "foo is the new bar",
|
||||
},
|
||||
{
|
||||
desc: "should replace foo by bar if content encoding is identity",
|
||||
rewrites: []Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentEncoding: "identity",
|
||||
contentType: "text/html",
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "bar is the new bar",
|
||||
},
|
||||
{
|
||||
desc: "should not remove the last modified header",
|
||||
rewrites: []Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentEncoding: "identity",
|
||||
contentType: "text/html",
|
||||
lastModified: true,
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "bar is the new bar",
|
||||
expLastModified: true,
|
||||
},
|
||||
{
|
||||
desc: "should support gzip encoding",
|
||||
rewrites: []Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentEncoding: "gzip",
|
||||
contentType: "text/html",
|
||||
lastModified: true,
|
||||
resBody: compressString("foo is the new bar", "gzip"),
|
||||
expResBody: compressString("bar is the new bar", "gzip"),
|
||||
expLastModified: true,
|
||||
},
|
||||
{
|
||||
desc: "should support deflate encoding",
|
||||
rewrites: []Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentEncoding: "deflate",
|
||||
contentType: "text/html",
|
||||
lastModified: true,
|
||||
resBody: compressString("foo is the new bar", "deflate"),
|
||||
expResBody: compressString("bar is the new bar", "deflate"),
|
||||
expLastModified: true,
|
||||
},
|
||||
{
|
||||
desc: "should ignore unsupported encoding",
|
||||
rewrites: []Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentEncoding: "br",
|
||||
contentType: "text/html",
|
||||
lastModified: true,
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "foo is the new bar",
|
||||
expLastModified: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
config := &Config{
|
||||
LastModified: test.lastModified,
|
||||
Rewrites: test.rewrites,
|
||||
LogLevel: -1,
|
||||
}
|
||||
|
||||
next := func(responseWriter http.ResponseWriter, req *http.Request) {
|
||||
responseWriter.Header().Set("Content-Encoding", test.contentEncoding)
|
||||
responseWriter.Header().Set("Content-Type", test.contentType)
|
||||
responseWriter.Header().Set("Last-Modified", "Thu, 02 Jun 2016 06:01:08 GMT")
|
||||
responseWriter.Header().Set("Content-Length", strconv.Itoa(len(test.resBody)))
|
||||
responseWriter.WriteHeader(http.StatusOK)
|
||||
|
||||
_, _ = fmt.Fprintf(responseWriter, test.resBody)
|
||||
}
|
||||
|
||||
rewriteBody, err := New(context.Background(), http.HandlerFunc(next), config, "rewriteBody")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Accept", "text/html")
|
||||
|
||||
rewriteBody.ServeHTTP(recorder, req)
|
||||
|
||||
if _, exists := recorder.Result().Header["Last-Modified"]; exists != test.expLastModified {
|
||||
t.Errorf("got last-modified header %v, want %v", exists, test.expLastModified)
|
||||
}
|
||||
|
||||
if _, exists := recorder.Result().Header["Content-Length"]; exists {
|
||||
t.Error("The Content-Length Header must be deleted")
|
||||
}
|
||||
|
||||
if !bytes.Equal([]byte(test.expResBody), recorder.Body.Bytes()) {
|
||||
t.Errorf("got body: %v\n wanted: %v", recorder.Body.Bytes(), []byte(test.expResBody))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func compressString(value string, encoding string) string {
|
||||
compressed, _ := compressutil.Encode([]byte(value), encoding)
|
||||
|
||||
return string(compressed)
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
rewrites []Rewrite
|
||||
expErr bool
|
||||
}{
|
||||
{
|
||||
desc: "should return no error",
|
||||
rewrites: []Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
{
|
||||
Regex: "bar",
|
||||
Replacement: "foo",
|
||||
},
|
||||
},
|
||||
expErr: false,
|
||||
},
|
||||
{
|
||||
desc: "should return an error",
|
||||
rewrites: []Rewrite{
|
||||
{
|
||||
Regex: "*",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
expErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
defaultMonitoring := httputil.MonitoringConfig{
|
||||
Types: []string{"text/html"},
|
||||
Methods: []string{http.MethodGet},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
config := &Config{
|
||||
Rewrites: test.rewrites,
|
||||
Monitoring: defaultMonitoring,
|
||||
}
|
||||
|
||||
_, err := New(context.Background(), nil, config, "rewriteBody")
|
||||
if test.expErr && err == nil {
|
||||
t.Fatal("expected error on bad regexp format")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MonitoringConfig structure of data for handling configuration for
|
||||
// controlling what content is monitored.
|
||||
type MonitoringConfig struct {
|
||||
Types []string `json:"types,omitempty" yaml:"types,omitempty" toml:"types,omitempty" export:"true"`
|
||||
Methods []string `json:"methods,omitempty" yaml:"methods,omitempty" toml:"methods,omitempty" export:"true"`
|
||||
}
|
||||
|
||||
// EnsureDefaults check Types and Methods for empty arrays and apply default values if found.
|
||||
func (config *MonitoringConfig) EnsureDefaults() {
|
||||
if len(config.Methods) == 0 {
|
||||
config.Methods = []string{http.MethodGet}
|
||||
}
|
||||
|
||||
if len(config.Types) == 0 {
|
||||
config.Types = []string{"text/html"}
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureProperFormat handle weird yaml parsing until the underlying issue can be resolved.
|
||||
func (config *MonitoringConfig) EnsureProperFormat() {
|
||||
if len(config.Methods) == 1 && strings.HasPrefix(config.Methods[0], "║24║") {
|
||||
config.Methods = strings.Split(strings.ReplaceAll(config.Methods[0], "║24║", ""), "║")
|
||||
}
|
||||
|
||||
if len(config.Types) == 1 && strings.HasPrefix(config.Types[0], "║24║") {
|
||||
config.Types = strings.Split(strings.ReplaceAll(config.Types[0], "║24║", ""), "║")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package httputil_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/packruler/rewrite-body/httputil"
|
||||
)
|
||||
|
||||
func TestMonitoringConfigParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
inputConfig httputil.MonitoringConfig
|
||||
expectedConfig httputil.MonitoringConfig
|
||||
}{
|
||||
{
|
||||
desc: "defaults will be supplied for empty arrays",
|
||||
inputConfig: httputil.MonitoringConfig{
|
||||
Types: []string{},
|
||||
Methods: []string{},
|
||||
},
|
||||
expectedConfig: httputil.MonitoringConfig{
|
||||
Types: []string{"text/html"},
|
||||
Methods: []string{http.MethodGet},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "defaults will be supplied for empty types with populated methods",
|
||||
inputConfig: httputil.MonitoringConfig{
|
||||
Types: []string{},
|
||||
Methods: []string{"POST"},
|
||||
},
|
||||
expectedConfig: httputil.MonitoringConfig{
|
||||
Types: []string{"text/html"},
|
||||
Methods: []string{http.MethodPost},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "defaults will be supplied for populated types with empty methods",
|
||||
inputConfig: httputil.MonitoringConfig{
|
||||
Types: []string{"application/json"},
|
||||
Methods: []string{},
|
||||
},
|
||||
expectedConfig: httputil.MonitoringConfig{
|
||||
Types: []string{"application/json"},
|
||||
Methods: []string{http.MethodGet},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "handle weird yaml parsing",
|
||||
inputConfig: httputil.MonitoringConfig{
|
||||
Types: []string{"║24║application/javascript║application/json"},
|
||||
Methods: []string{},
|
||||
},
|
||||
expectedConfig: httputil.MonitoringConfig{
|
||||
Types: []string{"application/javascript", "application/json"},
|
||||
Methods: []string{http.MethodGet},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
config := test.inputConfig
|
||||
|
||||
config.EnsureDefaults()
|
||||
config.EnsureProperFormat()
|
||||
|
||||
if len(config.Types) != len(test.expectedConfig.Types) {
|
||||
t.Errorf("Expected Types: '%v' | Got Types: '%v'", test.expectedConfig.Types, config.Types)
|
||||
}
|
||||
|
||||
for i, v := range test.expectedConfig.Types {
|
||||
if v != config.Types[i] {
|
||||
t.Errorf("Expected Types: '%v' | Got Types: '%v'", test.expectedConfig.Types, config.Types)
|
||||
}
|
||||
}
|
||||
|
||||
if len(config.Methods) != len(test.expectedConfig.Methods) {
|
||||
t.Errorf("Expected Methods: '%v' | Got Methods: '%v'", test.expectedConfig.Methods, config.Methods)
|
||||
}
|
||||
|
||||
for i, v := range test.expectedConfig.Methods {
|
||||
if v != config.Methods[i] {
|
||||
t.Errorf("Expected Methods: '%v' | Got Methods: '%v'", test.expectedConfig.Methods, config.Methods)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/packruler/rewrite-body/compressutil"
|
||||
"github.com/packruler/rewrite-body/logger"
|
||||
)
|
||||
|
||||
// RequestWrapper a struct that centralizes request modifications.
|
||||
type RequestWrapper struct {
|
||||
logWriter logger.LogWriter
|
||||
monitoring MonitoringConfig
|
||||
|
||||
http.Request
|
||||
}
|
||||
|
||||
// WrapRequest to get a new instance of RequestWrapper.
|
||||
func WrapRequest(request *http.Request, monitoringConfig MonitoringConfig, logWriter logger.LogWriter) *RequestWrapper {
|
||||
return &RequestWrapper{
|
||||
logWriter: logWriter,
|
||||
monitoring: monitoringConfig,
|
||||
Request: *request,
|
||||
}
|
||||
}
|
||||
|
||||
// CloneNoEncode create an http.Request that request no encoding.
|
||||
func (req *RequestWrapper) CloneNoEncode() *http.Request {
|
||||
clonedRequest := req.Clone(req.Context())
|
||||
|
||||
clonedRequest.Header.Set("Accept-Encoding", compressutil.Identity)
|
||||
|
||||
return clonedRequest
|
||||
}
|
||||
|
||||
// CloneWithSupportedEncoding create an http.Request that request only supported encoding.
|
||||
func (req *RequestWrapper) CloneWithSupportedEncoding() *http.Request {
|
||||
clonedRequest := req.Clone(req.Context())
|
||||
|
||||
clonedRequest.Header.Set("Accept-Encoding", removeUnsupportedAcceptEncoding(clonedRequest.Header))
|
||||
|
||||
return clonedRequest
|
||||
}
|
||||
|
||||
// GetEncodingTarget get the supported encoding algorithm preferred by request.
|
||||
func (req *RequestWrapper) GetEncodingTarget() string {
|
||||
// Limit Accept-Encoding header to encodings we can handle.
|
||||
// acceptEncoding := header.ParseAccept(req.Header, "Accept-Encoding")
|
||||
acceptEncoding := parseAcceptEncoding(req.Header)
|
||||
filteredEncodings := make([]encodingSpec, 0, len(acceptEncoding))
|
||||
|
||||
for _, a := range acceptEncoding {
|
||||
switch a.Value {
|
||||
case compressutil.Gzip, compressutil.Deflate:
|
||||
filteredEncodings = append(filteredEncodings, a)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filteredEncodings) == 0 {
|
||||
return compressutil.Identity
|
||||
}
|
||||
|
||||
sort.Slice(filteredEncodings, func(i, j int) bool {
|
||||
return filteredEncodings[i].Quality > filteredEncodings[j].Quality
|
||||
})
|
||||
|
||||
return filteredEncodings[0].Value
|
||||
}
|
||||
|
||||
type encodingSpec struct {
|
||||
Value string
|
||||
Quality float64
|
||||
}
|
||||
|
||||
func parseAcceptEncoding(header http.Header) []encodingSpec {
|
||||
encodingHeader := header.Get("Accept-Encoding")
|
||||
if encodingHeader == "*" {
|
||||
return []encodingSpec{{Quality: 1.0, Value: compressutil.Gzip}, {Quality: 1.0, Value: compressutil.Deflate}}
|
||||
}
|
||||
|
||||
encodingList := strings.Split(encodingHeader, ",")
|
||||
result := make([]encodingSpec, 0, len(encodingList))
|
||||
|
||||
for _, encoding := range encodingList {
|
||||
result = append(result, parseEncodingItem(encoding))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func parseEncodingItem(encoding string) encodingSpec {
|
||||
encoding = strings.TrimSpace(encoding)
|
||||
if encoding == "*" {
|
||||
return encodingSpec{Value: compressutil.Gzip, Quality: 1.0}
|
||||
}
|
||||
|
||||
split := strings.Split(encoding, ";q=")
|
||||
quality := 1.0
|
||||
|
||||
if qualitySplitSize := 2; len(split) == qualitySplitSize {
|
||||
targetFloat := 64
|
||||
|
||||
parsedQuality, err := strconv.ParseFloat(split[1], targetFloat)
|
||||
if err == nil {
|
||||
quality = parsedQuality
|
||||
}
|
||||
}
|
||||
|
||||
return encodingSpec{Value: split[0], Quality: quality}
|
||||
}
|
||||
|
||||
func removeUnsupportedAcceptEncoding(header http.Header) string {
|
||||
encodingList := strings.Split(header.Get("Accept-Encoding"), ",")
|
||||
result := make([]string, 0, len(encodingList))
|
||||
|
||||
for _, encoding := range encodingList {
|
||||
split := strings.Split(strings.TrimSpace(encoding), ";q=")
|
||||
switch split[0] {
|
||||
case compressutil.Gzip, compressutil.Deflate, compressutil.Identity:
|
||||
result = append(result, encoding)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(result, ",")
|
||||
}
|
||||
|
||||
// SupportsProcessing determine if http.Request is supported by this plugin.
|
||||
func (req *RequestWrapper) SupportsProcessing() bool {
|
||||
acceptHeader := req.Header.Get("Accept")
|
||||
isSupported := false
|
||||
|
||||
for _, monitoredType := range req.monitoring.Types {
|
||||
if strings.Contains(acceptHeader, monitoredType) {
|
||||
isSupported = true
|
||||
}
|
||||
}
|
||||
|
||||
if !isSupported {
|
||||
return false
|
||||
}
|
||||
|
||||
isSupported = false
|
||||
|
||||
// Ignore non GET requests
|
||||
for _, monitoredMethod := range req.monitoring.Methods {
|
||||
if strings.Contains(req.Method, monitoredMethod) {
|
||||
isSupported = true
|
||||
}
|
||||
}
|
||||
|
||||
if !isSupported {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.Contains(req.Header.Get("Upgrade"), "websocket") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/packruler/rewrite-body/logger"
|
||||
)
|
||||
|
||||
func TestGetEncodingTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
acceptEncoding string
|
||||
expectedTarget string
|
||||
}{
|
||||
{
|
||||
desc: "Supports gzip",
|
||||
acceptEncoding: "gzip",
|
||||
expectedTarget: "gzip",
|
||||
},
|
||||
{
|
||||
desc: "Supports deflate",
|
||||
acceptEncoding: "deflate",
|
||||
expectedTarget: "deflate",
|
||||
},
|
||||
{
|
||||
desc: "Supports identity",
|
||||
acceptEncoding: "identity",
|
||||
expectedTarget: "identity",
|
||||
},
|
||||
{
|
||||
desc: "Ignores brotli",
|
||||
acceptEncoding: "br, gzip",
|
||||
expectedTarget: "gzip",
|
||||
},
|
||||
{
|
||||
desc: "Wildcard to gzip",
|
||||
acceptEncoding: "*",
|
||||
expectedTarget: "gzip",
|
||||
},
|
||||
{
|
||||
desc: "Respects quality in order",
|
||||
acceptEncoding: "gzip;q=0.8, deflate;q=0.6",
|
||||
expectedTarget: "gzip",
|
||||
},
|
||||
{
|
||||
desc: "Respects quality out of order",
|
||||
acceptEncoding: "gzip;q=0.8, deflate;q=0.9",
|
||||
expectedTarget: "deflate",
|
||||
},
|
||||
}
|
||||
|
||||
defaultMonitoring := MonitoringConfig{
|
||||
Types: []string{"text/html"},
|
||||
Methods: []string{"GET"},
|
||||
}
|
||||
|
||||
defaultLogWriter := logger.CreateLogger(logger.Error)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
request, err := http.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
"http://google.com",
|
||||
&bytes.Reader{})
|
||||
if err != nil {
|
||||
t.Errorf("Error creating request: %v", err)
|
||||
}
|
||||
request.Header.Set("Accept-Encoding", test.acceptEncoding)
|
||||
|
||||
wrappedRequest := WrapRequest(request, defaultMonitoring, *defaultLogWriter)
|
||||
target := wrappedRequest.GetEncodingTarget()
|
||||
if target != test.expectedTarget {
|
||||
t.Errorf("Expected: '%s' | Got: '%s'", test.expectedTarget, target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUnuspportedEncoding(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
acceptEncoding string
|
||||
expectedTarget string
|
||||
}{
|
||||
{
|
||||
desc: "Supports gzip",
|
||||
acceptEncoding: "gzip",
|
||||
expectedTarget: "gzip",
|
||||
},
|
||||
{
|
||||
desc: "Supports deflate",
|
||||
acceptEncoding: "deflate",
|
||||
expectedTarget: "deflate",
|
||||
},
|
||||
{
|
||||
desc: "Supports identity",
|
||||
acceptEncoding: "identity",
|
||||
expectedTarget: "identity",
|
||||
},
|
||||
{
|
||||
desc: "Ignores brotli",
|
||||
acceptEncoding: "br, gzip",
|
||||
expectedTarget: " gzip",
|
||||
},
|
||||
{
|
||||
desc: "Wildcard is dropped",
|
||||
acceptEncoding: "*",
|
||||
expectedTarget: "",
|
||||
},
|
||||
{
|
||||
desc: "Respects quality in order",
|
||||
acceptEncoding: "gzip;q=0.8, deflate;q=0.6",
|
||||
expectedTarget: "gzip;q=0.8, deflate;q=0.6",
|
||||
},
|
||||
}
|
||||
|
||||
defaultMonitoring := MonitoringConfig{
|
||||
Types: []string{"text/html"},
|
||||
Methods: []string{"GET"},
|
||||
}
|
||||
|
||||
defaultLogWriter := logger.CreateLogger(logger.Error)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
request, err := http.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet,
|
||||
"http://google.com",
|
||||
&bytes.Reader{})
|
||||
if err != nil {
|
||||
t.Errorf("Error creating request: %v", err)
|
||||
}
|
||||
request.Header.Set("Accept-Encoding", test.acceptEncoding)
|
||||
|
||||
wrappedRequest := WrapRequest(request, defaultMonitoring, *defaultLogWriter)
|
||||
target := wrappedRequest.CloneWithSupportedEncoding().Header.Get("Accept-Encoding")
|
||||
|
||||
if target != test.expectedTarget {
|
||||
t.Errorf("Expected: '%s' | Got: '%s'", test.expectedTarget, target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportsProcessing(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
inputType string
|
||||
inputMethod string
|
||||
monitoringConfig MonitoringConfig
|
||||
expectedSupport bool
|
||||
}{
|
||||
{
|
||||
desc: "Supports default config",
|
||||
inputType: "text/html",
|
||||
inputMethod: http.MethodGet,
|
||||
expectedSupport: true,
|
||||
monitoringConfig: MonitoringConfig{
|
||||
Types: []string{"text/html"},
|
||||
Methods: []string{"GET"},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "Supports default browser load",
|
||||
inputType: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
|
||||
inputMethod: http.MethodGet,
|
||||
expectedSupport: true,
|
||||
monitoringConfig: MonitoringConfig{
|
||||
Types: []string{"text/html"},
|
||||
Methods: []string{"GET"},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "Supports when types includes unsupported type first",
|
||||
inputType: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
|
||||
inputMethod: http.MethodGet,
|
||||
expectedSupport: true,
|
||||
monitoringConfig: MonitoringConfig{
|
||||
Types: []string{"application/javascript", "text/html"},
|
||||
Methods: []string{"GET"},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "Supports multiple methods",
|
||||
inputType: "text/html",
|
||||
inputMethod: http.MethodPost,
|
||||
expectedSupport: true,
|
||||
monitoringConfig: MonitoringConfig{
|
||||
Types: []string{"text/html"},
|
||||
Methods: []string{"GET", "POST"},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "Does not support type not included",
|
||||
inputType: "application/javascript",
|
||||
inputMethod: http.MethodGet,
|
||||
expectedSupport: false,
|
||||
monitoringConfig: MonitoringConfig{
|
||||
Types: []string{"text/html"},
|
||||
Methods: []string{"GET"},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "Does not support method not included",
|
||||
inputType: "text/html",
|
||||
inputMethod: http.MethodPost,
|
||||
expectedSupport: false,
|
||||
monitoringConfig: MonitoringConfig{
|
||||
Types: []string{"text/html"},
|
||||
Methods: []string{"GET"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
defaultLogWriter := logger.CreateLogger(logger.Error)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
request, err := http.NewRequestWithContext(
|
||||
context.Background(),
|
||||
test.inputMethod,
|
||||
"http://google.com",
|
||||
&bytes.Reader{})
|
||||
if err != nil {
|
||||
t.Errorf("Error creating request: %v", err)
|
||||
}
|
||||
request.Header.Set("Accept", test.inputType)
|
||||
|
||||
wrappedRequest := WrapRequest(request, test.monitoringConfig, *defaultLogWriter)
|
||||
|
||||
if test.expectedSupport != wrappedRequest.SupportsProcessing() {
|
||||
t.Errorf("Test input: '%v'", test)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Package httputil a package for handling http data tasks
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/packruler/rewrite-body/compressutil"
|
||||
"github.com/packruler/rewrite-body/logger"
|
||||
)
|
||||
|
||||
// ResponseWrapper a wrapper used to simplify ResponseWriter data access and manipulation.
|
||||
type ResponseWrapper struct {
|
||||
buffer bytes.Buffer
|
||||
lastModified bool `default:"true"`
|
||||
wroteHeader bool
|
||||
|
||||
code int `default:"200"`
|
||||
|
||||
logWriter logger.LogWriter
|
||||
monitoring MonitoringConfig
|
||||
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
// WrapWriter create a ResponseWrapper for provided configuration.
|
||||
func WrapWriter(
|
||||
responseWriter http.ResponseWriter,
|
||||
monitoringConfig MonitoringConfig,
|
||||
logWriter logger.LogWriter,
|
||||
lastModified bool,
|
||||
) *ResponseWrapper {
|
||||
return &ResponseWrapper{
|
||||
buffer: bytes.Buffer{},
|
||||
lastModified: lastModified,
|
||||
wroteHeader: false,
|
||||
code: http.StatusOK,
|
||||
logWriter: logWriter,
|
||||
monitoring: monitoringConfig,
|
||||
ResponseWriter: responseWriter,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteHeader into wrapped ResponseWriter.
|
||||
func (wrapper *ResponseWrapper) WriteHeader(statusCode int) {
|
||||
if wrapper.wroteHeader {
|
||||
return
|
||||
}
|
||||
|
||||
if !wrapper.lastModified {
|
||||
wrapper.ResponseWriter.Header().Del("Last-Modified")
|
||||
}
|
||||
|
||||
wrapper.code = statusCode
|
||||
wrapper.wroteHeader = true
|
||||
|
||||
// Delegates the Content-Length Header creation to the final body write.
|
||||
wrapper.ResponseWriter.Header().Del("Content-Length")
|
||||
|
||||
wrapper.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
// Write data to internal buffer and mark the status code as http.StatusOK.
|
||||
func (wrapper *ResponseWrapper) Write(data []byte) (int, error) {
|
||||
if !wrapper.wroteHeader {
|
||||
wrapper.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
return wrapper.buffer.Write(data)
|
||||
}
|
||||
|
||||
// GetBuffer get a pointer to the ResponseWriter buffer.
|
||||
func (wrapper *ResponseWrapper) GetBuffer() *bytes.Buffer {
|
||||
return &wrapper.buffer
|
||||
}
|
||||
|
||||
// GetContent load the content currently in the internal buffer
|
||||
// accounting for possible encoding.
|
||||
func (wrapper *ResponseWrapper) GetContent() ([]byte, error) {
|
||||
encoding := wrapper.getContentEncoding()
|
||||
|
||||
return compressutil.Decode(wrapper.GetBuffer(), encoding)
|
||||
}
|
||||
|
||||
// SetContent write data to the internal ResponseWriter buffer
|
||||
// and match initial encoding.
|
||||
func (wrapper *ResponseWrapper) SetContent(data []byte, encoding string) {
|
||||
bodyBytes, _ := compressutil.Encode(data, encoding)
|
||||
|
||||
if !wrapper.wroteHeader {
|
||||
wrapper.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
if _, err := wrapper.ResponseWriter.Write(bodyBytes); err != nil {
|
||||
wrapper.logWriter.LogErrorf("unable to write rewriten body: %v", err)
|
||||
wrapper.LogHeaders()
|
||||
}
|
||||
}
|
||||
|
||||
func (wrapper *ResponseWrapper) getHeader(headerName string) string {
|
||||
return wrapper.ResponseWriter.Header().Get(headerName)
|
||||
}
|
||||
|
||||
// LogHeaders writes current response headers.
|
||||
func (wrapper *ResponseWrapper) LogHeaders() {
|
||||
wrapper.logWriter.LogDebugf("Error Headers: %v", wrapper.ResponseWriter.Header())
|
||||
}
|
||||
|
||||
// getContentEncoding get the Content-Encoding header value.
|
||||
func (wrapper *ResponseWrapper) getContentEncoding() string {
|
||||
return wrapper.getHeader("Content-Encoding")
|
||||
}
|
||||
|
||||
// getContentType get the Content-Encoding header value.
|
||||
func (wrapper *ResponseWrapper) getContentType() string {
|
||||
return wrapper.getHeader("Content-Type")
|
||||
}
|
||||
|
||||
// SupportsProcessing determine if HttpWrapper is supported by this plugin based on encoding.
|
||||
func (wrapper *ResponseWrapper) SupportsProcessing() bool {
|
||||
foundContentType := false
|
||||
|
||||
// If content type does not match return values with false
|
||||
contentType := wrapper.getContentType()
|
||||
for _, monitoredType := range wrapper.monitoring.Types {
|
||||
if strings.Contains(contentType, monitoredType) {
|
||||
foundContentType = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundContentType {
|
||||
return false
|
||||
}
|
||||
|
||||
encoding := wrapper.getContentEncoding()
|
||||
|
||||
// If content type is supported validate encoding as well
|
||||
switch encoding {
|
||||
case compressutil.Gzip, compressutil.Deflate, compressutil.Identity, "":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SetLastModified update the local lastModified variable from non-package-based users.
|
||||
func (wrapper *ResponseWrapper) SetLastModified(value bool) {
|
||||
wrapper.lastModified = value
|
||||
}
|
||||
|
||||
// CloseNotify returns a channel that receives at most a
|
||||
// single value (true) when the client connection has gone away.
|
||||
func (wrapper *ResponseWrapper) CloseNotify() <-chan bool {
|
||||
if w, ok := wrapper.ResponseWriter.(http.CloseNotifier); ok {
|
||||
return w.CloseNotify()
|
||||
}
|
||||
|
||||
return make(<-chan bool)
|
||||
}
|
||||
|
||||
// Hijack hijacks the connection.
|
||||
func (wrapper *ResponseWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
if hj, ok := wrapper.ResponseWriter.(http.Hijacker); ok {
|
||||
return hj.Hijack()
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("%T is not a http.Hijacker", wrapper.ResponseWriter)
|
||||
}
|
||||
|
||||
// Flush sends any buffered data to the client.
|
||||
func (wrapper *ResponseWrapper) Flush() {
|
||||
// If WriteHeader was already called from the caller, this is a NOOP.
|
||||
// Otherwise, codeCatcher.code is actually a 200 here.
|
||||
wrapper.WriteHeader(wrapper.code)
|
||||
|
||||
if flusher, ok := wrapper.ResponseWriter.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package logger a package for handling writing content to logs.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
// LogLevel type definition of supported log levels.
|
||||
type LogLevel int8
|
||||
|
||||
const (
|
||||
// Trace level of logs.
|
||||
Trace LogLevel = iota - 2
|
||||
// Debug level of logs.
|
||||
Debug
|
||||
// Info level of logs.
|
||||
Info
|
||||
// Warning level of logs.
|
||||
Warning
|
||||
// Error level of logs.
|
||||
Error
|
||||
)
|
||||
|
||||
// LogWriter the struct used for writing logs.
|
||||
type LogWriter struct {
|
||||
level LogLevel
|
||||
loggers map[LogLevel]*log.Logger
|
||||
}
|
||||
|
||||
// CreateLogger create the LogWriter struct with required content.
|
||||
func CreateLogger(level LogLevel) *LogWriter {
|
||||
loggers := make(map[LogLevel]*log.Logger, Error-1)
|
||||
|
||||
loggers[Trace] = log.New(os.Stdout, "Rewrite-Body | TRACE", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
loggers[Debug] = log.New(os.Stdout, "Rewrite-Body | DEBUG", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
loggers[Info] = log.New(os.Stdout, "Rewrite-Body | INFO", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
loggers[Warning] = log.New(os.Stdout, "Rewrite-Body | WARNING", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
loggers[Error] = log.New(os.Stderr, "Rewrite-Body | ERROR", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
|
||||
return &LogWriter{
|
||||
level: level,
|
||||
loggers: loggers,
|
||||
}
|
||||
}
|
||||
|
||||
func createLoggerWithBuffer(level LogLevel, buffer *bytes.Buffer) *LogWriter {
|
||||
loggers := make(map[LogLevel]*log.Logger, Error-1)
|
||||
|
||||
loggers[Trace] = log.New(buffer, "Rewrite-Body | TRACE", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
loggers[Debug] = log.New(buffer, "Rewrite-Body | DEBUG", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
loggers[Info] = log.New(buffer, "Rewrite-Body | INFO", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
loggers[Warning] = log.New(buffer, "Rewrite-Body | WARNING", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
loggers[Error] = log.New(buffer, "Rewrite-Body | ERROR", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
|
||||
return &LogWriter{
|
||||
level: level,
|
||||
loggers: loggers,
|
||||
}
|
||||
}
|
||||
|
||||
func (logger *LogWriter) writeLog(level LogLevel, message string) {
|
||||
if level < logger.level {
|
||||
return
|
||||
}
|
||||
|
||||
output := logger.loggers[level]
|
||||
output.Print(message)
|
||||
}
|
||||
|
||||
// LogTrace write Trace level logs.
|
||||
func (logger *LogWriter) LogTrace(message string) {
|
||||
logger.writeLog(Trace, message)
|
||||
}
|
||||
|
||||
// LogDebug write Debug level logs.
|
||||
func (logger *LogWriter) LogDebug(message string) {
|
||||
logger.writeLog(Debug, message)
|
||||
}
|
||||
|
||||
// LogInfo write Info level logs.
|
||||
func (logger *LogWriter) LogInfo(message string) {
|
||||
logger.writeLog(Info, message)
|
||||
}
|
||||
|
||||
// LogWarning write Warning level logs.
|
||||
func (logger *LogWriter) LogWarning(message string) {
|
||||
logger.writeLog(Warning, message)
|
||||
}
|
||||
|
||||
// LogError write Error level logs.
|
||||
func (logger *LogWriter) LogError(message string) {
|
||||
logger.writeLog(Error, message)
|
||||
}
|
||||
|
||||
// LogTracef write Trace level logs with formatting similar to fmt.Sprintf.
|
||||
func (logger *LogWriter) LogTracef(format string, a ...interface{}) {
|
||||
logger.writeLog(Trace, fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// LogDebugf write Debug level logs with formatting similar to fmt.Sprintf.
|
||||
func (logger *LogWriter) LogDebugf(format string, a ...interface{}) {
|
||||
logger.writeLog(Debug, fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// LogInfof write Info level logs with formatting similar to fmt.Sprintf.
|
||||
func (logger *LogWriter) LogInfof(format string, a ...interface{}) {
|
||||
logger.writeLog(Info, fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// LogWarningf write Warning level logs with formatting similar to fmt.Sprintf.
|
||||
func (logger *LogWriter) LogWarningf(format string, a ...interface{}) {
|
||||
logger.writeLog(Warning, fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// LogErrorf write Error level logs with formatting similar to fmt.Sprintf.
|
||||
func (logger *LogWriter) LogErrorf(format string, a ...interface{}) {
|
||||
logger.writeLog(Error, fmt.Sprintf(format, a...))
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLogging(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
configLevel LogLevel
|
||||
targetLevel LogLevel
|
||||
shouldWrite bool
|
||||
}{
|
||||
{
|
||||
desc: "Trace level should write when targeting Trace",
|
||||
configLevel: Trace,
|
||||
targetLevel: Trace,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Trace level should write when targeting Debug",
|
||||
configLevel: Trace,
|
||||
targetLevel: Debug,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Trace level should write when targeting Info",
|
||||
configLevel: Trace,
|
||||
targetLevel: Info,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Trace level should write when targeting Warning",
|
||||
configLevel: Trace,
|
||||
targetLevel: Warning,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Trace level should write when targeting Error",
|
||||
configLevel: Trace,
|
||||
targetLevel: Error,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Debug level should NOT write when targeting Trace",
|
||||
configLevel: Debug,
|
||||
targetLevel: Trace,
|
||||
shouldWrite: false,
|
||||
},
|
||||
{
|
||||
desc: "Debug level should write when targeting Debug",
|
||||
configLevel: Debug,
|
||||
targetLevel: Debug,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Debug level should write when targeting Info",
|
||||
configLevel: Debug,
|
||||
targetLevel: Info,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Debug level should write when targeting Warning",
|
||||
configLevel: Debug,
|
||||
targetLevel: Warning,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Debug level should write when targeting Error",
|
||||
configLevel: Debug,
|
||||
targetLevel: Error,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Info level should NOT write when targeting Trace",
|
||||
configLevel: Info,
|
||||
targetLevel: Trace,
|
||||
shouldWrite: false,
|
||||
},
|
||||
{
|
||||
desc: "Info level should NOT write when targeting Debug",
|
||||
configLevel: Info,
|
||||
targetLevel: Debug,
|
||||
shouldWrite: false,
|
||||
},
|
||||
{
|
||||
desc: "Info level should write when targeting Info",
|
||||
configLevel: Info,
|
||||
targetLevel: Info,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Info level should write when targeting Warning",
|
||||
configLevel: Info,
|
||||
targetLevel: Warning,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Info level should write when targeting Error",
|
||||
configLevel: Info,
|
||||
targetLevel: Error,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Warning level should NOT write when targeting Trace",
|
||||
configLevel: Warning,
|
||||
targetLevel: Trace,
|
||||
shouldWrite: false,
|
||||
},
|
||||
{
|
||||
desc: "Warning level should NOT write when targeting Debug",
|
||||
configLevel: Warning,
|
||||
targetLevel: Debug,
|
||||
shouldWrite: false,
|
||||
},
|
||||
{
|
||||
desc: "Warning level should NOT write when targeting Info",
|
||||
configLevel: Warning,
|
||||
targetLevel: Info,
|
||||
shouldWrite: false,
|
||||
},
|
||||
{
|
||||
desc: "Warning level should write when targeting Warning",
|
||||
configLevel: Warning,
|
||||
targetLevel: Warning,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Warning level should write when targeting Error",
|
||||
configLevel: Warning,
|
||||
targetLevel: Error,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Warning level should NOT write when targeting Trace",
|
||||
configLevel: Warning,
|
||||
targetLevel: Trace,
|
||||
shouldWrite: false,
|
||||
},
|
||||
{
|
||||
desc: "Warning level should NOT write when targeting Debug",
|
||||
configLevel: Warning,
|
||||
targetLevel: Debug,
|
||||
shouldWrite: false,
|
||||
},
|
||||
{
|
||||
desc: "Warning level should NOT write when targeting Info",
|
||||
configLevel: Warning,
|
||||
targetLevel: Info,
|
||||
shouldWrite: false,
|
||||
},
|
||||
{
|
||||
desc: "Warning level should write when targeting Warning",
|
||||
configLevel: Warning,
|
||||
targetLevel: Warning,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Warning level should write when targeting Error",
|
||||
configLevel: Warning,
|
||||
targetLevel: Error,
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
desc: "Error level should NOT write when targeting Trace",
|
||||
configLevel: Error,
|
||||
targetLevel: Trace,
|
||||
shouldWrite: false,
|
||||
},
|
||||
{
|
||||
desc: "Error level should NOT write when targeting Debug",
|
||||
configLevel: Error,
|
||||
targetLevel: Debug,
|
||||
shouldWrite: false,
|
||||
},
|
||||
{
|
||||
desc: "Error level should NOT write when targeting Info",
|
||||
configLevel: Error,
|
||||
targetLevel: Info,
|
||||
shouldWrite: false,
|
||||
},
|
||||
{
|
||||
desc: "Error level should NOT write when targeting Warning",
|
||||
configLevel: Error,
|
||||
targetLevel: Warning,
|
||||
shouldWrite: false,
|
||||
},
|
||||
{
|
||||
desc: "Error level should write when targeting Error",
|
||||
configLevel: Error,
|
||||
targetLevel: Error,
|
||||
shouldWrite: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
var buffer bytes.Buffer
|
||||
|
||||
logger := createLoggerWithBuffer(test.configLevel, &buffer)
|
||||
|
||||
switch test.targetLevel {
|
||||
case Trace:
|
||||
logger.LogTrace("test")
|
||||
case Debug:
|
||||
logger.LogDebug("test")
|
||||
case Info:
|
||||
logger.LogInfo("test")
|
||||
case Warning:
|
||||
logger.LogWarning("test")
|
||||
case Error:
|
||||
logger.LogError("test")
|
||||
}
|
||||
|
||||
loggedContent := buffer.Bytes()
|
||||
if !test.shouldWrite {
|
||||
if len(loggedContent) != 0 {
|
||||
t.Errorf("Log level %v written with test value set to %v: '%s'",
|
||||
test.targetLevel, test.configLevel, loggedContent)
|
||||
}
|
||||
} else {
|
||||
if len(loggedContent) == 0 {
|
||||
t.Errorf("Log level %v NOT written with test value set to %v: '%s'",
|
||||
test.targetLevel, test.configLevel, loggedContent)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package rewrite_body a plugin to rewrite response body.
|
||||
package rewrite_body
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/packruler/rewrite-body/handler"
|
||||
)
|
||||
|
||||
// CreateConfig creates and initializes the plugin configuration.
|
||||
func CreateConfig() *handler.Config {
|
||||
return &handler.Config{}
|
||||
}
|
||||
|
||||
// New creates and returns a new rewrite body plugin instance.
|
||||
func New(context context.Context, next http.Handler, config *handler.Config, name string) (http.Handler, error) {
|
||||
return handler.New(context, next, config, name)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package rewrite_body
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/packruler/rewrite-body/compressutil"
|
||||
"github.com/packruler/rewrite-body/handler"
|
||||
"github.com/packruler/rewrite-body/httputil"
|
||||
)
|
||||
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
contentEncoding string
|
||||
contentType string `default:"text/html"`
|
||||
rewrites []handler.Rewrite
|
||||
lastModified bool
|
||||
resBody string
|
||||
expResBody string
|
||||
expLastModified bool
|
||||
}{
|
||||
{
|
||||
desc: "should replace foo by bar",
|
||||
rewrites: []handler.Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentType: "text/html",
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "bar is the new bar",
|
||||
},
|
||||
{
|
||||
desc: "should replace foo by bar, then by foo",
|
||||
rewrites: []handler.Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
{
|
||||
Regex: "bar",
|
||||
Replacement: "foo",
|
||||
},
|
||||
},
|
||||
contentType: "text/html",
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "foo is the new foo",
|
||||
},
|
||||
{
|
||||
desc: "should not replace anything if content encoding is not identity or empty",
|
||||
rewrites: []handler.Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentEncoding: "other",
|
||||
contentType: "text/html",
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "foo is the new bar",
|
||||
},
|
||||
{
|
||||
desc: "should not replace anything if content type does not contain text or is not empty",
|
||||
rewrites: []handler.Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentType: "image",
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "foo is the new bar",
|
||||
},
|
||||
{
|
||||
desc: "should replace foo by bar if content encoding is identity",
|
||||
rewrites: []handler.Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentEncoding: "identity",
|
||||
contentType: "text/html",
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "bar is the new bar",
|
||||
},
|
||||
{
|
||||
desc: "should not remove the last modified header",
|
||||
rewrites: []handler.Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentEncoding: "identity",
|
||||
contentType: "text/html",
|
||||
lastModified: true,
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "bar is the new bar",
|
||||
expLastModified: true,
|
||||
},
|
||||
{
|
||||
desc: "should support gzip encoding",
|
||||
rewrites: []handler.Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentEncoding: "gzip",
|
||||
contentType: "text/html",
|
||||
lastModified: true,
|
||||
resBody: compressString("foo is the new bar", "gzip"),
|
||||
expResBody: compressString("bar is the new bar", "gzip"),
|
||||
expLastModified: true,
|
||||
},
|
||||
{
|
||||
desc: "should support deflate encoding",
|
||||
rewrites: []handler.Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentEncoding: "deflate",
|
||||
contentType: "text/html",
|
||||
lastModified: true,
|
||||
resBody: compressString("foo is the new bar", "deflate"),
|
||||
expResBody: compressString("bar is the new bar", "deflate"),
|
||||
expLastModified: true,
|
||||
},
|
||||
{
|
||||
desc: "should ignore unsupported encoding",
|
||||
rewrites: []handler.Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
contentEncoding: "br",
|
||||
contentType: "text/html",
|
||||
lastModified: true,
|
||||
resBody: "foo is the new bar",
|
||||
expResBody: "foo is the new bar",
|
||||
expLastModified: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
config := &handler.Config{
|
||||
LastModified: test.lastModified,
|
||||
Rewrites: test.rewrites,
|
||||
LogLevel: -1,
|
||||
}
|
||||
|
||||
next := func(responseWriter http.ResponseWriter, req *http.Request) {
|
||||
responseWriter.Header().Set("Content-Encoding", test.contentEncoding)
|
||||
responseWriter.Header().Set("Content-Type", test.contentType)
|
||||
responseWriter.Header().Set("Last-Modified", "Thu, 02 Jun 2016 06:01:08 GMT")
|
||||
responseWriter.Header().Set("Content-Length", strconv.Itoa(len(test.resBody)))
|
||||
responseWriter.WriteHeader(http.StatusOK)
|
||||
|
||||
_, _ = fmt.Fprintf(responseWriter, test.resBody)
|
||||
}
|
||||
|
||||
rewriteBody, err := New(context.Background(), http.HandlerFunc(next), config, "rewriteBody")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Accept", "text/html")
|
||||
|
||||
rewriteBody.ServeHTTP(recorder, req)
|
||||
|
||||
if _, exists := recorder.Result().Header["Last-Modified"]; exists != test.expLastModified {
|
||||
t.Errorf("got last-modified header %v, want %v", exists, test.expLastModified)
|
||||
}
|
||||
|
||||
if _, exists := recorder.Result().Header["Content-Length"]; exists {
|
||||
t.Error("The Content-Length Header must be deleted")
|
||||
}
|
||||
|
||||
if !bytes.Equal([]byte(test.expResBody), recorder.Body.Bytes()) {
|
||||
t.Errorf("got body: %v\n wanted: %v", recorder.Body.Bytes(), []byte(test.expResBody))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func compressString(value string, encoding string) string {
|
||||
compressed, _ := compressutil.Encode([]byte(value), encoding)
|
||||
|
||||
return string(compressed)
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
rewrites []handler.Rewrite
|
||||
expErr bool
|
||||
}{
|
||||
{
|
||||
desc: "should return no error",
|
||||
rewrites: []handler.Rewrite{
|
||||
{
|
||||
Regex: "foo",
|
||||
Replacement: "bar",
|
||||
},
|
||||
{
|
||||
Regex: "bar",
|
||||
Replacement: "foo",
|
||||
},
|
||||
},
|
||||
expErr: false,
|
||||
},
|
||||
{
|
||||
desc: "should return an error",
|
||||
rewrites: []handler.Rewrite{
|
||||
{
|
||||
Regex: "*",
|
||||
Replacement: "bar",
|
||||
},
|
||||
},
|
||||
expErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
defaultMonitoring := httputil.MonitoringConfig{
|
||||
Types: []string{"text/html"},
|
||||
Methods: []string{http.MethodGet},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
config := &handler.Config{
|
||||
Rewrites: test.rewrites,
|
||||
Monitoring: defaultMonitoring,
|
||||
}
|
||||
|
||||
_, err := New(context.Background(), nil, config, "rewriteBody")
|
||||
if test.expErr && err == nil {
|
||||
t.Fatal("expected error on bad regexp format")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@ connector.name=iceberg
|
||||
iceberg.catalog.type=nessie
|
||||
iceberg.nessie-catalog.uri=http://nessie:19120/api/v2
|
||||
iceberg.nessie-catalog.ref=main
|
||||
iceberg.nessie-catalog.default-warehouse-dir=s3://lakehouse/
|
||||
iceberg.nessie-catalog.default-warehouse-dir=${ENV:S3_WAREHOUSE}
|
||||
fs.native-s3.enabled=true
|
||||
s3.endpoint=http://rustfs:9000
|
||||
s3.endpoint=${ENV:S3_ENDPOINT}
|
||||
s3.path-style-access=true
|
||||
s3.region=us-east-1
|
||||
s3.region=${ENV:S3_REGION}
|
||||
s3.aws-access-key=${ENV:NESSIE_S3_ACCESS_KEY}
|
||||
s3.aws-secret-key=${ENV:NESSIE_S3_SECRET_KEY}
|
||||
|
||||
Reference in New Issue
Block a user