// 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(` ` + 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 }