Files
MeBox/internal/service/reader/browser_panel.go
T
2026-10-01 21:57:25 +08:00

825 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package reader
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/service/reader/rule"
)
// 本文件:书源 JS 的宿主浏览器(java.startBrowser / java.startBrowserAwait)。
//
// legado 用内置 WebView 承载页面:书源在自己的 JS 里拼一段 HTML(线路设置页
// 之类)或给一个网址,让用户点选、登录、过防爬校验,然后(Await 版本)把用户
// 操作后的页面源码当成 StrResponse 返回,书源再从中解析出结果写回源变量。
//
// 服务端没有 WebView,于是把这一段拆成三步:
// 1. JS 侧 startBrowserAwait 登记一个待办并阻塞等待;
// 2. 前端轮询到待办,在 <iframe> 里承载页面(同源,因此可以直接读回 DOM);
// 3. 用户点 √ 后把 iframe 的 DOM 回传,阻塞解除,JS 拿到 body()。
//
// 页面来源分两种,承载方式不同:
// - data:text/html;base64,…:书源自带的内嵌页面,直接解码返回;
// - http(s):服务端带书源 Cookie/请求头抓取后返回。这一步很关键——书源的
// 登录态存在服务端,浏览器里没有这些 Cookie,若直接让浏览器打开源站,
// 「用户后台」这类页面必然是未登录状态。页面里的资源地址会改写到同源
// 代理,使后续请求同样带上 Cookie。
const (
// browserAwaitTimeout 单次等待用户操作的上限。
browserAwaitTimeout = 10 * time.Minute
// browserPageTTL 待办条目与页面链接的有效期。
browserPageTTL = 15 * time.Minute
// browserMaxPageBytes 服务端抓取页面/资源的大小上限。
browserMaxPageBytes = 4 << 20
// browserFetchTimeout 抓取待承载页面/资源的超时。
browserFetchTimeout = 20 * time.Second
// browserModeWait 需要回传 DOM(startBrowserAwait)。
browserModeWait = "wait"
// browserModeOpen 只展示(startBrowser / showBrowser)。
browserModeOpen = "open"
)
// BrowserPage 前端待承载的一个页面(登录对话框轮询用)。
type BrowserPage struct {
ID string `json:"id"`
Title string `json:"title"`
// Mode wait = 需回传(点 √ 后回传 DOM);open = 仅展示。
Mode string `json:"mode"`
// PageURL 同源承载地址(iframe src),带 HMAC 签名。
PageURL string `json:"page_url"`
// Refetch 见 rule.BrowserTask.Refetch。
Refetch bool `json:"refetch"`
// SourceID 发起该请求的书源。
SourceID string `json:"source_id"`
// TargetURL 原始地址(data: 与超长地址不回传)。
TargetURL string `json:"target_url,omitempty"`
}
// BrowserPageSnapshot 承载页面时返回的数据。
type BrowserPageSnapshot struct {
ID string
HTML string
SourceID string
FinalURL string
}
// pendingBrowser 一个待用户完成的页面。
type pendingBrowser struct {
id string
sourceID string
sourceURL string
userID string
request rule.BrowserTask
// html 已就绪的页面源码(data: 直接解码;http 抓取后资源地址已改写)。
html string
finalURL string
mode string
expires time.Time
done chan struct{}
mu sync.Mutex
result rule.BrowserResult
err error
finished bool
}
// browserHost 把服务层的浏览器面板绑定到某个书源会话(实现 rule.BrowserHost)。
type browserHost struct {
svc *ReaderService
sourceURL string
sourceID string
userID string
}
func (h *browserHost) AwaitBrowser(ctx context.Context, req rule.BrowserTask) (rule.BrowserResult, error) {
return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
}
func (h *browserHost) OpenBrowser(ctx context.Context, req rule.BrowserTask) error {
return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
}
// ─── 服务层入口 ────────────────────────────────────────────────────────────
// awaitBrowser 登记待办并阻塞等待用户回传页面内容。
func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) (rule.BrowserResult, error) {
entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeWait)
if err != nil {
return rule.BrowserResult{}, err
}
defer s.dropBrowser(entry.id)
timeout := time.NewTimer(browserAwaitTimeout)
defer timeout.Stop()
select {
case <-entry.done:
entry.mu.Lock()
defer entry.mu.Unlock()
if entry.err != nil {
return rule.BrowserResult{}, entry.err
}
return entry.result, nil
case <-ctx.Done():
return rule.BrowserResult{}, ctx.Err()
case <-timeout.C:
return rule.BrowserResult{}, fmt.Errorf("等待页面操作超时(%s),请重试", browserAwaitTimeout)
}
}
// openBrowser 登记待办但不等待(页面展示给用户即可)。
func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) error {
_, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeOpen)
return err
}
// registerBrowser 准备页面内容并登记待办。
func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
// 先分配 ID:页面里的资源代理地址需要用它签名。
id := newBrowserID()
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, req)
if err != nil {
return nil, err
}
if finalURL == "" {
finalURL = req.URL
}
entry := &pendingBrowser{
id: id,
sourceID: sourceID,
sourceURL: sourceURL,
userID: userID,
request: req,
html: html,
finalURL: finalURL,
mode: mode,
expires: time.Now().Add(browserPageTTL),
done: make(chan struct{}),
}
s.browserMu.Lock()
if s.browserPending == nil {
s.browserPending = map[string]*pendingBrowser{}
}
s.pruneBrowsersLocked()
s.browserPending[id] = entry
s.browserMu.Unlock()
if s.log != nil {
s.log.Info("reader:待用户完成页面操作",
zap.String("source", sourceID), zap.String("mode", mode), zap.String("title", req.Title))
}
return entry, nil
}
// browserBridgeScript 注入到承载页面里:父窗口请求时回传当前 DOM,把页面自己的
// fetch / XMLHttpRequest 转交给父窗口代为请求,并补上沙箱里不可用的存储对象。
//
// 为什么不让父窗口直接读 contentDocument:那要求 iframe 同源,而书源页面是
// 第三方 HTML,拿到同源权限就能读写 MeBox 自己的页面与 localStorage(JWT)。
// 改成 postMessage 后,iframe 可以只开 allow-scripts(不透明源)。
//
// 为什么要补存储对象:不透明源里访问 localStorage / sessionStorage / document.cookie
// 会直接抛 SecurityError,SPA 类的「用户后台」在启动读 token 时就崩了,
// 表现是一直停在「正在安全加载」。这里用内存版兜住,既不给出同源权限,
// 又让页面能正常跑完启动流程。
//
// 为什么要代发请求:iframe 是不透明源,页面自己的 XHR 既带不上书源 Cookie,
// 也会被 CORS 拦掉——靠接口取数的页面会取不到数据。
const browserBridgeScript = `<script data-mebox-browser-bridge="1">(function(){` +
`if(window.__meboxBridgeInstalled){return}window.__meboxBridgeInstalled=true;` +
// ── 沙箱内存储兜底 ──
`function memStore(){var d={};return{` +
`getItem:function(k){k=String(k);return Object.prototype.hasOwnProperty.call(d,k)?d[k]:null},` +
`setItem:function(k,v){d[String(k)]=String(v)},` +
`removeItem:function(k){delete d[String(k)]},` +
`clear:function(){d={}},` +
`key:function(i){var ks=Object.keys(d);return i>=0&&i<ks.length?ks[i]:null},` +
`get length(){return Object.keys(d).length}}}` +
`function patchStore(n){try{var s=memStore();Object.defineProperty(window,n,{configurable:true,get:function(){return s}})}catch(e){}}` +
`patchStore('localStorage');patchStore('sessionStorage');` +
// document.cookie 也要给:这类页面常用它判断是否已登录(真实 Cookie 在服务端,
// 浏览器里没有)。把书源在该站点的 Cookie 预置进去,页面才会正常渲染。
`var ck={};` +
`(function(seed){String(seed||'').split(';').forEach(function(p){var i=p.indexOf('=');if(i>0){ck[p.slice(0,i).trim()]=p.slice(i+1).trim()}})})` + "(`__MEBOX_COOKIE__`)" + `;` +
`try{Object.defineProperty(document,'cookie',{configurable:true,` +
`get:function(){var o=[];for(var k in ck){o.push(k+'='+ck[k])}return o.join('; ')},` +
`set:function(v){try{var p=String(v).split(';')[0].split('=');if(p[0]){ck[p[0].trim()]=p.slice(1).join('=')}}catch(e){}}})}catch(e){}` +
// 书源页面自身的基地址:相对地址要按它解析,而不是按承载地址(我们自己的源)
`var BASE=` + "`__MEBOX_BASE_URL__`" + `;` +
`function abs(u){try{return new URL(u,BASE||location.href).href}catch(e){try{return new URL(u,location.href).href}catch(e2){return u}}}` +
// ── DOM 回传 ──
`window.addEventListener('message',function(e){` +
`if(!e||e.data!=='__mebox_dom__'){return}` +
`try{parent.postMessage({__mebox_dom__:true,html:document.documentElement.outerHTML},'*')}catch(err){}` +
`},false);` +
// ── 请求代理(父窗口持有登录态,由它转交服务端补 Cookie) ──
`var seq=0,waiters={};` +
`window.addEventListener('message',function(e){` +
`var d=e&&e.data;` +
`if(!d||d.__mebox_proxy_res__!==true){return}` +
`var w=waiters[d.reqId];` +
`if(!w){return}delete waiters[d.reqId];w(d);` +
`},false);` +
`function rpc(method,url,headers,body){` +
`return new Promise(function(resolve,reject){` +
`var id='r'+(++seq);waiters[id]=resolve;` +
`try{parent.postMessage({__mebox_proxy__:true,reqId:id,url:url,method:method,headers:headers||{},body:body||''},'*')}catch(e){delete waiters[id];reject(e);return}` +
`setTimeout(function(){if(waiters[id]){delete waiters[id];reject(new Error('mebox proxy timeout'))}},60000);` +
`})}` +
`function decode(res){return res&&res.base64?atob(res.body||''):((res&&res.body)||'')}` +
`var origFetch=window.fetch;` +
`if(origFetch){window.fetch=function(input,init){` +
`init=init||{};` +
`var url=typeof input==='string'?input:(input&&input.url);` +
`if(!url){return origFetch.apply(this,arguments)}` +
`var method=String(init.method||(input&&input.method)||'GET').toUpperCase();` +
`var headers={};` +
`try{new Headers(init.headers||(input&&input.headers)||{}).forEach(function(v,k){headers[k]=v})}catch(e){}` +
`var body=init.body?String(init.body):'';` +
`return rpc(method,abs(url),headers,body).then(function(res){` +
`return new Response(decode(res),{status:(res&&res.status)||200,headers:{'Content-Type':(res&&res.contentType)||'text/plain'}})` +
`})}}` +
`var OrigXHR=window.XMLHttpRequest;` +
`function ProxyXHR(){this._h={};this._m='GET';this._u='';this.readyState=0;this.status=0;this.response=null;this.responseText='';this.responseType=''}` +
`ProxyXHR.prototype.open=function(m,u){this._m=m;this._u=u;this.readyState=1;this._fire('onreadystatechange')};` +
`ProxyXHR.prototype.setRequestHeader=function(k,v){this._h[k]=v};` +
`ProxyXHR.prototype._fire=function(n){try{if(this[n])this[n]()}catch(e){}};` +
`ProxyXHR.prototype.send=function(body){` +
`var self=this;` +
`rpc(String(this._m).toUpperCase(),abs(this._u),this._h,body?String(body):'').then(function(res){` +
`self.status=(res&&res.status)||200;self.readyState=4;self.responseText=decode(res);` +
`if(self.responseType==='json'){try{self.response=JSON.parse(self.responseText)}catch(e){self.response=null}}else{self.response=self.responseText}` +
`self._fire('onreadystatechange');self._fire('onload')` +
`},function(){self.status=0;self.readyState=4;self._fire('onreadystatechange');self._fire('onerror')})};` +
`ProxyXHR.prototype.getResponseHeader=function(){return null};` +
`ProxyXHR.prototype.getAllResponseHeaders=function(){return ''};` +
`ProxyXHR.prototype.abort=function(){};` +
`window.XMLHttpRequest=ProxyXHR;` +
`})();</script>`
// injectBrowserBridge 把回传/代理脚本插进页面 <head>(没有 head 就插在最前面),
// 并把书源页面的基地址与 Cookie 填进脚本:前者供相对地址解析,后者供页面判断
// 登录态(真实 Cookie 在服务端,页面看不到)。
func injectBrowserBridge(html, baseURL, cookie string) string {
if strings.Contains(html, `data-mebox-browser-bridge`) {
return html
}
script := strings.ReplaceAll(browserBridgeScript, "__MEBOX_BASE_URL__", jsStringEscape(baseURL))
script = strings.ReplaceAll(script, "__MEBOX_COOKIE__", jsStringEscape(cookie))
lower := strings.ToLower(html)
if i := strings.Index(lower, "<head>"); i >= 0 {
pos := i + len("<head>")
return html[:pos] + script + html[pos:]
}
if i := strings.Index(lower, "<html"); i >= 0 {
if j := strings.Index(lower[i:], ">"); j >= 0 {
pos := i + j + 1
return html[:pos] + script + html[pos:]
}
}
return script + html
}
// jsStringEscape 转义要嵌进 JS 模板字符串的地址(反引号 / 反斜杠 / ${)。
func jsStringEscape(s string) string {
r := strings.NewReplacer("\\", "\\\\", "`", "\\`", "${", "\\${")
return r.Replace(s)
}
// prepareBrowserPage 得到 iframe 要承载的页面源码。
//
// 返回的页面里会注入回传/代理脚本;脚本需要两样东西:
// - 基地址:解析页面里的相对地址(书源自带的 data: 页面没有真实地址,
// 退化成书源站点地址——书源自己的 BaseUrl() 也指向它);
// - Cookie:页面常用 document.cookie 判断登录态,而真实 Cookie 在服务端,
// 不预置的话「用户后台」会以为未登录并把浏览器导到 /login。
func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, req rule.BrowserTask) (string, string, error) {
// 1) 书源自带 HTML(显式 html 参数或 data: URL)
if html := strings.TrimSpace(req.HTML); html != "" {
return injectBrowserBridge(html, sourceURL, s.browserCookieHeader(ctx, sourceURL, sourceURL)), req.URL, nil
}
if html := rule.ParseDataHTML(req.URL); html != "" {
return injectBrowserBridge(html, sourceURL, s.browserCookieHeader(ctx, sourceURL, sourceURL)), req.URL, nil
}
// 2) 外部地址:服务端带书源 Cookie 抓取,并把资源地址改写到代理
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
return "", "", fmt.Errorf("无法承载该地址: %s", truncateForLog(req.URL, 120))
}
body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, req.URL)
if err != nil {
return "", "", fmt.Errorf("打开页面失败: %w", err)
}
cookie := s.browserCookieHeader(ctx, sourceURL, finalURL)
if !strings.Contains(strings.ToLower(contentType), "html") {
// 非 HTML(如 JSON 接口):包一层 <pre>,至少让用户看到内容
wrapped := "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>" + htmlEscape(req.Title) +
"</title></head><body><pre style=\"white-space:pre-wrap;word-break:break-all;padding:16px;" +
"font:13px/1.6 ui-monospace,monospace\">" + htmlEscape(body) + "</pre></body></html>"
return injectBrowserBridge(wrapped, finalURL, cookie), finalURL, nil
}
return injectBrowserBridge(s.rewriteBrowserHTML(body, finalURL, id), finalURL, cookie), finalURL, nil
}
// browserCookieHeader 取书源在目标站点上的 Cookie 串(页面里预置 document.cookie 用)。
func (s *ReaderService) browserCookieHeader(ctx context.Context, sourceURL, target string) string {
if s.repo == nil || sourceURL == "" {
return ""
}
return s.newSourceState(ctx, sourceURL).CookieForRequest(target)
}
// fetchBrowserPage 服务端抓取页面(附带书源 Cookie / 登录请求头 / 书源请求头)。
func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target string) (string, string, string, error) {
contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, target)
if err != nil {
return "", "", "", err
}
return string(data), finalURL, contentType, nil
}
// FetchBrowserAsset 代理拉取页面资源(带书源 Cookie/请求头),供 iframe 内引用。
// 返回 (contentType, status, body, error)。
func (s *ReaderService) FetchBrowserAsset(ctx context.Context, sourceURL, target string) (string, int, []byte, error) {
contentType, status, data, _, err := s.requestBrowserResource(ctx, sourceURL, target)
return contentType, status, data, err
}
// requestBrowserResource 带书源凭据请求一个外部地址。
func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, target string) (string, int, []byte, string, error) {
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
defer cancel()
state := s.newSourceState(reqCtx, sourceURL)
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
if err != nil {
return "", 0, nil, "", err
}
for k, v := range state.LoginHeaderMap() {
if strings.EqualFold(k, "cookie") {
continue
}
httpReq.Header.Set(k, v)
}
if ck := state.CookieForRequest(target); ck != "" {
httpReq.Header.Set("Cookie", ck)
}
// 书源级请求头(含 Referer / UA),与阅读请求保持一致
if s.repo != nil {
if src, findErr := s.repo.GetSourceByURL(reqCtx, sourceURL); findErr == nil && src != nil && src.Header != "" {
var headers map[string]any
if json.Unmarshal([]byte(src.Header), &headers) == nil {
for k, v := range headers {
if httpReq.Header.Get(k) == "" {
httpReq.Header.Set(k, fmt.Sprintf("%v", v))
}
}
}
}
}
// Accept-Encoding 交给 net/http 管:显式设置会让它放弃自动解压,
// 压缩过的页面/资源就会以原始字节回到 iframe(页面直接白屏或乱码)。
helper.StripAcceptEncoding(httpReq.Header)
resp, err := s.http.Do(httpReq)
if err != nil {
return "", 0, nil, "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, browserMaxPageBytes))
if err != nil {
return "", 0, nil, "", err
}
data = helper.DecompressBody(resp, data)
finalURL := target
if resp.Request != nil && resp.Request.URL != nil {
finalURL = resp.Request.URL.String()
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
return contentType, resp.StatusCode, data, finalURL, nil
}
// BrowserXHRResult 页面内 fetch/XHR 经服务端转发后的响应。
type BrowserXHRResult struct {
Status int `json:"status"`
ContentType string `json:"content_type"`
Body string `json:"body"`
// Base64 为真时 Body 是 base64(二进制资源)。
Base64 bool `json:"base64"`
}
// browserXHRMaxBytes 转发接口响应的大小上限。
const browserXHRMaxBytes = 4 << 20
// ProxyBrowserXHR 以书源身份转发页面内的接口请求。
//
// 承载页面是不透明源,页面自己的 XHR 既带不上书源 Cookie 也会被 CORS 拦掉,
// 所以由父窗口把请求转交进来,这里补上书源凭据再发出去。
// 返回的 HTTP 状态/内容类型原样回给页面,让页面自己的逻辑能正常分支。
func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target string, headers map[string]string, body string) (*BrowserXHRResult, error) {
entry := s.lookupBrowser(id)
if entry == nil {
return nil, errors.New("页面已过期,请重新打开")
}
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
return nil, fmt.Errorf("仅支持 http(s) 地址: %s", truncateForLog(target, 120))
}
if method == "" {
method = http.MethodGet
}
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
defer cancel()
httpReq, err := http.NewRequestWithContext(reqCtx, strings.ToUpper(method), target, strings.NewReader(body))
if err != nil {
return nil, err
}
// 先放页面自己声明的头,再用书源凭据补缺(Cookie 始终以服务端为准)
for k, v := range headers {
if strings.EqualFold(k, "cookie") || strings.EqualFold(k, "host") ||
strings.EqualFold(k, "content-length") {
continue
}
httpReq.Header.Set(k, v)
}
state := s.newSourceState(reqCtx, entry.sourceURL)
for k, v := range state.LoginHeaderMap() {
if strings.EqualFold(k, "cookie") {
continue
}
if httpReq.Header.Get(k) == "" {
httpReq.Header.Set(k, v)
}
}
if httpReq.Header.Get("Cookie") == "" {
if ck := state.CookieForRequest(target); ck != "" {
httpReq.Header.Set("Cookie", ck)
}
}
if s.repo != nil {
if src, findErr := s.repo.GetSourceByURL(reqCtx, entry.sourceURL); findErr == nil && src != nil && src.Header != "" {
var extra map[string]any
if json.Unmarshal([]byte(src.Header), &extra) == nil {
for k, v := range extra {
if httpReq.Header.Get(k) == "" {
httpReq.Header.Set(k, fmt.Sprintf("%v", v))
}
}
}
}
}
helper.StripAcceptEncoding(httpReq.Header)
resp, err := s.http.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, browserXHRMaxBytes))
if err != nil {
return nil, err
}
data = helper.DecompressBody(resp, data)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "text/plain"
}
out := &BrowserXHRResult{Status: resp.StatusCode, ContentType: contentType}
if isTextualContent(contentType) {
out.Body = string(data)
} else {
out.Body = base64.StdEncoding.EncodeToString(data)
out.Base64 = true
}
return out, nil
}
// isTextualContent 判断响应是否可以直接当字符串交给页面。
func isTextualContent(contentType string) bool {
ct := strings.ToLower(contentType)
for _, p := range []string{"text/", "json", "javascript", "xml", "html", "x-www-form-urlencoded", "csv"} {
if strings.Contains(ct, p) {
return true
}
}
return false
}
// ─── 资源改写与代理 ────────────────────────────────────────────────────────
// cssURLRe 匹配 CSS 里的 url(...) 引用。
// Go 的 regexp 不支持反向引用,因此引号用可选的成对字符类近似匹配。
var cssURLRe = regexp.MustCompile(`url\(\s*['"]?([^'")]+)['"]?\s*\)`)
// rewriteBrowserHTML 把页面里的资源/表单/站内链接地址改写到同源代理,
// 使页面在 iframe 里的后续请求同样带上书源 Cookie。
func (s *ReaderService) rewriteBrowserHTML(body, baseURL, id string) string {
base, err := url.Parse(baseURL)
if err != nil {
return body
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
if err != nil {
return body
}
rewrite := func(sel, attr string) {
doc.Find(sel).Each(func(_ int, node *goquery.Selection) {
val, ok := node.Attr(attr)
if !ok || strings.TrimSpace(val) == "" {
return
}
if abs := absoluteBrowserURL(base, val); abs != "" {
node.SetAttr(attr, s.browserAssetProxyURL(id, abs))
}
})
}
rewrite("img[src]", "src")
rewrite("script[src]", "src")
rewrite("link[href]", "href")
rewrite("iframe[src]", "src")
rewrite("source[src]", "src")
rewrite("video[src]", "src")
rewrite("audio[src]", "src")
// 表单提交也走代理,避免 POST 丢掉 Cookie
rewrite("form[action]", "action")
// 站内链接走代理;站外链接保留(用户可能确实想出去)
doc.Find("a[href]").Each(func(_ int, node *goquery.Selection) {
val, ok := node.Attr("href")
if !ok {
return
}
abs := absoluteBrowserURL(base, val)
if abs == "" || !sameSite(abs, baseURL) {
return
}
node.SetAttr("href", s.browserAssetProxyURL(id, abs))
})
// 内联样式里的 url(...)
doc.Find("[style]").Each(func(_ int, node *goquery.Selection) {
style, ok := node.Attr("style")
if !ok || !strings.Contains(style, "url(") {
return
}
node.SetAttr("style", rewriteCSSURLs(style, base, func(abs string) string {
return s.browserAssetProxyURL(id, abs)
}))
})
out, err := doc.Html()
if err != nil {
return body
}
return "<!DOCTYPE html>" + out
}
// RewriteBrowserCSS 改写 CSS 文本里的 url(...) 引用(资源代理用)。
func (s *ReaderService) RewriteBrowserCSS(css, baseURL, id string) string {
base, err := url.Parse(baseURL)
if err != nil {
return css
}
return rewriteCSSURLs(css, base, func(abs string) string {
return s.browserAssetProxyURL(id, abs)
})
}
func rewriteCSSURLs(css string, base *url.URL, proxy func(abs string) string) string {
return cssURLRe.ReplaceAllStringFunc(css, func(m string) string {
sub := cssURLRe.FindStringSubmatch(m)
if len(sub) < 2 {
return m
}
abs := absoluteBrowserURL(base, sub[1])
if abs == "" {
return m
}
return `url("` + proxy(abs) + `")`
})
}
// absoluteBrowserURL 把页面里的相对地址解析成绝对地址;不可代理的协议返回空串。
func absoluteBrowserURL(base *url.URL, ref string) string {
ref = strings.TrimSpace(ref)
if ref == "" || strings.HasPrefix(ref, "#") {
return ""
}
lower := strings.ToLower(ref)
for _, p := range []string{"data:", "javascript:", "mailto:", "tel:", "blob:", "about:", "ws:", "wss:"} {
if strings.HasPrefix(lower, p) {
return ""
}
}
parsed, err := url.Parse(ref)
if err != nil {
return ""
}
abs := base.ResolveReference(parsed).String()
if !strings.HasPrefix(abs, "http://") && !strings.HasPrefix(abs, "https://") {
return ""
}
return abs
}
// sameSite 判断两个地址是否同站(eTLD+1)。
func sameSite(a, b string) bool {
return rule.CookieDomain(a) == rule.CookieDomain(b)
}
// ─── 签名与地址 ────────────────────────────────────────────────────────────
// browserSign 用 JWT 密钥做 HMAC,绑定待办 ID(页面)或 ID+目标地址(资源)。
func (s *ReaderService) browserSign(payload string) string {
mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret))
mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))[:32]
}
func (s *ReaderService) browserPageSig(id string) string { return s.browserSign("page|" + id) }
func (s *ReaderService) browserAssetSig(id, target string) string {
return s.browserSign("asset|" + id + "|" + target)
}
func (s *ReaderService) browserPageURL(id string) string {
return "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id)
}
// browserAssetProxyURL 生成资源代理地址(iframe 页面内引用用)。
func (s *ReaderService) browserAssetProxyURL(id, target string) string {
return "/api/reader/browser/asset?id=" + url.QueryEscape(id) +
"&s=" + s.browserAssetSig(id, target) +
"&u=" + base64.RawURLEncoding.EncodeToString([]byte(target))
}
// VerifyBrowserPage 校验页面承载签名,返回待办快照。
func (s *ReaderService) VerifyBrowserPage(id, sig string) (*BrowserPageSnapshot, error) {
if !hmac.Equal([]byte(s.browserPageSig(id)), []byte(sig)) {
return nil, errors.New("页面地址签名校验失败")
}
entry := s.lookupBrowser(id)
if entry == nil {
return nil, errors.New("页面已过期,请重新打开")
}
return &BrowserPageSnapshot{
ID: entry.id,
HTML: entry.html,
SourceID: entry.sourceID,
FinalURL: entry.finalURL,
}, nil
}
// VerifyBrowserAsset 校验资源代理签名并还原目标地址,返回(目标地址, 书源 URL)。
func (s *ReaderService) VerifyBrowserAsset(id, encoded, sig string) (string, string, error) {
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", "", errors.New("资源地址解码失败")
}
target := string(raw)
if !hmac.Equal([]byte(s.browserAssetSig(id, target)), []byte(sig)) {
return "", "", errors.New("资源地址签名校验失败")
}
entry := s.lookupBrowser(id)
if entry == nil {
return "", "", errors.New("页面已过期,请重新打开")
}
return target, entry.sourceURL, nil
}
// ─── 待办表 ────────────────────────────────────────────────────────────────
// PendingBrowserPages 返回该用户在某书源下待用户完成的页面(前端轮询)。
// 含 open 模式:startBrowser 只展示不回传,同样需要前端把页面呈现出来。
func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPage {
s.browserMu.Lock()
defer s.browserMu.Unlock()
s.pruneBrowsersLocked()
out := make([]BrowserPage, 0, len(s.browserPending))
for _, e := range s.browserPending {
if e.sourceID != sourceID {
continue
}
// 待办是短生命周期对象;未标注用户的老调用路径一律放行。
if e.userID != "" && userID != "" && e.userID != userID {
continue
}
out = append(out, s.pageOfLocked(e))
}
return out
}
// ResolveBrowser 用户完成/取消页面后回传结果,解除 JS 侧的阻塞。
func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, cancelled bool) error {
entry := s.lookupBrowser(id)
if entry == nil {
return errors.New("页面已过期或已完成")
}
entry.mu.Lock()
defer entry.mu.Unlock()
if entry.finished {
return errors.New("该页面已完成")
}
if cancelled {
entry.err = rule.ErrBrowserCancelled
} else {
resultURL := strings.TrimSpace(finalURL)
if resultURL == "" {
resultURL = entry.finalURL
}
entry.result = rule.BrowserResult{URL: resultURL, Body: body}
}
entry.finished = true
if entry.done != nil {
close(entry.done)
}
return nil
}
func (s *ReaderService) lookupBrowser(id string) *pendingBrowser {
s.browserMu.Lock()
defer s.browserMu.Unlock()
s.pruneBrowsersLocked()
return s.browserPending[id]
}
func (s *ReaderService) dropBrowser(id string) {
s.browserMu.Lock()
defer s.browserMu.Unlock()
delete(s.browserPending, id)
}
// pruneBrowsersLocked 清掉过期条目并唤醒仍在等待的调用方(避免泄漏协程)。
func (s *ReaderService) pruneBrowsersLocked() {
now := time.Now()
for id, e := range s.browserPending {
if now.Before(e.expires) {
continue
}
e.mu.Lock()
if !e.finished {
e.finished = true
e.err = errors.New("页面等待超时")
if e.done != nil {
close(e.done)
}
}
e.mu.Unlock()
delete(s.browserPending, id)
}
}
// pageOfLocked 生成前端展示用的描述(调用方需持有 browserMu)。
func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
target := e.request.URL
if strings.HasPrefix(target, "data:") || len(target) > 512 {
target = ""
}
return BrowserPage{
ID: e.id,
Title: e.request.Title,
Mode: e.mode,
PageURL: s.browserPageURL(e.id),
Refetch: e.request.Refetch,
SourceID: e.sourceID,
TargetURL: target,
}
}
// ─── 小工具 ────────────────────────────────────────────────────────────────
func newBrowserID() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}
func htmlEscape(s string) string {
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;").Replace(s)
}
func truncateForLog(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}