diff --git a/docs/index.md b/docs/index.md index 5f58d066c..daffd8315 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,9 +32,13 @@ Use `$path` expressions in templates for dynamic data extraction, and leverage b - **Enhanced Parameter Validation**: Comprehensive `_$()` validation chains with type conversion, regex matching, and range checking - **Job Input/Output Validation**: Declarative `check.in` and `check.out` sections with fluent validation syntax - **AI/LLM Integration**: Multi-provider LLM support (OpenAI, Anthropic, Gemini, Ollama) with function calling and image processing +- **MCP Client**: `$mcp()` for Model Context Protocol communication with stdio, HTTP, and SSE transports; OAuth2 (client credentials & authorization_code); tool blacklist (see `openaf-advanced.md` §19) +- **FTP/FTPS Client**: `$ftp()` shortcut for plain and TLS-secured FTP file transfers (see `openaf.md`) +- **HTTP Path Prefix**: Deploy the embedded HTTP server under a configurable subpath via `HTTPD_PREFIX` flag (see `openaf-advanced.md` §18, `openaf-flags.md`) - **Telemetry & Metrics**: Built-in metrics collection, OpenMetrics format support, and integration with monitoring systems - **Security Enhancements**: File integrity checking, authorized domains, and comprehensive audit trails -- **Async Promises**: `$do` / `$doV` helpers build on `oPromise` for threaded or virtual-thread asynchronous execution with familiar `.then` / `.catch` chaining.【F:js/openaf.js†L13130-L13157】【F:js/openaf.js†L12145-L12163】【F:js/openaf.js†L12208-L12251】 +- **Async Promises**: `$do` / `$doV` helpers build on `oPromise` for threaded or virtual-thread asynchronous execution with familiar `.then` / `.catch` chaining. +- **Inline Argument Token Interpolation**: oJob args support `"${key}"` and `"prefix-${key}-suffix"` patterns with default values and backslash escaping (see `ojob.md`) --- This index is intentionally minimal—open individual docs for full tables of contents. diff --git a/docs/ojob.md b/docs/ojob.md index 284edde28..8a1841ffb 100644 --- a/docs/ojob.md +++ b/docs/ojob.md @@ -392,7 +392,9 @@ todo: - **Inheritance**: These processed arguments are passed to the target job - **Template integration**: Works seamlessly with oJob's template processing - **Nested support**: Supports dot notation for nested object properties -- **Type preservation**: Values are processed as strings but maintain their intended types +- **Type preservation**: When a string value is exactly `"${key}"` the resolved value keeps its original type (number, boolean, array, object, etc.). When a token appears inside a longer string (e.g. `"prefix-${key}-suffix"`) the result is always a string. +- **Inline interpolation**: Tokens can appear anywhere inside a string value and multiple tokens may be combined — e.g. `"${host:-localhost}:${port:-8080}"`. Note that when multiple tokens are interpolated into a string context, each resolved value is converted via `String()` before concatenation. +- **Escaping**: Prefix a token with `\` to prevent resolution — `\${key}` is left as the literal string `${key}`. Use `\\${key}` to produce `\${key}` in the output (odd number of backslashes escapes, even number does not). **Usage examples:** ```yaml diff --git a/docs/openaf-advanced.md b/docs/openaf-advanced.md index df8fb47f6..41124aad7 100644 --- a/docs/openaf-advanced.md +++ b/docs/openaf-advanced.md @@ -516,5 +516,146 @@ function robustLLMCall(prompt, maxRetries = 3) { ## 17. Safe Dynamic Includes Combine integrity hashes + authorized domains + change auditing flags. For local development disable with environment variable toggles but keep production strict. +## 18. HTTP Path Prefix Support (ow.server.httpd) + +OpenAF's embedded HTTP server supports serving all resources and routes under a configurable path prefix. This is useful when deploying behind a reverse proxy that forwards requests under a subpath (e.g. `/app`). + +### Setting a prefix + +Set the `HTTPD_PREFIX` flag before starting any server. Key `"0"` is the global default for all ports; use a specific port number string to override per port: + +```javascript +// Set global prefix /app for all HTTP servers +__flags.HTTPD_PREFIX = { "0": "/app" }; + +// Or different prefixes per port +__flags.HTTPD_PREFIX = { "0": "", "8080": "/api", "9090": "/gui" }; +``` + +Via oJob YAML: +```yaml +ojob: + flags: + HTTPD_PREFIX: + "0": /app +``` + +### Prefix utility functions + +`ow.server.httpd` provides helper functions to work with prefixes: + +| Function | Description | +|----------|-------------| +| `ow.server.httpd.getPrefix(httpdOrPort)` | Returns the normalized prefix for the given server or port | +| `ow.server.httpd.withPrefix(httpdOrPort, uri)` | Prepend the prefix to `uri` (skips absolute URLs) | +| `ow.server.httpd.stripPrefix(httpdOrPort, uri)` | Remove the prefix from a URI for internal routing | +| `ow.server.httpd.normalizePrefix(aPrefix)` | Normalize a raw prefix string (ensures leading `/`, no trailing `/`) | + +```javascript +ow.loadServer(); +__flags.HTTPD_PREFIX = { "0": "/app" }; + +var httpd = ow.server.httpd.start(8080); + +// Build a prefixed URL +var link = ow.server.httpd.withPrefix(httpd, "/about"); // "/app/about" + +// Strip prefix for internal route matching +ow.server.httpd.route(httpd, ow.server.httpd.mapWithExistingFn(httpd, { + "/about": function(req) { + return httpd.replyOKText("About page"); + } +}), function(req) { + var internalURI = ow.server.httpd.stripPrefix(httpd, req.uri); + return httpd.replyNotFound(); +}); +``` + +All built-in GUI pages and static-file handlers automatically respect the configured prefix. + +## 19. MCP Client ($mcp) + +`$mcp(aOptions)` creates a Model Context Protocol (MCP) client for communicating with LLM tool servers. + +### Connection types + +| `type` | Description | +|--------|-------------| +| `stdio` (default) | Spawn a local process; communicate over stdin/stdout | +| `remote` / `http` | HTTP JSON-RPC endpoint | +| `sse` | HTTP endpoint with Server-Sent Events responses | +| `ojob` | In-process oJob jobs exposed as MCP tools | +| `dummy` | Local in-memory stub for testing | + +```javascript +// stdio MCP server +var client = $mcp({ type: "stdio", cmd: "my-mcp-server" }); +client.initialize(); +var tools = client.listTools(); +var result = client.callTool("myTool", { param: "value" }); + +// Remote HTTP MCP server +var remote = $mcp({ type: "remote", url: "https://mcp.example.com/mcp" }); +remote.initialize(); +``` + +### Authentication (`auth` option) + +For `remote`/`http`/`sse` connections: + +```javascript +// Static bearer token +var client = $mcp({ + type: "remote", + url: "https://mcp.example.com/mcp", + auth: { type: "bearer", token: "my-token" } +}); + +// OAuth2 client credentials +var client = $mcp({ + type: "remote", + url: "https://mcp.example.com/mcp", + auth: { + type: "oauth2", + tokenURL: "https://auth.example.com/oauth/token", + clientId: "my-client", + clientSecret: "my-secret", + scope: "mcp:read mcp:write" + } +}); + +// OAuth2 authorization_code (opens browser) +var client = $mcp({ + type: "remote", + url: "https://mcp.example.com/mcp", + auth: { + type: "oauth2", + grantType: "authorization_code", + authURL: "https://auth.example.com/authorize", + tokenURL: "https://auth.example.com/oauth/token", + clientId: "my-client", + redirectURI: "http://localhost:8080/callback", + disableOpenBrowser: false // set true to suppress browser launch + } +}); +``` + +OAuth2 token URLs can also be auto-discovered from the MCP server's OAuth 2.0 Protected Resource Metadata when `tokenURL`/`authURL` are omitted. + +### Tool blacklist + +Prevent specific tools from appearing in `listTools()` or being called via `callTool()`: + +```javascript +var client = $mcp({ + type: "stdio", + cmd: "my-mcp-server", + blacklist: ["dangerousTool", "internalTool"] +}); +client.initialize(); +// listTools() will not include blacklisted tools +// callTool("dangerousTool", {}) throws an error +``` + --- See also: `ojob-security.md`, `openaf-flags.md`, and main references. diff --git a/docs/openaf-flags.md b/docs/openaf-flags.md index 43313a03b..72ae59061 100644 --- a/docs/openaf-flags.md +++ b/docs/openaf-flags.md @@ -40,9 +40,16 @@ Central list of noteworthy runtime flags and environment variables. ## Server Management -| Env | Default | Purpose | -|-----|---------|---------| +| Env / Flag | Default | Purpose | +|------------|---------|---------| | OAF_PIDFILE | (unset) | Override PID file path in ow.server.checkIn | +| HTTPD_PREFIX *(flag)* | `{}` | Runtime flag (set via `__flags.HTTPD_PREFIX` or `ojob.flags`). Map of port-to-path-prefix entries for the embedded HTTP server. Key `"0"` is the global default. E.g. `{ "0": "/app", "8080": "/api" }`. Must be set before calling `ow.server.httpd.start()`. | + +## Template / Markdown + +| Flag | Default | Purpose | +|------|---------|---------| +| MD_RENDER_SVG | false | When true, ` ```svg ` fenced blocks in markdown are extracted and injected as inline SVG in HTML output instead of being treated as code blocks | ## Misc Performance / Behavior diff --git a/docs/openaf.md b/docs/openaf.md index 110d732d3..1a42f5ba6 100644 --- a/docs/openaf.md +++ b/docs/openaf.md @@ -759,6 +759,65 @@ Convenience getters: - `getJsSlon(i)` parse SLON - `getYaml(i)` parse YAML +### $ftp - FTP / FTPS Client + +`$ftp(aMap)` creates an FTP or FTPS client connection. `aMap` can be a URL string or a configuration map. + +**URL format:** +``` +ftp://user:pass@host:port/?timeout=5000&passive=true&binary=true +ftps://user:pass@host:port/?timeout=5000&passive=true&implicit=false&protocol=TLS +``` + +**Map format:** + +| Key | Default | Description | +|-----|---------|-------------| +| `host` | – | Remote hostname or IP | +| `port` | `21` / `990` | Port (990 for implicit FTPS) | +| `login` | – | Username | +| `pass` | – | Password | +| `secure` | `false` | Enable FTPS (explicit TLS) | +| `implicit` | `false` | Use implicit TLS mode (port 990) | +| `protocol` | `"TLS"` | TLS protocol name | +| `passive` | `true` | Use passive mode | +| `binary` | `true` | Binary transfer mode | +| `timeout` | – | Connection timeout in ms | + +**Available methods (all return `$ftp` for chaining except where noted):** + +| Method | Description | +|--------|-------------| +| `cd(aPath)` | Change remote working directory | +| `pwd()` | Return current remote working directory (String) | +| `mkdir(aDir)` | Create remote directory | +| `getFile(src, dst)` | Download remote `src` to local `dst` | +| `putFile(src, dst)` | Upload local `src` (path or stream) to remote `dst` | +| `rename(src, dst)` | Rename / move remote file | +| `listFiles(aPath)` | Return array of file-info maps for `aPath` | +| `rm(aFilePath)` | Delete remote file | +| `rmdir(aFilePath)` | Delete remote directory | +| `passive(bool)` | Toggle passive mode | +| `binary(bool)` | Toggle binary transfer mode | +| `timeout(ms)` | Set connection timeout | +| `close()` | Close the connection | + +**Examples:** +```javascript +// Plain FTP via URL +var ftp = $ftp("ftp://user:secret@ftp.example.com/"); +ftp.cd("/uploads") + .putFile("/local/report.csv", "report.csv") + .close(); + +// FTPS via map +var ftps = $ftp({ host: "ftp.example.com", secure: true, + login: "user", pass: "secret" }); +var files = ftps.listFiles("/data"); +ftps.getFile("/data/report.csv", "/tmp/report.csv"); +ftps.close(); +``` + ### $tb - Thread Box (Timeout / Stop Controller) Run a function with enforced timeout or stop condition: diff --git a/hbs/index.hbs b/hbs/index.hbs index 42735a047..472d8e513 100644 --- a/hbs/index.hbs +++ b/hbs/index.hbs @@ -5,8 +5,8 @@ {{title}} - - + + @@ -28,11 +28,11 @@ - - - - - + + + + +
@@ -55,7 +55,7 @@ $(".sidenav").sidenav(); }); window.onbeforeunload = function(event) { - $.ajax({ async: false, type: 'GET', url: '/exec/quit', success: function(d) {}}); + $.ajax({ async: false, type: 'GET', url: '{{uriPrefix}}/exec/quit', success: function(d) {}}); } diff --git a/hbs/md.hbs b/hbs/md.hbs index 56e542c8a..4ef11cd55 100644 --- a/hbs/md.hbs +++ b/hbs/md.hbs @@ -1,14 +1,14 @@ - - - - + + + + - + {{#each extras}} @@ -57,9 +57,9 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', eve {{{markdown}}} {{#if themedark}}{{/if}} {{#if themeauto}}{{/if}} -{{#if mdcodeclip}}{{/if}} +{{#if mdcodeclip}}{{/if}} {{#each posextras}} {{{this}}} {{/each}} - \ No newline at end of file + diff --git a/hbs/odoc.hbs b/hbs/odoc.hbs index ec9fb41e3..3cdc51d7c 100644 --- a/hbs/odoc.hbs +++ b/hbs/odoc.hbs @@ -27,7 +27,7 @@ var output = ""; if (newList.length == 1 || typeof newTerm !== 'undefined') { - $.getJSON("/odocKey?q=" + _.escape(searchVal), function(elem) { + $.getJSON("{{uriPrefix}}/odocKey?q=" + _.escape(searchVal), function(elem) { var out = ""; out += "" + elem.key + ""; out += "
" + elem.fullkey + "
"; @@ -80,4 +80,4 @@ \ No newline at end of file + diff --git a/hbs/opacks.hbs b/hbs/opacks.hbs index f4e9d3b41..792e04973 100644 --- a/hbs/opacks.hbs +++ b/hbs/opacks.hbs @@ -50,7 +50,7 @@ {{#if Local}} - + {{else}} {{/if}} @@ -59,7 +59,7 @@ {{#if Local}} {{else}} - + {{/if}}
Path:{{Path}}
Version:{{Version}}{{#if Update}} (new version {{RemoteVersion}} available)    cloud_downloadUpdate{{/if}}
Version:{{Version}}{{#if Update}} (new version {{RemoteVersion}} available)    cloud_downloadUpdate{{/if}}
Version available:{{RemoteVersion}}
Bug reporting:{{Bugs}}
cloud_downloadInstall
cloud_downloadInstall
diff --git a/js/openaf.js b/js/openaf.js index 50e411c32..0318689c3 100644 --- a/js/openaf.js +++ b/js/openaf.js @@ -216,6 +216,7 @@ var __flags = ( typeof __flags != "undefined" && "[object Object]" == Object.pro VISIBLELENGTH : true, MD_NOMAXWIDTH : true, MD_SHOWDOWN_OPTIONS : {}, + MD_RENDER_SVG : false, // If true, ```svg fenced blocks are turned into inline SVG before markdown to HTML conversion MD_CODECLIP : true, // If true, code blocks will have a button to copy the code to the clipboard MD_DARKMODE : "false", // Possible values: "auto", "true", "false" MD_CHART : false, // If true, code blocks with "chart" language will be rendered as Chart.js charts @@ -280,6 +281,8 @@ var __flags = ( typeof __flags != "undefined" && "[object Object]" == Object.pro HTTPD_THREADS : "auto", HTTPD_BUFSIZE : 8192, HTTPD_CUSTOMURIS : {}, + // Optional URI prefix by HTTP server port. If a specific port is unset, HTTPD_PREFIX[0] is used as the default. + HTTPD_PREFIX : {}, HTTPD_DEFAULT_IMPL : "nwu2", SQL_QUERY_METHOD : "auto", SQL_QUERY_H2_INMEM : false, @@ -8354,11 +8357,12 @@ const $rest = function(ops) { * or with local processes using stdio. The aOptions parameter is a map with the following * possible keys:\ * \ - * - type (string): Connection type, either "stdio" for local process or "remote" for HTTP server (default: "stdio") or "dummy"\ + * - type (string): Connection type, either "stdio" for local process, "remote"/"http" for HTTP server, "sse" for HTTP SSE responses (default: "stdio") or "dummy"\ * - url (string): Required for remote servers - the endpoint URL\ * - timeout (number): Timeout in milliseconds for operations (default: 60000)\ * - cmd (string|map|array): Required for stdio type - the command to execute or the map/array accepted by $sh\ * - options (map): Additional options passed to $rest for remote connections\ + * - sse (boolean): When true, remote/http requests expect Server-Sent Events responses with JSON-RPC payloads in `data:` events\ * - debug (boolean): Enable debug output showing JSON-RPC messages (default: false)\ * - shared (boolean): Share connections between identical configurations (default: false)\ * \ @@ -8398,6 +8402,7 @@ const $jsonrpc = function (aOptions) { aOptions = _$(aOptions, "aOptions").isMap().default({}) aOptions.type = _$(aOptions.type, "aOptions.type").isString().default("stdio") aOptions.timeout = _$(aOptions.timeout, "aOptions.timeout").isNumber().default(60000) + aOptions.sse = _$(aOptions.sse, "aOptions.sse").isBoolean().default(false) // debug = true will print JSON requests and responses using print() aOptions.debug = _$(aOptions.debug, "aOptions.debug").isBoolean().default(false) aOptions.shared = _$(aOptions.shared, "aOptions.shared").isBoolean().default(false) @@ -8453,7 +8458,7 @@ const $jsonrpc = function (aOptions) { } } else if (isString(aOptions.url)) { _payload = { - type: "remote", + type: (aOptions.type == "sse" || aOptions.sse) ? "sse" : "remote", url: aOptions.url } } else if (aOptions.type == "dummy" || aOptions.type == "ojob") { @@ -8484,6 +8489,27 @@ const $jsonrpc = function (aOptions) { if (aOptions.debug) printErr(ansiColor("yellow,BOLD", "DEBUG: ") + ansiColor("yellow", m)) } + const _pickHeaderCaseInsensitive = (headers, keyName) => { + if (!isMap(headers)) return __ + var _target = String(keyName).toLowerCase() + var _foundKey = Object.keys(headers).find(k => String(k).toLowerCase() == _target) + if (isUnDef(_foundKey)) return __ + var _v = headers[_foundKey] + if (Array.isArray(_v)) return _v.length > 0 ? _v[0] : __ + return _v + } + + const _session = { + mcpSessionId: __ + } + + const _captureSessionFromHeaders = headers => { + var _sid = _pickHeaderCaseInsensitive(headers, "mcp-session-id") + if (isDef(_sid) && String(_sid).length > 0) { + _session.mcpSessionId = String(_sid) + } + } + const _defaultCmdDir = (isDef(__flags) && isDef(__flags.JSONRPC) && isDef(__flags.JSONRPC.cmd) && isDef(__flags.JSONRPC.cmd.defaultDir)) ? __flags.JSONRPC.cmd.defaultDir : __ const _r = { @@ -8503,7 +8529,7 @@ const $jsonrpc = function (aOptions) { }, url: url => { aOptions.url = url - aOptions.type = "remote" + aOptions.type = (aOptions.type == "sse" || aOptions.sse) ? "sse" : "remote" return _r }, pwd: aPath => { @@ -8596,6 +8622,62 @@ const $jsonrpc = function (aOptions) { _debug("jsonrpc command set to: " + cmd) return _r }, + _readSSE: aStream => { + if (isMap(aStream)) { + if (isDef(aStream.stream)) return _r._readSSE(aStream.stream) + if (isDef(aStream.inputStream)) return _r._readSSE(aStream.inputStream) + if (isString(aStream.response)) return _r._readSSE(af.fromString2InputStream(aStream.response)) + if (isString(aStream.error)) { + var _parsedError = jsonParse(aStream.error, __, __, true) + return [ isDef(_parsedError) ? _parsedError : aStream ] + } + return [ aStream ] + } + if (isDef(aStream) && "function" === typeof aStream.readAllBytes && "function" !== typeof aStream.read) { + return _r._readSSE(af.fromBytes2InputStream(aStream.readAllBytes())) + } + var _events = [] + var _dataLines = [] + var _nonSseLines = [] + var _flush = () => { + if (_dataLines.length == 0) return + var _payload = _dataLines.join("\n").trim() + _dataLines = [] + if (_payload.length == 0 || _payload == "[DONE]") return + var _obj = jsonParse(_payload, __, __, true) + _events.push(isDef(_obj) ? _obj : _payload) + } + try { + ioStreamReadLines(aStream, line => { + var _line = String(line) + if (_line.length == 0) { + _flush() + return false + } + if (_line.indexOf(":") == 0) return false + if (_line.indexOf("data:") == 0) { + _dataLines.push(_line.substring(5).trim()) + } else if (_line.indexOf("event:") == 0 || _line.indexOf("id:") == 0 || _line.indexOf("retry:") == 0) { + // ignore SSE metadata + } else { + _nonSseLines.push(_line) + } + return false + }, "\n", false) + _flush() + } finally { + try { aStream.close() } catch(e) {} + } + if (_events.length > 0) return _events + if (_nonSseLines.length > 0) { + var _fallback = _nonSseLines.join("\n").trim() + if (_fallback.length > 0) { + var _fallbackObj = jsonParse(_fallback, __, __, true) + return [ isDef(_fallbackObj) ? _fallbackObj : _fallback ] + } + } + return [] + }, exec: (aMethod, aParams, aNotification, aExecOptions) => { aExecOptions = _$(aExecOptions, "aExecOptions").isMap().default({}) switch (aOptions.type) { @@ -8650,6 +8732,8 @@ const $jsonrpc = function (aOptions) { } if (aMethod == "initialize" && !aNotification) _r._info = isDef(_res) && isDef(_res.result) ? _res.result : _res return isDef(_res) && isDef(_res.result) ? _res.result : _res + case "sse": + aOptions.sse = true case "remote": default: _$(aOptions.url, "aOptions.url").isString().$_() @@ -8658,6 +8742,13 @@ const $jsonrpc = function (aOptions) { aParams = _$(aParams, "aParams").isMap().default({}) var _restOptions = clone(aOptions.options) if (isMap(aExecOptions.restOptions)) _restOptions = merge(_restOptions, aExecOptions.restOptions) + _restOptions.requestHeaders = _$( + _restOptions.requestHeaders, + "requestHeaders" + ).isMap().default({}) + if (isDef(_session.mcpSessionId) && isUnDef(_pickHeaderCaseInsensitive(_restOptions.requestHeaders, "mcp-session-id"))) { + _restOptions.requestHeaders["mcp-session-id"] = _session.mcpSessionId + } var _req = { jsonrpc: "2.0", @@ -8671,7 +8762,34 @@ const $jsonrpc = function (aOptions) { delete _req.id } _debug("jsonrpc -> " + stringify(_req, __, "")) - var res = $rest(_restOptions).post(aOptions.url, _req) + var _useSSE = (aOptions.type == "sse" || aOptions.sse) + var res + if (_useSSE) { + var _http = ow.loadObj().rest.connectionFactory() + _restOptions.httpClient = _http + _restOptions.requestHeaders = merge( + { Accept: "application/json, text/event-stream" }, + _$(_restOptions.requestHeaders, "requestHeaders").isMap().default({}) + ) + if (!!aNotification) { + var _notificationRes = $rest(_restOptions).post2Stream(aOptions.url, _req) + _captureSessionFromHeaders(_http.responseHeaders()) + if (isDef(_notificationRes) && "function" === typeof _notificationRes.close) { + try { _notificationRes.close() } catch(e) {} + } + return + } + var _streamRes = $rest(_restOptions).post2Stream(aOptions.url, _req) + _captureSessionFromHeaders(_http.responseHeaders()) + var _events = _r._readSSE(_streamRes) + res = _events.filter(r => isMap(r)).filter(r => r.id == _req.id || isUnDef(r.id)).shift() + if (isUnDef(res) && _events.length > 0) res = _events[0] + } else { + var _http = ow.loadObj().rest.connectionFactory() + _restOptions.httpClient = _http + res = $rest(_restOptions).post(aOptions.url, _req) + _captureSessionFromHeaders(_http.responseHeaders()) + } // Notifications do not expect a reply if (!!aNotification) return _debug("jsonrpc <- " + stringify(res, __, "")) @@ -8717,7 +8835,7 @@ const $jsonrpc = function (aOptions) { * \ * The aOptions parameter is a map with the following possible keys:\ * \ - * - type (string): Connection type - "stdio" for local process, "remote"/"http" for HTTP server, "dummy" for local testing, or "ojob" for oJob-based server (default: "stdio")\ + * - type (string): Connection type - "stdio" for local process, "remote"/"http" for HTTP server, "sse" for HTTP SSE responses, "dummy" for local testing, or "ojob" for oJob-based server (default: "stdio")\ * - url (string): Required for remote servers - the MCP server endpoint URL\ * - timeout (number): Timeout in milliseconds for operations (default: 60000)\ * - cmd (string): Required for stdio type - the command to launch the MCP server\ @@ -8727,10 +8845,19 @@ const $jsonrpc = function (aOptions) { * - For ojob: { job: path to oJob file, args: arguments map, init: init entry/entries to run, fns: map of additional functions, fnsMeta: map of additional function metadata }\ * - debug (boolean): Enable debug output showing JSON-RPC messages (default: false)\ * - shared (boolean): Enable shared JSON-RPC connections when possible (default: false)\ + * - sse (boolean): When true, remote/http MCP requests expect Server-Sent Events responses carrying JSON-RPC payloads\ * - strict (boolean): Enable strict MCP protocol compliance (default: true)\ * - clientInfo (map): Client information sent during initialization (default: {name: "OpenAF MCP Client", version: "1.0.0"})\ + * - blacklist (array): Optional array of MCP tool names to hide from listTools() and block in callTool()\ * - preFn (function): Function called before each tool execution with (toolName, toolArguments)\ * - posFn (function): Function called after each tool execution with (toolName, toolArguments, result)\ + * - auth (map): Optional authentication options for remote/http type:\ + * - type (string): "bearer" (static token) or "oauth2" (automatic token retrieval/refresh)\ + * - token (string): Bearer token when type is "bearer"\ + * - tokenType (string): Authorization scheme prefix (default: "Bearer")\ + * - For oauth2: tokenURL, clientId, clientSecret, scope, audience, resource, grantType (default: "client_credentials"), extraParams (map), refreshWindowMs (default: 30000), authURL/redirectURI for authorization_code flow\ + * - For oauth2: if tokenURL/authURL are omitted for remote/http MCP servers they can be discovered through OAuth 2.0 Protected Resource Metadata and Authorization Server Metadata\ + * - disableOpenBrowser (boolean): If true prevents opening a browser during OAuth2 authorization_code flow (default: false)\ * \ * Type-specific details:\ * \ @@ -8786,6 +8913,36 @@ const $jsonrpc = function (aOptions) { * var result2 = remoteClient.callTool("read_file", {path: "/tmp/example.txt"}, { requestHeaders: { Authorization: "Bearer ..." } });\ * var prompts = remoteClient.listPrompts();\ * \ + * // Remote MCP server with OAuth2 client credentials\ + * var oauthClient = $mcp({\ + * type: "remote",\ + * url: "https://example.com/mcp",\ + * auth: {\ + * type: "oauth2",\ + * tokenURL: "https://example.com/oauth/token",\ + * clientId: "my-client",\ + * clientSecret: "my-secret",\ + * scope: "mcp:read mcp:write"\ + * }\ + * });\ + * oauthClient.initialize();\ + * \ + * // OAuth2 authorization_code flow (opens browser by default)\ + * var oauthCodeClient = $mcp({\ + * type: "remote",\ + * url: "https://example.com/mcp",\ + * auth: {\ + * type: "oauth2",\ + * grantType: "authorization_code",\ + * authURL: "https://example.com/oauth/authorize",\ + * tokenURL: "https://example.com/oauth/token",\ + * redirectURI: "http://localhost/callback",\ + * clientId: "my-client",\ + * clientSecret: "my-secret",\ + * disableOpenBrowser: false\ + * }\ + * });\ + * \ * // Dummy mode for testing\ * var dummyClient = $mcp({\ * type: "dummy",\ @@ -8826,6 +8983,7 @@ const $mcp = function(aOptions) { aOptions = _$(aOptions, "aOptions").isMap().default({}) aOptions.type = _$(aOptions.type, "aOptions.type").isString().default("stdio") aOptions.timeout = _$(aOptions.timeout, "aOptions.timeout").isNumber().default(60000) + aOptions.sse = _$(aOptions.sse, "aOptions.sse").isBoolean().default(false) // debug = true will enable printing of JSON-RPC requests/responses aOptions.strict = _$(aOptions.strict, "aOptions.strict").isBoolean().default(true) aOptions.debug = _$(aOptions.debug, "aOptions.debug").isBoolean().default(false) @@ -8834,12 +8992,264 @@ const $mcp = function(aOptions) { name: "OpenAF MCP Client", version: "1.0.0" }) + aOptions.blacklist = _$(aOptions.blacklist, "aOptions.blacklist").isArray().default([]) aOptions.options = _$(aOptions.options, "aOptions.options").isMap().default(__) + aOptions.auth = _$(aOptions.auth, "aOptions.auth").isMap().default({}) aOptions.preFn = _$(aOptions.preFn, "aOptions.preFn").isFunction().default(__) aOptions.posFn = _$(aOptions.posFn, "aOptions.posFn").isFunction().default(__) aOptions.protocolVersion = _$(aOptions.protocolVersion, "aOptions.protocolVersion").isString().default("2024-11-05") + const _toolBlacklist = {} + aOptions.blacklist.forEach(toolName => { + toolName = _$(toolName, "aOptions.blacklist[]").isString().$_() + _toolBlacklist[toolName] = true + }) + const _defaultCmdDir = (isDef(__flags) && isDef(__flags.JSONRPC) && isDef(__flags.JSONRPC.cmd) && isDef(__flags.JSONRPC.cmd.defaultDir)) ? __flags.JSONRPC.cmd.defaultDir : __ + const _isToolBlacklisted = toolName => _toolBlacklist[toolName] === true + const _filterToolsList = toolsRes => { + if (isMap(toolsRes) && isArray(toolsRes.tools) && Object.keys(_toolBlacklist).length > 0) { + toolsRes.tools = toolsRes.tools.filter(tool => !_isToolBlacklisted(tool.name)) + } + return toolsRes + } + + const _auth = { + token: __, + tokenType: "Bearer", + expiresAt: 0, + refreshToken: __, + authorizationCode: _$(aOptions.auth.code, "aOptions.auth.code").isString().default(_$(aOptions.auth.authorizationCode, "aOptions.auth.authorizationCode").isString().default(__)), + pkceVerifier: __, + resource: __, + protectedResourceMetadataURL: __, + protectedResourceMetadata: __, + authorizationServerIssuer: __, + authorizationServerMetadataURL: __, + authorizationServerMetadata: __, + discoveredAuthURL: __, + discoveredTokenURL: __, + discoveredRegistrationURL: __ + } + + const _urlEnc = v => String(java.net.URLEncoder.encode(String(v), "UTF-8")) + const _base64URL = aBytes => String(af.fromBytes2String(af.toBase64Bytes(aBytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "") + const _uriToString = aURI => { + var _s = String(aURI.getScheme()).toLowerCase() + var _h = isDef(aURI.getHost()) ? String(aURI.getHost()).toLowerCase() : __ + var _p = aURI.normalize().getPath() + if (isUnDef(_p) || _p == "/") _p = "" + return String(new java.net.URI(_s, aURI.getUserInfo(), _h, aURI.getPort(), _p, null, null).toString()) + } + const _canonicalizeResourceURI = aURL => { + var _uri = new java.net.URI(String(aURL)) + if (isUnDef(_uri.getScheme()) || isUnDef(_uri.getHost())) throw new Error("Invalid MCP remote URL for OAuth resource discovery: " + aURL) + return _uriToString(_uri) + } + const _buildWellKnownURL = (aURL, aSuffix) => { + var _uri = new java.net.URI(String(aURL)) + if (isUnDef(_uri.getScheme()) || isUnDef(_uri.getHost())) throw new Error("Invalid URL for OAuth metadata discovery: " + aURL) + var _scheme = String(_uri.getScheme()).toLowerCase() + var _host = String(_uri.getHost()).toLowerCase() + var _path = _uri.normalize().getPath() + if (isUnDef(_path) || _path == "/") _path = "" + return String(new java.net.URI(_scheme, _uri.getUserInfo(), _host, _uri.getPort(), "/.well-known/" + aSuffix + _path, null, null).toString()) + } + const _fetchJSON = aURL => { + var _http = ow.loadObj().rest.connectionFactory() + var _res = $rest({ + httpClient: _http, + requestHeaders: { Accept: "application/json" } + }).get(aURL) + return { + body: _res, + headers: _http.responseHeaders(), + status: _http.responseCode() + } + } + const _getOAuthResource = () => { + if (isDef(_auth.resource)) return _auth.resource + _auth.resource = _$(aOptions.auth.resource, "aOptions.auth.resource").isString().default(_canonicalizeResourceURI(aOptions.url)) + return _auth.resource + } + const _getPKCEVerifier = () => { + if (isDef(_auth.pkceVerifier)) return _auth.pkceVerifier + var _seed = String(genUUID()) + String(genUUID()) + _auth.pkceVerifier = _base64URL(af.fromString2Bytes(_seed)) + return _auth.pkceVerifier + } + const _getPKCEChallenge = () => { + var _digest = java.security.MessageDigest.getInstance("SHA-256") + return _base64URL(_digest.digest(af.fromString2Bytes(_getPKCEVerifier()))) + } + const _discoverOAuthMetadata = () => { + if (isDef(_auth.authorizationServerMetadata)) return _auth.authorizationServerMetadata + if (isDef(aOptions.auth.tokenURL) && isDef(aOptions.auth.authURL)) return __ + var _resource = _getOAuthResource() + var _resourceMetadataURL = _$(aOptions.auth.protectedResourceMetadataURL, "aOptions.auth.protectedResourceMetadataURL").isString().default( + _buildWellKnownURL(_resource, "oauth-protected-resource") + ) + var _resourceMetadata = _fetchJSON(_resourceMetadataURL).body + if (!isMap(_resourceMetadata)) { + throw new Error("OAuth protected resource metadata response is invalid for " + _resourceMetadataURL) + } + if (!isArray(_resourceMetadata.authorization_servers) || _resourceMetadata.authorization_servers.length == 0) { + throw new Error("OAuth protected resource metadata doesn't contain authorization_servers") + } + var _issuer = _$(aOptions.auth.authorizationServer, "aOptions.auth.authorizationServer").isString().default( + _$(aOptions.auth.authorizationServerIssuer, "aOptions.auth.authorizationServerIssuer").isString().default(_resourceMetadata.authorization_servers[0]) + ) + var _authServerMetadataURL = _$(aOptions.auth.authorizationServerMetadataURL, "aOptions.auth.authorizationServerMetadataURL").isString().default( + _buildWellKnownURL(_issuer, "oauth-authorization-server") + ) + var _authServerMetadata = _fetchJSON(_authServerMetadataURL).body + if (!isMap(_authServerMetadata)) { + throw new Error("OAuth authorization server metadata response is invalid for " + _authServerMetadataURL) + } + _auth.protectedResourceMetadataURL = _resourceMetadataURL + _auth.protectedResourceMetadata = _resourceMetadata + _auth.authorizationServerIssuer = _issuer + _auth.authorizationServerMetadataURL = _authServerMetadataURL + _auth.authorizationServerMetadata = _authServerMetadata + _auth.discoveredAuthURL = _authServerMetadata.authorization_endpoint + _auth.discoveredTokenURL = _authServerMetadata.token_endpoint + _auth.discoveredRegistrationURL = _authServerMetadata.registration_endpoint + return _authServerMetadata + } + const _getResolvedAuthURL = () => { + var _authURL = isString(aOptions.auth.authURL) ? String(aOptions.auth.authURL) : __ + if (isDef(_authURL)) return _authURL + _discoverOAuthMetadata() + return isString(_auth.discoveredAuthURL) ? String(_auth.discoveredAuthURL) : __ + } + const _getResolvedTokenURL = () => { + var _tokenURL = isString(aOptions.auth.tokenURL) ? String(aOptions.auth.tokenURL) : __ + if (isDef(_tokenURL)) return _tokenURL + _discoverOAuthMetadata() + if (!isString(_auth.discoveredTokenURL)) throw new Error("OAuth authorization server metadata doesn't contain token_endpoint") + return String(_auth.discoveredTokenURL) + } + const _openAuthBrowser = aURL => { + if (_$(aOptions.auth.disableOpenBrowser, "aOptions.auth.disableOpenBrowser").isBoolean().default(false)) return + try { + if (java.awt.Desktop.isDesktopSupported()) { + java.awt.Desktop.getDesktop().browse(new java.net.URI(String(aURL))) + } + } catch(e) { + if (aOptions.debug) printErr(ansiColor("yellow", "OAuth2 browser open failed: " + e)) + } + } + + const _getAuthorizationCode = (_clientId, _scope, _audience, _resource) => { + if (isDef(_auth.authorizationCode)) return _auth.authorizationCode + var _authURL = _getResolvedAuthURL() + _$( _authURL, "authorization_endpoint").isString().$_() + var _redirectURI = _$(aOptions.auth.redirectURI, "aOptions.auth.redirectURI").isString().$_() + var _state = _$(aOptions.auth.state, "aOptions.auth.state").isString().default(genUUID()) + var _authParams = { + response_type: "code", + client_id: _clientId, + redirect_uri: _redirectURI, + state: _state + } + if (isDef(_scope)) _authParams.scope = _scope + if (isDef(_audience)) _authParams.audience = _audience + if (isDef(_resource)) _authParams.resource = _resource + _authParams.code_challenge = _getPKCEChallenge() + _authParams.code_challenge_method = "S256" + if (isMap(aOptions.auth.extraAuthParams)) _authParams = merge(_authParams, aOptions.auth.extraAuthParams) + var _query = Object.keys(_authParams).map(k => _urlEnc(k) + "=" + _urlEnc(_authParams[k])).join("&") + var _authFullURL = _authURL + (_authURL.indexOf("?") >= 0 ? "&" : "?") + _query + _openAuthBrowser(_authFullURL) + if (isFunction(aOptions.auth.onAuthorizationURL)) aOptions.auth.onAuthorizationURL(_authFullURL) + if (_$(aOptions.auth.promptForCode, "aOptions.auth.promptForCode").isBoolean().default(true)) { + _auth.authorizationCode = String(ask("OpenAF MCP OAuth2 - paste the authorization code: ")) + } else { + throw new Error("OAuth2 authorization code required. Set auth.code/auth.authorizationCode or enable promptForCode.") + } + return _auth.authorizationCode + } + + const _getAuthHeaders = () => { + if (isUnDef(aOptions.auth) || !isMap(aOptions.auth)) return __ + if (aOptions.type != "remote" && aOptions.type != "http" && aOptions.type != "sse") return __ + if (Object.keys(aOptions.auth).length == 0) return __ + + var _type = String(_$(aOptions.auth.type, "aOptions.auth.type").isString().default("bearer")).toLowerCase() + if (_type == "bearer") { + var _token = _$(aOptions.auth.token, "aOptions.auth.token").isString().$_() + var _tokenType = _$(aOptions.auth.tokenType, "aOptions.auth.tokenType").isString().default("Bearer") + return { Authorization: _tokenType + " " + _token } + } + + if (_type == "oauth2") { + var _tokenURL = _getResolvedTokenURL() + var _clientId = _$(aOptions.auth.clientId, "aOptions.auth.clientId").isString().$_() + var _clientSecret = _$(aOptions.auth.clientSecret, "aOptions.auth.clientSecret").isString().default(__) + var _grantType = String(_$(aOptions.auth.grantType, "aOptions.auth.grantType").isString().default("client_credentials")).toLowerCase() + var _scope = _$(aOptions.auth.scope, "aOptions.auth.scope").isString().default(__) + var _audience = _$(aOptions.auth.audience, "aOptions.auth.audience").isString().default(__) + var _resource = _getOAuthResource() + var _refreshWindowMs = _$(aOptions.auth.refreshWindowMs, "aOptions.auth.refreshWindowMs").isNumber().default(30000) + var _now = now() + if (isUnDef(_auth.token) || _auth.expiresAt <= (_now + _refreshWindowMs)) { + var _tokenParams + if (isDef(_auth.refreshToken)) { + _tokenParams = { + grant_type: "refresh_token", + refresh_token: _auth.refreshToken, + client_id: _clientId + } + } else if (_grantType == "authorization_code") { + _tokenParams = { + grant_type: "authorization_code", + code: _getAuthorizationCode(_clientId, _scope, _audience, _resource), + redirect_uri: _$(aOptions.auth.redirectURI, "aOptions.auth.redirectURI").isString().$_(), + client_id: _clientId, + code_verifier: _getPKCEVerifier() + } + } else { + _tokenParams = { + grant_type: _grantType, + client_id: _clientId + } + } + if (isDef(_clientSecret)) _tokenParams.client_secret = _clientSecret + if (isDef(_scope)) _tokenParams.scope = _scope + if (isDef(_audience)) _tokenParams.audience = _audience + if (isDef(_resource)) _tokenParams.resource = _resource + if (isMap(aOptions.auth.extraParams)) _tokenParams = merge(_tokenParams, aOptions.auth.extraParams) + + var _tokenRes = $rest({ urlEncode: true }).post(_tokenURL, _tokenParams) + if (isUnDef(_tokenRes) || isUnDef(_tokenRes.access_token)) { + throw new Error("OAuth2 token response doesn't contain access_token") + } + _auth.token = _tokenRes.access_token + if (isDef(_tokenRes.refresh_token)) _auth.refreshToken = _tokenRes.refresh_token + _auth.tokenType = _$(aOptions.auth.tokenType, "aOptions.auth.tokenType").isString().default(_$( + _tokenRes.token_type, "token_type").isString().default("Bearer") + ) + var _expiresIn = _$(Number(_tokenRes.expires_in), "expires_in").isNumber().default(__) + _auth.expiresAt = isDef(_expiresIn) ? (_now + (_expiresIn * 1000)) : Number.MAX_SAFE_INTEGER + } + return { Authorization: _auth.tokenType + " " + _auth.token } + } + + throw new Error("Unsupported MCP auth.type: " + aOptions.auth.type) + } + + const _execWithAuth = (method, params, notification, execOptions) => { + execOptions = _$(execOptions, "execOptions").isMap().default({}) + if (aOptions.type == "remote" || aOptions.type == "http" || aOptions.type == "sse") { + var _authHeaders = _getAuthHeaders() + if (isMap(_authHeaders)) { + var _restOptions = _$(execOptions.restOptions, "execOptions.restOptions").isMap().default({}) + _restOptions.requestHeaders = merge(_authHeaders, _$(_restOptions.requestHeaders, "requestHeaders").isMap().default({})) + execOptions.restOptions = _restOptions + } + } + return _jsonrpc.exec(method, params, notification, execOptions) + } if (aOptions.type == "ojob") { ow.loadOJob() @@ -9003,7 +9413,7 @@ const $mcp = function(aOptions) { clientInfo = _$(clientInfo, "clientInfo").isMap().default({}) clientInfo = merge(aOptions.clientInfo, clientInfo) - var initResult = _jsonrpc.exec("initialize", { + var initResult = _execWithAuth("initialize", { protocolVersion: aOptions.protocolVersion, capabilities: { sampling: {} @@ -9011,7 +9421,7 @@ const $mcp = function(aOptions) { clientInfo: clientInfo }) - if (isDef(initResult) && isUnDef(initResult.error)) { + if (isDef(initResult) && isUnDef(initResult.error)) { _r._initialized = true _r._capabilities = initResult.capabilities || {} _r._initResult = initResult @@ -9020,7 +9430,7 @@ const $mcp = function(aOptions) { if (aOptions.strict) { try { // send as a notification (no response expected) - _jsonrpc.exec("notifications/initialized", {}, true) + _execWithAuth("notifications/initialized", {}, true) } catch(e) { // Notifications might not return responses, ignore errors } @@ -9028,15 +9438,21 @@ const $mcp = function(aOptions) { return _r } else { - throw new Error("MCP initialization failed: " + (isDef(initResult) ? initResult.error : __ || "Unknown error")) + var _initError = "Unknown error" + if (isString(initResult)) _initError = initResult + if (isMap(initResult) && isDef(initResult.error)) { + if (isString(initResult.error)) _initError = initResult.error + if (isMap(initResult.error) && isDef(initResult.error.message)) _initError = initResult.error.message + } + throw new Error("MCP initialization failed: " + _initError) } }, getInfo: () => _r._initResult, - listTools: () => { + listTools: () => { if (!_r._initialized) { throw new Error("MCP client not initialized. Call initialize() first.") } - return _jsonrpc.exec("tools/list", {}) + return _filterToolsList(_execWithAuth("tools/list", {})) }, callTool: (toolName, toolArguments, toolOptions) => { if (!_r._initialized) { @@ -9045,13 +9461,16 @@ const $mcp = function(aOptions) { toolName = _$(toolName, "toolName").isString().$_() toolArguments = _$(toolArguments, "toolArguments").isMap().default({}) toolOptions = _$(toolOptions, "toolOptions").isMap().default(__) + if (_isToolBlacklisted(toolName)) { + throw new Error("MCP tool '" + toolName + "' is blacklisted.") + } // Call pre-function if provided if (aOptions.preFn) { aOptions.preFn(toolName, toolArguments) } // Call the tool - var _res = _jsonrpc.exec("tools/call", { + var _res = _execWithAuth("tools/call", { name: toolName, arguments: toolArguments }, __, { restOptions: toolOptions }) @@ -9065,7 +9484,7 @@ const $mcp = function(aOptions) { if (!_r._initialized) { throw new Error("MCP client not initialized. Call initialize() first.") } - return _jsonrpc.exec("prompts/list", {}) + return _execWithAuth("prompts/list", {}) }, getPrompt: (promptName, promptArguments) => { if (!_r._initialized) { @@ -9074,7 +9493,7 @@ const $mcp = function(aOptions) { promptName = _$(promptName, "promptName").isString().$_() promptArguments = _$(promptArguments, "promptArguments").isMap().default({}) - return _jsonrpc.exec("prompts/get", { + return _execWithAuth("prompts/get", { name: promptName, arguments: promptArguments }) @@ -9098,7 +9517,7 @@ const $mcp = function(aOptions) { if (!_r._initialized) { throw new Error("MCP client not initialized. Call initialize() first.") } - return _jsonrpc.exec("agents/list", {}) + return _execWithAuth("agents/list", {}) }, /** * @@ -9121,7 +9540,7 @@ const $mcp = function(aOptions) { throw new Error("MCP client not initialized. Call initialize() first.") } agentId = _$(agentId, "agentId").isString().$_() - return _jsonrpc.exec("agents/get", { id: agentId }) + return _execWithAuth("agents/get", { id: agentId }) }, /** * @@ -9157,7 +9576,7 @@ const $mcp = function(aOptions) { aOptions.preFn("agents/send", { id: agentId, message: message, options: options }) } - var result = _jsonrpc.exec("agents/send", { + var result = _execWithAuth("agents/send", { id: agentId, message: message, options: options @@ -9235,7 +9654,7 @@ const $mcp = function(aOptions) { return _r }, exec: (method, params) => { - return _jsonrpc.exec(method, params) + return _execWithAuth(method, params) }, destroy: () => { _jsonrpc.destroy() @@ -14524,6 +14943,199 @@ const $ssh = function(aMap) { return new __ssh(aMap); }; +/** + * + * $ftp.$ftp(aMap) : $ftp + * Builds an object to allow access through ftp/ftps. aMap should be a ftp/ftps string with the format: + * ftp://user:pass@host:port/?timeout=1234&passive=true&binary=true or + * ftps://user:pass@host:port/?timeout=1234&passive=true&binary=true&implicit=false&protocol=TLS or + * a map with the keys: host, port, login, pass, secure, implicit, protocol, passive, binary and timeout. + * See "help FTP.FTP" for more info. + * + */ +const $ftp = function(aMap) { + var __ftp = function(aMap) { + plugin("FTP"); + aMap = _$(aMap).$_("Please provide a ftp/ftps map or an URL"); + this.map = aMap; + this.ftp = this.__connect(aMap); + }; + + __ftp.prototype.__getftp = function() { + if (isUnDef(this.ftp)) this.ftp = this.__connect(this.map); + return this.ftp; + }; + + __ftp.prototype.__connect = function(aMap) { + var f; + + if (isMap(aMap)) { + aMap.secure = _$(aMap.secure).isBoolean().default(false); + aMap.implicit = _$(aMap.implicit).isBoolean().default(false); + aMap.port = _$(aMap.port).isNumber().default((aMap.secure && aMap.implicit ? 990 : 21)); + aMap.protocol = _$(aMap.protocol).isString().default("TLS"); + aMap.passive = _$(aMap.passive).isBoolean().default(true); + aMap.binary = _$(aMap.binary).isBoolean().default(true); + if (isDef(aMap.url)) aMap.host = aMap.url; + } + + if (!(aMap instanceof FTP)) { + f = new FTP((isString(aMap) ? aMap : aMap.host), aMap.port, aMap.login, aMap.pass, aMap.secure, aMap.implicit, aMap.protocol, aMap.passive, aMap.binary, aMap.timeout); + } else { + f = aMap; + } + + return f; + }; + + /** + * + * $ftp.cd(aPath) : $ftp + * Changes the current remote working directory. + * + */ + __ftp.prototype.cd = function(aPath) { + this.__getftp().cd(aPath); + return this; + }; + + /** + * + * $ftp.pwd() : String + * Returns the current remote working directory. + * + */ + __ftp.prototype.pwd = function() { + var res = this.__getftp().pwd(); + this.close(); + return res; + }; + + /** + * + * $ftp.timeout(aTimeout) : $ftp + * Sets aTimeout in ms for the ftp/ftps connection to a remote host defined by aMap. + * + */ + __ftp.prototype.timeout = function(aTimeout) { + this.__getftp().setTimeout(aTimeout); + return this; + }; + + /** + * + * $ftp.passive(isPassive) : $ftp + * Sets the passive mode for the ftp/ftps connection. + * + */ + __ftp.prototype.passive = function(isPassive) { + this.__getftp().setPassiveMode(isPassive); + return this; + }; + + /** + * + * $ftp.binary(isBinary) : $ftp + * Sets the file transfer mode between binary and ascii. + * + */ + __ftp.prototype.binary = function(isBinary) { + this.__getftp().setBinaryMode(isBinary); + return this; + }; + + /** + * + * $ftp.mkdir(aDirectory) : $ftp + * Creates aDirectory via FTP/FTPS on a remote host defined by aMap. + * + */ + __ftp.prototype.mkdir = function(aDir) { + this.__getftp().mkdir(aDir); + return this; + }; + + /** + * + * $ftp.getFile(aSource, aTarget) : $ftp + * Gets aSource filepath and stores it locally on aTarget from a remote host defined by aMap. + * + */ + __ftp.prototype.getFile = function(aSource, aTarget) { + this.__getftp().ftpGet(aSource, aTarget); + return this; + }; + + /** + * + * $ftp.putFile(aSource, aTarget) : $ftp + * Puts aSource local filepath or Java stream and stores it remotely in aTarget on a remote host defined by aMap. + * + */ + __ftp.prototype.putFile = function(aSource, aTarget) { + this.__getftp().ftpPut(aSource, aTarget); + return this; + }; + + /** + * + * $ftp.rename(aSource, aTarget) : $ftp + * Renames aSource filepath to aTarget filepath on a remote host defined by aMap. + * + */ + __ftp.prototype.rename = function(aSource, aTarget) { + this.__getftp().rename(aSource, aTarget); + return this; + }; + + /** + * + * $ftp.listFiles(aRemotePath) : Array + * Returns an array of maps with the listing of aRemotePath provided. + * + */ + __ftp.prototype.listFiles = function(aPath) { + var lst = this.__getftp().listFiles(aPath); + this.close(); + return (isDef(lst) ? lst.files : []); + }; + + /** + * + * $ftp.rm(aFilePath) : $ftp + * Remove aFilePath from a remote host defined by aMap. + * + */ + __ftp.prototype.rm = function(aFilePath) { + this.__getftp().rm(aFilePath); + return this; + }; + + /** + * + * $ftp.rmdir(aFilePath) : $ftp + * Removes a directory from a remote host defined by aMap. + * + */ + __ftp.prototype.rmdir = function(aFilePath) { + this.__getftp().rmdir(aFilePath); + return this; + }; + + /** + * + * $ftp.close() : $ftp + * Closes a remote host connection defined by aMap. + * + */ + __ftp.prototype.close = function() { + if (isDef(this.ftp)) this.ftp.close(); + return this; + }; + + return new __ftp(aMap); +}; + /** * * $csv(aMap) : $csv diff --git a/js/openafgui.js b/js/openafgui.js index 14059dca7..907cfa56e 100644 --- a/js/openafgui.js +++ b/js/openafgui.js @@ -86,20 +86,34 @@ var templates = ow.template.loadHBSs({ "odoc" : getOpenAFJar() + "::hbs/odoc.hbs" }); +function guiPrefix() { + return ow.server.httpd.getPrefix(httpServer); +} + +function guiPath(aURI) { + return ow.server.httpd.withPrefix(httpServer, aURI); +} + +function withGuiPage(aPage) { + var page = clone(aPage); + page.uriPrefix = guiPrefix(); + return page; +} + var defaultPage = { "title": "OpenAF", "logo" : { "text": "OpenAF", - "link": "/" + "link": guiPath("/") }, "served": { "text": "OpenAF (version " + getVersion() + ")", "link": "https://openaf.io" }, "navbar": [ - { "text": "openaf-Console", "link": "/exec/openaf-console" }, - { "text": "Documentation", "link": "/odoc" }, - { "text": "oPacks", "link": "/opack" } + { "text": "openaf-Console", "link": guiPath("/exec/openaf-console") }, + { "text": "Documentation", "link": guiPath("/odoc") }, + { "text": "oPacks", "link": guiPath("/opack") } ], "contents": "" + "

" + @@ -107,10 +121,10 @@ var defaultPage = { (verifyIfInstalled() ? "(version " + getVersion() + " installed in " + getOpenAFPath() + ")
" + ((needupdate) ? - "
There is a new version " + updateversion + ". Do you wish to update this version?   

play_for_workUpdate" + "
There is a new version " + updateversion + ". Do you wish to update this version?   

play_for_workUpdate" : "") : - "Do you want to install OpenAF?   

play_for_workInstall

(install will create scripts in " + getOpenAFPath() + " to make it easier to use OpenAF)." + "Do you want to install OpenAF?   

play_for_workInstall

(install will create scripts in " + getOpenAFPath() + " to make it easier to use OpenAF)." ) + "

" + "


" + "
" + String(Packages.openaf.AFCmdOS.argHelp).replace(/\n/g, "
").replace(/ /g, " ") + "
" @@ -224,11 +238,11 @@ ow.server.httpd.route(httpServer, ow.server.httpd.mapRoutesWithLibs(httpServer, } if (terms.length == 1) { - odoc.contents = templates("odoc", { "id": id, "list": false, "terms": terms, "res": searchHelp(id)[0] }); + odoc.contents = templates("odoc", { "id": id, "list": false, "terms": terms, "res": searchHelp(id)[0], "uriPrefix": guiPrefix() }); } else { - odoc.contents = templates("odoc", { "id": id, "list": true, "terms": terms }); + odoc.contents = templates("odoc", { "id": id, "list": true, "terms": terms, "uriPrefix": guiPrefix() }); } - return httpServer.replyOKHTML(templates("index", odoc)); + return httpServer.replyOKHTML(templates("index", withGuiPage(odoc))); }catch(e) {logErr(e)} }, "/odocKey": function(req) { @@ -245,6 +259,7 @@ ow.server.httpd.route(httpServer, ow.server.httpd.mapRoutesWithLibs(httpServer, var remote = getOPackRemoteDB(); opacks.contents = templates("opacks", { + "uriPrefix": guiPrefix(), "installed": $stream(Object.keys(local)) .map(function(r) { return { "Name": local[r].name, @@ -271,12 +286,12 @@ ow.server.httpd.route(httpServer, ow.server.httpd.mapRoutesWithLibs(httpServer, .sorted("Name") .toArray() }); - return httpServer.replyOKHTML(templates("index", opacks)); + return httpServer.replyOKHTML(templates("index", withGuiPage(opacks))); } catch(e) {logErr(e)} } }), function(req) { - return httpServer.replyOKHTML(templates("index", defaultPage)); + return httpServer.replyOKHTML(templates("index", withGuiPage(defaultPage))); }, __, function(req) { keepRunning = true; diff --git a/js/owrap.oJob.js b/js/owrap.oJob.js index fd1e6b6b5..a08460acf 100755 --- a/js/owrap.oJob.js +++ b/js/owrap.oJob.js @@ -1788,19 +1788,57 @@ OpenWrap.oJob.prototype.stop = function() { }; OpenWrap.oJob.prototype.__defaultArgs = function(aArgs) { + function _isEscaped(aStr, aPos) { + var c = 0 + for (var i = aPos - 1; i >= 0 && aStr.charAt(i) == "\\"; i--) c++ + return (c % 2) == 1 + } + + function _unescapeEscapedTokens(aStr) { + return String(aStr).replace(/(\\+)\$\{([^}]+)\}/g, (aM, aSlashes, aExpr) => { + if ((aSlashes.length % 2) == 1) { + return aSlashes.substring(1) + "${" + aExpr + "}" + } + return aM + }) + } + + function _resolveToken(aExpr, aKey, aRaw) { + var _p = aExpr.indexOf(":-") + var _k = (_p >= 0 ? aExpr.substring(0, _p) : aExpr) + var _d = (_p >= 0 ? aExpr.substring(_p + 2) : __) + var _r + + if (_k != aKey) { + _r = $$(aArgs).get(_k) + if ("undefined" == typeof _r && _p >= 0) { + // There is a default + _r = _d + } + return { self: false, value: _r } + } else { + logWarn("oJob: argument '" + aKey + "' can't be used as default value for itself (" + aRaw + ")") + return { self: true, value: __ } + } + } + traverse(aArgs, (aK, aV, aP, aO) => { - if ("string" == typeof aV && aV.length > 3 && aV.startsWith("${") && aV.endsWith("}")) { - var _s = aV.substring(2, aV.length - 1) - var _r, _t = _s.split(":-") - if (_t[0] != aK) { - _r = $$(aArgs).get(_t[0]) - if ("undefined" == typeof _r && _t.length > 1) { - // There is a default - _r = _t[1] - } - aO[aK] = _r + if ("string" == typeof aV && aV.length > 3) { + var _m = aV.match(/^\$\{([^}]+)\}$/) + if (isArray(_m) && !_isEscaped(aV, 0)) { + var _res = _resolveToken(_m[1], aK, aV) + if (!_res.self) aO[aK] = _res.value } else { - logWarn("oJob: argument '" + aK + "' can't be used as default value for itself (" + aV + ")") + if (aV.indexOf("${") >= 0) { + aO[aK] = aV.replace(/\$\{([^}]+)\}/g, (aM, aExpr, aPos, aSrc) => { + if (_isEscaped(aSrc, aPos)) return aM + var _res = _resolveToken(aExpr, aK, aV) + if (_res.self) return aM + if ("undefined" == typeof _res.value || _res.value == null) return "" + return String(_res.value) + }) + aO[aK] = _unescapeEscapedTokens(aO[aK]) + } } } }) diff --git a/js/owrap.server.js b/js/owrap.server.js index 6cfa6f768..301a89d32 100644 --- a/js/owrap.server.js +++ b/js/owrap.server.js @@ -2958,6 +2958,71 @@ OpenWrap.server.prototype.httpd = { __servers: {}, customLibs: {}, + normalizePrefix: function(aPrefix) { + if (isUnDef(aPrefix)) return "" + if (!isString(aPrefix)) return "" + + aPrefix = String(aPrefix).trim() + if (aPrefix == "" || aPrefix == "/") return "" + + aPrefix = aPrefix.replace(/^[^/]+/, "/$&").replace(/\/+/g, "/").replace(/\/$/, "") + return (aPrefix == "/" ? "" : aPrefix) + }, + + getPrefix: function(aHTTPdOrPort) { + var aPort = aHTTPdOrPort + if (isDef(aHTTPdOrPort) && isDef(aHTTPdOrPort.getPort)) aPort = aHTTPdOrPort.getPort() + if (isUnDef(aPort)) return this.normalizePrefix(__flags.HTTPD_PREFIX["0"]) + if (!isString(aPort) && !isNumber(aPort)) return this.normalizePrefix(__flags.HTTPD_PREFIX["0"]) + + aPort = String(aPort) + if (isDef(__flags.HTTPD_PREFIX[aPort])) return this.normalizePrefix(__flags.HTTPD_PREFIX[aPort]) + return this.normalizePrefix(__flags.HTTPD_PREFIX["0"]) + }, + + getHTMLPrefix: function(aHTTPdOrPrefix) { + if (isDef(aHTTPdOrPrefix) && isDef(aHTTPdOrPrefix.getPort)) return this.getPrefix(aHTTPdOrPrefix) + if (isString(aHTTPdOrPrefix)) return this.normalizePrefix(aHTTPdOrPrefix) + return this.getPrefix(0) + }, + + withPrefix: function(aHTTPdOrPort, aURI) { + if (!isString(aURI)) aURI = "/" + + if (aURI.match(/^[a-z]+:\/\//i) || aURI.startsWith("//")) return aURI + + var aPrefix = this.getPrefix(aHTTPdOrPort) + if (aPrefix == "") return aURI + + if (aURI == "") return aPrefix + if (!aURI.startsWith("/")) aURI = "/" + aURI + if (aURI == aPrefix || aURI.startsWith(aPrefix + "/")) return aURI + + return aPrefix + aURI + }, + + stripPrefix: function(aHTTPdOrPort, aURI) { + if (!isString(aURI)) aURI = "/" + + var aPrefix = this.getPrefix(aHTTPdOrPort) + if (aPrefix == "") return aURI + if (aURI == aPrefix || aURI == (aPrefix + "/")) return "/" + if (aURI.startsWith(aPrefix + "/")) return aURI.substring(aPrefix.length) + + return __ + }, + + escapeRE: function(aString) { + return String(aString).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + }, + + rewriteCSSWithPrefix: function(aHTTPd, aCSS) { + var aPrefix = this.getPrefix(aHTTPd) + if (aPrefix == "") return aCSS + + return String(aCSS).replace(/url\((['"]?)\/(fonts\/[^'")]+)\1\)/g, "url($1" + aPrefix + "/$2$1)") + }, + /** * * ow.server.httpd.start(aPort, aHost, keyStorePath, password, errorFunction, aWebSockets, aTimeout, aImpl) : Object @@ -3152,7 +3217,14 @@ OpenWrap.server.prototype.httpd = { aHTTPd.add(aPath, function(req) { try { if (aHTTPd.getImpl() == "java") cnvt2string(req) - var uri = req.uri.replace(new RegExp("^" + aP), ""); + var uri = req.uri.replace(new RegExp("^" + parent.escapeRE(aP)), ""); + var prefURI = parent.stripPrefix(aHTTPd, uri) + if (parent.getPrefix(aHTTPd) != "") { + if (isUnDef(prefURI)) return parent.__defaultRoutes[aPort](req, aHTTPd); + req.originalURI = req.uri + req.uri = prefURI + uri = prefURI + } if (isFunction(parent.__preRoutes[aPort])) parent.__preRoutes[aPort](req, aHTTPd); if (isFunction(parent.__routes[aPort][uri])) { return parent.__routes[aPort][uri](req, aHTTPd); @@ -3264,7 +3336,7 @@ OpenWrap.server.prototype.httpd = { aMapOfRoutes["/js/materialize2.js"] = function() { return aHTTPd.reply(ow.server.httpd.getFromOpenAF("js/materialize2.js"), ow.server.httpd.mimes.JS, ow.server.httpd.codes.OK, ow.server.httpd.cache.public) }; aMapOfRoutes["/js/nlinq.js"] = function() { return aHTTPd.reply(ow.server.httpd.getFromOpenAF("js/nlinq.js"), ow.server.httpd.mimes.JS, ow.server.httpd.codes.OK, ow.server.httpd.cache.public) } aMapOfRoutes["/css/materialize.css"] = function() { return aHTTPd.reply(ow.server.httpd.getFromOpenAF("css/materialize.css"), ow.server.httpd.mimes.CSS, ow.server.httpd.codes.OK, ow.server.httpd.cache.public) }; - aMapOfRoutes["/css/materialize-icon.css"] = function() { return aHTTPd.reply(ow.server.httpd.getFromOpenAF("css/materialize-icon.css"), ow.server.httpd.mimes.CSS, ow.server.httpd.codes.OK, ow.server.httpd.cache.public) }; + aMapOfRoutes["/css/materialize-icon.css"] = function() { return aHTTPd.reply(ow.server.httpd.rewriteCSSWithPrefix(aHTTPd, ow.server.httpd.getFromOpenAF("css/materialize-icon.css")), ow.server.httpd.mimes.CSS, ow.server.httpd.codes.OK, ow.server.httpd.cache.public) }; aMapOfRoutes["/css/github-gist.css"] = function() { return aHTTPd.reply(ow.server.httpd.getFromOpenAF("css/github-gist.css"), ow.server.httpd.mimes.CSS, ow.server.httpd.codes.OK, ow.server.httpd.cache.public) }; aMapOfRoutes["/css/github-markdown.css"] = function() { return aHTTPd.reply(ow.server.httpd.getFromOpenAF("css/github-markdown.css"), ow.server.httpd.mimes.CSS, ow.server.httpd.codes.OK, ow.server.httpd.cache.public) }; aMapOfRoutes["/css/nJSMap.css"] = function() { return aHTTPd.reply(ow.server.httpd.getFromOpenAF("css/nJSMap.css"), ow.server.httpd.mimes.CSS, ow.server.httpd.codes.OK, ow.server.httpd.cache.public) }; @@ -3439,7 +3511,7 @@ OpenWrap.server.prototype.httpd = { if (furi.match(new RegExp("^" + baseFilePath))) { if (furi.match(/\.md$/)) { - return aHTTPd.replyOKHTML(ow.template.parseMD2HTML(io.readFileString(furi), 1, noMaxWidth)); + return aHTTPd.replyOKHTML(ow.template.parseMD2HTML(io.readFileString(furi), 1, noMaxWidth, __, __, ow.server.httpd.getPrefix(aHTTPd))); } else { return aHTTPd.replyBytes(io.readFileBytes(furi), ow.server.httpd.getMimeType(furi), __, mapOfHeaders); } @@ -3451,7 +3523,7 @@ OpenWrap.server.prototype.httpd = { } } else { try { - return aHTTPd.replyOKHTML(ow.template.parseMD2HTML(af.fromInputStream2String(aBaseFilePath), 1, noMaxWidth)) + return aHTTPd.replyOKHTML(ow.template.parseMD2HTML(af.fromInputStream2String(aBaseFilePath), 1, noMaxWidth, __, __, ow.server.httpd.getPrefix(aHTTPd))) } catch(e) { return notFoundFunction(aHTTPd, aBaseFilePath, aBaseURI, aURI, e) } @@ -3489,7 +3561,7 @@ OpenWrap.server.prototype.httpd = { code += "document.getElementById(\"njsmap_out\").innerHTML = out;" return aHTTPd.replyOKHTML("" + _themeauto + "")*/ - return aHTTPd.replyOKHTML(ow.template.html.parseMapInHTML(aMapOrArray)) + return aHTTPd.replyOKHTML(ow.template.html.parseMapInHTML(aMapOrArray, __, ow.server.httpd.getPrefix(aHTTPd))) //return aHTTPd.replyOKHTML("" + res.out + _themeauto + ""); }, @@ -3500,6 +3572,9 @@ OpenWrap.server.prototype.httpd = { * */ replyRedirect: function(aHTTPd, newLocation, mapOfHeaders) { + if (isString(newLocation) && newLocation.startsWith("/") && !newLocation.startsWith("//")) { + newLocation = ow.server.httpd.withPrefix(aHTTPd, newLocation) + } return aHTTPd.reply("", "text/plain", 303, merge({"Location": newLocation}, mapOfHeaders)); }, @@ -4426,6 +4501,7 @@ OpenWrap.server.prototype.httpd.browse = { renderList: (lst, server, request, options) => { if (isDef(options.browse) && !options.browse) return "" const uri = request.uri + const publicParentURI = ow.server.httpd.withPrefix(server, options.parentURI) var puri = uri.replace(new RegExp("^" + options.parentURI + "/?"), "").replace(/\/$/, "") if (isString(options.suffix)) @@ -4439,20 +4515,20 @@ OpenWrap.server.prototype.httpd.browse = { } const breadcrumb = p => { - if (p == "") return "[/]() [" + (options.showURI ? options.parentURI.replace(/^\//, "") : "") + "](<" + options.parentURI.replace(/ +/g, "") + ">)" + if (p == "") return "[/](<" + ow.server.httpd.withPrefix(server, "/") + ">) [" + (options.showURI ? options.parentURI.replace(/^\//, "") : "") + "](<" + publicParentURI.replace(/ +/g, "") + ">)" var parts = p.split("/") - var b = "[/]() [" + (options.showURI ? options.parentURI.replace(/^\//, "") : "") + "](<" + options.parentURI.replace(/ +/g, "") + ">)" + var b = "[/](<" + ow.server.httpd.withPrefix(server, "/") + ">) [" + (options.showURI ? options.parentURI.replace(/^\//, "") : "") + "](<" + publicParentURI.replace(/ +/g, "") + ">)" for (var i = 0; i < parts.length; i++) { if (i == parts.length - 1) { b += (!options.showURI && i == 0 ? "" : " / ") + parts[i] + " " } else { - b += " [" + (!options.showURI && i == 0 ? "" : " / ") + " " + parts[i] + "](<" + options.parentURI + "/" + parts.slice(0, i + 1).join("/") + "/>)" + b += " [" + (!options.showURI && i == 0 ? "" : " / ") + " " + parts[i] + "](<" + publicParentURI + "/" + parts.slice(0, i + 1).join("/") + "/>)" } } return b } - const logo = _$(options.logo, "logo").isString().default("/fonts/openaf_small.png") + const logo = ow.server.httpd.withPrefix(server, (isString(options.logo) ? options.logo : "/fonts/openaf_small.png")) var content = "## " + breadcrumb( puri ) + "\n\n" content += "| |" + lst.fields.join(" | ") + " |\n" if (lst.alignFields) { @@ -4469,7 +4545,7 @@ OpenWrap.server.prototype.httpd.browse = { content += "|\n" if (puri != "/" && puri != "") { - content += "| | __[..](<" + options.parentURI + puri.replace(/[^\/]+\/?$/, "") + ">)__ | | |\n" + content += "| | __[..](<" + publicParentURI + puri.replace(/[^\/]+\/?$/, "") + ">)__ | | |\n" } $from(lst.list).sort("-isDirectory", "values." + lst.key).select(r => { @@ -4477,13 +4553,13 @@ OpenWrap.server.prototype.httpd.browse = { if (r.isDirectory) { content += " |" } else { - content += " 0 ? "/" : "") + puri + "/" + r.values[lst.key] + "?raw=true\" download=\"" + r.values[lst.key] + "\">↓ |" + content += " 0 ? "/" : "") + puri + "/" + r.values[lst.key] + "?raw=true\" download=\"" + r.values[lst.key] + "\">↓ |" } lst.fields.forEach((f, i) => { if (r.isDirectory) { - content += " " + (i == 0 ? "__[" + r.values[f] + "](<" + options.parentURI + (puri.length > 0 ? "/" : "") + puri + "/" + r.values[lst.key] + ">)__" : r.values[f]) + " |" + content += " " + (i == 0 ? "__[" + r.values[f] + "](<" + publicParentURI + (puri.length > 0 ? "/" : "") + puri + "/" + r.values[lst.key] + ">)__" : r.values[f]) + " |" } else { - content += " " + (i == 0 ? "[" + r.values[f] + "](<" + options.parentURI + (puri.length > 0 ? "/" : "") + puri + "/" + r.values[lst.key] + options.suffix + ">)" : r.values[f]) + " |" + content += " " + (i == 0 ? "[" + r.values[f] + "](<" + publicParentURI + (puri.length > 0 ? "/" : "") + puri + "/" + r.values[lst.key] + options.suffix + ">)" : r.values[f]) + " |" } }) content += "\n" @@ -4494,12 +4570,13 @@ OpenWrap.server.prototype.httpd.browse = { content += "\n" + options.footer + "\n" } - if (options.sortTab) content += "\n" + if (options.sortTab) content += "\n" - return ow.template.parseMD2HTML( content, true ) + return ow.template.parseMD2HTML(content, true, __, __, __, ow.server.httpd.getPrefix(server)) }, renderObj: (obj, server, request, options) => { const uri = request.uri + const publicParentURI = ow.server.httpd.withPrefix(server, options.parentURI) var puri = uri.replace(new RegExp("^" + options.parentURI + "/?"), "").replace(/\/$/, "") delete request.params["NanoHttpd.QUERY_STRING"] @@ -4536,7 +4613,7 @@ OpenWrap.server.prototype.httpd.browse = { } if (obj.type == "md") { - return server.replyOKHTML(ow.template.parseMD2HTML((options.useMDTemplate ? $t(String(obj.data), { request: request }) : String(obj.data)), true)) + return server.replyOKHTML(ow.template.parseMD2HTML((options.useMDTemplate ? $t(String(obj.data), { request: request }) : String(obj.data)), true, __, __, __, ow.server.httpd.getPrefix(server))) } if (obj.type == "json" && (isUnDef(request.params.parse) || request.params.parse != "false")) { @@ -4544,14 +4621,14 @@ OpenWrap.server.prototype.httpd.browse = { } const breadcrumb = p => { - if (p == "") return "[/]() [" + (options.showURI ? options.parentURI.replace(/^\//, "") : "") + "](<" + options.parentURI.replace(/ +/g, "") + ">)" + if (p == "") return "[/](<" + ow.server.httpd.withPrefix(server, "/") + ">) [" + (options.showURI ? options.parentURI.replace(/^\//, "") : "") + "](<" + publicParentURI.replace(/ +/g, "") + ">)" var parts = p.split("/") - var b = "[/]() [" + (options.showURI ? options.parentURI.replace(/^\//, "") : "") + "](<" + options.parentURI.replace(/ +/g, "") + ">)" + var b = "[/](<" + ow.server.httpd.withPrefix(server, "/") + ">) [" + (options.showURI ? options.parentURI.replace(/^\//, "") : "") + "](<" + publicParentURI.replace(/ +/g, "") + ">)" for (var i = 0; i < parts.length; i++) { if (i == parts.length - 1) { b += (!options.showURI && i == 0 ? "" : " / ") + parts[i] + " " } else { - b += " [" + (!options.showURI && i == 0 ? "" : " / ") + " " + parts[i] + "](<" + options.parentURI + "/" + parts.slice(0, i + 1).join("/") + "/>)" + b += " [" + (!options.showURI && i == 0 ? "" : " / ") + " " + parts[i] + "](<" + publicParentURI + "/" + parts.slice(0, i + 1).join("/") + "/>)" } } return b @@ -4574,7 +4651,7 @@ OpenWrap.server.prototype.httpd.browse = { if (obj.type == "rawjson") obj.type = "json" - const logo = _$(options.logo, "logo").isString().default("/fonts/openaf_small.png") + const logo = ow.server.httpd.withPrefix(server, (isString(options.logo) ? options.logo : "/fonts/openaf_small.png")) var content if (obj.type == "raw") { content = "## " + breadcrumb( puri ) + "\n\n" @@ -4597,7 +4674,7 @@ OpenWrap.server.prototype.httpd.browse = { } - return server.replyOKHTML( ow.template.parseMD2HTML( content, true ) ) + return server.replyOKHTML(ow.template.parseMD2HTML(content, true, __, __, __, ow.server.httpd.getPrefix(server))) }, renderEmpty: (request, options) => { const uri = request.uri @@ -4607,7 +4684,7 @@ OpenWrap.server.prototype.httpd.browse = { var content = "# " + (options.showURI ? options.parentURI + "/" : "") + puri + "\n\n" content += "*No content found.*\n" - return ow.template.parseMD2HTML( content, true ) + return ow.template.parseMD2HTML(content, true, __, __, __, ow.server.httpd.getPrefix(options.serverOrPort)) } } }, aOptions) diff --git a/js/owrap.template.js b/js/owrap.template.js index 278c5c5e0..9df05243a 100644 --- a/js/owrap.template.js +++ b/js/owrap.template.js @@ -804,10 +804,20 @@ OpenWrap.template.prototype.loadCompiledHBS = function(aFilename) { * \ *
*/ -OpenWrap.template.prototype.parseMD2HTML = function(aMarkdownString, isFull, removeMaxWidth, extraDownOptions, forceDark) { +OpenWrap.template.prototype.parseMD2HTML = function(aMarkdownString, isFull, removeMaxWidth, extraDownOptions, forceDark, aURIPrefix) { extraDownOptions = _$(extraDownOptions).isMap().default(__flags.MD_SHOWDOWN_OPTIONS) + aURIPrefix = ow.loadServer().httpd.getHTMLPrefix(aURIPrefix) removeMaxWidth = _$(removeMaxWidth, "removeMaxWidth").isBoolean().default(__flags.MD_NOMAXWIDTH) + var mdString = aMarkdownString + var svgBlocks = [] + if (__flags.MD_RENDER_SVG) { + mdString = String(mdString).replace(/(^|\n)```svg[ \t]*\r?\n([\s\S]*?)\r?\n```(?=\n|$)/g, (m, prefix, svg) => { + var token = "OAFMDSVGBLOCK" + svgBlocks.length + "PLACEHOLDER" + svgBlocks.push(svg) + return prefix + token + }) + } var showdown = require(getOpenAFJar() + "::js/showdown.js"); //var showdown = loadCompiledRequire("showdown_js"); showdown.setFlavor("github"); @@ -855,7 +865,7 @@ OpenWrap.template.prototype.parseMD2HTML = function(aMarkdownString, isFull, rem var _extras = [], _posextras = [] if (__flags.MD_CHART) { - _extras.push('') + _extras.push('') _posextras.push(` " + _themeauto + "" + return "" + _themeauto + "" }, /** * diff --git a/lib/jsch-2.27.8.jar b/lib/jsch-2.27.9.jar similarity index 76% rename from lib/jsch-2.27.8.jar rename to lib/jsch-2.27.9.jar index 0fc0d324b..3804ce941 100644 Binary files a/lib/jsch-2.27.8.jar and b/lib/jsch-2.27.9.jar differ diff --git a/lib/kotlin-stdlib-2.3.0.jar b/lib/kotlin-stdlib-2.3.0.jar deleted file mode 100644 index 336c320bc..000000000 Binary files a/lib/kotlin-stdlib-2.3.0.jar and /dev/null differ diff --git a/lib/kotlin-stdlib-2.3.10.jar b/lib/kotlin-stdlib-2.3.20.jar similarity index 62% rename from lib/kotlin-stdlib-2.3.10.jar rename to lib/kotlin-stdlib-2.3.20.jar index ff4808afe..135f994ae 100644 Binary files a/lib/kotlin-stdlib-2.3.10.jar and b/lib/kotlin-stdlib-2.3.20.jar differ diff --git a/pom.json b/pom.json index 63d67898b..77b6451e0 100644 --- a/pom.json +++ b/pom.json @@ -1 +1 @@ -{"{\"artifactId\":\"rhino\"}":{"groupId":"org.mozilla","artifactId":"rhino","version":"1.8.1"},"{\"artifactId\":\"rhino-xml\"}":{"groupId":"org.mozilla","artifactId":"rhino-xml","version":"1.8.1"},"{\"artifactId\":\"rhino-engine\"}":{"groupId":"org.mozilla","artifactId":"rhino-engine","version":"1.8.1"},"{\"artifactId\":\"asciilist-j7\"}":{"groupId":"de.vandermeer","artifactId":"asciilist-j7","version":"1.0.0"},"{\"artifactId\":\"asciitable-j7\"}":{"groupId":"de.vandermeer","artifactId":"asciitable-j7","version":"1.0.1"},"{\"artifactId\":\"commons-cli\"}":{"groupId":"commons-cli","artifactId":"commons-cli","version":"1.11.0"},"{\"artifactId\":\"commons-codec\"}":{"groupId":"commons-codec","artifactId":"commons-codec","version":"1.20.0"},"{\"artifactId\":\"commons-collections4\"}":{"groupId":"org.apache.commons","artifactId":"commons-collections4","version":"4.5.0"},"{\"artifactId\":\"commons-compress\"}":{"groupId":"org.apache.commons","artifactId":"commons-compress","version":"1.28.0"},"{\"artifactId\":\"commons-csv\"}":{"groupId":"org.apache.commons","artifactId":"commons-csv","version":"1.14.1"},"{\"artifactId\":\"commons-email\"}":{"groupId":"org.apache.commons","artifactId":"commons-email","version":"1.6.0"},"{\"artifactId\":\"commons-io\"}":{"groupId":"commons-io","artifactId":"commons-io","version":"2.21.0"},"{\"artifactId\":\"commons-lang3\"}":{"groupId":"org.apache.commons","artifactId":"commons-lang3","version":"3.20.0"},"{\"artifactId\":\"commons-logging\"}":{"groupId":"commons-logging","artifactId":"commons-logging","version":"1.3.5"},"{\"artifactId\":\"commons-math3\"}":{"groupId":"org.apache.commons","artifactId":"commons-math3","version":"3.6.1"},"{\"artifactId\":\"commons-net\"}":{"groupId":"commons-net","artifactId":"commons-net","version":"3.12.0"},"{\"artifactId\":\"googleauth\"}":{"groupId":"com.warrenstrange","artifactId":"googleauth","version":"1.5.0"},"{\"artifactId\":\"gson\"}":{"groupId":"com.google.code.gson","artifactId":"gson","version":"2.13.2"},"{\"artifactId\":\"h2\"}":{"groupId":"com.h2database","artifactId":"h2","version":"2.4.240"},"{\"artifactId\":\"jackson-annotations\"}":{"groupId":"com.fasterxml.jackson.core","artifactId":"jackson-annotations","version":"2.20"},"{\"artifactId\":\"jackson-core\"}":{"groupId":"com.fasterxml.jackson.core","artifactId":"jackson-core","version":"2.20.1"},"{\"artifactId\":\"jackson-databind\"}":{"groupId":"com.fasterxml.jackson.core","artifactId":"jackson-databind","version":"2.20.1"},"{\"artifactId\":\"jansi\"}":{"groupId":"org.fusesource.jansi","artifactId":"jansi","version":"1.18"},"{\"artifactId\":\"jaxb-api\"}":{"groupId":"javax.xml.bind","artifactId":"jaxb-api","version":"2.3.1"},"{\"artifactId\":\"jbsdiff\"}":{"groupId":"io.sigpipe","artifactId":"jbsdiff","version":"1.0"},"{\"artifactId\":\"jetty-client\"}":{"groupId":"org.eclipse.jetty","artifactId":"jetty-client","version":"12.1.5"},"{\"artifactId\":\"jetty-http\"}":{"groupId":"org.eclipse.jetty","artifactId":"jetty-http","version":"12.1.5"},"{\"artifactId\":\"jetty-io\"}":{"groupId":"org.eclipse.jetty","artifactId":"jetty-io","version":"12.1.5"},"{\"artifactId\":\"jetty-util\"}":{"groupId":"org.eclipse.jetty","artifactId":"jetty-util","version":"12.1.5"},"{\"artifactId\":\"jetty-websocket-jetty-common\"}":{"groupId":"org.eclipse.jetty.websocket","artifactId":"jetty-websocket-jetty-common","version":"12.1.5"},"{\"artifactId\":\"jetty-websocket-jetty-api\"}":{"groupId":"org.eclipse.jetty.websocket","artifactId":"jetty-websocket-jetty-api","version":"12.1.5"},"{\"artifactId\":\"jetty-websocket-jetty-client\"}":{"groupId":"org.eclipse.jetty.websocket","artifactId":"jetty-websocket-jetty-client","version":"12.1.5"},"{\"artifactId\":\"jetty-websocket-core-client\"}":{"groupId":"org.eclipse.jetty.websocket","artifactId":"jetty-websocket-core-client","version":"12.1.5"},"{\"artifactId\":\"jetty-websocket-core-common\"}":{"groupId":"org.eclipse.jetty.websocket","artifactId":"jetty-websocket-core-common","version":"12.1.5"},"{\"artifactId\":\"jline\"}":{"groupId":"jline","artifactId":"jline","version":"2.14.6"},"{\"artifactId\":\"jna-platform\"}":{"groupId":"net.java.dev.jna","artifactId":"jna-platform","version":"5.18.1"},"{\"artifactId\":\"jna\"}":{"groupId":"net.java.dev.jna","artifactId":"jna","version":"5.18.1"},"{\"artifactId\":\"postgresql\"}":{"groupId":"org.postgresql","artifactId":"postgresql","version":"42.7.8"},"{\"artifactId\":\"snmp4j\"}":{"groupId":"org.snmp4j","artifactId":"snmp4j","version":"3.9.6"},"{\"artifactId\":\"snmp4j-agent\"}":{"groupId":"org.snmp4j","artifactId":"snmp4j-agent","version":"3.8.3"},"{\"artifactId\":\"jjwt-impl\"}":{"groupId":"io.jsonwebtoken","artifactId":"jjwt-impl","version":"0.13.0"},"{\"artifactId\":\"jjwt-api\"}":{"groupId":"io.jsonwebtoken","artifactId":"jjwt-api","version":"0.13.0"},"{\"artifactId\":\"jjwt-gson\"}":{"groupId":"io.jsonwebtoken","artifactId":"jjwt-gson","version":"0.13.0"},"{\"artifactId\":\"okhttp\"}":{"groupId":"com.squareup.okhttp3","artifactId":"okhttp","version":"5.3.2"},"{\"artifactId\":\"okhttp-jvm\"}":{"groupId":"com.squareup.okhttp3","artifactId":"okhttp-jvm","version":"5.3.2"},"{\"artifactId\":\"kotlin-stdlib\"}":{"groupId":"org.jetbrains.kotlin","artifactId":"kotlin-stdlib","version":"2.3.0"},"{\"artifactId\":\"okio\"}":{"groupId":"com.squareup.okio","artifactId":"okio","version":"3.16.4"},"{\"artifactId\":\"okio-jvm\"}":{"groupId":"com.squareup.okio","artifactId":"okio-jvm","version":"3.16.4"},"{\"artifactId\":\"dnsjava\"}":{"groupId":"dnsjava","artifactId":"dnsjava","version":"3.6.3"},"{\"artifactId\":\"slf4j-api\"}":{"groupId":"org.slf4j","artifactId":"slf4j-api","version":"2.0.17"},"{\"artifactId\":\"slf4j-nop\"}":{"groupId":"org.slf4j","artifactId":"slf4j-nop","version":"2.0.17"},"{\"artifactId\":\"jsch\"}":{"groupId":"com.github.mwiede","artifactId":"jsch","version":"2.27.7"},"{\"artifactId\":\"sql-formatter\"}":{"groupId":"com.github.vertical-blank","artifactId":"sql-formatter","version":"2.0.5"},"{\"artifactId\":\"semver4j\"}":{"groupId":"org.semver4j","artifactId":"semver4j","version":"6.0.0"},"{\"artifactId\":\"ojdbc11\"}":{"groupId":"com.oracle.database.jdbc","artifactId":"ojdbc11","version":"23.9.0.25.07"},"{\"artifactId\":\"ojdbc17\"}":{"groupId":"com.oracle.database.jdbc","artifactId":"ojdbc17","version":"23.26.0.0.0"},"{\"artifactId\":\"jetty-compression-common\"}":{"groupId":"org.eclipse.jetty.compression","artifactId":"jetty-compression-common","version":"12.1.5"}} \ No newline at end of file +{"{\"artifactId\":\"rhino\"}":{"groupId":"org.mozilla","artifactId":"rhino","version":"1.8.1"},"{\"artifactId\":\"rhino-xml\"}":{"groupId":"org.mozilla","artifactId":"rhino-xml","version":"1.8.1"},"{\"artifactId\":\"rhino-engine\"}":{"groupId":"org.mozilla","artifactId":"rhino-engine","version":"1.8.1"},"{\"artifactId\":\"asciilist-j7\"}":{"groupId":"de.vandermeer","artifactId":"asciilist-j7","version":"1.0.0"},"{\"artifactId\":\"asciitable-j7\"}":{"groupId":"de.vandermeer","artifactId":"asciitable-j7","version":"1.0.1"},"{\"artifactId\":\"commons-cli\"}":{"groupId":"commons-cli","artifactId":"commons-cli","version":"1.11.0"},"{\"artifactId\":\"commons-codec\"}":{"groupId":"commons-codec","artifactId":"commons-codec","version":"1.21.0"},"{\"artifactId\":\"commons-collections4\"}":{"groupId":"org.apache.commons","artifactId":"commons-collections4","version":"4.5.0"},"{\"artifactId\":\"commons-compress\"}":{"groupId":"org.apache.commons","artifactId":"commons-compress","version":"1.28.0"},"{\"artifactId\":\"commons-csv\"}":{"groupId":"org.apache.commons","artifactId":"commons-csv","version":"1.14.1"},"{\"artifactId\":\"commons-email\"}":{"groupId":"org.apache.commons","artifactId":"commons-email","version":"1.6.0"},"{\"artifactId\":\"commons-io\"}":{"groupId":"commons-io","artifactId":"commons-io","version":"2.21.0"},"{\"artifactId\":\"commons-lang3\"}":{"groupId":"org.apache.commons","artifactId":"commons-lang3","version":"3.20.0"},"{\"artifactId\":\"commons-logging\"}":{"groupId":"commons-logging","artifactId":"commons-logging","version":"1.3.6"},"{\"artifactId\":\"commons-math3\"}":{"groupId":"org.apache.commons","artifactId":"commons-math3","version":"3.6.1"},"{\"artifactId\":\"commons-net\"}":{"groupId":"commons-net","artifactId":"commons-net","version":"3.12.0"},"{\"artifactId\":\"googleauth\"}":{"groupId":"com.warrenstrange","artifactId":"googleauth","version":"1.5.0"},"{\"artifactId\":\"gson\"}":{"groupId":"com.google.code.gson","artifactId":"gson","version":"2.13.2"},"{\"artifactId\":\"h2\"}":{"groupId":"com.h2database","artifactId":"h2","version":"2.4.240"},"{\"artifactId\":\"jackson-annotations\"}":{"groupId":"com.fasterxml.jackson.core","artifactId":"jackson-annotations","version":"2.21"},"{\"artifactId\":\"jackson-core\"}":{"groupId":"com.fasterxml.jackson.core","artifactId":"jackson-core","version":"2.21.1"},"{\"artifactId\":\"jackson-databind\"}":{"groupId":"com.fasterxml.jackson.core","artifactId":"jackson-databind","version":"2.21.1"},"{\"artifactId\":\"jansi\"}":{"groupId":"org.fusesource.jansi","artifactId":"jansi","version":"1.18"},"{\"artifactId\":\"jaxb-api\"}":{"groupId":"javax.xml.bind","artifactId":"jaxb-api","version":"2.3.1"},"{\"artifactId\":\"jbsdiff\"}":{"groupId":"io.sigpipe","artifactId":"jbsdiff","version":"1.0"},"{\"artifactId\":\"jetty-client\"}":{"groupId":"org.eclipse.jetty","artifactId":"jetty-client","version":"12.1.7"},"{\"artifactId\":\"jetty-http\"}":{"groupId":"org.eclipse.jetty","artifactId":"jetty-http","version":"12.1.7"},"{\"artifactId\":\"jetty-io\"}":{"groupId":"org.eclipse.jetty","artifactId":"jetty-io","version":"12.1.7"},"{\"artifactId\":\"jetty-util\"}":{"groupId":"org.eclipse.jetty","artifactId":"jetty-util","version":"12.1.7"},"{\"artifactId\":\"jetty-websocket-jetty-common\"}":{"groupId":"org.eclipse.jetty.websocket","artifactId":"jetty-websocket-jetty-common","version":"12.1.7"},"{\"artifactId\":\"jetty-websocket-jetty-api\"}":{"groupId":"org.eclipse.jetty.websocket","artifactId":"jetty-websocket-jetty-api","version":"12.1.7"},"{\"artifactId\":\"jetty-websocket-jetty-client\"}":{"groupId":"org.eclipse.jetty.websocket","artifactId":"jetty-websocket-jetty-client","version":"12.1.7"},"{\"artifactId\":\"jetty-websocket-core-client\"}":{"groupId":"org.eclipse.jetty.websocket","artifactId":"jetty-websocket-core-client","version":"12.1.7"},"{\"artifactId\":\"jetty-websocket-core-common\"}":{"groupId":"org.eclipse.jetty.websocket","artifactId":"jetty-websocket-core-common","version":"12.1.7"},"{\"artifactId\":\"jline\"}":{"groupId":"jline","artifactId":"jline","version":"2.14.6"},"{\"artifactId\":\"jna-platform\"}":{"groupId":"net.java.dev.jna","artifactId":"jna-platform","version":"5.18.1"},"{\"artifactId\":\"jna\"}":{"groupId":"net.java.dev.jna","artifactId":"jna","version":"5.18.1"},"{\"artifactId\":\"postgresql\"}":{"groupId":"org.postgresql","artifactId":"postgresql","version":"42.7.10"},"{\"artifactId\":\"snmp4j\"}":{"groupId":"org.snmp4j","artifactId":"snmp4j","version":"3.9.7"},"{\"artifactId\":\"snmp4j-agent\"}":{"groupId":"org.snmp4j","artifactId":"snmp4j-agent","version":"3.8.3"},"{\"artifactId\":\"jjwt-impl\"}":{"groupId":"io.jsonwebtoken","artifactId":"jjwt-impl","version":"0.13.0"},"{\"artifactId\":\"jjwt-api\"}":{"groupId":"io.jsonwebtoken","artifactId":"jjwt-api","version":"0.13.0"},"{\"artifactId\":\"jjwt-gson\"}":{"groupId":"io.jsonwebtoken","artifactId":"jjwt-gson","version":"0.13.0"},"{\"artifactId\":\"okhttp\"}":{"groupId":"com.squareup.okhttp3","artifactId":"okhttp","version":"5.3.2"},"{\"artifactId\":\"okhttp-jvm\"}":{"groupId":"com.squareup.okhttp3","artifactId":"okhttp-jvm","version":"5.3.2"},"{\"artifactId\":\"kotlin-stdlib\"}":{"groupId":"org.jetbrains.kotlin","artifactId":"kotlin-stdlib","version":"2.3.20"},"{\"artifactId\":\"okio\"}":{"groupId":"com.squareup.okio","artifactId":"okio","version":"3.17.0"},"{\"artifactId\":\"okio-jvm\"}":{"groupId":"com.squareup.okio","artifactId":"okio-jvm","version":"3.17.0"},"{\"artifactId\":\"dnsjava\"}":{"groupId":"dnsjava","artifactId":"dnsjava","version":"3.6.4"},"{\"artifactId\":\"slf4j-api\"}":{"groupId":"org.slf4j","artifactId":"slf4j-api","version":"2.0.17"},"{\"artifactId\":\"slf4j-nop\"}":{"groupId":"org.slf4j","artifactId":"slf4j-nop","version":"2.0.17"},"{\"artifactId\":\"jsch\"}":{"groupId":"com.github.mwiede","artifactId":"jsch","version":"2.27.9"},"{\"artifactId\":\"sql-formatter\"}":{"groupId":"com.github.vertical-blank","artifactId":"sql-formatter","version":"2.0.5"},"{\"artifactId\":\"semver4j\"}":{"groupId":"org.semver4j","artifactId":"semver4j","version":"6.0.0"},"{\"artifactId\":\"ojdbc11\"}":{"groupId":"com.oracle.database.jdbc","artifactId":"ojdbc11","version":"23.9.0.25.07"},"{\"artifactId\":\"ojdbc17\"}":{"groupId":"com.oracle.database.jdbc","artifactId":"ojdbc17","version":"23.26.1.0.0"},"{\"artifactId\":\"jetty-compression-common\"}":{"groupId":"org.eclipse.jetty.compression","artifactId":"jetty-compression-common","version":"12.1.7"},"{\"artifactId\":\"jackson-dataformat-toml\"}":{"groupId":"com.fasterxml.jackson.dataformat","artifactId":"jackson-dataformat-toml","version":"2.21.1"}} \ No newline at end of file diff --git a/pom.xml b/pom.xml index fbd97da52..52d1a1092 100644 --- a/pom.xml +++ b/pom.xml @@ -340,7 +340,7 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/ma org.jetbrains.kotlin kotlin-stdlib - 2.3.10 + 2.3.20 com.squareup.okio @@ -370,7 +370,7 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/ma com.github.mwiede jsch - 2.27.8 + 2.27.9 com.github.vertical-blank diff --git a/src/openaf/plugins/FTP.java b/src/openaf/plugins/FTP.java new file mode 100644 index 000000000..f4a61f6c6 --- /dev/null +++ b/src/openaf/plugins/FTP.java @@ -0,0 +1,514 @@ +package openaf.plugins; + +import java.io.ByteArrayInputStream; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Calendar; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.net.ftp.FTPClient; +import org.apache.commons.net.ftp.FTPFile; +import org.apache.commons.net.ftp.FTPReply; +import org.apache.commons.net.ftp.FTPSClient; +import org.mozilla.javascript.NativeJavaObject; +import org.mozilla.javascript.Scriptable; +import org.mozilla.javascript.ScriptableObject; +import org.mozilla.javascript.annotations.JSConstructor; +import org.mozilla.javascript.annotations.JSFunction; + +import openaf.AFCmdBase; + +/** + * + * Copyright 2026 Nuno Aguiar + * + */ +public class FTP extends ScriptableObject { + + private static final long serialVersionUID = 6100710955991642121L; + + protected String login, host, password, protocol = "TLS"; + protected int port; + protected int timeout = -1; + protected boolean secure = false; + protected boolean implicit = false; + protected boolean passive = true; + protected boolean binary = true; + protected FTPClient client; + + @Override + public String getClassName() { + return "FTP"; + } + + /** + * + * FTP.FTP(aHost, aPort, aLogin, aPass, isFTPS, isImplicit, aProtocol, isPassive, isBinary, aTimeout) : FTP + * Creates an instance of a FTP/FTPS client (and connects) given a host, port, login username and password. + * Alternatively you can provide a FTP/FTPS url where aHost = ftp://user:pass@host:port/?timeout=1234&passive=true&binary=true + * or ftps://user:pass@host:port/?timeout=1234&passive=true&binary=true&implicit=false&protocol=TLS. + * + * @throws Exception + */ + @JSConstructor + public void newFTP(String host, int port, String login, String pass, boolean secure, boolean implicit, String protocol, boolean passive, boolean binary, int timeout) throws Exception { + if (host.toLowerCase().startsWith("ftp:") || host.toLowerCase().startsWith("ftps:")) { + URI uri = new URI(host); + String scheme = uri.getScheme().toLowerCase(); + + if (scheme.equals("ftp") || scheme.equals("ftps")) { + this.secure = scheme.equals("ftps"); + this.port = uri.getPort(); + if (uri.getUserInfo() != null) { + if (uri.getUserInfo().indexOf(":") >= 0) { + this.login = AFCmdBase.afc.dIP(uri.getUserInfo().split(":")[0]); + this.password = AFCmdBase.afc.dIP(uri.getUserInfo().split(":")[1]); + } else { + this.login = AFCmdBase.afc.dIP(uri.getUserInfo()); + this.password = ""; + } + } + + if (uri.getQuery() != null) { + String[] parts = uri.getQuery().split("&"); + for(String part : parts) { + String[] kv = part.split("=", 2); + String key = kv[0].toLowerCase(); + String value = kv.length > 1 ? kv[1] : ""; + + switch(key) { + case "timeout": + setTimeout(Integer.parseInt(value)); + break; + case "passive": + this.passive = Boolean.parseBoolean(value); + break; + case "binary": + this.binary = Boolean.parseBoolean(value); + break; + case "implicit": + this.implicit = Boolean.parseBoolean(value); + break; + case "protocol": + if (value != null && value.length() > 0) this.protocol = value; + break; + } + } + } + + this.host = uri.getHost(); + if (this.port <= 0) this.port = this.secure && this.implicit ? 990 : 21; + } else { + throw new Exception("Host or FTP/FTPS url not correct."); + } + } else { + this.login = AFCmdBase.afc.dIP(login); + this.host = host; + this.password = AFCmdBase.afc.dIP(pass); + this.port = port > 0 ? port : (secure && implicit ? 990 : 21); + this.secure = secure; + this.implicit = implicit; + if (protocol != null && protocol.length() > 0) this.protocol = protocol; + this.passive = passive; + this.binary = binary; + if (timeout > 0) setTimeout(timeout); + } + + connectFTP(); + } + + protected void connectFTP() throws IOException { + if (secure) { + client = new FTPSClient(protocol, implicit); + } else { + client = new FTPClient(); + } + + if (timeout > 0) { + client.setConnectTimeout(timeout); + client.setDefaultTimeout(timeout); + client.setSoTimeout(timeout); + client.setDataTimeout(Duration.ofMillis(timeout)); + } + + client.connect(host, port); + if (!FTPReply.isPositiveCompletion(client.getReplyCode())) { + close(); + throw new IOException("Unable to connect to " + host + ":" + port + " (reply code = " + client.getReplyCode() + ")"); + } + + if (login != null && login.length() > 0) { + if (!client.login(login, password)) { + close(); + throw new IOException("FTP login failed for user '" + login + "'"); + } + } + + if (client instanceof FTPSClient) { + FTPSClient ftps = (FTPSClient) client; + ftps.execPBSZ(0); + ftps.execPROT("P"); + } + + applyModes(); + } + + protected void applyModes() throws IOException { + if (passive) { + client.enterLocalPassiveMode(); + } else { + client.enterLocalActiveMode(); + } + + client.setFileType(binary ? org.apache.commons.net.ftp.FTP.BINARY_FILE_TYPE : org.apache.commons.net.ftp.FTP.ASCII_FILE_TYPE); + } + + /** + * + * FTP.close() + * Closes the FTP/FTPS connection. + * + */ + @JSFunction + public void close() throws IOException { + if (client != null) { + if (client.isConnected()) { + try { + client.logout(); + } catch(Exception e) { + // Ignore logout failures on close. + } + client.disconnect(); + } + client = null; + } + } + + /** + * + * FTP.setTimeout(aTimeout) + * Sets aTimeout in ms for the FTP/FTPS connection. + * + */ + @JSFunction + public void setTimeout(int aTimeout) throws IOException { + timeout = aTimeout; + if (client != null) { + client.setConnectTimeout(aTimeout); + client.setDefaultTimeout(aTimeout); + client.setSoTimeout(aTimeout); + client.setDataTimeout(Duration.ofMillis(aTimeout)); + } + } + + /** + * + * FTP.getFTPClient() : Object + * Obtains the internal FTP/FTPS client. + * + */ + @JSFunction + public Object getFTPClient() { + return client; + } + + /** + * + * FTP.setPassiveMode(isPassive) + * Switches between passive and active mode. + * + */ + @JSFunction + public void setPassiveMode(boolean isPassive) throws IOException { + passive = isPassive; + if (client != null) applyModes(); + } + + /** + * + * FTP.setBinaryMode(isBinary) + * Switches between binary and ascii file transfer mode. + * + */ + @JSFunction + public void setBinaryMode(boolean isBinary) throws IOException { + binary = isBinary; + if (client != null) applyModes(); + } + + /** + * + * FTP.rename(aOriginalName, aNewName) + * Renames a remote original filename to a newname. + * + */ + @JSFunction + public void rename(String original, String newname) throws IOException { + if (!client.rename(original, newname)) { + throw new IOException(client.getReplyString()); + } + } + + /** + * + * FTP.rm(aFilePath) + * Removes a remote filename at the provided aFilePath. + * + */ + @JSFunction + public void rm(String path) throws IOException { + if (!client.deleteFile(path)) { + throw new IOException(client.getReplyString()); + } + } + + /** + * + * FTP.rmdir(aPath) + * Removes a remote directory at the provided aPath. + * + */ + @JSFunction + public void rmdir(String path) throws IOException { + if (!client.removeDirectory(path)) { + throw new IOException(client.getReplyString()); + } + } + + /** + * + * FTP.pwd() : String + * Returns the current remote path. + * + */ + @JSFunction + public String pwd() throws IOException { + return client.printWorkingDirectory(); + } + + /** + * + * FTP.cd(aPath) + * Changes the remote directory to the corresponding path. + * + */ + @JSFunction + public void cd(String path) throws IOException { + if (!client.changeWorkingDirectory(path)) { + throw new IOException(client.getReplyString()); + } + } + + /** + * + * FTP.mkdir(aPath) + * Tries to create a remote directory for the provided aPath. + * + */ + @JSFunction + public void mkdir(String path) throws IOException { + if (!client.makeDirectory(path)) { + throw new IOException(client.getReplyString()); + } + } + + /** + * + * FTP.listFiles(aPath) : Map + * Returns a files array where each entry has filename, longname, filepath, size, permissions, lastModified, + * createTime, isDirectory and isFile. + * + */ + @JSFunction + public Object listFiles(Object opath) throws IOException { + String path; + Scriptable no = (Scriptable) AFCmdBase.jse.newObject(AFCmdBase.jse.getGlobalscope()); + ArrayList list = new ArrayList(); + FTPFile[] files = null; + + if (opath == null || !(opath instanceof String)) { + path = "."; + } else { + path = ((String) opath).replaceAll("/+", "/"); + } + + try { + files = client.mlistDir(path); + } catch(Exception e) { + // Ignore and fall back to LIST. + } + + if (files == null || files.length == 0) { + files = client.listFiles(path); + } + + if ((files == null || files.length == 0) && path != null && path.length() > 0 && !path.equals(".")) { + String previous = client.printWorkingDirectory(); + try { + if (client.changeWorkingDirectory(path)) { + files = client.listFiles("."); + } + } finally { + if (previous != null && previous.length() > 0) { + client.changeWorkingDirectory(previous); + } + } + } + + if (files == null) files = new FTPFile[0]; + + for(FTPFile f : files) { + if (f != null && f.getName() != null && !f.getName().equals(".") && !f.getName().equals("..")) { + Scriptable record = (Scriptable) AFCmdBase.jse.newObject(no); + Calendar ts = f.getTimestamp(); + long time = ts != null ? ts.getTimeInMillis() : 0; + + record.put("filename", record, f.getName()); + record.put("longname", record, f.getRawListing()); + record.put("filepath", record, buildFilePath(path, f.getName())); + record.put("size", record, f.getSize()); + record.put("permissions", record, getPermissions(f)); + record.put("lastModified", record, time); + record.put("createTime", record, time); + record.put("isDirectory", record, f.isDirectory()); + record.put("isFile", record, f.isFile()); + list.add(record); + } + } + + no.put("files", no, AFCmdBase.jse.newArray(no, list.toArray())); + return no; + } + + /** + * + * FTP.get(aRemoteFilePath, aLocalFilePath) : String + * Retrieves a file, using the FTP/FTPS connection, from aRemoteFilePath to aLocalFilePath. + * Use FTP.getBytes in case you are reading a binary file into memory. + * + */ + @JSFunction + public String get(String remoteFile, String localFile) throws IOException { + if (localFile == null) localFile = remoteFile; + try (OutputStream os = new FileOutputStream(localFile)) { + if (!client.retrieveFile(remoteFile, os)) throw new IOException(client.getReplyString()); + } + return localFile; + } + + /** + * + * FTP.getBytes(aRemoteFile) : anArrayOfBytes + * Returns an array of bytes with the contents of aRemoteFilePath, using the FTP/FTPS connection. + * + */ + @JSFunction + public Object getBytes(String remoteFile) throws IOException { + try (InputStream is = client.retrieveFileStream(remoteFile)) { + if (is == null) throw new IOException(client.getReplyString()); + byte[] res = IOUtils.toByteArray(is); + if (!client.completePendingCommand()) throw new IOException(client.getReplyString()); + return res; + } + } + + /** + * + * FTP.putBytes(aRemoteFilePath, bytes) + * Writes an array of bytes on aRemoteFilePath, using the FTP/FTPS connection. + * + */ + @JSFunction + public void putBytes(String remoteFile, Object bytes) throws IOException { + if (!(bytes instanceof byte[])) throw new IOException("Expecting an array of bytes"); + try (InputStream is = new ByteArrayInputStream((byte[]) bytes)) { + if (!client.storeFile(remoteFile, is)) throw new IOException(client.getReplyString()); + } + } + + /** + * + * FTP.put(aSourceFilePath, aRemoteFilePath) + * Copies aSourceFilePath to aRemoteFilePath, using the FTP/FTPS connection. + * + */ + @JSFunction + public void put(String sourceFile, String remoteFile) throws IOException { + if (remoteFile == null) remoteFile = sourceFile; + try (InputStream is = new FileInputStream(sourceFile)) { + if (!client.storeFile(remoteFile, is)) throw new IOException(client.getReplyString()); + } + } + + /** + * + * FTP.ftpGet(aRemoteFile, aLocalFile) : JavaStream + * Retrieves a remote file over the FTP/FTPS connection to be stored on the local path provided. If aLocalFile is + * not provided the remote file contents will be returned as a Java Stream. + * + */ + @JSFunction + public Object ftpGet(String remoteFile, String localFile) throws IOException { + if (localFile != null && !localFile.equals("") && !localFile.endsWith("undefined")) { + get(remoteFile, localFile); + return remoteFile; + } else { + try (InputStream is = client.retrieveFileStream(remoteFile)) { + if (is == null) throw new IOException(client.getReplyString()); + InputStream res = IOUtils.toBufferedInputStream(is); + if (!client.completePendingCommand()) throw new IOException(client.getReplyString()); + return res; + } + } + } + + /** + * + * FTP.ftpPut(aSource, aRemoteFile) + * Sends aSource file (if string) or a Java stream to a remote file path over a FTP/FTPS connection. + * + * @throws Exception + */ + @JSFunction + public void ftpPut(Object aSource, String aRemoteFile) throws Exception { + if (aSource instanceof String) { + put((String) aSource, aRemoteFile); + } else { + if (aSource instanceof NativeJavaObject) aSource = ((NativeJavaObject) aSource).unwrap(); + if (aSource instanceof InputStream) { + try (InputStream is = (InputStream) aSource) { + if (!client.storeFile(aRemoteFile, is)) throw new IOException(client.getReplyString()); + } + } else { + throw new Exception("Expecting a string source file name or a Java Input stream as source"); + } + } + } + + protected String buildFilePath(String path, String fileName) { + if (path == null || path.length() == 0 || path.equals(".")) return fileName; + if (path.endsWith("/")) return path + fileName; + return path + "/" + fileName; + } + + protected String getPermissions(FTPFile file) { + StringBuilder sb = new StringBuilder(); + sb.append(file.isDirectory() ? "d" : "-"); + appendPermissions(sb, file, FTPFile.USER_ACCESS); + appendPermissions(sb, file, FTPFile.GROUP_ACCESS); + appendPermissions(sb, file, FTPFile.WORLD_ACCESS); + return sb.toString(); + } + + protected void appendPermissions(StringBuilder sb, FTPFile file, int access) { + sb.append(file.hasPermission(access, FTPFile.READ_PERMISSION) ? "r" : "-"); + sb.append(file.hasPermission(access, FTPFile.WRITE_PERMISSION) ? "w" : "-"); + sb.append(file.hasPermission(access, FTPFile.EXECUTE_PERMISSION) ? "x" : "-"); + } +} diff --git a/tests/autoTestAll.A2A.js b/tests/autoTestAll.A2A.js index 6da40eab0..750b3f375 100644 --- a/tests/autoTestAll.A2A.js +++ b/tests/autoTestAll.A2A.js @@ -138,6 +138,139 @@ client.destroy(); }; + exports.testClientRemoteSSE = function() { + ow.loadServer(); + + var port = findRandomOpenPort(); + var hs = ow.server.httpd.start(port); + + ow.server.httpd.route(hs, { + "/mcp-sse": function(req) { + var body = (isDef(req.files) && isDef(req.files.postData)) ? req.files.postData : req.data; + var rpc = jsonParse(body); + var isNotification = isUnDef(rpc.id) || isNull(rpc.id); + var result; + + switch(rpc.method) { + case "initialize": + result = { + protocolVersion: "2024-11-05", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "SSE MCP", version: "1.0.0" } + }; + break; + case "notifications/initialized": + return ow.server.httpd.reply("", 204, "text/plain", {}); + case "tools/list": + result = { + tools: [ + { + name: "ping", + description: "Ping tool", + inputSchema: { type: "object", properties: {} } + } + ] + }; + break; + case "tools/call": + result = { + content: [{ type: "text", text: "pong" }], + isError: false + }; + break; + default: + result = { unsupported: rpc.method }; + } + + if (isNotification) return ow.server.httpd.reply("", 204, "text/plain", {}); + + return ow.server.httpd.reply( + "event: message\n" + + "data: " + stringify({ jsonrpc: "2.0", result: result, id: rpc.id }, __, "") + "\n\n", + 200, + "text/event-stream", + { "Cache-Control": "no-cache" } + ); + } + }); + + try { + var client = $mcp({ + type: "remote", + url: "http://127.0.0.1:" + port + "/mcp-sse", + sse: true + }); + + client.initialize(); + + var tools = client.listTools(); + ow.test.assert(isArray(tools.tools), true, "SSE MCP should list tools"); + ow.test.assert(tools.tools[0].name, "ping", "SSE MCP should expose the ping tool"); + + var res = client.callTool("ping", {}); + ow.test.assert(res.content[0].text, "pong", "SSE MCP tool call should return pong"); + + client.destroy(); + } finally { + ow.server.httpd.stop(hs); + } + }; + + exports.testClientToolBlacklist = function() { + var client = $mcp({ + type: "dummy", + blacklist: ["secret_tool"], + options: { + fns: { + visible_tool: function(params) { + return { + content: [{ type: "text", text: "visible" }], + isError: false + }; + }, + secret_tool: function(params) { + return { + content: [{ type: "text", text: "secret" }], + isError: false + }; + } + }, + fnsMeta: { + visible_tool: { + name: "visible_tool", + description: "Visible tool", + inputSchema: { type: "object", properties: {} } + }, + secret_tool: { + name: "secret_tool", + description: "Secret tool", + inputSchema: { type: "object", properties: {} } + } + } + } + }); + + client.initialize(); + + var tools = client.listTools(); + ow.test.assert(isArray(tools.tools), true, "Dummy MCP should list tools"); + ow.test.assert(tools.tools.length, 1, "Blacklisted tool should be excluded from listTools"); + ow.test.assert(tools.tools[0].name, "visible_tool", "Only non-blacklisted tool should be listed"); + + var visibleRes = client.callTool("visible_tool", {}); + ow.test.assert(visibleRes.content[0].text, "visible", "Non-blacklisted tool should execute"); + + var blocked = false; + try { + client.callTool("secret_tool", {}); + } catch(e) { + blocked = String(e.message).indexOf("blacklisted") >= 0; + } + ow.test.assert(blocked, true, "Blacklisted tool should be rejected by callTool"); + + client.destroy(); + }; + exports.testSendMessage = function() { ow.loadServer(); diff --git a/tests/autoTestAll.A2A.yaml b/tests/autoTestAll.A2A.yaml index 1b4b3f34a..a5edb1b83 100644 --- a/tests/autoTestAll.A2A.yaml +++ b/tests/autoTestAll.A2A.yaml @@ -30,6 +30,20 @@ jobs: exec: | args.func = args.tests.testClientDummy; + # --------------------------------------------------- + - name: A2A::Client Remote SSE + from: A2A::Init + to : oJob Test + exec: | + args.func = args.tests.testClientRemoteSSE; + + # --------------------------------------------------- + - name: A2A::Client Tool Blacklist + from: A2A::Init + to : oJob Test + exec: | + args.func = args.tests.testClientToolBlacklist; + # --------------------------------------------------- - name: A2A::Send Message from: A2A::Init diff --git a/tests/autoTestAll.MCP.js b/tests/autoTestAll.MCP.js new file mode 100644 index 000000000..574b035a8 --- /dev/null +++ b/tests/autoTestAll.MCP.js @@ -0,0 +1,202 @@ +// Copyright 2023 Nuno Aguiar + +(function() { + var withOAuthMCPServer = function(testFn) { + ow.loadServer(); + + var port = findRandomOpenPort(); + var hs = ow.server.httpd.start(port, "127.0.0.1"); + var state = { + tokenRequests: [], + authorizeRequests: [], + mcpAuthHeaders: [] + }; + var issuer = "http://127.0.0.1:" + port + "/as"; + var resource = "http://127.0.0.1:" + port + "/mcp"; + + ow.server.httpd.route(hs, { + "/.well-known/oauth-protected-resource/mcp": function(req) { + return ow.server.httpd.reply({ + resource: resource, + authorization_servers: [ issuer ] + }, 200, "application/json", {}); + }, + "/.well-known/oauth-authorization-server/as": function(req) { + return ow.server.httpd.reply({ + issuer: issuer, + authorization_endpoint: issuer + "/authorize", + token_endpoint: issuer + "/token" + }, 200, "application/json", {}); + }, + "/as/authorize": function(req) { + state.authorizeRequests.push(req.params); + return ow.server.httpd.reply("ok", 200, "text/plain", {}); + }, + "/as/token": function(req) { + var body = (isDef(req.files) && isDef(req.files.postData)) ? req.files.postData : req.data; + var params = isMap(req.params) ? clone(req.params) : {}; + if (isString(body) && body.length > 0) params = merge(params, ow.server.rest.parseQuery(body)); + state.tokenRequests.push(params); + return ow.server.httpd.reply({ + access_token: "token-" + state.tokenRequests.length, + token_type: "Bearer", + expires_in: 3600 + }, 200, "application/json", {}); + }, + "/mcp": function(req) { + state.mcpAuthHeaders.push(req.header.authorization); + + var body = (isDef(req.files) && isDef(req.files.postData)) ? req.files.postData : req.data; + var rpc = jsonParse(body); + var isNotification = isUnDef(rpc.id) || isNull(rpc.id); + var result; + + switch(rpc.method) { + case "initialize": + result = { + protocolVersion: "2024-11-05", + capabilities: {}, + serverInfo: { name: "OAuth MCP", version: "1.0.0" } + }; + break; + case "notifications/initialized": + return ow.server.httpd.reply("", 204, "text/plain", {}); + case "tools/list": + result = { + tools: [ + { + name: "ping", + description: "Ping tool", + inputSchema: { type: "object", properties: {} } + } + ] + }; + break; + case "tools/call": + result = { + content: [{ type: "text", text: "pong" }], + isError: false + }; + break; + default: + result = {}; + } + + if (isNotification) return ow.server.httpd.reply("", 204, "text/plain", {}); + + return ow.server.httpd.reply({ + jsonrpc: "2.0", + result: result, + id: rpc.id + }, 200, "application/json", {}); + } + }); + + try { + testFn({ + port: port, + hs: hs, + issuer: issuer, + resource: resource, + state: state + }); + } finally { + ow.server.httpd.stop(hs); + } + }; + + exports.testOAuthDiscoveryClientCredentials = function() { + withOAuthMCPServer(function(ctx) { + var client = $mcp({ + type: "remote", + url: ctx.resource, + auth: { + type: "oauth2", + grantType: "client_credentials", + clientId: "client-a", + clientSecret: "secret-a" + } + }); + + try { + client.initialize(); + var tools = client.listTools(); + ow.test.assert(tools.tools[0].name, "ping", "Discovered MCP OAuth client should list tools."); + ow.test.assert(ctx.state.tokenRequests.length > 0, true, "OAuth token endpoint should be called."); + ow.test.assert(ctx.state.tokenRequests[0].resource, ctx.resource, "OAuth token request should include the MCP resource."); + ow.test.assert(ctx.state.tokenRequests[0].grant_type, "client_credentials", "OAuth token request should preserve client_credentials grant."); + ow.test.assert(ctx.state.mcpAuthHeaders[0], "Bearer token-1", "MCP request should include the discovered bearer token."); + } finally { + client.destroy(); + } + }); + }; + + exports.testOAuthAuthorizationCodeBuildsAuthorizationURL = function() { + withOAuthMCPServer(function(ctx) { + var capturedURL; + var client = $mcp({ + type: "remote", + url: ctx.resource, + auth: { + type: "oauth2", + grantType: "authorization_code", + clientId: "public-client", + redirectURI: "http://127.0.0.1/callback", + promptForCode: false, + disableOpenBrowser: true, + onAuthorizationURL: function(url) { capturedURL = url; } + } + }); + + try { + var failed = false; + try { + client.initialize(); + } catch(e) { + failed = String(e).indexOf("authorization code required") >= 0; + } + + ow.test.assert(failed, true, "Authorization code flow without a code should stop after building the authorization URL."); + ow.test.assert(isDef(capturedURL), true, "Authorization URL callback should receive the generated URL."); + ow.test.assert(capturedURL.indexOf(ctx.issuer + "/authorize") == 0, true, "Authorization URL should come from discovered metadata."); + ow.test.assert(capturedURL.indexOf("resource=" + encodeURIComponent(ctx.resource)) >= 0, true, "Authorization URL should include the MCP resource parameter."); + ow.test.assert(capturedURL.indexOf("code_challenge=") >= 0, true, "Authorization URL should include a PKCE code challenge."); + ow.test.assert(capturedURL.indexOf("code_challenge_method=S256") >= 0, true, "Authorization URL should use S256 PKCE."); + } finally { + client.destroy(); + } + }); + }; + + exports.testOAuthAuthorizationCodeTokenExchange = function() { + withOAuthMCPServer(function(ctx) { + var client = $mcp({ + type: "remote", + url: ctx.resource, + auth: { + type: "oauth2", + grantType: "authorization_code", + clientId: "public-client", + redirectURI: "http://127.0.0.1/callback", + authorizationCode: "auth-code-123", + disableOpenBrowser: true + } + }); + + try { + client.initialize(); + var res = client.callTool("ping", {}); + ow.test.assert(res.content[0].text, "pong", "Authorization code flow should authenticate the MCP call."); + ow.test.assert(ctx.state.tokenRequests.length > 0, true, "Authorization code flow should exchange the code for a token."); + ow.test.assert(ctx.state.tokenRequests[0].grant_type, "authorization_code", "Authorization code grant should be used."); + ow.test.assert(ctx.state.tokenRequests[0].resource, ctx.resource, "Authorization code token exchange should include the MCP resource."); + ow.test.assert(isDef(ctx.state.tokenRequests[0].code_verifier), true, "Authorization code token exchange should include a PKCE verifier."); + ow.test.assert(ctx.state.tokenRequests[0].code, "auth-code-123", "Authorization code token exchange should use the provided authorization code."); + ow.test.assert(ctx.state.mcpAuthHeaders[0], "Bearer token-1", "Authorization code flow should send the bearer token to the MCP server."); + } finally { + client.destroy(); + } + }); + }; +})(); diff --git a/tests/autoTestAll.MCP.yaml b/tests/autoTestAll.MCP.yaml new file mode 100644 index 000000000..734cc9af4 --- /dev/null +++ b/tests/autoTestAll.MCP.yaml @@ -0,0 +1,29 @@ +# Copyright 2023 Nuno Aguiar + +include: + - oJobTest.yaml + +jobs: + - name: MCP::Init + exec: | + args.tests = require("autoTestAll.MCP.js"); + + - name: MCP::OAuth discovery with client credentials + from: MCP::Init + to : oJob Test + exec: args.func = args.tests.testOAuthDiscoveryClientCredentials; + + - name: MCP::OAuth authorization URL includes resource and PKCE + from: MCP::Init + to : oJob Test + exec: args.func = args.tests.testOAuthAuthorizationCodeBuildsAuthorizationURL; + + - name: MCP::OAuth authorization code token exchange includes resource and verifier + from: MCP::Init + to : oJob Test + exec: args.func = args.tests.testOAuthAuthorizationCodeTokenExchange; + +todo: + - MCP::OAuth discovery with client credentials + - MCP::OAuth authorization URL includes resource and PKCE + - MCP::OAuth authorization code token exchange includes resource and verifier diff --git a/tests/autoTestAll.Server.js b/tests/autoTestAll.Server.js index 7fa82122a..2efdd698b 100644 --- a/tests/autoTestAll.Server.js +++ b/tests/autoTestAll.Server.js @@ -315,6 +315,81 @@ _testHTTPServer("nwu2") } + function _testHTTPServerPrefix(aImpl) { + ow.loadServer() + ow.loadObj() + + var port = findRandomOpenPort() + var prefix = "/myprefix" + __flags.HTTPD_PREFIX[port] = prefix + + var hs = ow.server.httpd.start(port, "127.0.0.1", __, __, __, __, __, aImpl) + + try { + ow.server.httpd.route(hs, + ow.server.httpd.mapRoutesWithLibs(hs, { + "/normal": req => hs.replyOKText(req.uri), + "/go": req => ow.server.httpd.replyRedirect(hs, "/target"), + "/target": req => hs.replyOKText("redirect target") + }), + req => hs.reply("", "text/plain", 401, {}) + ) + + ow.test.assert( + (new ow.obj.http()).get("http://127.0.0.1:" + port + prefix + "/normal"), + "/normal", + `(${aImpl}) Problem stripping HTTPD prefix before route handlers.` + ) + + var h = new ow.obj.http() + var failed = false + try { + h.get("http://127.0.0.1:" + port + "/normal") + } catch(e) { + if (h.responseCode() == 401) failed = true + } + ow.test.assert(failed, true, `(${aImpl}) Unexpected unprefixed access when HTTPD prefix is configured.`) + + ow.test.assert( + (new ow.obj.http()).get("http://127.0.0.1:" + port + prefix + "/go"), + "redirect target", + `(${aImpl}) Problem rewriting redirects with HTTPD prefix.` + ) + + var css = (new ow.obj.http()).get("http://127.0.0.1:" + port + prefix + "/css/materialize-icon.css") + ow.test.assert(css.indexOf(prefix + "/fonts/material-design-icons/Material-Design-Icons.woff2") >= 0, true, `(${aImpl}) Problem rewriting CSS font URLs with HTTPD prefix.`) + + var fontRes = (new ow.obj.http()).getBytes("http://127.0.0.1:" + port + prefix + "/fonts/openaf_small.png") + ow.test.assert(fontRes.responseBytes.length > 0, true, `(${aImpl}) Problem serving prefixed font routes.`) + } finally { + ow.server.httpd.stop(hs) + delete __flags.HTTPD_PREFIX[port] + } + } + exports.testHTTPServerPrefix = function() { + _testHTTPServerPrefix("nwu") + } + exports.testHTTPServerPrefixJava = function() { + _testHTTPServerPrefix("java") + } + exports.testHTTPServerPrefixNWU2 = function() { + _testHTTPServerPrefix("nwu2") + } + + exports.testHTTPServerPrefixHelpers = function() { + ow.loadServer() + + ow.test.assert(ow.server.httpd.withPrefix({}, { x: 1 }), "/", "Problem sanitizing non-string URI values for prefixing.") + ow.test.assert(ow.server.httpd.normalizePrefix({ x: 1 }), "", "Problem sanitizing non-string HTTPD prefix values.") + + delete __flags.HTTPD_PREFIX["12345"] + __flags.HTTPD_PREFIX["0"] = "/default-prefix" + ow.test.assert(ow.server.httpd.getPrefix(12345), "/default-prefix", "Problem using HTTPD_PREFIX[0] as default fallback.") + ow.test.assert(ow.server.httpd.getPrefix({}), "/default-prefix", "Problem using HTTPD_PREFIX[0] as fallback for invalid prefix helper arguments.") + ow.test.assert(ow.server.httpd.getHTMLPrefix({}), "/default-prefix", "Problem using HTTPD_PREFIX[0] as HTML prefix fallback for invalid arguments.") + delete __flags.HTTPD_PREFIX["0"] + } + exports.testQueue = function() { ow.loadServer(); var q = new ow.server.queue({ t: "q" }, "test"); @@ -454,4 +529,4 @@ io.rm(envPidFile); } }; -})(); \ No newline at end of file +})(); diff --git a/tests/autoTestAll.Server.yaml b/tests/autoTestAll.Server.yaml index 4b2b19ba3..deda48f95 100644 --- a/tests/autoTestAll.Server.yaml +++ b/tests/autoTestAll.Server.yaml @@ -60,6 +60,26 @@ jobs: to : oJob Test exec: args.func = args.tests.testHTTPServerJava; + - name: Server::HTTP server prefix + from: Server::Init + to : oJob Test + exec: args.func = args.tests.testHTTPServerPrefix; + + - name: Server::HTTP server prefix NWU2 + from: Server::Init + to : oJob Test + exec: args.func = args.tests.testHTTPServerPrefixNWU2; + + - name: Server::HTTP server prefix Java + from: Server::Init + to : oJob Test + exec: args.func = args.tests.testHTTPServerPrefixJava; + + - name: Server::HTTP server prefix helpers + from: Server::Init + to : oJob Test + exec: args.func = args.tests.testHTTPServerPrefixHelpers; + - name: Server::Scheduler from: Server::Init to : oJob Test @@ -99,8 +119,12 @@ todo: - Server::HTTP server - Server::HTTP server NWU2 - Server::HTTP server Java + - Server::HTTP server prefix + - Server::HTTP server prefix NWU2 + - Server::HTTP server prefix Java + - Server::HTTP server prefix helpers - Server::Locks - Server::Auth - Server::AuthApp - Server::Queue - - Server::CheckIn \ No newline at end of file + - Server::CheckIn diff --git a/tests/autoTestAll.Template.js b/tests/autoTestAll.Template.js index 3ec42905b..013798381 100644 --- a/tests/autoTestAll.Template.js +++ b/tests/autoTestAll.Template.js @@ -5,20 +5,34 @@ ow.loadTemplate(); }; - exports.testMD2HTML = function() { - var md = "# test 1"; + exports.testMD2HTML = function() { + var md = "# test 1"; ow.loadTemplate(); var out = ow.template.parseMD2HTML(md); ow.test.assert(out, "

test 1

", "Problem with ow.template.parseMD2HTML"); // TODO: Need to improve this test - out = ow.template.parseMD2HTML(md, true); - ow.test.assert(out.match(/highlight\.js/).length, 1, "Problem with ow.template.parseMD2HTML full html"); - }; - - exports.testSimpleTemplate = function() { - ow.loadTemplate(); + out = ow.template.parseMD2HTML(md, true); + ow.test.assert(out.match(/highlight\.js/).length, 1, "Problem with ow.template.parseMD2HTML full html"); + }; + + exports.testMD2HTMLWithPrefix = function() { + var md = "# test 1" + + ow.loadTemplate() + + var out = ow.template.parseMD2HTML(md, true, __, __, __, "/myprefix") + ow.test.assert(out.indexOf('src="/myprefix/js/highlight.js"') >= 0, true, "Problem with ow.template.parseMD2HTML full html prefix support") + ow.test.assert(out.indexOf('href="/myprefix/css/github-markdown.css"') >= 0, true, "Problem with ow.template.parseMD2HTML stylesheet prefix support") + + out = ow.template.html.parseMapInHTML({ a: 1 }, __, "/myprefix") + ow.test.assert(out.indexOf('src="/myprefix/js/openafsigil.js"') >= 0, true, "Problem with ow.template.html.parseMapInHTML prefix support") + ow.test.assert(out.indexOf('href="/myprefix/css/nJSMap.css"') >= 0, true, "Problem with ow.template.html.parseMapInHTML stylesheet prefix support") + }; + + exports.testSimpleTemplate = function() { + ow.loadTemplate(); ow.test.assert(templify("Hello {{name}}", { name: "OpenAF"}), "Hello OpenAF", "Problem with simple templify test 1."); ow.test.assert(templify("{{#each a}}{{this}}{{/each}}", {a:[1,2,3]}), "123", "Problem with simple templify test 2."); }; @@ -164,4 +178,4 @@ ow.test.assert(e.message, "The partial test could not be found", "Problem with deleting a template partial (different message?)."); } }; -})(); \ No newline at end of file +})(); diff --git a/tests/autoTestAll.Template.yaml b/tests/autoTestAll.Template.yaml index 7aefa6f2b..758f52318 100644 --- a/tests/autoTestAll.Template.yaml +++ b/tests/autoTestAll.Template.yaml @@ -14,10 +14,15 @@ jobs: to : oJob Test exec: args.func = args.tests.testLoadTemplate; - - name: Template::Test Markdown to HTML - from: Template::Init - to : oJob Test - exec: args.func = args.tests.testMD2HTML; + - name: Template::Test Markdown to HTML + from: Template::Init + to : oJob Test + exec: args.func = args.tests.testMD2HTML; + + - name: Template::Test Markdown to HTML prefix + from: Template::Init + to : oJob Test + exec: args.func = args.tests.testMD2HTMLWithPrefix; - name: Template::Test simple template from: Template::Init @@ -48,10 +53,11 @@ todo: # Template tests # -------------- - - Template::Load Template - - Template::Test Markdown to HTML - - Template::Test simple template + - Template::Load Template + - Template::Test Markdown to HTML + - Template::Test Markdown to HTML prefix + - Template::Test simple template - Template::Test format helpers - Template::Test conditional helpers - Template::Test partial helpers - - Template::Test openaf helpers \ No newline at end of file + - Template::Test openaf helpers diff --git a/tests/autoTestAll.allJobs.yaml b/tests/autoTestAll.allJobs.yaml index 5148b62f1..05dbbb1e3 100644 --- a/tests/autoTestAll.allJobs.yaml +++ b/tests/autoTestAll.allJobs.yaml @@ -18,6 +18,7 @@ include: - autoTestAll.Obj.yaml - autoTestAll.JMX.yaml - autoTestAll.ZIP.yaml -- autoTestAll.Sec.yaml -- autoTestAll.oJob.yaml -# - autoTestAll.SNMP.yaml // Test servers don't work \ No newline at end of file +- autoTestAll.Sec.yaml +- autoTestAll.oJob.yaml +- autoTestAll.MCP.yaml +# - autoTestAll.SNMP.yaml // Test servers don't work