Add user+pass login and 2fa support

This commit is contained in:
y a t s
2026-06-05 17:30:22 -04:00
parent 8e0720d587
commit 412f3108e1
6 changed files with 519 additions and 129 deletions
+232
View File
@@ -0,0 +1,232 @@
package libkiwi
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
type LoginResp struct {
Status string `json:"status"`
Message string `json:"message"`
Redirect *url.URL `json:"redirect"`
Visitor struct {
ConversationsUnread uint32 `json:"conversations_unread"`
AlertsUnviewed uint32 `json:"alerts_unviewed"`
TotalUnread uint32 `json:"total_unread"`
} `json:"visitor"`
}
func (lr *LoginResp) UnmarshalJSON(b []byte) error {
var jsonMap map[string]any
err := json.Unmarshal(b, &jsonMap)
if err != nil {
return err
}
for k, v := range jsonMap {
switch k {
case "status":
lr.Status = v.(string)
case "message":
lr.Message = v.(string)
case "redirect":
u, err := url.Parse(v.(string))
if err != nil {
return err
}
lr.Redirect = u
case "visitor":
for k, v := range v.(map[string]any) {
switch k {
case "conversations_unread":
convos, err := strconv.Atoi(v.(string))
if err != nil {
return err
}
lr.Visitor.ConversationsUnread = uint32(convos)
case "alerts_unviewed":
alerts, err := strconv.Atoi(v.(string))
if err != nil {
return err
}
lr.Visitor.AlertsUnviewed = uint32(alerts)
case "total_unread":
unread, err := strconv.Atoi(v.(string))
if err != nil {
return err
}
lr.Visitor.ConversationsUnread = uint32(unread)
}
}
}
}
return nil
}
var ErrNoXFToken = errors.New("Failed to locate xfToken on page.")
func xfToken(page io.Reader) (string, error) {
z := html.NewTokenizer(page)
for i := z.Next(); i != html.ErrorToken; i = z.Next() {
tk := z.Token()
if tk.DataAtom == atom.Html {
for _, a := range tk.Attr {
switch a.Key {
case "data-csrf":
return a.Val, nil
}
}
}
}
return "", ErrNoXFToken
}
func loginURL(host *url.URL) *url.URL {
return &url.URL{
Scheme: "https",
Host: host.Hostname(),
Path: "login",
}
}
func (kf *KF) newLoginRequest(ctx context.Context, user string, pass string) (*http.Request, error) {
u := loginURL(kf.domain)
resp, err := kf.GetPage(ctx, u)
if err != nil {
return nil, err
}
defer resp.Body.Close()
xfToken, err := xfToken(resp.Body)
if err != nil {
return nil, err
}
form := url.Values{
"_xfToken": {xfToken},
"login": {user},
"password": {pass},
"remember": {"1"},
}
// i.e. https://kiwifarms.net/login/login
postURL := fmt.Sprintf("%s/login", u.String())
req, err := http.NewRequestWithContext(ctx, "POST", postURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req, nil
}
func (kf *KF) Login(ctx context.Context, user string, pass string) (*http.Response, error) {
req, err := kf.newLoginRequest(ctx, user, pass)
if err != nil {
return nil, err
}
resp, err := kf.client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
// TODO
}
return resp, nil
}
func TwoFactorForm(xfToken string, code uint32, u *url.URL) url.Values {
return url.Values{
"code": {fmt.Sprint(code)},
"provider": {"totp"},
"trust": {"1"},
"confirm": {"1"},
"remember": {"1"},
"_xfRedirect": {fmt.Sprintf("https://%s/", u.Hostname())},
"_xfResponseType": {"json"},
"_xfToken": {xfToken},
"_xfWithData": {"1"},
"_xfRequestUri": {u.RequestURI()},
}
}
func (kf *KF) twoFactorAuthForm(ctx context.Context, resp *http.Response, code uint32) (*http.Request, error) {
u := resp.Request.URL
if !strings.Contains(u.Path, "two-step") {
// TODO: move err to proper type with url included.
return nil, errors.New("Did not redirect to two-step page.")
}
xfToken, err := xfToken(resp.Body)
if err != nil {
return nil, err
}
form := TwoFactorForm(xfToken, code, u)
reqURL := fmt.Sprintf("https://%s/login/two-step", u.Hostname())
req, err := http.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req, nil
}
func (kf *KF) TwoFactorAuth(ctx context.Context, resp *http.Response, code uint32) (*http.Response, error) {
req, err := kf.twoFactorAuthForm(ctx, resp, code)
if err != nil {
return nil, err
}
return kf.client.Do(req)
}
func setCookie(u *url.URL, jar http.CookieJar, newCookie *http.Cookie) {
cookies := jar.Cookies(u)
for i, c := range cookies {
if c.Name == newCookie.Name {
cookies[i] = newCookie
jar.SetCookies(u, cookies)
return
}
}
// Append if not already existing.
cookies = append(cookies, newCookie)
jar.SetCookies(u, cookies)
}
func (kf *KF) RefreshSession(ctx context.Context) error {
// Clear any existing session token to request a new one.
setCookie(kf.domain, kf.client.Jar, &http.Cookie{
Name: "xf_session",
Value: "",
})
resp, err := kf.GetPage(ctx, kf.domain)
if err != nil {
return err
}
resp.Body.Close()
return nil
}
+9 -5
View File
@@ -1,12 +1,16 @@
module gitgud.io/yats/libkiwi
go 1.25.6
require gitgud.io/yats/cerberus v0.0.0-20260214165307-66e6f74a4be9
go 1.26.1
require (
gitgud.io/yats/cerberus v0.0.0-20260214165307-66e6f74a4be9
github.com/PuerkitoBio/goquery v1.12.0
golang.org/x/net v0.55.0
)
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/sys v0.45.0 // indirect
)
+73 -4
View File
@@ -1,11 +1,80 @@
gitgud.io/yats/cerberus v0.0.0-20260214165307-66e6f74a4be9 h1:OSYrnxTeCuvaX6O8/AHUE4Xndb76vtcVwdvdLtGfp4Q=
gitgud.io/yats/cerberus v0.0.0-20260214165307-66e6f74a4be9/go.mod h1:WVfXXYUHR8x5hX0cpRUOlaeRqxR/9JxYhLbjFSb/jjc=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+101 -100
View File
@@ -4,60 +4,53 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strconv"
"strings"
"gitgud.io/yats/cerberus"
gq "github.com/PuerkitoBio/goquery"
)
type KF struct {
Client http.Client
client http.Client
domain *url.URL
}
// Supply your own http.Client to route through any proxies.
func NewKF(hc http.Client, host string, cookies string) (KF, error) {
u, err := parseHost(host)
func NewKF(hc http.Client, host *url.URL) (*KF, error) {
u, err := url.Parse(fmt.Sprintf("https://%s", host.Hostname()))
if err != nil {
return KF{}, err
return nil, err
}
if hc.Jar == nil {
jar, err := cookiejar.New(nil)
if err != nil {
return KF{}, err
}
hc.Jar = jar
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
if cookies != "" {
cs, err := parseCookieString(u, cookies)
if err != nil {
return KF{}, err
}
hc.Jar = jar
hc.Jar.SetCookies(u, cs)
kf := &KF{
client: hc,
domain: u,
}
// Update host url in case we get redirected across domains.
hc.CheckRedirect = func(req *http.Request, via []*http.Request) error {
hn := req.URL.Hostname()
if hn != u.Hostname() {
reqHost := req.URL.Hostname()
if reqHost != u.Hostname() {
// Deliberately set to Hostname() and not Host.
// This excludes any extra shit like ports.
u.Host = hn
u.Host = reqHost
}
return nil
}
return KF{
Client: hc,
domain: u,
}, nil
return kf, nil
}
func (kf *KF) GetPage(ctx context.Context, u *url.URL) (*http.Response, error) {
@@ -65,24 +58,20 @@ func (kf *KF) GetPage(ctx context.Context, u *url.URL) (*http.Response, error) {
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 6.1; rv:60.0) Gecko/20100101 Firefox/60.0")
resp, err := kf.Client.Do(req)
resp, err := kf.client.Do(req)
if err != nil {
return nil, err
}
hn := resp.Request.URL.Hostname()
if hn != kf.domain.Hostname() {
jar := kf.Client.Jar
jar.SetCookies(resp.Request.URL, jar.Cookies(kf.domain))
kf.domain.Host = hn
}
// KiwiFlare redirect is signaled by 203 status.
if resp.StatusCode == 203 {
err = kf.SolveKiwiFlare(ctx)
err = kf.solveKiwiFlare(ctx)
if err != nil {
return nil, err
}
// Try fetching the page again now that we're authed.
return kf.GetPage(ctx, u)
}
@@ -90,90 +79,102 @@ func (kf *KF) GetPage(ctx context.Context, u *url.URL) (*http.Response, error) {
return resp, nil
}
func parseCookieString(u *url.URL, cookies string) ([]*http.Cookie, error) {
sp := strings.Split(cookies, "; ")
cs := make([]*http.Cookie, len(sp))
for i, c := range sp {
kv := strings.Split(c, "=")
if len(kv) != 2 {
return nil, errors.New("Invalid cookie string: " + cookies)
}
cs[i] = &http.Cookie{
Domain: u.Hostname(),
Name: kv[0],
Path: "/",
Value: kv[1],
}
}
return cs, nil
type User struct {
ID uint32
Name string
Title string
URL *url.URL
}
func setCookie(jar http.CookieJar, u *url.URL, cookie *http.Cookie) {
cookies := jar.Cookies(u)
func parsePostAuthor(article *gq.Selection) (User, error) {
userBlock := article.Find("section.message-user")
for i, c := range cookies {
if c.Name == cookie.Name {
cookies[i] = cookie
// Store changes and stop looping since it's found.
jar.SetCookies(u, cookies)
return
}
idStr, ok := userBlock.Attr("data-user-id")
if !ok {
// TODO: Proper error types.
return User{}, errors.New("Failed to parse post author attr.")
}
// Append new cookie if existing one not found.
cookies = append(cookies, cookie)
jar.SetCookies(u, cookies)
}
func (kf *KF) CookieString() string {
var b strings.Builder
for _, c := range kf.Client.Jar.Cookies(kf.domain) {
fmt.Fprintf(&b, "%s: %s; ", c.Name, c.Value)
}
return strings.TrimSuffix(b.String(), "; ")
}
func (kf *KF) RefreshSession(ctx context.Context) (string, error) {
// Clear any existing session token to request a new one.
setCookie(kf.Client.Jar, kf.domain, &http.Cookie{
Name: "xf_session",
Value: "",
})
resp, err := kf.GetPage(ctx, kf.domain)
id, err := strconv.Atoi(idStr)
if err != nil {
return "", err
return User{}, err
}
name, ok := article.Attr("data-author")
if !ok {
return User{}, errors.New("Failed to parse post author attr.")
}
title := userBlock.Find(".message-userTitle").Text()
urlStr, ok := userBlock.Attr("itemid")
if !ok {
return User{}, errors.New("Failed to parse post author attr.")
}
u, err := url.Parse(urlStr)
if err != nil {
return User{}, err
}
user := User{
ID: uint32(id),
Name: name,
Title: title,
URL: u,
}
return user, nil
}
type Post struct {
Author User
Body io.Reader
}
func (kf *KF) GetPost(ctx context.Context, postID uint32) (Post, error) {
// Example post goto link: https://kiwifarms.st/goto/post?id=22058462
gtl := fmt.Sprintf("%s/goto/post?id=%d", kf.domain.String(), postID)
u, err := url.Parse(gtl)
if err != nil {
return Post{}, err
}
resp, err := kf.GetPage(ctx, u)
if err != nil {
return Post{}, err
}
defer resp.Body.Close()
return regexp.MustCompile(`xf_session=([^;]*)`).FindString(resp.Header.Get("Set-Cookie")), nil
}
doc, err := gq.NewDocumentFromReader(resp.Body)
if err != nil {
return Post{}, err
}
func (kf *KF) SolveKiwiFlare(ctx context.Context) error {
c, err := cerberus.NewChallenge(ctx, kf.Client, kf.domain.String())
if err != nil {
return err
// Selector: #js-post-22058462
article := doc.Find(fmt.Sprintf("article#js-post-%d", postID))
body := article.Find("div.message-content article.message-body")
if body.Length() == 0 {
return Post{}, errors.New("Failed to parse post message body.")
}
s, err := cerberus.Solve(ctx, c)
bh, err := body.Html()
if err != nil {
return err
return Post{}, err
}
resp, err := cerberus.Submit(ctx, kf.Client, s, "")
r := strings.NewReader(bh)
author, err := parsePostAuthor(article)
if err != nil {
return err
return Post{}, err
}
defer resp.Body.Close()
return nil
}
func parseHost(host string) (*url.URL, error) {
// Try prepending protocol if it seems to be missing.
if !strings.Contains(host, "://") {
host = "https://" + host
post := Post{
Author: author,
Body: r,
}
return url.Parse(host)
return post, nil
}
+80 -20
View File
@@ -1,52 +1,112 @@
package libkiwi
import (
"log"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"testing"
"golang.org/x/net/proxy"
)
const TEST_HOST = "kiwifarms.st"
const _TEST_HOST = "kiwifarms.st"
func TestGetPage(t *testing.T) {
cookies := os.Getenv("TEST_COOKIES")
kf, err := NewKF(http.Client{}, TEST_HOST, cookies)
kf, err := newTestKF()
if err != nil {
t.Error(err)
}
log.Println("Getting homepage")
resp, err := kf.GetPage(t.Context(), kf.domain)
ctx := t.Context()
t.Log("Getting homepage\n")
resp, err := kf.GetPage(ctx, kf.domain)
if err != nil {
t.Error(err)
return
}
defer resp.Body.Close()
log.Printf("Response status code: %d\n\n", resp.StatusCode)
t.Logf("Response status code: %d\n\n", resp.StatusCode)
for k, v := range resp.Header {
if len(v) > 0 {
log.Printf("%s: %s\n", k, v[0])
t.Logf("%s: %s\n", k, v[0])
}
}
log.Printf("Response host: %s\n\n", kf.domain)
log.Printf("Cookies: %+v\n", resp.Cookies())
t.Logf("Response host: %s\n\n", kf.domain)
}
func TestRefreshSession(t *testing.T) {
cookies := os.Getenv("TEST_COOKIES")
kf, err := NewKF(http.Client{}, TEST_HOST, cookies)
if err != nil {
t.Error(err)
}
log.Println("Refreshing xf_session")
tk, err := kf.RefreshSession(t.Context())
func TestGetPost(t *testing.T) {
kf, err := newTestKF()
if err != nil {
t.Error(err)
return
}
log.Println("New xf_session token: " + tk)
const POST_ID = 22058462
ctx := t.Context()
t.Logf("Getting post #%d\n", POST_ID)
post, err := kf.GetPost(ctx, POST_ID)
if err != nil {
t.Error(err)
return
}
t.Logf("Post author: %+v\n", post.Author)
}
func newTestKF() (*KF, error) {
host := os.Getenv("KF_HOST")
if host == "" {
host = _TEST_HOST
}
u, err := url.Parse("https://" + host)
if err != nil {
return nil, err
}
p := proxy.FromEnvironment()
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DialContext = p.(proxy.ContextDialer).DialContext
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
hc := http.Client{
Jar: jar,
Transport: tr,
}
return NewKF(hc, u)
}
func TestLogin(t *testing.T) {
user, pass := os.Getenv("TEST_USER"), os.Getenv("TEST_PASS")
if user == "" || pass == "" {
t.Log("TEST_USER and/or TEST_PASS empty. Skipping.\n")
return
}
kf, err := newTestKF()
if err != nil {
t.Error(err)
return
}
ctx := t.Context()
resp, err := kf.Login(ctx, user, pass)
if err != nil {
t.Error(err)
return
}
defer resp.Body.Close()
t.Logf("Login response: %+v\n", resp)
}
+24
View File
@@ -0,0 +1,24 @@
package libkiwi
import (
"context"
"gitgud.io/yats/cerberus"
)
func (kf *KF) solveKiwiFlare(ctx context.Context) error {
c, err := cerberus.NewChallenge(ctx, kf.client, kf.domain.String())
if err != nil {
return err
}
s, err := cerberus.Solve(ctx, c)
if err != nil {
return err
}
_, err = cerberus.Submit(ctx, kf.client, s, "/")
if err != nil {
return err
}
return nil
}