Spaces:
Runtime error
Runtime error
| 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); } | |
| }); |