Skip to content

Commit ccc629f

Browse files
feat: improve file validation for upload tool (#5)
Part of magicpod-internal/magicpod#22801
1 parent 8f74f79 commit ccc629f

File tree

1 file changed

+123
-4
lines changed

1 file changed

+123
-4
lines changed

src/tools/api-v1-0-upload-file-create.ts

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,130 @@ import fs from "fs";
44
import path from "node:path";
55
import axios from "axios";
66
import FormData from "form-data";
7+
import { createReadStream } from "fs";
8+
import { Transform } from "stream";
9+
10+
const ALLOWED_EXTENSIONS = ['.apk', '.aab', '.ipa', '.zip'];
11+
12+
const isValidFileExtension = (filePath: string): boolean => {
13+
const ext = path.extname(filePath).toLowerCase();
14+
return ALLOWED_EXTENSIONS.includes(ext);
15+
};
16+
17+
const isValidZipWithApp = async (filePath: string): Promise<boolean> => {
18+
if (path.extname(filePath).toLowerCase() !== '.zip') {
19+
return true; // Not a zip file, skip this validation
20+
}
21+
22+
return new Promise((resolve) => {
23+
const fileStream = createReadStream(filePath);
24+
let buffer = Buffer.alloc(0);
25+
let hasAppDirectory = false;
26+
let bytesRead = 0;
27+
const MAX_BYTES_TO_READ = 10 * 1024 * 1024; // 10MB limit for safety
28+
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
29+
30+
const zipParser = new Transform({
31+
transform(chunk, _encoding, callback) {
32+
if (hasAppDirectory || bytesRead > MAX_BYTES_TO_READ) {
33+
callback();
34+
return;
35+
}
36+
37+
bytesRead += chunk.length;
38+
buffer = Buffer.concat([buffer, chunk]);
39+
40+
// Parse ZIP local file headers
41+
let offset = 0;
42+
while (offset < buffer.length - 30) { // Minimum header size is 30 bytes
43+
// Look for local file header signature
44+
const signature = buffer.readUInt32LE(offset);
45+
if (signature !== LOCAL_FILE_HEADER_SIGNATURE) {
46+
offset++;
47+
continue;
48+
}
49+
50+
// Read filename length from header (at offset 26)
51+
if (offset + 30 > buffer.length) break;
52+
53+
const filenameLength = buffer.readUInt16LE(offset + 26);
54+
const extraFieldLength = buffer.readUInt16LE(offset + 28);
55+
56+
// Check if we have the complete entry
57+
if (offset + 30 + filenameLength > buffer.length) {
58+
break;
59+
}
60+
61+
// Extract filename
62+
const filename = buffer.subarray(offset + 30, offset + 30 + filenameLength).toString('utf8');
63+
64+
// Check if this is a .app directory (directories in ZIP end with /)
65+
if (filename.toLowerCase().endsWith('.app/')) {
66+
hasAppDirectory = true;
67+
callback();
68+
return;
69+
}
70+
71+
// Move to next entry
72+
offset += 30 + filenameLength + extraFieldLength;
73+
}
74+
75+
// Keep last 1KB of buffer for potential split headers
76+
if (buffer.length > 1024) {
77+
buffer = buffer.subarray(buffer.length - 1024);
78+
}
79+
80+
callback();
81+
}
82+
});
83+
84+
fileStream.pipe(zipParser);
85+
86+
fileStream.on('end', () => {
87+
resolve(hasAppDirectory);
88+
});
89+
90+
fileStream.on('error', () => {
91+
resolve(false);
92+
});
93+
94+
zipParser.on('error', () => {
95+
resolve(false);
96+
});
97+
});
98+
};
99+
100+
const validateFile = async (filePath: string): Promise<{ valid: boolean; error?: string }> => {
101+
if (!fs.existsSync(filePath)) {
102+
return { valid: false, error: "No such file exists. Note that an absolute path is required" };
103+
}
104+
105+
if (!isValidFileExtension(filePath)) {
106+
return {
107+
valid: false,
108+
error: "Invalid file type. Only .apk, .aab, .ipa files, or zipped .app files are allowed"
109+
};
110+
}
111+
112+
const ext = path.extname(filePath).toLowerCase();
113+
if (ext === '.zip') {
114+
const hasValidApp = await isValidZipWithApp(filePath);
115+
if (!hasValidApp) {
116+
return {
117+
valid: false,
118+
error: "ZIP file must contain an .app directory to be valid"
119+
};
120+
}
121+
}
122+
123+
return { valid: true };
124+
};
7125

8126
export const apiV1_0UploadFileCreate = (baseUrl: string, apiToken: string) => {
9127
return {
10128
name: "API-v1_0_upload-file_create",
11129
description:
12-
"Upload target app files (.app, .ipa, .apk or .aab) to MagicPod cloud",
130+
"Upload target app files (.ipa, .apk, .aab, or zipped .app) to MagicPod cloud",
13131
inputSchema: z.object({
14132
organizationName: z
15133
.string()
@@ -18,17 +136,18 @@ export const apiV1_0UploadFileCreate = (baseUrl: string, apiToken: string) => {
18136
localFilePath: z
19137
.string()
20138
.describe(
21-
"A local file path to upload to MagicPod. Note that an absolute path is required. Its extension must be .app, .ipa, .apk or .aab",
139+
"A local file path to upload to MagicPod. Note that an absolute path is required. Supported formats: .ipa, .apk, .aab files, or .zip files containing .app directories",
22140
),
23141
}),
24142
handleRequest: async ({ organizationName, projectName, localFilePath }) => {
25143
try {
26-
if (!fs.existsSync(localFilePath)) {
144+
const validation = await validateFile(localFilePath);
145+
if (!validation.valid) {
27146
return {
28147
content: [
29148
{
30149
type: "text",
31-
text: "No such file exists. Note that an absolute path is required",
150+
text: validation.error!,
32151
},
33152
],
34153
};

0 commit comments

Comments
 (0)