Spaces:
Runtime error
Runtime error
Update index.js
Browse files
index.js
CHANGED
|
@@ -12,7 +12,6 @@ const app = express();
|
|
| 12 |
const PORT = 3000;
|
| 13 |
|
| 14 |
// ββ DOWNLOAD HASH βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 15 |
-
// A stable hash generated once at startup. Share this URL to download the APK.
|
| 16 |
const DOWNLOAD_HASH = crypto.randomBytes(16).toString('hex');
|
| 17 |
|
| 18 |
app.use(express.json({ limit: '200mb' }));
|
|
@@ -28,8 +27,7 @@ const upload = multer({
|
|
| 28 |
}
|
| 29 |
});
|
| 30 |
|
| 31 |
-
// ββ KEYSTORE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
-
// We generate a debug keystore once and reuse it for all re-signs.
|
| 33 |
const KEYSTORE_PATH = path.join(__dirname, 'debug.keystore');
|
| 34 |
const KEY_ALIAS = 'supercity';
|
| 35 |
const KEY_PASS = 'supercity123';
|
|
@@ -49,14 +47,11 @@ function ensureKeystore() {
|
|
| 49 |
'-keypass', KEY_PASS,
|
| 50 |
'-dname', 'CN=SuperCityEditor, OU=Mod, O=Local, L=Local, S=Local, C=US',
|
| 51 |
], { encoding: 'utf8' });
|
| 52 |
-
|
| 53 |
-
if (result.status !== 0) {
|
| 54 |
-
throw new Error('keytool failed: ' + (result.stderr || result.stdout));
|
| 55 |
-
}
|
| 56 |
console.log('Keystore created at', KEYSTORE_PATH);
|
| 57 |
}
|
| 58 |
|
| 59 |
-
// ββ TOOL CHECK βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 60 |
function checkTools() {
|
| 61 |
const tools = { keytool: false, zipalign: false, apksigner: false, jarsigner: false };
|
| 62 |
for (const t of Object.keys(tools)) {
|
|
@@ -65,15 +60,80 @@ function checkTools() {
|
|
| 65 |
return tools;
|
| 66 |
}
|
| 67 |
|
| 68 |
-
// ββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
app.get('/download-link', (req, res) => {
|
| 72 |
const patched = fs.existsSync(path.join(__dirname, 'supercity_patched.apk'));
|
| 73 |
res.json({ url: `/download/${DOWNLOAD_HASH}`, patched });
|
| 74 |
});
|
| 75 |
|
| 76 |
-
// GET /download/:hash
|
| 77 |
app.get('/download/:hash', (req, res) => {
|
| 78 |
if (req.params.hash !== DOWNLOAD_HASH) return res.status(403).json({ error: 'Invalid download link' });
|
| 79 |
const patched = path.join(__dirname, 'supercity_patched.apk');
|
|
@@ -83,29 +143,24 @@ app.get('/download/:hash', (req, res) => {
|
|
| 83 |
fs.createReadStream(patched).pipe(res);
|
| 84 |
});
|
| 85 |
|
| 86 |
-
// GET /tools
|
| 87 |
-
app.get('/tools', (req, res) =>
|
| 88 |
-
res.json(checkTools());
|
| 89 |
-
});
|
| 90 |
|
| 91 |
-
// GET /session/:token
|
| 92 |
app.get('/session/:token', (req, res) => {
|
| 93 |
-
const
|
| 94 |
-
|
| 95 |
-
: path.join(os.tmpdir(), req.params.token);
|
| 96 |
-
const alive = fs.existsSync(path.join(sessionDir, 'original.apk'));
|
| 97 |
res.json({ alive });
|
| 98 |
});
|
| 99 |
|
| 100 |
-
// GET /debug
|
| 101 |
app.get('/debug', (req, res) => {
|
| 102 |
const exists = fs.existsSync(BUNDLED_APK);
|
| 103 |
if (!exists) return res.json({ exists: false, path: BUNDLED_APK });
|
| 104 |
-
|
| 105 |
const stat = fs.statSync(BUNDLED_APK);
|
| 106 |
try {
|
| 107 |
-
const zip
|
| 108 |
-
const entries
|
| 109 |
const hasChars = entries.some(e => e.replace(/\\/g, '/').toLowerCase() === 'assets/characters.txt');
|
| 110 |
res.json({ exists: true, path: BUNDLED_APK, sizeBytes: stat.size, entryCount: entries.length, hasCharactersTxt: hasChars, entries });
|
| 111 |
} catch (err) {
|
|
@@ -113,149 +168,172 @@ app.get('/debug', (req, res) => {
|
|
| 113 |
}
|
| 114 |
});
|
| 115 |
|
| 116 |
-
// GET /init
|
| 117 |
-
const BUNDLED_APK = path.join(__dirname, 'super-city-2.010.64-mod-t-5play.apk');
|
| 118 |
-
const PERSISTENT_DIR = path.join(os.tmpdir(), 'supercity-persistent');
|
| 119 |
-
|
| 120 |
-
// Ensure persistent session dir exists with the APK linked/copied once
|
| 121 |
-
function ensurePersistentSession() {
|
| 122 |
-
if (!fs.existsSync(PERSISTENT_DIR)) fs.mkdirSync(PERSISTENT_DIR, { recursive: true });
|
| 123 |
-
const dest = path.join(PERSISTENT_DIR, 'original.apk');
|
| 124 |
-
if (!fs.existsSync(dest)) {
|
| 125 |
-
fs.copyFileSync(BUNDLED_APK, dest);
|
| 126 |
-
fs.writeFileSync(path.join(PERSISTENT_DIR, 'original_name.txt'), path.basename(BUNDLED_APK));
|
| 127 |
-
}
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
app.get('/init', (req, res) => {
|
| 131 |
-
if (!fs.existsSync(BUNDLED_APK)) {
|
| 132 |
-
return res.status(404).json({ error: 'Bundled APK not found on server' });
|
| 133 |
-
}
|
| 134 |
try {
|
| 135 |
ensurePersistentSession();
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
);
|
| 140 |
-
if (!entry) {
|
| 141 |
-
const names = zip.getEntries().map(e => e.entryName).join(', ');
|
| 142 |
-
return res.status(400).json({ error: `assets/characters.txt not found. Entries: ${names}` });
|
| 143 |
-
}
|
| 144 |
-
const content = entry.getData().toString('utf8');
|
| 145 |
-
// Use a stable token derived from the APK filename β same across page reloads
|
| 146 |
-
const token = 'persistent';
|
| 147 |
-
res.json({ token, characters: content });
|
| 148 |
} catch (err) {
|
| 149 |
res.status(500).json({ error: err.message });
|
| 150 |
}
|
| 151 |
});
|
| 152 |
|
| 153 |
-
// POST /upload
|
| 154 |
app.post('/upload', upload.single('apk'), (req, res) => {
|
| 155 |
try {
|
| 156 |
-
const apkPath
|
| 157 |
-
const
|
| 158 |
-
const
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
// Keep the apk on disk temporarily, send back a session token
|
| 165 |
-
const token = Date.now() + '_' + Math.random().toString(36).slice(2);
|
| 166 |
-
const sessionDir = path.join(os.tmpdir(), token);
|
| 167 |
-
fs.mkdirSync(sessionDir);
|
| 168 |
-
fs.renameSync(apkPath, path.join(sessionDir, 'original.apk'));
|
| 169 |
-
fs.writeFileSync(path.join(sessionDir, 'original_name.txt'), req.file.originalname);
|
| 170 |
-
res.json({ token, characters: content });
|
| 171 |
} catch (err) {
|
| 172 |
try { fs.unlinkSync(req.file.path); } catch {}
|
| 173 |
res.status(500).json({ error: err.message });
|
| 174 |
}
|
| 175 |
});
|
| 176 |
|
| 177 |
-
// POST /upload-url
|
| 178 |
-
app.post('/upload-url',
|
| 179 |
const { url } = req.body;
|
| 180 |
-
if (!url || !url.toLowerCase().endsWith('.apk')) {
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
}
|
|
|
|
| 183 |
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
try {
|
| 190 |
-
const
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
| 205 |
} catch (err) {
|
| 206 |
-
|
| 207 |
-
const msg = err.response ? `Remote returned ${err.response.status}` : err.message;
|
| 208 |
-
res.status(500).json({ error: msg });
|
| 209 |
}
|
| 210 |
});
|
| 211 |
|
| 212 |
-
// POST /patch
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
|
|
|
| 216 |
|
| 217 |
-
const
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
const
|
| 221 |
-
const
|
| 222 |
-
const alignedApk = path.join(sessionDir, 'patched_aligned.apk');
|
| 223 |
-
const signedApk = path.join(sessionDir, 'patched_signed.apk');
|
| 224 |
|
| 225 |
-
if (!fs.existsSync(originalApk)) {
|
| 226 |
-
return res.status(404).json({ error: 'Session expired or not found. Re-upload the APK.' });
|
| 227 |
-
}
|
| 228 |
|
| 229 |
try {
|
| 230 |
ensureKeystore();
|
| 231 |
const tools = checkTools();
|
| 232 |
|
| 233 |
-
// 1.
|
| 234 |
-
|
| 235 |
-
// STORED (resources.arsc, .so libs, etc.) β causing "App not installed".
|
| 236 |
-
const workDir = path.join(sessionDir, 'apk_work');
|
| 237 |
if (fs.existsSync(workDir)) fs.rmSync(workDir, { recursive: true, force: true });
|
| 238 |
fs.mkdirSync(workDir, { recursive: true });
|
| 239 |
|
| 240 |
-
// Extract APK
|
| 241 |
const unzipResult = spawnSync('unzip', ['-q', '-o', originalApk, '-d', workDir], { encoding: 'utf8' });
|
| 242 |
if (unzipResult.status !== 0) throw new Error('unzip failed: ' + unzipResult.stderr);
|
| 243 |
|
| 244 |
-
// Remove
|
| 245 |
const metaInfDir = path.join(workDir, 'META-INF');
|
| 246 |
if (fs.existsSync(metaInfDir)) fs.rmSync(metaInfDir, { recursive: true, force: true });
|
| 247 |
|
| 248 |
-
//
|
| 249 |
-
const
|
|
|
|
| 250 |
fs.mkdirSync(path.dirname(charsDest), { recursive: true });
|
| 251 |
-
fs.writeFileSync(charsDest,
|
| 252 |
|
| 253 |
-
// Repack: compress most files,
|
| 254 |
-
// Step A β pack everything with compression
|
| 255 |
const zipResult = spawnSync('zip', ['-r', '-9', patchedApk, '.'], { cwd: workDir, encoding: 'utf8' });
|
| 256 |
if (zipResult.status !== 0) throw new Error('zip failed: ' + zipResult.stderr);
|
| 257 |
|
| 258 |
-
// Step B β find files that must stay STORED and re-add them uncompressed
|
| 259 |
const storedPatterns = ['*.so', '*.arsc', '*.png', '*.gif', '*.jpg', '*.jpeg',
|
| 260 |
'*.webp', '*.wav', '*.mp3', '*.ogg', '*.aac', '*.ttf', '*.otf'];
|
| 261 |
const storedFiles = storedPatterns.flatMap(pat => {
|
|
@@ -267,48 +345,38 @@ app.post('/patch', express.json({ limit: '10mb' }), (req, res) => {
|
|
| 267 |
if (sr.status !== 0 && sr.status !== 12) console.warn('zip -0 warning:', sr.stderr);
|
| 268 |
}
|
| 269 |
|
| 270 |
-
//
|
| 271 |
let toSign = patchedApk;
|
| 272 |
if (tools.zipalign) {
|
| 273 |
const zr = spawnSync('zipalign', ['-f', '-v', '4', patchedApk, alignedApk], { encoding: 'utf8' });
|
| 274 |
if (zr.status === 0) toSign = alignedApk;
|
| 275 |
-
else console.warn('zipalign failed, skipping
|
| 276 |
}
|
| 277 |
|
| 278 |
-
//
|
| 279 |
if (tools.apksigner) {
|
| 280 |
const sr = spawnSync('apksigner', [
|
| 281 |
-
'sign',
|
| 282 |
-
'--ks',
|
| 283 |
-
'--ks-
|
| 284 |
-
'--key-pass', `pass:${KEY_PASS}`,
|
| 285 |
-
'--ks-key-alias', KEY_ALIAS,
|
| 286 |
-
'--out', signedApk,
|
| 287 |
-
toSign
|
| 288 |
], { encoding: 'utf8' });
|
| 289 |
if (sr.status !== 0) throw new Error('apksigner failed: ' + sr.stderr);
|
| 290 |
} else if (tools.jarsigner) {
|
| 291 |
-
// jarsigner signs in-place; copy first
|
| 292 |
fs.copyFileSync(toSign, signedApk);
|
| 293 |
const sr = spawnSync('jarsigner', [
|
| 294 |
-
'-verbose',
|
| 295 |
-
'-
|
| 296 |
-
|
| 297 |
-
'-keystore', KEYSTORE_PATH,
|
| 298 |
-
'-storepass', STORE_PASS,
|
| 299 |
-
'-keypass', KEY_PASS,
|
| 300 |
-
signedApk,
|
| 301 |
-
KEY_ALIAS
|
| 302 |
], { encoding: 'utf8' });
|
| 303 |
if (sr.status !== 0) throw new Error('jarsigner failed: ' + sr.stderr);
|
| 304 |
} else {
|
| 305 |
-
throw new Error('No signing tool found. Install JDK
|
| 306 |
}
|
| 307 |
|
| 308 |
-
//
|
| 309 |
-
const
|
| 310 |
-
fs.copyFileSync(signedApk,
|
| 311 |
-
try { fs.rmSync(
|
| 312 |
res.json({ url: `/download/${DOWNLOAD_HASH}` });
|
| 313 |
|
| 314 |
} catch (err) {
|
|
@@ -318,9 +386,7 @@ app.post('/patch', express.json({ limit: '10mb' }), (req, res) => {
|
|
| 318 |
});
|
| 319 |
|
| 320 |
// ββ ROOT ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 321 |
-
app.get('/', (req, res) =>
|
| 322 |
-
res.sendFile(path.join(__dirname, 'index.html'));
|
| 323 |
-
});
|
| 324 |
|
| 325 |
// ββ START βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 326 |
app.listen(7860, () => {
|
|
|
|
| 12 |
const PORT = 3000;
|
| 13 |
|
| 14 |
// ββ DOWNLOAD HASH βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 15 |
const DOWNLOAD_HASH = crypto.randomBytes(16).toString('hex');
|
| 16 |
|
| 17 |
app.use(express.json({ limit: '200mb' }));
|
|
|
|
| 27 |
}
|
| 28 |
});
|
| 29 |
|
| 30 |
+
// ββ KEYSTORE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 31 |
const KEYSTORE_PATH = path.join(__dirname, 'debug.keystore');
|
| 32 |
const KEY_ALIAS = 'supercity';
|
| 33 |
const KEY_PASS = 'supercity123';
|
|
|
|
| 47 |
'-keypass', KEY_PASS,
|
| 48 |
'-dname', 'CN=SuperCityEditor, OU=Mod, O=Local, L=Local, S=Local, C=US',
|
| 49 |
], { encoding: 'utf8' });
|
| 50 |
+
if (result.status !== 0) throw new Error('keytool failed: ' + (result.stderr || result.stdout));
|
|
|
|
|
|
|
|
|
|
| 51 |
console.log('Keystore created at', KEYSTORE_PATH);
|
| 52 |
}
|
| 53 |
|
| 54 |
+
// ββ TOOL CHECK ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
function checkTools() {
|
| 56 |
const tools = { keytool: false, zipalign: false, apksigner: false, jarsigner: false };
|
| 57 |
for (const t of Object.keys(tools)) {
|
|
|
|
| 60 |
return tools;
|
| 61 |
}
|
| 62 |
|
| 63 |
+
// ββ CHARACTER FILE HELPERS ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
// characters.txt is one line: records separated by `3`, fields by `|`
|
| 65 |
+
// We never send the raw blob to the frontend β all edits happen server-side.
|
| 66 |
+
|
| 67 |
+
function sessionDir(token) {
|
| 68 |
+
return token === 'persistent'
|
| 69 |
+
? PERSISTENT_DIR
|
| 70 |
+
: path.join(os.tmpdir(), token);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function charsPath(token) {
|
| 74 |
+
return path.join(sessionDir(token), 'characters.txt');
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// Read the raw characters string from disk (guaranteed untouched)
|
| 78 |
+
function readChars(token) {
|
| 79 |
+
const p = charsPath(token);
|
| 80 |
+
if (!fs.existsSync(p)) throw new Error('Characters file not found. Re-upload the APK.');
|
| 81 |
+
return fs.readFileSync(p, 'utf8');
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Write back to disk β never goes through the browser
|
| 85 |
+
function writeChars(token, raw) {
|
| 86 |
+
fs.writeFileSync(charsPath(token), raw, 'utf8');
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// Parse raw string into array of field arrays
|
| 90 |
+
function parseChars(raw) {
|
| 91 |
+
return raw.split('3').map(r => r.split('|'));
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// Serialize back β exact inverse of parse, no newlines ever added
|
| 95 |
+
function serializeChars(records) {
|
| 96 |
+
return records.map(r => r.join('|')).join('3');
|
| 97 |
+
}
|
| 98 |
|
| 99 |
+
// Extract characters.txt from an APK and save it to the session dir
|
| 100 |
+
function extractCharsFromApk(apkPath, token) {
|
| 101 |
+
const zip = new AdmZip(apkPath);
|
| 102 |
+
const entry = zip.getEntries().find(e =>
|
| 103 |
+
e.entryName.replace(/\\/g, '/').toLowerCase() === 'assets/characters.txt'
|
| 104 |
+
);
|
| 105 |
+
if (!entry) {
|
| 106 |
+
const names = zip.getEntries().map(e => e.entryName).join(', ');
|
| 107 |
+
throw new Error(`assets/characters.txt not found in APK. Entries: ${names}`);
|
| 108 |
+
}
|
| 109 |
+
// Write raw bytes straight to disk β no string conversion that could add newlines
|
| 110 |
+
const raw = entry.getData().toString('utf8');
|
| 111 |
+
writeChars(token, raw);
|
| 112 |
+
return raw;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
// ββ PERSISTENT SESSION ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 116 |
+
const BUNDLED_APK = path.join(__dirname, 'super-city-2.010.64-mod-t-5play.apk');
|
| 117 |
+
const PERSISTENT_DIR = path.join(os.tmpdir(), 'supercity-persistent');
|
| 118 |
+
|
| 119 |
+
function ensurePersistentSession() {
|
| 120 |
+
if (!fs.existsSync(PERSISTENT_DIR)) fs.mkdirSync(PERSISTENT_DIR, { recursive: true });
|
| 121 |
+
const dest = path.join(PERSISTENT_DIR, 'original.apk');
|
| 122 |
+
if (!fs.existsSync(dest)) {
|
| 123 |
+
fs.copyFileSync(BUNDLED_APK, dest);
|
| 124 |
+
fs.writeFileSync(path.join(PERSISTENT_DIR, 'original_name.txt'), path.basename(BUNDLED_APK));
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// ββ ROUTES ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 129 |
+
|
| 130 |
+
// GET /download-link
|
| 131 |
app.get('/download-link', (req, res) => {
|
| 132 |
const patched = fs.existsSync(path.join(__dirname, 'supercity_patched.apk'));
|
| 133 |
res.json({ url: `/download/${DOWNLOAD_HASH}`, patched });
|
| 134 |
});
|
| 135 |
|
| 136 |
+
// GET /download/:hash
|
| 137 |
app.get('/download/:hash', (req, res) => {
|
| 138 |
if (req.params.hash !== DOWNLOAD_HASH) return res.status(403).json({ error: 'Invalid download link' });
|
| 139 |
const patched = path.join(__dirname, 'supercity_patched.apk');
|
|
|
|
| 143 |
fs.createReadStream(patched).pipe(res);
|
| 144 |
});
|
| 145 |
|
| 146 |
+
// GET /tools
|
| 147 |
+
app.get('/tools', (req, res) => res.json(checkTools()));
|
|
|
|
|
|
|
| 148 |
|
| 149 |
+
// GET /session/:token
|
| 150 |
app.get('/session/:token', (req, res) => {
|
| 151 |
+
const dir = sessionDir(req.params.token);
|
| 152 |
+
const alive = fs.existsSync(path.join(dir, 'original.apk'));
|
|
|
|
|
|
|
| 153 |
res.json({ alive });
|
| 154 |
});
|
| 155 |
|
| 156 |
+
// GET /debug
|
| 157 |
app.get('/debug', (req, res) => {
|
| 158 |
const exists = fs.existsSync(BUNDLED_APK);
|
| 159 |
if (!exists) return res.json({ exists: false, path: BUNDLED_APK });
|
|
|
|
| 160 |
const stat = fs.statSync(BUNDLED_APK);
|
| 161 |
try {
|
| 162 |
+
const zip = new AdmZip(BUNDLED_APK);
|
| 163 |
+
const entries = zip.getEntries().map(e => e.entryName);
|
| 164 |
const hasChars = entries.some(e => e.replace(/\\/g, '/').toLowerCase() === 'assets/characters.txt');
|
| 165 |
res.json({ exists: true, path: BUNDLED_APK, sizeBytes: stat.size, entryCount: entries.length, hasCharactersTxt: hasChars, entries });
|
| 166 |
} catch (err) {
|
|
|
|
| 168 |
}
|
| 169 |
});
|
| 170 |
|
| 171 |
+
// GET /init β load bundled APK, extract characters to disk, return token only
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
app.get('/init', (req, res) => {
|
| 173 |
+
if (!fs.existsSync(BUNDLED_APK)) return res.status(404).json({ error: 'Bundled APK not found on server' });
|
|
|
|
|
|
|
| 174 |
try {
|
| 175 |
ensurePersistentSession();
|
| 176 |
+
// Always re-extract from the bundled APK so characters.txt on disk is pristine
|
| 177 |
+
extractCharsFromApk(BUNDLED_APK, 'persistent');
|
| 178 |
+
res.json({ token: 'persistent' });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
} catch (err) {
|
| 180 |
res.status(500).json({ error: err.message });
|
| 181 |
}
|
| 182 |
});
|
| 183 |
|
| 184 |
+
// POST /upload β receive APK, extract characters to disk, return token only
|
| 185 |
app.post('/upload', upload.single('apk'), (req, res) => {
|
| 186 |
try {
|
| 187 |
+
const apkPath = req.file.path;
|
| 188 |
+
const token = Date.now() + '_' + Math.random().toString(36).slice(2);
|
| 189 |
+
const dir = path.join(os.tmpdir(), token);
|
| 190 |
+
fs.mkdirSync(dir);
|
| 191 |
+
fs.renameSync(apkPath, path.join(dir, 'original.apk'));
|
| 192 |
+
fs.writeFileSync(path.join(dir, 'original_name.txt'), req.file.originalname);
|
| 193 |
+
extractCharsFromApk(path.join(dir, 'original.apk'), token);
|
| 194 |
+
res.json({ token });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
} catch (err) {
|
| 196 |
try { fs.unlinkSync(req.file.path); } catch {}
|
| 197 |
res.status(500).json({ error: err.message });
|
| 198 |
}
|
| 199 |
});
|
| 200 |
|
| 201 |
+
// POST /upload-url β fetch APK from URL, extract characters to disk, return token only
|
| 202 |
+
app.post('/upload-url', async (req, res) => {
|
| 203 |
const { url } = req.body;
|
| 204 |
+
if (!url || !url.toLowerCase().endsWith('.apk')) return res.status(400).json({ error: 'URL must point to a .apk file' });
|
| 205 |
+
const token = Date.now() + '_' + Math.random().toString(36).slice(2);
|
| 206 |
+
const dir = path.join(os.tmpdir(), token);
|
| 207 |
+
fs.mkdirSync(dir);
|
| 208 |
+
const apkPath = path.join(dir, 'original.apk');
|
| 209 |
+
try {
|
| 210 |
+
const response = await axios.get(url, { responseType: 'arraybuffer', maxContentLength: 200 * 1024 * 1024 });
|
| 211 |
+
fs.writeFileSync(apkPath, response.data);
|
| 212 |
+
fs.writeFileSync(path.join(dir, 'original_name.txt'), url.split('/').pop());
|
| 213 |
+
extractCharsFromApk(apkPath, token);
|
| 214 |
+
res.json({ token });
|
| 215 |
+
} catch (err) {
|
| 216 |
+
fs.rmSync(dir, { recursive: true, force: true });
|
| 217 |
+
res.status(500).json({ error: err.response ? `Remote returned ${err.response.status}` : err.message });
|
| 218 |
}
|
| 219 |
+
});
|
| 220 |
|
| 221 |
+
// GET /characters/:token β return parsed character list for the UI
|
| 222 |
+
// Each character: { index, name, alias, fields: [...] }
|
| 223 |
+
// The frontend NEVER sees the raw blob β only structured data it needs to render.
|
| 224 |
+
app.get('/characters/:token', (req, res) => {
|
| 225 |
+
try {
|
| 226 |
+
const raw = readChars(req.params.token);
|
| 227 |
+
const records = parseChars(raw);
|
| 228 |
+
const list = records.map((fields, index) => ({
|
| 229 |
+
index,
|
| 230 |
+
name: fields[0] || '',
|
| 231 |
+
alias: fields[1] || '',
|
| 232 |
+
fields, // full field array so the UI can render all editable values
|
| 233 |
+
}));
|
| 234 |
+
res.json({ count: list.length, characters: list });
|
| 235 |
+
} catch (err) {
|
| 236 |
+
res.status(404).json({ error: err.message });
|
| 237 |
+
}
|
| 238 |
+
});
|
| 239 |
|
| 240 |
+
// POST /edit β edit one field of one character, server-side only
|
| 241 |
+
// Body: { token, characterIndex, fieldIndex, value }
|
| 242 |
+
app.post('/edit', (req, res) => {
|
| 243 |
+
const { token, characterIndex, fieldIndex, value } = req.body;
|
| 244 |
+
if (token === undefined || characterIndex === undefined || fieldIndex === undefined || value === undefined) {
|
| 245 |
+
return res.status(400).json({ error: 'Missing token, characterIndex, fieldIndex, or value' });
|
| 246 |
+
}
|
| 247 |
try {
|
| 248 |
+
const raw = readChars(token);
|
| 249 |
+
const records = parseChars(raw);
|
| 250 |
+
const ci = Number(characterIndex);
|
| 251 |
+
const fi = Number(fieldIndex);
|
| 252 |
+
if (!records[ci]) return res.status(400).json({ error: `Character index ${ci} out of range` });
|
| 253 |
+
if (fi < 0 || fi >= records[ci].length) return res.status(400).json({ error: `Field index ${fi} out of range` });
|
| 254 |
+
records[ci][fi] = String(value);
|
| 255 |
+
writeChars(token, serializeChars(records));
|
| 256 |
+
res.json({ ok: true, characterIndex: ci, fieldIndex: fi, value: records[ci][fi] });
|
| 257 |
+
} catch (err) {
|
| 258 |
+
res.status(500).json({ error: err.message });
|
| 259 |
+
}
|
| 260 |
+
});
|
| 261 |
|
| 262 |
+
// POST /edit-bulk β edit multiple fields at once (for batch updates)
|
| 263 |
+
// Body: { token, edits: [{ characterIndex, fieldIndex, value }, ...] }
|
| 264 |
+
app.post('/edit-bulk', (req, res) => {
|
| 265 |
+
const { token, edits } = req.body;
|
| 266 |
+
if (!token || !Array.isArray(edits)) return res.status(400).json({ error: 'Missing token or edits array' });
|
| 267 |
+
try {
|
| 268 |
+
const raw = readChars(token);
|
| 269 |
+
const records = parseChars(raw);
|
| 270 |
+
for (const { characterIndex, fieldIndex, value } of edits) {
|
| 271 |
+
const ci = Number(characterIndex);
|
| 272 |
+
const fi = Number(fieldIndex);
|
| 273 |
+
if (!records[ci]) throw new Error(`Character index ${ci} out of range`);
|
| 274 |
+
if (fi < 0 || fi >= records[ci].length) throw new Error(`Field index ${fi} out of range`);
|
| 275 |
+
records[ci][fi] = String(value);
|
| 276 |
}
|
| 277 |
+
writeChars(token, serializeChars(records));
|
| 278 |
+
res.json({ ok: true, applied: edits.length });
|
| 279 |
+
} catch (err) {
|
| 280 |
+
res.status(500).json({ error: err.message });
|
| 281 |
+
}
|
| 282 |
+
});
|
| 283 |
|
| 284 |
+
// POST /reset/:token β reset characters.txt back to original from the APK
|
| 285 |
+
app.post('/reset/:token', (req, res) => {
|
| 286 |
+
try {
|
| 287 |
+
const dir = sessionDir(req.params.token);
|
| 288 |
+
const apkPath = path.join(dir, 'original.apk');
|
| 289 |
+
if (!fs.existsSync(apkPath)) return res.status(404).json({ error: 'Session not found' });
|
| 290 |
+
extractCharsFromApk(apkPath, req.params.token);
|
| 291 |
+
res.json({ ok: true });
|
| 292 |
} catch (err) {
|
| 293 |
+
res.status(500).json({ error: err.message });
|
|
|
|
|
|
|
| 294 |
}
|
| 295 |
});
|
| 296 |
|
| 297 |
+
// POST /patch β repack + sign APK using characters.txt from disk (no body needed)
|
| 298 |
+
// Body: { token }
|
| 299 |
+
app.post('/patch', (req, res) => {
|
| 300 |
+
const { token } = req.body;
|
| 301 |
+
if (!token) return res.status(400).json({ error: 'Missing token' });
|
| 302 |
|
| 303 |
+
const dir = sessionDir(token);
|
| 304 |
+
const originalApk = path.join(dir, 'original.apk');
|
| 305 |
+
const patchedApk = path.join(dir, 'patched_unsigned.apk');
|
| 306 |
+
const alignedApk = path.join(dir, 'patched_aligned.apk');
|
| 307 |
+
const signedApk = path.join(dir, 'patched_signed.apk');
|
|
|
|
|
|
|
| 308 |
|
| 309 |
+
if (!fs.existsSync(originalApk)) return res.status(404).json({ error: 'Session expired or not found. Re-upload the APK.' });
|
|
|
|
|
|
|
| 310 |
|
| 311 |
try {
|
| 312 |
ensureKeystore();
|
| 313 |
const tools = checkTools();
|
| 314 |
|
| 315 |
+
// 1. Extract APK, preserving all original files
|
| 316 |
+
const workDir = path.join(dir, 'apk_work');
|
|
|
|
|
|
|
| 317 |
if (fs.existsSync(workDir)) fs.rmSync(workDir, { recursive: true, force: true });
|
| 318 |
fs.mkdirSync(workDir, { recursive: true });
|
| 319 |
|
|
|
|
| 320 |
const unzipResult = spawnSync('unzip', ['-q', '-o', originalApk, '-d', workDir], { encoding: 'utf8' });
|
| 321 |
if (unzipResult.status !== 0) throw new Error('unzip failed: ' + unzipResult.stderr);
|
| 322 |
|
| 323 |
+
// 2. Remove old signature
|
| 324 |
const metaInfDir = path.join(workDir, 'META-INF');
|
| 325 |
if (fs.existsSync(metaInfDir)) fs.rmSync(metaInfDir, { recursive: true, force: true });
|
| 326 |
|
| 327 |
+
// 3. Copy characters.txt from disk β read from our saved file, not from the browser
|
| 328 |
+
const savedChars = readChars(token);
|
| 329 |
+
const charsDest = path.join(workDir, 'assets', 'characters.txt');
|
| 330 |
fs.mkdirSync(path.dirname(charsDest), { recursive: true });
|
| 331 |
+
fs.writeFileSync(charsDest, savedChars, 'utf8');
|
| 332 |
|
| 333 |
+
// 4. Repack: compress most files, re-add STORED types uncompressed
|
|
|
|
| 334 |
const zipResult = spawnSync('zip', ['-r', '-9', patchedApk, '.'], { cwd: workDir, encoding: 'utf8' });
|
| 335 |
if (zipResult.status !== 0) throw new Error('zip failed: ' + zipResult.stderr);
|
| 336 |
|
|
|
|
| 337 |
const storedPatterns = ['*.so', '*.arsc', '*.png', '*.gif', '*.jpg', '*.jpeg',
|
| 338 |
'*.webp', '*.wav', '*.mp3', '*.ogg', '*.aac', '*.ttf', '*.otf'];
|
| 339 |
const storedFiles = storedPatterns.flatMap(pat => {
|
|
|
|
| 345 |
if (sr.status !== 0 && sr.status !== 12) console.warn('zip -0 warning:', sr.stderr);
|
| 346 |
}
|
| 347 |
|
| 348 |
+
// 5. zipalign
|
| 349 |
let toSign = patchedApk;
|
| 350 |
if (tools.zipalign) {
|
| 351 |
const zr = spawnSync('zipalign', ['-f', '-v', '4', patchedApk, alignedApk], { encoding: 'utf8' });
|
| 352 |
if (zr.status === 0) toSign = alignedApk;
|
| 353 |
+
else console.warn('zipalign failed, skipping:', zr.stderr);
|
| 354 |
}
|
| 355 |
|
| 356 |
+
// 6. Sign
|
| 357 |
if (tools.apksigner) {
|
| 358 |
const sr = spawnSync('apksigner', [
|
| 359 |
+
'sign', '--ks', KEYSTORE_PATH,
|
| 360 |
+
'--ks-pass', `pass:${STORE_PASS}`, '--key-pass', `pass:${KEY_PASS}`,
|
| 361 |
+
'--ks-key-alias', KEY_ALIAS, '--out', signedApk, toSign
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
], { encoding: 'utf8' });
|
| 363 |
if (sr.status !== 0) throw new Error('apksigner failed: ' + sr.stderr);
|
| 364 |
} else if (tools.jarsigner) {
|
|
|
|
| 365 |
fs.copyFileSync(toSign, signedApk);
|
| 366 |
const sr = spawnSync('jarsigner', [
|
| 367 |
+
'-verbose', '-sigalg', 'SHA256withRSA', '-digestalg', 'SHA-256',
|
| 368 |
+
'-keystore', KEYSTORE_PATH, '-storepass', STORE_PASS, '-keypass', KEY_PASS,
|
| 369 |
+
signedApk, KEY_ALIAS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
], { encoding: 'utf8' });
|
| 371 |
if (sr.status !== 0) throw new Error('jarsigner failed: ' + sr.stderr);
|
| 372 |
} else {
|
| 373 |
+
throw new Error('No signing tool found. Install JDK or Android SDK build-tools.');
|
| 374 |
}
|
| 375 |
|
| 376 |
+
// 7. Save output
|
| 377 |
+
const OUTPUT = path.join(__dirname, 'supercity_patched.apk');
|
| 378 |
+
fs.copyFileSync(signedApk, OUTPUT);
|
| 379 |
+
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
| 380 |
res.json({ url: `/download/${DOWNLOAD_HASH}` });
|
| 381 |
|
| 382 |
} catch (err) {
|
|
|
|
| 386 |
});
|
| 387 |
|
| 388 |
// ββ ROOT ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 389 |
+
app.get('/', (req, res) => res.sendFile(path.join(__dirname, 'index.html')));
|
|
|
|
|
|
|
| 390 |
|
| 391 |
// ββ START βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 392 |
app.listen(7860, () => {
|