Compare commits
2 Commits
f2e11f827f
...
eba447cd06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eba447cd06 | ||
|
|
9aec2d3150 |
57
QuickSetup.md
Normal file
57
QuickSetup.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Quick Setup
|
||||
|
||||
## user_config.json
|
||||
|
||||
`user_config.json` (in the project root) is where you put your local configuration overrides. It merges on top of `includes/config.json` — **do not edit `includes/config.json` directly**.
|
||||
|
||||
You only need to include keys you want to override. Copy `user_config.example.json` as a starting point, or start with a minimal file:
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"service_ip": "192.168.1.x"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The file supports `// line comments` and `/* block comments */`.
|
||||
|
||||
---
|
||||
|
||||
## configurator.js
|
||||
|
||||
`tools/configurator.js` is a command-line tool that sets or deletes individual keys in `user_config.json` without manually editing JSON.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
node tools/configurator.js <dot.path.key> <value> [--overwrite]
|
||||
node tools/configurator.js <dot.path.key> --delete [--overwrite]
|
||||
```
|
||||
|
||||
- Use `--overwrite` to replace a key that already exists.
|
||||
- Keys are expressed as dot-separated paths (e.g. `config.keys.user_data_key`).
|
||||
|
||||
---
|
||||
|
||||
## Setting service_ip
|
||||
|
||||
`service_ip` tells the box where to connect, this CANNOT be `0.0.0.0`, and must be an address reachable by your box when it connects via your setup. Can be `127.0.0.1` if you are running MAME/Viewer on the same machine as minisrv.
|
||||
|
||||
```
|
||||
node tools/configurator.js config.bind_ip 192.168.1.x --overwrite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setting user_data_key
|
||||
|
||||
`user_data_key` is used to encrypt user data. It should be a random secret string and **must be set before registering any users**. Changing it after users have registered will break existing accounts.
|
||||
|
||||
```
|
||||
node tools/configurator.js config.keys.user_data_key YOUR_RANDOM_SECRET --overwrite
|
||||
```
|
||||
|
||||
To generate a random key:
|
||||
```
|
||||
openssl rand -base64 32
|
||||
```
|
||||
@@ -2683,4 +2683,11 @@ if (bind_ports.length > 0) console.log(` * Started WTVP Server on port${bind_por
|
||||
if (pc_bind_ports.length > 0) console.log(` * Started HTTP Server on port${pc_bind_ports.length !== 1 ? "s" : ""} ` + pc_bind_ports.join(", ") + "...");
|
||||
if (protocolHandledPorts.size > 0) console.log(` * Started ${protocolHandledPorts.size} specialized protocol handler${protocolHandledPorts.size !== 1 ? "s" : ""} on port${protocolHandledPorts.size !== 1 ? "s" : ""} ` + [...protocolHandledPorts].map(([sn, sp, pt]) => `${pt} (${sp.toUpperCase()})`).join(", ") + "...");
|
||||
const listening_ip_string = (minisrv_config.config.bind_ip !== "0.0.0.0") ? "IP: " + minisrv_config.config.bind_ip : "all interfaces";
|
||||
console.log(" * Listening on", listening_ip_string, "~", "Service IP:", service_ip);
|
||||
console.log(" * Listening on", listening_ip_string, "~", "Service IP:", service_ip);
|
||||
|
||||
if (minisrv_config.config.keys.user_data_key === "PNa$WN7gz}!T=t6X7^=|Ii##CEB~p\\EP") {
|
||||
console.log(" * WARNING: You are using the default user data encryption key. This is not secure, and you should change it in the configuration file before registering any users.");
|
||||
console.log(" * To generate a random key in bash or PowerShell, you can run: node ./tools/configurator.js config.keys.user_data_key $(openssl rand -base64 32)");
|
||||
console.log(" * After changing the key in the user_config, you can run tools/update_user_data_key.js to update existing accounts with the new key.");
|
||||
console.log(" * Making a backup of your user accounts before doing this is highly recommended, in case something goes wrong during the update process.");
|
||||
}
|
||||
@@ -129,7 +129,7 @@ class WTVMSNTV2 {
|
||||
socket.rawDataListener = (chunk) => this.handleData(socket, chunk);
|
||||
socket.on('data', socket.rawDataListener);
|
||||
socket.on('error', (err) => {
|
||||
if (this.service_config.show_verbose_errors) console.error('[WTV-MSNTV2] socket error:', err.message);
|
||||
if (this.service_config.debug) console.error('[WTV-MSNTV2] socket error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -202,14 +202,14 @@ class WTVMSNTV2 {
|
||||
|
||||
const userAgent = request_headers['User-Agent'] || request_headers['user-agent'] || '';
|
||||
if (!this.isAllowedUserAgent(userAgent)) {
|
||||
if (this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3) {
|
||||
if (this.service_config.debug || this.minisrv_config.config.verbosity >= 3) {
|
||||
console.warn('[WTV-MSNTV2] unsupported User-Agent rejected:', userAgent || '<none>');
|
||||
}
|
||||
this.writeError(socket, 403, 'Forbidden', request_headers);
|
||||
return;
|
||||
}
|
||||
|
||||
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
|
||||
const verbose = this.service_config.debug || this.minisrv_config.config.verbosity >= 3;
|
||||
if (verbose) {
|
||||
console.log('[WTV-MSNTV2] incoming request:', requestLine);
|
||||
if (body.length) {
|
||||
@@ -250,7 +250,7 @@ class WTVMSNTV2 {
|
||||
handleConnect(socket, requestUrl) {
|
||||
const [host, portString] = requestUrl.split(':');
|
||||
const port = parseInt(portString, 10) || 443;
|
||||
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
|
||||
const verbose = this.service_config.debug || this.minisrv_config.config.verbosity >= 3;
|
||||
const connectIntercept = this.getConnectIntercept(host);
|
||||
if (verbose) console.log('[WTV-MSNTV2] CONNECT request:', requestUrl, 'intercept:', !!connectIntercept);
|
||||
|
||||
@@ -276,13 +276,13 @@ class WTVMSNTV2 {
|
||||
});
|
||||
|
||||
remote.on('error', (err) => {
|
||||
if (this.service_config.show_verbose_errors) console.error('[WTV-MSNTV2] CONNECT error:', err.message);
|
||||
if (this.service_config.debug) console.error('[WTV-MSNTV2] CONNECT error:', err.message);
|
||||
this.writeError(socket, 502, 'Bad Gateway');
|
||||
});
|
||||
}
|
||||
|
||||
setupTlsSocket(tlsSocket) {
|
||||
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
|
||||
const verbose = this.service_config.debug || this.minisrv_config.config.verbosity >= 3;
|
||||
const sslDebug = this.sslv2Debug;
|
||||
tlsSocket.on('secureConnect', () => {
|
||||
if (sslDebug) console.log('[WTV-MSNTV2] TLS handshake complete for intercepted CONNECT', tlsSocket.connectIntercept.match);
|
||||
@@ -302,7 +302,7 @@ class WTVMSNTV2 {
|
||||
}
|
||||
|
||||
setupForgeTls(socket, connectIntercept) {
|
||||
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
|
||||
const verbose = this.service_config.debug || this.minisrv_config.config.verbosity >= 3;
|
||||
const sslDebug = this.sslv2Debug;
|
||||
const creds = this.forgeTlsCredentials;
|
||||
if (!creds) {
|
||||
@@ -1234,7 +1234,7 @@ class WTVMSNTV2 {
|
||||
const requestLine = headerLines.shift();
|
||||
const requestParts = requestLine.split(' ');
|
||||
if (requestParts.length < 3) {
|
||||
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
|
||||
const verbose = this.service_config.debug || this.minisrv_config.config.verbosity >= 3;
|
||||
if (verbose) {
|
||||
console.warn('[WTV-MSNTV2] TLS invalid request line:', requestLine);
|
||||
console.warn('[WTV-MSNTV2] TLS raw header block:', headerBlock);
|
||||
@@ -1280,7 +1280,7 @@ class WTVMSNTV2 {
|
||||
};
|
||||
Object.assign(request_headers, headers);
|
||||
|
||||
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
|
||||
const verbose = this.service_config.debug || this.minisrv_config.config.verbosity >= 3;
|
||||
if (verbose) {
|
||||
console.log('[WTV-MSNTV2] decrypted request:', requestLine);
|
||||
console.log('[WTV-MSNTV2] decrypted headers:\n' + rawHeaders.join('\r\n'));
|
||||
@@ -1440,14 +1440,14 @@ class WTVMSNTV2 {
|
||||
const candidate = this.wtvshared.makeSafePath(base, '');
|
||||
// Exact match first
|
||||
if (candidate && fs.existsSync(candidate) && fs.lstatSync(candidate).isFile()) {
|
||||
if (this.service_config.show_verbose_errors) console.log('[WTV-MSNTV2] local intercept:', candidate);
|
||||
if (this.service_config.debug) console.log('[WTV-MSNTV2] local intercept:', candidate);
|
||||
return candidate;
|
||||
}
|
||||
// Dynamic suffixes: e.g. kickstart.aspx -> kickstart.aspx.js
|
||||
for (const suffix of dynSuffixes) {
|
||||
const dynCandidate = this.wtvshared.makeSafePath(base + suffix, '');
|
||||
if (dynCandidate && fs.existsSync(dynCandidate) && fs.lstatSync(dynCandidate).isFile()) {
|
||||
if (this.service_config.show_verbose_errors) console.log('[WTV-MSNTV2] local intercept (dynamic):', dynCandidate);
|
||||
if (this.service_config.debug) console.log('[WTV-MSNTV2] local intercept (dynamic):', dynCandidate);
|
||||
return dynCandidate;
|
||||
}
|
||||
}
|
||||
@@ -1753,14 +1753,14 @@ class WTVMSNTV2 {
|
||||
target = new url.URL(requestUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3) {
|
||||
if (this.service_config.debug || this.minisrv_config.config.verbosity >= 3) {
|
||||
console.error('[WTV-MSNTV2] invalid URL:', requestUrl, err.message);
|
||||
}
|
||||
this.writeError(socket, 400, 'Bad Request', request_headers);
|
||||
return;
|
||||
}
|
||||
|
||||
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
|
||||
const verbose = this.service_config.debug || this.minisrv_config.config.verbosity >= 3;
|
||||
const isHttps = target.protocol === 'https:';
|
||||
const agent = isHttps ? https : http;
|
||||
const requestPath = target.pathname + (target.search || '');
|
||||
@@ -1793,7 +1793,7 @@ class WTVMSNTV2 {
|
||||
|
||||
const maxResponseBytes = this.maxProxyResponseBytes;
|
||||
const proxyReq = agent.request(options, (res) => {
|
||||
const verbose = this.service_config.show_verbose_errors || this.minisrv_config.config.verbosity >= 3;
|
||||
const verbose = this.service_config.debug || this.minisrv_config.config.verbosity >= 3;
|
||||
if (verbose) {
|
||||
console.log('[WTV-MSNTV2] upstream response:', res.statusCode, res.statusMessage);
|
||||
console.log('[WTV-MSNTV2] upstream response headers:', JSON.stringify(res.headers));
|
||||
|
||||
@@ -219,13 +219,63 @@ class WTVClientSessionData {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the account store directory
|
||||
* @returns {string} Absolute path to the account store directory
|
||||
*/
|
||||
getAccountStoreDirectory() {
|
||||
return this.wtvshared.getAbsolutePath(this.minisrv_config.config.SessionStore + this.path.sep + "accounts");
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an account's SSID and User ID from just the username
|
||||
* @param {string} username The username to search for
|
||||
* @returns {Array} [found {boolean}, account_dir {string|null}, user_id {number|null}]
|
||||
*/
|
||||
findAccountByUsername(username) {
|
||||
const accounts_dir = this.getAccountStoreDirectory();
|
||||
if (this.fs.existsSync(accounts_dir)) {
|
||||
const account_dirs = this.fs.readdirSync(accounts_dir);
|
||||
for (let i = 0; i < account_dirs.length; i++) {
|
||||
const account_dir = accounts_dir + this.path.sep + account_dirs[i];
|
||||
if (this.fs.lstatSync(account_dir).isDirectory()) {
|
||||
const user_dirs = this.fs.readdirSync(account_dir);
|
||||
for (let j = 0; j < user_dirs.length; j++) {
|
||||
const user_file = account_dir + this.path.sep + user_dirs[j] + this.path.sep + `user${j}.json`;
|
||||
if (this.fs.existsSync(user_file)) {
|
||||
const user_data = JSON.parse(this.fs.readFileSync(user_file));
|
||||
if (user_data.subscriber_username.toLowerCase() === username.toLowerCase()) {
|
||||
return [true, account_dirs[i], j];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [false, null, null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the SSID for this session, and load a new user's session data, but only if the session
|
||||
* was initialized with a null SSID. This is primarily used for MSNTV2/Passport services
|
||||
* where the SSID is not known at the time of session initialization.
|
||||
* @param {string} ssid The new SSID to set
|
||||
* @param {number} userID The user ID to switch to after setting the SSID, defaults to 0 (primary account)
|
||||
* @param {boolean} forceSwitch If true, allows switching SSID even if one is already set (use with caution, can cause data corruption if used improperly)
|
||||
* @return {boolean} True if the SSID was set and session data loaded successfully, false otherwise
|
||||
*/
|
||||
setSSID(ssid, userID = 0, forceSwitch = false) {
|
||||
if (this.ssid !== null && !forceSwitch) return false; // SSID already set, cannot switch
|
||||
this.ssid = ssid;
|
||||
this.clearUserSessionMemory();
|
||||
this.switchUserID(userID);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the user's file store, or false if unregistered
|
||||
* @param subscriber {boolean} Returns the parent subscriber directory instead of the user's directory
|
||||
* @param {boolean} subscriber Returns the parent subscriber directory instead of the user's directory
|
||||
* @param {number|null} user_id The user ID to get the store for, or null for the current user
|
||||
* @returns {string|boolean} Absolute path to the user's file store, or false if unregistered
|
||||
*/
|
||||
getUserStoreDirectory(subscriber = false, user_id = null) {
|
||||
@@ -236,6 +286,11 @@ class WTVClientSessionData {
|
||||
return userstore + this.path.sep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a user from the account store
|
||||
* @param {number} user_id The ID of the user to remove
|
||||
* @returns {boolean} True if the user was successfully removed, false otherwise
|
||||
*/
|
||||
removeUser(user_id) {
|
||||
if (!this.isRegistered()) return false; // not registered
|
||||
if (parseInt(this.user_id) !== 0) return false; // not primary account
|
||||
@@ -692,14 +747,13 @@ class WTVClientSessionData {
|
||||
return CryptoJS.AES.decrypt(crypt, this.cryptoKey).toString(CryptoJS.enc.Utf8);
|
||||
}
|
||||
|
||||
|
||||
oldDecodePassword(passwd) {
|
||||
return CryptoJS.SHA512(passwd).toString(CryptoJS.enc.Base64);
|
||||
}
|
||||
|
||||
encodePassword(passwd) {
|
||||
//return CryptoJS.SHA512(passwd).toString(CryptoJS.enc.Base64);
|
||||
return this.encryptPassword(passwd);
|
||||
// SHA512 the user's password, then encrypt the hash with AES using the server's user_data_key.
|
||||
return this.encryptPassword(CryptoJS.SHA512(passwd).toString(CryptoJS.enc.Hex));
|
||||
}
|
||||
|
||||
setUserPassword(passwd) {
|
||||
@@ -729,8 +783,12 @@ class WTVClientSessionData {
|
||||
|
||||
validateUserPassword(passwd) {
|
||||
if (!this.getUserPasswordEnabled()) return true; // no password is set so always validate
|
||||
if (passwd === this.decryptPassword(this.getSessionData("subscriber_password"))) return true; // check against current encryption
|
||||
else if (this.oldDecodePassword(passwd) === this.getSessionData("subscriber_password")) {
|
||||
if (CryptoJS.SHA512(passwd).toString(CryptoJS.enc.Hex) === this.decryptPassword(this.getSessionData("subscriber_password"))) return true; // check against current encryption
|
||||
else if (passwd === this.decryptPassword(this.getSessionData("subscriber_password"))) {
|
||||
// check against the short-lived new encryption, if it matches then update to new encryption
|
||||
this.setUserPassword(passwd);
|
||||
return true;
|
||||
} else if (this.oldDecodePassword(passwd) === this.getSessionData("subscriber_password")) {
|
||||
// if password matches old hash, update to new encryption
|
||||
this.setUserPassword(passwd);
|
||||
return true;
|
||||
|
||||
@@ -48,8 +48,7 @@
|
||||
"show_diskmap": false, // Useful for debugging custom Diskmaps
|
||||
"unauthorized_url": "wtv-1800:/unauthorized?", // Where to send unauthorized users
|
||||
"enable_port_isolation": true, // Only respond to services on their correct ports
|
||||
"domain_name": "minisrv.local", // For MSNTV2 stuff
|
||||
"max_post_length": 20, // in megabytes
|
||||
"domain_name": "minisrv.local", // For personalizing mail and MSNTV2
|
||||
"require_valid_ssid": false, // require a valid SSID (with valid CRC)
|
||||
"user_accounts": { // user account settings
|
||||
"max_users_per_account": 6, // Max total users (including primary) per account
|
||||
|
||||
4
zefie_wtvp_minisrv/package-lock.json
generated
4
zefie_wtvp_minisrv/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "zefie_wtvp_minisrv",
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.74",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "zefie_wtvp_minisrv",
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.74",
|
||||
"license": "GPL3",
|
||||
"dependencies": {
|
||||
"@serialport/parser-readline": "^13.0.0",
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"name": "zefie_wtvp_minisrv",
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.74",
|
||||
"description": "WebTV Service (WTVP) Emulation Server",
|
||||
"main": "app.js",
|
||||
"homepage": "https://github.com/zefie/zefie_wtvp_minisrv",
|
||||
"license": "GPL3",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"irc": "node irconly.js",
|
||||
"test": "node test.js",
|
||||
"debug": "cross-env DEBUG=* node app.js",
|
||||
"modem-proxy": "node modem_proxy.js"
|
||||
|
||||
215
zefie_wtvp_minisrv/tools/configurator.js
Normal file
215
zefie_wtvp_minisrv/tools/configurator.js
Normal file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||
const USER_CONFIG_PATH = path.join(ROOT_DIR, 'user_config.json');
|
||||
|
||||
function printUsage() {
|
||||
console.log('Usage:');
|
||||
console.log(' node tools/configurator.js <dot.path.key> <value> [--overwrite]');
|
||||
console.log(' node tools/configurator.js <dot.path.key> --delete [--overwrite]');
|
||||
console.log('Examples:');
|
||||
console.log(' node tools/configurator.js config.keys.user_data_key mynewkey');
|
||||
console.log(' node tools/configurator.js config.passwords.enabled true --overwrite');
|
||||
console.log(' node tools/configurator.js config.fake.newkey --delete --overwrite');
|
||||
}
|
||||
|
||||
function parseJsonWithComments(json) {
|
||||
if (typeof json !== 'string') json = json ? json.toString() : '';
|
||||
let result = '';
|
||||
let i = 0;
|
||||
let isString = false;
|
||||
let isEscape = false;
|
||||
let isBlockComment = false;
|
||||
let isLineComment = false;
|
||||
|
||||
while (i < json.length) {
|
||||
const char = json[i];
|
||||
const nextChar = json[i + 1];
|
||||
|
||||
if (!isString && !isEscape && char === '/' && nextChar === '*') {
|
||||
isBlockComment = true;
|
||||
i += 1;
|
||||
} else if (isBlockComment && char === '*' && nextChar === '/') {
|
||||
isBlockComment = false;
|
||||
i += 1;
|
||||
} else if (!isString && !isEscape && char === '/' && nextChar === '/') {
|
||||
isLineComment = true;
|
||||
i += 1;
|
||||
} else if (isLineComment && (char === '\n' || char === '\r')) {
|
||||
isLineComment = false;
|
||||
} else if (!isBlockComment && !isLineComment) {
|
||||
if (char === '"' && !isEscape) {
|
||||
isString = !isString;
|
||||
}
|
||||
isEscape = char === '\\' && !isEscape;
|
||||
result += char;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return JSON.parse(result);
|
||||
}
|
||||
|
||||
function parseInputValue(raw) {
|
||||
const trimmed = raw.trim();
|
||||
const isLiteral = /^(?:-?\d+(?:\.\d+)?|true|false|null)$/i.test(trimmed);
|
||||
const startsLikeJson = trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed.startsWith('"');
|
||||
|
||||
if (!isLiteral && !startsLikeJson) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch (_error) {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function readUserConfig(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, '{}\n', 'utf8');
|
||||
return {};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
if (!content.trim()) return {};
|
||||
|
||||
try {
|
||||
return parseJsonWithComments(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse user_config.json: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function askYesNo(question) {
|
||||
return new Promise((resolve) => {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
const normalized = String(answer || '').trim().toLowerCase();
|
||||
resolve(normalized === 'y' || normalized === 'yes');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sortKeys(obj) {
|
||||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj;
|
||||
return Object.keys(obj).sort().reduce((sorted, key) => {
|
||||
sorted[key] = sortKeys(obj[key]);
|
||||
return sorted;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function getPathInfo(rootObj, parts) {
|
||||
let current = rootObj;
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
const part = parts[i];
|
||||
|
||||
if (!current[part] || typeof current[part] !== 'object' || Array.isArray(current[part])) {
|
||||
current[part] = {};
|
||||
}
|
||||
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
const leafKey = parts[parts.length - 1];
|
||||
const exists = Object.prototype.hasOwnProperty.call(current, leafKey);
|
||||
const oldValue = exists ? current[leafKey] : undefined;
|
||||
|
||||
return {
|
||||
parent: current,
|
||||
leafKey,
|
||||
exists,
|
||||
oldValue
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const overwrite = args.includes('--overwrite');
|
||||
const deleteMode = args.includes('--delete');
|
||||
const positional = args.filter((arg) => arg !== '--overwrite' && arg !== '--delete');
|
||||
|
||||
if ((deleteMode && positional.length < 1) || (!deleteMode && positional.length < 2)) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dotPath = positional[0];
|
||||
const valueRaw = deleteMode ? null : positional.slice(1).join(' ');
|
||||
const pathParts = dotPath.split('.').filter(Boolean);
|
||||
|
||||
if (pathParts.length === 0) {
|
||||
throw new Error('Invalid path. Provide a dot-separated path like config.keys.user_data_key');
|
||||
}
|
||||
|
||||
if (deleteMode && pathParts.length === 1) {
|
||||
const rootKey = pathParts[0];
|
||||
if (rootKey === 'config' || rootKey === 'services') {
|
||||
throw new Error('Deletion of root keys "config" and "services" is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
const config = readUserConfig(USER_CONFIG_PATH);
|
||||
|
||||
if (deleteMode) {
|
||||
const pathInfo = getPathInfo(config, pathParts);
|
||||
if (!pathInfo.exists) {
|
||||
console.log('Key does not exist. No changes made.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!overwrite) {
|
||||
const question = `Delete ${dotPath}? [y/N] `;
|
||||
const approved = await askYesNo(question);
|
||||
if (!approved) {
|
||||
console.log('No changes made.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
delete pathInfo.parent[pathInfo.leafKey];
|
||||
fs.writeFileSync(USER_CONFIG_PATH, `${JSON.stringify(sortKeys(config), null, 2)}\n`, 'utf8');
|
||||
console.log(`Deleted ${dotPath}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pathInfo = getPathInfo(config, pathParts);
|
||||
const newValue = parseInputValue(valueRaw);
|
||||
|
||||
if (pathInfo.exists && !overwrite) {
|
||||
const question = `Key ${dotPath} already exists. Overwrite? [y/N] `;
|
||||
const approved = await askYesNo(question);
|
||||
|
||||
if (!approved) {
|
||||
console.log('No changes made.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pathInfo.parent[pathInfo.leafKey] = newValue;
|
||||
|
||||
fs.writeFileSync(USER_CONFIG_PATH, `${JSON.stringify(sortKeys(config), null, 2)}\n`, 'utf8');
|
||||
|
||||
if (pathInfo.exists) {
|
||||
console.log(`Updated ${dotPath}.`);
|
||||
} else {
|
||||
console.log(`Created ${dotPath}.`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
442
zefie_wtvp_minisrv/tools/encode_webtv_mpeg.js
Normal file
442
zefie_wtvp_minisrv/tools/encode_webtv_mpeg.js
Normal file
@@ -0,0 +1,442 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* WebTV MPEG-1 PS Encoder
|
||||
*
|
||||
* This tool is incomplete, and may not generate correct WebTV MPEG yet
|
||||
*
|
||||
* Two-pass pipeline:
|
||||
* 1. ffmpeg encodes input to MPEG-1 PS (codec settings only)
|
||||
* 2. ES extracted via structure-aware pack walk (never naive payload scan)
|
||||
* 3. Video ES patched: fr_code=10, constrained_parameters_flag=1
|
||||
* 4. Output rebuilt as strict 2048-byte packs matching attract.mpg structure:
|
||||
* - One PES per pack: BA(12) + PES_hdr(6) + ff_0f(2) + data(2028) = 2048
|
||||
* - All PES optional headers: ff 0f (no timestamps)
|
||||
* - 3 audio pre-fill packs, then 1 audio per ~7 video packs
|
||||
* - No BB system header packet
|
||||
*
|
||||
* Usage: node encode_webtv_mpeg.js <input_video> <output.mpg> [duration_seconds]
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const PACK_SIZE = 2048;
|
||||
// MPEG-1 pack header variants used by known working files.
|
||||
const BA_HDR_MPEG1 = Buffer.from('000001ba2100010001802711', 'hex');
|
||||
const BA_HDR_ATTRACT = Buffer.from('000001ba0000025447474747', 'hex');
|
||||
const MP2_BITRATE_MPEG1_L2 = [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0];
|
||||
const MP2_SR_MPEG1 = [44100, 48000, 32000, 0];
|
||||
// Usable data per pack: 2048 - BA(12) - PES_fixed_hdr(6) - ff_0f(2) = 2028
|
||||
const DATA_PER_PACK = PACK_SIZE - BA_HDR_MPEG1.length - 6 - 2; // 2028
|
||||
|
||||
|
||||
function runCmd(args, description) {
|
||||
console.log(`[*] ${description}...`);
|
||||
try {
|
||||
execFileSync(args[0], args.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
console.log('[+] OK');
|
||||
return true;
|
||||
} catch (e) {
|
||||
const stderr = e.stderr ? e.stderr.toString().slice(0, 500) : e.message;
|
||||
console.error(`[!] Failed: ${stderr}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extract video (E0) and audio (C0) elementary streams.
|
||||
* Uses structure-aware pack walk — never scans payload bytes for start codes.
|
||||
*/
|
||||
function extractES(mpgPath) {
|
||||
console.log('[*] Extracting elementary streams (structure-aware)...');
|
||||
|
||||
const d = fs.readFileSync(mpgPath);
|
||||
const videoChunks = [];
|
||||
const audioChunks = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < d.length - 4) {
|
||||
if (d[i] !== 0x00 || d[i+1] !== 0x00 || d[i+2] !== 0x01) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const sid = d[i+3];
|
||||
|
||||
if (sid === 0xBA) {
|
||||
const mpeg2 = (d[i+4] & 0xC0) === 0x40;
|
||||
const hlen = mpeg2 ? 14 + (d[i+13] & 0x07) : 12;
|
||||
i += hlen;
|
||||
|
||||
} else if (sid === 0xE0 || sid === 0xC0) {
|
||||
const pktLen = (d[i+4] << 8) | d[i+5];
|
||||
const end = i + 6 + pktLen;
|
||||
let j = i + 6;
|
||||
|
||||
// Skip stuffing bytes (0xFF)
|
||||
while (j < end && d[j] === 0xFF) j++;
|
||||
|
||||
if (j < end) {
|
||||
let h = d[j];
|
||||
// Skip STD buffer (0x4x marker, 2 bytes)
|
||||
if ((h & 0xC0) === 0x40) {
|
||||
j += 2;
|
||||
h = j < end ? d[j] : 0;
|
||||
}
|
||||
// Skip PTS-only (5 bytes) or PTS+DTS (10 bytes) or no-ts (1 byte)
|
||||
if ((h & 0xF0) === 0x20) {
|
||||
j += 5;
|
||||
} else if ((h & 0xF0) === 0x30) {
|
||||
j += 10;
|
||||
} else if (h === 0x0F) {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (j <= end) {
|
||||
const payload = d.slice(j, end);
|
||||
if (sid === 0xE0) videoChunks.push(payload);
|
||||
else audioChunks.push(payload);
|
||||
}
|
||||
i = end;
|
||||
|
||||
} else if (sid === 0xB9) {
|
||||
break; // PS end code
|
||||
|
||||
} else if (sid >= 0xBB && sid <= 0xBF) {
|
||||
const pktLen = (d[i+4] << 8) | d[i+5];
|
||||
i += 6 + pktLen;
|
||||
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
const videoES = Buffer.concat(videoChunks);
|
||||
const audioES = Buffer.concat(audioChunks);
|
||||
console.log(`[+] Extracted: video=${videoES.length} bytes, audio=${audioES.length} bytes`);
|
||||
return { videoES, audioES };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Patch fr_code=10 and constrained_parameters_flag=1 in video ES buffer in-place.
|
||||
*/
|
||||
function patchSequenceHeaders(videoES) {
|
||||
let i = 0;
|
||||
let n = 0;
|
||||
while (i < videoES.length - 11) {
|
||||
// Find 00 00 01 B3
|
||||
if (videoES[i] === 0x00 && videoES[i+1] === 0x00 &&
|
||||
videoES[i+2] === 0x01 && videoES[i+3] === 0xB3) {
|
||||
videoES[i+7] = (videoES[i+7] & 0xF0) | 0x0A; // fr_code = 10
|
||||
videoES[i+11] |= 0x04; // constrained_parameters_flag
|
||||
n++;
|
||||
i += 4;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
console.log(`[+] Patched ${n} sequence header(s): fr_code=10, constrained=1`);
|
||||
return n;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Normalize MP2 frame headers for maximum WebTV compatibility.
|
||||
* Clears the "original" bit (header byte3 bit2), matching attract.mpg (fffd50c0).
|
||||
*/
|
||||
function normalizeMP2Headers(audioES) {
|
||||
let i = 0;
|
||||
let patched = 0;
|
||||
|
||||
while (i < audioES.length - 4) {
|
||||
if (audioES[i] === 0xFF && (audioES[i + 1] & 0xE0) === 0xE0) {
|
||||
const b1 = audioES[i + 1];
|
||||
const b2 = audioES[i + 2];
|
||||
|
||||
const version = (b1 >> 3) & 0x03; // 3 => MPEG-1
|
||||
const layer = (b1 >> 1) & 0x03; // 2 => Layer II
|
||||
const brIdx = (b2 >> 4) & 0x0F;
|
||||
const srIdx = (b2 >> 2) & 0x03;
|
||||
const pad = (b2 >> 1) & 0x01;
|
||||
|
||||
if (version === 3 && layer === 2 && MP2_BITRATE_MPEG1_L2[brIdx] && MP2_SR_MPEG1[srIdx]) {
|
||||
const bitrate = MP2_BITRATE_MPEG1_L2[brIdx] * 1000;
|
||||
const sampleRate = MP2_SR_MPEG1[srIdx];
|
||||
const frameLen = Math.floor((144 * bitrate) / sampleRate) + pad;
|
||||
|
||||
// Clear "original" bit (bit2 in 4th header byte): c4 -> c0
|
||||
audioES[i + 3] &= 0xFB;
|
||||
patched++;
|
||||
|
||||
i += frameLen;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
console.log(`[+] Normalized MP2 headers: patched ${patched} frame(s)`);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build MPEG-1 PS with strict 2048-byte packs matching attract.mpg structure:
|
||||
* - One PES per pack
|
||||
* - All PES optional headers: ff 0f (no timestamps)
|
||||
* - 3 audio pre-fill packs, then 1 audio per ~7 video packs
|
||||
* - No BB system header
|
||||
*/
|
||||
function buildWebTVPS(videoES, audioES, outputPath, audioIntervalOverride, baHeaderMode) {
|
||||
console.log('[*] Building WebTV MPEG-1 PS...');
|
||||
|
||||
const P = DATA_PER_PACK; // 2028
|
||||
|
||||
const baHdr = baHeaderMode === 'attract' ? BA_HDR_ATTRACT : BA_HDR_MPEG1;
|
||||
|
||||
function makePack(streamId, data) {
|
||||
// Pad or trim data to exactly P bytes
|
||||
const payload = Buffer.alloc(P);
|
||||
data.copy(payload, 0, 0, Math.min(data.length, P));
|
||||
|
||||
const pktLen = P + 2; // ff 0f(2) + data(P) = 2030
|
||||
const pesHdr = Buffer.alloc(8);
|
||||
pesHdr[0] = 0x00; pesHdr[1] = 0x00; pesHdr[2] = 0x01; pesHdr[3] = streamId;
|
||||
pesHdr[4] = (pktLen >> 8) & 0xFF;
|
||||
pesHdr[5] = pktLen & 0xFF;
|
||||
pesHdr[6] = 0xFF; // ff 0f
|
||||
pesHdr[7] = 0x0F;
|
||||
|
||||
return Buffer.concat([baHdr, pesHdr, payload]); // 12 + 6 + 2 + 2028 = 2048
|
||||
}
|
||||
|
||||
// Split ES into P-byte chunks
|
||||
const vChunks = [];
|
||||
for (let i = 0; i < videoES.length; i += P) vChunks.push(videoES.slice(i, i + P));
|
||||
const aChunks = [];
|
||||
for (let i = 0; i < audioES.length; i += P) aChunks.push(audioES.slice(i, i + P));
|
||||
|
||||
if (!vChunks.length || !aChunks.length) {
|
||||
console.error('[!] Empty video or audio stream');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match known-working WebTV cadence (attract.mpg is ~1 audio per 7 video packs)
|
||||
const inferredInterval = Math.max(1, Math.round(vChunks.length / aChunks.length));
|
||||
const audioInterval = Number.isFinite(audioIntervalOverride) && audioIntervalOverride > 0
|
||||
? Math.floor(audioIntervalOverride)
|
||||
: Math.max(1, Math.round((inferredInterval + 7) / 2));
|
||||
console.log(`[*] ${vChunks.length} video chunks, ${aChunks.length} audio chunks, ` +
|
||||
`1 audio per ~${audioInterval} video`);
|
||||
|
||||
const packs = [];
|
||||
let aIdx = 0;
|
||||
|
||||
// Pre-fill: 3 audio packs to prime the WebTV audio buffer
|
||||
const preFill = Math.min(3, aChunks.length);
|
||||
for (let k = 0; k < preFill; k++, aIdx++) {
|
||||
packs.push(makePack(0xC0, aChunks[aIdx]));
|
||||
}
|
||||
|
||||
let vIdx = 0;
|
||||
|
||||
// Spread audio over the full video timeline to avoid starving early playback
|
||||
// and dumping remaining audio at EOF.
|
||||
while (vIdx < vChunks.length || aIdx < aChunks.length) {
|
||||
if (vIdx >= vChunks.length) {
|
||||
packs.push(makePack(0xC0, aChunks[aIdx++]));
|
||||
continue;
|
||||
}
|
||||
if (aIdx >= aChunks.length) {
|
||||
packs.push(makePack(0xE0, vChunks[vIdx++]));
|
||||
continue;
|
||||
}
|
||||
|
||||
const videoProgress = vIdx / vChunks.length;
|
||||
const audioProgress = aIdx / aChunks.length;
|
||||
|
||||
// Prefer video until audio falls behind target cadence.
|
||||
if (audioProgress + (1 / Math.max(1, audioInterval * aChunks.length)) < videoProgress) {
|
||||
packs.push(makePack(0xC0, aChunks[aIdx++]));
|
||||
} else {
|
||||
packs.push(makePack(0xE0, vChunks[vIdx++]));
|
||||
}
|
||||
}
|
||||
|
||||
const endCode = Buffer.from([0x00, 0x00, 0x01, 0xB9]);
|
||||
const output = Buffer.concat([...packs, endCode]);
|
||||
|
||||
fs.writeFileSync(outputPath, output);
|
||||
console.log(`[+] Wrote ${packs.length} packs (${output.length} bytes)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
function verifyFile(mpgPath) {
|
||||
console.log('[*] Verifying file structure...');
|
||||
try {
|
||||
const result = execFileSync('ffprobe', [
|
||||
'-v', 'error',
|
||||
'-show_entries', 'stream=codec_name,codec_type',
|
||||
'-of', 'default=noprint_wrappers=1',
|
||||
mpgPath
|
||||
], { encoding: 'utf8' });
|
||||
if (result.includes('mpeg1video') && result.includes('mp2')) {
|
||||
console.log('[+] Valid: mpeg1video + mp2');
|
||||
return true;
|
||||
}
|
||||
console.error('[!] Missing video or audio stream');
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error(`[!] ffprobe failed: ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function checkPacks(mpgPath) {
|
||||
console.log('[*] Checking pack structure...');
|
||||
const d = fs.readFileSync(mpgPath);
|
||||
const baPos = [];
|
||||
for (let i = 0; i < d.length - 3; i++) {
|
||||
if (d[i] === 0x00 && d[i+1] === 0x00 && d[i+2] === 0x01 && d[i+3] === 0xBA) {
|
||||
baPos.push(i);
|
||||
i += 3;
|
||||
}
|
||||
}
|
||||
if (baPos.length < 2) {
|
||||
console.error('[!] Less than 2 BA packs found');
|
||||
return false;
|
||||
}
|
||||
const strides = new Set();
|
||||
for (let i = 0; i < baPos.length - 1; i++) {
|
||||
strides.add(baPos[i+1] - baPos[i]);
|
||||
}
|
||||
if (strides.size === 1 && strides.has(2048)) {
|
||||
console.log(`[+] Perfect: all ${baPos.length} packs are 2048 bytes`);
|
||||
return true;
|
||||
}
|
||||
console.log(`[!] Pack strides vary: ${[...strides].sort((a, b) => a - b).join(', ')}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Encode video to WebTV-compatible MPEG-1 PS.
|
||||
*
|
||||
* @param {string} inputFile Any video file ffmpeg can read
|
||||
* @param {string} outputFile Output .mpg path
|
||||
* @param {number|null} duration Optional clip length in seconds
|
||||
*/
|
||||
function encodeWebTV(inputFile, outputFile, duration, audioIntervalOverride, baHeaderMode, audioEncoder, audioESOverridePath) {
|
||||
const tmpFile = outputFile.replace(/(\.[^.]+)$/, '_raw$1');
|
||||
|
||||
// Step 1: Encode with ffmpeg (MPEG-1 video + MP2 audio, raw mux)
|
||||
const cmd = ['ffmpeg', '-y', '-i', inputFile];
|
||||
if (duration != null) cmd.push('-t', String(duration));
|
||||
cmd.push(
|
||||
'-vf', 'fps=15,scale=272:208,setsar=1',
|
||||
'-c:v', 'mpeg1video',
|
||||
'-b:v', '500k',
|
||||
'-maxrate', '700k',
|
||||
'-bufsize', '1024k',
|
||||
'-g', '15',
|
||||
'-bf', '2',
|
||||
'-c:a', audioEncoder,
|
||||
'-ar', '44100',
|
||||
'-ac', '1',
|
||||
'-b:a', '80k',
|
||||
'-strict', 'unofficial',
|
||||
'-f', 'mpeg',
|
||||
'-muxrate', '2000k',
|
||||
tmpFile
|
||||
);
|
||||
if (!runCmd(cmd, 'Encoding with ffmpeg (MPEG-1 PS)')) return false;
|
||||
|
||||
// Step 2: Extract ES using structure-aware pack walk
|
||||
const { videoES, audioES } = extractES(tmpFile);
|
||||
if (!videoES.length || !audioES.length) {
|
||||
console.error('[!] Failed to extract elementary streams');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 3: Patch video sequence headers (fr_code=10, constrained=1)
|
||||
const videoMut = Buffer.from(videoES);
|
||||
patchSequenceHeaders(videoMut);
|
||||
|
||||
let audioSource = audioES;
|
||||
if (audioESOverridePath) {
|
||||
if (!fs.existsSync(audioESOverridePath)) {
|
||||
console.error(`[!] Audio ES override not found: ${audioESOverridePath}`);
|
||||
return false;
|
||||
}
|
||||
audioSource = fs.readFileSync(audioESOverridePath);
|
||||
console.log(`[+] Using external audio ES override: ${audioESOverridePath} (${audioSource.length} bytes)`);
|
||||
}
|
||||
|
||||
const audioMut = Buffer.from(audioSource);
|
||||
normalizeMP2Headers(audioMut);
|
||||
|
||||
// Step 4: Rebuild as proper WebTV PS
|
||||
if (!buildWebTVPS(videoMut, audioMut, outputFile, audioIntervalOverride, baHeaderMode)) return false;
|
||||
|
||||
// Step 5: Verify
|
||||
if (!verifyFile(outputFile)) return false;
|
||||
checkPacks(outputFile);
|
||||
|
||||
// Cleanup temp file
|
||||
try { fs.unlinkSync(tmpFile); } catch (_) {}
|
||||
|
||||
console.log(`\n[+] Successfully encoded: ${outputFile}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// --- CLI entry point ---
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
const script = path.basename(process.argv[1]);
|
||||
console.error(`Usage: node ${script} <input_video> <output.mpg> [duration_seconds]`);
|
||||
console.error(`Example: node ${script} myvideo.mp4 webtv.mpg 15`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let audioIntervalOverride = null;
|
||||
let baHeaderMode = 'mpeg1';
|
||||
let audioEncoder = 'mp2fixed';
|
||||
let audioESOverridePath = null;
|
||||
const nonFlagArgs = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--audio-interval' && i + 1 < args.length) {
|
||||
audioIntervalOverride = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
} else if (args[i] === '--ba-header' && i + 1 < args.length) {
|
||||
baHeaderMode = String(args[i + 1]).toLowerCase() === 'attract' ? 'attract' : 'mpeg1';
|
||||
i++;
|
||||
} else if (args[i] === '--audio-encoder' && i + 1 < args.length) {
|
||||
const v = String(args[i + 1]).toLowerCase();
|
||||
audioEncoder = (v === 'mp2fixed') ? 'mp2fixed' : 'mp2';
|
||||
i++;
|
||||
} else if (args[i] === '--audio-es' && i + 1 < args.length) {
|
||||
audioESOverridePath = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
nonFlagArgs.push(args[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const [inputFile, outputFile, durationArg] = nonFlagArgs;
|
||||
const duration = durationArg != null ? parseFloat(durationArg) : null;
|
||||
|
||||
if (!fs.existsSync(inputFile)) {
|
||||
console.error(`[!] Input file not found: ${inputFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!encodeWebTV(inputFile, outputFile, duration, audioIntervalOverride, baHeaderMode, audioEncoder, audioESOverridePath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
257
zefie_wtvp_minisrv/tools/update_user_data_key.js
Normal file
257
zefie_wtvp_minisrv/tools/update_user_data_key.js
Normal file
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const CryptoJS = require('crypto-js');
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||
const DEFAULT_CONFIG_PATH = path.join(ROOT_DIR, 'includes', 'config.json');
|
||||
const USER_CONFIG_PATH = path.join(ROOT_DIR, 'user_config.json');
|
||||
|
||||
function parseJsonWithComments(json) {
|
||||
if (typeof json !== 'string') json = json ? json.toString() : '';
|
||||
let result = '';
|
||||
let i = 0;
|
||||
let isString = false;
|
||||
let isEscape = false;
|
||||
let isBlockComment = false;
|
||||
let isLineComment = false;
|
||||
|
||||
while (i < json.length) {
|
||||
const char = json[i];
|
||||
const nextChar = json[i + 1];
|
||||
|
||||
if (!isString && !isEscape && char === '/' && nextChar === '*') {
|
||||
isBlockComment = true;
|
||||
i += 1;
|
||||
} else if (isBlockComment && char === '*' && nextChar === '/') {
|
||||
isBlockComment = false;
|
||||
i += 1;
|
||||
} else if (!isString && !isEscape && char === '/' && nextChar === '/') {
|
||||
isLineComment = true;
|
||||
i += 1;
|
||||
} else if (isLineComment && (char === '\n' || char === '\r')) {
|
||||
isLineComment = false;
|
||||
} else if (!isBlockComment && !isLineComment) {
|
||||
if (char === '"' && !isEscape) {
|
||||
isString = !isString;
|
||||
}
|
||||
isEscape = char === '\\' && !isEscape;
|
||||
result += char;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return JSON.parse(result);
|
||||
}
|
||||
|
||||
function readJsonWithComments(filePath, required) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
if (required) throw new Error(`Required file not found: ${filePath}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
return parseJsonWithComments(raw);
|
||||
}
|
||||
|
||||
function integrateConfig(main, user) {
|
||||
const out = Array.isArray(main) ? main.slice() : { ...main };
|
||||
for (const key of Object.keys(user || {})) {
|
||||
const userVal = user[key];
|
||||
if (userVal && typeof userVal === 'object' && !Array.isArray(userVal)) {
|
||||
out[key] = integrateConfig(out[key] || {}, userVal);
|
||||
} else {
|
||||
out[key] = userVal;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isAbsoluteLike(p) {
|
||||
return /^(?:[a-zA-Z]:)?[\\/]/.test(p);
|
||||
}
|
||||
|
||||
function resolveFromRoot(p) {
|
||||
if (isAbsoluteLike(p)) return path.normalize(p);
|
||||
return path.resolve(ROOT_DIR, p);
|
||||
}
|
||||
|
||||
function listUserJsonFiles(accountsRoot) {
|
||||
const files = [];
|
||||
|
||||
function walk(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^user\d+\.json$/i.test(entry.name)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(accountsRoot)) walk(accountsRoot);
|
||||
return files;
|
||||
}
|
||||
|
||||
function decryptWithKey(value, key) {
|
||||
return CryptoJS.AES.decrypt(value, key).toString(CryptoJS.enc.Utf8);
|
||||
}
|
||||
|
||||
function encryptWithKey(value, key) {
|
||||
return CryptoJS.AES.encrypt(value, key).toString();
|
||||
}
|
||||
|
||||
function isPrintableAndSane(str) {
|
||||
if (typeof str !== 'string') return false;
|
||||
if (str.length === 0 || str.length > 512) return false;
|
||||
if (str.includes('\uFFFD')) return false;
|
||||
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/.test(str)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSaneDecryptedValue(fieldName, decrypted) {
|
||||
if (!isPrintableAndSane(decrypted)) return false;
|
||||
|
||||
if (fieldName === 'subscriber_password') {
|
||||
const isSha512Hex = /^[a-f0-9]{128}$/i.test(decrypted);
|
||||
if (isSha512Hex) return true;
|
||||
return decrypted.length <= 128;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasNestedKey(obj, pathParts) {
|
||||
let cur = obj;
|
||||
for (const part of pathParts) {
|
||||
if (!cur || typeof cur !== 'object' || !Object.prototype.hasOwnProperty.call(cur, part)) return false;
|
||||
cur = cur[part];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let oldKey = null;
|
||||
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
if (args[i] === '--oldkey' && args[i + 1]) {
|
||||
oldKey = args[i + 1];
|
||||
i += 1;
|
||||
} else if (args[i].startsWith('--oldkey=')) {
|
||||
oldKey = args[i].slice('--oldkey='.length);
|
||||
}
|
||||
}
|
||||
|
||||
return { oldKey };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { oldKey } = parseArgs();
|
||||
|
||||
const defaultConfig = readJsonWithComments(DEFAULT_CONFIG_PATH, true);
|
||||
const userConfig = readJsonWithComments(USER_CONFIG_PATH, false);
|
||||
|
||||
const defaultKey = oldKey ||
|
||||
(defaultConfig && defaultConfig.config && defaultConfig.config.keys
|
||||
? defaultConfig.config.keys.user_data_key
|
||||
: null);
|
||||
|
||||
const userHasKey = hasNestedKey(userConfig, ['config', 'keys', 'user_data_key']);
|
||||
const userKey = userHasKey ? userConfig.config.keys.user_data_key : null;
|
||||
|
||||
if (oldKey) {
|
||||
console.log(`Using provided --oldkey for decryption.`);
|
||||
}
|
||||
|
||||
if (!defaultKey || typeof defaultKey !== 'string') {
|
||||
throw new Error('Default config key config.keys.user_data_key is missing or invalid.');
|
||||
}
|
||||
|
||||
if (!userHasKey) {
|
||||
console.log('No config.keys.user_data_key found in user_config.json. Nothing to migrate.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof userKey !== 'string' || userKey.length === 0) {
|
||||
throw new Error('user_config.json config.keys.user_data_key is invalid.');
|
||||
}
|
||||
|
||||
if (userKey === defaultKey) {
|
||||
console.log('user_config.json key matches the default key. Nothing to migrate.');
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedConfig = integrateConfig(defaultConfig, userConfig);
|
||||
const sessionStore = mergedConfig && mergedConfig.config ? mergedConfig.config.SessionStore : null;
|
||||
if (!sessionStore || typeof sessionStore !== 'string') {
|
||||
throw new Error('config.SessionStore is missing or invalid.');
|
||||
}
|
||||
|
||||
const accountsRoot = path.join(resolveFromRoot(sessionStore), 'accounts');
|
||||
const userFiles = listUserJsonFiles(accountsRoot);
|
||||
|
||||
if (userFiles.length === 0) {
|
||||
console.log(`No user account files found in ${accountsRoot}. Nothing to migrate.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const writableUpdates = [];
|
||||
let touchedPasswordFields = 0;
|
||||
|
||||
for (const userFile of userFiles) {
|
||||
let accountData;
|
||||
try {
|
||||
accountData = JSON.parse(fs.readFileSync(userFile, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse account file ${userFile}: ${error.message}`);
|
||||
}
|
||||
|
||||
let fileChanged = false;
|
||||
|
||||
for (const fieldName of ['subscriber_password', 'subscriber_smtp_password']) {
|
||||
const encryptedValue = accountData[fieldName];
|
||||
if (encryptedValue === null || typeof encryptedValue === 'undefined') continue;
|
||||
if (typeof encryptedValue !== 'string') {
|
||||
throw new Error(`Suspicious ${fieldName} value in ${userFile}: expected string/null.`);
|
||||
}
|
||||
if (encryptedValue.length === 0) continue;
|
||||
|
||||
const decryptedValue = decryptWithKey(encryptedValue, defaultKey);
|
||||
if (!isSaneDecryptedValue(fieldName, decryptedValue)) {
|
||||
throw new Error(
|
||||
`Aborting: decrypted ${fieldName} in ${userFile} appears invalid/binary. No files were updated.\n` +
|
||||
`If you previously used a different key, re-run with: --oldkey "<your previous key>"`
|
||||
);
|
||||
}
|
||||
|
||||
accountData[fieldName] = encryptWithKey(decryptedValue, userKey);
|
||||
fileChanged = true;
|
||||
touchedPasswordFields += 1;
|
||||
}
|
||||
|
||||
if (fileChanged) {
|
||||
writableUpdates.push({ filePath: userFile, accountData });
|
||||
}
|
||||
}
|
||||
|
||||
for (const update of writableUpdates) {
|
||||
fs.writeFileSync(update.filePath, JSON.stringify(update.accountData), 'utf8');
|
||||
}
|
||||
|
||||
console.log(`Migrated ${touchedPasswordFields} encrypted password field(s) across ${writableUpdates.length} account file(s).`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error.message || error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -15,6 +15,9 @@
|
||||
"C:/Users/zefie/webtv/ServiceVault2",
|
||||
"/home/zefie/webtv/ServiceVault"
|
||||
],
|
||||
"keys": {
|
||||
"user_data_key": "SOMETHING_RANDOM_AND_SECRET" // this can be any string, but should be changed to a random value before registering any users.
|
||||
},
|
||||
"php_enabled": true, // enables PHP CGI support
|
||||
"php_binpath": "/usr/bin/php-cgi", // path to PHP CGI binary
|
||||
"cgi_enabled": true, // enables CGI Support
|
||||
|
||||
Reference in New Issue
Block a user