diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..bbc93b7 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,125 @@ +name: Release + +on: + push: + branches: + - main + +# Requires Forgejo v12+; older versions ignore this block. +concurrency: + group: release + cancel-in-progress: false + +jobs: + tag: + runs-on: debian + outputs: + new_tag: ${{ steps.tag.outputs.new_tag }} + steps: + - uses: https://git.sbriggs.io/actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a + with: + fetch-depth: 0 + + - name: Bump version and push tag + id: tag + shell: bash + env: + DEFAULT_BUMP: patch + run: | + set -euo pipefail + git config user.name "forgejo-actions[bot]" + git config user.email "forgejo-actions[bot]@noreply.localhost" + + # Already tagged? Reuse it instead of creating a duplicate. + if existing=$(git describe --exact-match --tags HEAD 2>/dev/null); then + echo "HEAD already tagged as $existing" + echo "new_tag=$existing" >> "$GITHUB_OUTPUT" + exit 0 + fi + + latest=$(git tag -l 'v[0-9]*' --sort=-v:refname | head -n1) + if [ -z "$latest" ]; then + major=0; minor=0; patch=0 + log=$(git log --pretty=%B) + else + ver=${latest#v} + IFS=. read -r major minor patch <<< "$ver" + log=$(git log --pretty=%B "$latest..HEAD") + fi + + bump="$DEFAULT_BUMP" + case "$log" in + *'#major'*) bump=major ;; + *'#minor'*) bump=minor ;; + *'#patch'*) bump=patch ;; + esac + + case "$bump" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + esac + + new_tag="v${major}.${minor}.${patch}" + echo "Bumping ${latest:-} -> $new_tag ($bump)" + + git tag -a "$new_tag" -m "$new_tag" + git push origin "$new_tag" + echo "new_tag=$new_tag" >> "$GITHUB_OUTPUT" + + build-and-release: + needs: tag + runs-on: debian + steps: + #- uses: https://code.forgejo.org/actions/checkout@v4 + - uses: https://git.sbriggs.io/actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a + with: + fetch-depth: 0 + ref: ${{ needs.tag.outputs.new_tag }} + + - uses: https://code.forgejo.org/actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build release binaries + run: make build + + - name: Compress Linux binaries + run: upx --best --lzma dist/*-linux-* + + - name: Generate release notes + id: notes + shell: bash + env: + TAG: ${{ needs.tag.outputs.new_tag }} + run: | + set -euo pipefail + prev=$(git tag -l 'v[0-9]*' --sort=-v:refname | grep -vFx "$TAG" | head -n1) + if [ -n "$prev" ]; then + range="$prev..$TAG" + header="## Changes since $prev" + else + range="$TAG" + header="## Changes" + fi + { + echo "notes<<__EOF__" + echo "$header" + echo + git log --no-merges --pretty='- %s (%h)' "$range" + echo "__EOF__" + } >> "$GITHUB_OUTPUT" + + - name: Publish Forgejo release + uses: https://git.sbriggs.io/actions/forgejo-release@585a6614e80a6bcfc429b956ae34831889cabb78 + with: + direction: upload + url: ${{ github.server_url }} + repo: ${{ github.repository }} + tag: ${{ needs.tag.outputs.new_tag }} + sha: ${{ github.sha }} + token: ${{ secrets.GITHUB_TOKEN }} + release-dir: dist + release-notes: ${{ steps.notes.outputs.notes }} + override: true + verbose: true \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml.OLD similarity index 86% rename from .github/workflows/release.yml rename to .github/workflows/release.yml.OLD index 7fc0524..7aa0ef6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml.OLD @@ -46,6 +46,12 @@ jobs: - name: Build release binaries run: make build + - name: Install UPX + run: sudo apt-get update && sudo apt-get install -y upx-ucl + + - name: Compress Linux binaries + run: upx --best --lzma dist/*-linux-* + - name: Publish GitHub release uses: softprops/action-gh-release@v2 with: diff --git a/Makefile b/Makefile index 8bbb7a3..7a961d6 100644 --- a/Makefile +++ b/Makefile @@ -18,11 +18,11 @@ all: test build ## build — compile for all target platforms build: $(TARGETS) -darwin/amd64: - GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(OUTDIR)/$(BINARY)-darwin-amd64 . +#darwin/amd64: +# GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(OUTDIR)/$(BINARY)-darwin-amd64 . -darwin/arm64: - GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(OUTDIR)/$(BINARY)-darwin-arm64 . +#darwin/arm64: +# GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(OUTDIR)/$(BINARY)-darwin-arm64 . linux/amd64: GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(OUTDIR)/$(BINARY)-linux-amd64 . @@ -30,8 +30,8 @@ linux/amd64: linux/arm64: GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o $(OUTDIR)/$(BINARY)-linux-arm64 . -linux/arm: - GOOS=linux GOARCH=arm GOARM=7 go build $(LDFLAGS) -o $(OUTDIR)/$(BINARY)-linux-armv7 . +#linux/arm: +# GOOS=linux GOARCH=arm GOARM=7 go build $(LDFLAGS) -o $(OUTDIR)/$(BINARY)-linux-armv7 . ## test — run all tests test: diff --git a/README.md b/README.md index 561b5cd..3d23ff3 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ A fast nginx log parser that generates themeable HTML reports with charts, searchable tables, and optional GeoIP2 hit maps. -![Dashboard overview](screenshot1.png) +![Dashboard overview](assets/screenshot1.png) -![Access log table](screenshot2.png) +![Access log table](assets/screenshot2.png) ## Features @@ -54,6 +54,7 @@ nxstats [flags] | `--no-gzip` | | `false` | Skip `.gz` compressed rotated logs | | `--split-by-day` | | `false` | Write one HTML report per calendar day plus index | | `--theme` | | `cyberpunk` | Report visual theme: `cyberpunk`, `purplerain`, `cactus` | +| `--map-api-key` | | | CARTO basemap API key, appended to tile URLs as `?key=` (env: `NXSTATS_MAP_API_KEY`) | ### Examples @@ -78,6 +79,12 @@ nxstats --dir /var/log/nginx --split-by-day -o index.html # Writes index.html + 2024-01-15.html, 2024-01-16.html, etc. ``` +**With a CARTO basemap API key (required for map tiles):** +```bash +nxstats --geoip ./GeoLite2-City.mmdb --map-api-key XYZ -o report.html +# or: NXSTATS_MAP_API_KEY=XYZ nxstats --geoip ./GeoLite2-City.mmdb -o report.html +``` + **With a different theme:** ```bash nxstats --dir /var/log/nginx --theme purplerain -o report.html @@ -91,6 +98,16 @@ The geographic distribution map requires a free MaxMind GeoLite2-City database. 2. Download `GeoLite2-City.mmdb` 3. Pass the path via `--geoip ./GeoLite2-City.mmdb` +## Basemap API Key + +The map's background tiles come from [CARTO](https://carto.com/), which now +requires an API key. Create one in the CARTO dashboard and pass it with +`--map-api-key` (or set `NXSTATS_MAP_API_KEY`); it is appended to the theme's +tile URL as `?key=...` in the generated report. Note that the key is embedded +in the HTML output, so treat generated reports as you would the key itself. + +Without a key the map still renders, but tiles may come back blank. + ## Log Format nxstats expects the standard nginx combined log format for access logs: diff --git a/screenshot1.png b/assets/screenshot1.png similarity index 100% rename from screenshot1.png rename to assets/screenshot1.png diff --git a/screenshot2.png b/assets/screenshot2.png similarity index 100% rename from screenshot2.png rename to assets/screenshot2.png diff --git a/cmd/root.go b/cmd/root.go index 1867ab6..38b077b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ var ( flagGeoIP string flagSplitByDay bool flagTheme string + flagMapAPIKey string ) var rootCmd = &cobra.Command{ @@ -46,6 +47,8 @@ func init() { rootCmd.Flags().BoolVar(&flagSplitByDay, "split-by-day", false, "Write one HTML report per calendar day plus an index page") rootCmd.Flags().StringVar(&flagTheme, "theme", report.DefaultTheme, "Report visual theme ("+strings.Join(report.ThemeNames(), ", ")+")") + rootCmd.Flags().StringVar(&flagMapAPIKey, "map-api-key", os.Getenv("NXSTATS_MAP_API_KEY"), + "CARTO basemap API key, appended to map tile URLs as ?key= (env: NXSTATS_MAP_API_KEY)") } // validateTheme returns an error if name is not a recognized theme. @@ -108,6 +111,7 @@ func runE(cmd *cobra.Command, args []string) error { AccessEntries: accessEntries, ErrorEntries: errorEntries, Theme: flagTheme, + MapAPIKey: flagMapAPIKey, } report.ComputeStats(&data) @@ -212,6 +216,7 @@ func runSplitByDay( for i := range days { days[i].Data.IndexFile = indexBase days[i].Data.DayTitle = days[i].Date.Format("2006-01-02") + days[i].Data.MapAPIKey = flagMapAPIKey // Per-day geo: set Count from this day's access entries if len(geoPoints) > 0 { diff --git a/internal/report/report.go b/internal/report/report.go index 12218be..e5c9216 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "html/template" + "net/url" "path/filepath" "sort" "strings" @@ -66,6 +67,10 @@ type ReportData struct { // Theme selects the visual theme (see themes.go). Empty or unrecognized // falls back to DefaultTheme. Theme string + + // MapAPIKey is the basemap provider API key. When non-empty it is appended + // to the theme's tile URL as a "key" query parameter (CARTO's format). + MapAPIKey string } // DaySummary is one row in the index page's daily breakdown table. @@ -338,6 +343,20 @@ type reportRenderData struct { MarkerColor string } +// tileURLWithKey appends the basemap provider API key to tileURL as a "key" +// query parameter, e.g. ".../{z}/{x}/{y}.png?key=XYZ". The URL is returned +// unchanged when no key is set. +func tileURLWithKey(tileURL, key string) string { + if key == "" || tileURL == "" { + return tileURL + } + sep := "?" + if strings.Contains(tileURL, "?") { + sep = "&" + } + return tileURL + sep + "key=" + url.QueryEscape(key) +} + // GenerateHTML renders the full HTML report from data. func GenerateHTML(data ReportData) (string, error) { th := themeFor(data.Theme) @@ -354,7 +373,7 @@ func GenerateHTML(data ReportData) (string, error) { Chart3: th.Chart3, ChartWarn: th.ChartWarn, ChartErr: th.ChartErr, - MapTileURL: th.MapTileURL, + MapTileURL: tileURLWithKey(th.MapTileURL, data.MapAPIKey), MapAttribution: th.MapAttribution, MarkerColor: th.MarkerColor, } diff --git a/internal/report/report_test.go b/internal/report/report_test.go index c0f2589..a0e6391 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -624,3 +624,56 @@ func TestGenerateHTML_DayTitleInHeader(t *testing.T) { t.Error("expected DayTitle in report HTML") } } + +// ── map API key ──────────────────────────────────────────────────────────── + +func TestTileURLWithKey(t *testing.T) { + cases := []struct{ url, key, want string }{ + {"https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png", "XYZ", + "https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png?key=XYZ"}, + {"https://tiles.example/{z}/{x}/{y}.png?style=dark", "XYZ", + "https://tiles.example/{z}/{x}/{y}.png?style=dark&key=XYZ"}, + {"https://tiles.example/{z}/{x}/{y}.png", "a b&c", + "https://tiles.example/{z}/{x}/{y}.png?key=a+b%26c"}, + {"https://tiles.example/{z}/{x}/{y}.png", "", + "https://tiles.example/{z}/{x}/{y}.png"}, + } + for _, c := range cases { + if got := tileURLWithKey(c.url, c.key); got != c.want { + t.Errorf("tileURLWithKey(%q, %q) = %q, want %q", c.url, c.key, got, c.want) + } + } +} + +func TestGenerateHTML_MapAPIKeyInTileURL(t *testing.T) { + data := makeTestData() + ComputeStats(&data) + data.GeoLocations = []GeoPoint{ + {IP: "1.1.1.1", Lat: 37.751, Lon: -97.822, Country: "United States", CountryCode: "US", Count: 2}, + } + data.MapAPIKey = "secret-key" + + html, err := GenerateHTML(data) + if err != nil { + t.Fatalf("GenerateHTML error: %v", err) + } + if !strings.Contains(html, "?key=secret-key") { + t.Error("expected map API key appended to the tile URL") + } +} + +func TestGenerateHTML_NoMapAPIKeyLeavesTileURL(t *testing.T) { + data := makeTestData() + ComputeStats(&data) + data.GeoLocations = []GeoPoint{ + {IP: "1.1.1.1", Lat: 37.751, Lon: -97.822, Country: "United States", CountryCode: "US", Count: 2}, + } + + html, err := GenerateHTML(data) + if err != nil { + t.Fatalf("GenerateHTML error: %v", err) + } + if strings.Contains(html, "key=") { + t.Error("did not expect a key query parameter when MapAPIKey is empty") + } +} diff --git a/internal/report/theme_cactus.go b/internal/report/theme_cactus.go index fb0f03c..849a9a6 100644 --- a/internal/report/theme_cactus.go +++ b/internal/report/theme_cactus.go @@ -241,7 +241,7 @@ var cactusTheme = themeAssets{ Chart3: "#e0956b", ChartWarn: "#d99a3d", ChartErr: "#c96a5b", - MapTileURL: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", + MapTileURL: "https://basemaps.cartocdn.com/rastertiles/light_all/{z}/{x}/{y}{r}.png", MapAttribution: `© CARTO`, MarkerColor: "#e0956b", } diff --git a/internal/report/theme_cyberpunk.go b/internal/report/theme_cyberpunk.go index 8173ba8..df2e61f 100644 --- a/internal/report/theme_cyberpunk.go +++ b/internal/report/theme_cyberpunk.go @@ -258,7 +258,7 @@ var cyberpunkTheme = themeAssets{ Chart3: "#ff00c8", ChartWarn: "#ffcc00", ChartErr: "#ff4444", - MapTileURL: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", + MapTileURL: "https://basemaps.cartocdn.com/rastertiles/dark_all/{z}/{x}/{y}{r}.png", MapAttribution: `© CARTO`, MarkerColor: "#ff00c8", } diff --git a/internal/report/theme_purplerain.go b/internal/report/theme_purplerain.go index db9020a..36c59a6 100644 --- a/internal/report/theme_purplerain.go +++ b/internal/report/theme_purplerain.go @@ -243,7 +243,7 @@ var purplerainTheme = themeAssets{ Chart3: "#e879f9", ChartWarn: "#fbbf24", ChartErr: "#fb7185", - MapTileURL: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", + MapTileURL: "https://basemaps.cartocdn.com/rastertiles/dark_all/{z}/{x}/{y}{r}.png", MapAttribution: `© CARTO`, MarkerColor: "#a78bfa", }