Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src-node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand All @@ -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);
});
147 changes: 147 additions & 0 deletions src-node/media-server.js
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions src-node/test-connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
162 changes: 162 additions & 0 deletions src-node/test/test-media-server.js
Original file line number Diff line number Diff line change
@@ -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<void>}
*/
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<Object>} 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);
Loading
Loading