package proxy
import (
"bufio"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
"sync"
"time"
"notion-manager/internal/netutil"
)
const (
notionOrigin = "https://app.notion.com"
notionReferer = notionOrigin + "/"
msgstoreHost = "msgstore.app.notion.com"
msgstoreOrigin = "https://" + msgstoreHost
maxProxyRedirectHops = 3
)
// Strip analytics/tracking script/noscript tags from HTML
var reAnalyticsScript = regexp.MustCompile(`(?s)<(?:script|noscript)[^>]*>.*?(?:googletagmanager\.com|customer\.io|gtag/js).*?(?:script|noscript)>`)
var reAllowedMsgstoreHost = regexp.MustCompile(`(?i)^msgstore(?:-[a-z0-9-]+)?\.(?:www\.notion\.so|app\.notion\.com)$`)
// ProxySession maps a proxy session cookie to a pooled account
type ProxySession struct {
Account *Account
CreatedAt time.Time
CookieJar http.CookieJar
}
// ReverseProxy proxies requests to notion.so with session/cookie injection
type ReverseProxy struct {
pool *AccountPool
sessions sync.Map // sessionID → *ProxySession
msgTransport http.RoundTripper
}
// NewReverseProxy creates a reverse proxy backed by the given account pool
func NewReverseProxy(pool *AccountPool) *ReverseProxy {
return &ReverseProxy{
pool: pool,
// Engine.IO requires sticky sessions: AWS ALB uses AWSALBAPP-0 cookie.
// Each ProxySession's CookieJar stores those cookies independently while
// this transport remains shared for connection reuse.
// DialContext routes through AppConfig.Proxy.NotionProxy at dial
// time so a /admin/settings flip applies to new msgstore
// connections without restarting the process.
msgTransport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return netutil.DialThroughProxy(ctx, network, addr, AppConfig.NotionProxyURL())
},
ForceAttemptHTTP2: false,
TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper),
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
}
func newProxySession(acc *Account) *ProxySession {
jar, _ := cookiejar.New(nil)
return &ProxySession{
Account: acc,
CreatedAt: time.Now(),
CookieJar: jar,
}
}
var safeFullCookieSeeds = map[string]bool{
"notion_check_cookie_consent": true,
"notion_cookie_sync_completed": true,
"notion_locale": true,
}
func isTransientSessionCookie(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
return strings.Contains(name, "sync_session") || strings.HasPrefix(name, "session_sync_")
}
func parseCookieHeader(raw string) []*http.Cookie {
if strings.TrimSpace(raw) == "" {
return nil
}
req := &http.Request{Header: make(http.Header)}
req.Header.Set("Cookie", raw)
return req.Cookies()
}
func accountSeedCookies(acc *Account) []*http.Cookie {
if acc == nil {
return nil
}
acc.mu.RLock()
defer acc.mu.RUnlock()
values := []struct {
name string
value string
}{
{name: "token_v2", value: acc.TokenV2},
{name: "notion_user_id", value: acc.UserID},
{name: "notion_users", value: notionUsersCookieValue(acc.UserID)},
{name: "notion_browser_id", value: acc.BrowserID},
{name: "device_id", value: acc.DeviceID},
}
cookies := make([]*http.Cookie, 0, len(values)+len(safeFullCookieSeeds))
seen := make(map[string]bool, len(values)+len(safeFullCookieSeeds))
for _, item := range values {
if item.value == "" {
continue
}
cookies = append(cookies, &http.Cookie{Name: item.name, Value: item.value})
seen[item.name] = true
}
// FullCookie can contain stale session-sync state. Only copy explicitly
// stable, non-authentication preferences; Account fields above remain the
// source of truth, so token_v2 and identity cookies can never be replaced.
for _, cookie := range parseCookieHeader(acc.FullCookie) {
name := strings.ToLower(cookie.Name)
if isTransientSessionCookie(name) || !safeFullCookieSeeds[name] || seen[name] {
continue
}
cookies = append(cookies, &http.Cookie{Name: cookie.Name, Value: cookie.Value})
seen[name] = true
}
return cookies
}
func notionUsersCookieValue(userID string) string {
if userID == "" {
return ""
}
users, err := json.Marshal([]string{userID})
if err != nil {
return ""
}
return url.PathEscape(string(users))
}
// accountCookieHeader returns the stable Notion cookie seed for an account.
func accountCookieHeader(acc *Account) string {
cookies := accountSeedCookies(acc)
parts := make([]string, 0, len(cookies))
for _, cookie := range cookies {
parts = append(parts, cookie.String())
}
return strings.Join(parts, "; ")
}
func setProxySessionCookies(req *http.Request, sess *ProxySession) {
req.Header.Del("Cookie")
jarNames := make(map[string]bool)
if sess != nil && sess.CookieJar != nil {
for _, cookie := range sess.CookieJar.Cookies(req.URL) {
jarNames[cookie.Name] = true
}
}
if sess == nil {
return
}
for _, cookie := range accountSeedCookies(sess.Account) {
if !jarNames[cookie.Name] {
req.AddCookie(cookie)
}
}
}
func proxySessionCookieHeader(sess *ProxySession, targetURL *url.URL) string {
req := &http.Request{Header: make(http.Header), URL: targetURL}
setProxySessionCookies(req, sess)
if sess != nil && sess.CookieJar != nil {
for _, cookie := range sess.CookieJar.Cookies(targetURL) {
req.AddCookie(cookie)
}
}
return req.Header.Get("Cookie")
}
func reverseProxyHTTPClient(timeout time.Duration, sess *ProxySession) *http.Client {
return newReverseProxyHTTPClient(timeout, getChromeRoundTripper(), sess)
}
func newReverseProxyHTTPClient(timeout time.Duration, transport http.RoundTripper, sess *ProxySession) *http.Client {
var jar http.CookieJar
if sess != nil {
jar = sess.CookieJar
}
return &http.Client{
Transport: transport,
Timeout: timeout,
Jar: jar,
CheckRedirect: reverseProxyCheckRedirect(sess),
}
}
func reverseProxyCheckRedirect(sess *ProxySession) func(*http.Request, []*http.Request) error {
return func(req *http.Request, via []*http.Request) error {
// The standard client may copy sensitive headers before CheckRedirect.
// Remove Cookie first so blocked destinations can never receive it.
req.Header.Del("Cookie")
if len(via) > maxProxyRedirectHops {
return fmt.Errorf("reverse proxy redirect limit exceeded: %d hops", maxProxyRedirectHops)
}
if req.URL.Scheme != "https" || !isAllowedNotionRedirectHost(req.URL.Hostname()) {
return fmt.Errorf("reverse proxy redirect blocked: %s", req.URL.Redacted())
}
for _, previous := range via {
if previous.URL.String() == req.URL.String() {
return fmt.Errorf("reverse proxy redirect loop blocked: %s", req.URL.Redacted())
}
}
req.Host = req.URL.Host
setProxySessionCookies(req, sess)
return nil
}
}
func isAllowedNotionRedirectHost(host string) bool {
return strings.EqualFold(host, "www.notion.so") || strings.EqualFold(host, "app.notion.com")
}
// getSession retrieves an existing session for the request.
// Sessions are created exclusively via /proxy/start (dashboard account selection).
// Returns nil if no valid session exists — caller should redirect to /dashboard/.
func (rp *ReverseProxy) getSession(r *http.Request) *ProxySession {
if c, err := r.Cookie("np_session"); err == nil {
if s, ok := rp.sessions.Load(c.Value); ok {
return s.(*ProxySession)
}
}
return nil
}
// configPatchScript returns JS that:
// 1. Sets all notion cookies via document.cookie (before SPA reads them)
// 2. Intercepts window.CONFIG assignment to patch URLs
// 3. Unregisters Service Workers
func configPatchScript(origin string, acc *Account) string {
// Build cookie-setting JS from full_cookie string
cookieJS := ""
for _, part := range strings.Split(accountCookieHeader(acc), ";") {
part = strings.TrimSpace(part)
if part != "" {
cookieJS += fmt.Sprintf(`document.cookie=%q+";path=/";`, part)
}
}
return fmt.Sprintf(``, cookieJS, origin)
}
func (rp *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Static assets: no auth needed, passthrough to notion.so CDN
if strings.HasPrefix(path, "/_assets/") ||
strings.HasPrefix(path, "/images/") ||
path == "/sw.js" ||
path == "/favicon.ico" {
rpProxyPassthrough(w, r, notionOrigin)
return
}
// Msgstore proxy via /_msgproxy/{targetHost}/...
if strings.HasPrefix(path, "/_msgproxy/") {
rest := strings.TrimPrefix(path, "/_msgproxy/")
slashIdx := strings.Index(rest, "/")
if slashIdx == -1 {
http.Error(w, "invalid proxy path", http.StatusBadRequest)
return
}
targetHost := rest[:slashIdx]
targetPath := rest[slashIdx:]
sess := rp.getSession(r)
if sess == nil {
http.NotFound(w, r)
return
}
if isWebSocketUpgrade(r) {
rp.proxyWebSocket(w, r, sess, targetHost, targetPath)
return
}
rp.proxyMsgstoreHTTP(w, r, sess, targetHost, targetPath)
return
}
// Ping: no session needed, return simple OK for GET
if path == "/api/v3/ping" && r.Method == "GET" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
w.Write([]byte(`{}`))
return
}
// All other routes need a session (created via /proxy/start from dashboard)
sess := rp.getSession(r)
if sess == nil {
http.NotFound(w, r)
return
}
// MessageStore proxy (real-time sync)
// Primus strips path from messageStore.url and uses origin + /primus-v8/
if strings.HasPrefix(path, "/primus-v8/") || strings.HasPrefix(path, "/msgstore/") {
targetHost := msgstoreHost
targetPath := path
if strings.HasPrefix(path, "/msgstore/") {
targetPath = strings.TrimPrefix(path, "/msgstore")
}
if isWebSocketUpgrade(r) {
rp.proxyWebSocket(w, r, sess, targetHost, targetPath)
return
}
rp.proxyMsgstoreHTTP(w, r, sess, targetHost, targetPath)
return
}
// Image proxy: rewrite embedded localhost URLs back to www.notion.so
if strings.HasPrefix(path, "/image/") {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
proxyOrigin := scheme + "://" + r.Host
// Fix embedded URL: replace proxy origin with notion origin
fixedURI := strings.ReplaceAll(r.URL.RequestURI(), url.PathEscape(proxyOrigin), url.PathEscape(notionOrigin))
fixedURI = strings.ReplaceAll(fixedURI, url.QueryEscape(proxyOrigin), url.QueryEscape(notionOrigin))
r.URL, _ = url.Parse(fixedURI)
rp.proxyGeneric(w, r, sess)
return
}
// API proxy (with notion-specific headers)
if strings.HasPrefix(path, "/api/") {
rp.proxyAPI(w, r, sess)
return
}
// HTML pages that need CONFIG injection
if path == "/ai" || strings.HasPrefix(path, "/chat") {
rp.proxyHTML(w, r, sess)
return
}
// Everything else: proxy with cookies, no HTML injection
rp.proxyGeneric(w, r, sess)
}
// proxyHTML fetches an HTML page, injects CONFIG patch, strips security headers
func (rp *ReverseProxy) proxyHTML(w http.ResponseWriter, r *http.Request, sess *ProxySession) {
targetURL := notionOrigin + r.URL.RequestURI()
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
req.Header.Set("User-Agent", AppConfig.Browser.UserAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
if al := r.Header.Get("Accept-Language"); al != "" {
req.Header.Set("Accept-Language", al)
}
setProxySessionCookies(req, sess)
// Deliberately omit Accept-Encoding so we get uncompressed HTML for patching
client := reverseProxyHTTPClient(30*time.Second, sess)
resp, err := client.Do(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
// Determine proxy origin from the incoming request
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
origin := scheme + "://" + r.Host
html := string(body)
// Strip analytics/tracking scripts (GTM, customer.io) to prevent connection errors
html = reAnalyticsScript.ReplaceAllString(html, "")
// Inject CONFIG interceptor before the very first