449 lines
17 KiB
Go
449 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/creator-hub/internal/douyin"
|
|
"github.com/gofiber/fiber/v3"
|
|
"golang.org/x/net/websocket"
|
|
)
|
|
|
|
const (
|
|
douyinOrigin = "https://www.douyin.com"
|
|
douyinOriginURL = "https://www.douyin.com/"
|
|
douyinIdentityPath = "/aweme/v1/web/user/profile/self/"
|
|
douyinWorksPath = "/aweme/v1/web/aweme/post/"
|
|
douyinResponseLimit = 1 << 20
|
|
browserControlTimeout = 15 * time.Second
|
|
)
|
|
|
|
var douyinAccountKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$`)
|
|
|
|
type restrictedBrowserResponse struct {
|
|
Status int
|
|
Body string
|
|
Challenge douyin.Challenge
|
|
}
|
|
|
|
type restrictedBrowser interface {
|
|
SetCookies(context.Context, string, []douyin.Cookie) error
|
|
Get(context.Context, string, string) (restrictedBrowserResponse, error)
|
|
}
|
|
|
|
type douyinGenerationRequest struct {
|
|
BindingVersion int64 `json:"binding_version"`
|
|
RuntimeID string `json:"runtime_id"`
|
|
NetworkID string `json:"network_id"`
|
|
NetworkExitID string `json:"network_exit_id"`
|
|
}
|
|
|
|
type douyinCookieRequest struct {
|
|
douyinGenerationRequest
|
|
Cookies []douyin.Cookie `json:"cookies"`
|
|
}
|
|
|
|
type douyinGetRequest struct {
|
|
douyinGenerationRequest
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
func (api gateway) setDouyinCookies(c fiber.Ctx) error {
|
|
var input douyinCookieRequest
|
|
if !runtimeIDPattern.MatchString(c.Params("id")) || decodeRestrictedBrowserRequest(c.Body(), &input) != nil || !validDouyinGeneration(input.douyinGenerationRequest) ||
|
|
!validDouyinCookies(input.Cookies) {
|
|
return writeError(c, http.StatusBadRequest, errors.New("invalid restricted browser request"))
|
|
}
|
|
_, release, err := api.locks.acquire(c.Params("id"))
|
|
if err != nil {
|
|
return writeError(c, statusFor(err), err)
|
|
}
|
|
defer release()
|
|
if err := api.requireDouyinGeneration(c.Params("id"), input.douyinGenerationRequest); err != nil {
|
|
return writeError(c, statusFor(err), err)
|
|
}
|
|
if api.browser == nil || api.browser.SetCookies(c.Context(), c.Params("id"), input.Cookies) != nil {
|
|
return writeError(c, http.StatusBadGateway, errors.New("restricted browser operation failed"))
|
|
}
|
|
if err := api.requireDouyinGeneration(c.Params("id"), input.douyinGenerationRequest); err != nil {
|
|
return writeError(c, statusFor(err), err)
|
|
}
|
|
return c.SendStatus(http.StatusNoContent)
|
|
}
|
|
|
|
func (api gateway) getDouyin(c fiber.Ctx) error {
|
|
var input douyinGetRequest
|
|
if !runtimeIDPattern.MatchString(c.Params("id")) || decodeRestrictedBrowserRequest(c.Body(), &input) != nil || !validDouyinGeneration(input.douyinGenerationRequest) ||
|
|
!validDouyinURL(input.URL) {
|
|
return writeError(c, http.StatusBadRequest, errors.New("invalid restricted browser request"))
|
|
}
|
|
_, release, err := api.locks.acquire(c.Params("id"))
|
|
if err != nil {
|
|
return writeError(c, statusFor(err), err)
|
|
}
|
|
defer release()
|
|
if err := api.requireDouyinGeneration(c.Params("id"), input.douyinGenerationRequest); err != nil {
|
|
return writeError(c, statusFor(err), err)
|
|
}
|
|
if api.browser == nil {
|
|
return writeError(c, http.StatusBadGateway, errors.New("restricted browser operation failed"))
|
|
}
|
|
response, err := api.browser.Get(c.Context(), c.Params("id"), input.URL)
|
|
if err != nil {
|
|
return writeError(c, http.StatusBadGateway, errors.New("restricted browser operation failed"))
|
|
}
|
|
if err := api.requireDouyinGeneration(c.Params("id"), input.douyinGenerationRequest); err != nil {
|
|
return writeError(c, statusFor(err), err)
|
|
}
|
|
return writeJSON(c, http.StatusOK, map[string]any{
|
|
"status": response.Status, "body": response.Body, "challenge": response.Challenge,
|
|
})
|
|
}
|
|
|
|
func decodeRestrictedBrowserRequest(body []byte, target any) error {
|
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
var trailing json.RawMessage
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
return errors.New("request must contain one JSON value")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validDouyinGeneration(input douyinGenerationRequest) bool {
|
|
return input.BindingVersion > 0 && exitIDPattern.MatchString(input.RuntimeID) &&
|
|
exitIDPattern.MatchString(input.NetworkID) && exitIDPattern.MatchString(input.NetworkExitID)
|
|
}
|
|
|
|
func (api gateway) requireDouyinGeneration(alias string, input douyinGenerationRequest) error {
|
|
runtimeID, labels, networks, err := api.managedContainerState(alias)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
version, _ := strconv.ParseInt(labels[bindingVersionLabel], 10, 64)
|
|
if runtimeID != input.RuntimeID || version != input.BindingVersion || labels[networkIDLabel] != input.NetworkID ||
|
|
labels[networkExitLabel] != input.NetworkExitID {
|
|
return errGenerationConflict
|
|
}
|
|
generation, _, exists, err := api.docker.inspectTenantNetwork(api.network, alias, input.BindingVersion,
|
|
input.RuntimeID, api.self, input.NetworkID, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists || !generation.RuntimeAttached || generation.SelfMember == "" || len(generation.GatewayMembers) == 0 ||
|
|
len(networks) != 1 || networks[generation.Name] != generation.ID {
|
|
return errGenerationConflict
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validDouyinCookies(cookies []douyin.Cookie) bool {
|
|
if len(cookies) == 0 || len(cookies) > 64 {
|
|
return false
|
|
}
|
|
for _, cookie := range cookies {
|
|
domain := strings.ToLower(strings.TrimSpace(cookie.Domain))
|
|
if cookie.Name == "" || len(cookie.Name) > 256 || len(cookie.Value) > 4096 || len(cookie.Domain) > 256 || len(cookie.Path) > 256 ||
|
|
domain != cookie.Domain || cookie.Expires < 0 || strings.ContainsAny(cookie.Name, ";\r\n\x00") || strings.ContainsAny(cookie.Value, ";\r\n\x00") ||
|
|
(domain != "douyin.com" && !strings.HasSuffix(domain, ".douyin.com")) || !strings.HasPrefix(cookie.Path, "/") ||
|
|
strings.ContainsAny(cookie.Path, ";\r\n\x00") || (cookie.SameSite != "" && cookie.SameSite != "Lax" &&
|
|
cookie.SameSite != "Strict" && cookie.SameSite != "None") {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validDouyinURL(raw string) bool {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil || parsed.Scheme != "https" || parsed.Host != "www.douyin.com" || parsed.User != nil || parsed.Fragment != "" {
|
|
return false
|
|
}
|
|
query := parsed.Query()
|
|
switch parsed.Path {
|
|
case douyinIdentityPath:
|
|
return parsed.RawQuery == ""
|
|
case douyinWorksPath:
|
|
return len(query) == 3 && len(query["sec_user_id"]) == 1 && douyinAccountKeyPattern.MatchString(query.Get("sec_user_id")) &&
|
|
len(query["count"]) == 1 && query.Get("count") == "20" && len(query["max_cursor"]) == 1 && query.Get("max_cursor") == "0"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type cdpBrowser struct {
|
|
endpoint func(string) string
|
|
client *http.Client
|
|
}
|
|
|
|
func (browser cdpBrowser) SetCookies(ctx context.Context, alias string, cookies []douyin.Cookie) error {
|
|
connection, err := browser.connect(ctx, alias)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer connection.Close()
|
|
commandID := 0
|
|
cdpCookies := make([]map[string]any, 0, len(cookies))
|
|
for _, cookie := range cookies {
|
|
value := map[string]any{
|
|
"name": cookie.Name, "value": cookie.Value, "url": douyinOriginURL, "path": cookie.Path,
|
|
"secure": cookie.Secure, "httpOnly": cookie.HTTPOnly,
|
|
}
|
|
if cookie.SameSite != "" {
|
|
value["sameSite"] = cookie.SameSite
|
|
}
|
|
if cookie.Expires != 0 {
|
|
value["expires"] = cookie.Expires
|
|
}
|
|
cdpCookies = append(cdpCookies, value)
|
|
}
|
|
if err := cdpCommand(connection, &commandID, "Network.enable", map[string]any{}, nil, nil); err != nil {
|
|
return err
|
|
}
|
|
if err := cdpCommand(connection, &commandID, "Network.clearBrowserCookies", map[string]any{}, nil, nil); err != nil {
|
|
return err
|
|
}
|
|
if err := cdpCommand(connection, &commandID, "Page.enable", map[string]any{}, nil, nil); err != nil {
|
|
return err
|
|
}
|
|
if err := cdpCommand(connection, &commandID, "Page.setLifecycleEventsEnabled", map[string]bool{"enabled": true}, nil, nil); err != nil {
|
|
return err
|
|
}
|
|
var events []cdpMessage
|
|
var navigation struct {
|
|
FrameID string `json:"frameId"`
|
|
LoaderID string `json:"loaderId"`
|
|
ErrorText string `json:"errorText"`
|
|
}
|
|
if err := cdpCommand(connection, &commandID, "Page.navigate", map[string]string{"url": douyinOriginURL}, &navigation, &events); err != nil ||
|
|
navigation.ErrorText != "" || navigation.FrameID == "" || navigation.LoaderID == "" {
|
|
return errors.New("restricted browser navigation failed")
|
|
}
|
|
if err := waitForDouyinPage(ctx, connection, &commandID, navigation.FrameID, navigation.LoaderID, events); err != nil {
|
|
return err
|
|
}
|
|
return cdpCommand(connection, &commandID, "Network.setCookies", map[string]any{"cookies": cdpCookies}, nil, nil)
|
|
}
|
|
|
|
func (browser cdpBrowser) Get(ctx context.Context, alias, target string) (restrictedBrowserResponse, error) {
|
|
connection, err := browser.connect(ctx, alias)
|
|
if err != nil {
|
|
return restrictedBrowserResponse{}, err
|
|
}
|
|
defer connection.Close()
|
|
commandID := 0
|
|
var currentOrigin struct {
|
|
Result struct {
|
|
Value string `json:"value"`
|
|
} `json:"result"`
|
|
}
|
|
if err := cdpCommand(connection, &commandID, "Runtime.evaluate", map[string]any{
|
|
"expression": "location.origin", "returnByValue": true,
|
|
}, ¤tOrigin, nil); err != nil || currentOrigin.Result.Value != douyinOrigin {
|
|
return restrictedBrowserResponse{}, errors.New("restricted browser origin changed")
|
|
}
|
|
encodedURL, _ := json.Marshal(target)
|
|
expression := `(async()=>{const r=await fetch(` + string(encodedURL) + `,{credentials:"include",redirect:"error"});` +
|
|
`if(!r.body)return {status:r.status,body:"",too_large:false};const q=r.body.getReader(),d=new TextDecoder();let n=0,b="";` +
|
|
`for(;;){const x=await q.read();if(x.done)break;if(n+x.value.byteLength>=` + strconv.Itoa(douyinResponseLimit) +
|
|
`){await q.cancel();return {too_large:true};}n+=x.value.byteLength;b+=d.decode(x.value,{stream:true});}` +
|
|
`b+=d.decode();return {status:r.status,body:b,too_large:false};})()`
|
|
var evaluated struct {
|
|
Result struct {
|
|
Value struct {
|
|
Status int `json:"status"`
|
|
Body string `json:"body"`
|
|
TooLarge bool `json:"too_large"`
|
|
} `json:"value"`
|
|
} `json:"result"`
|
|
ExceptionDetails json.RawMessage `json:"exceptionDetails"`
|
|
}
|
|
if err := cdpCommand(connection, &commandID, "Runtime.evaluate", map[string]any{
|
|
"expression": expression, "awaitPromise": true, "returnByValue": true,
|
|
}, &evaluated, nil); err != nil || len(evaluated.ExceptionDetails) != 0 || evaluated.Result.Value.TooLarge ||
|
|
evaluated.Result.Value.Status < 200 || evaluated.Result.Value.Status > 599 ||
|
|
(evaluated.Result.Value.Status >= 300 && evaluated.Result.Value.Status < 400) {
|
|
return restrictedBrowserResponse{}, errors.New("restricted browser fetch failed")
|
|
}
|
|
body := evaluated.Result.Value.Body
|
|
return restrictedBrowserResponse{Status: evaluated.Result.Value.Status, Body: body, Challenge: detectDouyinChallenge(evaluated.Result.Value.Status, body)}, nil
|
|
}
|
|
|
|
func (browser cdpBrowser) connect(ctx context.Context, alias string) (*websocket.Conn, error) {
|
|
base := "http://" + namePrefix + alias + ":9222"
|
|
if browser.endpoint != nil {
|
|
base = browser.endpoint(alias)
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/list", nil)
|
|
if err != nil {
|
|
return nil, errors.New("restricted browser unavailable")
|
|
}
|
|
client := http.Client{Timeout: browserControlTimeout}
|
|
if browser.client != nil {
|
|
client = *browser.client
|
|
}
|
|
if client.Timeout <= 0 {
|
|
client.Timeout = browserControlTimeout
|
|
}
|
|
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return nil, errors.New("restricted browser unavailable")
|
|
}
|
|
defer response.Body.Close()
|
|
var targets []struct {
|
|
Type string `json:"type"`
|
|
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
|
|
}
|
|
if response.StatusCode != http.StatusOK {
|
|
return nil, errors.New("restricted browser unavailable")
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, (64<<10)+1))
|
|
if err != nil || len(body) > 64<<10 {
|
|
return nil, errors.New("restricted browser unavailable")
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
|
if decoder.Decode(&targets) != nil || len(targets) > 32 {
|
|
return nil, errors.New("restricted browser unavailable")
|
|
}
|
|
var trailing json.RawMessage
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
return nil, errors.New("restricted browser unavailable")
|
|
}
|
|
baseURL, _ := url.Parse(base)
|
|
pageTarget := ""
|
|
for _, target := range targets {
|
|
if target.Type != "page" {
|
|
continue
|
|
}
|
|
if pageTarget != "" {
|
|
return nil, errors.New("restricted browser unavailable")
|
|
}
|
|
websocketURL, parseErr := url.Parse(target.WebSocketDebuggerURL)
|
|
if parseErr != nil || websocketURL.Scheme != "ws" || !strings.HasPrefix(websocketURL.Path, "/devtools/page/") {
|
|
return nil, errors.New("restricted browser unavailable")
|
|
}
|
|
if websocketURL.Hostname() == "localhost" || websocketURL.Hostname() == "127.0.0.1" || websocketURL.Hostname() == "::1" {
|
|
websocketURL.Host = baseURL.Host
|
|
}
|
|
if websocketURL.Host != baseURL.Host {
|
|
return nil, errors.New("restricted browser unavailable")
|
|
}
|
|
pageTarget = websocketURL.String()
|
|
}
|
|
if pageTarget == "" {
|
|
return nil, errors.New("restricted browser unavailable")
|
|
}
|
|
config, err := websocket.NewConfig(pageTarget, "devtools://devtools")
|
|
if err != nil {
|
|
return nil, errors.New("restricted browser unavailable")
|
|
}
|
|
connection, err := config.DialContext(ctx)
|
|
if err != nil {
|
|
return nil, errors.New("restricted browser unavailable")
|
|
}
|
|
deadline := time.Now().Add(browserControlTimeout)
|
|
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
|
deadline = contextDeadline
|
|
}
|
|
_ = connection.SetDeadline(deadline)
|
|
return connection, nil
|
|
}
|
|
|
|
type cdpMessage struct {
|
|
ID int `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params"`
|
|
Result json.RawMessage `json:"result"`
|
|
Error json.RawMessage `json:"error"`
|
|
}
|
|
|
|
func cdpCommand(connection *websocket.Conn, commandID *int, method string, parameters any, output any, events *[]cdpMessage) error {
|
|
*commandID = *commandID + 1
|
|
if err := websocket.JSON.Send(connection, map[string]any{"id": *commandID, "method": method, "params": parameters}); err != nil {
|
|
return errors.New("restricted browser command failed")
|
|
}
|
|
for range 128 {
|
|
var reply cdpMessage
|
|
if err := websocket.JSON.Receive(connection, &reply); err != nil {
|
|
return errors.New("restricted browser command failed")
|
|
}
|
|
if reply.ID != *commandID {
|
|
if events != nil && reply.ID == 0 && reply.Method != "" {
|
|
*events = append(*events, reply)
|
|
}
|
|
continue
|
|
}
|
|
if len(reply.Error) != 0 || len(reply.Result) == 0 || bytes.Equal(bytes.TrimSpace(reply.Result), []byte("null")) {
|
|
return errors.New("restricted browser command failed")
|
|
}
|
|
if output != nil && json.Unmarshal(reply.Result, output) != nil {
|
|
return errors.New("restricted browser command failed")
|
|
}
|
|
return nil
|
|
}
|
|
return errors.New("restricted browser command failed")
|
|
}
|
|
|
|
func waitForDouyinPage(ctx context.Context, connection *websocket.Conn, commandID *int, frameID, loaderID string, events []cdpMessage) error {
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
|
deadline = contextDeadline
|
|
}
|
|
_ = connection.SetReadDeadline(deadline)
|
|
for attempts := 0; attempts < 256; attempts++ {
|
|
var event cdpMessage
|
|
if len(events) != 0 {
|
|
event, events = events[0], events[1:]
|
|
} else if websocket.JSON.Receive(connection, &event) != nil {
|
|
return errors.New("restricted browser navigation failed")
|
|
}
|
|
if event.Method != "Page.lifecycleEvent" {
|
|
continue
|
|
}
|
|
var lifecycle struct {
|
|
FrameID string `json:"frameId"`
|
|
LoaderID string `json:"loaderId"`
|
|
Name string `json:"name"`
|
|
}
|
|
if json.Unmarshal(event.Params, &lifecycle) != nil || lifecycle.FrameID != frameID || lifecycle.LoaderID != loaderID || lifecycle.Name != "load" {
|
|
continue
|
|
}
|
|
var evaluated struct {
|
|
Result struct {
|
|
Value string `json:"value"`
|
|
} `json:"result"`
|
|
}
|
|
if err := cdpCommand(connection, commandID, "Runtime.evaluate", map[string]any{
|
|
"expression": "location.origin", "returnByValue": true,
|
|
}, &evaluated, nil); err != nil || evaluated.Result.Value != douyinOrigin {
|
|
return errors.New("restricted browser navigation failed")
|
|
}
|
|
return nil
|
|
}
|
|
return errors.New("restricted browser navigation failed")
|
|
}
|
|
|
|
func detectDouyinChallenge(status int, body string) douyin.Challenge {
|
|
lower := strings.ToLower(body)
|
|
if status == http.StatusPreconditionFailed || strings.Contains(lower, "captcha") || strings.Contains(lower, "verify_center_decision_conf") {
|
|
return douyin.ChallengeCaptcha
|
|
}
|
|
if strings.Contains(lower, "device_challenge") || strings.Contains(lower, "device verification") {
|
|
return douyin.ChallengeDevice
|
|
}
|
|
return douyin.ChallengeNone
|
|
}
|