utils / index.js
Reaperxxxx's picture
Update index.js
a94f438 verified
Raw
History Blame Contribute Delete
18.8 kB
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); }
});