#!/usr/bin/env python
### phidx.cgi: CGI script for dynamically serving thumbnailed
### collections of images.
###
### Copyright (c) 2005 C. Michael Pilato
###
import os
import os.path
import re
import time
import urllib
import sys
import cgi
import string
import fnmatch
import ConfigParser
# EZT comes from the ezt/ subdir here.
sys.path.insert(0, "ezt")
try:
import ezt
except:
sys.stderr.write(
"Error importing 'ezt' (see https://github.com/gstein/ezt).\n"
"Install it and try again ('pip install -t ezt ezt' should work).\n")
sys.stderr.flush()
sys.exit(1)
__version__ = '4.2.0.kff-branch.%s' \
% re.match('\S+\s+(\S+)\s+\S+', "$Revision$").group(1)
###
### URL SCHEME:
###
### CGIURL[/ALBUM][/PATH_INFO][?OPTIONS]
###
### If PATH_INFO is a directory, shows a directory listing (subdirs
### at the top, images at the bottom). If a FILE, shows a thumbnail
### of the file.
###
### OPTIONS:
###
### t=[on|off] Thumbnail display on/off toggle
### s=SIZE Image size (subject to MAX_IMAGE_SIZE; 0 = original)
### d=[on|off] Direct image mode (no wrapping HTML) on/off toggle
### r=[0|1|2|3] Image rotation (number x 90 degrees counterclockwise)
###
############################################################################
# Global Variables
COOKIE_KEY = 'photo_opts='
IMAGE_EXTENSIONS = ['.jpg', '.gif', '.png']
class UnknownAlbumException(Exception):
pass
class MissingAlbumException(Exception):
pass
class OptionSet:
def __init__(self, options):
vars(self).update(options)
class Config:
def __init__(self):
cgi_dir = os.path.dirname(sys.argv[0])
template_file = os.path.join(cgi_dir, 'phidx.ezt')
conf_file = os.path.join(cgi_dir, 'phidx.conf')
self.parser = None
self.albums = {}
self.defaults = {
'max_image_size' : 640,
'thumbnail_size' : 120,
'template_file' : template_file,
'location' : None,
'ignores' : '.*, CVS',
'obscure' : 1,
}
# Parse the conf-file, if one exists.
if not os.path.isfile(conf_file):
return
self.parser = parser = ConfigParser.ConfigParser()
parser.read(conf_file)
if parser.has_section('defaults'):
self._parse_options_section('defaults', self.defaults)
if parser.has_section('albums'):
for key, value in parser.items('albums', raw=1):
self.albums[key] = value
def _parse_options_section(self, section, options):
for option in ['max_image_size',
'thumbnail_size',
'template',
'ignores',
'obscure',
]:
if self.parser.has_option(section, option):
value = self.parser.get(section, option)
try:
value = int(value)
except ValueError:
pass
options[option] = value
def get_albums(self):
return self.albums.keys()
def get_default_options(self):
return OptionSet(self.defaults.copy())
def get_album_options(self, album):
options = self.defaults.copy()
# If there's no album supplied, that's a problem.
if not album:
raise MissingAlbumException
# If we don't recognize the album (either because we have no
# config file, or because the album isn't present in that
# file), that, too, is a problem.
try:
options['location'] = self.albums[album]
except KeyError:
raise UnknownAlbumException, album
# If the album has an options overrides section, merge it into
# the defaults.
if self.parser.has_section(album):
self._parse_options_section(album, options)
# Finally, return the options.
return OptionSet(options)
def _cookie_parse(cookie):
# Parse the cookie into name/value pairs.
cookie_vars = {}
if cookie:
start = cookie.find(COOKIE_KEY)
if start != -1:
end = cookie[start:].find(';')
start = len(COOKIE_KEY)
if end == -1:
cookie = cookie[start:]
else:
cookie = cookie[start:start+end]
pieces = cookie.split(',')
for piece in pieces:
nameval = piece.split('=')
cookie_vars[nameval[0]] = len(nameval) > 1 and nameval[1] or ''
return cookie_vars
def _cookie_string(cookie_vars):
pieces = []
for name in cookie_vars.keys():
if cookie_vars[name]:
pieces.append(name + '=' + cookie_vars[name])
outstring = ','.join(pieces) or ''
if outstring:
outstring = '%s%s; path=/; expires=31-Dec-2012 23:59:59 GMT' \
% (COOKIE_KEY, outstring)
return outstring
def _cgi_parse():
cgi_data = cgi.parse()
cgi_vars = {}
for name in cgi_data.keys():
if cgi_data[name]:
cgi_vars[name] = cgi_data[name][0]
return cgi_vars
def _cgi_string(cgi_vars):
pieces = []
for name in cgi_vars.keys():
pieces.append(name + '=' + cgi_vars[name])
outstring = '&'.join(pieces)
return outstring and '?' + outstring or outstring
class Request:
def __init__(self):
"""Do some setup-ish stuff."""
# Get environment info.
remote_addr = os.environ.get('REMOTE_ADDR')
script_name = os.environ.get('SCRIPT_NAME')
server_name = os.environ.get('SERVER_NAME')
cookie = os.environ.get('HTTP_COOKIE')
# Build the script hrefs (one for running the script, one for
# appending photo path stuffs.
self.script_name = script_name
self.script_href = 'http://' + server_name + urllib.quote(script_name)
self.script_dir_href = os.path.dirname(self.script_href)
# Parse the path info.
path_info = os.environ.get('PATH_INFO')
path_pieces = []
if path_info:
path_pieces = filter(None, path_info.split('/'))
if '..' in path_pieces:
raise Exception, "Invalid URL"
self.album = None
if len(path_pieces):
self.album = path_pieces[0]
# Get the configuration bits.
do_listing = 0
self.config = Config()
try:
self.options = self.config.get_album_options(self.album)
del path_pieces[0]
except MissingAlbumException:
self.album = None
self.options = self.config.get_default_options()
do_listing = 1
path_info = '/'.join(path_pieces)
if path_info == '':
path_info = None
self.path_info = path_info
self.real_path = self.options.location
if self.path_info:
self.real_path = os.path.join(self.real_path, path_info)
# Get the current timestamp.
self.local_time = time.ctime()
# Handle the cookie.
self.cookie_vars = _cookie_parse(cookie)
# CGI options, fetch and validate.
self.cgi_vars = _cgi_parse()
if int(self.cgi_vars.get('s', 0)) > self.options.max_image_size:
self.cgi_vars['s'] = str(self.options.max_image_size)
# Merge cookie data into CGI data; CGI wins.
if not self.cgi_vars.has_key('s') \
and self.cookie_vars.get('s'):
self.cgi_vars['s'] = self.cookie_vars['s']
if not self.cgi_vars.has_key('t') \
and self.cookie_vars.get('t'):
self.cgi_vars['t'] = self.cookie_vars['t']
# What kind of request is this?
if do_listing:
self.do_album_listing()
elif os.path.isfile(self.real_path):
self.do_file()
else:
self.do_directory()
def _gen_url(self, path_info, cgi_vars):
base_href = self.script_href
if self.album:
base_href = base_href + '/' + urllib.quote(self.album)
if path_info:
base_href = base_href + '/' + urllib.quote(path_info)
return base_href + _cgi_string(cgi_vars)
def _init_template_data(self, is_dir):
data = {
'version' : __version__,
'thumbnail_size' : self.options.thumbnail_size,
'album' : self.album,
'path' : self.path_info,
'mode' : is_dir and "dir" or "file",
'localtime' : self.local_time,
}
return data
def _generate_output(self, data, extra_headers=[]):
template = ezt.Template(self.options.template, 1, ezt.FORMAT_RAW)
sys.stdout.write("Content-type: text/html\n")
for header in extra_headers:
sys.stdout.write(header + "\n")
sys.stdout.write("\n")
template.generate(sys.stdout, data)
sys.exit(0)
def _cached_thumbnail_path(self, path, size, rotate):
return os.path.join(os.path.dirname(self.real_path),
".phidx", "thumbnails", str(size), str(rotate),
os.path.basename(self.real_path))
def do_file(self):
"""Handle file displays."""
import mimetypes
mimetype = mimetypes.guess_type(self.real_path)[0]
base, ext = os.path.splitext(self.real_path)
if ext.lower() not in IMAGE_EXTENSIONS or not mimetype:
raise Exception, "Unsupported file format!"
size = int(self.cgi_vars.get('s', '0'))
rotate = int(self.cgi_vars.get('r', '0'))
if self.cgi_vars.get('d', 'off') == 'on':
# Direct mode -- we're serving a picture.
try:
import Image
# If thumbnailing, try for cached thumbnail image first,
# else generate it on the fly (and then save in cache).
c = self._cached_thumbnail_path(self.real_path, size, rotate)
if size and os.path.exists(c):
im = Image.open(open(c, 'rb'))
else:
im = Image.open(open(self.real_path, 'rb'))
if size:
im.thumbnail((size, size))
# Do rotation only after thumbnailing, for efficiency.
### TODO: Inexplicably, if we uncomment this rotation,
### the thumbnail fails to display the first time. It
### displays subsequently, when fetched from the cache,
### and with correct rotation too -- which means that
### the rotation worked and the image got saved in the
### cache. So why won't it display the first time???
# im = im.rotate(rotate * 90)
print "Content-type: %s\n" % (mimetype)
# Try to initialize cache, but fail silently if must.
if size:
try:
if not os.path.exists(c):
if not os.path.exists(os.path.dirname(c)):
os.makedirs(os.path.dirname(c))
im.save(open(c, 'wb'), im.format)
except Exception:
pass
im.save(sys.stdout, im.format)
except OSError:
raise Exception, "Unsupported file format!"
else:
# Indirect mode -- we're serving an HTML picture wrapper.
if not size:
raise Exception, "Script error -- no indirect mode " \
"support for unsized images."
rotate_l = (rotate + 1) % 4
rotate_r = (rotate - 1) % 4
# Generate output
data = self._init_template_data(0)
data.update({
'up_href' : self._gen_url(os.path.dirname(self.path_info), {}),
'prev_href' : None,
'next_href' : None,
'rotate_left_href' : self._gen_url(self.path_info,
{'s' : str(size),
'd' : 'off',
'r' : str(rotate_l)}),
'rotate_right_href' : self._gen_url(self.path_info,
{'s' : str(size),
'd' : 'off',
'r' : str(rotate_r)}),
'image_full_href' : self._gen_url(self.path_info,
{'s' : '0',
'd' : 'on',
'r' : str(rotate)}),
'image_href' : self._gen_url(self.path_info,
{'s' : str(size),
'd' : 'on',
'r' : str(rotate)}),
})
self._generate_output(data)
def _get_settings(self):
settings = []
thumbnail_options = []
thumbnail_options.append(_item(name='on', value='on'))
thumbnail_options.append(_item(name='off', value='off'))
settings.append(_item(description='Thumbnail display',
name='t',
value=self.cgi_vars.get('t', 'on') == 'on' \
and 'on' or 'off',
options=thumbnail_options))
size_options = []
size_options.append(_item(name='320', value='320'))
size_options.append(_item(name='640', value='640'))
size_options.append(_item(name='no maximum', value='0'))
settings.append(_item(description='Maximum image size (0 = none)',
name='s',
value=int(self.cgi_vars.get('s', '0')),
options=size_options))
return settings
def do_directory(self):
"""Handle directory listings."""
# -----------------------------------------------------------------
# Setup the directory listing section, which includes subdirectories
# and, if not displaying thumbnails, image files.
# -----------------------------------------------------------------
subdirs = []
images = []
base_path = self.path_info or ''
entries = os.listdir(self.real_path)
entries.sort()
ignores = map(string.strip,
filter(None,
string.split(self.options.ignores or '', ',')))
def _is_ignored(filename):
for ignore in ignores:
if fnmatch.fnmatch(filename, ignore):
return 1
return 0
for entry in entries:
# Skip ignored stuff
if _is_ignored(entry):
continue
real_path = os.path.join(self.real_path, entry)
if not os.access(real_path, os.R_OK):
continue
if os.path.isdir(real_path):
# Subdirectory
subdir = _item(name=entry,
href=self._gen_url(os.path.join(base_path,
entry),
self.cgi_vars))
subdirs.append(subdir)
else:
# File
base, ext = os.path.splitext(entry)
if ext.lower() not in IMAGE_EXTENSIONS:
continue
cgi_vars = self.cgi_vars.copy()
cgi_vars['d'] = 'off'
thumb_cgi_vars = self.cgi_vars.copy()
thumb_cgi_vars['s'] = str(self.options.thumbnail_size)
thumb_cgi_vars['d'] = 'on'
if not int(self.cgi_vars.get('s', '0')):
cgi_vars['d'] = 'on'
thumb_href = self._gen_url(os.path.join(base_path, entry),
thumb_cgi_vars)
img_href = self._gen_url(os.path.join(base_path, entry),
cgi_vars)
images.append(_item(name=entry,
href=img_href,
thumbnail_href=thumb_href))
up_href = None
if self.path_info:
up_href = self._gen_url(os.path.dirname(self.path_info),
self.cgi_vars)
subdirs.reverse() ### TODO : Custom sort
# -----------------------------------------------------------------
# Generate the output.
# -----------------------------------------------------------------
data = self._init_template_data(1)
data.update({
'settings' : self._get_settings(),
'settings_form_href' : self._gen_url(self.path_info, {}),
'up_href' : up_href,
'subdirs' : subdirs,
'images' : images,
'thumbnails' : ezt.boolean(self.cgi_vars.get('t', 'on') == 'on'),
})
self._generate_output(data,
['Set-cookie: %s'
% (_cookie_string(self.cgi_vars))])
def do_album_listing(self):
subdirs = []
albums = self.config.get_albums()
albums.sort()
for album in albums:
options = self.config.get_album_options(album)
if options.obscure != 0:
continue
subdir = _item(name=album,
href=self._gen_url(album, self.cgi_vars))
subdirs.append(subdir)
if not subdirs:
raise MissingAlbumException
data = self._init_template_data(1)
data.update({
'settings' : self._get_settings(),
'settings_form_href' : self._gen_url(self.path_info, {}),
'up_href' : None,
'subdirs' : subdirs,
'images' : [],
'thumbnails' : ezt.boolean(self.cgi_vars.get('t', 'on') == 'on'),
})
self._generate_output(data,
['Set-cookie: %s'
% (_cookie_string(self.cgi_vars))])
class _item:
def __init__(self, **kw):
vars(self).update(kw)
def test(path_info, query_string):
os.environ['SCRIPT_NAME'] = '/SCRIPT_NAME'
os.environ['SERVER_NAME'] = 'SERVER_NAME'
os.environ['PATH_INFO'] = path_info
os.environ['QUERY_STRING'] = query_string
req = Request()
def print_exception():
exc_type, exc, exc_tb = sys.exc_info()
try:
import traceback
tb = string.join(traceback.format_exception(exc_type, exc, exc_tb), '')
finally:
# Prevent circular reference. sys.exc_info documentation warns
# "Assigning the traceback return value to a local variable in
# a function that is handling an exception will cause a
# circular reference..." This is all based on 'exc_tb', and
# we're now done with it. Toss it.
del exc_tb
print 'Content-type: text/html\n'
print ''
print '
HTTP_COOKIE = %s
' \
% (os.environ.get('HTTP_COOKIE'))
print 'PATH_INFO = %s
' \
% (os.environ.get('PATH_INFO'))
print 'QUERY_STRING = %s
' \
% (os.environ.get('QUERY_STRING'))
print '%s
' % (tb)
print '
'
def main():
try:
req = Request()
except SystemExit:
pass
except MissingAlbumException:
print 'Content-type: text/html\n'
print 'Missing Album
'
print 'Unable to determine which album you wish to view.
'
except UnknownAlbumException, e:
print 'Content-type: text/html\n'
print 'Unknown Album
'
print 'There is no album named "%s" available for viewing.
' \
% (e)
except Exception:
print_exception()
if __name__ == "__main__":
if os.environ.has_key('DEBUG'):
test(sys.argv[1], sys.argv[2])
else:
main()