From c258b46443e8c26bd723a8eb05be4b737632cd44 Mon Sep 17 00:00:00 2001 From: Tom Wiesing Date: Sun, 16 Oct 2022 11:32:03 +0200 Subject: [PATCH] add wisski_php_server --- cmd/drupal_setting.go | 4 +- cmd/info.go | 2 +- cmd/pathbuilders.go | 5 +- cmd/prefixes.go | 2 +- internal/component/instances/php/server.php | 34 ++ .../{wisski_status.go => wisski_info.go} | 85 ++--- .../instances/wisski_pathbuilders.go | 19 +- internal/component/instances/wisski_php.go | 132 +------- .../component/instances/wisski_php_server.go | 295 ++++++++++++++++++ internal/component/instances/wisski_prefix.go | 14 +- .../snapshots/extras_pathbuilders.go | 2 +- 11 files changed, 411 insertions(+), 183 deletions(-) create mode 100644 internal/component/instances/php/server.php rename internal/component/instances/{wisski_status.go => wisski_info.go} (60%) create mode 100644 internal/component/instances/wisski_php_server.go diff --git a/cmd/drupal_setting.go b/cmd/drupal_setting.go index 53fc0d3..1749aa9 100644 --- a/cmd/drupal_setting.go +++ b/cmd/drupal_setting.go @@ -47,7 +47,7 @@ func (ds setting) Run(context wisski_distillery.Context) error { if ds.Positionals.Value == "" { // get the setting - value, err := instance.GetSettingsPHP(ds.Positionals.Setting) + value, err := instance.GetSettingsPHP(nil, ds.Positionals.Setting) if err != nil { return errSettingGet.Wrap(err) } @@ -69,7 +69,7 @@ func (ds setting) Run(context wisski_distillery.Context) error { } // set the serialized value! - if err := instance.SetSettingsPHP(ds.Positionals.Setting, data); err != nil { + if err := instance.SetSettingsPHP(nil, ds.Positionals.Setting, data); err != nil { return errSettingSet.Wrap(err) } diff --git a/cmd/info.go b/cmd/info.go index 6084165..11856ba 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -60,7 +60,7 @@ func (i info) Run(context wisski_distillery.Context) error { context.Printf("Running: %v\n", info.Running) context.Printf("Locked: %v\n", info.Locked) context.Printf("Last Rebuild: %v\n", info.LastRebuild.String()) - context.Printf("Last Update: %v\n", info.LastUpdate.String()) + context.Printf("Last Update: %v\n", info.LastUpdate.String()) context.Printf("Skip Prefixes: %v\n", info.NoPrefixes) context.Printf("Prefixes: (count %d)\n", len(info.Prefixes)) diff --git a/cmd/pathbuilders.go b/cmd/pathbuilders.go index e5e9509..0dde06e 100644 --- a/cmd/pathbuilders.go +++ b/cmd/pathbuilders.go @@ -37,6 +37,7 @@ var errNoPathbuilder = exit.Error{ } func (pb pathbuilders) Run(context wisski_distillery.Context) error { + // get the wisski instance, err := context.Environment.Instances().WissKI(pb.Positionals.Slug) if err != nil { @@ -45,7 +46,7 @@ func (pb pathbuilders) Run(context wisski_distillery.Context) error { // get all of the pathbuilders if pb.Positionals.Name == "" { - names, err := instance.Pathbuilders() + names, err := instance.Pathbuilders(nil) if err != nil { return errPathbuilders.WithMessageF(err) } @@ -56,7 +57,7 @@ func (pb pathbuilders) Run(context wisski_distillery.Context) error { } // get all the pathbuilders - xml, err := instance.Pathbuilder(pb.Positionals.Name) + xml, err := instance.Pathbuilder(nil, pb.Positionals.Name) if xml == "" { return errNoPathbuilder.WithMessageF(pb.Positionals.Name) } diff --git a/cmd/prefixes.go b/cmd/prefixes.go index 31b1472..f596ca5 100644 --- a/cmd/prefixes.go +++ b/cmd/prefixes.go @@ -36,7 +36,7 @@ func (p prefixes) Run(context wisski_distillery.Context) error { return err } - prefixes, err := instance.Prefixes() + prefixes, err := instance.Prefixes(nil) if err != nil { return errPrefixesGeneric.Wrap(err) } diff --git a/internal/component/instances/php/server.php b/internal/component/instances/php/server.php new file mode 100644 index 0000000..59f37d8 --- /dev/null +++ b/internal/component/instances/php/server.php @@ -0,0 +1,34 @@ +" + code) - if err != nil { - return err - } - - entrypointEscape, err := marshalPHP(entrypoint) - if err != nil { - return err - } - - argsEscape, err := marshalPHP(args) - if err != nil { - return err - } - - // assemble the script - script := ` - ob_start(null, 0, PHP_OUTPUT_HANDLER_CLEANABLE); - eval(` + codeEscape + `); - ob_end_clean(); - - call_user_func(function(){ - ob_start(null, 0, PHP_OUTPUT_HANDLER_CLEANABLE); - $result = call_user_func_array(` + entrypointEscape + `, ` + argsEscape + `); - ob_end_clean(); - echo json_encode($result); - }); -` - - // run the script - var output bytes.Buffer - res, err := wisski.Shell(io.Streams(&output, nil, strings.NewReader(script), 0), "-c", "drush php:script -") - if res != 0 { - return ErrExecNonZero - } - if err != nil { - return err - } - - // did not request to receive a result - if result == nil { - return nil - } - - // decode the output - return json.NewDecoder(&output).Decode(result) -} - -const marshalRune = 'F' // press to pay respect - -// marshalPHP marshals some data which can be marshaled using [json.Encode] into a PHP Expression. -// the string can be safely used directly within php. -func marshalPHP(data any) (string, error) { - // this function uses json as a data format to transport the data into php. - // then we build a heredoc to encode it safely, and decode it in php - - // Step 1: Encode the data as json - jbytes, err := json.Marshal(data) - if err != nil { - return "", err - } - jstring := string(jbytes) - - // Step 2: Find a delimiter for the heredoc. - // Step 2a: Find the longest sequence of [marshalRune]s inside the encoded string. - var current, longest int - for _, r := range jstring { - - if r == marshalRune { - current++ - } else { - current = 0 - } - - if current > longest { - longest = current - } - } - // Step 2b: Build a string of marshalRune that is one longer! - delim := strings.Repeat(string(marshalRune), longest+1) - - // Step 3: Assemble the encoded string! - result := "call_user_func(function(){$x=<<<'" + delim + "'\n" + jstring + "\n" + delim + ";return json_decode(trim($x));})" // press to doubt - return result, nil -} - -// - //go:embed php/settings.php var settingsPHP string -func (wisski *WissKI) GetSettingsPHP(key string) (value any, err error) { - err = wisski.ExecPHPScript(stream.FromNil(), &value, settingsPHP, "get_setting", key) +func (wisski *WissKI) GetSettingsPHP(server *PHPServer, key string) (value any, err error) { + err = wisski.ExecPHPScript(server, &value, settingsPHP, "get_setting", key) return } -func (wisski *WissKI) SetSettingsPHP(key string, value any) error { - return wisski.ExecPHPScript(stream.FromNil(), nil, settingsPHP, "set_setting", key, value) +func (wisski *WissKI) SetSettingsPHP(server *PHPServer, key string, value any) error { + return wisski.ExecPHPScript(server, nil, settingsPHP, "set_setting", key, value) } diff --git a/internal/component/instances/wisski_php_server.go b/internal/component/instances/wisski_php_server.go new file mode 100644 index 0000000..fef554b --- /dev/null +++ b/internal/component/instances/wisski_php_server.go @@ -0,0 +1,295 @@ +package instances + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "sync" + + _ "embed" + + "github.com/alessio/shellescape" + "github.com/tkw1536/goprogram/lib/collection" + "github.com/tkw1536/goprogram/lib/nobufio" + "github.com/tkw1536/goprogram/stream" +) + +// Common PHP Error +var ( + errPHPInit = "Unable to initialize" + errPHPMarshal = "Marshal failed" + errPHPInvalid = PHPServerError{Message: "Invalid code to execute"} + errPHPReceive = "Failed to receive response" + errPHPClosed = PHPServerError{Message: "Server closed"} +) + +// PHPError represents an error during PHPServer logic +type PHPServerError struct { + Message string + Err error +} + +func (err PHPServerError) Unwrap() error { + return err.Err +} + +func (err PHPServerError) Error() string { + if err.Err == nil { + return fmt.Sprintf("PHPServer: %s", err.Message) + } + return fmt.Sprintf("PHPServer: %s: %s", err.Message, err.Err) +} + +// PHPThrowable represents an error during php code +type PHPThrowable string + +func (throwable PHPThrowable) Error() string { + return string(throwable) +} + +// NewPHPServer returns a new server that can execute code within this distillery. +// When err == nil, the caller must call server.Close(). +// +// See [PHPServer]. +func (wisski *WissKI) NewPHPServer() (*PHPServer, error) { + // create input and output pipes + ir, iw, err := os.Pipe() + if err != nil { + return nil, PHPServerError{errPHPInit, err} + } + or, ow, err := os.Pipe() + if err != nil { + ir.Close() + iw.Close() + return nil, PHPServerError{errPHPInit, err} + } + + // create a context to close the server + context, cancel := context.WithCancel(context.Background()) + + // start the shell process, which will close everything once done + go func() { + defer func() { + ir.Close() + iw.Close() + or.Close() + ow.Close() + + cancel() + }() + + // start the server + io := stream.NewIOStream(ow, nil, ir, 0) + wisski.Shell(io, "-c", shellescape.QuoteCommand([]string{"drush", "php:eval", serverPHP})) + }() + + // return the seerver + return &PHPServer{ + in: iw, + out: or, + c: context, + }, nil +} + +// PHPServer represents a server that executes code within a distillery. +// A typical use-case is to define functions using [MarshalEval], and then call those functions [MarshalCall]. +// +// A nil PHPServer will return [ErrServerBroken] on every function call. +type PHPServer struct { + m sync.Mutex + + in io.WriteCloser + out io.Reader + c context.Context +} + +// MarshalEval evaluates code on the server and Marshals the result into value. +// When value is nil, the results are discarded. +// +// code is directly passed to php's "eval" function. +// as such any functions defined will remain in server memory. +// +// When an exception is thrown by the PHP Code, error is not nil, and dest remains unchanged. +func (server *PHPServer) MarshalEval(value any, code string) error { + server.m.Lock() + defer server.m.Unlock() + + // quick hack: when the server is already done + if err := server.c.Err(); err != nil { + return errPHPClosed + } + + // marshal the code, and send it to the server + bytes, err := json.Marshal(code) + if err != nil { + return PHPServerError{Message: errPHPMarshal, Err: err} + } + + // send it to the server + io.WriteString(server.in, string(bytes)+"\n") + + // read the next line (as a response) + data, err := nobufio.ReadLine(server.out) + if err != nil { + return PHPServerError{Message: errPHPReceive, Err: err} + } + + // read whatever we received + var received [2]json.RawMessage + if err := json.Unmarshal([]byte(data), &received); err != nil { + return PHPServerError{Message: errPHPMarshal, Err: err} + } + + // check if there was an error + var errString string + if err := json.Unmarshal(received[1], &errString); err == nil && errString != "" { + return PHPThrowable(errString) + } + + // special case: no return value => no unmarshaling needed + if value == nil { + return nil + } + + // read the actual result! + return json.Unmarshal(received[0], value) +} + +// Eval is like [MarshalEval], but returns the value as an any +func (server *PHPServer) Eval(code string) (value any, err error) { + err = server.MarshalEval(&value, code) + return +} + +// MarshalCall calls a previously defined function with the given arguments. +// Arguments are sent to php using json Marshal, and are 'json_decode'd on the php side. +// +// Return values are received as in [MarshalEval]. +func (server *PHPServer) MarshalCall(value any, function string, args ...any) error { + // marshal a code for the call + userFunction, err := marshalPHP(function) + if err != nil { + return PHPServerError{Message: errPHPMarshal, Err: err} + } + userFunctionArgs, err := marshalPHP(args) + if err != nil { + return PHPServerError{Message: errPHPMarshal, Err: err} + } + code := "return call_user_func_array(" + userFunction + "," + userFunctionArgs + ");" + + // and return the evaluated code! + return server.MarshalEval(value, code) +} + +// Call is like [MarshalCall] but returns the return value of the function as an any +func (server *PHPServer) Call(function string, args ...any) (value any, err error) { + err = server.MarshalCall(&value, function, args...) + return +} + +const marshalRune = 'F' // press to pay respect + +// marshalPHP marshals some data which can be marshaled using [json.Encode] into a PHP Expression. +// the string can be safely used directly within php. +func marshalPHP(data any) (string, error) { + // this function uses json as a data format to transport the data into php. + // then we build a heredoc to encode it safely, and decode it in php + + // Step 1: Encode the data as json + jbytes, err := json.Marshal(data) + if err != nil { + return "", err + } + jstring := string(jbytes) + + // Step 2: Find a delimiter for the heredoc. + // Step 2a: Find the longest sequence of [marshalRune]s inside the encoded string. + var current, longest int + for _, r := range jstring { + + if r == marshalRune { + current++ + } else { + current = 0 + } + + if current > longest { + longest = current + } + } + // Step 2b: Build a string of marshalRune that is one longer! + delim := strings.Repeat(string(marshalRune), longest+1) + + // Step 3: Assemble the encoded string! + result := "call_user_func(function(){$x=<<<'" + delim + "'\n" + jstring + "\n" + delim + ";return json_decode(trim($x));})" // press to doubt + return result, nil +} + +// Close closes this server and prevents any further code from being run. +func (server *PHPServer) Close() error { + server.m.Lock() + defer server.m.Unlock() + + // if the context is already closed + if err := server.c.Err(); err != nil { + return errPHPClosed + } + + server.in.Close() + <-server.c.Done() + + return nil +} + +// ExecPHPScript executes the PHP code as a script on the given server. +// When server is nil, creates a new server and automatically closes it after execution. +// +// The script should define a function called entrypoint, and may define additional functions. +// +// Code must start with "