diff --git a/cli_composer.go b/cli_composer.go index ef6e19b6..d46e70be 100644 --- a/cli_composer.go +++ b/cli_composer.go @@ -332,9 +332,8 @@ func composeFormReport(ctx context.Context, args []string) { msg.SetBody(formMsg.Body) - if xml := formMsg.AttachmentXML; xml != "" { - attachmentFile := fbb.NewFile(formMsg.AttachmentName, []byte(xml)) - msg.AddFile(attachmentFile) + for _, f := range formMsg.Attachments() { + msg.AddFile(f) } postMessage(msg) diff --git a/http.go b/http.go index 2bcd0800..834eee61 100644 --- a/http.go +++ b/http.go @@ -32,6 +32,7 @@ import ( "github.com/la5nta/wl2k-go/transport/ardop" "github.com/la5nta/pat/internal/buildinfo" + "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/directories" "github.com/la5nta/pat/internal/gpsd" @@ -277,12 +278,20 @@ func postOutboundMessageHandler(w http.ResponseWriter, r *http.Request) { } } - cookie, err := r.Cookie("forminstance") - if err == nil { - formData := formsMgr.GetPostedFormData(cookie.Value) - if xml := formData.MsgXML; xml != "" { - name := formsMgr.GetXMLAttachmentNameForForm(formData.TargetForm, formData.IsReply) - msg.AddFile(fbb.NewFile(name, []byte(formData.MsgXML))) + if cookie, err := r.Cookie("forminstance"); err == nil { + // We must add the attachment files here because it is impossible + // for the frontend to dynamically add form files due to legacy + // security vulnerabilities in older HTML specs. + // The rest of the form data (to, subject, body etc) is added by + // the frontend. + formData, ok := formsMgr.GetPostedFormData(cookie.Value) + if !ok { + debug.Printf("form instance key (%q) not valid", cookie.Value) + http.Error(w, "form instance key not valid", http.StatusBadRequest) + return + } + for _, f := range formData.Attachments() { + msg.AddFile(f) } } diff --git a/internal/forms/forms.go b/internal/forms/forms.go index e15ff2b0..6cbe317e 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -36,6 +36,7 @@ import ( "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/directories" "github.com/la5nta/pat/internal/gpsd" + "github.com/la5nta/wl2k-go/fbb" "github.com/pd0mz/go-maidenhead" ) @@ -47,15 +48,21 @@ const ( ) // 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 + config Config + + // 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 { - sync.RWMutex - internalFormDataMap map[string]FormData + mu sync.RWMutex + m map[string]MessageForm } } @@ -91,27 +98,27 @@ type FormFolder struct { 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 + To string `json:"msg_to"` + Cc string `json:"msg_cc"` + Subject string `json:"msg_subject"` + Body string `json:"msg_body"` + + attachmentXML string + targetForm Form + isReply bool + submitted time.Time +} + +// Attachments returns the attachments generated by the filled-in form +func (m MessageForm) Attachments() []*fbb.File { + var files []*fbb.File + if xml := m.attachmentXML; xml != "" { + name := getXMLAttachmentNameForForm(m.targetForm, m.isReply) + files = append(files, fbb.NewFile(name, []byte(xml))) + } + return files } // UpdateResponse is the API response format for the upgrade forms endpoint @@ -128,7 +135,7 @@ func NewManager(conf Config) *Manager { retval := &Manager{ config: conf, } - retval.postedFormData.internalFormDataMap = make(map[string]FormData) + retval.postedFormData.m = make(map[string]MessageForm) return retval } @@ -180,18 +187,14 @@ func (m *Manager) PostFormDataHandler(w http.ResponseWriter, r *http.Request) { log.Printf("missing cookie %s %s", formPath, r.URL) return } - formData := FormData{ - IsReply: composeReply, - TargetForm: form, - Fields: make(map[string]string), - } + fields := make(map[string]string, len(r.PostForm)) for key, values := range r.PostForm { - formData.Fields[strings.TrimSpace(strings.ToLower(key))] = values[0] + fields[strings.TrimSpace(strings.ToLower(key))] = values[0] } formMsg, err := formMessageBuilder{ Template: form, - FormValues: formData.Fields, + FormValues: fields, Interactive: false, IsReply: composeReply, FormsMgr: m, @@ -200,16 +203,10 @@ func (m *Manager) PostFormDataHandler(w http.ResponseWriter, r *http.Request) { 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.postedFormData.mu.Lock() + m.postedFormData.m[formInstanceKey.Value] = formMsg + m.postedFormData.mu.Unlock() m.cleanupOldFormData() _, _ = io.WriteString(w, "") @@ -223,14 +220,20 @@ func (m *Manager) GetFormDataHandler(w http.ResponseWriter, r *http.Request) { log.Printf("missing cookie %s %s", formInstanceKey, r.URL) return } - _ = json.NewEncoder(w).Encode(m.GetPostedFormData(formInstanceKey.Value)) + 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) FormData { - m.postedFormData.RLock() - defer m.postedFormData.RUnlock() - return m.postedFormData.internalFormDataMap[key] +func (m *Manager) GetPostedFormData(key string) (MessageForm, bool) { + m.postedFormData.mu.RLock() + defer m.postedFormData.mu.RUnlock() + v, ok := m.postedFormData.m[key] + return v, ok } // GetFormTemplateHandler handles the request for viewing a form filled-in with instance values @@ -392,8 +395,8 @@ func unzip(srcArchivePath, dstRoot string) error { 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 { +// getXMLAttachmentNameForForm returns the user-visible filename for the message attachment that holds the form instance values +func getXMLAttachmentNameForForm(f Form, isReply bool) string { attachmentName := filepath.Base(f.ViewerURI) if isReply { attachmentName = filepath.Base(f.ReplyViewerURI) @@ -917,15 +920,19 @@ func (b formMessageBuilder) build() (MessageForm, error) { b.initFormValues() - formVarsAsXML := "" - for varKey, varVal := range b.FormValues { - formVarsAsXML += fmt.Sprintf(" <%s>%s\n", xmlEscape(varKey), xmlEscape(varVal), xmlEscape(varKey)) - } - msgForm, err := b.scanTmplBuildMessage(tmplPath) if err != nil { return MessageForm{}, err } + //TODO: Should any of these be set in scanTmplBuildMessage? + msgForm.targetForm = b.Template + msgForm.isReply = b.IsReply + msgForm.submitted = time.Now() + + formVarsAsXML := "" + for varKey, varVal := range b.FormValues { + formVarsAsXML += fmt.Sprintf(" <%s>%s\n", xmlEscape(varKey), xmlEscape(varVal), xmlEscape(varKey)) + } // Add XML if a viewer is defined for this form if b.Template.ViewerURI != "" { @@ -933,7 +940,7 @@ func (b formMessageBuilder) build() (MessageForm, error) { if b.IsReply && b.Template.ReplyViewerURI != "" { viewer = b.Template.ReplyViewerURI } - msgForm.AttachmentXML = fmt.Sprintf(`%s + msgForm.attachmentXML = fmt.Sprintf(`%s %s %s @@ -957,7 +964,6 @@ func (b formMessageBuilder) build() (MessageForm, error) { filepath.Base(viewer), filepath.Base(b.Template.ReplyTxtFileURI), formVarsAsXML) - msgForm.AttachmentName = b.FormsMgr.GetXMLAttachmentNameForForm(b.Template, false) } msgForm.To = strings.TrimSpace(msgForm.To) @@ -1075,13 +1081,13 @@ func fillPlaceholders(s string, re *regexp.Regexp, values map[string]string) str } func (m *Manager) cleanupOldFormData() { - m.postedFormData.Lock() - defer m.postedFormData.Unlock() - for key, form := range m.postedFormData.internalFormDataMap { - elapsed := time.Since(form.Submitted).Hours() + 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.internalFormDataMap, key) + delete(m.postedFormData.m, key) } } } diff --git a/web/src/js/app.js b/web/src/js/app.js index be66c84e..4af245a6 100644 --- a/web/src/js/app.js +++ b/web/src/js/app.js @@ -217,37 +217,38 @@ function forgetFormData() { let pollTimer; function pollFormData() { - $.get( - 'api/form', - {}, - function (data) { + $.ajax({ + method: 'GET', + url: '/api/form', + dataType: 'json', + success: function (data) { + // TODO: Should verify forminstance key in case of multi-user scenario + console.log('done polling'); console.log(data); - if (!$('#composer').hasClass('hidden') && (!data.target_form || !data.target_form.name)) { + if (!$('#composer').hasClass('hidden')) { + writeFormDataToComposer(data); + } + }, + error: function () { + if (!$('#composer').hasClass('hidden')) { + // TODO: Consider replacing this polling mechanism with a WS message (push) pollTimer = window.setTimeout(pollFormData, 1000); - } else { - console.log('done polling'); - if (!$('#composer').hasClass('hidden') && data.target_form && data.target_form.name) { - writeFormDataToComposer(data); - } } }, - 'json' - ); + }); } function writeFormDataToComposer(data) { - if (data.target_form) { - $('#msg_body').val(data.msg_body); - if (data.msg_to) { - $('#msg_to').tokenfield('setTokens', data.msg_to.split(/[ ;,]/).filter(Boolean)); - } - if (data.msg_cc) { - $('#msg_cc').tokenfield('setTokens', data.msg_cc.split(/[ ;,]/).filter(Boolean)); - } - if (data.msg_subject) { - // in case of composing a form-based reply we keep the 'Re: ...' subject line - $('#msg_subject').val(data.msg_subject); - } + $('#msg_body').val(data.msg_body); + if (data.msg_to) { + $('#msg_to').tokenfield('setTokens', data.msg_to.split(/[ ;,]/).filter(Boolean)); + } + if (data.msg_cc) { + $('#msg_cc').tokenfield('setTokens', data.msg_cc.split(/[ ;,]/).filter(Boolean)); + } + if (data.msg_subject) { + // in case of composing a form-based reply we keep the 'Re: ...' subject line + $('#msg_subject').val(data.msg_subject); } }