4e4c9170e3
EventFromNotification took the date from the notification's own day, on the grounds that a meeting notification is about today or it would not be firing. Calendar apps break that. A 21:00 reminder reading "Tomorrow at 09:00" became an event at 09:00 today, twelve hours in the past, and FactKey filed that wrong meeting under today's date. Storing a wrong meeting is the one outcome this parse works to avoid. An explicit day word now moves the date: завтра, tomorrow, послезавтра, сегодня, today, tonight. Matched whole, so послезавтра is not read as завтра, and stripped from the summary so the meeting is not named after the day. Anything still landing more than two hours before the notification is refused, which covers the cases with no day word at all. The grace keeps a repost for a meeting already under way. Also matches the bearer scheme with EqualFold. A phone sending "bearer <tok>" fell through to the X-Maven-Token branch and got a 401 that looked like a wrong token. A bare token with no scheme in Authorization is now rejected rather than silently accepted. The route table in mavweb gains its /api/ambient row, and the missing calendar_busy write is recorded as a known gap. Found in review of #57.
151 lines
5.6 KiB
Go
151 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/kami/maven/internal/calendar"
|
|
"github.com/kami/maven/internal/ipc"
|
|
)
|
|
|
|
// POST /api/ambient — the work calendar read (Vikunja #126).
|
|
//
|
|
// Maven does not hold a work credential. A corp mail or calendar session on the
|
|
// homelab ties the box's blast radius to the employer's data, so the work
|
|
// calendar is read as a SIGNAL instead: an Android notification-listener on the
|
|
// owner's phone posts meeting notifications here over wg/LAN, and the ones that
|
|
// clearly describe a meeting become calendar events at source=ambient:notif,
|
|
// confidence below 1.0. Mail as a notification signal, not a mailbox.
|
|
//
|
|
// Off unless configured: no -ambient-token, no route. The token is a shared
|
|
// secret because the poster is a phone service, not a browser — WebAuthn has no
|
|
// answer for a background Android service. The endpoint is write-only and
|
|
// accepts exactly one shape of write; it cannot read anything back out.
|
|
//
|
|
// A notification with no recognisable clock reading stores NOTHING. Maven is
|
|
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
|
|
// is worse than a gap.
|
|
//
|
|
// KNOWN GAP: this writes calendar_event_* and nothing else, so an ambient
|
|
// meeting is good enough to recite and not good enough to stop a nudge —
|
|
// calendar_busy is still written only by the CalDAV poller. That is backwards,
|
|
// since suppressing a nudge is the lower-risk use of a low-confidence signal.
|
|
// calendar_busy is a level rather than an event, so an ambient writer needs an
|
|
// expiry, which is its own task and not a change here.
|
|
|
|
// ambientMaxBody bounds the request. A notification is two short lines.
|
|
const ambientMaxBody = 8 << 10
|
|
|
|
type ambientResp struct {
|
|
Stored bool `json:"stored"`
|
|
Key string `json:"key,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
// handleAmbient ingests one relayed notification. token is the configured
|
|
// shared secret; an empty token means the capability is off and the handler is
|
|
// never registered, so it is treated as a hard failure here too.
|
|
func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, token string) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if token == "" {
|
|
http.Error(w, "ambient ingest disabled (no -ambient-token)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if !ambientAuthorized(r, token) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if core == nil {
|
|
http.Error(w, "ambient ingest disabled (no -core)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
var n calendar.Notification
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, ambientMaxBody))
|
|
if err != nil {
|
|
http.Error(w, "read failed", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := json.Unmarshal(body, &n); err != nil {
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if n.Posted.IsZero() {
|
|
writeAmbient(w, http.StatusBadRequest, ambientResp{Reason: "posted_at is required"})
|
|
return
|
|
}
|
|
|
|
ev, ok := calendar.EventFromNotification(n)
|
|
if !ok {
|
|
// Not an event. 202: the relay did its job, there is just nothing here
|
|
// worth remembering, and it must not retry.
|
|
writeAmbient(w, http.StatusAccepted, ambientResp{Reason: "no meeting time in notification"})
|
|
return
|
|
}
|
|
|
|
key := calendar.FactKey(ev)
|
|
val := calendar.FactValue(ev)
|
|
|
|
// Append-only discipline, same as cmd/mavcaldav: a phone reposts the same
|
|
// notification many times, and each repost is the same event.
|
|
if prev, err := core.LatestFactBySource(r.Context(), key, calendar.SourceAmbient); err == nil && prev.Value == val {
|
|
writeAmbient(w, http.StatusOK, ambientResp{Stored: false, Key: key, Reason: "unchanged"})
|
|
return
|
|
} else if err != nil && !errors.Is(err, ipc.ErrNoFact) {
|
|
log.Printf("ambient: read %s: %v", key, err)
|
|
http.Error(w, "read failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
// kind=env: an observation about the world, never a self-fact — a passive
|
|
// signal does not write truth about the owner. Confidence below 1.0 is the
|
|
// honest part: this is a notification about a meeting, not a reading of a
|
|
// calendar, and the query path hedges when it recites one.
|
|
if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{
|
|
Ts: ev.Start,
|
|
Kind: "env",
|
|
Key: key,
|
|
Value: val,
|
|
Source: calendar.SourceAmbient,
|
|
Confidence: calendar.AmbientConfidence,
|
|
}); err != nil {
|
|
log.Printf("ambient: write %s: %v", key, err)
|
|
http.Error(w, "write failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
log.Printf("ambient: %s=%s (%s, pkg=%s)", key, val, calendar.SourceAmbient, n.Package)
|
|
writeAmbient(w, http.StatusCreated, ambientResp{Stored: true, Key: key})
|
|
}
|
|
|
|
// ambientAuthorized accepts the token as a bearer header or as an X-Maven-Token
|
|
// header, compared in constant time.
|
|
//
|
|
// The scheme is matched case-insensitively. RFC 7235 says it is, and a phone
|
|
// client sending "bearer <tok>" used to fall through to the X-Maven-Token
|
|
// branch and get a silent 401 with nothing to see from the phone's side.
|
|
func ambientAuthorized(r *http.Request, token string) bool {
|
|
got := ""
|
|
if authz := strings.TrimSpace(r.Header.Get("Authorization")); len(authz) >= len("Bearer") &&
|
|
strings.EqualFold(authz[:len("Bearer")], "Bearer") {
|
|
got = strings.TrimSpace(authz[len("Bearer"):])
|
|
}
|
|
if got == "" {
|
|
got = strings.TrimSpace(r.Header.Get("X-Maven-Token"))
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1
|
|
}
|
|
|
|
func writeAmbient(w http.ResponseWriter, code int, resp ambientResp) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|