mirror of
https://github.com/p1neappleXpress/OpenFlux.git
synced 2026-10-02 05:04:39 +08:00
Транспорт boards.yandex.ru, автокапча Яндекса и JSON-API досок
Транспорт boards (--transport boards):
- transport/yandex/boards.go: гостевая сессия на boards.yandex.ru поверх
socket.io/engine.io v4. Канал данных — notify-position (эфемерные события
курсора, payload в base64 в data.position.x). Объекты доски (modify-objects)
для передачи НЕ используются: они оседают на доске навсегда и реплеятся при
каждом реконнекте. handleServerModifyObjects оставлен только на приём.
Своё эхо фильтруется по participant из envelope.
- main.go: case "boards" через buildMuxTransport (список досок = мультиплекс).
Фикс: свой engine.io ping рвал соединение каждые 20 с.
В engine.io v4 пингует СЕРВЕР пакетом "2", клиент отвечает "3" (в v3 было
наоборот). Наш pingLoop слал "2" от клиента, сервер считал это невалидным
пакетом и закрывал сокет ровно через boardsPingInterval — бесконечный цикл
handshake -> close 1005 -> reconnect. Ответ на серверный пинг уже был в
handleMessage, так что pingLoop просто убран.
Автокапча (перенос из upstream 38782b2):
- transport/yandex/captcha.go: solveCaptcha — showcaptchafast -> __SSR_DATA__
-> SHA-256 PoW -> gzip+base64 fingerprint -> POST формы.
- boards: errCaptchaRequired + getAllowCaptcha, обёрнуты в
getWhiteboardSolvingCaptcha (после решения повторяем GET /whiteboard —
сервер выдаёт свежие cookies для последующих /api).
- vyandex: ветка showcaptchafast в цикле редиректов.
- yandex: fetchDocInfo переведён на ручной цикл редиректов с cookiejar.
Раньше он следовал редиректам автоматически, поэтому редирект на капчу
отрабатывал молча и вместо документа приходил её HTML — перехватывать
было негде.
Фикс: API досок требует JSON (иначе 415).
POST /api с application/x-www-form-urlencoded теперь отвечает
415 {"code":99,"description":"Request body must use a JSON Content-Type"}.
Тело должно быть {"action":..., "content":"<base64>"} с Content-Type
application/json; сама base64-обёртка content не изменилась. Оба вызова
(request-guest-token, get-whiteboard-info) сведены в newAPIRequest.
Замеры (клиент на маке, нода в SJC, доска на 2 участника):
round-trip через доску ~270 мс, 20 МБ на 1.0-1.5 МБ/с (~27% от прямого
канала), реконнектов и переполнений очереди под нагрузкой нет,
sleep/wake ноутбука переживает (восстановление ~12 с).
Не входит: boards не заведён в iOS-мостах (export_ios.go,
export_ios_packet.go) и в Swift TransportKind — в приложении вернёт
startBadTransport. Путь капчи вживую не проверен: за прогоны Яндекс её
ни разу не выдал, отдельно протестирован только PoW-солвер.
This commit is contained in:
@@ -94,7 +94,9 @@ struct ContentView: View {
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 28) {
|
||||
Text("Выберите профиль и нажмите кнопку подключения")
|
||||
Text(store.profiles.isEmpty
|
||||
? "Добавьте профиль: вставьте ссылку на документ или отсканируйте QR"
|
||||
: "Выберите профиль и нажмите кнопку подключения")
|
||||
.font(.footnote).foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -184,7 +186,22 @@ struct ContentView: View {
|
||||
|
||||
// MARK: profile dropdown
|
||||
|
||||
@ViewBuilder
|
||||
private var profilePicker: some View {
|
||||
if store.profiles.isEmpty {
|
||||
Button {
|
||||
editing = nil; showEditor = true
|
||||
} label: {
|
||||
Label("Добавить профиль", systemImage: "plus.circle.fill")
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
} else {
|
||||
profileMenu
|
||||
}
|
||||
}
|
||||
|
||||
private var profileMenu: some View {
|
||||
Menu {
|
||||
ForEach(store.profiles) { p in
|
||||
Button {
|
||||
@@ -312,13 +329,15 @@ struct ProfileEditorView: View {
|
||||
Button {
|
||||
importFromClipboard()
|
||||
} label: {
|
||||
Label("Вставить ссылку из буфера (OFLUX1)", systemImage: "doc.on.clipboard")
|
||||
Label("Вставить из буфера", systemImage: "doc.on.clipboard")
|
||||
}
|
||||
Button {
|
||||
showScanner = true
|
||||
} label: {
|
||||
Label("Сканировать QR-код", systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
Text("Подойдёт обычная ссылка на документ (disk.yandex.ru / cloud.mail.ru) или конфиг OFLUX1.")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
if let m = importMsg {
|
||||
Text(m).font(.caption2).foregroundColor(.secondary)
|
||||
}
|
||||
@@ -327,10 +346,10 @@ struct ProfileEditorView: View {
|
||||
.sheet(isPresented: $showScanner) {
|
||||
QRScannerView { code in
|
||||
showScanner = false
|
||||
if applyParsed(parseOFLUX(code)) {
|
||||
if ingest(code) {
|
||||
importMsg = "QR распознан: \(transport.title)."
|
||||
} else {
|
||||
importMsg = "QR не содержит корректной OFLUX1-ссылки."
|
||||
importMsg = "QR не содержит ссылки или конфига."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,13 +392,33 @@ struct ProfileEditorView: View {
|
||||
}
|
||||
|
||||
private func importFromClipboard() {
|
||||
if applyParsed(parseOFLUX(UIPasteboard.general.string ?? "")) {
|
||||
importMsg = "Ссылка вставлена: \(transport.title)."
|
||||
if ingest(UIPasteboard.general.string ?? "") {
|
||||
importMsg = "Вставлено: \(transport.title)."
|
||||
} else {
|
||||
importMsg = "В буфере нет корректной OFLUX1-ссылки."
|
||||
importMsg = "В буфере нет ссылки или OFLUX1-конфига."
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept EITHER an OFLUX1 config OR a plain document link. A plain link goes
|
||||
/// into the field of the currently selected transport (Mail.ru is detected by
|
||||
/// host); the name is auto-filled if empty. Returns false if it is neither.
|
||||
@discardableResult
|
||||
private func ingest(_ raw: String) -> Bool {
|
||||
let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if applyParsed(parseOFLUX(s)) { return true }
|
||||
guard s.lowercased().hasPrefix("http") else { return false }
|
||||
if s.lowercased().contains("cloud.mail.ru") {
|
||||
transportRaw = TransportKind.mail.rawValue
|
||||
}
|
||||
switch transport {
|
||||
case .yandex: url1 = s
|
||||
case .volga, .mail: single = s
|
||||
case .max: return false
|
||||
}
|
||||
if name.trimmingCharacters(in: .whitespaces).isEmpty { name = transport.title }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Fill the editor fields from a parsed OFLUX config. Returns false if nil.
|
||||
@discardableResult
|
||||
private func applyParsed(_ parsed: (kind: String, urls: [String])?) -> Bool {
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ options:
|
||||
settings:
|
||||
base:
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: "44"
|
||||
CURRENT_PROJECT_VERSION: "45"
|
||||
DEVELOPMENT_TEAM: "8GQH8GQ252"
|
||||
|
||||
targets:
|
||||
|
||||
@@ -98,6 +98,10 @@ func main() {
|
||||
var trans transport.Transport
|
||||
|
||||
switch *transportType {
|
||||
case "boards":
|
||||
trans = buildMuxTransport(globalDocUrl, func(u string) transport.Transport {
|
||||
return yandex.NewBoardsTransport(u, config)
|
||||
})
|
||||
case "yandex":
|
||||
trans = buildMuxTransport(globalDocUrl, func(u string) transport.Transport {
|
||||
return yandex.NewYandexDocsTransport(u, config)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
package yandex
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"universal-bypass-tool/utils"
|
||||
)
|
||||
|
||||
// solveCaptcha проходит Яндекс-капчу (blink-check) для заданного URL.
|
||||
//
|
||||
// Возвращает retpath (пустая строка = капча не требовалась).
|
||||
// Cookies в jar обновляются на месте.
|
||||
func solveCaptcha(docURL string, jar http.CookieJar, userAgent string) (string, error) {
|
||||
if jar == nil {
|
||||
return "", fmt.Errorf("captcha: nil cookiejar")
|
||||
}
|
||||
if userAgent == "" {
|
||||
userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:153.0) Gecko/20100101 Firefox/153.0"
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Jar: jar,
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
utils.Debugf("[CAPTCHA] solve start: url=%s", docURL)
|
||||
|
||||
captchaURL := ""
|
||||
currentURL := docURL
|
||||
for i := 0; i < 10; i++ {
|
||||
utils.Debugf("[CAPTCHA] GET %s", shortStr(currentURL, 120))
|
||||
req, _ := http.NewRequest("GET", currentURL, nil)
|
||||
setBrowserHeaders(req, userAgent)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("captcha GET: %w", err)
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
utils.Debugf("[CAPTCHA] status=%d location=%s",
|
||||
resp.StatusCode, shortStr(resp.Header.Get("Location"), 100))
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
utils.Debugf("[CAPTCHA] 200 OK — капча не требуется")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if resp.StatusCode < 300 || resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("captcha unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
loc := resp.Header.Get("Location")
|
||||
if loc == "" {
|
||||
return "", fmt.Errorf("captcha: redirect without Location")
|
||||
}
|
||||
|
||||
if strings.Contains(loc, "showcaptchafast") {
|
||||
captchaURL = loc
|
||||
break
|
||||
}
|
||||
currentURL = loc
|
||||
}
|
||||
|
||||
if captchaURL == "" {
|
||||
return "", fmt.Errorf("captcha: showcaptchafast not found in redirect chain")
|
||||
}
|
||||
|
||||
utils.Debugf("[CAPTCHA] GET showcaptchafast")
|
||||
req, _ := http.NewRequest("GET", captchaURL, nil)
|
||||
setBrowserHeaders(req, userAgent)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("captcha showcaptcha GET: %w", err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("captcha showcaptcha status %d", resp.StatusCode)
|
||||
}
|
||||
utils.Debugf("[CAPTCHA] showcaptcha: %d bytes", len(body))
|
||||
|
||||
ssr, formAction, err := parseCaptchaHTML(string(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
utils.Debugf("[CAPTCHA] uniqueKey=%s timestamp=%d complexity=%d prefix=%s",
|
||||
ssr.UniqueKey, ssr.Timestamp, ssr.Pow.Complexity, shortStr(ssr.Pow.Prefix, 32))
|
||||
|
||||
t0 := time.Now()
|
||||
nonceHex, attempts := solveCaptchaPoW(ssr.Pow.Prefix, ssr.Pow.Complexity)
|
||||
utils.Debugf("[CAPTCHA] PoW solved: nonce=%s attempts=%d time=%v",
|
||||
nonceHex, attempts, time.Since(t0))
|
||||
|
||||
fp := buildCaptchaFingerprint(nonceHex, userAgent)
|
||||
fpEncoded := encodeCaptchaFingerprint(fp)
|
||||
utils.Debugf("[CAPTCHA] fingerprint: json=%d encoded=%d bytes",
|
||||
len(mustMarshal(fp)), len(fpEncoded))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("version", "1.5.0")
|
||||
form.Set("uniquekey", ssr.UniqueKey)
|
||||
form.Set("chstate", "ok")
|
||||
form.Set("fingerprint", fpEncoded)
|
||||
|
||||
utils.Debugf("[CAPTCHA] POST %s", shortStr(formAction, 100))
|
||||
req2, _ := http.NewRequest("POST", formAction, strings.NewReader(form.Encode()))
|
||||
setBrowserHeaders(req2, userAgent)
|
||||
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req2.Header.Set("Origin", "https://docs.yandex.ru")
|
||||
req2.Header.Set("Referer", captchaURL)
|
||||
|
||||
resp2, err := client.Do(req2)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("captcha POST: %w", err)
|
||||
}
|
||||
io.Copy(io.Discard, resp2.Body)
|
||||
resp2.Body.Close()
|
||||
|
||||
utils.Debugf("[CAPTCHA] POST result: status=%d location=%s",
|
||||
resp2.StatusCode, shortStr(resp2.Header.Get("Location"), 120))
|
||||
|
||||
if resp2.StatusCode < 300 || resp2.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("captcha POST unexpected status %d", resp2.StatusCode)
|
||||
}
|
||||
|
||||
retpath := resp2.Header.Get("Location")
|
||||
if retpath == "" {
|
||||
retpath = docURL
|
||||
}
|
||||
|
||||
utils.Debugf("[CAPTCHA] solve OK, retpath=%s", shortStr(retpath, 120))
|
||||
return retpath, nil
|
||||
}
|
||||
|
||||
// ---- парсинг showcaptchafast ----
|
||||
|
||||
type captchaSSRData struct {
|
||||
UniqueKey string `json:"uniqueKey"`
|
||||
Action string `json:"action"`
|
||||
Pow struct {
|
||||
Complexity int `json:"complexity"`
|
||||
Prefix string `json:"prefix"`
|
||||
} `json:"pow"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
var (
|
||||
reSSRData = regexp.MustCompile(`window\.__SSR_DATA__\s*=\s*JSON\.parse\(atob\("([^"]+)"\)\)`)
|
||||
reFormAction = regexp.MustCompile(`<form[^>]*id="tmgrdfrend-form"[^>]*action="([^"]+)"`)
|
||||
)
|
||||
|
||||
func parseCaptchaHTML(html string) (*captchaSSRData, string, error) {
|
||||
m := reSSRData.FindStringSubmatch(html)
|
||||
if len(m) < 2 {
|
||||
return nil, "", fmt.Errorf("captcha: __SSR_DATA__ not found")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(m[1])
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("captcha: SSR_DATA base64: %w", err)
|
||||
}
|
||||
var ssr captchaSSRData
|
||||
if err := json.Unmarshal(raw, &ssr); err != nil {
|
||||
return nil, "", fmt.Errorf("captcha: SSR_DATA json: %w", err)
|
||||
}
|
||||
|
||||
m2 := reFormAction.FindStringSubmatch(html)
|
||||
if len(m2) < 2 {
|
||||
return nil, "", fmt.Errorf("captcha: form action not found")
|
||||
}
|
||||
formAction := strings.ReplaceAll(m2[1], "&", "&")
|
||||
if strings.HasPrefix(formAction, "/") {
|
||||
formAction = "https://docs.yandex.ru" + formAction
|
||||
}
|
||||
|
||||
return &ssr, formAction, nil
|
||||
}
|
||||
|
||||
// ---- PoW ----
|
||||
|
||||
func solveCaptchaPoW(prefixHex string, complexity int) (string, int) {
|
||||
prefix, err := hexDecode(prefixHex)
|
||||
if err != nil || len(prefix) == 0 {
|
||||
prefix = []byte(prefixHex)
|
||||
}
|
||||
|
||||
var nonce [16]byte
|
||||
for attempts := 1; attempts < 10_000_000; attempts++ {
|
||||
ts := uint64(time.Now().UnixMilli())
|
||||
putU64LE(nonce[0:8], ts)
|
||||
putU64LE(nonce[8:16], uint64(rand.Int63()))
|
||||
|
||||
h := sha256.New()
|
||||
h.Write(nonce[:])
|
||||
h.Write(prefix)
|
||||
sum := h.Sum(nil)
|
||||
|
||||
if captchaCheckComplexity(sum, complexity) {
|
||||
return hexEncode(nonce[:]), attempts
|
||||
}
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// captchaCheckComplexity — точная копия checkComplexity из fp.js.
|
||||
func captchaCheckComplexity(h []byte, complexity int) bool {
|
||||
if complexity < 0 || complexity > 8*len(h) {
|
||||
return false
|
||||
}
|
||||
e, o := 0, 0
|
||||
for e <= complexity-8 {
|
||||
if h[o] != 0 {
|
||||
return false
|
||||
}
|
||||
e += 8
|
||||
o++
|
||||
}
|
||||
mask := byte(255) << uint(8+e-complexity)
|
||||
return h[o]&mask == 0
|
||||
}
|
||||
|
||||
// ---- fingerprint ----
|
||||
|
||||
func buildCaptchaFingerprint(nonceHex, userAgent string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"b6": 8, "b7": 8, "b9": []string{"en-US", "en"},
|
||||
"c2": "", "c4": "MacIntel", "c5": []interface{}{}, "c9": userAgent,
|
||||
"f4": 1080, "f5": 1920, "f6": 24, "f7": 1080, "f8": true,
|
||||
"f9": []int{1920, 1080}, "g1": 1920,
|
||||
"g2": "Europe/Moscow", "g3": -180,
|
||||
"j5": true,
|
||||
"m2": map[string]interface{}{"mTP": 0, "tE": false, "tS": false},
|
||||
"n6": false,
|
||||
"o2": 0, "o3": "srgb", "o4": 0, "o5": "en-US",
|
||||
"o8": nil, "o9": nil,
|
||||
"p1": nil, "p2": 0, "p3": nil, "p4": nil,
|
||||
"p5": nil, "p6": nil, "p8": []interface{}{}, "p9": "111111111",
|
||||
"j6": 48000,
|
||||
"a1": "",
|
||||
"a2": map[string]interface{}{"w": false, "d": ""},
|
||||
"a3": map[string]interface{}{
|
||||
"acos": 1.4444399284962483, "asin": 0.12349655394506357,
|
||||
"atan": 0.4636476090008061, "cos": -0.8390715290095377,
|
||||
"exp": 2.718281828459045, "log1p": 2.3978952727983707,
|
||||
"sin": -0.9917788534431158, "tan": -0.23206847684369653,
|
||||
},
|
||||
"a4": map[string]interface{}{"minDelta": 0.1, "maxDelta": 1.2},
|
||||
"a5": nil,
|
||||
"k4": []interface{}{},
|
||||
"j1": map[string]interface{}{
|
||||
"vn": "WebKit", "vr": "WebKit WebGL", "vU": "",
|
||||
"r": "Mozilla", "rU": "", "sLV": "WebGL GLSL ES 1.0 (1.0)",
|
||||
},
|
||||
"j2": map[string]interface{}{
|
||||
"cA": []interface{}{}, "p": []interface{}{}, "sP": []interface{}{},
|
||||
"e": []interface{}{}, "eP": []interface{}{},
|
||||
},
|
||||
"m10": nonceHex,
|
||||
"version": "1.5.0",
|
||||
}
|
||||
}
|
||||
|
||||
func encodeCaptchaFingerprint(fp map[string]interface{}) string {
|
||||
raw, _ := json.Marshal(fp)
|
||||
var buf bytes.Buffer
|
||||
w := gzip.NewWriter(&buf)
|
||||
w.Write(raw)
|
||||
w.Close()
|
||||
return "~" + base64.StdEncoding.EncodeToString(buf.Bytes()) + "~"
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func setBrowserHeaders(req *http.Request, userAgent string) {
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
req.Header.Set("Sec-GPC", "1")
|
||||
req.Header.Set("Upgrade-Insecure-Requests", "1")
|
||||
req.Header.Set("Sec-Fetch-Dest", "document")
|
||||
req.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
req.Header.Set("Sec-Fetch-Site", "none")
|
||||
req.Header.Set("Sec-Fetch-User", "?1")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.Header.Set("Connection", "keep-alive")
|
||||
}
|
||||
|
||||
func hexEncode(b []byte) string {
|
||||
const h = "0123456789abcdef"
|
||||
out := make([]byte, len(b)*2)
|
||||
for i, v := range b {
|
||||
out[i*2] = h[v>>4]
|
||||
out[i*2+1] = h[v&0x0f]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func hexDecode(s string) ([]byte, error) {
|
||||
if len(s)%2 != 0 {
|
||||
return nil, fmt.Errorf("odd hex length")
|
||||
}
|
||||
out := make([]byte, len(s)/2)
|
||||
for i := 0; i < len(out); i++ {
|
||||
hi, lo := hexVal(s[i*2]), hexVal(s[i*2+1])
|
||||
if hi < 0 || lo < 0 {
|
||||
return nil, fmt.Errorf("invalid hex")
|
||||
}
|
||||
out[i] = byte(hi<<4 | lo)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hexVal(c byte) int {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return int(c - '0')
|
||||
case c >= 'a' && c <= 'f':
|
||||
return int(c-'a') + 10
|
||||
case c >= 'A' && c <= 'F':
|
||||
return int(c-'A') + 10
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func putU64LE(b []byte, v uint64) {
|
||||
b[0] = byte(v)
|
||||
b[1] = byte(v >> 8)
|
||||
b[2] = byte(v >> 16)
|
||||
b[3] = byte(v >> 24)
|
||||
b[4] = byte(v >> 32)
|
||||
b[5] = byte(v >> 40)
|
||||
b[6] = byte(v >> 48)
|
||||
b[7] = byte(v >> 56)
|
||||
}
|
||||
|
||||
func mustMarshal(v interface{}) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
@@ -197,6 +197,18 @@ func authorize(docURL string) (*volgaAuth, error) {
|
||||
if loc == "" {
|
||||
return nil, fmt.Errorf("redirect without Location from %s", currentURL)
|
||||
}
|
||||
|
||||
// Капча — проходим и повторяем ИСХОДНЫЙ url (не loc).
|
||||
if strings.Contains(loc, "showcaptchafast") {
|
||||
utils.Debugf("[VOLGA] captcha required, solving...")
|
||||
if _, cerr := solveCaptcha(docURL, jar, volgaUserAgent); cerr != nil {
|
||||
return nil, fmt.Errorf("captcha solve: %w", cerr)
|
||||
}
|
||||
utils.Debugf("[VOLGA] captcha solved, retrying from %s", docURL)
|
||||
currentURL = docURL
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(loc, "/") {
|
||||
u, _ := url.Parse(currentURL)
|
||||
loc = u.Scheme + "://" + u.Host + loc
|
||||
|
||||
+72
-12
@@ -8,6 +8,7 @@ import (
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -404,24 +405,80 @@ func reconnectBackoff(n int) time.Duration {
|
||||
}
|
||||
|
||||
func (t *YandexDocsTransport) fetchDocInfo(url, userID string) (YandexDocsInfo, error) {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
client := &http.Client{
|
||||
// Cap redirects so an auth/login redirect loop fails fast instead of
|
||||
// hanging until the timeout (a private doc redirects to passport).
|
||||
Jar: jar,
|
||||
// НЕ следуем редиректам автоматически — иначе редирект на капчу
|
||||
// отработает молча и мы получим её HTML вместо документа.
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("stopped after 10 redirects (login required? doc not public?)")
|
||||
}
|
||||
return nil
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
utils.Debugf("[YDOCS] fetchDocInfo GET %s", url)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return YandexDocsInfo{}, err
|
||||
ua := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:153.0) Gecko/20100101 Firefox/153.0"
|
||||
|
||||
// Явно следуем по редиректам: до 10 хопов (auth/login-петля на приватном
|
||||
// документе тогда падает быстро, а не висит до таймаута).
|
||||
currentURL := url
|
||||
var resp *http.Response
|
||||
var err error
|
||||
|
||||
for hop := 0; hop < 10; hop++ {
|
||||
utils.Debugf("[YDOCS] hop %d: GET %s", hop, shortStr(currentURL, 120))
|
||||
|
||||
req, _ := http.NewRequest("GET", currentURL, nil)
|
||||
req.Header.Set("User-Agent", ua)
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
return YandexDocsInfo{}, fmt.Errorf("GET %s: %w", currentURL, err)
|
||||
}
|
||||
|
||||
utils.Debugf("[YDOCS] status=%d location=%s",
|
||||
resp.StatusCode, shortStr(resp.Header.Get("Location"), 120))
|
||||
|
||||
// 200 — дошли до документа
|
||||
if resp.StatusCode == 200 {
|
||||
break
|
||||
}
|
||||
|
||||
// 3xx — редирект
|
||||
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||||
loc := resp.Header.Get("Location")
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if loc == "" {
|
||||
return YandexDocsInfo{}, fmt.Errorf("redirect without Location from %s", currentURL)
|
||||
}
|
||||
|
||||
// Капча — проходим и повторяем ИСХОДНЫЙ url (не loc).
|
||||
if strings.Contains(loc, "showcaptchafast") {
|
||||
utils.Debugf("[YDOCS] captcha detected, solving...")
|
||||
if _, cerr := solveCaptcha(currentURL, jar, ua); cerr != nil {
|
||||
return YandexDocsInfo{}, fmt.Errorf("captcha solve: %w", cerr)
|
||||
}
|
||||
utils.Debugf("[YDOCS] captcha solved, retrying original url")
|
||||
currentURL = url
|
||||
continue
|
||||
}
|
||||
|
||||
// Обычный редирект — идём по нему.
|
||||
currentURL = loc
|
||||
continue
|
||||
}
|
||||
|
||||
// Другой статус — ошибка
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return YandexDocsInfo{}, fmt.Errorf("unexpected status %d at %s", resp.StatusCode, currentURL)
|
||||
}
|
||||
|
||||
if resp == nil {
|
||||
return YandexDocsInfo{}, fmt.Errorf("no response after redirects")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return YandexDocsInfo{}, fmt.Errorf("stopped after 10 redirects (login required? doc not public?)")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -433,6 +490,9 @@ func (t *YandexDocsTransport) fetchDocInfo(url, userID string) (YandexDocsInfo,
|
||||
for _, c := range resp.Cookies() {
|
||||
cookies = append(cookies, fmt.Sprintf("%s=%s", c.Name, c.Value))
|
||||
}
|
||||
for _, c := range jar.Cookies(resp.Request.URL) {
|
||||
cookies = append(cookies, fmt.Sprintf("%s=%s", c.Name, c.Value))
|
||||
}
|
||||
|
||||
matches := clientConfigRe.FindStringSubmatch(html)
|
||||
if len(matches) < 2 {
|
||||
|
||||
Reference in New Issue
Block a user