diff --git a/src-node/index.js b/src-node/index.js index 4ad5376e00..5cde37499f 100644 --- a/src-node/index.js +++ b/src-node/index.js @@ -66,6 +66,7 @@ const path = require('path'); const PhoenixFS = require('@phcode/fs/dist/phoenix-fs'); const NodeConnector = require("./node-connector"); const LivePreview = require("./live-preview"); +const MediaServer = require("./media-server"); require("./test-connection"); require("./utils"); require("./terminal"); @@ -99,6 +100,7 @@ const PHOENIX_STATIC_SERVER_URL = `/Static${randomNonce(8)}`; const PHOENIX_NODE_URL = `/PhoenixNode${randomNonce(8)}`; const PHOENIX_LIVE_PREVIEW_COMM_URL = `/PreviewComm${randomNonce(8)}`; const PHOENIX_AUTO_AUTH_URL = `/AutoAuth${randomNonce(8)}`; +const PHOENIX_MEDIA_URL = `/Media${randomNonce(8)}`; const savedConsoleLog = console.log; @@ -197,7 +199,8 @@ function processCommand(line) { phoenixNodeURL: `ws://localhost:${port}${PHOENIX_NODE_URL}`, staticServerURL: `http://localhost:${port}${PHOENIX_STATIC_SERVER_URL}`, livePreviewCommURL: `ws://localhost:${port}${PHOENIX_LIVE_PREVIEW_COMM_URL}`, - autoAuthURL: `http://localhost:${port}${PHOENIX_AUTO_AUTH_URL}` + autoAuthURL: `http://localhost:${port}${PHOENIX_AUTO_AUTH_URL}`, + mediaURL: `http://localhost:${port}${PHOENIX_MEDIA_URL}` }, jsonCmd.commandID); }); return; @@ -319,6 +322,11 @@ const server = http.createServer((req, res) => { } else if (req.url.startsWith(PHOENIX_AUTO_AUTH_URL)) { return autoAuth(req, res); + } else if (req.url.startsWith(PHOENIX_MEDIA_URL)) { + // Video and audio the editor has opened, streamed from disk with byte + // range support so the media element can seek. See media-server.js. + const mediaURL = new URL(req.url, `http://${req.headers.host}`); + return MediaServer.serveMedia(req, res, mediaURL); }else { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); @@ -340,5 +348,6 @@ server.listen(0, localhostOnly, () => { savedConsoleLog(`Phoenix node connector url is ws://localhost:${port}${PHOENIX_NODE_URL}`); savedConsoleLog(`Phoenix live preview comm url is ws://localhost:${port}${PHOENIX_LIVE_PREVIEW_COMM_URL}`); savedConsoleLog(`Phoenix AutoAuth url is ws://localhost:${port}${PHOENIX_AUTO_AUTH_URL}`); + savedConsoleLog(`Phoenix media url is http://localhost:${port}${PHOENIX_MEDIA_URL}`); serverPortResolve(port); }); diff --git a/src-node/media-server.js b/src-node/media-server.js new file mode 100644 index 0000000000..b5230cd2bb --- /dev/null +++ b/src-node/media-server.js @@ -0,0 +1,147 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * media-server Module + * + * Serves a local file over the node http server, so video and audio can be + * played without the renderer ever holding the bytes. + * + * The viewer used to read the whole file through the filesystem API and hand a + * base64 data URI to the media element. That caps at FileUtils.MAX_FILE_SIZE + * (16MB), costs roughly three times the file in memory - the byte array, the + * intermediate strings and the base64, which is itself a third larger - and + * cannot start playing or seek until all of it has been read and encoded. + * Reading from disk here instead removes the cap, holds one stream buffer + * rather than the file, and answers byte ranges, which is what lets the media + * element seek at all. + * + * Any readable path is served, not just paths under the open project, because + * the editor can open media from anywhere. Nothing is registered first: the + * request carries the path and this reads it, which keeps the viewer's job to + * building a url. + * + * The route is guarded the same way as every other route on this server, by a + * large random prefix chosen at startup - see the security note in index.js. + * That guard is the whole of it, and it is enough: reaching this route means + * running code in the renderer, and renderer code can already read any file it + * likes through the filesystem API. Serving a file here grants nothing that + * was not already available. + */ + +const fs = require('fs'); +const path = require('path'); + +const MIME_TYPES = { + ".mp4": "video/mp4", + ".m4v": "video/mp4", + ".webm": "video/webm", + ".ogv": "video/ogg", + ".mov": "video/quicktime", + ".mkv": "video/x-matroska", + ".avi": "video/x-msvideo", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".ogg": "audio/ogg", + ".oga": "audio/ogg", + ".m4a": "audio/mp4", + ".flac": "audio/flac", + ".aac": "audio/aac", + ".aif": "audio/aiff", + ".aiff": "audio/aiff", + ".opus": "audio/opus" +}; + +/** + * Serve the file named by the request, honouring byte ranges. + * + * Range is the point of this route. A media element asks for a couple of bytes + * to find the size, then for the piece it needs; refuse ranges and it cannot + * seek, and will usually pull the whole file to play any of it. + * + * @param {IncomingMessage} req + * @param {ServerResponse} res + * @param {URL} requestURL - the parsed request url, carrying ?platformPath= + */ +function serveMedia(req, res, requestURL) { + const wanted = requestURL.searchParams.get("platformPath"); + // A native path for whichever platform this is: "/home/me/a.mp4" on linux + // and mac, "c:\\users\\me\\a.mp4" on windows, url encoded by the caller so + // that spaces and backslashes survive the query string. It must be + // absolute - a relative one would be resolved against node's working + // directory, which is not anywhere the caller meant. + if (!wanted || !path.isAbsolute(wanted)) { + res.writeHead(400, {"Content-Type": "text/plain"}); + res.end("400: Bad Request"); + return; + } + // normalised so that "." and ".." in the path cannot name a different file + // than the one that gets checked below + const filePath = path.resolve(wanted); + + let stat; + try { + stat = fs.statSync(filePath); + } catch (e) { + res.writeHead(404, {"Content-Type": "text/plain"}); + res.end("404: Not Found"); + return; + } + if (!stat.isFile()) { + res.writeHead(404, {"Content-Type": "text/plain"}); + res.end("404: Not Found"); + return; + } + const size = stat.size; + + // No Access-Control-Allow-Origin here, unlike the static route: a media + // element does not need it, and there is no reason to let other origins + // read local files. + const headers = { + "Content-Type": MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream", + "Accept-Ranges": "bytes", + "Cache-Control": "no-store" + }; + + const match = /bytes=(\d*)-(\d*)/.exec(req.headers.range || ""); + if (!match) { + headers["Content-Length"] = size; + res.writeHead(200, headers); + fs.createReadStream(filePath).pipe(res); + return; + } + + // An open ended range ("bytes=500-") runs to the end of the file. + const start = match[1] ? parseInt(match[1], 10) : 0; + let end = match[2] ? parseInt(match[2], 10) : size - 1; + if (isNaN(start) || isNaN(end) || start > end || start >= size) { + res.writeHead(416, {"Content-Range": `bytes */${size}`}); + res.end(); + return; + } + end = Math.min(end, size - 1); + + headers["Content-Range"] = `bytes ${start}-${end}/${size}`; + headers["Content-Length"] = end - start + 1; + res.writeHead(206, headers); + fs.createReadStream(filePath, {start: start, end: end}).pipe(res); +} + +exports.serveMedia = serveMedia; diff --git a/src-node/test-connection.js b/src-node/test-connection.js index 1d391d11d3..84cdab648a 100644 --- a/src-node/test-connection.js +++ b/src-node/test-connection.js @@ -2,6 +2,7 @@ const NodeConnector = require("./node-connector"); require("./test/test-cli-locator"); require("./test/test-ai-image-tools"); require("./test/test-npm-node-shim"); +require("./test/test-media-server"); const TEST_NODE_CONNECTOR_ID = "ph_test_connector"; const nodeConnector = NodeConnector.createNodeConnector(TEST_NODE_CONNECTOR_ID, exports); diff --git a/src-node/test/test-media-server.js b/src-node/test/test-media-server.js new file mode 100644 index 0000000000..f37953d023 --- /dev/null +++ b/src-node/test/test-media-server.js @@ -0,0 +1,162 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * Node side helpers for the media server spec. + * + * The real handler is driven over a real http server and a real file, because + * what is worth testing here is the wire behaviour a media element depends on - + * the status, the range headers and the bytes - not the shape of the code. + */ + +const http = require('http'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const MediaServer = require("../media-server"); +const NodeConnector = require("../node-connector"); + +const ROUTE = "/MediaTestRoute"; + +let server, port, fixturePath, fixtureSize; + +/** + * A file with known, position dependent contents, so a served range can be + * checked to be the range that was asked for rather than merely the right + * length. + * @return {string} path of the file written + */ +function _writeFixture() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ph-media-test-")); + const file = path.join(dir, "fixture.mp4"); + const buf = Buffer.alloc(4096); + for (let i = 0; i < buf.length; i++) { + buf[i] = i % 256; + } + fs.writeFileSync(file, buf); + return file; +} + +/** + * Start a server that routes to the real media handler, and write the fixture. + * @return {Promise<{port: number, route: string, path: string, size: number}>} + */ +function startMediaTestServer() { + if (server) { + return Promise.resolve({port, route: ROUTE, path: fixturePath, size: fixtureSize}); + } + fixturePath = _writeFixture(); + fixtureSize = fs.statSync(fixturePath).size; + return new Promise(function (resolve) { + server = http.createServer(function (req, res) { + if (!req.url.startsWith(ROUTE)) { + res.writeHead(404); + res.end("Not Found"); + return; + } + MediaServer.serveMedia(req, res, new URL(req.url, `http://${req.headers.host}`)); + }); + server.listen(0, "localhost", function () { + port = server.address().port; + resolve({port, route: ROUTE, path: fixturePath, size: fixtureSize}); + }); + }); +} + +/** + * Stop the server and remove the fixture. + * @return {Promise} + */ +function stopMediaTestServer() { + return new Promise(function (resolve) { + const done = function () { + server = null; + if (fixturePath) { + try { + fs.rmSync(path.dirname(fixturePath), {recursive: true, force: true}); + } catch (e) { /* already gone */ } + fixturePath = null; + } + resolve(); + }; + if (!server) { done(); return; } + server.close(done); + }); +} + +/** + * Ask the media route for something and report what came back. + * @param {Object} params + * @param {string} [params.platformPath] - value for the query string, raw + * @param {string} [params.range] - a Range header to send + * @param {boolean} [params.omitParam] - leave the query string off entirely + * @return {Promise} status, headers of interest and a body digest + */ +function requestMedia({platformPath, range, omitParam}) { + const target = omitParam + ? `http://localhost:${port}${ROUTE}` + : `http://localhost:${port}${ROUTE}?platformPath=` + + encodeURIComponent(platformPath === undefined ? fixturePath : platformPath); + return new Promise(function (resolve) { + const req = http.get(target, {headers: range ? {Range: range} : {}}, function (res) { + const chunks = []; + res.on("data", function (c) { chunks.push(c); }); + res.on("end", function () { + const body = Buffer.concat(chunks); + resolve({ + status: res.statusCode, + contentType: res.headers["content-type"] || null, + acceptRanges: res.headers["accept-ranges"] || null, + contentRange: res.headers["content-range"] || null, + contentLength: res.headers["content-length"] || null, + cors: res.headers["access-control-allow-origin"] || null, + length: body.length, + // the fixture's bytes are their own offset mod 256, so this + // says whether the bytes are the ones that were asked for + firstByte: body.length ? body[0] : null, + lastByte: body.length ? body[body.length - 1] : null + }); + }); + }); + req.on("error", function (e) { resolve({error: e.message}); }); + }); +} + +/** + * What the handler makes of a path, without touching the disk. Answers the + * cross platform question: a native path is absolute on its own platform, and + * anything relative must be refused whichever platform this runs on. + * @param {Object} params + * @param {string} params.candidate + * @return {Promise<{absolutePosix: boolean, absoluteWin: boolean}>} + */ +async function classifyPath({candidate}) { + return { + absolutePosix: path.posix.isAbsolute(candidate), + absoluteWin: path.win32.isAbsolute(candidate) + }; +} + +exports.startMediaTestServer = startMediaTestServer; +exports.stopMediaTestServer = stopMediaTestServer; +exports.requestMedia = requestMedia; +exports.classifyPath = classifyPath; + +NodeConnector.createNodeConnector("ph_test_media_server", exports); diff --git a/src/editor/MediaViewer.js b/src/editor/MediaViewer.js index cc97ce74d2..82bdbc8ed1 100644 --- a/src/editor/MediaViewer.js +++ b/src/editor/MediaViewer.js @@ -59,8 +59,37 @@ define(function (require, exports, module) { return _MIME_TYPES[extension] || (isAudio ? "audio/mpeg" : "video/mp4"); } + /** + * Whether the file can be streamed rather than read into the page. Needs the + * node side up, so this is false in the browser and until node is ready. + * @return {boolean} + * @private + */ + function _canStreamMedia() { + return !!(Phoenix.isNativeApp && window.isNodeReady && + window.PhNodeEngine && window.PhNodeEngine.mediaURL); + } + + /** + * The url node will stream this file from. + * + * Nothing is registered first - the path rides in the query string and node + * reads it - so this is a plain string built here, with no round trip to + * wait on and no state on either side to keep in step. + * + * @param {File} file + * @return {string} + * @private + */ + function _mediaStreamURL(file) { + const platformPath = Phoenix.fs.getTauriPlatformPath(file.fullPath); + return window.PhNodeEngine.mediaURL + + "?platformPath=" + encodeURIComponent(platformPath); + } + // blob: URLs are rejected by the media loader on the custom app protocol in native builds // ("Media load rejected by URL safety check"), so we use a data URI like ImageViewer does. + // Only the browser takes this path now, see MediaView.prototype._loadMedia. function _mediaToDataURI(file, isAudio, cb) { file.read({encoding: window.fs.BYTE_ARRAY_ENCODING}, function (err, content) { if (err) { @@ -136,11 +165,24 @@ define(function (require, exports, module) { } /** - * Reads the media file and points the media element at its content + * Points the media element at the file, streaming it from node where we can. + * + * The desktop app serves the file over the node http server and hands the + * element a url, so the bytes never pass through here: no size limit, no + * copy of the file in memory, and the element can ask for the piece it + * needs, which is what lets it seek. In the browser there is no such server + * and the data URI below is all there is - which is why the 16MB cap and + * its error message still apply there. * @private */ MediaView.prototype._loadMedia = function () { const self = this; + if (_canStreamMedia()) { + this.$mediaError.hide(); + this.$mediaPreview.show(); + this.$mediaPreview[0].src = _mediaStreamURL(this.file); + return; + } _mediaToDataURI(this.file, this._isAudio, function (err, dataURI) { if (err) { self._showError(err === FileSystemError.EXCEEDS_MAX_FILE_SIZE @@ -154,6 +196,7 @@ define(function (require, exports, module) { }); }; + /** * Shows an error message instead of the media element * @param {string} message diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index f3af4ffa30..c8dbebcf16 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1117,6 +1117,8 @@ define({ "STATUSBAR_LINE_COUNT_SINGULAR": "\u2014 {0} Line", "STATUSBAR_LINE_COUNT_PLURAL": "\u2014 {0} Lines", "STATUSBAR_USER_EXTENSIONS_DISABLED": "Extensions Disabled", + "STATUSBAR_MCP_CONTROLLED": "Remote Controlled", + "STATUSBAR_MCP_CONTROLLED_TOOLTIP": "This editor is being remotely controlled. Another program is connected to it and can read and change your files.", "STATUSBAR_INSERT": "INS", "STATUSBAR_OVERWRITE": "OVR", "STATUSBAR_INSOVR_TOOLTIP": "Click to toggle cursor between Insert (INS) and Overwrite (OVR) modes", diff --git a/src/node-loader.js b/src/node-loader.js index c612729f20..094b5bf3df 100644 --- a/src/node-loader.js +++ b/src/node-loader.js @@ -747,6 +747,8 @@ function nodeLoader() { fs.forceUseNodeWSEndpoint(true); setNodeWSEndpoint(message.phoenixNodeURL); KernalModeTrust.localAutoAuthURL = message.autoAuthURL; + // base url the media viewer streams opened video and audio from + window.PhNodeEngine.mediaURL = message.mediaURL; window.isNodeReady = true; resolve(message); // node is designed such that it is not required at boot time to lower startup time. @@ -859,6 +861,8 @@ function nodeLoader() { fs.forceUseNodeWSEndpoint(true); setNodeWSEndpoint(message.phoenixNodeURL); KernalModeTrust.localAutoAuthURL = message.autoAuthURL; + // base url the media viewer streams opened video and audio from + window.PhNodeEngine.mediaURL = message.mediaURL; window.isNodeReady = true; resolve(message); window.PhNodeEngine._nodeLoadTime = Date.now() - nodeLoadstartTime; diff --git a/src/phoenix-builder/builder-connect-dialog.html b/src/phoenix-builder/builder-connect-dialog.html index cac1e045f1..5663fadf2f 100644 --- a/src/phoenix-builder/builder-connect-dialog.html +++ b/src/phoenix-builder/builder-connect-dialog.html @@ -4,6 +4,7 @@

Phoenix Builder MCP

+
+

+ MCP is off in production builds. To instrument one, a machine admin drops a dated + permission in a root owned folder — being able to write there is what proves an + admin allowed it. It is a date, not a switch, so a permission cannot be left on by + accident: it works on that day only. +

+ +

1. Grant it (click to copy, then run in a terminal)

+
{{mcpGrantCommand}}
+

+ Writes {{mcpOverrideFile}} with today's date, {{mcpToday}}. + Re-run it on any day you want to instrument again. +

+ +

2. Restart the production app twice

+

+ Nothing else to switch on — the file is the permission, and the app picks it up + by itself. Boot reads no files, to keep startup quick, so it consults a cached copy of + the date: the first start after step 1 caches it, the second acts on it. Removing the + file clears the cache the same way, one start later. +

+ +

Revoking

+
{{mcpRevokeCommand}}
+

+ It also lapses on its own at midnight. While anything is connected the production + window shows a red Remote Controlled badge in its status bar. +

+