@@ -4,12 +4,130 @@ import fs from "fs";
44import path from "node:path" ;
55import axios from "axios" ;
66import 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
8126export 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