diff --git a/client.go b/client.go new file mode 100644 index 0000000..70cf9fb --- /dev/null +++ b/client.go @@ -0,0 +1,265 @@ +package rumble + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + + "github.com/robertkrimen/otto" +) + +const ( + domain = "rumble.com" + urlBase = "https://" + domain + urlAccount = urlBase + "/account" + urlService = urlBase + "/service.php?name=" + urlServiceUserGetSalts = urlService + "user.get_salts" + urlServiceUserLogin = urlService + "user.login" + urlServiceUserLogout = urlService + "user.logout" +) + +type Client struct { + httpClient *http.Client +} + +type NewClientOptions struct { + Cookies []*http.Cookie +} + +func NewClient(opts NewClientOptions) (*Client, error) { + cl, err := newHttpClient(opts.Cookies) + if err != nil { + return nil, pkgErr("error creating new http client: %v", err) + } + + return &Client{httpClient: cl}, nil +} + +func newHttpClient(cookies []*http.Cookie) (*http.Client, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, fmt.Errorf("error creating cookiejar: %v", err) + } + + url, err := url.Parse(urlBase) + if err != nil { + return nil, fmt.Errorf("error parsing url: %v", err) + } + jar.SetCookies(url, cookies) + + return &http.Client{Jar: jar}, nil +} + +func (c *Client) Login(username string, password string) ([]*http.Cookie, error) { + if c.httpClient == nil { + return nil, pkgErr("", fmt.Errorf("http client is nil")) + } + + salts, err := c.getSalts(username) + if err != nil { + return nil, pkgErr("error getting salts: %v", err) + } + + cookies, err := c.login(username, password, salts) + if err != nil { + return nil, pkgErr("error logging in", err) + } + + return cookies, nil +} + +type GetSaltsData struct { + Salts []string `json:"salts"` +} + +type GetSaltsResponse struct { + Data GetSaltsData `json:"data"` +} + +func (c *Client) getSalts(username string) ([]string, error) { + u := url.URL{} + q := u.Query() + q.Add("username", username) + body := q.Encode() + + resp, err := c.httpClient.Post(urlServiceUserGetSalts, "application/x-www-form-urlencoded", strings.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("http post request returned error: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http post response status not %s: %s", http.StatusText(http.StatusOK), resp.Status) + } + + bodyB, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %v", err) + } + + var gsr GetSaltsResponse + err = json.NewDecoder(strings.NewReader(string(bodyB))).Decode(&gsr) + if err != nil { + return nil, fmt.Errorf("error decoding response body: %v", err) + } + + return gsr.Data.Salts, nil +} + +func (c *Client) login(username string, password string, salts []string) ([]*http.Cookie, error) { + hashes, err := hash(password, salts) + if err != nil { + return nil, fmt.Errorf("error generating password hashes: %v", err) + } + + u := url.URL{} + q := u.Query() + q.Add("username", username) + q.Add("password_hashes", hashes) + body := q.Encode() + resp, err := c.httpClient.Post(urlServiceUserLogin, "application/x-www-form-urlencoded", strings.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("http post request returned error: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http post response status not %s: %s", http.StatusText(http.StatusOK), resp.Status) + } + + bodyB, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %v", err) + } + + session, err := loginSession(bodyB) + if err != nil { + return nil, fmt.Errorf("error getting login session: %v", err) + } + + if session == "false" { + return nil, fmt.Errorf("failed to log in ") + } + + return resp.Cookies(), nil +} + +func hash(password string, salts []string) (string, error) { + vm := otto.New() + + vm.Set("password", password) + vm.Set("salt0", salts[0]) + vm.Set("salt1", salts[1]) + vm.Set("salt2", salts[2]) + + _, err := vm.Run(md5) + if err != nil { + return "", fmt.Errorf("error running md5 javascript: %v", err) + } + + hashesV, err := vm.Get("hashes") + if err != nil { + return "", fmt.Errorf("error getting hashes: %v", err) + } + + hashesS, err := hashesV.ToString() + if err != nil { + return "", fmt.Errorf("error converting hashes value to string: %v", err) + } + + return hashesS, nil +} + +type LoginSessionDataBool struct { + Session bool `json:"session"` +} + +type LoginSessionBool struct { + Data LoginSessionDataBool `json:"data"` +} + +type LoginSessionDataString struct { + Session string `json:"session"` +} + +type LoginSessionString struct { + Data LoginSessionDataString `json:"data"` +} + +func loginSession(body []byte) (string, error) { + bodyS := string(body) + + var lss LoginSessionString + err := json.NewDecoder(strings.NewReader(bodyS)).Decode(&lss) + if err == nil { + return lss.Data.Session, nil + } + + var lsb LoginSessionBool + err = json.NewDecoder(strings.NewReader(bodyS)).Decode(&lsb) + if err == nil { + return "false", nil + } + + return "", fmt.Errorf("error decoding response body") +} + +func (c *Client) Logout() error { + if c.httpClient == nil { + return pkgErr("", fmt.Errorf("http client is nil")) + } + + err := c.logout() + if err != nil { + return pkgErr("error logging out", err) + } + + return nil +} + +func (c *Client) logout() error { + resp, err := c.httpClient.Get(urlServiceUserLogout) + if err != nil { + return fmt.Errorf("http get request returned error: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("http get response status not %s: %s", http.StatusText(http.StatusOK), resp.Status) + } + + return nil +} + +type LoggedInResponseUser struct { + LoggedIn bool `json:"logged_in"` +} + +type LoggedInResponse struct { + User LoggedInResponseUser `json:"user"` +} + +func (c *Client) LoggedIn() (bool, error) { + resp, err := c.httpClient.Get(urlServiceUserLogin) + if err != nil { + return false, pkgErr("http get request returned error", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("http get response status not %s: %s", http.StatusText(http.StatusOK), resp.Status) + } + + bodyB, err := io.ReadAll(resp.Body) + if err != nil { + return false, pkgErr("error reading response body", err) + } + + var lir LoggedInResponse + err = json.NewDecoder(strings.NewReader(string(bodyB))).Decode(&lir) + if err != nil { + return false, pkgErr("error un-marshaling response body", err) + } + + return lir.User.LoggedIn, nil +} diff --git a/error.go b/error.go new file mode 100644 index 0000000..b28a59d --- /dev/null +++ b/error.go @@ -0,0 +1,14 @@ +package rumble + +import "fmt" + +const pkgName = "rumble" + +func pkgErr(prefix string, err error) error { + pkgErr := pkgName + if prefix != "" { + pkgErr = fmt.Sprintf("%s: %s", pkgErr, prefix) + } + + return fmt.Errorf("%s: %v", pkgErr, err) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1afae8a --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/tylertravisty/rumble-lib-go + +go 1.22.2 + +require ( + github.com/robertkrimen/otto v0.4.0 // indirect + golang.org/x/text v0.4.0 // indirect + gopkg.in/sourcemap.v1 v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ae0405c --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/robertkrimen/otto v0.4.0 h1:/c0GRrK1XDPcgIasAsnlpBT5DelIeB9U/Z/JCQsgr7E= +github.com/robertkrimen/otto v0.4.0/go.mod h1:uW9yN1CYflmUQYvAMS0m+ZiNo3dMzRUDQJX0jWbzgxw= +golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= +gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= diff --git a/md5.js.go b/md5.js.go new file mode 100644 index 0000000..64b0f6d --- /dev/null +++ b/md5.js.go @@ -0,0 +1,85 @@ +package rumble + +const md5 = ` +/* @license + * Version 2.2 Copyright (C) Paul Johnston 1999 - 2009 + * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet + * Distributed under the BSD License + * See http://pajhome.org.uk/crypt/md5 for more info. + */ + +md5 = function() { + function n(){ + this.hex="0123456789abcdef".split("") + } + return n.prototype={ + hash:function(n){ + var h=this; + return h.binHex(h.binHash(h.strBin(n),n.length<<3)) + }, + hashUTF8:function(n){ + return this.hash(this.encUTF8(n)) + }, + hashRaw:function(n){ + var h=this; + return h.binStr(h.binHash(h.strBin(n),n.length<<3)) + }, + hashRawUTF8:function(n){ + return this.hashRaw(this.encUTF8(n)) + }, + hashStretch:function(n,h,i){ + return this.binHex(this.binHashStretch(n,h,i)) + }, + binHashStretch:function(n,h,i){ + var t,r,f=this,n=f.encUTF8(n),e=h+n,g=32+n.length<<3,o=f.strBin(n),a=o.length,e=f.binHash(f.strBin(e),e.length<<3); + for(i=i||1024,t=0;t>>6&31,128|63&h):h<=65535?t+=String.fromCharCode(224|h>>>12&15,128|h>>>6&63,128|63&h):h<=2097151&&(t+=String.fromCharCode(240|h>>>18&7,128|h>>>12&63,128|h>>>6&63,128|63&h)); + return t + }, + strBin:function(n){ + for(var h=n.length<<3,i=[],t=0;t>5]|=(255&n.charCodeAt(t>>3))<<(31&t); + return i + }, + binHex:function(n){ + for(var h,i,t="",r=n.length<<5,f=0;f>5]>>>(31&f)&255)>>>4&15,t+=this.hex[i]+this.hex[h&=15]; + return t + }, + binStr:function(n){ + for(var h,i="",t=n.length<<5,r=0;r>5]>>>(31&r)&255,i+=String.fromCharCode(h); + return i + }, + binHexBin:function(n){ + for(var h,i,t=n.length<<5,r=[],f=0;f>5]>>>(31&f)&255)>>>4&15,r[f>>4]|=(9>16)+(i>>16)+(r>>16)+(e>>16)+(t>>16)<<16|65535&t)<>>32-f)>>16)+(h>>16)+((t=(65535&i)+(65535&h))>>16)<<16|65535&t + }, + gg:function(n,h,i,t,r,f,e){ + i=h&t|i&~t,t=(65535&n)+(65535&i)+(65535&r)+(65535&e); + return((i=(i=(n>>16)+(i>>16)+(r>>16)+(e>>16)+(t>>16)<<16|65535&t)<>>32-f)>>16)+(h>>16)+((t=(65535&i)+(65535&h))>>16)<<16|65535&t + }, + hh:function(n,h,i,t,r,f,e){ + i=h^i^t,t=(65535&n)+(65535&i)+(65535&r)+(65535&e); + return((i=(i=(n>>16)+(i>>16)+(r>>16)+(e>>16)+(t>>16)<<16|65535&t)<>>32-f)>>16)+(h>>16)+((t=(65535&i)+(65535&h))>>16)<<16|65535&t + }, + ii:function(n,h,i,t,r,f,e){ + i^=h|~t,t=(65535&n)+(65535&i)+(65535&r)+(65535&e); + return((i=(i=(n>>16)+(i>>16)+(r>>16)+(e>>16)+(t>>16)<<16|65535&t)<>>32-f)>>16)+(h>>16)+((t=(65535&i)+(65535&h))>>16)<<16|65535&t + }, + binHash:function(n,h){ + var i,t,r,f,e,g,o=1732584193,a=-271733879,u=-1732584194,s=271733878,c=this;for(n[h>>5]|=128<<(31&h),n[14+(h+64>>>9<<4)]=h,i=n.length,t=0;t>16)+(g>>16)+((g=(65535&o)+(65535&g))>>16)<<16|65535&g,a=(a>>16)+(r>>16)+((g=(65535&a)+(65535&r))>>16)<<16|65535&g,u=(u>>16)+(f>>16)+((g=(65535&u)+(65535&f))>>16)<<16|65535&g,s=(s>>16)+(e>>16)+((g=(65535&s)+(65535&e))>>16)<<16|65535&g; + return[o,a,u,s] + } + }, + new n +}(); + +hashes = [md5.hash(md5.hashStretch(password, salt0, 128) + salt1),md5.hashStretch(password, salt2, 128), salt1] +`