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