Reaperxxxx commited on
Commit
1837d5a
Β·
verified Β·
1 Parent(s): bd82bd3

Create index.js

Browse files
Files changed (1) hide show
  1. index.js +225 -0
index.js ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const multer = require('multer');
3
+ const AdmZip = require('adm-zip');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const { execSync, spawnSync } = require('child_process');
8
+ const axios = require('axios');
9
+
10
+ const app = express();
11
+ const PORT = 3000;
12
+
13
+ app.use(express.json({ limit: '200mb' }));
14
+ app.use(express.static(path.join(__dirname, 'public')));
15
+
16
+ // Multer: store APK uploads in temp dir
17
+ const upload = multer({
18
+ dest: os.tmpdir(),
19
+ limits: { fileSize: 200 * 1024 * 1024 },
20
+ fileFilter: (req, file, cb) => {
21
+ if (file.originalname.endsWith('.apk')) cb(null, true);
22
+ else cb(new Error('Only .apk files accepted'));
23
+ }
24
+ });
25
+
26
+ // ── KEYSTORE ────────────────────────────────────────────────────────────────
27
+ // We generate a debug keystore once and reuse it for all re-signs.
28
+ const KEYSTORE_PATH = path.join(__dirname, 'debug.keystore');
29
+ const KEY_ALIAS = 'supercity';
30
+ const KEY_PASS = 'supercity123';
31
+ const STORE_PASS = 'supercity123';
32
+
33
+ function ensureKeystore() {
34
+ if (fs.existsSync(KEYSTORE_PATH)) return;
35
+ console.log('Generating debug keystore...');
36
+ const result = spawnSync('keytool', [
37
+ '-genkeypair', '-v',
38
+ '-keystore', KEYSTORE_PATH,
39
+ '-alias', KEY_ALIAS,
40
+ '-keyalg', 'RSA',
41
+ '-keysize', '2048',
42
+ '-validity', '10000',
43
+ '-storepass', STORE_PASS,
44
+ '-keypass', KEY_PASS,
45
+ '-dname', 'CN=SuperCityEditor, OU=Mod, O=Local, L=Local, S=Local, C=US',
46
+ ], { encoding: 'utf8' });
47
+
48
+ if (result.status !== 0) {
49
+ throw new Error('keytool failed: ' + (result.stderr || result.stdout));
50
+ }
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)) {
58
+ try { execSync(`which ${t}`, { stdio: 'ignore' }); tools[t] = true; } catch {}
59
+ }
60
+ return tools;
61
+ }
62
+
63
+ // ── ROUTES ───────────────────────────────────────────────────────────────────
64
+
65
+ // GET /tools β€” let frontend know what's available
66
+ app.get('/tools', (req, res) => {
67
+ res.json(checkTools());
68
+ });
69
+
70
+ // POST /upload β€” receive APK, extract characters.txt, return it as text
71
+ app.post('/upload', upload.single('apk'), (req, res) => {
72
+ try {
73
+ const apkPath = req.file.path;
74
+ const zip = new AdmZip(apkPath);
75
+ const entry = zip.getEntry('assets/characters.txt');
76
+ if (!entry) {
77
+ fs.unlinkSync(apkPath);
78
+ return res.status(400).json({ error: 'assets/characters.txt not found in APK' });
79
+ }
80
+ const content = entry.getData().toString('utf8');
81
+ // Keep the apk on disk temporarily, send back a session token
82
+ const token = Date.now() + '_' + Math.random().toString(36).slice(2);
83
+ const sessionDir = path.join(os.tmpdir(), token);
84
+ fs.mkdirSync(sessionDir);
85
+ fs.renameSync(apkPath, path.join(sessionDir, 'original.apk'));
86
+ fs.writeFileSync(path.join(sessionDir, 'original_name.txt'), req.file.originalname);
87
+ res.json({ token, characters: content });
88
+ } catch (err) {
89
+ try { fs.unlinkSync(req.file.path); } catch {}
90
+ res.status(500).json({ error: err.message });
91
+ }
92
+ });
93
+
94
+ // POST /upload-url β€” fetch APK from a URL, extract characters.txt
95
+ app.post('/upload-url', express.json(), async (req, res) => {
96
+ const { url } = req.body;
97
+ if (!url || !url.toLowerCase().endsWith('.apk')) {
98
+ return res.status(400).json({ error: 'URL must point to a .apk file' });
99
+ }
100
+
101
+ const token = Date.now() + '_' + Math.random().toString(36).slice(2);
102
+ const sessionDir = path.join(os.tmpdir(), token);
103
+ fs.mkdirSync(sessionDir);
104
+ const apkPath = path.join(sessionDir, 'original.apk');
105
+
106
+ try {
107
+ const response = await axios.get(url, { responseType: 'arraybuffer', maxContentLength: 200 * 1024 * 1024 });
108
+ fs.writeFileSync(apkPath, response.data);
109
+
110
+ const zip = new AdmZip(apkPath);
111
+ const entry = zip.getEntry('assets/characters.txt');
112
+ if (!entry) {
113
+ fs.rmSync(sessionDir, { recursive: true, force: true });
114
+ return res.status(400).json({ error: 'assets/characters.txt not found in APK' });
115
+ }
116
+
117
+ const content = entry.getData().toString('utf8');
118
+ const fname = url.split('/').pop();
119
+ fs.writeFileSync(path.join(sessionDir, 'original_name.txt'), fname);
120
+ res.json({ token, characters: content });
121
+
122
+ } catch (err) {
123
+ fs.rmSync(sessionDir, { recursive: true, force: true });
124
+ const msg = err.response ? `Remote returned ${err.response.status}` : err.message;
125
+ res.status(500).json({ error: msg });
126
+ }
127
+ });
128
+
129
+ // POST /patch β€” receive edited characters text + token, repack + sign APK
130
+ app.post('/patch', express.json({ limit: '10mb' }), (req, res) => {
131
+ const { token, characters } = req.body;
132
+ if (!token || !characters) return res.status(400).json({ error: 'Missing token or characters' });
133
+
134
+ const sessionDir = path.join(os.tmpdir(), token);
135
+ const originalApk = path.join(sessionDir, 'original.apk');
136
+ const patchedApk = path.join(sessionDir, 'patched_unsigned.apk');
137
+ const alignedApk = path.join(sessionDir, 'patched_aligned.apk');
138
+ const signedApk = path.join(sessionDir, 'patched_signed.apk');
139
+
140
+ if (!fs.existsSync(originalApk)) {
141
+ return res.status(404).json({ error: 'Session expired or not found. Re-upload the APK.' });
142
+ }
143
+
144
+ try {
145
+ ensureKeystore();
146
+ const tools = checkTools();
147
+
148
+ // 1. Open APK as zip, replace assets/characters.txt
149
+ const zip = new AdmZip(originalApk);
150
+ zip.updateFile('assets/characters.txt', Buffer.from(characters, 'utf8'));
151
+
152
+ // 2. Delete META-INF (old signature) so re-sign works cleanly
153
+ const toDelete = [];
154
+ zip.getEntries().forEach(e => {
155
+ if (e.entryName.startsWith('META-INF/')) toDelete.push(e.entryName);
156
+ });
157
+ toDelete.forEach(name => zip.deleteFile(name));
158
+
159
+ zip.writeZip(patchedApk);
160
+
161
+ // 3. zipalign (align before signing for v2/v3; v1 is fine either way)
162
+ let toSign = patchedApk;
163
+ if (tools.zipalign) {
164
+ const zr = spawnSync('zipalign', ['-f', '-v', '4', patchedApk, alignedApk], { encoding: 'utf8' });
165
+ if (zr.status === 0) toSign = alignedApk;
166
+ else console.warn('zipalign failed, skipping alignment:', zr.stderr);
167
+ }
168
+
169
+ // 4. Sign β€” prefer apksigner (v1+v2), fallback to jarsigner (v1 only)
170
+ if (tools.apksigner) {
171
+ const sr = spawnSync('apksigner', [
172
+ 'sign',
173
+ '--ks', KEYSTORE_PATH,
174
+ '--ks-pass', `pass:${STORE_PASS}`,
175
+ '--key-pass', `pass:${KEY_PASS}`,
176
+ '--ks-key-alias', KEY_ALIAS,
177
+ '--out', signedApk,
178
+ toSign
179
+ ], { encoding: 'utf8' });
180
+ if (sr.status !== 0) throw new Error('apksigner failed: ' + sr.stderr);
181
+ } else if (tools.jarsigner) {
182
+ // jarsigner signs in-place; copy first
183
+ fs.copyFileSync(toSign, signedApk);
184
+ const sr = spawnSync('jarsigner', [
185
+ '-verbose',
186
+ '-sigalg', 'SHA256withRSA',
187
+ '-digestalg', 'SHA-256',
188
+ '-keystore', KEYSTORE_PATH,
189
+ '-storepass', STORE_PASS,
190
+ '-keypass', KEY_PASS,
191
+ signedApk,
192
+ KEY_ALIAS
193
+ ], { encoding: 'utf8' });
194
+ if (sr.status !== 0) throw new Error('jarsigner failed: ' + sr.stderr);
195
+ } else {
196
+ 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.');
197
+ }
198
+
199
+ // 5. Stream signed APK back
200
+ const origName = fs.existsSync(path.join(sessionDir, 'original_name.txt'))
201
+ ? fs.readFileSync(path.join(sessionDir, 'original_name.txt'), 'utf8').trim()
202
+ : 'SuperCity.apk';
203
+ const outName = origName.replace(/\.apk$/i, '_modded.apk');
204
+
205
+ res.setHeader('Content-Disposition', `attachment; filename="${outName}"`);
206
+ res.setHeader('Content-Type', 'application/vnd.android.package-archive');
207
+ const stream = fs.createReadStream(signedApk);
208
+ stream.pipe(res);
209
+ stream.on('end', () => {
210
+ // Cleanup session after download
211
+ try { fs.rmSync(sessionDir, { recursive: true, force: true }); } catch {}
212
+ });
213
+
214
+ } catch (err) {
215
+ console.error(err);
216
+ res.status(500).json({ error: err.message });
217
+ }
218
+ });
219
+
220
+ // ── START ─────────────────────────────────────────────────────────────────────
221
+ app.listen(7860, () => {
222
+ console.log(`\n🦸 Super City Editor backend running at http://localhost:${PORT}`);
223
+ console.log('Open that URL in your browser to use the editor.\n');
224
+ try { ensureKeystore(); } catch (e) { console.warn('Keystore not ready yet:', e.message); }
225
+ });