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 := `` 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) }