// Copyright 2020 Rainer Grosskopf (KI7RMJ). All rights reserved. // Use of this source code is governed by the MIT-license that can be // found in the LICENSE file. // Processes Winlink-compatible message template (aka Winlink forms) package forms import ( "archive/zip" "bufio" "bytes" "context" "encoding/json" "encoding/xml" "errors" "fmt" "io" "io/ioutil" "log" "math" "net/http" "os" "path" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" "unicode/utf8" "github.com/dimchansky/utfbom" "github.com/la5nta/pat/cfg" "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/gpsd" "github.com/pd0mz/go-maidenhead" ) const ( fieldValueFalseInXML = "False" htmlFileExt = ".html" txtFileExt = ".txt" formsVersionInfoURL = "https://api.getpat.io/v1/forms/standard-templates/latest" ) // Manager manages the forms subsystem // When the web frontend POSTs the form template data, this map holds the POST'ed data. // Each form composer instance renders into another browser tab, and has a unique instance cookie. // This instance cookie is the key into the map, so that we can keep the values // from different form authoring sessions separate from each other. type Manager struct { config Config postedFormData struct { sync.RWMutex internalFormDataMap map[string]FormData } } // Config passes config options to the forms package type Config struct { FormsPath string MyCall string Locator string AppVersion string LineReader func() string UserAgent string GPSd cfg.GPSdConfig } // Form holds information about a Winlink form template type Form struct { Name string `json:"name"` TxtFileURI string `json:"txt_file_uri"` InitialURI string `json:"initial_uri"` ViewerURI string `json:"viewer_uri"` ReplyTxtFileURI string `json:"reply_txt_file_uri"` ReplyInitialURI string `json:"reply_initial_uri"` ReplyViewerURI string `json:"reply_viewer_uri"` } // FormFolder is a folder with forms. A tree structure with Form leaves and sub-Folder branches type FormFolder struct { Name string `json:"name"` Path string `json:"path"` Version string `json:"version"` FormCount int `json:"form_count"` Forms []Form `json:"forms"` Folders []FormFolder `json:"folders"` } // FormData holds the instance data that define a filled-in form type FormData struct { TargetForm Form `json:"target_form"` Fields map[string]string `json:"fields"` MsgTo string `json:"msg_to"` MsgCc string `json:"msg_cc"` MsgSubject string `json:"msg_subject"` MsgBody string `json:"msg_body"` MsgXML string `json:"msg_xml"` IsReply bool `json:"is_reply"` Submitted time.Time `json:"submitted"` } // MessageForm represents a concrete form-based message type MessageForm struct { To string Cc string Subject string Body string AttachmentXML string AttachmentName string } // UpdateResponse is the API response format for the upgrade forms endpoint type UpdateResponse struct { NewestVersion string `json:"newestVersion"` Action string `json:"action"` } var client = httpClient{http.Client{Timeout: 10 * time.Second}} // NewManager instantiates the forms manager func NewManager(conf Config) *Manager { _ = os.MkdirAll(conf.FormsPath, 0o755) retval := &Manager{ config: conf, } retval.postedFormData.internalFormDataMap = make(map[string]FormData) return retval } // GetFormsCatalogHandler reads all forms from config.FormsPath and writes them in the http response as a JSON object graph // This lets the frontend present a tree-like GUI for the user to select a form for composing a message func (m *Manager) GetFormsCatalogHandler(w http.ResponseWriter, r *http.Request) { formFolder, err := m.buildFormFolder() if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("%s %s: %s", r.Method, r.URL.Path, err) return } _ = json.NewEncoder(w).Encode(formFolder) } // PostFormDataHandler - When the user is done filling a form, the frontend posts the input fields to this handler, // which stores them in a map, so that other browser tabs can read the values back with GetFormDataHandler func (m *Manager) PostFormDataHandler(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(10e6); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } formPath := r.URL.Query().Get("formPath") if formPath == "" { http.Error(w, "formPath query param missing", http.StatusBadRequest) log.Printf("formPath query param missing %s %s", r.Method, r.URL.Path) return } composeReply, _ := strconv.ParseBool(r.URL.Query().Get("composereply")) formFolder, err := m.buildFormFolder() if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("%s %s: %s", r.Method, r.URL.Path, err) return } form, err := findFormFromURI(formPath, formFolder) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("can't find form to match posted form data %s %s", formPath, r.URL) return } formInstanceKey, err := r.Cookie("forminstance") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("missing cookie %s %s", formPath, r.URL) return } formData := FormData{ IsReply: composeReply, TargetForm: form, Fields: make(map[string]string), } for key, values := range r.PostForm { formData.Fields[strings.TrimSpace(strings.ToLower(key))] = values[0] } formMsg, err := formMessageBuilder{ Template: form, FormValues: formData.Fields, Interactive: false, IsReply: composeReply, FormsMgr: m, }.build() if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("%s %s: %s", r.Method, r.URL.Path, err) } formData.MsgTo = formMsg.To formData.MsgCc = formMsg.Cc formData.MsgSubject = formMsg.Subject formData.MsgBody = formMsg.Body formData.MsgXML = formMsg.AttachmentXML formData.Submitted = time.Now() m.postedFormData.Lock() m.postedFormData.internalFormDataMap[formInstanceKey.Value] = formData m.postedFormData.Unlock() m.cleanupOldFormData() _, _ = io.WriteString(w, "") } // GetFormDataHandler is the counterpart to PostFormDataHandler. Returns the form field values to the frontend func (m *Manager) GetFormDataHandler(w http.ResponseWriter, r *http.Request) { formInstanceKey, err := r.Cookie("forminstance") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("missing cookie %s %s", formInstanceKey, r.URL) return } _ = json.NewEncoder(w).Encode(m.GetPostedFormData(formInstanceKey.Value)) } // GetPostedFormData is similar to GetFormDataHandler, but used when posting the form-based message to the outbox func (m *Manager) GetPostedFormData(key string) FormData { m.postedFormData.RLock() defer m.postedFormData.RUnlock() return m.postedFormData.internalFormDataMap[key] } // GetFormTemplateHandler handles the request for viewing a form filled-in with instance values func (m *Manager) GetFormTemplateHandler(w http.ResponseWriter, r *http.Request) { formPath := r.URL.Query().Get("formPath") if formPath == "" { http.Error(w, "formPath query param missing", http.StatusBadRequest) log.Printf("formPath query param missing %s %s", r.Method, r.URL.Path) return } absPathTemplate, err := m.findAbsPathForTemplatePath(formPath) if err != nil { http.Error(w, "find the full path for requested template "+formPath, http.StatusBadRequest) log.Printf("find the full path for requested template %s %s: %s", r.Method, r.URL.Path, "can't open template "+formPath) return } responseText, err := m.fillFormTemplate(absPathTemplate, "/api/form?"+r.URL.Query().Encode(), nil, make(map[string]string)) if err != nil { http.Error(w, "can't open template "+formPath, http.StatusBadRequest) log.Printf("problem filling form template file %s %s: can't open template %s. Err: %s", r.Method, r.URL.Path, formPath, err) return } _, err = io.WriteString(w, responseText) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("can't write form into response %s %s: %s", r.Method, r.URL.Path, err) return } } // UpdateFormTemplatesHandler handles API calls to update form templates. func (m *Manager) UpdateFormTemplatesHandler(w http.ResponseWriter, r *http.Request) { response, err := m.UpdateFormTemplates(r.Context()) if err != nil { http.Error(w, fmt.Sprint(err), http.StatusInternalServerError) return } jsn, _ := json.Marshal(response) _, _ = w.Write(jsn) } // UpdateFormTemplates handles searching for and installing the latest version of the form templates. func (m *Manager) UpdateFormTemplates(ctx context.Context) (UpdateResponse, error) { if err := os.MkdirAll(m.config.FormsPath, 0o755); err != nil { return UpdateResponse{}, fmt.Errorf("can't write to forms dir [%w]", err) } log.Printf("Updating form templates; current version is %v", m.getFormsVersion()) latest, err := m.getLatestFormsInfo(ctx) if err != nil { return UpdateResponse{}, err } if !m.isNewerVersion(latest.Version) { log.Printf("Latest forms version is %v; nothing to do", latest.Version) return UpdateResponse{ NewestVersion: latest.Version, Action: "none", }, nil } if err = m.downloadAndUnzipForms(ctx, latest.ArchiveURL); err != nil { return UpdateResponse{}, err } log.Printf("Finished forms update to %v", latest.Version) // TODO: re-init forms manager return UpdateResponse{ NewestVersion: latest.Version, Action: "update", }, nil } type formsInfo struct { Version string `json:"version"` ArchiveURL string `json:"archive_url"` } func (m *Manager) getLatestFormsInfo(ctx context.Context) (*formsInfo, error) { resp, err := client.Get(ctx, m.config.UserAgent, formsVersionInfoURL) if err != nil || resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("can't fetch winlink forms version page: %w", err) } defer resp.Body.Close() var v formsInfo if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { return nil, err } return &v, nil } func (m *Manager) downloadAndUnzipForms(ctx context.Context, downloadLink string) error { log.Printf("Updating forms via %v", downloadLink) resp, err := client.Get(ctx, m.config.UserAgent, downloadLink) if err != nil { return fmt.Errorf("can't download update ZIP: %w", err) } defer resp.Body.Close() f, err := ioutil.TempFile(os.TempDir(), "pat") if err != nil { return fmt.Errorf("can't create temp file for download: %w", err) } defer f.Close() defer os.Remove(f.Name()) if _, err := io.Copy(f, resp.Body); err != nil { return fmt.Errorf("can't write update ZIP: %w", err) } if err := unzip(f.Name(), m.config.FormsPath); err != nil { return fmt.Errorf("can't unzip forms update: %w", err) } return nil } func unzip(srcArchivePath, dstRoot string) error { // Closure to address file descriptors issue with all the deferred .Close() methods extractAndWriteFile := func(zf *zip.File) error { if zf.FileInfo().IsDir() { return nil } destPath := filepath.Join(dstRoot, zf.Name) // Check for ZipSlip (Directory traversal) if !strings.HasPrefix(destPath, filepath.Clean(dstRoot)+string(os.PathSeparator)) { return fmt.Errorf("illegal file path: %s", destPath) } // Ensure target directory exists if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { return fmt.Errorf("can't create target directory: %w", err) } // Write file src, err := zf.Open() if err != nil { return err } defer src.Close() dst, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zf.Mode()) if err != nil { return err } defer dst.Close() _, err = io.Copy(dst, src) return err } r, err := zip.OpenReader(srcArchivePath) if err != nil { return err } defer r.Close() for _, f := range r.File { err := extractAndWriteFile(f) if err != nil { return err } } return nil } // GetXMLAttachmentNameForForm returns the user-visible filename for the message attachment that holds the form instance values func (m *Manager) GetXMLAttachmentNameForForm(f Form, isReply bool) string { attachmentName := filepath.Base(f.ViewerURI) if isReply { attachmentName = filepath.Base(f.ReplyViewerURI) } attachmentName = strings.TrimSuffix(attachmentName, filepath.Ext(attachmentName)) attachmentName = "RMS_Express_Form_" + attachmentName + ".xml" if len(attachmentName) > 255 { attachmentName = strings.TrimPrefix(attachmentName, "RMS_Express_Form_") } return attachmentName } // RenderForm finds the associated form and returns the filled-in form in HTML given the contents of a form attachment func (m *Manager) RenderForm(contentUnsanitized []byte, composeReply bool) (string, error) { type Node struct { XMLName xml.Name Content []byte `xml:",innerxml"` Nodes []Node `xml:",any"` } sr := utfbom.SkipOnly(bytes.NewReader(contentUnsanitized)) contentData, err := io.ReadAll(sr) if err != nil { return "", fmt.Errorf("error reading sanitized form xml: %w", err) } if !utf8.Valid(contentData) { log.Println("Warning: unsupported string encoding in form XML, expected utf-8") } var n1 Node formParams := make(map[string]string) formVars := make(map[string]string) if err := xml.Unmarshal(contentData, &n1); err != nil { return "", err } if n1.XMLName.Local != "RMS_Express_Form" { return "", errors.New("missing RMS_Express_Form tag in form XML") } for _, n2 := range n1.Nodes { switch n2.XMLName.Local { case "form_parameters": for _, n3 := range n2.Nodes { formParams[n3.XMLName.Local] = string(n3.Content) } case "variables": for _, n3 := range n2.Nodes { formVars[n3.XMLName.Local] = string(n3.Content) } } } switch { case formParams["display_form"] == "": return "", errors.New("missing display_form tag in form XML") case composeReply && formParams["reply_template"] == "": return "", errors.New("missing reply_template tag in form XML for a reply message") } formFolder, err := m.buildFormFolder() if err != nil { return "", err } formToLoad := formParams["display_form"] if composeReply { // we're authoring a reply formToLoad = formParams["reply_template"] } form, err := findFormFromURI(formToLoad, formFolder) if err != nil { return "", err } var formRelPath string switch { case composeReply: // authoring a form reply formRelPath = form.ReplyInitialURI case strings.HasSuffix(form.ReplyViewerURI, formParams["display_form"]): // viewing a form reply formRelPath = form.ReplyViewerURI default: // viewing a form formRelPath = form.ViewerURI } absPathTemplate, err := m.findAbsPathForTemplatePath(formRelPath) if err != nil { return "", err } return m.fillFormTemplate(absPathTemplate, "/api/form?composereply=true&formPath="+formRelPath, regexp.MustCompile(`{[vV][aA][rR]\s+(\w+)\s*}`), formVars) } // ComposeForm combines all data needed for the whole form-based message: subject, body, and attachment func (m *Manager) ComposeForm(tmplPath string, subject string) (MessageForm, error) { formFolder, err := m.buildFormFolder() if err != nil { return MessageForm{}, fmt.Errorf("failed to build form folder tree: %v", err) } tmplPath = filepath.Clean(tmplPath) form, err := findFormFromURI(tmplPath, formFolder) if err != nil { return MessageForm{}, fmt.Errorf("failed to find '%s': %v", tmplPath, err) } formValues := map[string]string{ "subjectline": subject, "templateversion": m.getFormsVersion(), "msgsender": m.config.MyCall, } fmt.Printf("Form '%s', version: %s", form.TxtFileURI, formValues["templateversion"]) formMsg, err := formMessageBuilder{ Template: form, FormValues: formValues, Interactive: true, IsReply: false, FormsMgr: m, }.build() if err != nil { return MessageForm{}, err } return formMsg, nil } func (f Form) matchesName(nameToMatch string) bool { return f.InitialURI == nameToMatch || strings.EqualFold(f.InitialURI, nameToMatch+htmlFileExt) || f.ViewerURI == nameToMatch || strings.EqualFold(f.ViewerURI, nameToMatch+htmlFileExt) || f.ReplyInitialURI == nameToMatch || f.ReplyInitialURI == nameToMatch+".0" || f.ReplyViewerURI == nameToMatch || f.ReplyViewerURI == nameToMatch+".0" || f.TxtFileURI == nameToMatch || strings.EqualFold(f.TxtFileURI, nameToMatch+txtFileExt) } func (f Form) containsName(partialName string) bool { return strings.Contains(f.InitialURI, partialName) || strings.Contains(f.ViewerURI, partialName) || strings.Contains(f.ReplyInitialURI, partialName) || strings.Contains(f.ReplyViewerURI, partialName) || strings.Contains(f.ReplyTxtFileURI, partialName) || strings.Contains(f.TxtFileURI, partialName) } func (m *Manager) buildFormFolder() (FormFolder, error) { formFolder, err := m.innerRecursiveBuildFormFolder(m.config.FormsPath) formFolder.Version = m.getFormsVersion() return formFolder, err } func (m *Manager) innerRecursiveBuildFormFolder(rootPath string) (FormFolder, error) { rootFile, err := os.Open(rootPath) if err != nil { return FormFolder{}, err } defer rootFile.Close() rootFileInfo, _ := os.Stat(rootPath) if !rootFileInfo.IsDir() { return FormFolder{}, errors.New(rootPath + " is not a directory") } folder := FormFolder{ Name: rootFileInfo.Name(), Path: rootFile.Name(), Forms: []Form{}, Folders: []FormFolder{}, } infos, err := rootFile.Readdir(0) if err != nil { return folder, err } _ = rootFile.Close() formCnt := 0 for _, info := range infos { if info.IsDir() { subfolder, err := m.innerRecursiveBuildFormFolder(path.Join(rootPath, info.Name())) if err != nil { return folder, err } folder.Folders = append(folder.Folders, subfolder) folder.FormCount += subfolder.FormCount continue } if !strings.EqualFold(filepath.Ext(info.Name()), txtFileExt) { continue } frm, err := m.buildFormFromTxt(path.Join(rootPath, info.Name())) if err != nil { continue } if frm.InitialURI != "" || frm.ViewerURI != "" { formCnt++ folder.Forms = append(folder.Forms, frm) folder.FormCount++ } } sort.Slice(folder.Folders, func(i, j int) bool { return folder.Folders[i].Name < folder.Folders[j].Name }) sort.Slice(folder.Forms, func(i, j int) bool { return folder.Forms[i].Name < folder.Forms[j].Name }) return folder, nil } func (m *Manager) buildFormFromTxt(txtPath string) (Form, error) { f, err := os.Open(txtPath) if err != nil { return Form{}, err } defer f.Close() formsPathWithSlash := m.config.FormsPath + "/" form := Form{ Name: strings.TrimSuffix(path.Base(txtPath), path.Ext(txtPath)), TxtFileURI: strings.TrimPrefix(txtPath, formsPathWithSlash), } scanner := bufio.NewScanner(f) baseURI := path.Dir(form.TxtFileURI) for scanner.Scan() { l := scanner.Text() switch { case strings.HasPrefix(l, "Form:"): // Form: , files := strings.Split(strings.TrimPrefix(l, "Form:"), ",") // Extend to absolute paths and add missing html extension for i := range files { files[i] = path.Join(baseURI, strings.TrimSpace(files[i])) if ext := path.Ext(files[i]); ext == "" { files[i] += ".html" } } form.InitialURI = files[0] if len(files) > 1 { form.ViewerURI = files[1] } case strings.HasPrefix(l, "ReplyTemplate:"): form.ReplyTxtFileURI = path.Join(baseURI, strings.TrimSpace(strings.TrimPrefix(l, "ReplyTemplate:"))) tmpForm, _ := m.buildFormFromTxt(path.Join(m.config.FormsPath, form.ReplyTxtFileURI)) form.ReplyInitialURI = tmpForm.InitialURI form.ReplyViewerURI = tmpForm.ViewerURI } } return form, err } func findFormFromURI(formName string, folder FormFolder) (Form, error) { form := Form{Name: "unknown"} for _, subFolder := range folder.Folders { form, err := findFormFromURI(formName, subFolder) if err == nil { return form, nil } } for _, form := range folder.Forms { if form.matchesName(formName) { return form, nil } } // couldn't find it by full path, so try to find match by guessing folder name formName = path.Join(folder.Name, formName) for _, form := range folder.Forms { if form.containsName(formName) { return form, nil } } return form, errors.New("form not found") } func (m *Manager) findAbsPathForTemplatePath(tmplPath string) (string, error) { absPathTemplate := filepath.Join(m.config.FormsPath, path.Clean(tmplPath)) // now deal with cases where the html file name specified in the .txt file, has different caseness than the actual .html file on disk. absPathTemplateFolder := filepath.Dir(absPathTemplate) templateDir, err := os.Open(absPathTemplateFolder) if err != nil { return "", errors.New("can't read template folder") } defer templateDir.Close() fileNames, err := templateDir.Readdirnames(0) if err != nil { return "", errors.New("can't read template folder") } for _, name := range fileNames { if strings.EqualFold(filepath.Base(tmplPath), name) { return filepath.Join(absPathTemplateFolder, name), nil } } return "", fmt.Errorf("unable to resolve absolute template path") } // gpsPos returns the current GPS Position func (m *Manager) gpsPos() (gpsd.Position, error) { addr := m.config.GPSd.Addr if addr == "" { return gpsd.Position{}, errors.New("GPSd: not configured.") } if !m.config.GPSd.AllowForms { return gpsd.Position{}, errors.New("GPSd: allow_forms is disabled. GPS position will not be available in form templates.") } conn, err := gpsd.Dial(addr) if err != nil { log.Printf("GPSd daemon: %s", err) return gpsd.Position{}, err } defer conn.Close() conn.Watch(true) log.Println("Waiting for position from GPSd...") // TODO: make the GPSd timeout configurable return conn.NextPosTimeout(3 * time.Second) } type gpsStyle int const ( // documentation: https://www.winlink.org/sites/default/files/RMSE_FORMS/insertion_tags.zip signedDecimal gpsStyle = iota // 41.1234 -73.4567 decimal // 46.3795N 121.5835W degreeMinute // 46-22.77N 121-35.01W ) func gpsFmt(style gpsStyle, pos gpsd.Position) string { var ( northing string easting string latDegrees int latMinutes float64 lonDegrees int lonMinutes float64 ) noPos := gpsd.Position{} if pos == noPos { return "(Not available)" } switch style { case degreeMinute: { latDegrees = int(math.Trunc(math.Abs(pos.Lat))) latMinutes = (math.Abs(pos.Lat) - float64(latDegrees)) * 60 lonDegrees = int(math.Trunc(math.Abs(pos.Lon))) lonMinutes = (math.Abs(pos.Lon) - float64(lonDegrees)) * 60 } fallthrough case decimal: { if pos.Lat >= 0 { northing = "N" } else { northing = "S" } if pos.Lon >= 0 { easting = "E" } else { easting = "W" } } } switch style { case signedDecimal: return fmt.Sprintf("%.4f %.4f", pos.Lat, pos.Lon) case decimal: return fmt.Sprintf("%.4f%s %.4f%s", math.Abs(pos.Lat), northing, math.Abs(pos.Lon), easting) case degreeMinute: return fmt.Sprintf("%02d-%05.2f%s %03d-%05.2f%s", latDegrees, latMinutes, northing, lonDegrees, lonMinutes, easting) default: return "(Not available)" } } func posToGridSquare(pos gpsd.Position) string { point := maidenhead.NewPoint(pos.Lat, pos.Lon) gridsquare, err := point.GridSquare() if err != nil { return "" } return gridsquare } func (m *Manager) fillFormTemplate(absPathTemplate string, formDestURL string, placeholderRegEx *regexp.Regexp, formVars map[string]string) (string, error) { fUnsanitized, err := os.Open(absPathTemplate) if err != nil { return "", err } defer fUnsanitized.Close() // skipping over UTF-8 byte-ordering mark EFBBEF, some 3rd party templates use it // (e.g. Sonoma county's ICS213_v2.1_SonomaACS_TwoWay_Initial_Viewer.html) f := utfbom.SkipOnly(fUnsanitized) sanitizedFileContent, err := io.ReadAll(f) if err != nil { return "", fmt.Errorf("error reading file %s", absPathTemplate) } if !utf8.Valid(sanitizedFileContent) { log.Printf("Warning: unsupported string encoding in template %s, expected utf-8", absPathTemplate) } now := time.Now() validPos := "NO" nowPos, err := m.gpsPos() if err != nil { debug.Printf("GPSd error: %v", err) } else { validPos = "YES" debug.Printf("GPSd position: %s", gpsFmt(signedDecimal, nowPos)) } var buf bytes.Buffer scanner := bufio.NewScanner(bytes.NewReader(sanitizedFileContent)) for scanner.Scan() { l := scanner.Text() l = strings.ReplaceAll(l, "http://{FormServer}:{FormPort}", formDestURL) // some Canada BC forms don't use the {FormServer} placeholder, it's OK, can deal with it here l = strings.ReplaceAll(l, "http://localhost:8001", formDestURL) l = strings.ReplaceAll(l, "{MsgSender}", m.config.MyCall) l = strings.ReplaceAll(l, "{Callsign}", m.config.MyCall) l = strings.ReplaceAll(l, "{ProgramVersion}", "Pat "+m.config.AppVersion) l = strings.ReplaceAll(l, "{DateTime}", formatDateTime(now)) l = strings.ReplaceAll(l, "{UDateTime}", formatDateTimeUTC(now)) l = strings.ReplaceAll(l, "{Date}", formatDate(now)) l = strings.ReplaceAll(l, "{UDate}", formatDateUTC(now)) l = strings.ReplaceAll(l, "{UDTG}", formatUDTG(now)) l = strings.ReplaceAll(l, "{Time}", formatTime(now)) l = strings.ReplaceAll(l, "{UTime}", formatTimeUTC(now)) l = strings.ReplaceAll(l, "{GPS}", gpsFmt(degreeMinute, nowPos)) l = strings.ReplaceAll(l, "{GPS_DECIMAL}", gpsFmt(decimal, nowPos)) l = strings.ReplaceAll(l, "{GPS_SIGNED_DECIMAL}", gpsFmt(signedDecimal, nowPos)) // Lots of undocumented tags found in the Winlink check in form. // Note also various ways of capitalizing. Perhaps best to do case insenstive string replacements.... l = strings.ReplaceAll(l, "{Latitude}", fmt.Sprintf("%.4f", nowPos.Lat)) l = strings.ReplaceAll(l, "{latitude}", fmt.Sprintf("%.4f", nowPos.Lat)) l = strings.ReplaceAll(l, "{Longitude}", fmt.Sprintf("%.4f", nowPos.Lon)) l = strings.ReplaceAll(l, "{longitude}", fmt.Sprintf("%.4f", nowPos.Lon)) l = strings.ReplaceAll(l, "{GridSquare}", posToGridSquare(nowPos)) l = strings.ReplaceAll(l, "{GPSValid}", fmt.Sprintf("%s ", validPos)) if placeholderRegEx != nil { l = fillPlaceholders(l, placeholderRegEx, formVars) } buf.WriteString(l + "\n") } return buf.String(), nil } func (m *Manager) getFormsVersion() string { // walking up the path to find a version file. // Winlink's Standard_Forms.zip includes it in its root. dir := m.config.FormsPath if filepath.Ext(dir) == txtFileExt { dir = filepath.Dir(dir) } var verFile *os.File // loop to walk up the subfolders until we find the top, or Winlink's Standard_Forms_Version.dat file for { f, err := os.Open(filepath.Join(dir, "Standard_Forms_Version.dat")) if err != nil { dir = filepath.Dir(dir) // have not found the version file or couldn't open it, going up by one if dir == "." || dir == ".." || strings.HasSuffix(dir, string(os.PathSeparator)) { return "unknown" // reached top-level and couldn't find version .dat file } continue } // found and opened the version file verFile = f break } if verFile != nil { defer verFile.Close() return readFileFirstLine(verFile) } return "unknown" } func readFileFirstLine(f *os.File) string { scanner := bufio.NewScanner(f) if scanner.Scan() { return scanner.Text() } return "" } type formMessageBuilder struct { Interactive bool IsReply bool Template Form FormValues map[string]string FormsMgr *Manager } // build returns message subject, body, and XML attachment content for the given template and variable map func (b formMessageBuilder) build() (MessageForm, error) { tmplPath := filepath.Join(b.FormsMgr.config.FormsPath, b.Template.TxtFileURI) if filepath.Ext(tmplPath) == "" { tmplPath += txtFileExt } if b.IsReply && b.Template.ReplyTxtFileURI != "" { tmplPath = filepath.Join(b.FormsMgr.config.FormsPath, b.Template.ReplyTxtFileURI) } b.initFormValues() formVarsAsXML := "" for varKey, varVal := range b.FormValues { formVarsAsXML += fmt.Sprintf(" <%s>%s\n", xmlEscape(varKey), xmlEscape(varVal), xmlEscape(varKey)) } viewer := "" if b.Template.ViewerURI != "" { viewer = filepath.Base(b.Template.ViewerURI) } if b.IsReply && b.Template.ReplyViewerURI != "" { viewer = filepath.Base(b.Template.ReplyViewerURI) } replier := "" if !b.IsReply && b.Template.ReplyTxtFileURI != "" { replier = filepath.Base(b.Template.ReplyTxtFileURI) } msgForm, err := b.scanTmplBuildMessage(tmplPath) if err != nil { return MessageForm{}, err } // Add XML if a viewer is defined for this form if b.Template.ViewerURI != "" { msgForm.AttachmentXML = fmt.Sprintf(`%s %s %s %s %s %s %s %s %s `, xml.Header, "1.0", b.FormsMgr.config.AppVersion, time.Now().UTC().Format("20060102150405"), b.FormsMgr.config.MyCall, b.FormsMgr.config.Locator, viewer, replier, formVarsAsXML) msgForm.AttachmentName = b.FormsMgr.GetXMLAttachmentNameForForm(b.Template, false) } msgForm.To = strings.TrimSpace(msgForm.To) msgForm.Cc = strings.TrimSpace(msgForm.Cc) msgForm.Subject = strings.TrimSpace(msgForm.Subject) msgForm.Body = strings.TrimSpace(msgForm.Body) return msgForm, nil } func (b formMessageBuilder) initFormValues() { if b.IsReply { b.FormValues["msgisreply"] = "True" } else { b.FormValues["msgisreply"] = "False" } b.FormValues["msgsender"] = b.FormsMgr.config.MyCall // some defaults that we can't set yet. Winlink doesn't seem to care about these b.FormValues["msgto"] = "" b.FormValues["msgcc"] = "" b.FormValues["msgsubject"] = "" b.FormValues["msgbody"] = "" b.FormValues["msgp2p"] = "" b.FormValues["msgisforward"] = fieldValueFalseInXML b.FormValues["msgisacknowledgement"] = fieldValueFalseInXML b.FormValues["msgseqnum"] = "0" } func (b formMessageBuilder) scanTmplBuildMessage(tmplPath string) (MessageForm, error) { infile, err := os.Open(tmplPath) if err != nil { return MessageForm{}, err } defer infile.Close() placeholderRegEx := regexp.MustCompile(`<[vV][aA][rR]\s+(\w+)\s*>`) scanner := bufio.NewScanner(infile) var msgForm MessageForm var inBody bool for scanner.Scan() { lineTmpl := scanner.Text() lineTmpl = fillPlaceholders(lineTmpl, placeholderRegEx, b.FormValues) lineTmpl = strings.ReplaceAll(lineTmpl, "", b.FormsMgr.config.MyCall) lineTmpl = strings.ReplaceAll(lineTmpl, "", "Pat "+b.FormsMgr.config.AppVersion) if strings.HasPrefix(lineTmpl, "Form:") { continue } if strings.HasPrefix(lineTmpl, "ReplyTemplate:") { continue } if strings.HasPrefix(lineTmpl, "Msg:") { lineTmpl = strings.TrimSpace(strings.TrimPrefix(lineTmpl, "Msg:")) inBody = true } if b.Interactive { matches := placeholderRegEx.FindAllStringSubmatch(lineTmpl, -1) fmt.Println(lineTmpl) for i := range matches { varName := matches[i][1] varNameLower := strings.ToLower(varName) if b.FormValues[varNameLower] != "" { continue } fmt.Print(varName + ": ") b.FormValues[varNameLower] = "blank" val := b.FormsMgr.config.LineReader() if val != "" { b.FormValues[varNameLower] = val } } } lineTmpl = fillPlaceholders(lineTmpl, placeholderRegEx, b.FormValues) switch { case strings.HasPrefix(lineTmpl, "Subject:"): msgForm.Subject = strings.TrimPrefix(lineTmpl, "Subject:") case strings.HasPrefix(lineTmpl, "To:"): msgForm.To = strings.TrimPrefix(lineTmpl, "To:") case strings.HasPrefix(lineTmpl, "Cc:"): msgForm.Cc = strings.TrimPrefix(lineTmpl, "Cc:") case inBody: msgForm.Body += lineTmpl + "\n" default: log.Printf("skipping unknown template line: '%s'", lineTmpl) } } return msgForm, nil } func xmlEscape(s string) string { var buf bytes.Buffer if err := xml.EscapeText(&buf, []byte(s)); err != nil { log.Printf("Error trying to escape XML string %s", err) } return buf.String() } func fillPlaceholders(s string, re *regexp.Regexp, values map[string]string) string { if _, ok := values["txtstr"]; !ok { values["txtstr"] = "" } result := s matches := re.FindAllStringSubmatch(s, -1) for _, match := range matches { value, ok := values[strings.ToLower(match[1])] if ok { result = strings.ReplaceAll(result, match[0], value) } } return result } func (m *Manager) cleanupOldFormData() { m.postedFormData.Lock() defer m.postedFormData.Unlock() for key, form := range m.postedFormData.internalFormDataMap { elapsed := time.Since(form.Submitted).Hours() if elapsed > 24 { log.Println("deleting old FormData after", elapsed, "hrs") delete(m.postedFormData.internalFormDataMap, key) } } } func (m *Manager) isNewerVersion(newestVersion string) bool { currentVersion := m.getFormsVersion() cv := strings.Split(currentVersion, ".") nv := strings.Split(newestVersion, ".") for i := 0; i < 4; i++ { var cp int64 if len(cv) > i { cp, _ = strconv.ParseInt(cv[i], 10, 16) } var np int64 if len(nv) > i { np, _ = strconv.ParseInt(nv[i], 10, 16) } if cp < np { return true } } return false } type httpClient struct{ http.Client } func (c httpClient) Get(ctx context.Context, userAgent, url string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", userAgent) req.Header.Set("Cache-Control", "no-cache") return c.Do(req) }