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.
378 lines
13 KiB
Go
378 lines
13 KiB
Go
// 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
|
|
}
|