Skip to content

Instantly share code, notes, and snippets.

@robcowie
Created April 20, 2010 18:14
Show Gist options
  • Save robcowie/372849 to your computer and use it in GitHub Desktop.
Save robcowie/372849 to your computer and use it in GitHub Desktop.

Revisions

  1. robcowie created this gist Apr 20, 2010.
    505 changes: 505 additions & 0 deletions woof.py
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,505 @@
    #!/usr/bin/env python
    # -*- encoding: utf-8 -*-
    #
    # woof -- an ad-hoc single file webserver
    # Copyright (C) 2004-2009 Simon Budig <[email protected]>
    #
    # This program is free software; you can redistribute it and/or modify
    # it under the terms of the GNU General Public License as published by
    # the Free Software Foundation; either version 2 of the License, or
    # (at your option) any later version.
    #
    # This program is distributed in the hope that it will be useful,
    # but WITHOUT ANY WARRANTY; without even the implied warranty of
    # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    # GNU General Public License for more details.
    #
    # A copy of the GNU General Public License is available at
    # http://www.fsf.org/licenses/gpl.txt, you can also write to the
    # Free Software Foundation, Inc., 59 Temple Place - Suite 330,
    # Boston, MA 02111-1307, USA.

    # Darwin support with the help from Mat Caughron, <[email protected]>
    # Solaris support by Colin Marquardt, <[email protected]>
    # FreeBSD support with the help from Andy Gimblett, <[email protected]>
    # Cygwin support by Stefan Reichör <[email protected]>
    # tarfile usage suggested by Morgan Lefieux <[email protected]>
    # File upload support loosely based on code from Stephen English <[email protected]>

    import sys, os, errno, socket, getopt, commands, tempfile
    import cgi, urllib, BaseHTTPServer
    import ConfigParser
    import shutil, tarfile, zipfile
    import struct

    maxdownloads = 1
    TM = object
    cpid = -1
    compressed = 'gz'
    upload = False


    class EvilZipStreamWrapper(TM):
    def __init__ (self, victim):
    self.victim_fd = victim
    self.position = 0
    self.tells = []
    self.in_file_data = 0

    def tell (self):
    self.tells.append (self.position)
    return self.position

    def seek (self, offset, whence = 0):
    if offset != 0:
    if offset == self.tells[0] + 14:
    # the zipfile module tries to fix up the file header.
    # write Data descriptor header instead,
    # the next write from zipfile
    # is CRC, compressed_size and file_size (as required)
    self.write ("PK\007\010")
    elif offset == self.tells[1]:
    # the zipfile module goes to the end of the file. The next
    # data written definitely is infrastructure (in_file_data = 0)
    self.tells = []
    self.in_file_data = 0
    else:
    raise "unexpected seek for EvilZipStreamWrapper"

    def write (self, data):
    # only test for headers if we know that we're not writing
    # (potentially compressed) data.
    if self.in_file_data == 0:
    if data[:4] == zipfile.stringFileHeader:
    # fix the file header for extra Data descriptor
    hdr = list (struct.unpack (zipfile.structFileHeader, data[:30]))
    hdr[3] |= (1 << 3)
    data = struct.pack (zipfile.structFileHeader, *hdr) + data[30:]
    self.in_file_data = 1
    elif data[:4] == zipfile.stringCentralDir:
    # fix the directory entry to match file header.
    hdr = list (struct.unpack (zipfile.structCentralDir, data[:46]))
    hdr[5] |= (1 << 3)
    data = struct.pack (zipfile.structCentralDir, *hdr) + data[46:]

    self.position += len (data)
    self.victim_fd.write (data)

    def __getattr__ (self, name):
    return getattr (self.victim_fd, name)


    # Utility function to guess the IP (as a string) where the server can be
    # reached from the outside. Quite nasty problem actually.

    def find_ip ():
    # we get a UDP-socket for the TEST-networks reserved by IANA.
    # It is highly unlikely, that there is special routing used
    # for these networks, hence the socket later should give us
    # the ip address of the default route.
    # We're doing multiple tests, to guard against the computer being
    # part of a test installation.

    candidates = []
    for test_ip in ["192.0.2.0", "198.51.100.0", "203.0.113.0"]:
    s = socket.socket (socket.AF_INET, socket.SOCK_DGRAM)
    s.connect ((test_ip, 80))
    ip_addr = s.getsockname ()[0]
    s.close ()
    if ip_addr in candidates:
    return ip_addr
    candidates.append (ip_addr)

    return candidates[0]


    # Main class implementing an HTTP-Requesthandler, that serves just a single
    # file and redirects all other requests to this file (this passes the actual
    # filename to the client).
    # Currently it is impossible to serve different files with different
    # instances of this class.

    class FileServHTTPRequestHandler (BaseHTTPServer.BaseHTTPRequestHandler):
    server_version = "Simons FileServer"
    protocol_version = "HTTP/1.0"

    filename = "."

    def log_request (self, code='-', size='-'):
    if code == 200:
    BaseHTTPServer.BaseHTTPRequestHandler.log_request (self, code, size)


    def do_POST (self):
    global maxdownloads, upload

    if not upload:
    self.send_error (501, "Unsupported method (POST)")
    return

    # taken from
    # http://mail.python.org/pipermail/python-list/2006-September/402441.html

    ctype, pdict = cgi.parse_header (self.headers.getheader ('Content-Type'))
    form = cgi.FieldStorage (fp = self.rfile,
    headers = self.headers,
    environ = {'REQUEST_METHOD' : 'POST'},
    keep_blank_values = 1,
    strict_parsing = 1)
    if not form.has_key ("upfile"):
    self.send_error (403, "No upload provided")
    return

    upfile = form["upfile"]

    if not upfile.file or not upfile.filename:
    self.send_error (403, "No upload provided")
    return

    upfilename = upfile.filename

    if "\\" in upfilename:
    upfilename = upfilename.split ("\\")[-1]

    upfilename = os.path.basename (upfile.filename)

    destfile = None
    for suffix in ["", ".1", ".2", ".3", ".4", ".5", ".6", ".7", ".8", ".9"]:
    destfilename = os.path.join (".", upfilename + suffix)
    try:
    destfile = os.open (destfilename, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0644)
    break
    except OSError, e:
    if e.errno == errno.EEXIST:
    continue
    raise

    if not destfile:
    upfilename += "."
    destfile, destfilename = tempfile.mkstemp (prefix = upfilename, dir = ".")

    print >>sys.stderr, "accepting uploaded file: %s -> %s" % (upfilename, destfilename)

    shutil.copyfileobj (upfile.file, os.fdopen (destfile, "w"))

    if upfile.done == -1:
    self.send_error (408, "upload interrupted")

    txt = """\
    <html>
    <head><title>Woof Upload</title></head>
    <body>
    <h1>Woof Upload complete</title></h1>
    <p>Thanks a lot!</p>
    </body>
    </html>
    """
    self.send_response (200)
    self.send_header ("Content-Type", "text/html")
    self.send_header ("Content-Length", str (len (txt)))
    self.end_headers ()
    self.wfile.write (txt)

    maxdownloads -= 1

    return


    def do_GET (self):
    global maxdownloads, cpid, compressed, upload

    # Form for uploading a file
    if upload:
    txt = """\
    <html>
    <head><title>Woof Upload</title></head>
    <body>
    <h1>Woof Upload</title></h1>
    <form name="upload" method="POST" enctype="multipart/form-data">
    <p><input type="file" name="upfile" /></p>
    <p><input type="submit" value="Upload!" /></p>
    </form>
    </body>
    </html>
    """
    self.send_response (200)
    self.send_header ("Content-Type", "text/html")
    self.send_header ("Content-Length", str (len (txt)))
    self.end_headers ()
    self.wfile.write (txt)
    return

    # Redirect any request to the filename of the file to serve.
    # This hands over the filename to the client.

    self.path = urllib.quote (urllib.unquote (self.path))
    location = "/" + urllib.quote (os.path.basename (self.filename))
    if os.path.isdir (self.filename):
    if compressed == 'gz':
    location += ".tar.gz"
    elif compressed == 'bz2':
    location += ".tar.bz2"
    elif compressed == 'zip':
    location += ".zip"
    else:
    location += ".tar"

    if self.path != location:
    txt = """\
    <html>
    <head><title>302 Found</title></head>
    <body>302 Found <a href="%s">here</a>.</body>
    </html>\n""" % location
    self.send_response (302)
    self.send_header ("Location", location)
    self.send_header ("Content-Type", "text/html")
    self.send_header ("Content-Length", str (len (txt)))
    self.end_headers ()
    self.wfile.write (txt)
    return

    maxdownloads -= 1

    # let a separate process handle the actual download, so that
    # multiple downloads can happen simultaneously.

    cpid = os.fork ()

    if cpid == 0:
    # Child process
    child = None
    type = None

    if os.path.isfile (self.filename):
    type = "file"
    elif os.path.isdir (self.filename):
    type = "dir"

    if not type:
    print >> sys.stderr, "can only serve files or directories. Aborting."
    sys.exit (1)

    self.send_response (200)
    self.send_header ("Content-Type", "application/octet-stream")
    if os.path.isfile (self.filename):
    self.send_header ("Content-Length",
    os.path.getsize (self.filename))
    self.end_headers ()

    try:
    if type == "file":
    datafile = file (self.filename)
    shutil.copyfileobj (datafile, self.wfile)
    datafile.close ()
    elif type == "dir":
    if compressed == 'zip':
    ezfile = EvilZipStreamWrapper (self.wfile)
    zfile = zipfile.ZipFile (ezfile, 'w', zipfile.ZIP_DEFLATED)
    stripoff = os.path.dirname (self.filename) + os.sep

    for root, dirs, files in os.walk (self.filename):
    for f in files:
    filename = os.path.join (root, f)
    if filename[:len (stripoff)] != stripoff:
    raise RuntimeException, "invalid filename assumptions, please report!"
    zfile.write (filename, filename[len (stripoff):])
    zfile.close ()
    else:
    tfile = tarfile.open (mode=('w|' + compressed),
    fileobj=self.wfile)
    tfile.add (self.filename,
    arcname=os.path.basename(self.filename))
    tfile.close ()
    except Exception, e:
    print e
    print >>sys.stderr, "Connection broke. Aborting"


    def serve_files (filename, maxdown = 1, ip_addr = '', port = 8080):
    global maxdownloads

    maxdownloads = maxdown

    # We have to somehow push the filename of the file to serve to the
    # class handling the requests. This is an evil way to do this...

    FileServHTTPRequestHandler.filename = filename

    try:
    httpd = BaseHTTPServer.HTTPServer ((ip_addr, port),
    FileServHTTPRequestHandler)
    except socket.error:
    print >>sys.stderr, "cannot bind to IP address '%s' port %d" % (ip_addr, port)
    sys.exit (1)

    if not ip_addr:
    ip_addr = find_ip ()
    if ip_addr:
    print "Now serving on http://%s:%s/" % (ip_addr, httpd.server_port)

    while cpid != 0 and maxdownloads > 0:
    httpd.handle_request ()



    def usage (defport, defmaxdown, errmsg = None):
    name = os.path.basename (sys.argv[0])
    print >>sys.stderr, """
    Usage: %s [-i <ip_addr>] [-p <port>] [-c <count>] <file>
    %s [-i <ip_addr>] [-p <port>] [-c <count>] [-z|-j|-Z|-u] <dir>
    %s [-i <ip_addr>] [-p <port>] [-c <count>] -s
    %s [-i <ip_addr>] [-p <port>] [-c <count>] -U
    Serves a single file <count> times via http on port <port> on IP
    address <ip_addr>.
    When a directory is specified, an tar archive gets served. By default
    it is gzip compressed. You can specify -z for gzip compression,
    -j for bzip2 compression, -Z for ZIP compression or -u for no compression.
    You can configure your default compression method in the configuration
    file described below.
    When -s is specified instead of a filename, %s distributes itself.
    When -U is specified, woof provides an upload form and allows uploading files.
    defaults: count = %d, port = %d
    You can specify different defaults in two locations: /etc/woofrc
    and ~/.woofrc can be INI-style config files containing the default
    port and the default count. The file in the home directory takes
    precedence. The compression methods are "off", "gz", "bz2" or "zip".
    Sample file:
    [main]
    port = 8008
    count = 2
    ip = 127.0.0.1
    compressed = gz
    """ % (name, name, name, name, name, defmaxdown, defport)

    if errmsg:
    print >>sys.stderr, errmsg
    print >>sys.stderr
    sys.exit (1)



    def main ():
    global cpid, upload, compressed

    maxdown = 1
    port = 8080
    ip_addr = ''

    config = ConfigParser.ConfigParser()
    config.read (['/etc/woofrc', os.path.expanduser('~/.woofrc')])

    if config.has_option ('main', 'port'):
    port = config.getint ('main', 'port')

    if config.has_option ('main', 'count'):
    maxdown = config.getint ('main', 'count')

    if config.has_option ('main', 'ip'):
    ip_addr = config.get ('main', 'ip')

    if config.has_option ('main', 'compressed'):
    formats = { 'gz' : 'gz',
    'true' : 'gz',
    'bz' : 'bz2',
    'bz2' : 'bz2',
    'zip' : 'zip',
    'off' : '',
    'false' : '' }
    compressed = config.get ('main', 'compressed')
    compressed = formats.get (compressed, 'gz')

    defaultport = port
    defaultmaxdown = maxdown

    try:
    options, filenames = getopt.getopt (sys.argv[1:], "hUszjZui:c:p:")
    except getopt.GetoptError, desc:
    usage (defaultport, defaultmaxdown, desc)

    for option, val in options:
    if option == '-c':
    try:
    maxdown = int (val)
    if maxdown <= 0:
    raise ValueError
    except ValueError:
    usage (defaultport, defaultmaxdown,
    "invalid download count: %r. "
    "Please specify an integer >= 0." % val)

    elif option == '-i':
    ip_addr = val

    elif option == '-p':
    try:
    port = int (val)
    except ValueError:
    usage (defaultport, defaultmaxdown,
    "invalid port number: %r. Please specify an integer" % val)

    elif option == '-s':
    filenames.append (__file__)

    elif option == '-h':
    usage (defaultport, defaultmaxdown)

    elif option == '-U':
    upload = True

    elif option == '-z':
    compressed = 'gz'
    elif option == '-j':
    compressed = 'bz2'
    elif option == '-Z':
    compressed = 'zip'
    elif option == '-u':
    compressed = ''

    else:
    usage (defaultport, defaultmaxdown, "Unknown option: %r" % option)

    if upload:
    if len (filenames) > 0:
    usage (defaultport, defaultmaxdown,
    "Conflicting usage: simultaneous up- and download not supported.")
    filename = None

    else:
    if len (filenames) == 1:
    filename = os.path.abspath (filenames[0])
    else:
    usage (defaultport, defaultmaxdown,
    "Can only serve single files/directories.")

    if not os.path.exists (filename):
    usage (defaultport, defaultmaxdown,
    "%s: No such file or directory" % filenames[0])

    if not (os.path.isfile (filename) or os.path.isdir (filename)):
    usage (defaultport, defaultmaxdown,
    "%s: Neither file nor directory" % filenames[0])

    serve_files (filename, maxdown, ip_addr, port)

    # wait for child processes to terminate
    if cpid != 0:
    try:
    while 1:
    os.wait ()
    except OSError:
    pass



    if __name__=='__main__':
    try:
    main ()
    except KeyboardInterrupt:
    pass