Coverage for /home/antoine/projects/xpra-git/dist/python3/lib64/python/xpra/server/http_handler.py : 13%
Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This file is part of Xpra.
2# Copyright (C) 2016-2020 Antoine Martin <antoine@xpra.org>
3# Xpra is released under the terms of the GNU GPL v2, or, at your option, any
4# later version. See the file COPYING for details.
6import os
7import glob
8import posixpath
9import mimetypes
10from urllib.parse import unquote
11from http.server import BaseHTTPRequestHandler
13from xpra.util import envbool, std, csv, AdHocStruct, repr_ellipsized
14from xpra.platform.paths import get_desktop_background_paths
15from xpra.log import Logger
17log = Logger("http")
19HTTP_ACCEPT_ENCODING = os.environ.get("XPRA_HTTP_ACCEPT_ENCODING", "br,gzip").split(",")
20DIRECTORY_LISTING = envbool("XPRA_HTTP_DIRECTORY_LISTING", False)
22EXTENSION_TO_MIMETYPE = {
23 ".wasm" : "application/wasm",
24 ".js" : "text/javascript",
25 ".css" : "text/css",
26 }
29#should be converted to use standard library
30def parse_url(handler):
31 try:
32 args_str = handler.path.split("?", 1)[1]
33 except IndexError:
34 return {}
35 #parse args:
36 args = {}
37 for x in args_str.split("&"):
38 v = x.split("=", 1)
39 if len(v)==1:
40 args[v[0]] = ""
41 else:
42 args[v[0]] = v[1]
43 return args
46"""
47Xpra's builtin HTTP server.
48* locates the desktop background file at "/background.png" dynamically if missing,
49* supports the magic ?echo-headers query,
50* loads http headers from a directory (and caches the data),
51* sets cache headers on responses,
52* supports delegation to external script classes,
53* supports pre-compressed brotli and gzip, can gzip on-the-fly,
54(subclassed in WebSocketRequestHandler to add WebSocket support)
55"""
56class HTTPRequestHandler(BaseHTTPRequestHandler):
58 wbufsize = None #we flush explicitly when needed
59 server_version = "Xpra-HTTP-Server"
60 http_headers_cache = {}
61 http_headers_time = {}
63 def __init__(self, sock, addr,
64 web_root="/usr/share/xpra/www/",
65 http_headers_dirs=("/etc/xpra/http-headers",), script_paths=None):
66 self.web_root = web_root
67 self.http_headers_dirs = http_headers_dirs
68 self.script_paths = script_paths or {}
69 server = AdHocStruct()
70 server.logger = log
71 self.directory_listing = DIRECTORY_LISTING
72 super().__init__(sock, addr, server)
74 def translate_path(self, path):
75 #code duplicated from superclass since we can't easily inject the web_root..
76 s = path
77 # abandon query parameters
78 path = path.split('?',1)[0]
79 path = path.split('#',1)[0]
80 # Don't forget explicit trailing slash when normalizing. Issue17324
81 trailing_slash = path.rstrip().endswith('/')
82 path = posixpath.normpath(unquote(path))
83 words = path.split('/')
84 words = filter(None, words)
85 path = self.web_root
86 xdg_data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
87 www_dir_options = [self.web_root]+[os.path.join(x, "xpra", "www") for x in xdg_data_dirs.split(":")]
88 for p in www_dir_options:
89 if os.path.exists(p) and os.path.isdir(p):
90 path = p
91 break
92 for word in words:
93 word = os.path.splitdrive(word)[1]
94 word = os.path.split(word)[1]
95 if word in (os.curdir, os.pardir):
96 continue
97 path = os.path.join(path, word)
98 if trailing_slash:
99 path += '/'
100 #hack for locating the default desktop background at runtime:
101 if not os.path.exists(path) and s.endswith("/background.png"):
102 paths = get_desktop_background_paths()
103 for p in paths:
104 matches = glob.glob(p)
105 if matches:
106 path = matches[0]
107 break
108 if not os.path.exists(path):
109 #better send something than a 404,
110 #use a transparent 1x1 image:
111 path = os.path.join(self.web_root, "icons", "empty.png")
112 log("translate_path(%s)=%s", s, path)
113 return path
116 def log_error(self, fmt, *args):
117 #don't log 404s at error level:
118 if len(args)==2 and args[0]==404:
119 log(fmt, *args)
120 else:
121 log.error(fmt, *args)
123 def log_message(self, fmt, *args):
124 if args and len(args)==3 and fmt=='"%s" %s %s' and args[1]=="400":
125 fmt = '"%r" %s %s'
126 args = list(args)
127 args[0] = repr_ellipsized(args[0])
128 log(fmt, *args)
131 def end_headers(self):
132 #magic for querying request header values:
133 path = getattr(self, "path", "")
134 if path.endswith("?echo-headers"):
135 #ie: "en-GB,en-US;q=0.8,en;q=0.6"
136 accept = self.headers.get("Accept-Language")
137 if accept:
138 self.send_header("Echo-Accept-Language", std(accept, extras="-,./:;="))
139 for k,v in self.get_headers().items():
140 self.send_header(k, v)
141 BaseHTTPRequestHandler.end_headers(self)
143 def get_headers(self):
144 return self.may_reload_headers(self.http_headers_dirs)
146 @classmethod
147 def may_reload_headers(cls, http_headers_dirs):
148 if cls.http_headers_cache:
149 #do we need to refresh the cache?
150 mtimes = {}
151 for d in http_headers_dirs:
152 if os.path.exists(d) and os.path.isdir(d):
153 mtime = os.path.getmtime(d)
154 if mtime>cls.http_headers_time.get(d, -1):
155 mtimes[d] = mtime
156 if not mtimes:
157 return cls.http_headers_cache
158 log("headers directories have changed: %s", mtimes)
159 headers = {}
160 for d in http_headers_dirs:
161 if not os.path.exists(d) or not os.path.isdir(d):
162 continue
163 mtime = os.path.getmtime(d)
164 for f in sorted(os.listdir(d)):
165 header_file = os.path.join(d, f)
166 if not os.path.isfile(header_file):
167 continue
168 log("may_reload_headers() loading from '%s'", header_file)
169 with open(header_file, "r") as f:
170 for line in f:
171 sline = line.strip().rstrip('\r\n').strip()
172 if sline.startswith("#") or not sline:
173 continue
174 parts = sline.split("=", 1)
175 if len(parts)!=2:
176 continue
177 headers[parts[0]] = parts[1]
178 cls.http_headers_time[d] = mtime
179 log("may_reload_headers() headers=%s, mtime=%s", headers, mtime)
180 cls.http_headers_cache = headers
181 return headers
184 def do_POST(self):
185 try:
186 length = int(self.headers.get('content-length'))
187 data = self.rfile.read(length)
188 log("POST data=%s (%i bytes)", data, length)
189 self.handle_request()
190 except Exception:
191 log.error("Error processing POST request", exc_info=True)
193 def do_GET(self):
194 self.handle_request()
196 def handle_request(self):
197 content = self.send_head()
198 if content:
199 self.wfile.write(content)
201 def do_HEAD(self):
202 self.send_head()
204 #code taken from MIT licensed code in GzipSimpleHTTPServer.py
205 def send_head(self):
206 path = self.path.split("?",1)[0].split("#",1)[0]
207 script = self.script_paths.get(path)
208 log("send_head() script(%s)=%s", path, script)
209 if script:
210 log("request for %s handled using %s", path, script)
211 content = script(self)
212 return content
213 path = self.translate_path(self.path)
214 if not path:
215 self.send_error(404, "Path not found")
216 return None
217 if os.path.isdir(path):
218 if not path.endswith('/'):
219 # redirect browser - doing basically what apache does
220 self.send_response(301)
221 self.send_header("Location", path + "/")
222 self.end_headers()
223 return None
224 for index in "index.html", "index.htm":
225 index = os.path.join(path, index)
226 if os.path.exists(index):
227 path = index
228 break
229 else:
230 if not self.directory_listing:
231 self.send_error(403, "Directory listing forbidden")
232 return None
233 return self.list_directory(path).read()
234 ext = os.path.splitext(path)[1]
235 f = None
236 try:
237 # Always read in binary mode. Opening files in text mode may cause
238 # newline translations, making the actual size of the content
239 # transmitted *less* than the content-length!
240 f = open(path, 'rb')
241 fs = os.fstat(f.fileno())
242 content_length = fs[6]
243 headers = {}
244 content_type = EXTENSION_TO_MIMETYPE.get(ext)
245 if not content_type:
246 if not mimetypes.inited:
247 mimetypes.init()
248 ctype = mimetypes.guess_type(path, False)
249 if ctype and ctype[0]:
250 content_type = ctype[0]
251 log("guess_type(%s)=%s", path, content_type)
252 if content_type:
253 headers["Content-type"] = content_type
254 accept = self.headers.get('accept-encoding', '').split(",")
255 accept = tuple(x.split(";")[0].strip() for x in accept)
256 content = None
257 log("accept-encoding=%s", csv(accept))
258 for enc in HTTP_ACCEPT_ENCODING:
259 #find a matching pre-compressed file:
260 if enc not in accept:
261 continue
262 compressed_path = "%s.%s" % (path, enc) #ie: "/path/to/index.html.br"
263 if not os.path.exists(compressed_path):
264 continue
265 if not os.path.isfile(compressed_path):
266 log.warn("Warning: '%s' is not a file!", compressed_path)
267 continue
268 if not os.access(compressed_path, os.R_OK):
269 log.warn("Warning: '%s' is not readable", compressed_path)
270 continue
271 st = os.stat(compressed_path)
272 if st.st_size==0:
273 log.warn("Warning: '%s' is empty", compressed_path)
274 continue
275 log("sending pre-compressed file '%s'", compressed_path)
276 #read pre-gzipped file:
277 f.close()
278 f = None
279 f = open(compressed_path, 'rb')
280 content = f.read()
281 assert content, "no data in %s" % compressed_path
282 headers["Content-Encoding"] = enc
283 break
284 if not content:
285 content = f.read()
286 assert len(content)==content_length, \
287 "expected %s to contain %i bytes but read %i bytes" % (path, content_length, len(content))
288 if content_length>128 and \
289 ("gzip" in accept) and \
290 ("gzip" in HTTP_ACCEPT_ENCODING) \
291 and (ext not in (".png", )):
292 #gzip it on the fly:
293 import zlib
294 assert len(content)==content_length, \
295 "expected %s to contain %i bytes but read %i bytes" % (path, content_length, len(content))
296 gzip_compress = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS | 16)
297 compressed_content = gzip_compress.compress(content) + gzip_compress.flush()
298 if len(compressed_content)<content_length:
299 log("gzip compressed '%s': %i down to %i bytes", path, content_length, len(compressed_content))
300 headers["Content-Encoding"] = "gzip"
301 content = compressed_content
302 f.close()
303 f = None
304 headers["Content-Length"] = len(content)
305 headers["Last-Modified"] = self.date_time_string(fs.st_mtime)
306 #send back response headers:
307 self.send_response(200)
308 for k,v in headers.items():
309 self.send_header(k, v)
310 self.end_headers()
311 except IOError as e:
312 log("send_head()", exc_info=True)
313 log.error("Error sending '%s':", path)
314 emsg = str(e)
315 if emsg.endswith(": '%s'" % path):
316 log.error(" %s", emsg.rsplit(":", 1)[0])
317 else:
318 log.error(" %s", e)
319 try:
320 self.send_error(404, "File not found")
321 except OSError:
322 log("failed to send 404 error - maybe some of the headers were already sent?", exc_info=True)
323 return None
324 finally:
325 if f:
326 try:
327 f.close()
328 except OSError:
329 log("failed to close", exc_info=True)
330 return content