Files
Ananta-ps/tools/bubble-image-host.js
T

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();