Wires 1284's per-session token into the real write form and its real POST /send; adds tests for 1284/AC1-AC4 against that real page and handler, beside this issue's own AC1-AC4.
185 lines
6.4 KiB
Go
185 lines
6.4 KiB
Go
package main
|
|
|
|
// The write page and a send's state (novox/hq ADR 0440 §6, issue 1267): the form offers only the accounts
|
|
// mail-store.accounts answers; its POST (behind issue 1284's own per-session token, wired in here as this
|
|
// form's hidden field) calls mail-store.send, which raises the operator's Approve on Telegram and sends
|
|
// nothing itself, then shows the send's state from mail-store.send-status — in words, refreshing itself every
|
|
// 5 seconds with no script (a meta refresh) until a final state (sent, failed, refused, not proven, declined,
|
|
// expired, unknown; novox/hq ADR 0436 §12, issue 1261).
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// SendStatusRefreshEvery is how often the state page asks again while a send is still waiting (ADR 0440 §6).
|
|
const SendStatusRefreshEvery = 5
|
|
|
|
var writeTemplate = template.Must(template.New("write").Parse(`<!DOCTYPE html>
|
|
<html><head><title>Write mail</title></head><body>
|
|
<h1>Write</h1>
|
|
{{if .Error}}<p>{{.Error}}</p>{{else}}
|
|
<form method="post" action="/send">
|
|
<select name="account">{{range .Accounts}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
|
<input type="text" name="to" placeholder="to">
|
|
<input type="text" name="subject" placeholder="subject">
|
|
<textarea name="body"></textarea>
|
|
{{.TokenField}}
|
|
<button type="submit">Send</button>
|
|
</form>
|
|
{{end}}
|
|
</body></html>`))
|
|
|
|
type writeTemplateData struct {
|
|
Accounts []string
|
|
TokenField template.HTML
|
|
Error string
|
|
}
|
|
|
|
var sendStatusTemplate = template.Must(template.New("send-status").Parse(`<!DOCTYPE html>
|
|
<html><head><title>Send</title>{{if .Refresh}}<meta http-equiv="refresh" content="{{.RefreshSeconds}}; url={{.RefreshURL}}">{{end}}</head>
|
|
<body>
|
|
{{if .Note}}<p>{{.Note}}</p>{{end}}
|
|
<p>{{.Words}}</p>
|
|
</body></html>`))
|
|
|
|
type sendStatusData struct {
|
|
Note, Words, RefreshURL string
|
|
RefreshSeconds int
|
|
Refresh bool
|
|
}
|
|
|
|
// grantedAccountNames is mail-store.accounts' own answer, read as the list of account names it granted —
|
|
// shared by the write page (issue 1267/AC1) and the send handler (AC4).
|
|
func (b *Bundle) grantedAccountNames(ctx context.Context) ([]string, error) {
|
|
raw, err := b.accountsAsk(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var answer accountsAnswer
|
|
if err := json.Unmarshal(raw, &answer); err != nil {
|
|
return nil, err
|
|
}
|
|
names := make([]string, 0, len(answer.Accounts))
|
|
for _, a := range answer.Accounts {
|
|
names = append(names, a.Account)
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
func containsName(names []string, name string) bool {
|
|
for _, n := range names {
|
|
if n == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// splitAddresses is the write form's own "to" field (one text input) split into mail-store.send's own array
|
|
// argument, on whitespace or commas.
|
|
func splitAddresses(s string) []string {
|
|
var out []string
|
|
for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' }) {
|
|
if part != "" {
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// writePage is issue 1267/AC1: offers exactly the accounts mail-store.accounts answers, and (issue 1284) the
|
|
// signed-in session's own form token as a hidden field, so the POST below can tell this session's own form
|
|
// from any other's.
|
|
func (b *Bundle) writePage(w http.ResponseWriter, r *http.Request) {
|
|
data := writeTemplateData{}
|
|
if sess, ok := SessionFrom(r); ok {
|
|
data.TokenField = FormTokenInput(sess)
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
names, err := b.grantedAccountNames(ctx)
|
|
if err != nil {
|
|
data.Error = "the granted accounts could not be answered"
|
|
} else {
|
|
data.Accounts = names
|
|
}
|
|
renderPage(w, writeTemplate, data)
|
|
}
|
|
|
|
// sendHandler is issue 1267/AC2, AC4: called only once RequireFormToken (issue 1284) has already let the POST
|
|
// through. An account mail-store did not answer sends nothing (AC4); otherwise mail-store.send is called with
|
|
// exactly account, to, subject and body, and the answer is the send's own state page, noting that the
|
|
// operator's Approve is asked on Telegram (AC2).
|
|
func (b *Bundle) sendHandler(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
account := r.PostFormValue("account")
|
|
to := r.PostFormValue("to")
|
|
subject := r.PostFormValue("subject")
|
|
body := r.PostFormValue("body")
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
granted, err := b.grantedAccountNames(ctx)
|
|
if err != nil || !containsName(granted, account) {
|
|
renderPage(w, sendStatusTemplate, sendStatusData{Words: "the account is not granted"})
|
|
return
|
|
}
|
|
raw, err := b.sendAsk(ctx, map[string]any{
|
|
"account": account, "to": splitAddresses(to), "subject": subject, "body": body,
|
|
})
|
|
if err != nil {
|
|
renderPage(w, sendStatusTemplate, sendStatusData{Words: "the send could not be asked"})
|
|
return
|
|
}
|
|
var answer struct {
|
|
Ask string `json:"ask"`
|
|
}
|
|
_ = json.Unmarshal(raw, &answer)
|
|
b.renderSendStatus(w, answer.Ask, "The operator's Approve is asked on Telegram.")
|
|
}
|
|
|
|
// sendStatusPage is GET /send-status?id=...: the send's own state page, polled by its own meta refresh (issue
|
|
// 1267/AC3) until a final state.
|
|
func (b *Bundle) sendStatusPage(w http.ResponseWriter, r *http.Request) {
|
|
b.renderSendStatus(w, r.URL.Query().Get("id"), "")
|
|
}
|
|
|
|
// renderSendStatus is issue 1267/AC3: the send's state in words from mail-store.send-status; "waiting" (not
|
|
// yet final) refreshes itself every SendStatusRefreshEvery seconds with a meta refresh, no script; every
|
|
// other state (sent, failed, refused, not proven, declined, expired, unknown) is final and does not refresh.
|
|
func (b *Bundle) renderSendStatus(w http.ResponseWriter, id, note string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
defer cancel()
|
|
data := sendStatusData{Note: note}
|
|
raw, err := b.sendStatusAsk(ctx, map[string]any{"id": id})
|
|
if err != nil {
|
|
data.Words = "the send's state could not be answered"
|
|
renderPage(w, sendStatusTemplate, data)
|
|
return
|
|
}
|
|
var answer struct {
|
|
Status string `json:"status"`
|
|
Words string `json:"words"`
|
|
}
|
|
_ = json.Unmarshal(raw, &answer)
|
|
data.Words = answer.Status
|
|
if answer.Words != "" {
|
|
data.Words += ": " + answer.Words
|
|
}
|
|
if answer.Status == "waiting" {
|
|
data.Refresh = true
|
|
data.RefreshSeconds = SendStatusRefreshEvery
|
|
data.RefreshURL = "/send-status?id=" + url.QueryEscape(id)
|
|
}
|
|
renderPage(w, sendStatusTemplate, data)
|
|
}
|