1""" 2A local HTTP server for Android OTA package generation. 3Based on OTA_from_target_files.py 4 5Usage:: 6 python ./web_server.py [<port>] 7 8API:: 9 GET /check : check the status of all jobs 10 GET /check/<id> : check the status of the job with <id> 11 GET /file : fetch the target file list 12 GET /file/<path> : Add build file(s) in <path>, and return the target file list 13 GET /download/<id> : download the ota package with <id> 14 POST /run/<id> : submit a job with <id>, 15 arguments set in a json uploaded together 16 POST /file/<filename> : upload a target file 17 [TODO] POST /cancel/<id> : cancel a job with <id> 18 19TODO: 20 - Avoid unintentionally path leakage 21 - Avoid overwriting build when uploading build with same file name 22 23Other GET request will be redirected to the static request under 'dist' directory 24""" 25 26from http.server import BaseHTTPRequestHandler, SimpleHTTPRequestHandler, HTTPServer 27from socketserver import ThreadingMixIn 28from threading import Lock 29from ota_interface import ProcessesManagement 30from target_lib import TargetLib 31import logging 32import json 33import cgi 34import os 35import stat 36import zipfile 37 38LOCAL_ADDRESS = '0.0.0.0' 39 40 41class CORSSimpleHTTPHandler(SimpleHTTPRequestHandler): 42 def end_headers(self): 43 try: 44 origin_address, _ = cgi.parse_header(self.headers['Origin']) 45 self.send_header('Access-Control-Allow-Credentials', 'true') 46 self.send_header('Access-Control-Allow-Origin', origin_address) 47 except TypeError: 48 pass 49 super().end_headers() 50 51 52class RequestHandler(CORSSimpleHTTPHandler): 53 def _set_response(self, code=200, type='text/html'): 54 self.send_response(code) 55 self.send_header('Content-type', type) 56 self.end_headers() 57 58 def do_OPTIONS(self): 59 self.send_response(200) 60 self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS') 61 self.send_header("Access-Control-Allow-Headers", "X-Requested-With") 62 self.send_header("Access-Control-Allow-Headers", "Content-Type") 63 self.end_headers() 64 65 def do_GET(self): 66 if self.path == '/check' or self.path == '/check/': 67 statuses = jobs.get_status() 68 self._set_response(type='application/json') 69 self.wfile.write( 70 json.dumps([status.to_dict_basic() 71 for status in statuses]).encode() 72 ) 73 elif self.path.startswith('/check/'): 74 id = self.path[7:] 75 status = jobs.get_status_by_ID(id=id) 76 self._set_response(type='application/json') 77 self.wfile.write( 78 json.dumps(status.to_dict_detail(target_lib)).encode() 79 ) 80 elif self.path.startswith('/file') or self.path.startswith("/reconstruct_build_list"): 81 if self.path == '/file' or self.path == '/file/': 82 file_list = target_lib.get_builds() 83 else: 84 file_list = target_lib.new_build_from_dir() 85 builds_info = [build.to_dict() for build in file_list] 86 self._set_response(type='application/json') 87 self.wfile.write( 88 json.dumps(builds_info).encode() 89 ) 90 logging.debug( 91 "GET request:\nPath:%s\nHeaders:\n%s\nBody:\n%s\n", 92 str(self.path), str(self.headers), file_list 93 ) 94 return 95 elif self.path.startswith('/download'): 96 self.path = self.path[10:] 97 return CORSSimpleHTTPHandler.do_GET(self) 98 else: 99 if not os.path.exists('dist' + self.path): 100 logging.info('redirect to dist') 101 self.path = '/dist/' 102 else: 103 self.path = '/dist' + self.path 104 return CORSSimpleHTTPHandler.do_GET(self) 105 106 def do_POST(self): 107 if self.path.startswith('/run'): 108 content_type, _ = cgi.parse_header(self.headers['content-type']) 109 if content_type != 'application/json': 110 self.send_response(400) 111 self.end_headers() 112 return 113 content_length = int(self.headers['Content-Length']) 114 post_data = json.loads(self.rfile.read(content_length)) 115 try: 116 jobs.ota_generate(post_data, id=str(self.path[5:])) 117 self._set_response(code=200) 118 self.send_header("Content-Type", 'application/json') 119 self.wfile.write(json.dumps( 120 {"success": True, "msg": "OTA Generator started running"}).encode()) 121 except Exception as e: 122 logging.warning( 123 "Failed to run ota_from_target_files %s", e.__traceback__) 124 self.send_error( 125 400, "Failed to run ota_from_target_files", str(e)) 126 logging.debug( 127 "POST request:\nPath:%s\nHeaders:\n%s\nBody:\n%s\n", 128 str(self.path), str(self.headers), 129 json.dumps(post_data) 130 ) 131 elif self.path.startswith('/file'): 132 file_name = os.path.join('target', self.path[6:]) 133 file_length = int(self.headers['Content-Length']) 134 with open(file_name, 'wb') as output_file: 135 # Unwrap the uploaded file first (due to the usage of FormData) 136 # The wrapper has a boundary line at the top and bottom 137 # and some file information in the beginning 138 # There are a file content line, a file name line, and an empty line 139 # The boundary line in the bottom is 4 bytes longer than the top one 140 # Please refer to the following links for more details: 141 # https://stackoverflow.com/questions/8659808/how-does-http-file-upload-work 142 # https://datatracker.ietf.org/doc/html/rfc1867 143 upper_boundary = self.rfile.readline() 144 file_length -= len(upper_boundary) * 2 + 4 145 file_length -= len(self.rfile.readline()) 146 file_length -= len(self.rfile.readline()) 147 file_length -= len(self.rfile.readline()) 148 BUFFER_SIZE = 1024*1024 149 for offset in range(0, file_length, BUFFER_SIZE): 150 chunk = self.rfile.read( 151 min(file_length-offset, BUFFER_SIZE)) 152 output_file.write(chunk) 153 target_lib.new_build(self.path[6:], file_name) 154 self._set_response(code=201) 155 self.wfile.write( 156 "File received, saved into {}".format( 157 file_name).encode('utf-8') 158 ) 159 else: 160 self.send_error(400) 161 162 163class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): 164 pass 165 166 167def run_server(SeverClass=ThreadedHTTPServer, HandlerClass=RequestHandler, port=8000): 168 server_address = (LOCAL_ADDRESS, port) 169 server_instance = SeverClass(server_address, HandlerClass) 170 try: 171 logging.info( 172 'Server is on, address:\n %s', 173 'http://' + str(server_address[0]) + ':' + str(port)) 174 server_instance.serve_forever() 175 except KeyboardInterrupt: 176 pass 177 server_instance.server_close() 178 logging.info('Server has been turned off.') 179 180 181if __name__ == '__main__': 182 from sys import argv 183 print(argv) 184 logging.basicConfig(level=logging.INFO) 185 EXTRACT_DIR = None 186 if os.path.exists("otatools.zip"): 187 logging.info("Found otatools.zip, extracting...") 188 EXTRACT_DIR = "/tmp/otatools-" + str(os.getpid()) 189 os.makedirs(EXTRACT_DIR, exist_ok=True) 190 with zipfile.ZipFile("otatools.zip", "r") as zfp: 191 zfp.extractall(EXTRACT_DIR) 192 # mark all binaries executable by owner 193 bin_dir = os.path.join(EXTRACT_DIR, "bin") 194 for filename in os.listdir(bin_dir): 195 os.chmod(os.path.join(bin_dir, filename), stat.S_IRWXU) 196 logging.info("Extracted otatools to {}".format(EXTRACT_DIR)) 197 if not os.path.isdir('target'): 198 os.mkdir('target', 755) 199 if not os.path.isdir('output'): 200 os.mkdir('output', 755) 201 target_lib = TargetLib() 202 jobs = ProcessesManagement(otatools_dir=EXTRACT_DIR) 203 if len(argv) == 2: 204 run_server(port=int(argv[1])) 205 else: 206 run_server() 207