328 lines
12 KiB
JavaScript
328 lines
12 KiB
JavaScript
"use strict";
|
|
|
|
// Local object-storage stand-in for the phone app pictures: 趴卜 (Bubble, AppId 12) and 眼界
|
|
// (Scope/Tuite, AppId 17), plus the 眼界 comment files. One endpoint serves all of them because the
|
|
// client only ever learns a single bucket/endpoint pair.
|
|
//
|
|
// The client only ever holds an object key ("Bubble/Laikaya_Restaurant_Menghuayuan.png"), never a
|
|
// URL. NewBubbleMgr.lua:883-889 puts SocialMediaConfig.ImageOSS[1] into store.bucketName and the
|
|
// widget turns that into a download through LX6.Utils.AliOssManager, whose bucket/endpoint come
|
|
// from SyncOssBucketInfo(64833344) and nothing else. AliOssManager builds
|
|
//
|
|
// "https://" + Bucket + "." + Endpoint + "/" + ObjectKey
|
|
//
|
|
// with https hardcoded (measured in Player.log), so with bucket "bubble" and endpoint
|
|
// "127.0.0.1.sslip.io:8443" the client asks for
|
|
// https://bubble.127.0.0.1.sslip.io:8443/Bubble/x.png -- loopback, no internet involved.
|
|
//
|
|
// OFFLINE BY DESIGN: this host only ever reads files under tools/bubble-images. It never dials out.
|
|
// A missing picture is a 404 logged into access.log, never an upstream fetch. The 55 pictures were
|
|
// warmed once and are meant to ship with the server folder.
|
|
//
|
|
// 眼界 (Tuite) 2026-09-25: two more object namespaces ride the very same endpoint, because the
|
|
// client always builds "https://" + Bucket + "." + Endpoint + "/" + ObjectKey from the single
|
|
// SyncOssBucketInfo(64833344) push — there is no per-app bucket to configure.
|
|
// * "Tuite/<name>.png" -> tools/bubble-images/Tuite/<name>
|
|
// TuiteImageConfig only carries a key, never a CDN url, so these cannot be proxied; they are
|
|
// real files on disk (176 of them, four of which are frames pulled from the official video).
|
|
// * "<pid>/PersonalTuite_comment_<id>.json" -> tools/bubble-images/tuite-comments/<pid>/...
|
|
// SocialNetWorkUtils.lua:1017 calls CheckFileExists (a HEAD) first and only downloads when it
|
|
// answers, so HEAD must return 200 too. A 404 here is silent: the client falls back to
|
|
// BuildInitialYanjieComments and the post simply shows no comments at all.
|
|
// DecodeYanjieCommentFile (lua:950) tries plain JSON before the bundled AES key, so serving the
|
|
// files verbatim as application/json is enough — no encryption involved.
|
|
//
|
|
// The second listener is plain http and exists for exactly one file: Windows/SChannel wants the CRL
|
|
// advertised by the leaf certificate before it accepts the chain, and it insists on http for it.
|
|
// Keep serving /root.crl or every handshake fails with CRYPT_E_REVOCATION_OFFLINE (0x80092013),
|
|
// whose symptom is "client reports SSL failure while this host logs zero requests".
|
|
//
|
|
// Usage:
|
|
// node tools/bubble-image-host.js # https 8443 + http 8099
|
|
// node tools/bubble-image-host.js --tls 8443 --http 8099
|
|
// node tools/bubble-image-host.js --tls-only 8443
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const http = require("http");
|
|
const https = require("https");
|
|
|
|
const ROOT = path.resolve(__dirname, "..");
|
|
const WEB = path.join(ROOT, "tools", "bubble-images");
|
|
const CACHE = path.join(WEB, "Bubble");
|
|
const TUITE = path.join(WEB, "Tuite");
|
|
const COMMENTS = path.join(WEB, "tuite-comments");
|
|
const TLS_DIR = path.join(WEB, "tls");
|
|
const ACCESS_LOG = path.join(WEB, "access.log");
|
|
|
|
const DEFAULT_TLS_PORT = 8443;
|
|
const DEFAULT_HTTP_PORT = 8099;
|
|
|
|
function parseArgs(argv) {
|
|
const args = { tls: null, http: null };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const token = argv[i];
|
|
if (token === "--tls" || token === "--https" || token === "--tls-only") {
|
|
args.tls = Number(argv[++i]);
|
|
if (token === "--tls-only") args.http = 0;
|
|
} else if (token === "--http") {
|
|
args.http = Number(argv[++i]);
|
|
}
|
|
}
|
|
if (args.tls === null && args.http === null) {
|
|
args.tls = DEFAULT_TLS_PORT;
|
|
args.http = DEFAULT_HTTP_PORT;
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function mimeFor(file) {
|
|
const ext = path.extname(file).toLowerCase();
|
|
if (ext === ".png") return "image/png";
|
|
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
if (ext === ".webp") return "image/webp";
|
|
if (ext === ".gif") return "image/gif";
|
|
if (ext === ".json") return "application/json";
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
function logLine(status, method, urlPath, note) {
|
|
const stamp = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
const line = `${stamp}\t${status}\t${method}\t${urlPath}\t${note}`;
|
|
try {
|
|
fs.appendFileSync(ACCESS_LOG, line + "\n", "utf8");
|
|
} catch (err) {
|
|
/* a full or locked log must never take the host down */
|
|
}
|
|
console.log(line);
|
|
}
|
|
|
|
// Whatever shape arrives -- /Bubble/x.png, /<bucket>/Bubble/x.png, /<bucket>/Bubble/x.png?x-oss-...
|
|
// -- the answer is the same cached file. Only the basename is ever used, so a traversal attempt
|
|
// ("../../evil.png") cannot escape the cache directory.
|
|
function resolveCachedFile(rawPath) {
|
|
let frag = "";
|
|
try {
|
|
frag = decodeURIComponent(rawPath.split("?")[0]);
|
|
} catch (err) {
|
|
frag = rawPath.split("?")[0];
|
|
}
|
|
frag = frag.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
if (!frag) return null;
|
|
|
|
const candidates = [frag, path.basename(frag)];
|
|
const parts = frag.split("/");
|
|
for (let i = 1; i < parts.length; i++) candidates.push(parts.slice(i).join("/"));
|
|
|
|
for (const candidate of candidates) {
|
|
if (!candidate || candidate.indexOf("..") >= 0) continue;
|
|
const direct = path.join(CACHE, candidate);
|
|
if (direct.startsWith(CACHE) && isFile(direct)) return direct;
|
|
const flat = path.join(CACHE, path.basename(candidate));
|
|
if (isFile(flat)) return flat;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function urlFragment(rawPath) {
|
|
let frag = "";
|
|
try {
|
|
frag = decodeURIComponent(rawPath.split("?")[0]);
|
|
} catch (err) {
|
|
frag = rawPath.split("?")[0];
|
|
}
|
|
return frag.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
}
|
|
|
|
// 眼界 (Tuite) covers. The key is "Tuite/<name>.png" but it arrives with whatever prefix the client
|
|
// built (bucket, personal directory), so — same rule as the Bubble cache — only the basename counts.
|
|
function resolveTuiteFile(rawPath) {
|
|
const frag = urlFragment(rawPath);
|
|
if (!frag) return null;
|
|
const file = path.join(TUITE, path.basename(frag));
|
|
if (!file.startsWith(TUITE) || !isFile(file)) return null;
|
|
try {
|
|
// A zero-byte placeholder renders as "no picture" while still logging a 200 — treat it as a miss.
|
|
if (fs.statSync(file).size === 0) return null;
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
return file;
|
|
}
|
|
|
|
// 眼界 comments: "<pid>/PersonalTuite_comment_<tuiteId>.json". The pid directory is just what the
|
|
// server told the client to use, so a mismatch must not blank every comment list: fall back to a
|
|
// scan over the pid directories and answer with the same file name wherever it lives.
|
|
function resolveCommentFile(rawPath) {
|
|
const frag = urlFragment(rawPath);
|
|
if (!frag) return null;
|
|
const name = path.basename(frag);
|
|
if (!name.startsWith("PersonalTuite_comment_") || !name.endsWith(".json")) return null;
|
|
const parts = frag.split("/");
|
|
const pid = parts.length >= 2 ? parts[parts.length - 2] : "";
|
|
if (pid && pid.indexOf("..") < 0) {
|
|
const direct = path.join(COMMENTS, pid, name);
|
|
if (direct.startsWith(COMMENTS) && isFile(direct)) return direct;
|
|
}
|
|
try {
|
|
for (const entry of fs.readdirSync(COMMENTS)) {
|
|
const candidate = path.join(COMMENTS, entry, name);
|
|
if (candidate.startsWith(COMMENTS) && isFile(candidate)) return candidate;
|
|
}
|
|
} catch (err) {
|
|
/* no comment directory at all - the client falls back to its built-in comments */
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isFile(file) {
|
|
try {
|
|
return fs.statSync(file).isFile();
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function countFiles(dir) {
|
|
try {
|
|
return fs.readdirSync(dir).filter((n) => isFile(path.join(dir, n))).length;
|
|
} catch (err) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// Comments are stored per pid: tools/bubble-images/tuite-comments/<pid>/PersonalTuite_comment_*.json
|
|
function countFilesRecursive(dir) {
|
|
let total = 0;
|
|
let entries = [];
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch (err) {
|
|
return 0;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) total += countFilesRecursive(full);
|
|
else if (entry.isFile()) total += 1;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function handle(req, res) {
|
|
const urlPath = req.url || "/";
|
|
|
|
if (urlPath.split("?")[0] === "/root.crl") {
|
|
const crl = path.join(TLS_DIR, "root.crl");
|
|
if (!isFile(crl)) {
|
|
logLine(404, req.method, urlPath, "no-crl");
|
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
res.end("crl missing");
|
|
return;
|
|
}
|
|
const body = fs.readFileSync(crl);
|
|
logLine(200, req.method, urlPath, "root.crl");
|
|
res.writeHead(200, {
|
|
"Content-Type": "application/pkix-crl",
|
|
"Content-Length": body.length,
|
|
"Cache-Control": "public, max-age=3600",
|
|
});
|
|
res.end(body);
|
|
return;
|
|
}
|
|
|
|
// 眼界 comments come first: they are JSON, and answering a comment request with a picture body
|
|
// would be dropped just as silently as a 404 would.
|
|
const commentFile = resolveCommentFile(urlPath);
|
|
if (commentFile) {
|
|
const commentBody = fs.readFileSync(commentFile);
|
|
logLine(200, req.method, urlPath, `comment ${path.basename(commentFile)}`);
|
|
res.writeHead(200, {
|
|
"Content-Type": "application/json",
|
|
"Content-Length": commentBody.length,
|
|
"Cache-Control": "public, max-age=86400",
|
|
"Access-Control-Allow-Origin": "*",
|
|
});
|
|
res.end(req.method === "HEAD" ? undefined : commentBody);
|
|
return;
|
|
}
|
|
|
|
let file = resolveCachedFile(urlPath);
|
|
let fileRoot = CACHE;
|
|
if (!file) {
|
|
// Not a Bubble key. 眼界 covers live in their own folder; anything else really is missing.
|
|
file = resolveTuiteFile(urlPath);
|
|
fileRoot = TUITE;
|
|
}
|
|
if (!file) {
|
|
logLine(404, req.method, urlPath, "no-key-match");
|
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
res.end("no such object key");
|
|
return;
|
|
}
|
|
|
|
const body = fs.readFileSync(file);
|
|
logLine(200, req.method, urlPath, path.relative(fileRoot, file).replace(/\\/g, "/"));
|
|
res.writeHead(200, {
|
|
"Content-Type": mimeFor(file),
|
|
"Content-Length": body.length,
|
|
"Cache-Control": "public, max-age=86400",
|
|
"Access-Control-Allow-Origin": "*",
|
|
});
|
|
res.end(req.method === "HEAD" ? undefined : body);
|
|
}
|
|
|
|
function startListener(port, tlsOptions) {
|
|
const server = tlsOptions
|
|
? https.createServer(tlsOptions, handle)
|
|
: http.createServer(handle);
|
|
server.on("error", (err) => {
|
|
console.error(
|
|
`[bubble-image-host] cannot listen on ${tlsOptions ? "https" : "http"}://127.0.0.1:${port}: ${err.message}`
|
|
);
|
|
if (err.code === "EADDRINUSE") {
|
|
console.error(
|
|
"[bubble-image-host] another instance is already running - pictures will still work, " +
|
|
"this second copy is exiting."
|
|
);
|
|
}
|
|
process.exit(1);
|
|
});
|
|
server.listen(port, "127.0.0.1", () => {
|
|
console.log(`[bubble-image-host] serving on ${tlsOptions ? "https" : "http"}://127.0.0.1:${port}`);
|
|
});
|
|
return server;
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
fs.mkdirSync(CACHE, { recursive: true });
|
|
|
|
const cached = countFiles(CACHE);
|
|
const tuite = countFiles(TUITE);
|
|
const comments = countFilesRecursive(COMMENTS);
|
|
console.log(
|
|
`[bubble-image-host] bubble=${cached} tuite=${tuite} comments=${comments} ` +
|
|
`root=${WEB} access.log=${ACCESS_LOG}`
|
|
);
|
|
|
|
let tlsOptions = null;
|
|
if (args.tls) {
|
|
const cert = path.join(TLS_DIR, "server.cert.pem");
|
|
const key = path.join(TLS_DIR, "server.key.pem");
|
|
if (!isFile(cert) || !isFile(key)) {
|
|
console.error(`[bubble-image-host] missing certificate pair under ${TLS_DIR}`);
|
|
process.exit(1);
|
|
}
|
|
tlsOptions = { cert: fs.readFileSync(cert), key: fs.readFileSync(key) };
|
|
}
|
|
|
|
if (args.tls) startListener(args.tls, tlsOptions);
|
|
if (args.http) startListener(args.http, null);
|
|
|
|
const stop = () => process.exit(0);
|
|
process.on("SIGINT", stop);
|
|
process.on("SIGTERM", stop);
|
|
}
|
|
|
|
main();
|