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.
This commit is contained in:
+229
@@ -0,0 +1,229 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user