release gate / check (push) Failing after 6m43s
High severity: - wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup - bucket public rate limits on effective client IP, add per-bookmark budget, and forward the signed browser identity from the share page - disable first-run bootstrap in server deployments and document seeding - serialize last-admin demotion in one advisory-locked transaction Medium/low: - cap and batch share recipient inserts - add a shared outbound budget for preview/CardDAV/IMAP/signal work - pin CardDAV dials to 443 and bound IMAP logout - fix rightward kanban column drags and normalize cards on column delete - classify backend outages in auth guards, add request timeouts and an error boundary, remove the dead login route, surface throttling - enforce pinned/unpinned page reorder grouping server-side - avoid preview work for stale bookmark versions; drop duplicate dialog fetch - sequence-guard collection/tag/calendar/quick-find loads - validate property config and fix MULTI_SELECT/DATE filter inputs - document index maintenance; apply same-origin check to sign-out - trim signup names, unique shared dates and kanban keys (migration 010), reset reference cache on identity changes, hide OIDC issuer, return 413 for oversized direct bodies, escape Markdown link destinations - remove dead template files and duplicate test helper Verified with go build/vet, full PostgreSQL race suite, db convergence, tsc, both ESLint configs and a Next production build.
612 lines
22 KiB
Go
612 lines
22 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/xml"
|
|
"errors"
|
|
"github.com/andrew/go-computer/internal/config"
|
|
crypt "github.com/andrew/go-computer/internal/crypto"
|
|
"github.com/andrew/go-computer/internal/db"
|
|
"github.com/andrew/go-computer/internal/netguard"
|
|
"github.com/andrew/go-computer/internal/outbound"
|
|
"github.com/andrew/go-computer/internal/signals"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// carddavDial pins production CardDAV traffic to 443 (review M-3), matching
|
|
// previews (80/443) and IMAP (143/993). Without it a user could make the
|
|
// server TLS-handshake and send Basic-auth credentials to any public port.
|
|
// The fixture/LAN escape hatch keeps loopback fakes and deliberate private
|
|
// deployments working.
|
|
func carddavDial(ctx context.Context, network, address string) (net.Conn, error) {
|
|
if netguard.AllowPrivate() {
|
|
return netguard.DialContext(ctx, network, address)
|
|
}
|
|
return netguard.DialContextPorts(ctx, network, address, 443)
|
|
}
|
|
|
|
// carddavClient dials through netguard so a user-supplied Nextcloud URL can
|
|
// never point the server at internal infrastructure (same guard as URL
|
|
// previews). The transport is shared to avoid leaking idle sockets per sync.
|
|
var carddavClient = &http.Client{
|
|
Timeout: 20 * time.Second,
|
|
Transport: &http.Transport{DialContext: carddavDial},
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
|
|
func HandleAlertsCheck(w http.ResponseWriter, r *http.Request) {
|
|
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(b) FROM "Bookmark" b WHERE "ownerId"=$1 AND "alertAt"<=now()+interval '15 minutes' AND NOT "alertSent" ORDER BY "alertAt"`, user(r))
|
|
respond(w, 200, v, e)
|
|
}
|
|
func HandleAlertsDismiss(w http.ResponseWriter, r *http.Request) {
|
|
_, e := db.Update(r.Context(), database(r), "Bookmark", r.PathValue("bookmarkId"), "ownerId", user(r), object{"alertSent": true, "updatedAt": time.Now().UTC()})
|
|
respond(w, 200, OK{true}, e)
|
|
}
|
|
func HandleUsersSearch(w http.ResponseWriter, r *http.Request) {
|
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
if len([]rune(q)) < 2 {
|
|
respond(w, 200, []object{}, nil)
|
|
return
|
|
}
|
|
// The share picker needs names and IDs; substring matching on emails would
|
|
// let any user enumerate the whole user base, so emails must match exactly
|
|
// and are returned masked. The name pattern escapes LIKE wildcards
|
|
// (review SEC-3): without that, "%%" or "a%" enumerated every user
|
|
// 10 rows at a time.
|
|
v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object('id',id,'name',name,'email',CASE WHEN position('@' in email)=0 THEN NULL ELSE left(email,1)||'***'||substr(email, position('@' in email)) END,'image',image) FROM "User" WHERE id<>$1 AND (name ILIKE $2 ESCAPE '\' OR lower(email)=lower($3)) ORDER BY name LIMIT 10`, user(r), "%"+likeEscaper.Replace(q)+"%", q)
|
|
respond(w, 200, v, e)
|
|
}
|
|
func HandleSharedDatesList(w http.ResponseWriter, r *http.Request) {
|
|
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(s)||jsonb_build_object('creator',jsonb_build_object('id',c.id,'name',c.name,'email',c.email),'recipient',jsonb_build_object('id',u.id,'name',u.name,'email',u.email)) FROM "SharedDate" s JOIN "User" c ON c.id=s."creatorId" JOIN "User" u ON u.id=s."recipientId" WHERE s."creatorId"=$1 OR s."recipientId"=$1 ORDER BY date`, user(r))
|
|
respond(w, 200, v, e)
|
|
}
|
|
func HandleSharedDatesCreate(w http.ResponseWriter, r *http.Request) {
|
|
b, ok := read(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
dt, e := dateValue(b["date"])
|
|
if e != nil || dt == nil {
|
|
respond(w, 0, nil, bad("Date required"))
|
|
return
|
|
}
|
|
list, e := cappedIDs(b, "recipientIds", maxSharedDateRecipients)
|
|
if e != nil || len(list) == 0 {
|
|
respond(w, 0, nil, bad("Recipients required"))
|
|
return
|
|
}
|
|
if e = stringFields(b, "note", ""); e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
if e = limitText(b, "note", maxSharedDateNoteLen); e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
out := []object{}
|
|
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
|
|
for _, id := range list {
|
|
// Review L-3: re-sharing the same creator/recipient/date updates
|
|
// the note instead of inserting a duplicate calendar row.
|
|
v, e := db.One(r.Context(), tx, `INSERT INTO "SharedDate"(id,date,note,"creatorId","recipientId") VALUES($1,$2,$3,$4,$5)
|
|
ON CONFLICT ("creatorId","recipientId",date) DO UPDATE SET note=EXCLUDED.note
|
|
RETURNING to_jsonb("SharedDate")`, db.NewID(), dt, b["note"], user(r), id)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return nil
|
|
})
|
|
respond(w, 201, out, e)
|
|
}
|
|
func HandleContactsList(w http.ResponseWriter, r *http.Request) {
|
|
v, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(c)||jsonb_build_object('_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "Bookmark" b WHERE b."contactId"=c.id AND b."ownerId"=$1))) FROM "Contact" c WHERE "userId"=$1 ORDER BY "displayName"`, user(r))
|
|
respond(w, 200, v, e)
|
|
}
|
|
|
|
type davResponse struct {
|
|
Responses []struct {
|
|
Href string `xml:"href"`
|
|
Propstats []struct {
|
|
Status string `xml:"status"`
|
|
Prop struct {
|
|
Data string `xml:"address-data"`
|
|
} `xml:"prop"`
|
|
} `xml:"propstat"`
|
|
} `xml:"response"`
|
|
}
|
|
|
|
// resolveCardDAVTarget resolves the user-supplied cardDavPath against the
|
|
// configured base URL and revalidates the COMPLETE resolved destination
|
|
// (review CR-01): a same-host absolute path like "http://host/..." must not
|
|
// downgrade an https base, because Basic-auth credentials are attached to the
|
|
// resulting request. The resolved URL must keep the base's scheme and host
|
|
// and carry no embedded userinfo. The scheme check on the base URL itself
|
|
// (https unless the netguard fixture flag is set) happens at the caller, so
|
|
// pinning the resolved scheme to the base scheme enforces https end to end.
|
|
func resolveCardDAVTarget(base *url.URL, path string) (*url.URL, error) {
|
|
target, e := base.Parse(path)
|
|
if e != nil {
|
|
return nil, bad("Invalid CardDAV path")
|
|
}
|
|
if target.Host != base.Host || target.Scheme != base.Scheme || target.User != nil {
|
|
return nil, bad("CardDAV path must stay on the Nextcloud host and scheme")
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
func HandleContactsSync(w http.ResponseWriter, r *http.Request) {
|
|
b, ok := read(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !validURL(str(b, "nextcloudUrl")) || str(b, "username") == "" || str(b, "password") == "" {
|
|
respond(w, 0, nil, bad("Nextcloud URL and credentials required"))
|
|
return
|
|
}
|
|
base, _ := url.Parse(str(b, "nextcloudUrl"))
|
|
// CardDAV transport (review recheck): Basic-auth credentials must never
|
|
// travel plaintext. https is required unless the netguard escape hatch is
|
|
// set (loopback test fakes / deliberate LAN deployments — never
|
|
// production Compose). Redirects are not followed at all
|
|
// (ErrUseLastResponse), so a verified origin cannot be downgraded, and
|
|
// the path resolution below is pinned to the same host AND the same
|
|
// scheme (review CR-01: the final resolved destination is revalidated,
|
|
// not just the configured base URL).
|
|
if base.Scheme != "https" && !netguard.AllowPrivate() {
|
|
respond(w, 0, nil, bad("Nextcloud URL must use https"))
|
|
return
|
|
}
|
|
// Review M-3: the transport pins production dials to 443; reject an
|
|
// explicit non-standard port up front for a clear error instead of a
|
|
// generic connection failure.
|
|
if !netguard.AllowPrivate() && base.Port() != "" && base.Port() != "443" {
|
|
respond(w, 0, nil, bad("Nextcloud URL must use port 443"))
|
|
return
|
|
}
|
|
path := str(b, "cardDavPath")
|
|
if path == "" {
|
|
path = "/remote.php/dav/addressbooks/users/" + url.PathEscape(str(b, "username")) + "/contacts/"
|
|
}
|
|
target, e := resolveCardDAVTarget(base, path)
|
|
if e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
// Review M-2: CardDAV sync draws from the shared outbound budget with
|
|
// IMAP/preview/signal checks, so one user cannot open unbounded
|
|
// long-lived connections or starve egress.
|
|
release, retry, ok := outbound.Reserve("carddav:"+user(r), 10, time.Minute)
|
|
if !ok {
|
|
outboundBusy(w, retry)
|
|
return
|
|
}
|
|
defer release()
|
|
payload := `<?xml version="1.0"?><card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><card:address-data/></d:prop></card:addressbook-query>`
|
|
req, e := http.NewRequestWithContext(r.Context(), "REPORT", target.String(), strings.NewReader(payload))
|
|
if e != nil {
|
|
respond(w, 0, nil, bad("Invalid CardDAV URL"))
|
|
return
|
|
}
|
|
req.SetBasicAuth(str(b, "username"), str(b, "password"))
|
|
req.Header.Set("Depth", "1")
|
|
req.Header.Set("Content-Type", "application/xml")
|
|
res, e := carddavClient.Do(req)
|
|
if e != nil {
|
|
respond(w, 0, nil, bad("Could not connect to Nextcloud"))
|
|
return
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != 207 && res.StatusCode != 200 {
|
|
respond(w, 0, nil, bad("Nextcloud rejected the CardDAV request"))
|
|
return
|
|
}
|
|
var dav davResponse
|
|
if e = xml.NewDecoder(io.LimitReader(res.Body, 8<<20)).Decode(&dav); e != nil {
|
|
respond(w, 0, nil, bad("Invalid CardDAV response"))
|
|
return
|
|
}
|
|
synced := 0
|
|
removed := 0
|
|
seenUIDs := []string{}
|
|
// complete tracks whether the server answered with full data for every
|
|
// card. Deletion reconciliation (review CR-07) runs ONLY on a complete,
|
|
// non-empty snapshot: a failed, truncated or partially-withheld response
|
|
// must never be read as "these contacts were deleted remotely".
|
|
complete := true
|
|
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
|
|
for _, entry := range dav.Responses {
|
|
gotData := false
|
|
for _, prop := range entry.Propstats {
|
|
if !strings.Contains(prop.Status, "200") {
|
|
continue
|
|
}
|
|
v := parseVCard(prop.Prop.Data)
|
|
uid, fn := vCardPick(v["UID"]), vCardPick(v["FN"])
|
|
if uid == "" || fn == "" {
|
|
// A card we cannot identify stays untouched locally — and
|
|
// blocks reconciliation, since we cannot prove which
|
|
// stored contact (if any) it corresponds to.
|
|
complete = false
|
|
continue
|
|
}
|
|
// Contact data comes from a remote server, so apply the same
|
|
// size discipline as user input. A UID is an identity key and
|
|
// must never be truncated; skip an absurd one and keep the
|
|
// snapshot incomplete so reconciliation stays conservative.
|
|
if len([]rune(uid)) > maxContactUIDLen {
|
|
complete = false
|
|
continue
|
|
}
|
|
gotData = true
|
|
seenUIDs = append(seenUIDs, uid)
|
|
email := trimLimit(vCardPick(v["EMAIL"]), maxContactEmailLen)
|
|
phone := trimLimit(vCardPick(v["TEL"]), maxContactPhoneLen)
|
|
fn = trimLimit(fn, maxContactNameLen)
|
|
_, e := tx.ExecContext(r.Context(), `INSERT INTO "Contact"(id,uid,"displayName",email,phone,"cardDavUrl",source,"userId") VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(uid,"userId") DO UPDATE SET "displayName"=EXCLUDED."displayName",email=EXCLUDED.email,phone=EXCLUDED.phone,"cardDavUrl"=EXCLUDED."cardDavUrl",source=EXCLUDED.source,"updatedAt"=now()`, db.NewID(), uid, fn, nullable(email), nullable(phone), entry.Href, target.String(), user(r))
|
|
if e != nil {
|
|
return e
|
|
}
|
|
synced++
|
|
}
|
|
if !gotData {
|
|
complete = false
|
|
}
|
|
}
|
|
// Remote-deletion reconciliation (review CR-07). Policy: the sync
|
|
// MIRRORS the configured address book, scoped by provenance — only
|
|
// contacts previously imported from THIS source URL are candidates,
|
|
// manually created contacts (source NULL) and other address books'
|
|
// imports are never touched, and bookmarks referencing a removed
|
|
// contact keep working (contactId is ON DELETE SET NULL). Guards:
|
|
// a non-empty, fully-answered snapshot only; an empty or partial
|
|
// response preserves everything (a genuinely emptied address book
|
|
// stops mirroring deletions until it has at least one card — the
|
|
// deliberate safe-side tradeoff).
|
|
if complete && synced > 0 {
|
|
res, e := tx.ExecContext(r.Context(), `DELETE FROM "Contact" WHERE "userId"=$1 AND source=$2 AND NOT (uid = ANY($3::text[]))`, user(r), target.String(), seenUIDs)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
removed = int(n)
|
|
}
|
|
return nil
|
|
})
|
|
if e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
respond(w, 200, object{"synced": synced, "removed": removed}, nil)
|
|
}
|
|
func nullable(s string) any {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// vCardValue is one parsed property occurrence: its parameters (TYPE, PREF,
|
|
// ENCODING, ...) and its unescaped value. Repeated properties keep EVERY
|
|
// occurrence in file order (review CR-08) instead of the last one clobbering
|
|
// the rest.
|
|
type vCardValue struct {
|
|
params map[string][]string
|
|
value string
|
|
}
|
|
|
|
// splitVCardName splits the property name on unquoted semicolons so quoted
|
|
// parameter values ("...;X=a;b") survive.
|
|
func splitVCardName(name string) []string {
|
|
out := []string{}
|
|
var cur strings.Builder
|
|
inQuotes := false
|
|
for _, r := range name {
|
|
switch {
|
|
case r == '"':
|
|
inQuotes = !inQuotes
|
|
cur.WriteRune(r)
|
|
case r == ';' && !inQuotes:
|
|
out = append(out, cur.String())
|
|
cur.Reset()
|
|
default:
|
|
cur.WriteRune(r)
|
|
}
|
|
}
|
|
return append(out, cur.String())
|
|
}
|
|
|
|
// parseVCard parses the documented subset of vCard (RFC 2426/6350) the
|
|
// contact sync consumes: CRLF and bare-LF unfolding, group prefixes
|
|
// (item1.EMAIL), parameter lists (including bare vCard 2.1 tokens as
|
|
// implicit TYPE values), and the standard escapes. Unsupported data is
|
|
// dropped, not silently misparsed (review CR-08): properties declaring a
|
|
// base64 ENCODING are skipped — the stored fields (UID/FN/EMAIL/TEL) are
|
|
// never legitimately encoded, and keeping the raw text would pretend to be
|
|
// a correct parse. A colon inside a quoted parameter value is outside the
|
|
// supported subset.
|
|
func parseVCard(s string) map[string][]vCardValue {
|
|
s = strings.ReplaceAll(s, "\r\n ", "")
|
|
s = strings.ReplaceAll(s, "\r\n\t", "")
|
|
s = strings.ReplaceAll(s, "\n ", "")
|
|
s = strings.ReplaceAll(s, "\n\t", "")
|
|
out := map[string][]vCardValue{}
|
|
for _, line := range strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n") {
|
|
parts := strings.SplitN(line, ":", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
fields := splitVCardName(parts[0])
|
|
key := strings.ToUpper(fields[0])
|
|
if i := strings.LastIndex(key, "."); i >= 0 {
|
|
key = key[i+1:] // strip group prefix (item1.EMAIL -> EMAIL)
|
|
}
|
|
if key == "" {
|
|
continue
|
|
}
|
|
params := map[string][]string{}
|
|
for _, p := range fields[1:] {
|
|
kv := strings.SplitN(p, "=", 2)
|
|
if len(kv) != 2 {
|
|
// Bare token (vCard 2.1 style "EMAIL;INTERNET;WORK"): an
|
|
// implicit TYPE value.
|
|
if tok := strings.TrimSpace(kv[0]); tok != "" {
|
|
params["TYPE"] = append(params["TYPE"], strings.ToUpper(tok))
|
|
}
|
|
continue
|
|
}
|
|
pk := strings.ToUpper(strings.TrimSpace(kv[0]))
|
|
for _, pv := range strings.Split(kv[1], ",") {
|
|
params[pk] = append(params[pk], strings.ToUpper(strings.Trim(strings.TrimSpace(pv), `"`)))
|
|
}
|
|
}
|
|
if enc := params["ENCODING"]; len(enc) > 0 && (enc[0] == "B" || enc[0] == "BASE64") {
|
|
continue // unsupported encoding: drop instead of misparsing
|
|
}
|
|
value := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`).Replace(parts[1])
|
|
out[key] = append(out[key], vCardValue{params: params, value: value})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// vCardPick deterministically selects the display value among repeated
|
|
// occurrences (review CR-08): the lowest explicit PREF=n wins (vCard 4.0),
|
|
// then the first TYPE=PREF occurrence (vCard 3.0), then file order.
|
|
func vCardPick(values []vCardValue) string {
|
|
if len(values) == 0 {
|
|
return ""
|
|
}
|
|
best := values[0].value
|
|
bestPref := 1 << 30
|
|
for _, v := range values {
|
|
for _, p := range v.params["PREF"] {
|
|
if n, e := strconv.Atoi(p); e == nil && n < bestPref {
|
|
bestPref, best = n, v.value
|
|
}
|
|
}
|
|
}
|
|
if bestPref != 1<<30 {
|
|
return best
|
|
}
|
|
for _, v := range values {
|
|
for _, p := range v.params["TYPE"] {
|
|
if p == "PREF" {
|
|
return v.value
|
|
}
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
func mailboxData(b object) (signals.Mailbox, error) {
|
|
if e := stringFields(b, "", "host username password folder"); e != nil {
|
|
return signals.Mailbox{}, e
|
|
}
|
|
if e := boolFields(b, "tls"); e != nil {
|
|
return signals.Mailbox{}, e
|
|
}
|
|
for _, l := range []struct {
|
|
key string
|
|
max int
|
|
}{
|
|
{"host", maxMailboxHostLen},
|
|
{"username", maxMailboxUsernameLen},
|
|
{"password", maxMailboxPasswordLen},
|
|
{"folder", maxMailboxFolderLen},
|
|
} {
|
|
if e := limitText(b, l.key, l.max); e != nil {
|
|
return signals.Mailbox{}, e
|
|
}
|
|
}
|
|
m := signals.Mailbox{Host: str(b, "host"), Username: str(b, "username"), Password: str(b, "password"), Folder: str(b, "folder"), Port: 993, TLS: true}
|
|
if n, ok := b["port"]; ok {
|
|
v, ok := n.(float64)
|
|
if !ok || v < 1 || v > 65535 || float64(int(v)) != v {
|
|
return m, bad("Invalid port")
|
|
}
|
|
m.Port = int(v)
|
|
}
|
|
if v, ok := b["tls"].(bool); ok {
|
|
m.TLS = v
|
|
}
|
|
// Normal IMAP ports only (review "outbound fetches aren't limited to
|
|
// normal ports"). The fixture flags keep loopback fakes and deliberate
|
|
// LAN deployments working on an ephemeral port.
|
|
if !signals.AllowInsecure() && !netguard.AllowPrivate() && m.Port != 993 && m.Port != 143 {
|
|
return m, bad("Mailbox port must be 143 or 993")
|
|
}
|
|
// Review NET-02: mailbox credentials must never travel over a plaintext
|
|
// socket. Insecure settings are rejected at the API boundary; the
|
|
// development-only INTEGRATIONS_ALLOW_INSECURE_IMAP escape hatch mirrors
|
|
// netguard's fixture flag and is never set in production Compose.
|
|
if !m.TLS && !signals.AllowInsecure() {
|
|
return m, bad("TLS is required for mailbox connections")
|
|
}
|
|
if m.Folder == "" {
|
|
m.Folder = "INBOX"
|
|
}
|
|
if m.Host == "" || m.Username == "" {
|
|
return m, bad("Host and username required")
|
|
}
|
|
return m, nil
|
|
}
|
|
func HandleImapGet(w http.ResponseWriter, r *http.Request) {
|
|
v, e := db.One(r.Context(), database(r), `SELECT to_jsonb(m)-'password'||jsonb_build_object('password','••••••••') FROM "ImapConfig" m WHERE "userId"=$1`, user(r))
|
|
if e == db.ErrNotFound {
|
|
respond(w, 200, nil, nil)
|
|
return
|
|
}
|
|
respond(w, 200, v, e)
|
|
}
|
|
func HandleImapSave(w http.ResponseWriter, r *http.Request) {
|
|
b, ok := read(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
m, e := mailboxData(b)
|
|
if e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
// Review CR-21: a blank/masked password means "keep the stored secret".
|
|
// The old path read the existing ciphertext and re-submitted it in the
|
|
// upsert, so a password saved concurrently between that read and write
|
|
// was silently rolled back. The keep-stored path therefore never assigns
|
|
// the password column at all — whatever is stored at commit time wins.
|
|
if m.Password == "" || m.Password == "••••••••" {
|
|
res, e := database(r).ExecContext(r.Context(), `UPDATE "ImapConfig" SET host=$1,port=$2,tls=$3,username=$4,folder=$5 WHERE "userId"=$6`, m.Host, m.Port, m.TLS, m.Username, m.Folder, user(r))
|
|
if e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
// No configuration exists to keep a password from (or it was
|
|
// removed concurrently): initial creation requires a real one.
|
|
respond(w, 0, nil, bad("Password required"))
|
|
return
|
|
}
|
|
respond(w, 200, OK{true}, nil)
|
|
return
|
|
}
|
|
pw, e := crypt.Encrypt(m.Password, config.Load().EncryptionSecret())
|
|
if e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
_, e = database(r).ExecContext(r.Context(), `INSERT INTO "ImapConfig"(id,host,port,tls,username,password,folder,"userId") VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT("userId") DO UPDATE SET host=EXCLUDED.host,port=EXCLUDED.port,tls=EXCLUDED.tls,username=EXCLUDED.username,password=EXCLUDED.password,folder=EXCLUDED.folder`, db.NewID(), m.Host, m.Port, m.TLS, m.Username, pw, m.Folder, user(r))
|
|
respond(w, 200, OK{true}, e)
|
|
}
|
|
func HandleImapTest(w http.ResponseWriter, r *http.Request) {
|
|
b, ok := read(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
m, e := mailboxData(b)
|
|
if e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
if m.Password == "" || m.Password == "••••••••" {
|
|
saved, e := signals.LoadMailbox(r.Context(), database(r), user(r))
|
|
if errors.Is(e, db.ErrNotFound) {
|
|
respond(w, 0, nil, bad("Password required"))
|
|
return
|
|
}
|
|
if e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
m.Password = saved.Password
|
|
}
|
|
release, retry, ok := outbound.Reserve("imap:"+user(r), 10, time.Minute)
|
|
if !ok {
|
|
outboundBusy(w, retry)
|
|
return
|
|
}
|
|
defer release()
|
|
c, e := signals.Connect(r.Context(), m)
|
|
if e != nil {
|
|
respond(w, 0, nil, bad("Mailbox connection or login failed"))
|
|
return
|
|
}
|
|
// Review M-2: a hostile server must not hold the handler open during the
|
|
// goodbye exchange after the test has already succeeded.
|
|
c.Timeout = 5 * time.Second
|
|
c.Logout()
|
|
respond(w, 200, OK{true}, nil)
|
|
}
|
|
func HandleImapCheck(w http.ResponseWriter, r *http.Request) {
|
|
// Bounded, single-connection pass: the watched-bookmark query is capped and
|
|
// all searches reuse one IMAP login under an overall deadline (matches the
|
|
// scheduler's 45s budget) instead of one fresh connection per bookmark.
|
|
bms, e := db.Many(r.Context(), database(r), `SELECT to_jsonb(b) FROM "Bookmark" b WHERE "ownerId"=$1 AND "imapWatchEnabled" ORDER BY "createdAt" LIMIT 50`, user(r))
|
|
if e != nil {
|
|
respond(w, 0, nil, e)
|
|
return
|
|
}
|
|
matches := []object{}
|
|
watched := make([]object, 0, len(bms))
|
|
for _, b := range bms {
|
|
if str(b, "imapQuery") != "" {
|
|
watched = append(watched, b)
|
|
}
|
|
}
|
|
if len(watched) > 0 {
|
|
m, e := signals.LoadMailbox(r.Context(), database(r), user(r))
|
|
if e != nil {
|
|
respond(w, 0, nil, bad("Configure IMAP first"))
|
|
return
|
|
}
|
|
release, retry, ok := outbound.Reserve("imap:"+user(r), 10, time.Minute)
|
|
if !ok {
|
|
outboundBusy(w, retry)
|
|
return
|
|
}
|
|
defer release()
|
|
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
|
defer cancel()
|
|
c, e := signals.Connect(ctx, m)
|
|
if e != nil {
|
|
respond(w, 0, nil, bad("Mailbox connection or login failed"))
|
|
return
|
|
}
|
|
stop := context.AfterFunc(ctx, func() { c.Terminate() })
|
|
defer stop()
|
|
// Keep the goodbye exchange bounded too (review M-2); searches above
|
|
// stay under the caller's 45-second context.
|
|
defer func() { c.Timeout = 5 * time.Second; c.Logout() }()
|
|
searchFailed := false
|
|
for _, b := range watched {
|
|
events, e := signals.SearchConn(c, m, "", str(b, "imapQuery"))
|
|
// A search can fail after returning partial events (review
|
|
// follow-up). Surface those matches and the failure together;
|
|
// only a failure with nothing found stays a plain 400.
|
|
for _, event := range events {
|
|
matches = append(matches, object{"bookmarkId": b["id"], "title": event.Title, "from": event.Summary, "externalId": event.ExternalID, "occurredAt": event.OccurredAt})
|
|
}
|
|
if e != nil {
|
|
searchFailed = true
|
|
break
|
|
}
|
|
}
|
|
if searchFailed {
|
|
if len(matches) == 0 {
|
|
respond(w, 0, nil, bad("Mailbox search failed"))
|
|
return
|
|
}
|
|
respond(w, 200, object{"matches": matches, "error": "Mailbox search failed (partial results)"}, nil)
|
|
return
|
|
}
|
|
}
|
|
respond(w, 200, object{"matches": matches}, nil)
|
|
}
|