@@ -96,7 +96,7 @@ EXPOSE 2222 2324 8080 CMD ["/app/srv", "srv", "config.local.yaml"]
# ---------------------------------------------------------------------------
-# runtime: default target — debian slim, no compiler
+# runtime: the default target, debian slim, no compiler
# ---------------------------------------------------------------------------
FROM debian:bookworm-slim AS runtime
@@ -8,7 +8,7 @@ ```
# Hammer
-**tools over SSH** — a BBS-style terminal you connect to with your SSH key, plus the
+Tools over SSH: a BBS-style terminal you connect to with your SSH key, plus the
HTTP service behind it. Weather is the first tool; the platform is being built around it.
Hammer is a single static Go binary. Fonts, translations, templates and page assets are
@@ -2,7 +2,7 @@ #!/usr/bin/env bash set -euo pipefail
# ──────────────────────────────────────────────────────────────────────────────
-# build.sh — Build helper script for the project
+# build.sh: builds the project
# Usage:
# ./build.sh build
# ./build.sh clean
@@ -80,7 +80,7 @@ if [[ "$copied" -eq 0 ]]; then error "No fonts were copied into $EMBED_TARGET_DIR/fonts. Install the font packages listed above (see README) and re-run. Refusing to build without embedded fonts."
fi
if [[ "$copied" -lt "${#FONTS[@]}" ]]; then
- warn "Only $copied/${#FONTS[@]} fonts copied — some scripts/glyphs will fall back or render as tofu in PNG output."
+ warn "Only $copied/${#FONTS[@]} fonts copied; some scripts/glyphs will fall back or render as tofu in PNG output."
fi
}
@@ -1,44 +1,48 @@-# Deploy Hammer on the CloudPanel VPS (Docker) — one-shot runbook
+# Deploying Hammer on the CloudPanel VPS
+
+How the `/opt/hammer` deployment was set up, so it can be done again from the
+repo. It runs in Docker (image target `games`, user `wttr`, uid 1000), not as a
+binary under systemd. The notes include the quirks this particular box has.
-Reproduces the working `/opt/hammer` deployment from the repo. Method is
-**Docker** (image target `games`, runs as user `wttr` uid 1000), not a
-binary+systemd. Corrected for the real quirks of this box.
+## Layout
-## Topology (as actually deployed)
-- **Web (weather):** `https://hammer.pablomurad.com` → CloudPanel reverse proxy
- → **`http://127.0.0.1:8081`**. Port **8080 is taken by CloudPanel's own
- nginx**, so the container's HTTP (8080 inside) is published on host
- **127.0.0.1:8081**. CloudPanel terminates TLS (Let's Encrypt).
-- **SSH BBS:** `ssh -p 2323 guest@hammer.pablomurad.com`. Published directly on
- TCP **2323** (no reverse proxy — SSH is not HTTP). The user name is cosmetic.
- A client offering an SSH key is identified by it (saved profile, SFTP file
- area, jobs). `bbs.allowAnonymous: true` in `config.docker.yaml` also lets
- keyless clients in; they get defaults for that connection only, nothing is
- saved, and they have no file area.
-- **Same port, three modes:** interactive terminal → menu; a command or a pipe
- → NDJSON dispatcher; `sftp -P 2323` → the caller's file area
- (`/app/cache/files`, inside the `deploy/cache` volume).
-- **Port 22** stays with the box's admin sshd — do not touch it.
+- Web (weather): `https://hammer.pablomurad.com` goes through CloudPanel's
+ reverse proxy to `http://127.0.0.1:8081`. CloudPanel's own nginx already holds
+ 8080, so the container's HTTP port (8080 inside) is published on host
+ 127.0.0.1:8081. CloudPanel handles TLS with Let's Encrypt.
+- SSH BBS: `ssh -p 2323 guest@hammer.pablomurad.com`, published straight on TCP
+ 2323. There is no proxy in front of it, since SSH is not HTTP. The user name
+ doesn't matter. A client with an SSH key is known by that key (saved profile,
+ SFTP file area, jobs). `bbs.allowAnonymous: true` in `config.docker.yaml` lets
+ keyless clients in as well: they get the defaults for that connection, nothing
+ is saved, and they have no file area.
+- The same port does three things: an interactive terminal gets the menu, a
+ command or a pipe gets the NDJSON dispatcher, and `sftp -P 2323` gets the
+ caller's file area (`/app/cache/files`, inside the `deploy/cache` volume).
+- Port 22 is the box's admin sshd. Leave it alone.
-## Prerequisites
-- Docker + Docker Compose v2 on the box (`docker compose version`).
-- CloudPanel with `clpctl` available.
-- The repo cloned, e.g. `/opt/bbs-hammer` (git `master`).
-- Secrets you must supply (NOT in the repo): OpenCage token, WWO URL/key, and
- the MaxMind GeoLite2-City database.
+## Before you start
+
+- Docker and Docker Compose v2 on the box (`docker compose version`).
+- CloudPanel, with `clpctl`.
+- The repo cloned somewhere, for example `/opt/bbs-hammer` (branch `master`).
+- The secrets, which are not in the repo: an OpenCage token, the WWO URL and
+ key, and the MaxMind GeoLite2-City database.
## Steps
### 1. Get the code
+
```bash
sudo git clone https://github.com/runawaydevil/bbs-hammer.git /opt/bbs-hammer
cd /opt/bbs-hammer
```
-### 2. Secrets — deploy/.env (MUST be owned by uid 1000)
-The container runs as `wttr` uid 1000 and reads `/app/.env`. If the file is not
-readable by uid 1000 the container crash-loops with
-`loading .env: permission denied`. Create it and chown it:
+### 2. Secrets in deploy/.env, owned by uid 1000
+
+The container runs as uid 1000 and reads `/app/.env`. If uid 1000 can't read
+it, the container restarts in a loop with `loading .env: permission denied`.
+
```bash
cat > deploy/.env <<'ENV'
OPENCAGE_TOKEN=__your_opencage_token__
@@ -50,8 +54,11 @@ sudo chown 1000:1000 deploy/.env sudo chmod 600 deploy/.env
```
-### 3. GeoLite2 database — download with `curl -L` (follows MaxMind's 302)
-MaxMind 302-redirects to R2 storage; without `-L` you save the redirect HTML.
+### 3. The GeoLite2 database
+
+MaxMind answers with a 302 to its R2 storage. Without `curl -L` you save the
+redirect page instead of the database.
+
```bash
KEY=__your_maxmind_license_key__
sudo mkdir -p deploy/data
@@ -61,33 +68,41 @@ tar -xzf /tmp/geo.tar.gz -C /tmp sudo cp /tmp/GeoLite2-City_*/GeoLite2-City.mmdb deploy/data/GeoLite2-City.mmdb
```
-### 4. Runtime dirs must be writable by uid 1000
-The container writes the SSH host key, profiles DB, caches and NES game saves
-into mounted volumes. Create and chown them (data stays read-only):
+### 4. Folders uid 1000 can write
+
+The container writes the SSH host key, the profiles database, caches and NES
+saves into mounted volumes. Create them and hand them over (data stays
+read-only):
+
```bash
sudo mkdir -p deploy/{cache,etc,log,data,games-saves,games-roms}
sudo chown -R 1000:1000 deploy/cache deploy/etc deploy/log deploy/data deploy/games-saves deploy/games-roms
```
-The image target is `games`: the runtime plus nesdoor, its emulator core and the
-free homebrew from `games/catalog.yaml`, fetched and hash-checked at build time.
-At startup Hammer copies those games into `deploy/games-roms`, each one once.
-To add a game, copy its `.nes` file into `deploy/games-roms`; it shows up in the
-list straight away, with no restart. Delete a file to take a game off the list;
-a catalog game you delete is not copied back.
-Building it needs outbound HTTPS to GitHub and the Wayback Machine. To deploy
-without games, set `target: runtime` in the compose file: the `bbs.games` block
-then finds no door, logs that games are off, and the menu stays as before.
-### 5. Build + run
+The `games` target is the runtime plus nesdoor, its emulator core and the free
+homebrew listed in `games/catalog.yaml`, fetched and hash-checked at build
+time. On start Hammer copies those games into `deploy/games-roms`, each one
+once. To add a game, drop its `.nes` file in `deploy/games-roms`; it appears in
+the list right away, no restart needed. Delete a file to take a game off the
+list; a catalog game you delete isn't copied back.
+
+The build needs outbound HTTPS to GitHub and archive.org. To deploy without
+games, set `target: runtime` in the compose file: `bbs.games` then finds no
+door, logs that games are off, and the menu is as it was.
+
+### 5. Build and run
+
```bash
docker compose -f deploy/docker-compose.prod.yml up -d --build
-docker compose -f deploy/docker-compose.prod.yml logs -f # watch for a clean start
+docker compose -f deploy/docker-compose.prod.yml logs -f # watch it start
```
-A clean start shows no `permission denied`, no `font ... file does not exist`,
-and the SSH listener up on 2323. The host key is generated on first boot into
-`deploy/etc/` and persists across restarts.
+
+A good start has no `permission denied`, no `font ... file does not exist`,
+and the SSH listener up on 2323. The host key is made on the first boot, in
+`deploy/etc/`, and kept from then on.
+
+### 6. CloudPanel reverse proxy and TLS
-### 6. CloudPanel reverse proxy + TLS (clpctl)
```bash
clpctl site:add:reverse-proxy \
--domainName='hammer.pablomurad.com' \
@@ -97,17 +112,21 @@ --siteUserPassword='__choose_a_password__'
clpctl lets-encrypt:install:certificate --domainName='hammer.pablomurad.com'
```
-Ensure a DNS A record `hammer.pablomurad.com` → this box's IP exists first.
+
+The DNS A record for `hammer.pablomurad.com` has to point at this box first.
### 7. Firewall
+
```bash
-sudo ufw allow 2323/tcp # SSH BBS (direct)
+sudo ufw allow 2323/tcp # SSH BBS
sudo ufw allow 2324/tcp # telnet BBS (keyless, read-only)
```
-### 7b. Browser terminal through CloudPanel
-`bbs.web` serves a WebSocket at `/:terminal/ws`. In the CloudPanel site's vhost
-(Sites → hammer.pablomurad.com → Vhost), add a location before `location /`:
+### 8. The browser terminal through CloudPanel
+
+`bbs.web` serves a WebSocket at `/:terminal/ws`. In the site's vhost
+(Sites, hammer.pablomurad.com, Vhost), add this before `location /`:
+
```nginx
location = /:terminal/ws {
proxy_pass http://127.0.0.1:8081;
@@ -122,50 +141,55 @@ proxy_read_timeout 600s; proxy_send_timeout 600s;
}
```
-8081 is loopback-only (bound to 127.0.0.1 in compose) — nothing to open. Port
-22 (admin sshd) is left untouched.
-### 8. Verify
+8081 is bound to 127.0.0.1 in the compose file, so there is nothing to open for
+it.
+
+### 9. Check it
+
```bash
-curl -sS https://hammer.pablomurad.com/London | head # web weather via TLS
+curl -sS https://hammer.pablomurad.com/London | head # weather over TLS
ssh -p 2323 guest@hammer.pablomurad.com # BBS splash
ssh -p 2323 hammer.pablomurad.com hash input=hello # dispatcher: one JSON line
echo put /etc/hostname | sftp -b - -P 2323 hammer.pablomurad.com # file area (needs a key)
```
-The client IP the weather service sees must be the visitor's, not the Docker
-gateway: `server.trustedProxies` in `config.docker.yaml` trusts loopback and
+
+The weather service has to see the visitor's IP, not the Docker gateway's.
+`server.trustedProxies` in `config.docker.yaml` trusts loopback and
`172.16.0.0/12` (Docker's bridge ranges), which is where CloudPanel's proxy
-arrives from inside the container. If the compose network lives elsewhere, add
-its gateway there, or visitors will all look like one address (one location,
+shows up from inside the container. If the compose network is somewhere else,
+add its gateway, or every visitor looks like the same address (one location,
one rate-limit bucket).
-## Redeploy / update
+## Updating
+
```bash
cd /opt/bbs-hammer && git pull
docker compose -f deploy/docker-compose.prod.yml up -d --build
```
-`deploy/{cache,etc,log,data}` and `deploy/.env` are gitignored and survive the
-pull, so the host key and profiles are preserved.
+
+`deploy/{cache,etc,log,data}` and `deploy/.env` are gitignored, so they survive
+the pull and the host key and profiles stay.
+
+## Things this box taught me
-## Gotchas (learned on this box)
-- **8081, not 8080** — 8080 belongs to CloudPanel's nginx.
-- **`chown 1000:1000 deploy/.env`** — otherwise the container loops on
+- 8081, not 8080: CloudPanel's nginx has 8080.
+- `chown 1000:1000 deploy/.env`, or the container loops on
`loading .env: permission denied`.
-- **`chown -R 1000:1000 deploy/{cache,etc,log}`** — otherwise it can't write the
- host key / profiles DB.
-- **`curl -L`** for the mmdb — MaxMind 302-redirects to R2.
-- **`cap_add: NET_RAW, NET_ADMIN`** are required for the `path` tool (ping/MTR);
- already set in the compose. Do NOT use `privileged: true`.
-- **Unknown config keys stop the service.** A `config.docker.yaml` from before the
- OCR removal (`ocrInbox`, `ocrWorkDir`, `ocrTTL`, `ocrmypdfPath`) fails at startup;
- use the one in the repo.
-- **Moderators:** put your key's fingerprint in `bbs.community.admins` of
- `config.docker.yaml` (`ssh-keygen -lf ~/.ssh/id_ed25519.pub` prints it, the
- `SHA256:...` part) and restart. Bans are stored in `hammer.db`; chat history is
- in memory only.
-- **Rate limits:** HTTP weather is limited per visitor IP (`server.rateLimit`),
- dispatcher calls per key (`bbs.dispatch`), and geocoder calls per UTC day for
- everyone (`geo.dailyLookupLimit`, set under the OpenCage free tier).
-- **Never commit** `deploy/.env`, the `.mmdb`, or the CloudPanel site password.
-- Profiles saved by older builds for anonymous visitors (keyed `ip:…`) are no
- longer read and can stay in `hammer.db` harmlessly.
+- `chown -R 1000:1000 deploy/{cache,etc,log}`, or it can't write the host key
+ and the profiles database.
+- `curl -L` for the mmdb, because of MaxMind's redirect.
+- The `path` tool (ping, MTR) needs `cap_add: NET_RAW, NET_ADMIN`, which the
+ compose file already has. Don't use `privileged: true`.
+- Unknown config keys stop the service. A `config.docker.yaml` from before the
+ OCR feature was removed (`ocrInbox`, `ocrWorkDir`, `ocrTTL`, `ocrmypdfPath`)
+ won't start; use the one in the repo.
+- Moderators go in `bbs.community.admins` in `config.docker.yaml`, as the key's
+ `SHA256:...` fingerprint (`ssh-keygen -lf ~/.ssh/id_ed25519.pub` prints it),
+ then restart. Bans are kept in `hammer.db`.
+- Rate limits: HTTP weather per visitor IP (`server.rateLimit`), dispatcher
+ calls per key (`bbs.dispatch`), and geocoder calls per UTC day for everyone
+ (`geo.dailyLookupLimit`, set under OpenCage's free tier).
+- Never commit `deploy/.env`, the `.mmdb` or the CloudPanel site password.
+- Profiles that older builds saved for anonymous visitors (keyed `ip:...`) are
+ no longer read; they can stay in `hammer.db`.
@@ -1,15 +1,15 @@-# Hammer — config baked for the production Docker container (target: runtime).
+# Hammer: config baked into the production Docker container (target: runtime).
# The container runs `srv srv config.yaml` from WORKDIR /app as user wttr
# (uid 1000). Secrets come from /app/.env via ${VAR} expansion. Unknown keys
# are rejected at startup, so keep this in sync with internal/config.
cache:
- responses: # final rendered output — fast/in-memory
+ responses: # rendered output, kept in memory
type: lru
size: 50000
ttl: 10m
enabled: true
- weather: # raw upstream data — disk-backed and persistent
+ weather: # raw upstream data, on disk
type: disk
dir: /app/cache/weather
ttl: 45m
@@ -43,7 +43,7 @@ accessLog: /app/log/access.log errorsLog: /app/log/errors.log
interval: 300
-# HTTP weather — listens on 8080 inside the container; compose publishes it on
+# HTTP weather listens on 8080 inside the container; compose publishes it on
# host 127.0.0.1:8081 (8080 belongs to CloudPanel's nginx).
server:
portHttp: 8080
@@ -60,7 +60,7 @@ brand: host: hammer.pablomurad.com
ssh: "ssh -p 2323 hammer.pablomurad.com"
-# SSH BBS — listens on 2323 inside the container; compose publishes 2323:2323.
+# The SSH BBS listens on 2323 inside the container; compose publishes 2323:2323.
bbs:
enabled: true
port: 2323
@@ -1,14 +1,14 @@-# Hammer — production config for hammer.pablomurad.com.
+# Hammer: production config for hammer.pablomurad.com.
# Secrets come from /hammer/.env via ${VAR} expansion. Unknown keys are
# rejected at startup, so keep this in sync with internal/config.
cache:
- responses: # final rendered output — keep fast/in-memory
+ responses: # rendered output, kept in memory
type: lru
size: 50000
ttl: 10m
enabled: true
- weather: # raw upstream data — disk-backed and persistent
+ weather: # raw upstream data, on disk
type: disk
dir: /hammer/cache/weather
ttl: 45m
@@ -42,7 +42,7 @@ accessLog: /hammer/log/access.log errorsLog: /hammer/log/errors.log
interval: 300
-# HTTP weather service — kept on loopback-reachable 8080, fronted by nginx.
+# The HTTP weather service stays on 8080, reachable from loopback, behind nginx.
server:
portHttp: 8080
# nginx on this host is the only proxy; X-Forwarded-For from anyone else is ignored.
@@ -56,7 +56,7 @@ brand: host: hammer.pablomurad.com
ssh: "ssh hammer.pablomurad.com"
-# SSH BBS — owns port 22 on this host so `ssh guest@hammer.pablomurad.com`
+# The SSH BBS owns port 22 on this host, so `ssh guest@hammer.pablomurad.com`
# works with no -p flag. Requires CAP_NET_BIND_SERVICE (see hammer.service).
bbs:
enabled: true
@@ -1,4 +1,4 @@-# Hammer — production Docker deploy (CloudPanel VPS).
+# Hammer: production Docker deploy (CloudPanel VPS).
#
# One-shot: from the repo root, provide deploy/.env (chown 1000:1000) and
# deploy/data/GeoLite2-City.mmdb, then:
@@ -8,7 +8,7 @@ # # HTTP weather is published on loopback 8081 (8080 is taken by CloudPanel's
# nginx); the CloudPanel reverse proxy points at http://127.0.0.1:8081.
# The SSH BBS is published directly on 2323. Port 22 (admin sshd) is untouched.
-# No secrets live in this file — they come from deploy/.env at runtime.
+# No secrets in this file; they come from deploy/.env at runtime.
services:
hammer:
build:
@@ -1,5 +1,5 @@ [Unit]
-Description=Hammer — tools over SSH + HTTP weather
+Description=Hammer, tools over SSH and HTTP weather
After=network-online.target
Wants=network-online.target
@@ -18,7 +18,7 @@ AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
-# Hardening — the service only needs to write under cache/, log/ and etc/.
+# Hardening: the service only writes under cache/, log/ and etc/.
ProtectSystem=strict
ReadWritePaths=/hammer/cache /hammer/log /hammer/etc
ProtectHome=true
@@ -16,8 +16,8 @@ "github.com/runawaydevil/hammer/internal/ui" )
// The chat screen: messages scroll up, and the line being typed stays at the
-// bottom. Two goroutines write to the terminal — the session goroutine echoes
-// typing, and a receiver prints incoming messages — so every screen update
+// bottom. Two goroutines write to the terminal (the session goroutine echoes
+// typing, and a receiver prints incoming messages), so every screen update
// happens under st.wmu, and the receiver redraws the input line after each
// message it prints. Only the session goroutine reads input.
@@ -5,10 +5,9 @@ import "time"
// Config holds configuration for all cache layers used by the service.
//
-// We have two (and potentially more) semantically different caches:
-// 1. Responses → final rendered output (HTML, JSON, PNG, text, etc.) — should stay fast/in-memory
-// 2. Weather → raw upstream data from weather backends (WWO, etc.) — can be disk-backed and persistent
-// 3. Future → location, IP geodata, translations, etc.
+// Two caches with different jobs:
+// 1. Responses: the rendered output (HTML, JSON, PNG, text). Keep it in memory.
+// 2. Weather: raw data from the weather backends (WWO). Fine on disk, survives restarts.
type Config struct {
// Responses is the cache for fully generated responses (what the client receives).
// This is the existing LRU cache passed to NewWeatherService.
@@ -100,8 +100,8 @@ // Set stores the completed entry and notifies waiters. func (d *DiskCacher) Set(key string, entry domain.CacheEntry) {
// Persist to disk
if err := d.writeToDisk(key, &entry); err != nil {
- // In production you might want to log this, but don't fail the Set
- // (the in-memory notification still happens)
+ // Losing the disk copy is not worth failing over; the waiters
+ // below still get the entry.
}
d.mu.Lock()
@@ -143,7 +143,7 @@ p, ok := d.inProgress[key] d.mu.RUnlock()
if !ok {
- // Already completed or never started → try disk
+ // Done, or never started: try the disk
return d.Get(key), nil
}
@@ -99,7 +99,7 @@ return false }
}
- // No valid entry and not in progress → we take ownership
+ // Nothing valid and nobody fetching: this caller fetches
c.inProgress[key] = struct{}{}
c.cache.Add(key, inProgressMarker)
return true
@@ -7,7 +7,7 @@ "strings" )
// LoadDotEnv reads a .env file and sets any variable that is not already
-// present in the environment. A missing file is not an error — the caller
+// present in the environment. A missing file is not an error; the caller
// may rely entirely on the real environment. Lines are KEY=VALUE; blank
// lines and # comments are ignored, and surrounding quotes on the value are
// stripped.
@@ -409,7 +409,7 @@ return nil, fmt.Errorf("unsupported type %q", a.Kind) }
// resolveInput picks the payload: inline "input", a "file" from the caller's
-// area, or stdin in command mode — exactly one of them.
+// area, or stdin in command mode, and exactly one of them.
func resolveInput(args map[string]any, env *Env) ([]byte, *Error) {
inline, hasInline := args[ArgInput].(string)
file, hasFile := args[ArgFile].(string)
@@ -72,7 +72,7 @@ // Expires is the absolute time after which this cache entry // should be considered stale and no longer served.
Expires time.Time
- // ── Fields added for better multi-source / observability support ──
+ // The rest is for debugging and cache inspection.
// CachedAt records when this entry was stored.
CachedAt time.Time `json:"cached_at"`
@@ -61,7 +61,7 @@ if record.Country.Names.English != "" { data.Country = record.Country.Names.English
}
- // Region (first subdivision — usually state/province)
+ // Region: the first subdivision, usually the state or province
if len(record.Subdivisions) > 0 {
if record.Subdivisions[0].Names.English != "" {
data.Region = record.Subdivisions[0].Names.English
@@ -58,22 +58,22 @@ // Cache combines in-memory LRU, SQLite read pool, and write support.
type Cache struct {
config *Config
- // ── Persistent storage ───────────────────────────────
+ // Persistent storage
dbPath string
godbWrite *godb.DB // used for writes (schema + batch)
sqlReadPool *sql.DB // read-only connection pool
- // ── Fast in-memory cache ─────────────────────────────
+ // Fast in-memory cache
lru *lru.Cache[string, *Location]
lruMu sync.Mutex // only for lazy init
- // ── Batch writer ─────────────────────────────────────
+ // Batch writer
writeQueue chan *Location
batchSize int
flushInterval time.Duration
done chan struct{} // graceful shutdown
- // ── Synchronization ──────────────────────────────────
+ // Synchronization
writeMu sync.Mutex
searcher *Searcher
indexField string
@@ -138,7 +138,7 @@ return c, nil
}
-// ── Batch Writer ─────────────────────────────────────────────────────────────
+// Batch Writer
func (c *Cache) batchWriter() {
ticker := time.NewTicker(c.flushInterval)
@@ -225,7 +225,7 @@ c.godbWrite.Rollback() // safety }
}
-// Resolve returns location data — first from cache (LRU → persistent), then from external search.
+// Resolve returns location data from the cache (memory, then disk), or else from an external search.
func (c *Cache) Resolve(location string) (*Location, error) {
defer log.Debugln("Resolve() finished")
location = normalizeLocationName(location)
@@ -383,7 +383,7 @@ } return os.WriteFile(c.cacheFile(addr), bytes, 0o600)
}
-// ── LRU helpers ────────────────────────────────────────────────────────────────
+// LRU helpers
func (c *Cache) initLRUOnce() {
c.lruMu.Lock()
defer c.lruMu.Unlock()
@@ -423,7 +423,7 @@ } c.lru.Add(key, val)
}
-// ── Utility functions ──────────────────────────────────────────────────────────
+// Utility functions
func (c *Cache) cacheFile(item string) string {
return path.Join(c.filesCacheDir, item)
}
@@ -18,7 +18,7 @@ // It processes the URL path, domain name, and headers according to the specified rules. func FromRequest(r *http.Request) (*options.Options, error) {
opts := &options.Options{}
- // Step 1: Extract components from the URL path
+ // The path, without its outer slashes, split into parts.
urlPath := r.URL.Path
// Remove leading and trailing slashes for consistency
urlPath = strings.Trim(urlPath, "/")
@@ -33,7 +33,7 @@ if strings.HasPrefix(urlPath, "files/") { opts.View = "files"
}
- // Step 2: Process domain name for VIEW or LANG
+ // A subdomain can name the view or the language (v2.wttr.in, de.wttr.in).
host := r.Host
domainParts := strings.SplitN(host, ".", 2)
if len(domainParts) > 0 {
@@ -48,7 +48,7 @@ opts.View = domainPrefix }
}
- // Step 3: Process the first path component for VIEW or LANG if not set by domain
+ // Otherwise the first path part can.
firstComponent := ""
if len(components) > 0 && components[0] != "" {
firstComponent = strings.ToLower(components[0])
@@ -59,7 +59,7 @@ opts.View = firstComponent }
}
- // Step 4: Extract location and format from the path
+ // Then the location and the output format.
location := ""
if len(components) > 1 && components[1] != "" {
location = components[1]
@@ -88,7 +88,7 @@ } opts.Location = strings.ReplaceAll(opts.Location, "+", " ")
opts.Location = strings.TrimPrefix(opts.Location, "~")
- // Step 5: Set default language from header if not set by domain or URL
+ // No language yet: try Accept-Language, then English.
if opts.Lang == "" {
if lang := langFromAccept(r.Header.Get("Accept-Language")); lang != "" {
opts.Lang = lang
@@ -98,8 +98,8 @@ if opts.Lang == "" { opts.Lang = "en"
}
- // Step 6: Set User-Agent from header. Output defaulting based on the
- // agent is deferred to ApplyAutoFixes, where the view is known.
+ // The output that suits the client is picked in ApplyAutoFixes,
+ // once the view is known.
opts.Agent = r.Header.Get("User-Agent")
return opts, nil
@@ -387,7 +387,6 @@ return true }
// isValidView checks if the provided string is a valid view name.
-// This is a placeholder; in a real application, you'd have a list of supported views.
func isValidView(view string) bool {
validViews := []string{
"files", "page", "line",
@@ -29,7 +29,7 @@ // Parse implements QueryParser.Parse func (p *strictQueryParser) Parse(ctx context.Context, r *http.Request, ipOpts *options.Options) (*options.Options, error) {
queryString := r.URL.RawQuery
- // Step 1: use the existing parser to get validated map[string]string
+ // Validate first, with the same parser the old code used.
rawMap, err := ParseQueryString(queryString, p.config)
if err != nil {
return nil, err
@@ -52,7 +52,7 @@ return nil, fmt.Errorf("response missing current_condition array") }
cc := full.CurrentCondition[0]
- // ── Parse numeric fields with safe defaults ────────────────────────────────
+ // Parse numeric fields with safe defaults
tempC, _ := strconv.ParseFloat(cc.TempC, 64)
tempF, _ := strconv.ParseFloat(cc.TempF, 64)
feelsC, _ := strconv.ParseFloat(cc.FeelsLikeC, 64)
@@ -70,28 +70,27 @@ cloud, _ := strconv.Atoi(cc.Cloudcover)
visKm, _ := strconv.ParseFloat(cc.Visibility, 64)
- // ── Description (prefer translated lang_xx if present) ─────────────────────
+ // Description (prefer translated lang_xx if present)
desc := ""
if len(cc.WeatherDesc) > 0 {
desc = cc.WeatherDesc[0].Value
}
// Try to find any lang_ field (lang_ru, lang_de, etc.)
- for _, v := range cc.WeatherDesc { // actually it's []ValueItem, but we check parent
- // Note: real code might need to look at the full map or use a lang prefix search
- // For simplicity we take the first description (usually English or requested lang)
+ // The first non-empty description wins: English, or the language asked for.
+ for _, v := range cc.WeatherDesc {
if desc == "" && len(v.Value) > 0 {
desc = v.Value
}
}
- // ── Observation time ───────────────────────────────────────────────────────
+ // Observation time
obsTime, err := time.Parse("2006-01-02 15:04", cc.LocalObsDateTime)
if err != nil {
obsTime = time.Now().UTC() // fallback
}
- // ── Location fallback ──────────────────────────────────────────────────────
+ // Location fallback
locName := "?"
if len(full.Request) > 0 {
locName = full.Request[0].Query
@@ -110,7 +109,7 @@ FeelsLikeF: feelsF, Humidity: humidity,
PrecipMM: precipMM,
PrecipInches: precipInches,
- ChanceOfRain: 0, // often comes from hourly → can be filled later if needed
+ ChanceOfRain: 0, // only the hourly data has it
PressureHpa: pressure,
UVIndex: uv,
WindKmph: windKmph,
@@ -34,7 +34,7 @@ return v, nil
}
-// GetStringValue — convenience wrapper for the most common case (strings & numbers)
+// GetStringValue is the common case: a string or a number, as text.
func GetStringValue(data interface{}, jqQuery string) (string, error) {
val, err := GetValue(data, jqQuery)
if err != nil {
@@ -14,9 +14,7 @@ "github.com/runawaydevil/hammer/internal/localization" "github.com/runawaydevil/hammer/internal/options"
)
-// ──────────────────────────────────────────────────────────────────────────────
// Unified render function signature
-// ──────────────────────────────────────────────────────────────────────────────
type RenderFunc func(ctx *RenderContext) string
@@ -25,7 +23,7 @@ // in the compact one-line text format (?format=... or preconfigured IDs). //
// It implements the weather.Renderer interface.
type OnelineRenderer struct {
- // Currently stateless — all configuration comes from query.Options
+ // Stateless: all configuration comes from query.Options.
// Future fields could include:
// - symbolSets map[string]map[int]string // different emoji/icon sets
// - moonCalculator MoonCalculator // injectable moon phase logic
@@ -41,9 +39,7 @@ func NewOnelineRenderer() *OnelineRenderer { return &OnelineRenderer{}
}
-// ──────────────────────────────────────────────────────────────────────────────
// Helpers (usually kept private in the same file)
-// ──────────────────────────────────────────────────────────────────────────────
func (r *OnelineRenderer) determineFormat(opts *options.Options) string {
if opts == nil || opts.Format == "" {
@@ -80,10 +76,8 @@ Location *domain.Location Now time.Time
}
-// ──────────────────────────────────────────────────────────────────────────────
// Central registry: letter → render function
// This matches format_specifiers.letter from oneline.yaml
-// ──────────────────────────────────────────────────────────────────────────────
var placeholderRenderers = map[rune]RenderFunc{
'c': RenderConditionEmoji,
@@ -110,9 +104,7 @@ 'T': RenderLocalTime, 'Z': RenderTimezone,
}
-// ──────────────────────────────────────────────────────────────────────────────
// Main rendering loop using the map
-// ──────────────────────────────────────────────────────────────────────────────
func (r *OnelineRenderer) Render(q domain.Query, localizer localization.Localizer) (domain.RenderOutput, error) {
if q.Weather == nil || len(*q.Weather) == 0 {
@@ -152,7 +144,7 @@ if fn, ok := placeholderRenderers[letter]; ok {
sb.WriteString(fn(ctx))
} else {
- // unknown placeholder → keep literal or log
+ // unknown placeholder: keep it as written
sb.WriteString(string([]rune{'%', letter}))
}
continue
@@ -184,7 +176,7 @@ sb.WriteString(fn(ctx)) continue
}
- // Unknown → keep %X as-is
+ // unknown: keep %X as written
sb.WriteByte('%')
sb.WriteByte(letter)
continue
@@ -225,9 +217,9 @@ sb.WriteByte(byte(v)) i += 3
continue
}
- // invalid hex → keep literal %XX
+ // not hex: keep %XX as written
}
- // lone % or invalid → keep as is
+ // a lone %: keep it
sb.WriteByte(s[i])
i++
}
@@ -8,9 +8,7 @@ "strconv" "time"
)
-// ──────────────────────────────────────────────────────────────────────────────
-// All render functions — same signature
-// ──────────────────────────────────────────────────────────────────────────────
+// Every render function has this signature.
func RenderConditionFullName(ctx *RenderContext) string {
if ctx.Options.Lang == "en" {
@@ -184,13 +182,13 @@ if ctx == nil || ctx.Data == nil || ctx.Data.ConditionCode == "" { return "?"
}
- // Step 1: Get symbolic name from code (e.g. "116" → "PartlyCloudy")
+ // The symbolic name for the code ("116" is "PartlyCloudy"),
symbolicName, ok := WWOCodeToName[ctx.Data.ConditionCode]
if !ok {
return "?"
}
- // Step 2: Look up the plain ASCII-style symbol
+ // then its plain ASCII symbol.
plainSymbol, exists := WeatherSymbolPlain[symbolicName]
if !exists {
return "?"
@@ -26,7 +26,7 @@ return age
}
-// RenderMoonPhaseEmoji — improved to better match visual expectation + Python spirit
+// RenderMoonPhaseEmoji picks the emoji closest to how the moon looks, like the Python version.
func RenderMoonPhaseEmoji(ctx *RenderContext) string {
if ctx == nil {
return "🌕"
@@ -53,7 +53,7 @@ return phases[idx] }
// RenderMoonDay returns approximate lunar day (days since last new moon, 0–29)
-// Rounded to nearest integer — suitable for weather/terminal display
+// Rounded to a whole day, plenty for a weather report.
func RenderMoonDay(ctx *RenderContext) string {
illum := suncalc.GetMoonIllumination(ctx.Now)
@@ -19,7 +19,7 @@ }
lat := ctx.Location.Latitude
lon := ctx.Location.Longitude
- date := ctx.Now.Truncate(24 * time.Hour) // midnight of "today" (UTC or local — adjust if needed)
+ date := ctx.Now.Truncate(24 * time.Hour) // midnight today, UTC
rise, _ := sunrise.SunriseSunset(
lat, lon,
@@ -65,7 +65,7 @@ localSet := set.In(loc) return localSet.Format("15:04:05")
}
-// solarNoonTime — approximate as midpoint between sunrise & sunset
+// solarNoonTime is the midpoint between sunrise and sunset, near enough.
func solarNoonTime(ctx *RenderContext) string {
riseStr := sunriseTime(ctx)
setStr := sunsetTime(ctx)
@@ -107,7 +107,7 @@ if ctx.Location.TimeZone != "" { var err error
loc, err = time.LoadLocation(ctx.Location.TimeZone)
if err == nil {
- // success — we have both coords + valid tz
+ // both coordinates and a valid zone
return lat, lon, loc, lat != 0 || lon != 0
}
// log invalid tz name but continue with coords
@@ -130,12 +130,10 @@ return lat, lon, loc, ok
}
-// ────────────────────────────────────────────────
// Optional: Helper to get "today" at midnight local time
// Useful for twilight calculations
-// ────────────────────────────────────────────────
func getLocalMidnight(ctx *RenderContext) time.Time {
- _, _, loc, _ := getLatLonAndTZ(ctx) // ignore ok here — UTC is safe fallback
+ _, _, loc, _ := getLatLonAndTZ(ctx) // ok ignored: UTC is a safe fallback
now := ctx.Now
if now.IsZero() {
now = time.Now()
@@ -150,8 +148,7 @@ if !ok { return "??:??:??"
}
- // Use a proper date – here assuming "today" from context or current time
- // In real code, prefer to take date from astronomy/weather data if available
+ // Today in the location's zone; the report has no date of its own here.
now := time.Now().In(loc)
utcDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) // midnight UTC
@@ -66,7 +66,7 @@ if query.Weather == nil { return domain.RenderOutput{Content: []byte{}}, nil
}
- // Deserialize WeatherRaw → domain.Weather (standard pattern)
+ // WeatherRaw into domain.Weather
var weather domain.Weather
err := json.Unmarshal(*query.Weather, &weather)
if err != nil {
@@ -79,7 +79,7 @@ rendered := r.renderCurrent(weather.CurrentCondition[0], "current", alreadySeen) output.WriteString(rendered)
}
- // Forecast — next 3 days
+ // Forecast for the next 3 days
for i := 0; i < 3 && i < len(weather.Weather); i++ {
rendered := r.renderCurrent(weather.Weather[i], fmt.Sprintf("%dd", i), alreadySeen)
output.WriteString(rendered)
@@ -34,7 +34,7 @@ return " " }
}
-// drawAstronomical — port of the original Python draw_astronomical function:
+// drawAstronomical is a port of the Python draw_astronomical function:
// a moon-phase line and a 72-hour daylight timeline aligned with the time scale.
func drawAstronomical(loc *domain.Location, opts *options.Options) string {
if loc == nil || (loc.Latitude == 0 && loc.Longitude == 0) {
@@ -82,7 +82,7 @@ return moonLine.String() + "\n" + timeline.String() + "\n\n" }
// parseTimeStr converts "HH:MM:SS" (or "??:??:??") into a time.Time on the same day as `ref`.
-// Falls back to `ref` on error — matches Python’s try/except behavior.
+// Falls back to `ref` on error, like the Python try/except.
func parseTimeStr(s string, ref time.Time) time.Time {
if s == "" || s == "??:??:??" || s == "??:??:???" {
return ref
@@ -7,10 +7,10 @@ "math" "strings"
)
-// sparkBars as []rune — correct Unicode handling
+// sparkBars is a []rune so the multi-byte glyphs index right.
var sparkBars = []rune(" _▁▂▃▄▅▇█")
-// drawRainSpark — now matches Python v2 label position (TOP of the sparkline)
+// drawRainSpark draws the rain sparkline with its label on top, as Python v2 does.
func drawRainSpark(data, chanceData []float64, height, width int) string {
if len(data) == 0 || width <= 0 {
return strings.Repeat(" ", max(width, 0)) + "\n"
@@ -80,7 +80,7 @@ // Prepend label + blank line (matches original Python behavior) return "\n" + maxLine + "\n" + output.String()
}
-// box — exact port of Python _box
+// box is a straight port of Python's _box.
func box(row int, value, maxValue float64, height int) string {
if maxValue == 0 {
return " "
@@ -126,7 +126,7 @@ // renderSections with proper terminal display width func renderSections(sections []section) string {
var b strings.Builder
- // === Step 1: Calculate GLOBAL max display width per column ===
+ // The widest label and value of each column, across all sections.
maxLabel := make([]int, 0)
maxValue := make([]int, 0)
@@ -151,7 +151,7 @@ } }
}
- // === Step 2: Render with alignment ===
+ // Then draw them aligned.
for _, sec := range sections {
if len(sec) == 0 {
continue
@@ -23,7 +23,7 @@ Data: &oneline.ParsedCurrentCondition{ ConditionCode: strconv.Itoa(code),
},
Options: opts,
- // Location, Now, DataRaw can stay nil/zero — emoji renderer doesn't need them
+ // Location, Now and DataRaw stay zero: the emoji renderer doesn't use them
}
b.WriteString(oneline.RenderConditionEmoji(ctx))
@@ -78,7 +78,7 @@ chanceInterp := interpolate(chanceValues, 72) buf.WriteString(drawRainSpark(precipInterp, chanceInterp, 5, 72))
buf.WriteString("\n")
- // Weather emojis — one 3-column cell per slot, 24 slots across the panel,
+ // Weather emojis: one 3-column cell per slot, 24 slots across the panel,
// however many forecast slots the upstream returned.
codes := extractAllHourlyInt(weather, func(h domain.Hourly) string { return h.WeatherCode })
buf.WriteString(drawWeatherEmoji(resampleInts(codes, panelSlots), opts))
@@ -17,8 +17,8 @@ // maxField bounds each handshake field read by a server.
const maxField = 64
-// Login sends the client handshake on c — an empty field, then the client
-// user, the server user and "terminal/speed" — and waits for the server's
+// Login sends the client handshake on c (an empty field, then the client
+// user, the server user and "terminal/speed") and waits for the server's
// single zero byte. After it, c is a plain byte stream.
func Login(c net.Conn, clientUser, serverUser, terminal string, wait time.Duration) error {
for _, f := range []string{clientUser, serverUser, terminal} {
@@ -22,7 +22,7 @@ const Sep = "\x00sep\x00"
// BoxANSI draws a box around lines that may contain ANSI SGR sequences,
// padding each line to at least minInner cells using VisibleWidth. frame is
-// the SGR code applied to the border glyphs only — content colours are
+// the SGR code applied to the border glyphs only; content colours are
// preserved. Pass "" for an uncoloured border.
func BoxANSI(lines []string, minInner int, style BoxStyle, frame string) []string {
return TitledBoxANSI("", lines, minInner, style, frame)
@@ -4,15 +4,15 @@ // The shared terminal palette. Tools use these so their output matches the // surrounding interface instead of inventing their own colours.
const (
Reset = "\033[0m"
- Frame = "\033[38;5;44m" // cyan — borders
- Title = "\033[1;38;5;51m" // bold bright cyan — titles
- Accent = "\033[38;5;250m" // light grey — secondary text
- Prompt = "\033[1;38;5;213m" // bold pink — prompts and keys
- Dim = "\033[38;5;244m" // mid grey — hints
- Value = "\033[38;5;255m" // near white — data values
- Good = "\033[38;5;84m" // green — confirmations
- Bad = "\033[38;5;203m" // soft red — errors
- Warn = "\033[1;38;5;221m" // bold yellow — destructive confirmations
+ Frame = "\033[38;5;44m" // cyan: borders
+ Title = "\033[1;38;5;51m" // bold bright cyan: titles
+ Accent = "\033[38;5;250m" // light grey: secondary text
+ Prompt = "\033[1;38;5;213m" // bold pink: prompts and keys
+ Dim = "\033[38;5;244m" // mid grey: hints
+ Value = "\033[38;5;255m" // near white: data values
+ Good = "\033[38;5;84m" // green: confirmations
+ Bad = "\033[38;5;203m" // soft red: errors
+ Warn = "\033[1;38;5;221m" // bold yellow: destructive confirmations
// ClearScreen erases the visible screen and homes the cursor. It leaves
// the scrollback alone on purpose.
@@ -53,7 +53,7 @@ return nil, errors.New("location not found") }
l.m.Unlock()
- // Use the existing Resolve method — it does cache lookup + upstream query + timezone
+ // Resolve does the cache lookup, the upstream query and the time zone
// + caching of result
raw, err := l.cache.Resolve(locationName)
if err != nil {
@@ -90,14 +90,8 @@ if err != nil { return nil, fmt.Errorf("invalid longitude in cached location: %w", err)
}
- // Country / CountryCode are not present in the existing Location type.
- // Options:
- // 1. Leave them empty (simplest)
- // 2. Parse them from Fullname (heuristic, fragile)
- // 3. Extend upstream query to return them (requires changing Nominatim parsers)
- // 4. Use a second service (e.g. reverse geocoding with country info)
- //
- // → for most use-cases option 1 is acceptable
+ // Country and CountryCode stay empty: the cached Location has neither,
+ // and nothing that reads them needs them yet.
return &domain.Location{
Name: raw.Name, // already normalized
@@ -181,10 +181,10 @@ if entry, err := s.Cacher.WaitForCompletion(cacheKey, 12*time.Second); err == nil && entry != nil { s.serveFromCache(w, entry)
return
}
- // Timeout or failure → fall through and compute ourselves
+ // timed out or failed: compute it here
}
- // We are the leader → compute and store
+ // first in line: compute and store
s.computeAndStore(ctx, w, r, cacheKey, overallStart)
}
@@ -233,7 +233,7 @@ }
// computeResponse performs location resolution, weather fetch, rendering and formatting.
// Returns ready-to-cache domain.CacheEntry or error.
-// Does NOT write to ResponseWriter — that stays in handler.
+// It never writes the response; the handler does.
func (s *WeatherService) computeResponse(
ctx context.Context, r *http.Request,
tracker *TimeTracker,
@@ -242,7 +242,7 @@ debugRequested := r.URL.Query().Get("debug") != ""
clientIP := getClientIP(r)
- // ── Determine location string ────────────────────────────────────────
+ // Determine location string
start := time.Now()
path := r.URL.Path // cleanPath(r.URL.Path) // helper: trim / and extensions
autoDetect := isAutoDetectPath(path)
@@ -305,7 +305,7 @@ locStr = "Berlin" log.Println("no location could be determined")
}
- // ── Geocode ───────────────────────────────────────────────────────────
+ // Geocode
start = time.Now()
location, err := s.Locator.GetLocation(locStr)
if err != nil {
@@ -327,7 +327,7 @@ formatOut *domain.FormatOutput query domain.Query
)
- // ── Build Query ───────────────────────────────────────────────────────
+ // Build Query
start = time.Now()
query = domain.Query{
ClientData: &domain.ClientData{
@@ -356,7 +356,7 @@ return uplinkResponse, err }
tracker.Add(fmt.Sprintf("Upstream processing (view=%s)", opts.View), time.Since(start))
} else {
- // ── Fetch + Render (shared core) ──────────────────────────────────────
+ // Fetch + Render (shared core)
start = time.Now()
query.IPData = ipData
@@ -377,7 +377,7 @@ } }
renderOut.Content = brand.Apply(s.Brand, renderOut.Content, opts, tagline)
- // ── Format ────────────────────────────────────────────────────────────
+ // Format
start = time.Now()
formatter := s.selectFormatter(opts.Output)
@@ -55,18 +55,18 @@ // GetWeather returns weather data, using cache when possible. func (c *CachedWeatherClient) GetWeather(lat, lon float64, lang string) ([]byte, error) {
key := MakeWeatherCacheKey(lat, lon, lang)
- // 1. Fast path: check cache
+ // In the cache already?
if entry := c.cacher.Get(key); entry != nil {
return entry.Body, nil
}
- // 2. Try to become the leader (coalescing)
+ // Otherwise one caller fetches,
if c.cacher.SetInProgressIfNotExists(key) {
- // We are the one fetching
+ // this one
return c.fetchAndCache(key, lat, lon, lang)
}
- // 3. Another goroutine is already fetching → wait for it
+ // and the rest wait for it.
return c.waitForInProgress(key)
}