Review round 2 F3 (ADR 0517 §3, 1858/AC5): acted=1 stayed in the address, so a reload, a restored tab or a bookmark of it recorded no page opened or opened. An act's 303 (and a mode chosen) now sets mail_acted for 10 seconds, HttpOnly and SameSite=Strict, Secure but on 127.0.0.1; the next list or pane it reaches records nothing and clears it; the address carries no marker. The local run keeps the browser's other cookies.
144 lines
5.6 KiB
Go
144 lines
5.6 KiB
Go
package web
|
|
|
|
// What the operator does on the mail page (novox/hq ADR 0517 §1, §2, §4, §5; issue 1859): tick an item done,
|
|
// undo it, remind it tomorrow, choose a mode. Each is a post RequireFormToken has already let through (the
|
|
// session's own token, csrf.go), calling its one mail-reading verb for the signed-in operator; the page keeps
|
|
// no state of its own (ADR 0440 §4). Posted by the page's script (X-Requested-With: fetch), it answers JSON:
|
|
// done, where to go, or failed with the cause. Posted by a plain form, it goes back to the list, or shows the
|
|
// list again saying the act failed and why — never dropped silently.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// byScript says whether the page's own script posted the form.
|
|
func byScript(r *http.Request) bool { return r.Header.Get("X-Requested-With") == "fetch" }
|
|
|
|
func answerJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// actFailed says an act failed, with its cause: JSON to the script, the list page with the cause otherwise.
|
|
func (b *Bundle) actFailed(w http.ResponseWriter, r *http.Request, mode Mode, cause string) {
|
|
if byScript(r) {
|
|
answerJSON(w, http.StatusBadGateway, map[string]any{"failed": cause})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
data := b.listDataQuiet(ctx, r, mode)
|
|
data.Notice = cause
|
|
renderNamed(w, http.StatusBadGateway, "list", data)
|
|
}
|
|
|
|
// actDone answers an act that succeeded: done to the script, or back to the page at back with the act's own
|
|
// cookie (ActedCookie), so the page shown after it records nothing.
|
|
func actDone(w http.ResponseWriter, r *http.Request, back string) {
|
|
if byScript(r) {
|
|
answerJSON(w, http.StatusOK, map[string]any{"done": true})
|
|
return
|
|
}
|
|
setActed(w, r, actedFor)
|
|
http.Redirect(w, r, back, http.StatusSeeOther)
|
|
}
|
|
|
|
// actedFor is how long the act's cookie lives: the one GET a browser makes at once for the act's 303, or the
|
|
// script's navigation after a mode chosen (review round 2 F3).
|
|
const actedFor = 10
|
|
|
|
// setActed sets the act's cookie for maxAge seconds, or clears it with a negative maxAge: HttpOnly,
|
|
// SameSite=Strict, and Secure as the session cookie is, but on this machine's own 127.0.0.1 (the local run,
|
|
// cmd/mail-page-dev, served over plain http).
|
|
func setActed(w http.ResponseWriter, r *http.Request, maxAge int) {
|
|
host := r.Host
|
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
|
host = h
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: ActedCookie, Value: "1", Path: "/", MaxAge: maxAge, HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode, Secure: host != "127.0.0.1"})
|
|
}
|
|
|
|
// afterAnAct says whether r asks the page shown after an act — by the act's cookie, or the script's refresh —
|
|
// and clears the cookie, so it holds for that one page alone (review rounds 1 and 2, F1 and F3).
|
|
func afterAnAct(w http.ResponseWriter, r *http.Request) bool {
|
|
if r.Header.Get(RefreshHeader) != "" {
|
|
return true
|
|
}
|
|
if _, err := r.Cookie(ActedCookie); err != nil {
|
|
return false
|
|
}
|
|
setActed(w, r, -1)
|
|
return true
|
|
}
|
|
|
|
// listAddress is the list page's own address for mode.
|
|
func listAddress(mode Mode) string { return "/?" + url.Values{"mode": {mode.Name}}.Encode() }
|
|
|
|
// itemHandler is POST /item (1859/AC5): act handle, unhandle or remind, on the item (a task, a promise, or
|
|
// the mail itself) of the mail entry id, in the mode the page is in.
|
|
func (b *Bundle) itemHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "post only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
mode := modeNamed(r.PostFormValue("mode"))
|
|
id, err := strconv.Atoi(r.PostFormValue("id"))
|
|
if err != nil || id <= 0 {
|
|
http.Error(w, "an item needs its mail entry's id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
ask := map[string]func(context.Context, map[string]any) (json.RawMessage, error){
|
|
"handle": b.mailHandleAsk, "unhandle": b.mailUnhandleAsk, "remind": b.mailRemindAsk,
|
|
}[r.PostFormValue("act")]
|
|
if ask == nil {
|
|
http.Error(w, "the act is none of handle, unhandle, remind", http.StatusBadRequest)
|
|
return
|
|
}
|
|
args := map[string]any{"id": float64(id), "mode": mode.Name}
|
|
if item := r.PostFormValue("item"); item != "" {
|
|
args["item"] = item
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
if _, err := ask(ctx, args); err != nil {
|
|
b.actFailed(w, r, mode, "That did not happen: "+err.Error())
|
|
return
|
|
}
|
|
back := r.PostFormValue("back")
|
|
if back == "" || back[0] != '/' || (len(back) > 1 && back[1] == '/') {
|
|
back = listAddress(mode) // only an address of this page itself
|
|
}
|
|
actDone(w, r, back)
|
|
}
|
|
|
|
// modeHandler is POST /mode (1859/AC2, AC5): the mode chosen, recorded (ADR 0517 §4), then its list.
|
|
func (b *Bundle) modeHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "post only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
mode := modeNamed(r.PostFormValue("mode"))
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
if _, err := b.mailRecordAsk(ctx, map[string]any{"what": "mode", "mode": mode.Name}); err != nil {
|
|
b.actFailed(w, r, mode, "The mode chosen could not be kept: "+err.Error())
|
|
return
|
|
}
|
|
// The mode chosen is its own line: the list it goes to records nothing more (F1, F3), by the act's cookie,
|
|
// with or without the script.
|
|
setActed(w, r, actedFor)
|
|
if byScript(r) {
|
|
answerJSON(w, http.StatusOK, map[string]any{"done": true, "go": listAddress(mode)})
|
|
return
|
|
}
|
|
http.Redirect(w, r, listAddress(mode), http.StatusSeeOther)
|
|
}
|