Hide keyboard shortcuts

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. 

5 

6import os 

7import glob 

8import posixpath 

9import mimetypes 

10from urllib.parse import unquote 

11from http.server import BaseHTTPRequestHandler 

12 

13from xpra.util import envbool, std, csv, AdHocStruct, repr_ellipsized 

14from xpra.platform.paths import get_desktop_background_paths 

15from xpra.log import Logger 

16 

17log = Logger("http") 

18 

19HTTP_ACCEPT_ENCODING = os.environ.get("XPRA_HTTP_ACCEPT_ENCODING", "br,gzip").split(",") 

20DIRECTORY_LISTING = envbool("XPRA_HTTP_DIRECTORY_LISTING", False) 

21 

22EXTENSION_TO_MIMETYPE = { 

23 ".wasm" : "application/wasm", 

24 ".js" : "text/javascript", 

25 ".css" : "text/css", 

26 } 

27 

28 

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 

44 

45 

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): 

57 

58 wbufsize = None #we flush explicitly when needed 

59 server_version = "Xpra-HTTP-Server" 

60 http_headers_cache = {} 

61 http_headers_time = {} 

62 

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) 

73 

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 

114 

115 

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) 

122 

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) 

129 

130 

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) 

142 

143 def get_headers(self): 

144 return self.may_reload_headers(self.http_headers_dirs) 

145 

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 

182 

183 

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) 

192 

193 def do_GET(self): 

194 self.handle_request() 

195 

196 def handle_request(self): 

197 content = self.send_head() 

198 if content: 

199 self.wfile.write(content) 

200 

201 def do_HEAD(self): 

202 self.send_head() 

203 

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