Files
OpsLog/webpublish.go
T
rouggy fb78b1c052 feat(webpublish): publish the log as a web page or CSV, with FTP upload
Settings -> Web publishing. Renders the last N QSOs with the columns the
operator picks, writes the file locally, and optionally uploads it by FTP or
explicit FTPS.

Local write FIRST, upload second, always. A network failure then leaves a good
file on disk that can be published another way, instead of a truncated one on
the server. The local write itself goes to a temp file and renames over the
target, so a reader — or a syncing client — never sees a half-written page.

The HTML page is fully self-contained: inline CSS, inline sort script, no font,
no CDN, no external request at all. It has to work on hosting that blocks
third-party requests, and a page about someone's hobby should not report its
readers to anyone.

Columns are a curated set, not "every ADIF field". This is published to the
public: RST and QSL status belong on it, the operator's home address does not.

Two triggers, both debounced through one path: a QSO is logged, or the optional
timer fires. Fifteen seconds of coalescing means a run of contacts produces one
upload rather than one per QSO, and nobody reading a web page can tell the
difference.

The config is one JSON blob under a single settings key, and that key is marked
sensitive: the FTP password lives inside it, so the whole blob is encrypted at
rest with the others. A locked vault reads back empty, which correctly reads as
"not configured" — publishing must not run with a password it cannot decrypt.
2026-08-10 09:07:08 +02:00

230 lines
6.6 KiB
Go

package main
// Web publishing — the Wails boundary for internal/webpub, plus the scheduling.
//
// Two triggers, deliberately: a QSO is logged, or the periodic timer fires.
// Both go through publishSoon, which DEBOUNCES: a run of contacts must not
// produce one FTP session per QSO, and a page that is fifteen seconds stale is
// indistinguishable from a live one to anybody reading it on the web.
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"hamlog/internal/applog"
"hamlog/internal/qso"
"hamlog/internal/webpub"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// publishDebounce is how long a publish request waits for company. Long enough
// to fold a burst of logging into one upload, short enough that the page looks
// live to a reader who just heard you on the air.
const publishDebounce = 15 * time.Second
type webPublisher struct {
mu sync.Mutex
timer *time.Timer
ticker *time.Ticker
tickStp chan struct{}
last time.Time
lastErr string
}
// WebPublishStatus is what the settings panel shows under the buttons.
//
// No column list here: the panel gets that from WebPublishColumns(). An
// anonymous struct in a bound type also breaks the Wails generator, which has
// no name to emit for it.
type WebPublishStatus struct {
LastRun string `json:"last_run"` // "" = never this session
LastErr string `json:"last_err"`
}
// GetWebPublishConfig reads the stored configuration (defaults applied).
func (a *App) GetWebPublishConfig() (webpub.Config, error) {
var cfg webpub.Config
if a.settings == nil {
cfg.Normalise()
return cfg, fmt.Errorf("db not initialized")
}
raw, err := a.settings.Get(a.ctx, keyWebPublish)
if err == nil && strings.TrimSpace(raw) != "" {
// A locked vault hands back "" rather than ciphertext — that reads as "not
// configured", which is exactly right here: publishing must not run with a
// password we cannot decrypt.
_ = json.Unmarshal([]byte(raw), &cfg)
}
cfg.Normalise()
return cfg, nil
}
// SaveWebPublishConfig persists it and restarts the periodic timer.
func (a *App) SaveWebPublishConfig(cfg webpub.Config) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
cfg.Normalise()
b, err := json.Marshal(cfg)
if err != nil {
return err
}
if err := a.settings.Set(a.ctx, keyWebPublish, string(b)); err != nil {
return err
}
a.restartWebPublishTimer()
return nil
}
// WebPublishColumns lists the offered columns for the picker.
func (a *App) WebPublishColumns() []map[string]string {
cols := webpub.KnownColumnKeys()
out := make([]map[string]string, 0, len(cols))
for _, c := range cols {
out = append(out, map[string]string{"key": c.Key, "header": c.Header})
}
return out
}
// TestWebPublishFTP validates the server settings without uploading anything.
func (a *App) TestWebPublishFTP(cfg webpub.Config) (string, error) {
return webpub.Test(cfg)
}
// PublishLogNow renders and publishes immediately, ignoring the debounce, and
// reports what happened. This is the "Publish now" button: the operator is
// waiting on the answer, so it runs synchronously and returns the real error.
func (a *App) PublishLogNow() (string, error) {
cfg, _ := a.GetWebPublishConfig()
return a.publish(cfg)
}
// publish does the work: read the QSOs, render, write locally, then upload.
//
// Local FIRST and upload second, always. A network failure then leaves a good
// file on disk that the operator can publish another way, instead of a
// truncated one on the server.
func (a *App) publish(cfg webpub.Config) (string, error) {
if a.qso == nil {
return "", fmt.Errorf("logbook not ready")
}
cfg.Normalise()
qsos, err := a.qso.List(a.ctx, qso.ListFilter{Limit: cfg.Count})
if err != nil {
return "", fmt.Errorf("read the log: %w", err)
}
station := ""
if p, perr := a.profiles.Active(a.ctx); perr == nil {
station = p.Callsign
}
data, err := webpub.Render(cfg, qsos, station)
if err != nil {
return "", fmt.Errorf("build the page: %w", err)
}
path, err := webpub.WriteLocal(cfg, data)
if err != nil {
return "", err
}
msg := fmt.Sprintf("%d QSO → %s", len(qsos), path)
if cfg.FTPEnabled {
if err := webpub.Upload(cfg, data); err != nil {
// The local file IS written — say so, so the operator knows the failure
// is the transfer and not the export.
return msg, fmt.Errorf("written locally, but the upload failed: %w", err)
}
msg += fmt.Sprintf(" → ftp://%s/%s", cfg.FTPHost, strings.TrimPrefix(cfg.FTPFolder+"/"+cfg.FTPFileName, "/"))
}
return msg, nil
}
// publishSoon schedules a debounced publish. Called on every logged QSO.
func (a *App) publishSoon() {
cfg, _ := a.GetWebPublishConfig()
if !cfg.Enabled {
return
}
a.webpub.mu.Lock()
defer a.webpub.mu.Unlock()
if a.webpub.timer != nil {
a.webpub.timer.Stop()
}
a.webpub.timer = time.AfterFunc(publishDebounce, a.publishNowBackground)
}
// publishNowBackground runs a scheduled publish and records the outcome for the
// settings panel. Never surfaces a dialog: this fires while the operator is
// working, and a web server that is down must not interrupt logging.
func (a *App) publishNowBackground() {
cfg, _ := a.GetWebPublishConfig()
if !cfg.Enabled {
return
}
msg, err := a.publish(cfg)
a.webpub.mu.Lock()
a.webpub.last = time.Now()
if err != nil {
a.webpub.lastErr = err.Error()
} else {
a.webpub.lastErr = ""
}
a.webpub.mu.Unlock()
if err != nil {
applog.Printf("webpublish: %v", err)
} else {
applog.Printf("webpublish: %s", msg)
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "webpublish:done", msg)
}
}
// GetWebPublishStatus reports the last run for the settings panel.
func (a *App) GetWebPublishStatus() WebPublishStatus {
var st WebPublishStatus
a.webpub.mu.Lock()
if !a.webpub.last.IsZero() {
st.LastRun = a.webpub.last.UTC().Format("2006-01-02 15:04:05") + " UTC"
}
st.LastErr = a.webpub.lastErr
a.webpub.mu.Unlock()
return st
}
// restartWebPublishTimer (re)arms the periodic refresh from the saved interval.
// Stopped and rebuilt on every save, so a changed interval takes effect at once
// rather than after the old one has fired.
func (a *App) restartWebPublishTimer() {
a.webpub.mu.Lock()
if a.webpub.tickStp != nil {
close(a.webpub.tickStp)
a.webpub.tickStp = nil
}
a.webpub.mu.Unlock()
cfg, _ := a.GetWebPublishConfig()
if !cfg.Enabled || cfg.IntervalMin <= 0 {
return
}
stop := make(chan struct{})
a.webpub.mu.Lock()
a.webpub.tickStp = stop
a.webpub.mu.Unlock()
go func(every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
a.publishNowBackground()
}
}
}(time.Duration(cfg.IntervalMin) * time.Minute)
}