gallery.py: Rework headers to use an OrderedDict
[blog.git] / posts / gallery / gallery.py
1 #!/usr/bin/env python
2 #
3 # Copyright (C) 2010-2013 W. Trevor King <wking@tremily.us>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
18 """
19 CGI gallery server for a picture directory organized along::
20
21   pics
22   |-- some_directory
23   |   |-- a_picture.jpg
24   |   |-- another_picture.jpg
25   |   |-- ...
26   |-- another_directory
27   |   |-- a_picture.jpg
28   |   |-- captioned_picture.jpg
29   |   |-- captioned_picture.jpg.txt
30   |-- ...
31
32 With::
33
34   pics$ gallery.py
35
36 Note that you can store a caption for ``<PICTURE>`` as plain text in
37 ``<PICTURE>.txt``.
38
39 See RFC 3875 for more details on the the Common Gateway Interface.
40
41 This script can also be run as a Simple Common Gateway Interface
42 (SCGI) with the ``--scgi`` option.
43 """
44
45 import collections as _collections
46 import logging as _logging
47 import logging.handlers as _logging_handlers
48 import math as _math
49 import mimetypes as _mimetypes
50 import os as _os
51 import os.path as _os_path
52 import random as _random
53 import re as _re
54 import subprocess as _subprocess
55 try:  # Python 3
56     import urllib.parse as _urllib_parse
57 except ImportError:  # Python 2
58     import urlparse as _urllib_parse
59 import xml.sax.saxutils as _xml_sax_saxutils
60
61
62 __version__ = '0.5'
63
64
65 IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.tif', '.tiff', '.png', '.gif']
66 VIDEO_EXTENSIONS = ['.mov', '.mp4', '.ogv']
67 STREAMING_TYPES = ['video/ogg']
68 RESPONSES = {  # httplib takes half a second to load
69     200: 'OK',
70     400: 'Bad Request',
71     404: 'Not Found',
72     500: 'Internal Server Error',
73     }
74
75 LOG = _logging.getLogger('gallery.py')
76 LOG.addHandler(_logging.StreamHandler())
77 #LOG.addHandler(_logging_handlers.SysLogHandler())
78 LOG.handlers[0].setFormatter(
79     _logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
80 LOG.setLevel(_logging.DEBUG)
81 #LOG.setLevel(_logging.WARNING)
82
83
84 class CommandError(Exception):
85     def __init__(self, command, status, stdout=None, stderr=None):
86         strerror = ['Command failed (%d):\n  %s\n' % (status, stderr),
87                     'while executing\n  %s' % str(command)]
88         super(CommandError, self).__init__('\n'.join(strerror))
89         self.command = command
90         self.status = status
91         self.stdout = stdout
92         self.stderr = stderr
93
94
95 class HTTPError(Exception):
96     def __init__(self, status, message=None, content=None):
97         if message is None:
98             message = RESPONSES[status]
99         if content is None:
100             content = message
101         super(HTTPError, self).__init__('{} {}'.format(status, message))
102         self.status = status
103         self.message = message
104         self.content = content
105
106
107 class ProcessingComplete(Exception):
108     pass
109
110
111 def invoke(args, stdin=None, stdout=_subprocess.PIPE, stderr=_subprocess.PIPE,
112            expect=(0,), cwd=None, encoding=None):
113     """
114     expect should be a tuple of allowed exit codes.  cwd should be
115     the directory from which the command will be executed.  When
116     unicode_output == True, convert stdout and stdin strings to
117     unicode before returing them.
118     """
119     if cwd == None:
120         cwd = '.'
121     LOG.debug('{}$ {}'.format(cwd, ' '.join(args)))
122     try :
123         q = _subprocess.Popen(args, stdin=_subprocess.PIPE, stdout=stdout,
124                               stderr=stderr, cwd=cwd)
125     except OSError as e:
126         raise CommandError(args, status=e.args[0], stderr=e)
127     stdout,stderr = q.communicate(input=stdin)
128     status = q.wait()
129     LOG.debug('{:d}\n{}{}'.format(status, stdout, stderr))
130     if status not in expect:
131         raise CommandError(args, status, stdout, stderr)
132     return status, stdout, stderr
133
134 def is_image(filename):
135     for extension in IMAGE_EXTENSIONS:
136         if filename.lower().endswith(extension):
137             return True
138     return False
139
140 def is_video(filename):
141     for extension in IMAGE_EXTENSIONS:
142         if filename.lower().endswith(extension):
143             return True
144     return False
145
146 def image_base(filename):
147     parts = filename.rsplit('.', 1)
148     assert len(parts) == 2, parts
149     return parts[0]
150
151
152 class CGIGalleryServer (object):
153     def __init__(self, base_path='.',
154                  base_url='/',
155                  cache_path='/tmp/gallery-cache/',
156                  serve_originals=True):
157         self._base_path = _os_path.abspath(base_path)
158         self._base_url = base_url
159         self._cache_path = cache_path
160         self._serve_originals = serve_originals
161         self._text_charset = 'UTF-8'
162         self._url_regexp = _re.compile('^[a-zA-Z0-9._/-]*$')
163         self._rows = 3
164         self._columns = 3
165         self.header = []
166         self.footer = []
167
168     def _get_charset(self, headers):
169         content_type = headers.get('Content-type', '')
170         if 'charset=' in content_type:
171             return content_type.split('charset=', 1)[-1]
172
173     def _http_headers(self, mime='text/html', status=200):
174         msg = RESPONSES[status]
175         headers = _collections.OrderedDict()
176         headers['Status'] = '{:d} {}'.format(status, msg)
177         if mime.startswith('text/'):
178             charset = '; charset={}'.format(self._text_charset)
179         else:
180             charset = ''
181         headers['Content-type'] = '{}{}'.format(mime, charset)
182         return headers
183
184     def _add_header(self, headers=None, stream=None):
185         if headers and self._write_http_headers:
186             for key, value in headers.items():
187                 stream.write(
188                     '{}: {}\r\n'.format(key, value).encode('US-ASCII'))
189             stream.write('\r\n'.encode('US-ASCII'))
190
191     def _response(self, headers=None, content='<h1>It works!</h1>',
192                   stream=None):
193         if headers is None:
194             headers = self._http_headers()
195         self._add_header(headers=headers, stream=stream)
196         charset = self._get_charset(headers=headers)
197         if charset:
198             content = content.encode(charset)
199         stream.write(content)
200         raise ProcessingComplete()
201
202     def _response_stream(self, headers=None, content=None, stream=None,
203                          chunk_size=1024):
204         LOG.debug('streaming response')
205         if headers is None:
206             headers = self._http_headers()
207         charset = self._get_charset(headers=headers)
208         if charset:
209             raise HTTPError(
210                 500,
211                 content='charset {} set for streamed response'.format(charset))
212         self._add_header(headers=headers, stream=stream)
213         stream.flush()  # flush headers
214         while True:
215             chunk = content.read(chunk_size)
216             if not chunk:
217                 break
218             stream.write(chunk)
219         raise ProcessingComplete()
220
221     def _error(self, status=404, content=None, stream=None):
222         headers = self._http_headers(status=status)
223         if content is None:
224             content = RESPONSES[status]
225         self._response(headers=headers, content=content, stream=stream)
226
227     def validate_url(self, url, exists=True, directory=False):
228         LOG.debug('validating {} (exists={}, directory={})'.format(
229                 repr(url), exists, directory))
230         if url is None:
231             return
232         elif (not self._url_regexp.match(url) or
233             url.startswith('/') or
234             '..' in url
235             ):
236             LOG.error('invalid url')
237             raise HTTPError(404)
238         if exists:
239             path = _os_path.join(self._base_path, url)
240             if directory:
241                 if not _os_path.isdir(path):
242                     LOG.error('nonexistent directory')
243                     raise HTTPError(404)
244             else:
245                 if not _os_path.isfile(path):
246                     raise HTTPError(404, content='nonexistent file')
247
248     def serve(self, url=None, page=0, stream=None):
249         LOG.info('serving url {} (page {})'.format(url, page))
250         try:
251             try:
252                 if url is None:
253                     self.index(stream=stream)
254                 elif url.endswith('random'):
255                     self.random(
256                         url=url, stream=stream, max_width=500, max_height=500)
257                 elif self.is_cacheable(url=url):
258                     self.validate_url(url=url, exists=False)
259                     self.cached(url=url, stream=stream)
260                 else:
261                     self.validate_url(url=url, exists=False, directory=True)
262                     self.page(url=url, page=page, stream=stream)
263                 raise HTTPError(404, content='unexpected URL type')
264             except HTTPError as e:
265                 LOG.error(e.message)
266                 self._error(e.status, content=e.content, stream=stream)
267         except ProcessingComplete:
268             pass
269
270     def page_from_query(self, query=None, query_string=None):
271         """Extract the requested page from a query string
272
273         This is a helper method for CGIGalleryServer consumers.
274         Specify either query or query_string, but not both.
275         """
276         if query is None:
277             query = _urllib_parse.parse_qs(query_string)
278         page = 0
279         if 'pp' in query:
280             pp = query['pp']
281             if isinstance(pp, list):
282                 pp = pp[0]
283             try:
284                 page = int(pp) - 1
285             except ValueError:
286                 pass
287         return page
288
289     def relative_url(self, url):
290         if url is None:
291             return url
292         if not url.startswith(self._base_url):
293             content = 'cannot convert {} to a relative URL of {}'.format(
294                 url, self._base_url)
295             raise HTTPError(404, content=content)
296         if url == self._base_url:
297             return None
298         return url[len(self._base_url):]
299
300     def _url(self, path):
301         relpath = _os_path.relpath(
302             _os_path.join(self._base_path, path), self._base_path)
303         if relpath == '.':
304             relpath = ''
305         elif path.endswith('/'):
306             relpath += '/'
307         return '{}{}'.format(self._base_url, relpath)
308
309     def _label(self, path):
310         dirname,base = _os_path.split(path)
311         if not base:  # directory path ending with '/'
312             dirname,base = _os_path.split(dirname)
313         return base.replace('_', ' ').title()
314
315     def _link(self, path, text=None):
316         if text is None:
317             text = self._label(path)
318         return '<a href="{}">{}</a>'.format(self._url(path), text)
319
320     def _subdirs(self, path):
321         try:
322             order = [d.strip() for d in
323                      open(_os_path.join(path, '_order')).readlines()]
324         except IOError:
325             order = []
326         dirs = sorted(_os.listdir(path))
327         start = []
328         for d in order:
329             if d in dirs:
330                 start.append(d)
331                 dirs.remove(d)
332         for d in start + dirs:
333             dirpath = _os_path.join(path, d)
334             if _os_path.isdir(dirpath):
335                 yield dirpath
336
337     def _images(self, path):
338         for p in sorted(_os.listdir(path)):
339             if p.startswith('.') or p.endswith('~'):
340                 continue
341             picture_path = _os_path.join(path, p)
342             if is_image(picture_path):
343                 yield picture_path
344
345     def index(self, stream=None):
346         LOG.debug('index page')
347         return self._directory(self._base_path, stream=stream)
348
349     def _original_url(self, url):
350         """Reverse thumbnail URL mapping
351
352         Returns (original_url, generating_callback, callback_kwargs).
353         """
354         base,extension = _os_path.splitext(url)
355         if extension in ['.png']:
356             try:
357                 root,width,height = base.rsplit('-', 2)
358             except ValueError:
359                 raise HTTPError(
360                     400, content='missing width/height in {}'.format(base))
361             try:
362                 width = int(width)
363                 height = int(height)
364             except ValueError as e:
365                 raise HTTPError(
366                     400, content='invalid width/height: {}'.format(e))
367             return (
368                 root + '.jpg',
369                 self._thumb, 
370                 {'max_width': width,
371                  'max_height': height},
372                 )
373         elif extension in VIDEO_EXTENSIONS:
374             return (
375                 base + '.mov',
376                 getattr(self, '_{}'.format(extension), None),
377                 {},
378                 )
379         raise HTTPError(400, content='no original URL for {}'.format(url))
380
381     def _thumb(self, image, max_width=None, max_height=None):
382         if not _os_path.exists(self._cache_path):
383             _os.makedirs(self._cache_path)
384         dirname,filename = _os_path.split(image)
385         reldir = _os_path.relpath(dirname, self._base_path)
386         cache_dir = _os_path.join(self._cache_path, reldir)
387         if not _os_path.isdir(cache_dir):
388             _os.makedirs(cache_dir)
389         extension = '-{:d}-{:d}.png'.format(max_width, max_height)
390         thumb_filename = image_base(filename)+extension
391         thumb_url = _os_path.join(dirname, thumb_filename)
392         thumb_path = _os_path.join(cache_dir, thumb_filename)
393         image_path = _os_path.join(self._base_path, image)
394         if not _os_path.isfile(image_path):
395             raise HTTPError(
396                 404, content='image path for thumbnail does not exist')
397         if (not _os_path.isfile(thumb_path)
398             or _os_path.getmtime(image_path) > _os_path.getmtime(thumb_path)):
399             invoke(['convert', '-format', 'png', '-strip', '-quality', '95',
400                     image_path,
401                     '-thumbnail', '{:d}x{:d}'.format(max_width, max_height),
402                     thumb_path])
403         return (thumb_path, self._url(thumb_url))
404
405     def _mp4(self, video, *args):
406         if not video.endswith('.mov'):
407             raise HTTPError(
408                 500, content="can't translate {} to MPEGv4".format(video))
409         dirname,filename = _os_path.split(video)
410         mp4_filename = image_base(filename) + '.mp4'
411         reldir = _os_path.relpath(dirname, self._base_path)
412         cache_dir = _os_path.join(self._cache_path, reldir)
413         if not _os_path.isdir(cache_dir):
414             _os.makedirs(cache_dir)
415         mp4_url = _os_path.join(dirname, mp4_filename)
416         mp4_path = _os_path.join(cache_dir, mp4_filename)
417         if not _os_path.isfile(video):
418             raise HTTPError(404, content='source video path does not exist')
419         if (not _os_path.isfile(mp4_path)
420             or _os_path.getmtime(video) > _os_path.getmtime(mp4_path)):
421             arg = ['ffmpeg', '-i', video, '-acodec', 'libfaac', '-aq', '200',
422                    '-ac', '1', '-s', '640x480', '-vcodec', 'libx264',
423                    '-preset', 'slower', '-vpre', 'ipod640', '-b', '800k',
424                    '-bt', '800k', '-aspect', '640:480', '-threads', '0']
425             arg.extend(args)
426             arg.append(mp4_path)
427             invoke(arg)
428         return (mp4_path, self._url(mp4_url))
429
430     def _ogv(self, video, *args):
431         if not video.endswith('.mov'):
432             LOG.error("can't translate {} to Ogg Video".format(video))
433         dirname,filename = _os_path.split(video)
434         ogv_filename = image_base(filename) + '.ogv'
435         reldir = _os_path.relpath(dirname, self._base_path)
436         cache_dir = _os_path.join(self._cache_path, reldir)
437         if not _os_path.isdir(cache_dir):
438             _os.makedirs(cache_dir)
439         ogv_url = _os_path.join(dirname, ogv_filename)
440         ogv_path = _os_path.join(cache_dir, ogv_filename)
441         if not _os_path.isfile(video):
442             LOG.error('source video path does not exist')
443         if (not _os_path.isfile(ogv_path)
444             or _os_path.getmtime(video) > _os_path.getmtime(ogv_path)):
445             arg = ['ffmpeg2theora', '--optimize']
446             arg.extend(args)
447             arg.extend(['--output', ogv_path, video])
448             invoke(arg)
449         return (ogv_path, self._url(ogv_url))
450
451     def _get_image_caption(self, path):
452         caption_path = path + '.txt'
453         try:
454             return open(caption_path, 'r').read()
455         except IOError:
456             return None
457
458     def _get_image_video(self, path, fallback=None):
459         base_path = image_base(path)
460         for extension in VIDEO_EXTENSIONS:
461             video_path = base_path + extension
462             if _os_path.isfile(video_path):
463                 return self._video(video_path, fallback=fallback)
464         return None
465
466     def _captioned_video(self, path, href=None):
467         img = self._image(path, max_width=640, max_height=480)
468         caption = self._get_image_caption(path)
469         video = self._get_image_video(path, fallback=[img])
470         content = []
471         if video:
472             content.extend(video)
473             if href:
474                 content.append('<p>{}</p>'.format(
475                         self._link(path=href, text='gallery page')))
476         elif href:
477             content.append(self._link(path=href, text=img))
478         else:
479             content.append(img)
480         if caption:
481             caption = _xml_sax_saxutils.escape(caption)
482             content.append('<p>{}</p>'.format(caption))
483         return content
484
485     def _video(self, video, fallback=None, **kwargs):
486         if fallback is None:
487             fallback = [
488                 '<p>Your browser does not support the &lt;video&gt; tag, try',
489                 'downloading the video and playing it in an external player.',
490                 '</p>',
491                 ]
492         fallback = ['    '+line for line in fallback]
493         ogv_path,ogv_url = self._ogv(video)
494         mp4_path,mp4_url = self._mp4(video)
495         return [
496             '<p>',
497             ('  <video preloads="none" controls="controls" '
498              'width="640" height="480">'),
499             '    <source src="{}"'.format(mp4_url),
500             ('''            type='video/mp4; '''
501              '''codecs="avc1.42E01E, mp4a.40.2"' />'''),
502             '    <source src="{}"'.format(ogv_url),
503             '''            type='video/ogg; codecs="theora,vorbis"' />''',
504             ] + fallback + [
505             '  </video>',
506             '</p>',
507             '<p>Download as',
508             '  <a href="{}">Ogg/Theora/Vorbis</a> or'.format(ogv_url),
509             ('  <a href="{}">Mpeg4/H.264(ConstrainedBaselineProfile)/AAC</a>.'
510              ).format(mp4_url),
511             '<p>',
512             ]
513
514     def _image(self, image, **kwargs):
515         if kwargs:
516             image_path,image_url = self._thumb(image, **kwargs)
517         else:
518             image_url = image
519         sections = ['<img src="{}"'.format(image_url)]
520         caption = self._get_image_caption(path=image)
521         if caption:
522             caption = _xml_sax_saxutils.quoteattr(
523                 caption.replace('\n', ' ').strip())
524             sections.extend([
525                     'title={}'.format(caption),
526                     'alt={}'.format(caption),
527                     ])
528         sections.append('/>')
529         return ' '.join(sections)
530
531     def _image_page(self, image):
532         return image_base(image) + '/'
533
534     def random(self, url=None, stream=None, **kwargs):
535         LOG.debug('random image')
536         if url.endswith('/random'):
537             url = url[:(-len('/random'))]
538             self.validate_url(url=url, directory=True, stream=stream)
539             base_dir = _os_path.join(self._base_path, url)
540         elif url == 'random':
541             base_dir = self._base_path
542         else:
543             raise HTTPError(404)
544         images = []
545         for dirpath,dirnames,filenames in _os.walk(base_dir):
546             for filename in filenames:
547                 if is_image(filename):
548                     images.append(_os_path.join(dirpath, filename))
549         if not images:
550             self._response(content='<p>no images to choose from</p>',
551                            stream=stream)
552         image = _random.choice(images)
553         LOG.debug('selected random image {}'.format(image))
554         page = self._image_page(image)
555         content = self._captioned_video(path=image, href=page)
556         self._response(content='\n'.join(content), stream=stream)
557
558     def is_cacheable(self, url):
559         return is_image(url) or is_video(url)
560
561     def cached(self, url, stream=None):
562         LOG.debug('retrieving possibly cached item')
563         mime = _mimetypes.guess_type(url)[0]
564         if mime is None:
565             raise HTTPError(
566                 500, content='unknown mime type for {}'.format(url))
567         cache_path = _os_path.join(self._cache_path, url)
568         original_path = _os_path.join(self._base_path, url)
569         path = None
570         if _os_path.isfile(cache_path):
571             LOG.debug('return cached item {}'.format(cache_path))
572             path = cache_path
573         elif self._serve_originals and _os_path.isfile(original_path):
574             LOG.debug('return original item {}'.format(original_path))
575             path = original_path
576         else:
577             LOG.debug('possibly create cached item {}'.format(cache_path))
578             original_url,callback,kwargs = self._original_url(url)
579             original_path = _os_path.join(self._base_path, original_url)
580             if callback and _os_path.isfile(original_path):
581                 path,cache_url = callback(original_path, **kwargs)
582         if not path:
583             raise HTTPError(404)
584         try:
585             content = open(path, 'rb')
586         except IOError as e:
587             LOG.error(e)
588             raise HTTPError(404, content='item not found {}'.format(url))
589         headers = self._http_headers(mime=mime)
590         if mime in STREAMING_TYPES:
591             self._response_stream(
592                 headers=headers, content=content, stream=stream)
593         content = content.read()
594         self._response(headers=headers, content=content, stream=stream)
595
596     def page(self, url, page=0, stream=None):
597         LOG.debug('HTML page {} {}'.format(url, page))
598         if not url.endswith('/'):
599             raise HTTPError(
600                 404, content='HTML page URLs must end with a slash')
601         abspath = _os_path.join(self._base_path, url)
602         if _os_path.isdir(abspath):
603             self._directory(path=abspath, page=page, stream=stream)
604         for extension in IMAGE_EXTENSIONS:
605             file_path = abspath[:-1] + extension
606             if _os_path.isfile(file_path):
607                 self._page(path=file_path, stream=stream)
608         raise HTTPError(404, content='unknown HTML page {}'.format(url))
609
610     def _directory_header(self, path):
611         relpath = _os_path.relpath(path, self._base_path)
612         crumbs = []
613         dirname = relpath
614         while dirname:
615             dirname,base = _os_path.split(dirname)
616             if base != '.':
617                 crumbs.insert(0, base)
618         crumbs.insert(0, '')
619         links = [None] * len(crumbs)
620         for i,c in enumerate(crumbs):
621             if i < len(crumbs)-1:
622                 if i == 0:
623                     links[i] = self._link(self._base_path, 'Gallery')
624                 else:
625                     relpath = '/'.join(crumbs[1:i+1]) + '/'
626                     fullpath = _os_path.join(self._base_path, relpath)
627                     links[i] = self._link(path=fullpath)
628             else:
629                 if i == 0:
630                     links[i] = 'Gallery'
631                 else:
632                     links[i] = self._label(crumbs[i])
633         content = ['<h1>{}</h1>'.format(' '.join(links))]
634         return content
635
636     def _directory_page_navigation(self, path, page, pages):
637         if pages <= 1:
638             return []
639         prev_page = path + '?pp={:d}'.format((page - 1) % pages + 1)
640         next_page = path + '?pp={:d}'.format((page + 1) % pages + 1)
641         return [
642             '<div style="text-align: center;">',
643             '<p>',
644             self._link(prev_page, 'previous'),
645             '({:d} of {:d})'.format(page+1, pages),
646             self._link(next_page, 'next'),
647             '</p>',
648             '</div>', 
649            ]
650
651     def _directory_subdirs(self, path):
652         content = []
653         dirs = list(self._subdirs(path))
654         if dirs:
655             content.append('<ul>')
656             for d in dirs:
657                 content.append('  <li>{}</li>'.format(self._link(d+'/')))
658             content.append('</ul>')
659         return content
660
661     def _directory_images(self, path, images):
662         content = ['<table style="margin-left: auto; margin-right: auto;">']
663         column = 0
664         for image in images:
665             page = self._image_page(image)
666             img = self._image(image, max_width=300, max_height=300)
667             link = self._link(page, img)
668             if column == 0:
669                 content.append('  <tr>')
670             content.extend([
671                     '    <td style="text-align: center;">',
672                     '      {}'.format(link),
673                     '    </td>',
674                     ])
675             column += 1
676             if column == self._columns:
677                 content.append('  </tr>')
678                 column = 0
679         if column != 0:
680             #content.extend()
681             content.append('  </tr>')
682         content.append('</table>')
683         return content
684
685     def _directory(self, path, page=0, stream=None):
686         LOG.debug('directory page {} {}'.format(path, page))
687         images = list(self._images(path))
688         images_per_page = self._rows * self._columns
689         pages = int(_math.ceil(float(len(images)) / images_per_page)) or 1
690         if page < 0 or page >= pages:
691             raise HTTPError(
692                 404,
693                 content=(
694                     'page out of bounds for this gallery 0 <= {:d} < {:d}'
695                     ).format(page, pages))
696         first_image = images_per_page * page
697         images = images[first_image:first_image+images_per_page]
698         content = []
699         content.extend(self.header)
700         content.extend(self._directory_header(path))
701         nav = self._directory_page_navigation(path, page=page, pages=pages)
702         content.extend(nav)
703         content.extend(self._directory_subdirs(path))
704         content.extend(self._directory_images(path, images=images))
705         content.extend(nav)
706         content.extend(self.footer)
707         self._response(content='\n'.join(content), stream=stream)
708
709     def _page(self, path, stream=None):
710         LOG.debug('image page {}'.format(path))
711         gallery = _os_path.dirname(path)
712         images = list(self._images(gallery))
713         images_per_page = self._rows * self._columns
714         i = images.index(path)
715         page = i // images_per_page
716         gallery_page = '{}/?pp={:d}'.format(gallery, page + 1)
717         prev_page = self._image_page(images[i - 1])
718         next_page = self._image_page(images[(i + 1) % len(images)])
719         content = []
720         content.extend(self.header)
721         content.extend([
722                 '<div style="text-align: center;">',
723                 '<p>',
724                 self._link(prev_page, 'previous'),
725                 self._link(gallery_page, 'all'),
726                 self._link(next_page, 'next'),
727                 '</p>',
728                 ])
729         content.extend(self._captioned_video(path))
730         content.append('</div>')
731         content.extend(self.footer)
732         self._response(content='\n'.join(content), stream=stream)
733
734
735 def serve_cgi(server):
736     import cgi
737     import cgitb
738     import sys
739
740     url=None
741     cgitb.enable()
742     #cgitb.enable(display=0, logdir="/tmp/")
743     data = cgi.FieldStorage()
744     page = server.page_from_query(
745         query={key: data[key].getlist() for key in data.keys()})
746     if 'p' in data:
747         p = data['p']
748         if isinstance(p, list):
749             p = p[0]
750         url = p.value
751     server.serve(url=url, page=page, stream=sys.stdout)
752
753 def serve_scgi(server, host='localhost', port=4000):
754     import scgi
755     import scgi.scgi_server
756
757     class GalleryHandler(scgi.scgi_server.SCGIHandler):
758         def produce(self, env, bodysize, input, output):
759             #LOG.info(HTTP_USER_AGENT REQUEST_METHOD REMOTE_ADDR REQUEST_URI
760             url = env.get('DOCUMENT_URI', None)
761             page = server.page_from_query(
762                 query_string=env.get('QUERY_STRING', ''))
763             try:
764                 try:
765                     url = server.relative_url(url=url)
766                 except HTTPError as e:
767                     LOG.error(e.content)
768                     server._error(e.status, content=e.content, stream=output)
769             except ProcessingComplete:
770                 pass
771             else:
772                 server.serve(url=url, page=page, stream=output)
773
774     s = scgi.scgi_server.SCGIServer(
775         handler_class=GalleryHandler, host=host, port=port)
776     LOG.info('serving SCGI on {}:{}'.format(host, port))
777     s.serve()
778
779
780 if __name__ == '__main__':
781     import argparse as _argparse
782
783     parser = _argparse.ArgumentParser(
784         description=__doc__, version=__version__,
785         formatter_class=_argparse.RawDescriptionHelpFormatter)
786     parser.add_argument(
787         '--scgi', default=False, action='store_const', const=True,
788         help='Run as a SCGI server (vs. serving a single CGI call)')
789     parser.add_argument(
790         '--port', default=4000, type=int,
791         help='Port to listen to (if runing as a SCGI server)')
792     parser.add_argument(
793         '--base-path', default='.',
794         help='Path to the root gallery source')
795     parser.add_argument(
796         '--base-url', default='/',
797         help='URL for the root gallery source')
798     parser.add_argument(
799         '--shared-path', default=None,
800         help=('Optional path to the shared directory containing '
801               '`header.shtml` and `footer.shtml`'))
802     parser.add_argument(
803         '--cache-path', default='/tmp/gallery-cache',
804         help='Path to the thumbnail and movie cache directory')
805
806     args = parser.parse_args()
807
808     s = CGIGalleryServer(
809         base_path=args.base_path, base_url=args.base_url,
810         cache_path=args.cache_path)
811     if args.shared_path:
812         shared = args.shared_path
813         s.header = [open(_os_path.join(shared, 'header.shtml'), 'r').read()]
814         s.footer = [open(_os_path.join(shared, 'footer.shtml'), 'r').read()]
815
816     if args.scgi:
817         serve_scgi(server=s, port=args.port)
818     else:
819         serve_cgi(server=s)