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:
@@ -0,0 +1,377 @@
|
||||
// Package webpub publishes the log as a file an operator can put on a website:
|
||||
// a self-contained HTML page or a CSV, written locally and optionally uploaded
|
||||
// by FTP/FTPS.
|
||||
//
|
||||
// Design notes that matter:
|
||||
//
|
||||
// - The local file is ALWAYS written first and the upload layered on top. A
|
||||
// network failure then leaves a good file on disk rather than a truncated
|
||||
// one on the server, and the operator can publish it by any other means.
|
||||
// - The page is self-contained: no external CSS, font or script. It has to
|
||||
// work dropped into any hosting, including one that blocks third-party
|
||||
// requests, and it must not leak the reader's visit to anyone.
|
||||
// - Columns are a fixed, curated set rather than "every ADIF field". This is
|
||||
// a page shown to the public: RST and QSL status belong, the operator's
|
||||
// home address does not.
|
||||
package webpub
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"html"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jlaffaye/ftp"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// Config is the whole feature's configuration. Stored per profile.
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Format string `json:"format"` // "html" | "csv"
|
||||
Folder string `json:"folder"` // local output folder
|
||||
FileName string `json:"file_name"` // e.g. "log.html"
|
||||
Title string `json:"title"` // page heading; blank → callsign
|
||||
Count int `json:"count"` // publish the last N QSOs
|
||||
// IntervalMin is the periodic refresh in minutes. 0 = only republish when a
|
||||
// QSO is logged. Every publish is debounced regardless (see Publisher).
|
||||
IntervalMin int `json:"interval_min"`
|
||||
Columns []string `json:"columns"`
|
||||
|
||||
FTPEnabled bool `json:"ftp_enabled"`
|
||||
FTPHost string `json:"ftp_host"`
|
||||
FTPPort int `json:"ftp_port"`
|
||||
FTPUser string `json:"ftp_user"`
|
||||
FTPPassword string `json:"ftp_password"`
|
||||
FTPTLS bool `json:"ftp_tls"` // explicit AUTH TLS (FTPS)
|
||||
FTPFolder string `json:"ftp_folder"`
|
||||
FTPFileName string `json:"ftp_file_name"`
|
||||
}
|
||||
|
||||
// Column is one publishable field: a stable key, the header printed in the
|
||||
// file, and how to read it off a QSO.
|
||||
type Column struct {
|
||||
Key string
|
||||
Header string
|
||||
Value func(q *qso.QSO) string
|
||||
}
|
||||
|
||||
func str(p *int) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(*p)
|
||||
}
|
||||
|
||||
// Columns is the curated set, in default display order. Add here to offer a new
|
||||
// one; the stored config keeps keys, so order changes are safe.
|
||||
var Columns = []Column{
|
||||
{"date", "Date", func(q *qso.QSO) string { return q.QSODate.UTC().Format("2006-01-02") }},
|
||||
{"time", "UTC", func(q *qso.QSO) string { return q.QSODate.UTC().Format("15:04") }},
|
||||
{"callsign", "Call", func(q *qso.QSO) string { return q.Callsign }},
|
||||
{"band", "Band", func(q *qso.QSO) string { return q.Band }},
|
||||
{"mode", "Mode", func(q *qso.QSO) string { return q.Mode }},
|
||||
{"freq", "Freq", func(q *qso.QSO) string {
|
||||
if q.FreqHz == nil || *q.FreqHz == 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64)
|
||||
}},
|
||||
{"rst_sent", "RST S", func(q *qso.QSO) string { return q.RSTSent }},
|
||||
{"rst_rcvd", "RST R", func(q *qso.QSO) string { return q.RSTRcvd }},
|
||||
{"name", "Name", func(q *qso.QSO) string { return q.Name }},
|
||||
{"qth", "QTH", func(q *qso.QSO) string { return q.QTH }},
|
||||
{"country", "Country", func(q *qso.QSO) string { return q.Country }},
|
||||
{"grid", "Grid", func(q *qso.QSO) string { return q.Grid }},
|
||||
{"dxcc", "DXCC", func(q *qso.QSO) string { return str(q.DXCC) }},
|
||||
{"cqz", "CQ", func(q *qso.QSO) string { return str(q.CQZ) }},
|
||||
{"ituz", "ITU", func(q *qso.QSO) string { return str(q.ITUZ) }},
|
||||
{"iota", "IOTA", func(q *qso.QSO) string { return q.IOTA }},
|
||||
{"pota", "POTA", func(q *qso.QSO) string { return q.POTARef }},
|
||||
{"sota", "SOTA", func(q *qso.QSO) string { return q.SOTARef }},
|
||||
{"qsl_sent", "QSL S", func(q *qso.QSO) string { return q.QSLSent }},
|
||||
{"qsl_rcvd", "QSL R", func(q *qso.QSO) string { return q.QSLRcvd }},
|
||||
{"lotw_rcvd", "LoTW", func(q *qso.QSO) string { return q.LOTWRcvd }},
|
||||
{"station", "Station", func(q *qso.QSO) string { return q.StationCallsign }},
|
||||
{"comment", "Comment", func(q *qso.QSO) string { return q.Comment }},
|
||||
}
|
||||
|
||||
// DefaultColumns is what a fresh configuration publishes — the columns a reader
|
||||
// of someone else's log actually looks for.
|
||||
var DefaultColumns = []string{"date", "time", "callsign", "band", "mode", "rst_sent", "rst_rcvd", "country"}
|
||||
|
||||
func columnsFor(keys []string) []Column {
|
||||
if len(keys) == 0 {
|
||||
keys = DefaultColumns
|
||||
}
|
||||
byKey := make(map[string]Column, len(Columns))
|
||||
for _, c := range Columns {
|
||||
byKey[c.Key] = c
|
||||
}
|
||||
out := make([]Column, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if c, ok := byKey[strings.TrimSpace(k)]; ok {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 { // every stored key unknown (config from a newer build)
|
||||
return columnsFor(DefaultColumns)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// KnownColumnKeys lists the offered columns, for the settings UI.
|
||||
func KnownColumnKeys() []Column {
|
||||
out := make([]Column, len(Columns))
|
||||
copy(out, Columns)
|
||||
sort.SliceStable(out, func(i, j int) bool { return false }) // keep declared order
|
||||
return out
|
||||
}
|
||||
|
||||
// Normalise fills in the defaults a half-filled config would otherwise carry
|
||||
// into the renderer.
|
||||
func (c *Config) Normalise() {
|
||||
if c.Format != "csv" {
|
||||
c.Format = "html"
|
||||
}
|
||||
if strings.TrimSpace(c.FileName) == "" {
|
||||
if c.Format == "csv" {
|
||||
c.FileName = "log.csv"
|
||||
} else {
|
||||
c.FileName = "log.html"
|
||||
}
|
||||
}
|
||||
if c.Count <= 0 {
|
||||
c.Count = 100
|
||||
}
|
||||
if c.FTPPort <= 0 {
|
||||
c.FTPPort = 21
|
||||
}
|
||||
if len(c.Columns) == 0 {
|
||||
c.Columns = append([]string{}, DefaultColumns...)
|
||||
}
|
||||
if strings.TrimSpace(c.FTPFileName) == "" {
|
||||
c.FTPFileName = c.FileName
|
||||
}
|
||||
}
|
||||
|
||||
// Render builds the file contents for the given QSOs.
|
||||
func Render(cfg Config, qsos []qso.QSO, stationCall string) ([]byte, error) {
|
||||
cfg.Normalise()
|
||||
cols := columnsFor(cfg.Columns)
|
||||
if cfg.Format == "csv" {
|
||||
return renderCSV(cols, qsos)
|
||||
}
|
||||
return renderHTML(cfg, cols, qsos, stationCall), nil
|
||||
}
|
||||
|
||||
func renderCSV(cols []Column, qsos []qso.QSO) ([]byte, error) {
|
||||
var b strings.Builder
|
||||
w := csv.NewWriter(&b)
|
||||
head := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
head[i] = c.Header
|
||||
}
|
||||
if err := w.Write(head); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := make([]string, len(cols))
|
||||
for i := range qsos {
|
||||
for j, c := range cols {
|
||||
row[j] = c.Value(&qsos[i])
|
||||
}
|
||||
if err := w.Write(row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
if err := w.Error(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
|
||||
// renderHTML writes a standalone page: inline CSS, inline sort script, no
|
||||
// external request of any kind.
|
||||
func renderHTML(cfg Config, cols []Column, qsos []qso.QSO, stationCall string) []byte {
|
||||
title := strings.TrimSpace(cfg.Title)
|
||||
if title == "" {
|
||||
if stationCall != "" {
|
||||
title = stationCall + " — log"
|
||||
} else {
|
||||
title = "Log"
|
||||
}
|
||||
}
|
||||
esc := html.EscapeString
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>` + esc(title) + `</title>
|
||||
<style>
|
||||
:root{color-scheme:light dark;--bg:#fff;--fg:#16181d;--mut:#5b6270;--line:#e2e5ea;--head:#f4f6f8;--zebra:#fafbfc;--accent:#2a78d6}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#16181d;--fg:#e6e8ec;--mut:#9aa2b1;--line:#2e343f;--head:#1f232b;--zebra:#1b1f26;--accent:#6da7ec}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;padding:1.5rem 1rem;background:var(--bg);color:var(--fg);
|
||||
font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||
.wrap{max-width:1100px;margin:0 auto}
|
||||
h1{margin:0 0 .25rem;font-size:1.35rem}
|
||||
.meta{margin:0 0 1rem;color:var(--mut);font-size:.8rem}
|
||||
.scroll{overflow-x:auto;border:1px solid var(--line);border-radius:8px}
|
||||
table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
|
||||
th,td{padding:.45rem .6rem;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
|
||||
th{position:sticky;top:0;background:var(--head);font-size:.72rem;letter-spacing:.05em;
|
||||
text-transform:uppercase;color:var(--mut);cursor:pointer;user-select:none}
|
||||
th:hover{color:var(--fg)}
|
||||
tbody tr:nth-child(even){background:var(--zebra)}
|
||||
tbody tr:last-child td{border-bottom:0}
|
||||
td.call{font-family:ui-monospace,Consolas,monospace;font-weight:700;color:var(--accent)}
|
||||
.foot{margin-top:.75rem;color:var(--mut);font-size:.75rem}
|
||||
</style>
|
||||
</head>
|
||||
<body><div class="wrap">
|
||||
<h1>` + esc(title) + `</h1>
|
||||
<p class="meta">` + strconv.Itoa(len(qsos)) + ` QSO · ` + time.Now().UTC().Format("2006-01-02 15:04") + ` UTC</p>
|
||||
<div class="scroll"><table><thead><tr>`)
|
||||
for _, c := range cols {
|
||||
b.WriteString(`<th>` + esc(c.Header) + `</th>`)
|
||||
}
|
||||
b.WriteString(`</tr></thead><tbody>`)
|
||||
for i := range qsos {
|
||||
b.WriteString(`<tr>`)
|
||||
for _, c := range cols {
|
||||
cls := ""
|
||||
if c.Key == "callsign" {
|
||||
cls = ` class="call"`
|
||||
}
|
||||
b.WriteString(`<td` + cls + `>` + esc(c.Value(&qsos[i])) + `</td>`)
|
||||
}
|
||||
b.WriteString(`</tr>`)
|
||||
}
|
||||
b.WriteString(`</tbody></table></div>
|
||||
<p class="foot">Generated by OpsLog</p>
|
||||
</div>
|
||||
<script>
|
||||
// Click a header to sort. Kept tiny and dependency-free: the page has to work
|
||||
// offline and on any hosting.
|
||||
document.querySelectorAll('th').forEach(function(th,i){
|
||||
th.addEventListener('click',function(){
|
||||
var tb=th.closest('table').tBodies[0],
|
||||
rows=Array.prototype.slice.call(tb.rows),
|
||||
asc=th.dataset.asc!=='1';
|
||||
rows.sort(function(a,b){
|
||||
var x=a.cells[i].textContent.trim(), y=b.cells[i].textContent.trim(),
|
||||
nx=parseFloat(x), ny=parseFloat(y),
|
||||
n=!isNaN(nx)&&!isNaN(ny)&&x!==''&&y!=='';
|
||||
var c=n?(nx-ny):x.localeCompare(y);
|
||||
return asc?c:-c;
|
||||
});
|
||||
rows.forEach(function(r){tb.appendChild(r)});
|
||||
th.closest('tr').querySelectorAll('th').forEach(function(o){delete o.dataset.asc});
|
||||
th.dataset.asc=asc?'1':'0';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
`)
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// WriteLocal writes the payload into the configured folder and returns the path.
|
||||
func WriteLocal(cfg Config, data []byte) (string, error) {
|
||||
cfg.Normalise()
|
||||
dir := strings.TrimSpace(cfg.Folder)
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("no output folder set")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create %s: %w", dir, err)
|
||||
}
|
||||
path := filepath.Join(dir, cfg.FileName)
|
||||
// Write to a temp file and rename over the target: a reader (or a syncing
|
||||
// client) never sees a half-written page.
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return "", fmt.Errorf("write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", fmt.Errorf("replace %s: %w", path, err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Upload sends the payload to the configured FTP/FTPS server.
|
||||
func Upload(cfg Config, data []byte) error {
|
||||
cfg.Normalise()
|
||||
host := strings.TrimSpace(cfg.FTPHost)
|
||||
if host == "" {
|
||||
return fmt.Errorf("no FTP server set")
|
||||
}
|
||||
c, err := dial(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = c.Quit() }()
|
||||
|
||||
if err := c.Login(cfg.FTPUser, cfg.FTPPassword); err != nil {
|
||||
return fmt.Errorf("login as %q: %w", cfg.FTPUser, err)
|
||||
}
|
||||
if dir := strings.TrimSpace(cfg.FTPFolder); dir != "" {
|
||||
if err := c.ChangeDir(dir); err != nil {
|
||||
return fmt.Errorf("enter remote folder %q: %w", dir, err)
|
||||
}
|
||||
}
|
||||
if err := c.Stor(cfg.FTPFileName, strings.NewReader(string(data))); err != nil {
|
||||
return fmt.Errorf("upload %q: %w", cfg.FTPFileName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dial(cfg Config) (*ftp.ServerConn, error) {
|
||||
addr := fmt.Sprintf("%s:%d", strings.TrimSpace(cfg.FTPHost), cfg.FTPPort)
|
||||
opts := []ftp.DialOption{ftp.DialWithTimeout(20 * time.Second)}
|
||||
if cfg.FTPTLS {
|
||||
// Explicit FTPS (AUTH TLS), the form virtually every web host offers.
|
||||
// InsecureSkipVerify is NOT set: a certificate that does not validate is
|
||||
// a real warning, and silently accepting it would defeat the point of
|
||||
// ticking the TLS box in the first place.
|
||||
opts = append(opts, ftp.DialWithExplicitTLS(&tls.Config{ServerName: strings.TrimSpace(cfg.FTPHost)}))
|
||||
}
|
||||
c, err := ftp.Dial(addr, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to %s: %w", addr, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Test connects, logs in and enters the remote folder without uploading — the
|
||||
// "Test connection" button. Returns a short human-readable success line.
|
||||
func Test(cfg Config) (string, error) {
|
||||
cfg.Normalise()
|
||||
c, err := dial(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = c.Quit() }()
|
||||
if err := c.Login(cfg.FTPUser, cfg.FTPPassword); err != nil {
|
||||
return "", fmt.Errorf("login as %q: %w", cfg.FTPUser, err)
|
||||
}
|
||||
if dir := strings.TrimSpace(cfg.FTPFolder); dir != "" {
|
||||
if err := c.ChangeDir(dir); err != nil {
|
||||
return "", fmt.Errorf("enter remote folder %q: %w", dir, err)
|
||||
}
|
||||
}
|
||||
cwd, _ := c.CurrentDir()
|
||||
return "connected — remote folder " + cwd, nil
|
||||
}
|
||||
Reference in New Issue
Block a user