|
@@ -11,6 +11,8 @@ import (
|
|
|
"net/url"
|
|
"net/url"
|
|
|
"os"
|
|
"os"
|
|
|
"path/filepath"
|
|
"path/filepath"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "time"
|
|
|
|
|
|
|
|
"github.com/robertkrimen/otto"
|
|
"github.com/robertkrimen/otto"
|
|
|
"imuslab.com/arozos/mod/agi/static"
|
|
"imuslab.com/arozos/mod/agi/static"
|
|
@@ -23,9 +25,45 @@ import (
|
|
|
This is a library for allowing AGI script to make HTTP Request from the VM
|
|
This is a library for allowing AGI script to make HTTP Request from the VM
|
|
|
Returning either the head or the body of the request
|
|
Returning either the head or the body of the request
|
|
|
|
|
|
|
|
|
|
+ In addition to the classic helpers (get / post / head / download / getb64 /
|
|
|
|
|
+ getCode / redirect), the library exposes a curl-like http.request(options)
|
|
|
|
|
+ function that lets a script pick the method, set arbitrary request headers,
|
|
|
|
|
+ send a raw / JSON / url-encoded form / binary (base64) body, use HTTP basic
|
|
|
|
|
+ auth, set a timeout and control redirect following, then read back the
|
|
|
|
|
+ status code, response headers and body of the reply.
|
|
|
|
|
+
|
|
|
Author: tobychui
|
|
Author: tobychui
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
|
|
|
+// httpRequestOptions mirrors the JS options object passed to http.request. It
|
|
|
|
|
+// covers the most common parameters curl supports (method, headers, request
|
|
|
|
|
+// body in several shapes, basic auth, timeout and redirect handling).
|
|
|
|
|
+type httpRequestOptions struct {
|
|
|
|
|
+ URL string `json:"url"` //Target URL (required)
|
|
|
|
|
+ Method string `json:"method"` //HTTP method, default GET
|
|
|
|
|
+ Headers map[string]string `json:"headers"` //Request headers to set
|
|
|
|
|
+ Body string `json:"body"` //Raw text request body
|
|
|
|
|
+ BodyBase64 string `json:"bodyBase64"` //Binary request body, base64 encoded
|
|
|
|
|
+ Form map[string]string `json:"form"` //application/x-www-form-urlencoded body
|
|
|
|
|
+ JSON json.RawMessage `json:"json"` //JSON request body (sets Content-Type)
|
|
|
|
|
+ ContentType string `json:"contentType"` //Override the Content-Type header
|
|
|
|
|
+ Username string `json:"username"` //HTTP basic auth username
|
|
|
|
|
+ Password string `json:"password"` //HTTP basic auth password
|
|
|
|
|
+ Timeout float64 `json:"timeout"` //Timeout in seconds (0 = no timeout)
|
|
|
|
|
+ FollowRedirect *bool `json:"followRedirect"` //Follow 3xx redirects (default true)
|
|
|
|
|
+ ResponseType string `json:"responseType"` //"text" (default) or "base64" for binary
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// httpResponse is the object returned by http.request describing the reply.
|
|
|
|
|
+type httpResponse struct {
|
|
|
|
|
+ Ok bool `json:"ok"` //True when the status code is in the 2xx range
|
|
|
|
|
+ Status int `json:"status"` //HTTP status code
|
|
|
|
|
+ StatusText string `json:"statusText"` //HTTP status line text
|
|
|
|
|
+ Headers map[string][]string `json:"headers"` //Response headers
|
|
|
|
|
+ Body string `json:"body"` //Response body (text, or base64 when responseType is "base64")
|
|
|
|
|
+ Error string `json:"error"` //Non-empty when the request could not be completed
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
func (g *Gateway) HTTPLibRegister() {
|
|
func (g *Gateway) HTTPLibRegister() {
|
|
|
err := g.RegisterLib("http", g.injectHTTPFunctions)
|
|
err := g.RegisterLib("http", g.injectHTTPFunctions)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
@@ -34,6 +72,105 @@ func (g *Gateway) HTTPLibRegister() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// buildHTTPRequestBody resolves the request body and default Content-Type from
|
|
|
|
|
+// the given options. Body precedence is: bodyBase64 > form > json > body.
|
|
|
|
|
+func buildHTTPRequestBody(opt httpRequestOptions) (io.Reader, string, error) {
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case opt.BodyBase64 != "":
|
|
|
|
|
+ raw, err := base64.StdEncoding.DecodeString(opt.BodyBase64)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, "", errors.New("invalid bodyBase64: " + err.Error())
|
|
|
|
|
+ }
|
|
|
|
|
+ return bytes.NewReader(raw), "application/octet-stream", nil
|
|
|
|
|
+ case len(opt.Form) > 0:
|
|
|
|
|
+ values := url.Values{}
|
|
|
|
|
+ for k, v := range opt.Form {
|
|
|
|
|
+ values.Set(k, v)
|
|
|
|
|
+ }
|
|
|
|
|
+ return strings.NewReader(values.Encode()), "application/x-www-form-urlencoded", nil
|
|
|
|
|
+ case len(opt.JSON) > 0:
|
|
|
|
|
+ return bytes.NewReader(opt.JSON), "application/json", nil
|
|
|
|
|
+ case opt.Body != "":
|
|
|
|
|
+ return strings.NewReader(opt.Body), "", nil
|
|
|
|
|
+ default:
|
|
|
|
|
+ return nil, "", nil
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// doHTTPRequest builds and executes an HTTP request from the given options and
|
|
|
|
|
+// returns a populated httpResponse. Any transport-level failure is reported via
|
|
|
|
|
+// the Error field rather than as a Go error so scripts always get an object.
|
|
|
|
|
+func doHTTPRequest(opt httpRequestOptions) httpResponse {
|
|
|
|
|
+ if strings.TrimSpace(opt.URL) == "" {
|
|
|
|
|
+ return httpResponse{Error: "missing request url"}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ method := strings.ToUpper(strings.TrimSpace(opt.Method))
|
|
|
|
|
+ if method == "" {
|
|
|
|
|
+ method = "GET"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ body, defaultContentType, err := buildHTTPRequestBody(opt)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return httpResponse{Error: err.Error()}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ req, err := http.NewRequest(method, opt.URL, body)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return httpResponse{Error: err.Error()}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ //Apply a default Content-Type for the chosen body, then let explicit
|
|
|
|
|
+ //headers / the contentType option override it.
|
|
|
|
|
+ if defaultContentType != "" {
|
|
|
|
|
+ req.Header.Set("Content-Type", defaultContentType)
|
|
|
|
|
+ }
|
|
|
|
|
+ req.Header.Set("User-Agent", "arozos-http-client/1.1")
|
|
|
|
|
+ for k, v := range opt.Headers {
|
|
|
|
|
+ req.Header.Set(k, v)
|
|
|
|
|
+ }
|
|
|
|
|
+ if strings.TrimSpace(opt.ContentType) != "" {
|
|
|
|
|
+ req.Header.Set("Content-Type", opt.ContentType)
|
|
|
|
|
+ }
|
|
|
|
|
+ if opt.Username != "" || opt.Password != "" {
|
|
|
|
|
+ req.SetBasicAuth(opt.Username, opt.Password)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ client := &http.Client{}
|
|
|
|
|
+ if opt.Timeout > 0 {
|
|
|
|
|
+ client.Timeout = time.Duration(opt.Timeout * float64(time.Second))
|
|
|
|
|
+ }
|
|
|
|
|
+ if opt.FollowRedirect != nil && !*opt.FollowRedirect {
|
|
|
|
|
+ client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
|
|
|
+ return http.ErrUseLastResponse
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ resp, err := client.Do(req)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return httpResponse{Error: err.Error()}
|
|
|
|
|
+ }
|
|
|
|
|
+ defer resp.Body.Close()
|
|
|
|
|
+
|
|
|
|
|
+ bodyContent, err := io.ReadAll(resp.Body)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return httpResponse{Error: err.Error()}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ bodyString := string(bodyContent)
|
|
|
|
|
+ if strings.ToLower(strings.TrimSpace(opt.ResponseType)) == "base64" {
|
|
|
|
|
+ bodyString = base64.StdEncoding.EncodeToString(bodyContent)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return httpResponse{
|
|
|
|
|
+ Ok: resp.StatusCode >= 200 && resp.StatusCode < 300,
|
|
|
|
|
+ Status: resp.StatusCode,
|
|
|
|
|
+ StatusText: resp.Status,
|
|
|
|
|
+ Headers: resp.Header,
|
|
|
|
|
+ Body: bodyString,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
func (g *Gateway) injectHTTPFunctions(payload *static.AgiLibInjectionPayload) {
|
|
func (g *Gateway) injectHTTPFunctions(payload *static.AgiLibInjectionPayload) {
|
|
|
vm := payload.VM
|
|
vm := payload.VM
|
|
|
u := payload.User
|
|
u := payload.User
|
|
@@ -41,6 +178,26 @@ func (g *Gateway) injectHTTPFunctions(payload *static.AgiLibInjectionPayload) {
|
|
|
//scriptPath := payload.ScriptPath
|
|
//scriptPath := payload.ScriptPath
|
|
|
w := payload.Writer
|
|
w := payload.Writer
|
|
|
//r := payload.Request
|
|
//r := payload.Request
|
|
|
|
|
+
|
|
|
|
|
+ //_http_request(optionsJSON) => response object as JSON string. This is the
|
|
|
|
|
+ //curl-like entry point backing http.request and all method helpers.
|
|
|
|
|
+ vm.Set("_http_request", func(call otto.FunctionCall) otto.Value {
|
|
|
|
|
+ opt := httpRequestOptions{}
|
|
|
|
|
+ optJSON := getOttoStringArg(call, 0)
|
|
|
|
|
+ if s := strings.TrimSpace(optJSON); s != "" && s != "undefined" && s != "null" {
|
|
|
|
|
+ if err := json.Unmarshal([]byte(optJSON), &opt); err != nil {
|
|
|
|
|
+ out, _ := json.Marshal(httpResponse{Error: "invalid request options: " + err.Error()})
|
|
|
|
|
+ rv, _ := vm.ToValue(string(out))
|
|
|
|
|
+ return rv
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ resp := doHTTPRequest(opt)
|
|
|
|
|
+ out, _ := json.Marshal(resp)
|
|
|
|
|
+ rv, _ := vm.ToValue(string(out))
|
|
|
|
|
+ return rv
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
vm.Set("_http_get", func(call otto.FunctionCall) otto.Value {
|
|
vm.Set("_http_get", func(call otto.FunctionCall) otto.Value {
|
|
|
//Get URL from function variable
|
|
//Get URL from function variable
|
|
|
url, err := call.Argument(0).ToString()
|
|
url, err := call.Argument(0).ToString()
|
|
@@ -276,11 +433,53 @@ func (g *Gateway) injectHTTPFunctions(payload *static.AgiLibInjectionPayload) {
|
|
|
return otto.TrueValue()
|
|
return otto.TrueValue()
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- //Wrap all the native code function into an imagelib class
|
|
|
|
|
|
|
+ //Wrap all the native code function into an http class
|
|
|
vm.Run(`
|
|
vm.Run(`
|
|
|
var http = {};
|
|
var http = {};
|
|
|
- http.get = _http_get;
|
|
|
|
|
- http.post = _http_post;
|
|
|
|
|
|
|
+
|
|
|
|
|
+ //http.request(options) => response object {ok, status, statusText, headers, body, error}
|
|
|
|
|
+ //options: {url, method, headers, body, bodyBase64, form, json, contentType,
|
|
|
|
|
+ // username, password, timeout, followRedirect, responseType}
|
|
|
|
|
+ http.request = function(options){
|
|
|
|
|
+ return JSON.parse(_http_request(JSON.stringify(options || {})));
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ //Classic helpers. http.get / http.post now accept optional headers.
|
|
|
|
|
+ http.get = function(url, headers){
|
|
|
|
|
+ if (typeof headers == "undefined"){
|
|
|
|
|
+ //Backward-compatible fast path: returns body string (or null on error)
|
|
|
|
|
+ return _http_get(url);
|
|
|
|
|
+ }
|
|
|
|
|
+ return http.request({url: url, method: "GET", headers: headers}).body;
|
|
|
|
|
+ };
|
|
|
|
|
+ http.post = function(url, body, headers, contentType){
|
|
|
|
|
+ if (typeof headers == "undefined" && typeof contentType == "undefined"){
|
|
|
|
|
+ //Backward-compatible fast path: JSON body, returns body string
|
|
|
|
|
+ return _http_post(url, body);
|
|
|
|
|
+ }
|
|
|
|
|
+ return http.request({
|
|
|
|
|
+ url: url, method: "POST", body: body,
|
|
|
|
|
+ headers: headers, contentType: contentType
|
|
|
|
|
+ }).body;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ //Method + body-shape convenience helpers built on http.request.
|
|
|
|
|
+ http.put = function(url, body, headers, contentType){
|
|
|
|
|
+ return http.request({url: url, method: "PUT", body: body, headers: headers, contentType: contentType});
|
|
|
|
|
+ };
|
|
|
|
|
+ http.patch = function(url, body, headers, contentType){
|
|
|
|
|
+ return http.request({url: url, method: "PATCH", body: body, headers: headers, contentType: contentType});
|
|
|
|
|
+ };
|
|
|
|
|
+ http.delete = function(url, headers){
|
|
|
|
|
+ return http.request({url: url, method: "DELETE", headers: headers});
|
|
|
|
|
+ };
|
|
|
|
|
+ http.postForm = function(url, form, headers){
|
|
|
|
|
+ return http.request({url: url, method: "POST", form: form, headers: headers});
|
|
|
|
|
+ };
|
|
|
|
|
+ http.postJSON = function(url, obj, headers){
|
|
|
|
|
+ return http.request({url: url, method: "POST", json: obj, headers: headers});
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
http.head = _http_head;
|
|
http.head = _http_head;
|
|
|
http.download = _http_download;
|
|
http.download = _http_download;
|
|
|
http.getb64 = _http_getb64;
|
|
http.getb64 = _http_getb64;
|