Errors in plist parsing could abort a directory listing. Reported by
[pyTivo/wmcbrine.git] / httpserver.py
blob20ccd794c1d94c9c4fbd8d7fc0ebf44e61069501
1 import BaseHTTPServer
2 import SocketServer
3 import cgi
4 import logging
5 import mimetypes
6 import os
7 import shutil
8 import socket
9 import time
10 from urllib import unquote_plus, quote
11 from xml.sax.saxutils import escape
13 from Cheetah.Template import Template
14 import config
15 from plugin import GetPlugin, EncodeUnicode
17 SCRIPTDIR = os.path.dirname(__file__)
19 SERVER_INFO = """<?xml version="1.0" encoding="utf-8"?>
20 <TiVoServer>
21 <Version>1.6</Version>
22 <InternalName>pyTivo</InternalName>
23 <InternalVersion>1.0</InternalVersion>
24 <Organization>pyTivo Developers</Organization>
25 <Comment>http://pytivo.sf.net/</Comment>
26 </TiVoServer>"""
28 VIDEO_FORMATS = """<?xml version="1.0" encoding="utf-8"?>
29 <TiVoFormats>
30 <Format><ContentType>video/x-tivo-mpeg</ContentType><Description/></Format>
31 </TiVoFormats>"""
33 VIDEO_FORMATS_TS = """<?xml version="1.0" encoding="utf-8"?>
34 <TiVoFormats>
35 <Format><ContentType>video/x-tivo-mpeg</ContentType><Description/></Format>
36 <Format><ContentType>video/x-tivo-mpeg-ts</ContentType><Description/></Format>
37 </TiVoFormats>"""
39 BASE_HTML = """<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
40 "http://www.w3.org/TR/html4/strict.dtd">
41 <html> <head><title>pyTivo</title></head> <body> %s </body> </html>"""
43 RELOAD = '<p>The <a href="%s">page</a> will reload in %d seconds.</p>'
44 UNSUP = '<h3>Unsupported Command</h3> <p>Query:</p> <ul>%s</ul>'
46 class TivoHTTPServer(SocketServer.ThreadingMixIn, BaseHTTPServer.HTTPServer):
47 def __init__(self, server_address, RequestHandlerClass):
48 self.containers = {}
49 self.stop = False
50 self.restart = False
51 self.logger = logging.getLogger('pyTivo')
52 BaseHTTPServer.HTTPServer.__init__(self, server_address,
53 RequestHandlerClass)
54 self.daemon_threads = True
56 def add_container(self, name, settings):
57 if name in self.containers or name == 'TiVoConnect':
58 raise "Container Name in use"
59 try:
60 self.containers[name] = settings
61 except KeyError:
62 self.logger.error('Unable to add container ' + name)
64 def reset(self):
65 self.containers.clear()
66 for section, settings in config.getShares():
67 self.add_container(section, settings)
69 def handle_error(self, request, client_address):
70 self.logger.exception('Exception during request from %s' %
71 (client_address,))
73 def set_beacon(self, beacon):
74 self.beacon = beacon
76 def set_service_status(self, status):
77 self.in_service = status
79 class TivoHTTPHandler(BaseHTTPServer.BaseHTTPRequestHandler):
80 def __init__(self, request, client_address, server):
81 self.wbufsize = 0x10000
82 self.server_version = 'pyTivo/1.0'
83 self.sys_version = ''
84 BaseHTTPServer.BaseHTTPRequestHandler.__init__(self, request,
85 client_address, server)
87 def address_string(self):
88 host, port = self.client_address[:2]
89 return host
91 def version_string(self):
92 """ Override version_string() so it doesn't include the Python
93 version.
95 """
96 return self.server_version
98 def do_GET(self):
99 tsn = self.headers.getheader('TiVo_TCD_ID',
100 self.headers.getheader('tsn', ''))
101 if not self.authorize(tsn):
102 return
103 if tsn:
104 ip = self.address_string()
105 config.tivos[tsn] = ip
107 if not tsn in config.tivo_names or config.tivo_names[tsn] == tsn:
108 config.tivo_names[tsn] = self.server.beacon.get_name(ip)
110 if '?' in self.path:
111 path, opts = self.path.split('?', 1)
112 query = cgi.parse_qs(opts)
113 else:
114 path = self.path
115 query = {}
117 if path == '/TiVoConnect':
118 self.handle_query(query, tsn)
119 else:
120 ## Get File
121 splitpath = [x for x in unquote_plus(path).split('/') if x]
122 if splitpath:
123 self.handle_file(query, splitpath)
124 else:
125 ## Not a file not a TiVo command
126 self.infopage()
128 def do_POST(self):
129 tsn = self.headers.getheader('TiVo_TCD_ID',
130 self.headers.getheader('tsn', ''))
131 if not self.authorize(tsn):
132 return
133 ctype, pdict = cgi.parse_header(self.headers.getheader('content-type'))
134 if ctype == 'multipart/form-data':
135 query = cgi.parse_multipart(self.rfile, pdict)
136 else:
137 length = int(self.headers.getheader('content-length'))
138 qs = self.rfile.read(length)
139 query = cgi.parse_qs(qs, keep_blank_values=1)
140 self.handle_query(query, tsn)
142 def do_command(self, query, command, target, tsn):
143 for name, container in config.getShares(tsn):
144 if target == name:
145 plugin = GetPlugin(container['type'])
146 if hasattr(plugin, command):
147 self.cname = name
148 self.container = container
149 method = getattr(plugin, command)
150 method(self, query)
151 return True
152 else:
153 break
154 return False
156 def handle_query(self, query, tsn):
157 mname = False
158 if 'Command' in query and len(query['Command']) >= 1:
160 command = query['Command'][0]
162 # If we are looking at the root container
163 if (command == 'QueryContainer' and
164 (not 'Container' in query or query['Container'][0] == '/')):
165 self.root_container()
166 return
168 if 'Container' in query:
169 # Dispatch to the container plugin
170 basepath = query['Container'][0].split('/')[0]
171 if self.do_command(query, command, basepath, tsn):
172 return
174 elif command == 'QueryItem':
175 path = query.get('Url', [''])[0]
176 splitpath = [x for x in unquote_plus(path).split('/') if x]
177 if splitpath and not '..' in splitpath:
178 if self.do_command(query, command, splitpath[0], tsn):
179 return
181 elif (command == 'QueryFormats' and 'SourceFormat' in query and
182 query['SourceFormat'][0].startswith('video')):
183 if config.is_ts_capable(tsn):
184 self.send_xml(VIDEO_FORMATS_TS)
185 else:
186 self.send_xml(VIDEO_FORMATS)
187 return
189 elif command == 'QueryServer':
190 self.send_xml(SERVER_INFO)
191 return
193 elif command in ('FlushServer', 'ResetServer'):
194 # Does nothing -- included for completeness
195 self.send_response(200)
196 self.end_headers()
197 return
199 # If we made it here it means we couldn't match the request to
200 # anything.
201 self.unsupported(query)
203 def handle_file(self, query, splitpath):
204 if '..' not in splitpath: # Protect against path exploits
205 ## Pass it off to a plugin?
206 for name, container in self.server.containers.items():
207 if splitpath[0] == name:
208 self.cname = name
209 self.container = container
210 base = os.path.normpath(container['path'])
211 path = os.path.join(base, *splitpath[1:])
212 plugin = GetPlugin(container['type'])
213 plugin.send_file(self, path, query)
214 return
216 ## Serve it from a "content" directory?
217 base = os.path.join(SCRIPTDIR, *splitpath[:-1])
218 path = os.path.join(base, 'content', splitpath[-1])
220 if os.path.isfile(path):
221 try:
222 handle = open(path, 'rb')
223 except:
224 self.send_error(404)
225 return
227 # Send the header
228 mime = mimetypes.guess_type(path)[0]
229 self.send_response(200)
230 if mime:
231 self.send_header('Content-type', mime)
232 self.send_header('Content-length', os.path.getsize(path))
233 self.end_headers()
235 # Send the body of the file
236 try:
237 shutil.copyfileobj(handle, self.wfile)
238 except:
239 pass
240 handle.close()
241 return
243 ## Give up
244 self.send_error(404)
246 def authorize(self, tsn=None):
247 # if allowed_clients is empty, we are completely open
248 allowed_clients = config.getAllowedClients()
249 if not allowed_clients or (tsn and config.isTsnInConfig(tsn)):
250 return True
251 client_ip = self.client_address[0]
252 for allowedip in allowed_clients:
253 if client_ip.startswith(allowedip):
254 return True
256 self.send_fixed('Unauthorized.', 'text/plain', 403)
257 return False
259 def log_message(self, format, *args):
260 self.server.logger.info("%s [%s] %s" % (self.address_string(),
261 self.log_date_time_string(), format%args))
263 def send_fixed(self, page, mime, code=200, refresh=''):
264 self.send_response(code)
265 self.send_header('Content-Type', mime)
266 self.send_header('Content-Length', len(page))
267 self.send_header('Connection', 'close')
268 self.send_header('Expires', '0')
269 if refresh:
270 self.send_header('Refresh', refresh)
271 self.end_headers()
272 self.wfile.write(page)
274 def send_xml(self, page):
275 self.send_fixed(page, 'text/xml')
277 def send_html(self, page, code=200, refresh=''):
278 self.send_fixed(page, 'text/html; charset=utf-8', code, refresh)
280 def root_container(self):
281 tsn = self.headers.getheader('TiVo_TCD_ID', '')
282 tsnshares = config.getShares(tsn)
283 tsncontainers = []
284 for section, settings in tsnshares:
285 try:
286 mime = GetPlugin(settings['type']).CONTENT_TYPE
287 if mime.split('/')[1] in ('tivo-videos', 'tivo-music',
288 'tivo-photos'):
289 settings['content_type'] = mime
290 tsncontainers.append((section, settings))
291 except Exception, msg:
292 self.server.logger.error(section + ' - ' + str(msg))
293 t = Template(file=os.path.join(SCRIPTDIR, 'templates',
294 'root_container.tmpl'),
295 filter=EncodeUnicode)
296 t.containers = tsncontainers
297 t.hostname = socket.gethostname()
298 t.escape = escape
299 t.quote = quote
300 self.send_xml(str(t))
302 def infopage(self):
303 t = Template(file=os.path.join(SCRIPTDIR, 'templates',
304 'info_page.tmpl'),
305 filter=EncodeUnicode)
306 t.admin = ''
308 if config.get_server('tivo_mak') and config.get_server('togo_path'):
309 t.togo = '<br>Pull from TiVos:<br>'
310 else:
311 t.togo = ''
313 if (config.get_server('tivo_username') and
314 config.get_server('tivo_password')):
315 t.shares = '<br>Push from video shares:<br>'
316 else:
317 t.shares = ''
319 for section, settings in config.getShares():
320 plugin_type = settings.get('type')
321 if plugin_type == 'settings':
322 t.admin += ('<a href="/TiVoConnect?Command=Settings&amp;' +
323 'Container=' + quote(section) +
324 '">Web Configuration</a><br>')
325 elif plugin_type == 'togo' and t.togo:
326 for tsn in config.tivos:
327 if tsn:
328 t.togo += ('<a href="/TiVoConnect?' +
329 'Command=NPL&amp;Container=' + quote(section) +
330 '&amp;TiVo=' + config.tivos[tsn] + '">' +
331 escape(config.tivo_names[tsn]) + '</a><br>')
332 elif plugin_type and t.shares:
333 plugin = GetPlugin(plugin_type)
334 if hasattr(plugin, 'Push'):
335 t.shares += ('<a href="/TiVoConnect?Command=' +
336 'QueryContainer&amp;Container=' +
337 quote(section) + '&Format=text/html">' +
338 section + '</a><br>')
340 self.send_html(str(t))
342 def unsupported(self, query):
343 message = UNSUP % '\n'.join(['<li>%s: %s</li>' % (escape(key),
344 escape(repr(value)))
345 for key, value in query.items()])
346 text = BASE_HTML % message
347 self.send_html(text, code=404)
349 def redir(self, message, seconds=2):
350 url = self.headers.getheader('Referer')
351 if url:
352 message += RELOAD % (escape(url), seconds)
353 refresh = '%d; url=%s' % (seconds, url)
354 else:
355 refresh = ''
356 text = (BASE_HTML % message).encode('utf-8')
357 self.send_html(text, refresh=refresh)