const express = require('express'); const multer = require('multer'); const AdmZip = require('adm-zip'); const path = require('path'); const fs = require('fs'); const os = require('os'); const { execSync, spawnSync } = require('child_process'); const axios = require('axios'); const crypto = require('crypto'); const app = express(); const PORT = 3000; // ── DOWNLOAD HASH ───────────────────────────────────────────────────────────── const DOWNLOAD_HASH = crypto.randomBytes(16).toString('hex'); app.use(express.json({ limit: '200mb' })); app.use(express.static(path.join(__dirname, 'public'))); // Multer: store APK uploads in temp dir const upload = multer({ dest: os.tmpdir(), limits: { fileSize: 200 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (file.originalname.endsWith('.apk')) cb(null, true); else cb(new Error('Only .apk files accepted')); } }); // ── KEYSTORE ────────────────────────────────────────────────────────────────── const KEYSTORE_PATH = path.join(__dirname, 'debug.keystore'); const KEY_ALIAS = 'supercity'; const KEY_PASS = 'supercity123'; const STORE_PASS = 'supercity123'; function ensureKeystore() { if (fs.existsSync(KEYSTORE_PATH)) return; console.log('Generating debug keystore...'); const result = spawnSync('keytool', [ '-genkeypair', '-v', '-keystore', KEYSTORE_PATH, '-alias', KEY_ALIAS, '-keyalg', 'RSA', '-keysize', '2048', '-validity', '10000', '-storepass', STORE_PASS, '-keypass', KEY_PASS, '-dname', 'CN=SuperCityEditor, OU=Mod, O=Local, L=Local, S=Local, C=US', ], { encoding: 'utf8' }); if (result.status !== 0) throw new Error('keytool failed: ' + (result.stderr || result.stdout)); console.log('Keystore created at', KEYSTORE_PATH); } // ── TOOL CHECK ──────────────────────────────────────────────────────────────── function checkTools() { const tools = { keytool: false, zipalign: false, apksigner: false, jarsigner: false }; for (const t of Object.keys(tools)) { try { execSync(`which ${t}`, { stdio: 'ignore' }); tools[t] = true; } catch {} } return tools; } // ── CHARACTER FILE HELPERS ──────────────────────────────────────────────────── // characters.txt is a single line of pipe-delimited fields. // Character boundaries are detected by the pattern: name | alias | number // (same logic as the frontend parseCharacters function). function sessionDir(token) { return token === 'persistent' ? PERSISTENT_DIR : path.join(os.tmpdir(), token); } function charsPath(token) { return path.join(sessionDir(token), 'characters.txt'); } // Read the raw characters string from disk (guaranteed untouched) function readChars(token) { const p = charsPath(token); if (!fs.existsSync(p)) throw new Error('Characters file not found. Re-upload the APK.'); return fs.readFileSync(p, 'utf8'); } // Write back to disk — raw flat pipe-delimited string, no newlines added function writeChars(token, raw) { fs.writeFileSync(charsPath(token), raw, 'utf8'); } // Mirror of the HTML's parseCharacters(): // Splits on '|', detects boundaries where: allFields[i-1]=name, allFields[i]=alias, allFields[i+1]=number // Returns array of: { index, name, alias, fields, startIdx } // - name: allFields[start - 1] (the token just before the boundary) // - alias: fields[0] (first field inside the boundary = allFields[start]) // - fields: allFields[start .. next_boundary-2] (the character's own pipe segment) // - startIdx: index into allFields where this character's fields begin function parseChars(raw) { const allFields = raw.split('|'); function isNameToken(s) { s = s.trim(); return s.length > 1 && /[A-Za-z]/.test(s) && !/^-?[\d.]+$/.test(s); } function isNumeric(s) { return /^-?[\d.]+$/.test(s.trim()); } const boundaries = []; for (let i = 1; i < allFields.length - 1; i++) { if (isNameToken(allFields[i]) && isNumeric(allFields[i + 1]) && isNameToken(allFields[i - 1])) { boundaries.push(i); } } const characters = []; for (let b = 0; b < boundaries.length; b++) { const start = boundaries[b]; const end = b + 1 < boundaries.length ? boundaries[b + 1] - 1 : allFields.length; const fields = allFields.slice(start, end); characters.push({ index: b, name: allFields[start - 1].trim(), alias: fields[0].trim(), fields, // fields[0]=alias, fields[1..]=numeric stats startIdx: start, // position in allFields — needed for writeBack }); } return { allFields, characters }; } // Write edited characters back into the original allFields array and rejoin with '|' // edits: [{ characterIndex, fieldIndex, value }] function applyEdits(raw, edits) { const { allFields, characters } = parseChars(raw); for (const { characterIndex, fieldIndex, value } of edits) { const ch = characters[characterIndex]; if (!ch) throw new Error(`Character index ${characterIndex} out of range`); if (fieldIndex < 0 || fieldIndex >= ch.fields.length) throw new Error(`Field index ${fieldIndex} out of range for character ${characterIndex}`); // Write back into allFields at the correct absolute position allFields[ch.startIdx + fieldIndex] = String(value); } return allFields.join('|'); } // Extract characters.txt from an APK and save it to the session dir function extractCharsFromApk(apkPath, token) { const zip = new AdmZip(apkPath); const entry = zip.getEntries().find(e => e.entryName.replace(/\\/g, '/').toLowerCase() === 'assets/characters.txt' ); if (!entry) { const names = zip.getEntries().map(e => e.entryName).join(', '); throw new Error(`assets/characters.txt not found in APK. Entries: ${names}`); } // Write raw bytes straight to disk — no string conversion that could add newlines const raw = entry.getData().toString('utf8'); writeChars(token, raw); return raw; } // ── PERSISTENT SESSION ──────────────────────────────────────────────────────── const BUNDLED_APK = path.join(__dirname, 'super-city-2.010.64-mod-t-5play.apk'); const PERSISTENT_DIR = path.join(os.tmpdir(), 'supercity-persistent'); function ensurePersistentSession() { if (!fs.existsSync(PERSISTENT_DIR)) fs.mkdirSync(PERSISTENT_DIR, { recursive: true }); const dest = path.join(PERSISTENT_DIR, 'original.apk'); if (!fs.existsSync(dest)) { fs.copyFileSync(BUNDLED_APK, dest); fs.writeFileSync(path.join(PERSISTENT_DIR, 'original_name.txt'), path.basename(BUNDLED_APK)); } } // ── ROUTES ──────────────────────────────────────────────────────────────────── // GET /download-link app.get('/download-link', (req, res) => { const patched = fs.existsSync(path.join(__dirname, 'supercity_patched.apk')); res.json({ url: `/download/${DOWNLOAD_HASH}`, patched }); }); // GET /download/:hash app.get('/download/:hash', (req, res) => { if (req.params.hash !== DOWNLOAD_HASH) return res.status(403).json({ error: 'Invalid download link' }); const patched = path.join(__dirname, 'supercity_patched.apk'); if (!fs.existsSync(patched)) return res.status(404).json({ error: 'No patched APK yet — edit and export first' }); res.setHeader('Content-Disposition', 'attachment; filename="supercity_modded.apk"'); res.setHeader('Content-Type', 'application/vnd.android.package-archive'); fs.createReadStream(patched).pipe(res); }); // GET /tools app.get('/tools', (req, res) => res.json(checkTools())); // GET /session/:token app.get('/session/:token', (req, res) => { const dir = sessionDir(req.params.token); const alive = fs.existsSync(path.join(dir, 'original.apk')); res.json({ alive }); }); // GET /debug app.get('/debug', (req, res) => { const exists = fs.existsSync(BUNDLED_APK); if (!exists) return res.json({ exists: false, path: BUNDLED_APK }); const stat = fs.statSync(BUNDLED_APK); try { const zip = new AdmZip(BUNDLED_APK); const entries = zip.getEntries().map(e => e.entryName); const hasChars = entries.some(e => e.replace(/\\/g, '/').toLowerCase() === 'assets/characters.txt'); res.json({ exists: true, path: BUNDLED_APK, sizeBytes: stat.size, entryCount: entries.length, hasCharactersTxt: hasChars, entries }); } catch (err) { res.json({ exists: true, path: BUNDLED_APK, sizeBytes: stat.size, zipError: err.message }); } }); // GET /init — load bundled APK, extract characters to disk, return token only app.get('/init', (req, res) => { if (!fs.existsSync(BUNDLED_APK)) return res.status(404).json({ error: 'Bundled APK not found on server' }); try { ensurePersistentSession(); // Always re-extract from the bundled APK so characters.txt on disk is pristine extractCharsFromApk(BUNDLED_APK, 'persistent'); res.json({ token: 'persistent' }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /upload — receive APK, extract characters to disk, return token only app.post('/upload', upload.single('apk'), (req, res) => { try { const apkPath = req.file.path; const token = Date.now() + '_' + Math.random().toString(36).slice(2); const dir = path.join(os.tmpdir(), token); fs.mkdirSync(dir); fs.renameSync(apkPath, path.join(dir, 'original.apk')); fs.writeFileSync(path.join(dir, 'original_name.txt'), req.file.originalname); extractCharsFromApk(path.join(dir, 'original.apk'), token); res.json({ token }); } catch (err) { try { fs.unlinkSync(req.file.path); } catch {} res.status(500).json({ error: err.message }); } }); // POST /upload-url — fetch APK from URL, extract characters to disk, return token only app.post('/upload-url', async (req, res) => { const { url } = req.body; if (!url || !url.toLowerCase().endsWith('.apk')) return res.status(400).json({ error: 'URL must point to a .apk file' }); const token = Date.now() + '_' + Math.random().toString(36).slice(2); const dir = path.join(os.tmpdir(), token); fs.mkdirSync(dir); const apkPath = path.join(dir, 'original.apk'); try { const response = await axios.get(url, { responseType: 'arraybuffer', maxContentLength: 200 * 1024 * 1024 }); fs.writeFileSync(apkPath, response.data); fs.writeFileSync(path.join(dir, 'original_name.txt'), url.split('/').pop()); extractCharsFromApk(apkPath, token); res.json({ token }); } catch (err) { fs.rmSync(dir, { recursive: true, force: true }); res.status(500).json({ error: err.response ? `Remote returned ${err.response.status}` : err.message }); } }); // GET /characters/:token — return parsed character list for the UI // Each character: { index, name, alias, fields: [...] } // The frontend NEVER sees the raw blob — only structured data it needs to render. app.get('/characters/:token', (req, res) => { try { const raw = readChars(req.params.token); const { characters } = parseChars(raw); res.json({ count: characters.length, characters }); } catch (err) { res.status(404).json({ error: err.message }); } }); // POST /edit — edit one field of one character, server-side only // Body: { token, characterIndex, fieldIndex, value } app.post('/edit', (req, res) => { const { token, characterIndex, fieldIndex, value } = req.body; if (token === undefined || characterIndex === undefined || fieldIndex === undefined || value === undefined) { return res.status(400).json({ error: 'Missing token, characterIndex, fieldIndex, or value' }); } try { const raw = readChars(token); const edited = applyEdits(raw, [{ characterIndex: Number(characterIndex), fieldIndex: Number(fieldIndex), value }]); writeChars(token, edited); res.json({ ok: true, characterIndex: Number(characterIndex), fieldIndex: Number(fieldIndex), value: String(value) }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /edit-bulk — edit multiple fields at once (for batch updates) // Body: { token, edits: [{ characterIndex, fieldIndex, value }, ...] } app.post('/edit-bulk', (req, res) => { const { token, edits } = req.body; if (!token || !Array.isArray(edits)) return res.status(400).json({ error: 'Missing token or edits array' }); try { const raw = readChars(token); const edited = applyEdits(raw, edits.map(e => ({ characterIndex: Number(e.characterIndex), fieldIndex: Number(e.fieldIndex), value: e.value, }))); writeChars(token, edited); res.json({ ok: true, applied: edits.length }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /reset/:token — reset characters.txt back to original from the APK app.post('/reset/:token', (req, res) => { try { const dir = sessionDir(req.params.token); const apkPath = path.join(dir, 'original.apk'); if (!fs.existsSync(apkPath)) return res.status(404).json({ error: 'Session not found' }); extractCharsFromApk(apkPath, req.params.token); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /patch — repack + sign APK using characters.txt from disk (no body needed) // Body: { token } app.post('/patch', (req, res) => { const { token } = req.body; if (!token) return res.status(400).json({ error: 'Missing token' }); const dir = sessionDir(token); const originalApk = path.join(dir, 'original.apk'); const patchedApk = path.join(dir, 'patched_unsigned.apk'); const alignedApk = path.join(dir, 'patched_aligned.apk'); const signedApk = path.join(dir, 'patched_signed.apk'); if (!fs.existsSync(originalApk)) return res.status(404).json({ error: 'Session expired or not found. Re-upload the APK.' }); try { ensureKeystore(); const tools = checkTools(); // 1. Extract APK, preserving all original files const workDir = path.join(dir, 'apk_work'); if (fs.existsSync(workDir)) fs.rmSync(workDir, { recursive: true, force: true }); fs.mkdirSync(workDir, { recursive: true }); const unzipResult = spawnSync('unzip', ['-q', '-o', originalApk, '-d', workDir], { encoding: 'utf8' }); if (unzipResult.status !== 0) throw new Error('unzip failed: ' + unzipResult.stderr); // 2. Remove old signature const metaInfDir = path.join(workDir, 'META-INF'); if (fs.existsSync(metaInfDir)) fs.rmSync(metaInfDir, { recursive: true, force: true }); // 3. Copy characters.txt from disk — read from our saved file, not from the browser const savedChars = readChars(token); const charsDest = path.join(workDir, 'assets', 'characters.txt'); fs.mkdirSync(path.dirname(charsDest), { recursive: true }); fs.writeFileSync(charsDest, savedChars, 'utf8'); // 4. Repack: compress most files, re-add STORED types uncompressed const zipResult = spawnSync('zip', ['-r', '-9', patchedApk, '.'], { cwd: workDir, encoding: 'utf8' }); if (zipResult.status !== 0) throw new Error('zip failed: ' + zipResult.stderr); const storedPatterns = ['*.so', '*.arsc', '*.png', '*.gif', '*.jpg', '*.jpeg', '*.webp', '*.wav', '*.mp3', '*.ogg', '*.aac', '*.ttf', '*.otf']; const storedFiles = storedPatterns.flatMap(pat => { const r = spawnSync('find', ['.', '-name', pat], { cwd: workDir, encoding: 'utf8' }); return r.stdout.trim().split('\n').filter(Boolean); }); if (storedFiles.length > 0) { const sr = spawnSync('zip', ['-0', '-u', patchedApk, ...storedFiles], { cwd: workDir, encoding: 'utf8' }); if (sr.status !== 0 && sr.status !== 12) console.warn('zip -0 warning:', sr.stderr); } // 5. zipalign let toSign = patchedApk; if (tools.zipalign) { const zr = spawnSync('zipalign', ['-f', '-v', '4', patchedApk, alignedApk], { encoding: 'utf8' }); if (zr.status === 0) toSign = alignedApk; else console.warn('zipalign failed, skipping:', zr.stderr); } // 6. Sign if (tools.apksigner) { const sr = spawnSync('apksigner', [ 'sign', '--ks', KEYSTORE_PATH, '--ks-pass', `pass:${STORE_PASS}`, '--key-pass', `pass:${KEY_PASS}`, '--ks-key-alias', KEY_ALIAS, '--out', signedApk, toSign ], { encoding: 'utf8' }); if (sr.status !== 0) throw new Error('apksigner failed: ' + sr.stderr); } else if (tools.jarsigner) { fs.copyFileSync(toSign, signedApk); const sr = spawnSync('jarsigner', [ '-verbose', '-sigalg', 'SHA256withRSA', '-digestalg', 'SHA-256', '-keystore', KEYSTORE_PATH, '-storepass', STORE_PASS, '-keypass', KEY_PASS, signedApk, KEY_ALIAS ], { encoding: 'utf8' }); if (sr.status !== 0) throw new Error('jarsigner failed: ' + sr.stderr); } else { throw new Error('No signing tool found. Install JDK or Android SDK build-tools.'); } // 7. Save output const OUTPUT = path.join(__dirname, 'supercity_patched.apk'); fs.copyFileSync(signedApk, OUTPUT); try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {} res.json({ url: `/download/${DOWNLOAD_HASH}` }); } catch (err) { console.error(err); res.status(500).json({ error: err.message }); } }); // ── ROOT ────────────────────────────────────────────────────────────────────── app.get('/', (req, res) => res.sendFile(path.join(__dirname, 'index.html'))); // ── START ───────────────────────────────────────────────────────────────────── app.listen(7860, () => { console.log(`\n🦸 Super City Editor backend running at http://localhost:${PORT}`); console.log(`\n⬇ Direct APK download: http://localhost:${PORT}/download/${DOWNLOAD_HASH}\n`); try { ensureKeystore(); } catch (e) { console.warn('Keystore not ready yet:', e.message); } });