// 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 ( "bytes" "context" "encoding/json" "encoding/xml" "errors" "fmt" "io" "io/fs" "log" "net/http" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "unicode" "unicode/utf8" "github.com/la5nta/wl2k-go/fbb" "github.com/la5nta/wl2k-go/mailbox" "github.com/la5nta/pat/cfg" "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/directories" "github.com/la5nta/pat/internal/gpsd" ) const formsVersionInfoURL = "https://api.getpat.io/v1/forms/standard-templates/latest" const ( htmlFileExt = ".html" txtFileExt = ".txt" replyFileExt = ".0" ) // Manager manages the forms subsystem type Manager struct { config Config sequence Sequence // postedFormData serves as an kv-store holding intermediate data for // communicating form values submitted by the served HTML form files to // the rest of the app. // // 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. postedFormData struct { mu sync.RWMutex m map[string]Message } } func (m *Manager) SeqSet(v int) error { _, err := m.sequence.Set(int64(v)) return err } // LocatorProvider provides access to the current locator value type LocatorProvider interface { Locator() string } // Config passes config options to the forms package type Config struct { FormsPath string SequencePath string SequenceFormat string MyCall string AppVersion string UserAgent string GPSd cfg.GPSdConfig LocatorProvider LocatorProvider } // 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 []Template `json:"forms"` Folders []FormFolder `json:"folders"` } // 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, sequence: OpenSequence(conf.SequencePath), } retval.postedFormData.m = make(map[string]Message) return retval } func (m *Manager) Close() error { if m == nil { return nil } m.sequence.Close() return nil } // 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 handles both HTML form submissions and text-only template submissions. // The handler detects the content type and processes accordingly, storing the results in // the forms map for retrieval by other browser tabs. func (m *Manager) PostFormDataHandler(mboxRoot string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { inReplyTo := r.URL.Query().Get("in-reply-to") templatePath := r.URL.Query().Get("template") if templatePath == "" { http.Error(w, "template query param missing", http.StatusBadRequest) log.Printf("template query param missing %s %s", r.Method, r.URL.Path) return } formInstanceKey, err := r.Cookie("forminstance") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("missing cookie %s %s", templatePath, r.URL) return } var formValues, promptResponses map[string]string switch contentType := r.Header.Get("Content-Type"); { case strings.HasPrefix(contentType, "multipart/form-data"): // Process HTML form submission if err := r.ParseMultipartForm(10e6); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } formValues = make(map[string]string, len(r.PostForm)) for key, values := range r.PostForm { formValues[strings.TrimSpace(strings.ToLower(key))] = values[0] } case strings.HasPrefix(contentType, "application/json"): // Process JSON template submission (from builtin text-only template editor) var payload struct { Responses map[string]string `json:"responses"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } formValues = map[string]string{"templateversion": m.getFormsVersion()} promptResponses = payload.Responses default: http.Error(w, "unsupported content type", http.StatusBadRequest) return } templatePath = m.abs(templatePath) // Make sure we don't escape FormsPath if !directories.IsInPath(m.config.FormsPath, templatePath) { http.Error(w, fmt.Sprintf("%s escapes forms directory", templatePath), http.StatusForbidden) return } // Load template template, err := readTemplate(m.abs(templatePath), formFilesFromPath(m.config.FormsPath)) switch { case os.IsNotExist(err): http.Error(w, err.Error(), http.StatusNotFound) return case err != nil: http.Error(w, err.Error(), http.StatusInternalServerError) log.Printf("failed to parse relevant form template (%q): %v", m.rel(templatePath), err) return } // Load optional in-reply-to message var inReplyToMsg *fbb.Message if inReplyTo != "" { var err error inReplyToMsg, err = mailbox.OpenMessage(filepath.Join(mboxRoot, inReplyTo+mailbox.Ext)) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("failed to load in-reply-to (%q): %v", inReplyTo, err) return } } // Build message msg, err := messageBuilder{ Template: template, FormValues: formValues, PromptResponses: promptResponses, // This is for text-only templates only Interactive: false, InReplyToMsg: inReplyToMsg, FormsMgr: m, }.build() if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("%s %s: %s", r.Method, r.URL.Path, err) return } // Save the result for the front-end to retrieve m.postedFormData.mu.Lock() m.postedFormData.m[formInstanceKey.Value] = msg m.postedFormData.mu.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 } v, ok := m.GetPostedFormData(formInstanceKey.Value) if !ok { http.NotFound(w, r) return } _ = json.NewEncoder(w).Encode(v) } // GetPostedFormData is similar to GetFormDataHandler, but used when posting the form-based message to the outbox func (m *Manager) GetPostedFormData(key string) (Message, bool) { m.postedFormData.mu.RLock() defer m.postedFormData.mu.RUnlock() v, ok := m.postedFormData.m[key] return v, ok } // GetFormAssetHandler serves files referenced by HTML forms (through {FormFolder}). // // It's primary use case is to load stylesheets and smilar resources. func (m *Manager) GetFormAssetHandler(w http.ResponseWriter, r *http.Request) { path := m.abs(r.URL.Path) // Make sure we don't escape FormsPath if !directories.IsInPath(m.config.FormsPath, path) { http.Error(w, fmt.Sprintf("%s escapes forms directory", path), http.StatusForbidden) return } http.ServeFile(w, r, path) } // GetTemplateDataHandler serves partially processed template content. // // It's primary use case is to provide template files to the text-only template editor. func (m *Manager) GetTemplateDataHandler(mboxRoot string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { templatePath := r.URL.Query().Get("template") // Make sure we don't escape FormsPath if !directories.IsInPath(m.config.FormsPath, m.abs(templatePath)) { http.Error(w, fmt.Sprintf("%s escapes forms directory", templatePath), http.StatusForbidden) return } template, err := readTemplate(m.abs(templatePath), formFilesFromPath(m.config.FormsPath)) if err != nil { http.NotFound(w, r) return } // Load optional in-reply-to message var inReplyToMsg *fbb.Message if inReplyTo := r.URL.Query().Get("in-reply-to"); inReplyTo != "" { var err error inReplyToMsg, err = mailbox.OpenMessage(filepath.Join(mboxRoot, inReplyTo+mailbox.Ext)) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("failed to load in-reply-to (%q): %v", inReplyTo, err) return } } // Open the template f, err := os.Open(template.Path) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer f.Close() // Process insertion tags (for preview) var buf bytes.Buffer if _, err := io.Copy(&buf, newTrimBomReader(f)); err != nil { panic(err) } text := insertionTagReplacer(m, inReplyToMsg, templatePath, "<", ">")(buf.String()) io.WriteString(w, text) } } // GetFormTemplateHandler serves a template's HTML form (filled-in with instance values) func (m *Manager) GetFormTemplateHandler(w http.ResponseWriter, r *http.Request) { templatePath := r.URL.Query().Get("template") if templatePath == "" { http.Error(w, "template query param missing", http.StatusBadRequest) log.Printf("template query param missing %s %s", r.Method, r.URL.Path) return } templatePath = m.abs(templatePath) // Make sure we don't escape FormsPath if !directories.IsInPath(m.config.FormsPath, templatePath) { http.Error(w, fmt.Sprintf("%s escapes forms directory", templatePath), http.StatusForbidden) return } template, err := readTemplate(templatePath, formFilesFromPath(m.config.FormsPath)) switch { case os.IsNotExist(err): http.Error(w, err.Error(), http.StatusNotFound) return case err != nil: http.Error(w, err.Error(), http.StatusInternalServerError) log.Printf("failed to parse requested template (%q): %v", m.rel(templatePath), err) return } formPath := template.InputFormPath if formPath == "" { // This is a text-only template. Redirect to template editor. http.Redirect(w, r, "/ui/template?"+r.URL.Query().Encode(), http.StatusFound) return } responseText, err := m.fillFormTemplate(formPath, nil, "/api/form?"+r.URL.Query().Encode(), nil) if err != nil { http.Error(w, err.Error(), 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 := os.CreateTemp(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 } // RenderForm finds the associated form and returns the filled-in form in HTML given the contents of a form attachment func (m *Manager) RenderForm(data []byte, inReplyToMsg *fbb.Message, inReplyToPath string) (string, error) { type Node struct { XMLName xml.Name Content []byte `xml:",innerxml"` Nodes []Node `xml:",any"` } data = trimBom(data) if !utf8.Valid(data) { 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(data, &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) } } } filesMap := formFilesFromPath(m.config.FormsPath) switch { case inReplyToPath != "": replyTemplate := formParams["reply_template"] if replyTemplate == "" { return "", errors.New("missing reply_template tag in form XML for a reply message") } if filepath.Ext(replyTemplate) == "" { replyTemplate += replyFileExt } path := filesMap.get(replyTemplate) if path == "" { return "", fmt.Errorf("reply template not found: %q", replyTemplate) } template, err := readTemplate(path, filesMap) if err != nil { return "", fmt.Errorf("failed to read referenced reply template: %w", err) } submitURL := "/api/form?in-reply-to=" + url.QueryEscape(inReplyToPath) + "&template=" + url.QueryEscape(m.rel(template.Path)) return m.fillFormTemplate(template.InputFormPath, inReplyToMsg, submitURL, formVars) default: displayForm := formParams["display_form"] if displayForm == "" { return "", errors.New("missing display_form tag in form XML") } if filepath.Ext(displayForm) == "" { displayForm += htmlFileExt } // Viewing a form (initial or reply) path := filesMap.get(displayForm) if path == "" { return "", fmt.Errorf("display from not found: %q", displayForm) } return m.fillFormTemplate(path, inReplyToMsg, "", formVars) } } // ComposeTemplate composes a message from a template (templatePath) by prompting the user through stdio. // // It combines all data needed for the whole template-based message: subject, body, and attachments. func (m *Manager) ComposeTemplate(templatePath string, subject string, inReplyToMsg *fbb.Message, lineReader func() string) (Message, error) { template, err := readTemplate(templatePath, formFilesFromPath(m.config.FormsPath)) switch { case os.IsNotExist(err) && !filepath.IsAbs(templatePath): // Try resolving the path relative to forms directory. return m.ComposeTemplate(m.abs(templatePath), subject, inReplyToMsg, lineReader) case err != nil: return Message{}, err } formValues := map[string]string{ "subjectline": subject, "templateversion": m.getFormsVersion(), } fmt.Printf("Form '%s', version: %s\n", m.rel(template.Path), formValues["templateversion"]) return messageBuilder{ Interactive: true, LineReader: lineReader, Template: template, FormValues: formValues, FormsMgr: m, InReplyToMsg: inReplyToMsg, }.build() } func (m *Manager) buildFormFolder() (FormFolder, error) { formFolder, err := m.innerRecursiveBuildFormFolder(m.config.FormsPath, formFilesFromPath(m.config.FormsPath)) formFolder.Version = m.getFormsVersion() return formFolder, err } func (m *Manager) innerRecursiveBuildFormFolder(rootPath string, filesMap formFilesMap) (FormFolder, error) { folder := FormFolder{ Name: filepath.Base(rootPath), Path: rootPath, Forms: []Template{}, Folders: []FormFolder{}, } err := fs.WalkDir(os.DirFS(rootPath), ".", func(path string, d fs.DirEntry, err error) error { switch { case err != nil: return err case path == ".": return nil case d.IsDir(): subfolder, err := m.innerRecursiveBuildFormFolder(filepath.Join(rootPath, path), filesMap) if err != nil { return err } folder.Folders = append(folder.Folders, subfolder) folder.FormCount += subfolder.FormCount return fs.SkipDir case !strings.EqualFold(filepath.Ext(d.Name()), txtFileExt): return nil default: template, err := readTemplate(filepath.Join(rootPath, path), filesMap) if err != nil { debug.Printf("failed to load form file %q: %v", path, err) return nil } // Relative paths for the JSON response template.Path = m.rel(template.Path) folder.Forms = append(folder.Forms, template) folder.FormCount++ return nil } }) 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, err } // abs returns the absolute path of a path relative to m.FormsPath. // // It is primarily used to resolve template references from the web gui, which // are relative to m.config.FormsPath. func (m *Manager) abs(path string) string { if filepath.IsAbs(path) { return path } return filepath.Join(m.config.FormsPath, path) } // rel returns a path relative to m.FormsPath. // // The web gui uses this variant to reference template files. func (m *Manager) rel(path string) string { if !filepath.IsAbs(path) { return path } rel, err := filepath.Rel(m.config.FormsPath, path) if err != nil { panic(err) } return rel } const gpsMockAddr = "mock" // Hack for unit testing // 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 addr == gpsMockAddr { return gpsd.Position{Lat: 59.41378, Lon: 5.268}, nil } 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) } func (m *Manager) fillFormTemplate(templatePath string, inReplyToMsg *fbb.Message, formDestURL string, formVars map[string]string) (string, error) { data, err := readFile(templatePath) if err != nil { return "", err } // Set the "form server" URL data = strings.ReplaceAll(data, "http://{FormServer}:{FormPort}", formDestURL) data = strings.ReplaceAll(data, "http://localhost:8001", formDestURL) // Some Canada BC forms are hardcoded to this URL // Substitute insertion tags and variables data = insertionTagReplacer(m, inReplyToMsg, templatePath, "{", "}")(data) data = variableReplacer("{", "}", formVars)(data) return data, nil } func (m *Manager) getFormsVersion() string { str, err := readFile(m.abs("Standard_Forms_Version.dat")) if err != nil { debug.Printf("failed to open version file: %v", err) return "unknown" } // Drop any whitespace in the string // (version 1.1.6.0 was released as `1.1.6\t.0`). str = strings.Map(func(r rune) rune { if unicode.IsSpace(r) { return -1 } return r }, str) return str } func (m *Manager) cleanupOldFormData() { m.postedFormData.mu.Lock() defer m.postedFormData.mu.Unlock() for key, form := range m.postedFormData.m { elapsed := time.Since(form.submitted).Hours() if elapsed > 24 { log.Println("deleting old FormData after", elapsed, "hrs") delete(m.postedFormData.m, 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) }