// 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. // - Every ADIF field is offered, because an operator publishing a county hunt // or an award chase needs fields no curated list would have guessed. That // puts the judgement on them, and it is a real one: this page is PUBLIC, and // the catalogue includes the correspondent's address, e-mail and the // operator's own street. Nothing is published unless it is chosen, and the // default selection stays the eight columns a reader of someone else's log // actually looks for. package webpub import ( "crypto/tls" "encoding/csv" "fmt" "html" "os" "path/filepath" "sort" "strconv" "strings" "time" "github.com/jlaffaye/ftp" "hamlog/internal/geo" "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 // Group is the section a picker shows this under. With every ADIF field on // offer, a flat list is unusable — the grouping IS what makes it navigable. Group string Value func(q *qso.QSO) string } func str(p *int) string { if p == nil { return "" } return strconv.Itoa(*p) } func str64(p *int64) string { if p == nil { return "" } return strconv.FormatInt(*p, 10) } // flt drops a trailing ".0": a distance reads better as "1420" than "1420.0", // and a bearing as "142.5" keeps the half-degree that matters. func flt(p *float64) string { if p == nil { return "" } return strconv.FormatFloat(*p, 'f', -1, 64) } // distanceKm is the path length for the Distance column. // // The stored DISTANCE field is only ever filled by an ADIF import that carried // one — OpsLog does not compute it when logging — so publishing it straight gave // an empty column for every QSO made here, which is how this was reported. // // So it falls back to the two locators, which are on the QSO already. Rounded to // whole kilometres: the grids are squares tens of kilometres across, and a // decimal on a figure that imprecise claims an accuracy nobody has. func distanceKm(q *qso.QSO) string { if q.Distance != nil && *q.Distance > 0 { return flt(q.Distance) } km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid) if !ok || km <= 0 { return "" } return strconv.FormatFloat(km, 'f', 0, 64) } func stamp(t time.Time) string { if t.IsZero() { return "" } return t.UTC().Format("2006-01-02 15:04") } // Columns is every field a QSO can carry, in ADIF order, grouped for the picker. // // Generated from the qso.QSO struct rather than curated by hand: the previous // list held 23 of them, so anyone wanting to publish a county, a satellite pass // or an award reference simply could not. A hand list also silently rots — a new // ADIF field is added to the struct and nobody remembers this file exists. // // The stored configuration keeps KEYS, so order and grouping can change freely. var Columns = []Column{ // Date and time are one column in the database and two here: a log reads by // day, and a reader scanning for "that evening" should not have to parse a // timestamp. Frequency is stored in Hz and published in MHz. {"date", "Date", "QSO", func(q *qso.QSO) string { return q.QSODate.UTC().Format("2006-01-02") }}, {"time", "UTC", "QSO", func(q *qso.QSO) string { return q.QSODate.UTC().Format("15:04") }}, {"freq", "Freq", "QSO", func(q *qso.QSO) string { if q.FreqHz == nil || *q.FreqHz == 0 { return "" } return strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) }}, {"callsign", "Callsign", "QSO", func(q *qso.QSO) string { return q.Callsign }}, {"qso_date_off", "End", "QSO", func(q *qso.QSO) string { return stamp(q.QSODateOff) }}, {"band", "Band", "QSO", func(q *qso.QSO) string { return q.Band }}, {"band_rx", "Band RX", "QSO", func(q *qso.QSO) string { return q.BandRX }}, {"mode", "Mode", "QSO", func(q *qso.QSO) string { return q.Mode }}, {"submode", "Submode", "QSO", func(q *qso.QSO) string { return q.Submode }}, {"freq_rx_hz", "Freq RX", "QSO", func(q *qso.QSO) string { return str64(q.FreqRXHz) }}, {"rst_sent", "RST S", "QSO", func(q *qso.QSO) string { return q.RSTSent }}, {"rst_rcvd", "RST R", "QSO", func(q *qso.QSO) string { return q.RSTRcvd }}, {"name", "Name", "QSO", func(q *qso.QSO) string { return q.Name }}, {"qth", "QTH", "QSO", func(q *qso.QSO) string { return q.QTH }}, {"address", "Address", "QSO", func(q *qso.QSO) string { return q.Address }}, {"email", "Email", "QSO", func(q *qso.QSO) string { return q.Email }}, {"web", "Web", "QSO", func(q *qso.QSO) string { return q.Web }}, {"grid", "Grid", "Location", func(q *qso.QSO) string { return q.Grid }}, {"gridsquare_ext", "Grid ext", "Location", func(q *qso.QSO) string { return q.GridExt }}, {"vucc_grids", "VUCC", "Location", func(q *qso.QSO) string { return q.VUCCGrids }}, {"country", "Country", "Location", func(q *qso.QSO) string { return q.Country }}, {"state", "State", "Location", func(q *qso.QSO) string { return q.State }}, {"cnty", "County", "Location", func(q *qso.QSO) string { return q.County }}, {"dxcc", "Dxcc", "Location", func(q *qso.QSO) string { return str(q.DXCC) }}, {"cont", "Cont", "Location", func(q *qso.QSO) string { return q.Continent }}, {"cqz", "CQ", "Location", func(q *qso.QSO) string { return str(q.CQZ) }}, {"ituz", "ITU", "Location", func(q *qso.QSO) string { return str(q.ITUZ) }}, {"iota", "Iota", "Awards", func(q *qso.QSO) string { return q.IOTA }}, {"sota_ref", "SOTA", "Awards", func(q *qso.QSO) string { return q.SOTARef }}, {"pota_ref", "POTA", "Awards", func(q *qso.QSO) string { return q.POTARef }}, {"age", "Age", "QSO", func(q *qso.QSO) string { return str(q.Age) }}, {"lat", "Lat", "Location", func(q *qso.QSO) string { return flt(q.Lat) }}, {"lon", "Lon", "Location", func(q *qso.QSO) string { return flt(q.Lon) }}, {"rig", "Rig", "QSO", func(q *qso.QSO) string { return q.Rig }}, {"ant", "Ant", "QSO", func(q *qso.QSO) string { return q.Ant }}, {"qsl_sent", "QSL S", "QSL", func(q *qso.QSO) string { return q.QSLSent }}, {"qsl_rcvd", "QSL R", "QSL", func(q *qso.QSO) string { return q.QSLRcvd }}, {"qsl_sent_date", "Qsl sent date", "QSL", func(q *qso.QSO) string { return q.QSLSentDate }}, {"qsl_rcvd_date", "Qsl rcvd date", "QSL", func(q *qso.QSO) string { return q.QSLRcvdDate }}, {"qsl_via", "Qsl via", "QSL", func(q *qso.QSO) string { return q.QSLVia }}, {"qsl_msg", "Qsl msg", "QSL", func(q *qso.QSO) string { return q.QSLMsg }}, {"qslmsg_rcvd", "Qslmsg rcvd", "QSL", func(q *qso.QSO) string { return q.QSLMsgRcvd }}, {"lotw_sent", "LoTW S", "QSL", func(q *qso.QSO) string { return q.LOTWSent }}, {"lotw_rcvd", "LoTW R", "QSL", func(q *qso.QSO) string { return q.LOTWRcvd }}, {"lotw_sent_date", "Lotw sent date", "QSL", func(q *qso.QSO) string { return q.LOTWSentDate }}, {"lotw_rcvd_date", "Lotw rcvd date", "QSL", func(q *qso.QSO) string { return q.LOTWRcvdDate }}, {"eqsl_sent", "eQSL S", "QSL", func(q *qso.QSO) string { return q.EQSLSent }}, {"eqsl_rcvd", "eQSL R", "QSL", func(q *qso.QSO) string { return q.EQSLRcvd }}, {"eqsl_sent_date", "Eqsl sent date", "QSL", func(q *qso.QSO) string { return q.EQSLSentDate }}, {"eqsl_rcvd_date", "Eqsl rcvd date", "QSL", func(q *qso.QSO) string { return q.EQSLRcvdDate }}, {"clublog_qso_upload_date", "Clublog qso upload date", "QSL", func(q *qso.QSO) string { return q.ClublogUploadDate }}, {"clublog_qso_upload_status", "Clublog qso upload status", "QSL", func(q *qso.QSO) string { return q.ClublogUploadStatus }}, {"hrdlog_qso_upload_date", "Hrdlog qso upload date", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadDate }}, {"hrdlog_qso_upload_status", "Hrdlog qso upload status", "QSL", func(q *qso.QSO) string { return q.HRDLogUploadStatus }}, {"qrzcom_qso_upload_date", "Qrzcom qso upload date", "QSL", func(q *qso.QSO) string { return q.QRZComUploadDate }}, {"qrzcom_qso_upload_status", "Qrzcom qso upload status", "QSL", func(q *qso.QSO) string { return q.QRZComUploadStatus }}, {"qrzcom_qso_download_date", "Qrzcom qso download date", "QSL", func(q *qso.QSO) string { return q.QRZComDownloadDate }}, {"qrzcom_qso_download_status", "Qrzcom qso download status", "QSL", func(q *qso.QSO) string { return q.QRZComDownloadStatus }}, {"contest_id", "Contest", "Contest", func(q *qso.QSO) string { return q.ContestID }}, {"srx", "Srx", "Contest", func(q *qso.QSO) string { return str(q.SRX) }}, {"stx", "Stx", "Contest", func(q *qso.QSO) string { return str(q.STX) }}, {"srx_string", "SRX str", "QSO", func(q *qso.QSO) string { return q.SRXString }}, {"stx_string", "STX str", "QSO", func(q *qso.QSO) string { return q.STXString }}, {"check", "Check", "Contest", func(q *qso.QSO) string { return q.Check }}, {"precedence", "Precedence", "Contest", func(q *qso.QSO) string { return q.Precedence }}, {"arrl_sect", "Section", "Contest", func(q *qso.QSO) string { return q.ARRLSect }}, {"prop_mode", "Prop", "QSO", func(q *qso.QSO) string { return q.PropMode }}, {"sat_name", "Sat", "QSO", func(q *qso.QSO) string { return q.SatName }}, {"sat_mode", "Sat mode", "QSO", func(q *qso.QSO) string { return q.SatMode }}, {"ant_az", "Ant az", "QSO", func(q *qso.QSO) string { return flt(q.AntAz) }}, {"ant_el", "Ant el", "QSO", func(q *qso.QSO) string { return flt(q.AntEl) }}, {"ant_path", "Ant path", "QSO", func(q *qso.QSO) string { return q.AntPath }}, {"station_callsign", "Station", "QSO", func(q *qso.QSO) string { return q.StationCallsign }}, {"operator", "Operator", "QSO", func(q *qso.QSO) string { return q.Operator }}, {"my_grid", "My grid", "My station", func(q *qso.QSO) string { return q.MyGrid }}, {"my_gridsquare_ext", "My gridsquare ext", "My station", func(q *qso.QSO) string { return q.MyGridExt }}, {"my_country", "My country", "My station", func(q *qso.QSO) string { return q.MyCountry }}, {"my_state", "My state", "My station", func(q *qso.QSO) string { return q.MyState }}, {"my_cnty", "My cnty", "My station", func(q *qso.QSO) string { return q.MyCounty }}, {"my_iota", "My iota", "My station", func(q *qso.QSO) string { return q.MyIOTA }}, {"my_sota_ref", "My sota ref", "My station", func(q *qso.QSO) string { return q.MySOTARef }}, {"my_pota_ref", "My pota ref", "My station", func(q *qso.QSO) string { return q.MyPOTARef }}, {"my_dxcc", "My dxcc", "My station", func(q *qso.QSO) string { return str(q.MyDXCC) }}, {"my_cq_zone", "My cq zone", "My station", func(q *qso.QSO) string { return str(q.MyCQZone) }}, {"my_itu_zone", "My itu zone", "My station", func(q *qso.QSO) string { return str(q.MyITUZone) }}, {"my_lat", "My lat", "My station", func(q *qso.QSO) string { return flt(q.MyLat) }}, {"my_lon", "My lon", "My station", func(q *qso.QSO) string { return flt(q.MyLon) }}, {"my_street", "My street", "My station", func(q *qso.QSO) string { return q.MyStreet }}, {"my_city", "My city", "My station", func(q *qso.QSO) string { return q.MyCity }}, {"my_postal_code", "My postal code", "My station", func(q *qso.QSO) string { return q.MyPostalCode }}, {"my_rig", "My rig", "My station", func(q *qso.QSO) string { return q.MyRig }}, {"my_antenna", "My antenna", "My station", func(q *qso.QSO) string { return q.MyAntenna }}, {"tx_pwr", "TX pwr", "QSO", func(q *qso.QSO) string { return flt(q.TXPower) }}, {"comment", "Comment", "QSO", func(q *qso.QSO) string { return q.Comment }}, {"notes", "Notes", "QSO", func(q *qso.QSO) string { return q.Notes }}, {"sig", "Sig", "Awards", func(q *qso.QSO) string { return q.SIG }}, {"sig_info", "Sig info", "Awards", func(q *qso.QSO) string { return q.SIGInfo }}, {"my_sig", "My sig", "My station", func(q *qso.QSO) string { return q.MySIG }}, {"my_sig_info", "My sig info", "My station", func(q *qso.QSO) string { return q.MySIGInfo }}, {"wwff_ref", "WWFF", "Awards", func(q *qso.QSO) string { return q.WWFFRef }}, {"my_wwff_ref", "My wwff ref", "My station", func(q *qso.QSO) string { return q.MyWWFFRef }}, {"distance", "Distance", "Location", distanceKm}, {"rx_pwr", "RX pwr", "QSO", func(q *qso.QSO) string { return flt(q.RXPower) }}, {"a_index", "A", "QSO", func(q *qso.QSO) string { return flt(q.AIndex) }}, {"k_index", "K", "QSO", func(q *qso.QSO) string { return flt(q.KIndex) }}, {"sfi", "SFI", "QSO", func(q *qso.QSO) string { return flt(q.SFI) }}, {"skcc", "Skcc", "Awards", func(q *qso.QSO) string { return q.SKCC }}, {"fists", "Fists", "Awards", func(q *qso.QSO) string { return q.FISTS }}, {"ten_ten", "10-10", "Awards", func(q *qso.QSO) string { return q.TenTen }}, {"contacted_op", "Op worked", "QSO", func(q *qso.QSO) string { return q.ContactedOp }}, {"eq_call", "Eq call", "QSO", func(q *qso.QSO) string { return q.EqCall }}, {"pfx", "Pfx", "Location", func(q *qso.QSO) string { return q.PFX }}, {"my_name", "My name", "My station", func(q *qso.QSO) string { return q.MyName }}, {"class", "Class", "Contest", func(q *qso.QSO) string { return q.Class }}, {"darc_dok", "DOK", "Awards", func(q *qso.QSO) string { return q.DarcDOK }}, {"my_darc_dok", "My darc dok", "My station", func(q *qso.QSO) string { return q.MyDarcDOK }}, {"region", "Region", "Location", func(q *qso.QSO) string { return q.Region }}, {"silent_key", "SK", "QSO", func(q *qso.QSO) string { return q.SilentKey }}, {"swl", "Swl", "QSO", func(q *qso.QSO) string { return q.SWL }}, {"qso_complete", "Complete", "QSO", func(q *qso.QSO) string { return q.QSOComplete }}, {"qso_random", "Random", "QSO", func(q *qso.QSO) string { return q.QSORandom }}, {"credit_granted", "Credit granted", "QSL", func(q *qso.QSO) string { return q.CreditGranted }}, {"credit_submitted", "Credit sub.", "QSL", func(q *qso.QSO) string { return q.CreditSubmitted }}, {"my_arrl_sect", "My arrl sect", "My station", func(q *qso.QSO) string { return q.MyARRLSect }}, {"my_vucc_grids", "My vucc grids", "My station", func(q *qso.QSO) string { return q.MyVUCCGrids }}, {"award_refs", "Award refs", "Awards", func(q *qso.QSO) string { return q.AwardRefs }}, } // 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"} // legacyKeys maps the short names the hand-written table used onto the ADIF // names the generated one uses. A configuration saved before this change still // publishes the same columns; without it, three of them would vanish silently on // upgrade, which is the worst way for a setting to change. var legacyKeys = map[string]string{ "pota": "pota_ref", "sota": "sota_ref", "station": "station_callsign", } 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 { k = strings.TrimSpace(k) if alias, ok := legacyKeys[k]; ok { k = alias } if c, ok := byKey[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(` ` + esc(title) + `

` + esc(title) + `

` + strconv.Itoa(len(qsos)) + ` QSO · ` + time.Now().UTC().Format("2006-01-02 15:04") + ` UTC

`) for _, c := range cols { b.WriteString(``) } b.WriteString(``) for i := range qsos { // The row's position as published. Sorting is a view on top of it, so a // third click can put the table back the way the operator first saw it. b.WriteString(``) for _, c := range cols { cls := "" if c.Key == "callsign" { cls = ` class="call"` } b.WriteString(`` + esc(c.Value(&qsos[i])) + ``) } b.WriteString(``) } b.WriteString(`
` + esc(c.Header) + `

Generated by OpsLog

`) 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 }