"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/.png" -> tools/bubble-images/Tuite/ // 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). // * "/PersonalTuite_comment_.json" -> tools/bubble-images/tuite-comments//... // 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, //Bubble/x.png, //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/.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: "/PersonalTuite_comment_.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//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();