Reaperxxxx commited on
Commit
143064c
Β·
verified Β·
1 Parent(s): 087ba8a

Update index.js

Browse files
Files changed (1) hide show
  1. index.js +211 -145
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
- // ── ROUTES ───────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
- // GET /download-link β€” returns the current download URL
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 β€” serve the patched APK only
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 β€” let frontend know what's available
87
- app.get('/tools', (req, res) => {
88
- res.json(checkTools());
89
- });
90
 
91
- // GET /session/:token β€” check if a session is still alive
92
  app.get('/session/:token', (req, res) => {
93
- const sessionDir = req.params.token === 'persistent'
94
- ? PERSISTENT_DIR
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 β€” APK diagnostics
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 = new AdmZip(BUNDLED_APK);
108
- const entries = zip.getEntries().map(e => e.entryName);
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 β€” load bundled APK from disk, no upload needed
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
- const zip = new AdmZip(BUNDLED_APK);
137
- const entry = zip.getEntries().find(e =>
138
- e.entryName.replace(/\\/g, '/').toLowerCase() === 'assets/characters.txt'
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 β€” receive APK, extract characters.txt, return it as text
154
  app.post('/upload', upload.single('apk'), (req, res) => {
155
  try {
156
- const apkPath = req.file.path;
157
- const zip = new AdmZip(apkPath);
158
- const entry = zip.getEntry('assets/characters.txt');
159
- if (!entry) {
160
- fs.unlinkSync(apkPath);
161
- return res.status(400).json({ error: 'assets/characters.txt not found in APK' });
162
- }
163
- const content = entry.getData().toString('utf8');
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 β€” fetch APK from a URL, extract characters.txt
178
- app.post('/upload-url', express.json(), async (req, res) => {
179
  const { url } = req.body;
180
- if (!url || !url.toLowerCase().endsWith('.apk')) {
181
- return res.status(400).json({ error: 'URL must point to a .apk file' });
 
 
 
 
 
 
 
 
 
 
 
 
182
  }
 
183
 
184
- const token = Date.now() + '_' + Math.random().toString(36).slice(2);
185
- const sessionDir = path.join(os.tmpdir(), token);
186
- fs.mkdirSync(sessionDir);
187
- const apkPath = path.join(sessionDir, 'original.apk');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
 
 
 
 
 
 
 
 
189
  try {
190
- const response = await axios.get(url, { responseType: 'arraybuffer', maxContentLength: 200 * 1024 * 1024 });
191
- fs.writeFileSync(apkPath, response.data);
 
 
 
 
 
 
 
 
 
 
 
192
 
193
- const zip = new AdmZip(apkPath);
194
- const entry = zip.getEntry('assets/characters.txt');
195
- if (!entry) {
196
- fs.rmSync(sessionDir, { recursive: true, force: true });
197
- return res.status(400).json({ error: 'assets/characters.txt not found in APK' });
 
 
 
 
 
 
 
 
 
198
  }
 
 
 
 
 
 
199
 
200
- const content = entry.getData().toString('utf8');
201
- const fname = url.split('/').pop();
202
- fs.writeFileSync(path.join(sessionDir, 'original_name.txt'), fname);
203
- res.json({ token, characters: content });
204
-
 
 
 
205
  } catch (err) {
206
- fs.rmSync(sessionDir, { recursive: true, force: true });
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 β€” receive edited characters text + token, repack + sign APK
213
- app.post('/patch', express.json({ limit: '10mb' }), (req, res) => {
214
- const { token, characters } = req.body;
215
- if (!token || !characters) return res.status(400).json({ error: 'Missing token or characters' });
 
216
 
217
- const sessionDir = token === 'persistent'
218
- ? PERSISTENT_DIR
219
- : path.join(os.tmpdir(), token);
220
- const originalApk = path.join(sessionDir, 'original.apk');
221
- const patchedApk = path.join(sessionDir, 'patched_unsigned.apk');
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. Repack APK using system unzip/zip to preserve per-entry compression.
234
- // AdmZip re-deflates ALL entries including files Android requires to be
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 META-INF so apksigner can add a fresh signature
245
  const metaInfDir = path.join(workDir, 'META-INF');
246
  if (fs.existsSync(metaInfDir)) fs.rmSync(metaInfDir, { recursive: true, force: true });
247
 
248
- // Overwrite characters.txt with edited content
249
- const charsDest = path.join(workDir, 'assets', 'characters.txt');
 
250
  fs.mkdirSync(path.dirname(charsDest), { recursive: true });
251
- fs.writeFileSync(charsDest, Buffer.from(characters, 'utf8'));
252
 
253
- // Repack: compress most files, but re-add known STORED types uncompressed.
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
- // 3. zipalign (align before signing for v2/v3; v1 is fine either way)
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 alignment:', zr.stderr);
276
  }
277
 
278
- // 4. Sign β€” prefer apksigner (v1+v2), fallback to jarsigner (v1 only)
279
  if (tools.apksigner) {
280
  const sr = spawnSync('apksigner', [
281
- 'sign',
282
- '--ks', KEYSTORE_PATH,
283
- '--ks-pass', `pass:${STORE_PASS}`,
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
- '-sigalg', 'SHA256withRSA',
296
- '-digestalg', 'SHA-256',
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 (keytool+jarsigner) or Android SDK build-tools (apksigner+zipalign) and make sure they are on PATH.');
306
  }
307
 
308
- // 5. Save to fixed output path and return download URL
309
- const PATCHED_OUTPUT = path.join(__dirname, 'supercity_patched.apk');
310
- fs.copyFileSync(signedApk, PATCHED_OUTPUT);
311
- try { fs.rmSync(sessionDir, { recursive: true, force: true }); } catch {}
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, () => {