SebaGeek Quod erat expectandum.

Scripte & Zeug: Python Servefile Script

Das ist ein kleines Script, welches ich gebastelt habe um schnell ein paar Dateien im LAN zu verschicken. Startet man das Script, so öffnet es einen threaded Webserver auf Port 8080 und erlaubt es anderen diese Datei über HTTP herunterzuladen (der angegebene Pfad wird dabei ignoriert).

Download:

Git (development version) git clone git://git.someserver.de/git/servefile/
Source-Tarball servefile-0.4.3.tar.gz
Debian Repository servefile
Gentoo www-servers/servefile
Arch AUR/servefile

Old tarballs servefile-0.4.1.tar.gz
servefile-0.4.0.tar.gz
servefile-0.3.2.tar.gz

   1 #!/usr/bin/python
   2 # -*- coding: utf-8 -*-
   3 
   4 # Licensed under GNU General Public License v3 or later
   5 # Written by Sebastian Lohff (seba@seba-geek.de)
   6 # http://seba-geek.de/stuff/servefile/
   7 
   8 from __future__ import print_function
   9 
  10 __version__ = '0.4.3'
  11 
  12 import argparse
  13 import base64
  14 import cgi
  15 import datetime
  16 import mimetypes
  17 import urllib
  18 import os
  19 import posixpath
  20 import re
  21 import select
  22 import socket
  23 from subprocess import Popen, PIPE
  24 import sys
  25 import time
  26 
  27 # fix imports for python2/python3
  28 try:
  29     import BaseHTTPServer
  30     import SocketServer
  31 except ImportError:
  32     # both have different names in python3
  33     import http.server as BaseHTTPServer
  34     import socketserver as SocketServer
  35 
  36 # only activate SSL if available
  37 HAVE_SSL = False
  38 try:
  39 	from OpenSSL import SSL, crypto
  40 	HAVE_SSL = True
  41 except ImportError:
  42 	pass
  43 
  44 def getDateStrNow():
  45 	""" Get the current time formatted for HTTP header """
  46 	now = datetime.datetime.fromtimestamp(time.mktime(time.gmtime()))
  47 	return now.strftime("%a, %d %b %Y %H:%M:%S GMT")
  48 
  49 class FileBaseHandler(BaseHTTPServer.BaseHTTPRequestHandler):
  50 	fileName = None
  51 	blockSize = 1024 * 1024
  52 	server_version = "servefile/" + __version__
  53 
  54 	def checkAndDoRedirect(self, fileName=None):
  55 		""" If request didn't request self.fileName redirect to self.fileName.
  56 
  57 		Returns True if a redirect was issued. """
  58 		if not fileName:
  59 			fileName = self.fileName
  60 		if urllib.unquote(self.path) != "/" + fileName:
  61 			self.send_response(302)
  62 			self.send_header('Location', '/' + fileName)
  63 			self.end_headers()
  64 			return True
  65 		return False
  66 
  67 	def sendContentHeaders(self, fileName, fileLength, lastModified=None):
  68 		""" Send default Content headers for given fileName and fileLength.
  69 
  70 		If no lastModified is given the current date is taken. If
  71 		fileLength is lesser than 0 no Content-Length will be sent."""
  72 		if not lastModified:
  73 			lastModified = getDateStrNow()
  74 
  75 		if fileLength >= 0:
  76 			self.send_header('Content-Length', str(fileLength))
  77 		self.send_header('Connection', 'close')
  78 		self.send_header('Last-Modified', lastModified)
  79 		self.send_header('Content-Type', 'application/octet-stream')
  80 		self.send_header('Content-Disposition', 'attachment; filename="%s"' % fileName)
  81 		self.send_header('Content-Transfer-Encoding', 'binary')
  82 
  83 	def isRangeRequest(self):
  84 		""" Return True if partial content is requestet """
  85 		return "Range" in self.headers
  86 
  87 	def handleRangeRequest(self, fileLength):
  88 		""" Find out and handle continuing downloads.
  89 
  90 		Returns a tuple of a boolean, if this is a valid range request,
  91 		and a range. When the requested range is out of range, range is
  92 		set to None.
  93 		"""
  94 		fromto = None
  95 		if self.isRangeRequest():
  96 			cont = self.headers.get("Range").split("=")
  97 			if len(cont) > 1 and cont[0] == 'bytes':
  98 				fromto = cont[1].split('-')
  99 				if len(fromto) > 1:
 100 					if fromto[1] == '':
 101 						fromto[1] = fileLength - 1
 102 					try:
 103 						fromto[0] = int(fromto[0])
 104 						fromto[1] = int(fromto[1])
 105 					except:
 106 						return (False, None)
 107 
 108 					if fromto[0] >= fileLength or fromto[0] < 0 or fromto[1] >= fileLength or fromto[1]-fromto[0] < 0:
 109 						# oops, already done! (requested range out of range)
 110 						self.send_response(416)
 111 						self.send_header('Content-Range', 'bytes */%d' % fileLength)
 112 						self.end_headers()
 113 						return (True, None)
 114 					return (True, fromto)
 115 		# broken request or no range header
 116 		return (False, None)
 117 
 118 	def sendFile(self, filePath, fileLength=None, lastModified=None):
 119 		""" Send file with continuation support.
 120 
 121 		filePath: path to file to be sent
 122 		fileLength: length of file (if None is given this will be found out)
 123 		lastModified: time the file was last modified, None for "now"
 124 		"""
 125 		if not fileLength:
 126 			fileLength = os.stat(filePath).st_size
 127 
 128 		(responseCode, myfile) = self.getFileHandle(filePath)
 129 		if not myfile:
 130 			self.send_response(responseCode)
 131 			self.end_headers()
 132 			return
 133 
 134 		(continueDownload, fromto) = self.handleRangeRequest(fileLength)
 135 		if continueDownload:
 136 			if not fromto:
 137 				# we are done
 138 				return True
 139 
 140 			# now we can wind the file *brrrrrr*
 141 			myfile.seek(fromto[0])
 142 
 143 		if fromto != None:
 144 			self.send_response(216)
 145 			self.send_header('Content-Range', 'bytes %d-%d/%d' % (fromto[0], fromto[1], fileLength))
 146 			fileLength = fromto[1] - fromto[0] + 1
 147 		else:
 148 			self.send_response(200)
 149 
 150 		fileName = self.fileName
 151 		if not fileName:
 152 			fileName = os.path.basename(filePath)
 153 		self.sendContentHeaders(fileName, fileLength, lastModified)
 154 		self.end_headers()
 155 		block = self.getChunk(myfile, fromto)
 156 		while block:
 157 			try:
 158 				self.wfile.write(block)
 159 			except socket.error as e:
 160 				print("%s ABORTED transmission (Reason %s: %s)" % (self.client_address[0], e[0], e[1]))
 161 				return False
 162 			block = self.getChunk(myfile, fromto)
 163 		myfile.close()
 164 		print("%s finished downloading %s" % (self.client_address[0], filePath))
 165 		return True
 166 
 167 	def getChunk(self, myfile, fromto):
 168 		if fromto and myfile.tell()+self.blockSize >= fromto[1]:
 169 			readsize = fromto[1]-myfile.tell()+1
 170 		else:
 171 			readsize = self.blockSize
 172 		return myfile.read(readsize)
 173 
 174 	def getFileHandle(self, path):
 175 		""" Get handle to a file.
 176 
 177 		Return a tuple of HTTP response code and file handle.
 178 		If the handle couldn't be acquired it is set to None
 179 		and an appropriate HTTP error code is returned.
 180 		"""
 181 		myfile = None
 182 		responseCode = 200
 183 		try:
 184 			myfile = open(path, 'rb')
 185 		except IOError as e:
 186 			responseCode = self.getResponseForErrno(e.errno)
 187 		return (responseCode, myfile)
 188 
 189 	def getFileLength(self, path):
 190 		""" Get length of a file.
 191 
 192 		Return a tuple of HTTP response code and file length.
 193 		If filelength couldn't be determined, it is set to -1
 194 		and an appropriate HTTP error code is returned.
 195 		"""
 196 		fileSize = -1
 197 		responseCode = 200
 198 		try:
 199 			fileSize = os.stat(path).st_size
 200 		except IOError as e:
 201 			responseCode = self.getResponseForErrno(e.errno)
 202 		return (responseCode, fileSize)
 203 
 204 	def getResponseForErrno(self, errno):
 205 		""" Return HTTP response code for an IOError errno """
 206 		if errno == errno.ENOENT:
 207 			return 404
 208 		elif errno == errno.EACCESS:
 209 			return 403
 210 		else:
 211 			return 500
 212 
 213 
 214 class FileHandler(FileBaseHandler):
 215 	filePath = "/dev/null"
 216 	fileLength = 0
 217 	startTime = getDateStrNow()
 218 
 219 	def do_HEAD(self):
 220 		if self.checkAndDoRedirect():
 221 			return
 222 		self.send_response(200)
 223 		self.sendContentHeaders(self.fileName, self.fileLength, self.startTime)
 224 		self.end_headers()
 225 
 226 	def do_GET(self):
 227 		if self.checkAndDoRedirect():
 228 			return
 229 		self.sendFile(self.filePath, self.fileLength, self.startTime)
 230 
 231 
 232 class TarFileHandler(FileBaseHandler):
 233 	target = None
 234 	compression = "none"
 235 	compressionMethods = ("none", "gzip", "bzip2")
 236 
 237 	def do_HEAD(self):
 238 		if self.checkAndDoRedirect():
 239 			return
 240 		self.send_response(200)
 241 		self.sendContentHeaders(self.fileName, -1)
 242 		self.end_headers()
 243 
 244 	def do_GET(self):
 245 		if self.checkAndDoRedirect():
 246 			return
 247 
 248 		tarCmd = Popen(self.getCompressionCmd(), stdout=PIPE)
 249 		# give the process a short time to find out if it can
 250 		# pack/compress the file
 251 		time.sleep(0.05)
 252 		if tarCmd.poll() != None and tarCmd.poll() != 0:
 253 			# something went wrong
 254 			print("Error while compressing '%s'. Aborting request." % self.target)
 255 			self.send_response(500)
 256 			self.end_headers()
 257 			return
 258 
 259 		self.send_response(200)
 260 		self.sendContentHeaders(self.fileName, -1)
 261 		self.end_headers()
 262 
 263 		block = True
 264 		while block and block != '':
 265 			block = tarCmd.stdout.read(self.blockSize)
 266 			if block and block != '':
 267 				self.wfile.write(block)
 268 		print("%s finished downloading" % (self.client_address[0]))
 269 
 270 	def getCompressionCmd(self):
 271 		if self.compression == "none":
 272 			cmd = ["tar", "-c"]
 273 		elif self.compression == "gzip":
 274 			cmd = ["tar", "-cz"]
 275 		elif self.compression == "bzip2":
 276 			cmd = ["tar", "-cj"]
 277 		else:
 278 			raise ValueError("Unknown compression mode '%s'." % self.compression)
 279 
 280 		dirname = os.path.basename(self.target.rstrip("/"))
 281 		chdirTo = os.path.dirname(self.target.rstrip("/"))
 282 		if chdirTo != '':
 283 			cmd.extend(["-C", chdirTo])
 284 		cmd.append(dirname)
 285 		return cmd
 286 
 287 	@staticmethod
 288 	def getCompressionExt():
 289 		if TarFileHandler.compression == "none":
 290 			return ".tar"
 291 		elif TarFileHandler.compression == "gzip":
 292 			return ".tar.gz"
 293 		elif TarFileHandler.compression == "bzip2":
 294 			return ".tar.bz2"
 295 		raise ValueError("Unknown compression mode '%s'." % TarFileHandler.compression)
 296 
 297 
 298 class DirListingHandler(FileBaseHandler):
 299 	""" DOCUMENTATION MISSING """
 300 
 301 	targetDir = None
 302 
 303 	def do_HEAD(self):
 304 		self.getFileOrDirectory(head=True)
 305 
 306 	def do_GET(self):
 307 		self.getFileOrDirectory(head=False)
 308 
 309 	def getFileOrDirectory(self, head=False):
 310 		""" Send file or directory index, depending on requested path """
 311 		path = self.getCleanPath()
 312 
 313 		if os.path.isdir(path):
 314 			if not self.path.endswith('/'):
 315 				self.send_response(301)
 316 				self.send_header("Location", self.path + '/')
 317 				self.end_headers()
 318 			else:
 319 				self.sendDirectoryListing(path, head)
 320 		elif os.path.isfile(path):
 321 			if head:
 322 				(response, length) = self.getFileLength(path)
 323 				if length < 0:
 324 					self.send_response(response)
 325 					self.end_headers()
 326 				else:
 327 					self.send_response(200)
 328 					self.sendContentHeaders(self, path, length)
 329 					self.end_headers()
 330 			else:
 331 				self.sendFile(path, head)
 332 		else:
 333 			self.send_response(404)
 334 			errorMsg = """<!DOCTYPE html><html>
 335 				<head><title>404 Not Found</title></head>
 336 				<body>
 337 				<h1>Not Found</h1>
 338 				<p>The requestet URL %s was not found on this server</p>
 339 				<p><a href="/">Back to /</a>
 340 				</body>
 341 				</html>""" % self.escapeHTML(urllib.unquote(self.path))
 342 			self.send_header("Content-Length", str(len(errorMsg)))
 343 			self.send_header('Connection', 'close')
 344 			self.end_headers()
 345 			if not head:
 346 				self.wfile.write(errorMsg)
 347 
 348 	def escapeHTML(self, htmlstr):
 349 		entities = [("<", "&lt;"), (">", "&gt;")]
 350 		for src, dst in entities:
 351 			htmlstr = htmlstr.replace(src, dst)
 352 		return htmlstr
 353 
 354 	def _appendToListing(self, content, item, itemPath, stat, is_dir):
 355 		# Strings to display on directory listing
 356 		lastModifiedDate = datetime.datetime.fromtimestamp(stat.st_mtime)
 357 		lastModified = lastModifiedDate.strftime("%Y-%m-%d %H:%M")
 358 		fileSize = "%.1f%s" % self.convertSize(stat.st_size)
 359 		(fileType, _) = mimetypes.guess_type(itemPath)
 360 		if not fileType:
 361 			fileType = "-"
 362 
 363 		if is_dir:
 364 			item += "/"
 365 			fileType = "Directory"
 366 		content.append("""
 367 			<tr>
 368 				<td class="name"><a href="%s">%s</a></td>
 369 				<td class="last-modified">%s</td>
 370 				<td class="size">%s</td>
 371 				<td class="type">%s</td>
 372 			</tr>
 373 		""" % (urllib.quote(item), item, lastModified, fileSize, fileType))
 374 
 375 	def sendDirectoryListing(self, path, head):
 376 		""" Generate a directorylisting for path and send it """
 377 		header = """<!DOCTYPE html>
 378 <html>
 379 	<head>
 380 		<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
 381 		<title>Index of %(path)s</title>
 382 		<style type="text/css">
 383 			a { text-decoration: none; color: #0000BB;}
 384 			a:visited { color: #000066;}
 385 			a:hover, a:focus, a:active { text-decoration: underline; color: #cc0000; text-indent: 5px; }
 386 			body { background-color: #eaeaea; padding: 20px 0; margin: 0; font: 400 13px/1.2em Arial, sans-serif; }
 387 			h1 { margin: 0 10px 12px 10px; font-family: Arial, sans-serif; }
 388 			div.content { background-color: white; border-color: #ccc; border-width: 1px 0; border-style: solid; padding: 10px 10px 15px 10px; }
 389 			td { padding-right: 15px; text-align: left; font-family: monospace; }
 390 			th { font-weight: bold; font-size: 115%%; padding: 0 15px 5px 0; text-align: left; }
 391 			.size { text-align: right; }
 392 			.footer { font: 12px monospace; color: #333; margin: 5px 10px 0; }
 393 			.footer, h1 { text-shadow: 0 1px 0 white; }
 394 		</style>
 395 	</head>
 396 <body>
 397 	<h1>Index of %(path)s</h1>
 398 	<div class="content">
 399 	<table summary="Directory Listing">
 400 		<thead>
 401 			<tr>
 402 				<th class="name">Name</th>
 403 				<th class="lastModified">Last Modified</th>
 404 				<th class="size">Size</th>
 405 				<th class="type">Type</th>
 406 			</tr>
 407 		</thead>
 408 		<tbody>
 409 		""" % {'path': posixpath.normpath(urllib.unquote(self.path))}
 410 		footer = """</tbody></table></div>
 411 <div class="footer"><a href="http://seba-geek.de/stuff/servefile/">servefile %(version)s</a></div>
 412 </body>
 413 </html>""" % {'version': __version__}
 414 		content = []
 415 
 416 		dir_items = list()
 417 		file_items = list()
 418 
 419 		for item in [".."] + sorted(os.listdir(path), key=lambda x:x.lower()):
 420 				# create path to item
 421 				itemPath = os.path.join(path, item)
 422 
 423 				# Hide "../" in listing of the (virtual) root directory
 424 				if item == '..' and path == DirListingHandler.targetDir.rstrip('/') + '/':
 425 					continue
 426 
 427 				# try to stat file for size, last modified... continue on error
 428 				stat = None
 429 				try:
 430 					stat = os.stat(itemPath)
 431 				except IOError:
 432 					continue
 433 
 434 				if os.path.isdir(itemPath):
 435 					target_items = dir_items
 436 				else:
 437 					target_items = file_items
 438 				target_items.append((item, itemPath, stat))
 439 
 440 		# Directories first, then files
 441 		for (tuple_list, is_dir) in (
 442 				(dir_items, True),
 443 				(file_items, False),
 444 				):
 445 			for (item, itemPath, stat) in tuple_list:
 446 				self._appendToListing(content, item, itemPath, stat, is_dir=is_dir)
 447 
 448 		listing = header + "\n".join(content) + footer
 449 
 450 		# write listing
 451 		self.send_response(200)
 452 		self.send_header("Content-Type", "text/html")
 453 		if head:
 454 			self.end_headers()
 455 			return
 456 		self.send_header("Content-Length", str(len(listing)))
 457 		self.send_header('Connection', 'close')
 458 		self.end_headers()
 459 		self.wfile.write(listing)
 460 
 461 	def convertSize(self, size):
 462 		for ext in "KMGT":
 463 			size /= 1024.0
 464 			if size < 1024.0:
 465 				break
 466 		if ext == "K" and size < 0.1:
 467 			size = 0.1
 468 		return (size, ext.strip())
 469 
 470 	def getCleanPath(self):
 471 		urlPath = posixpath.normpath(urllib.unquote(self.path)).strip("/")
 472 		path = os.path.join(self.targetDir, urlPath)
 473 		return path
 474 
 475 
 476 class FilePutter(BaseHTTPServer.BaseHTTPRequestHandler):
 477 	""" Simple HTTP Server which allows uploading to a specified directory
 478 	either via multipart/form-data or POST/PUT requests containing the file.
 479 	"""
 480 
 481 	targetDir = None
 482 	maxUploadSize = 0
 483 	blockSize = 1024 * 1024
 484 	uploadPage = """
 485 <!docype html>
 486 <html>
 487 	<form action="/" method="post" enctype="multipart/form-data">
 488 		<label for="file">Filename:</label>
 489 		<input type="file" name="file" id="file" />
 490 		<br />
 491 		<input type="submit" name="submit" value="Upload" />
 492 	</form>
 493 </html>
 494 """
 495 
 496 	def do_GET(self):
 497 		""" Answer every GET request with the upload form """
 498 		self.sendResponse(200, self.uploadPage)
 499 
 500 	def do_POST(self):
 501 		""" Upload a file via POST
 502 
 503 		If the content-type is multipart/form-data it checks for the file
 504 		field and saves the data to disk. For other content-types it just
 505 		calls do_PUT and is handled as such except for the http response code.
 506 
 507 		Files can be uploaded with wget --post-file=path/to/file <url> or
 508 		curl -X POST -d @file <url> .
 509 		"""
 510 		length = self.getContentLength()
 511 		if length < 0:
 512 			return
 513 		ctype = self.headers.getheader('Content-Type')
 514 
 515 		# check for multipart/form-data.
 516 		if not (ctype and ctype.lower().startswith("multipart/form-data")):
 517 			# not a normal multipart request ==> handle as PUT request
 518 			return self.do_PUT(fromPost=True)
 519 
 520 		# create FieldStorage object for multipart parsing
 521 		env = os.environ
 522 		env['REQUEST_METHOD'] = "POST"
 523 		fstorage = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ=env)
 524 		if not "file" in fstorage:
 525 			self.sendResponse(400, "No file found in request.")
 526 			return
 527 
 528 		destFileName = self.getTargetName(fstorage["file"].filename)
 529 		if destFileName == "":
 530 			self.sendResponse(400, "Filename was empty or invalid")
 531 			return
 532 
 533 		# write file down to disk, send a 200 afterwards
 534 		target = open(destFileName, "w")
 535 		bytesLeft = length
 536 		while bytesLeft > 0:
 537 			bytesToRead = min(self.blockSize, bytesLeft)
 538 			target.write(fstorage["file"].file.read(bytesToRead))
 539 			bytesLeft -= bytesToRead
 540 		target.close()
 541 		self.sendResponse(200, "OK! Thanks for uploading")
 542 		print("Received file '%s' from %s." % (destFileName, self.client_address[0]))
 543 
 544 	def do_PUT(self, fromPost=False):
 545 		""" Upload a file via PUT
 546 
 547 		The request path is used as filename, so uploading a file to the url
 548 		http://host:8080/testfile will cause the file to be named testfile. If
 549 		no filename is given, a random name will be generated.
 550 
 551 		Files can be uploaded with e.g. curl -X POST -d @file <url> .
 552 		"""
 553 		length = self.getContentLength()
 554 		if length < 0:
 555 			return
 556 
 557 		fileName = urllib.unquote(self.path)
 558 		if fileName == "/":
 559 			# if no filename was given we have to generate one
 560 			fileName = str(time.time())
 561 
 562 		cleanFileName = self.getTargetName(fileName)
 563 		if cleanFileName == "":
 564 			self.sendResponse(400, "Filename was invalid")
 565 			return
 566 
 567 		# Sometimes clients want to be told to continue with their transfer
 568 		if self.headers.getheader("Expect") == "100-continue":
 569 			self.send_response(100)
 570 			self.end_headers()
 571 
 572 		target = open(cleanFileName, "w")
 573 		bytesLeft = int(self.headers['Content-Length'])
 574 		while bytesLeft > 0:
 575 			bytesToRead = min(self.blockSize, bytesLeft)
 576 			target.write(self.rfile.read(bytesToRead))
 577 			bytesLeft -= bytesToRead
 578 		target.close()
 579 		self.sendResponse(200 if fromPost else 201, "OK!")
 580 
 581 	def getContentLength(self):
 582 		length = 0
 583 		try:
 584 			length = int(self.headers['Content-Length'])
 585 		except (ValueError, KeyError):
 586 			pass
 587 		if length <= 0:
 588 			self.sendResponse(411, "Content-Length was invalid or not set.")
 589 			return -1
 590 		if self.maxUploadSize > 0 and length > self.maxUploadSize:
 591 			self.sendResponse(413, "Your file was too big! Maximum allowed size is %d byte. <a href=\"/\">back</a>" % self.maxUploadSize)
 592 			return -1
 593 		return length
 594 
 595 	def sendResponse(self, code, msg):
 596 		""" Send a HTTP response with HTTP statuscode code and message msg,
 597 		providing the correct content-length.
 598 		"""
 599 		self.send_response(code)
 600 		self.send_header('Content-Type', 'text/html')
 601 		self.send_header('Content-Length', str(len(msg)))
 602 		self.send_header('Connection', 'close')
 603 		self.end_headers()
 604 		self.wfile.write(msg)
 605 
 606 	def getTargetName(self, fname):
 607 		""" Generate a clean and secure filename.
 608 
 609 		This function takes a filename and strips all the slashes out of it.
 610 		If the file already exists in the target directory, a (NUM) will be
 611 		appended, so no file will be overwritten.
 612 		"""
 613 		cleanFileName = fname.replace("/", "")
 614 		if cleanFileName == "":
 615 			return ""
 616 		destFileName = os.path.join(self.targetDir, cleanFileName)
 617 		if not os.path.exists(destFileName):
 618 			return destFileName
 619 		else:
 620 			i = 1
 621 			extraDestFileName = destFileName + "(%s)" % i
 622 			while os.path.exists(extraDestFileName):
 623 				i += 1
 624 				extraDestFileName = destFileName + "(%s)" % i
 625 			return extraDestFileName
 626 		# never reached
 627 
 628 class ThreadedHTTPServer(SocketServer.ThreadingMixIn, BaseHTTPServer.HTTPServer):
 629 	pass
 630 
 631 def catchSSLErrors(BaseSSLClass):
 632 	""" Class decorator which catches SSL errors and prints them. """
 633 	class X(BaseSSLClass):
 634 		def handle_one_request(self, *args, **kwargs):
 635 			try:
 636 				BaseSSLClass.handle_one_request(self, *args, **kwargs)
 637 			except SSL.Error as e:
 638 				if str(e) == "":
 639 					print("%s SSL error (empty error message)" % (self.client_address[0],))
 640 				else:
 641 					print("%s SSL error: %s" % (self.client_address[0], e))
 642 	return X
 643 
 644 
 645 class SecureThreadedHTTPServer(ThreadedHTTPServer):
 646 	def __init__(self, pubKey, privKey, server_address, RequestHandlerClass, bind_and_activate=True):
 647 		ThreadedHTTPServer.__init__(self, server_address, RequestHandlerClass, bind_and_activate)
 648 		ctx = SSL.Context(SSL.SSLv23_METHOD)
 649 		if type(pubKey) is crypto.X509 and type(privKey) is crypto.PKey:
 650 			ctx.use_certificate(pubKey)
 651 			ctx.use_privatekey(privKey)
 652 		else:
 653 			ctx.use_certificate_file(pubKey)
 654 			ctx.use_privatekey_file(privKey)
 655 
 656 		self.bsocket = socket.socket(self.address_family, self.socket_type)
 657 		self.socket = SSL.Connection(ctx, self.bsocket)
 658 
 659 		if bind_and_activate:
 660 			self.server_bind()
 661 			self.server_activate()
 662 
 663 	def shutdown_request(self, request):
 664 		request.shutdown()
 665 
 666 
 667 class SecureHandler():
 668 	def setup(self):
 669 		self.connection = self.request
 670 		self.rfile = socket._fileobject(self.request, "rb", self.rbufsize)
 671 		self.wfile = socket._fileobject(self.request, "wb", self.wbufsize)
 672 
 673 class ServeFileException(Exception):
 674 	pass
 675 
 676 
 677 class ServeFile():
 678 	""" Main class to manage everything. """
 679 
 680 	_NUM_MODES = 4
 681 	(MODE_SINGLE, MODE_SINGLETAR, MODE_UPLOAD, MODE_LISTDIR) = range(_NUM_MODES)
 682 
 683 	def __init__(self, target, port=8080, serveMode=0, useSSL=False):
 684 		self.target = target
 685 		self.port = port
 686 		self.serveMode = serveMode
 687 		self.dirCreated = False
 688 		self.useSSL = useSSL
 689 		self.cert = self.key = None
 690 		self.auth = None
 691 		self.maxUploadSize = 0
 692 		self.listenIPv4 = True
 693 		self.listenIPv6 = True
 694 
 695 		if self.serveMode not in range(self._NUM_MODES):
 696 			self.serveMode = None
 697 			raise ValueError("Unknown serve mode, needs to be MODE_SINGLE, MODE_SINGLETAR, MODE_UPLOAD or MODE_DIRLIST.")
 698 
 699 	def setIPv4(self, ipv4):
 700 		""" En- or disable ipv4 """
 701 		self.listenIPv4 = ipv4
 702 
 703 	def setIPv6(self, ipv6):
 704 		""" En- or disable ipv6 """
 705 		self.listenIPv6 = ipv6
 706 
 707 	def getIPs(self):
 708 		""" Get IPs from all interfaces via ip or ifconfig. """
 709 		# ip and ifconfig sometimes are located in /sbin/
 710 		os.environ['PATH'] += ':/sbin:/usr/sbin'
 711 		proc = Popen(r"ip addr|" + \
 712 					  "sed -n -e 's/.*inet6\{0,1\} \([0-9.a-fA-F:]\+\).*/\\1/ p'|" + \
 713 					  "grep -v '^fe80\|^127.0.0.1\|^::1'", \
 714 					  shell=True, stdout=PIPE, stderr=PIPE)
 715 		if proc.wait() != 0:
 716 			# ip failed somehow, falling back to ifconfig
 717 			oldLang = os.environ.get("LC_ALL", None)
 718 			os.environ['LC_ALL'] = "C"
 719 			proc = Popen(r"ifconfig|" + \
 720 						  "sed -n 's/.*inet6\{0,1\}\( addr:\)\{0,1\} \{0,1\}\([0-9a-fA-F.:]*\).*/" + \
 721 						  "\\2/p'|" + \
 722 						  "grep -v '^fe80\|^127.0.0.1\|^::1'", \
 723 						  shell=True, stdout=PIPE, stderr=PIPE)
 724 			if oldLang:
 725 				os.environ['LC_ALL'] = oldLang
 726 			else:
 727 				del(os.environ['LC_ALL'])
 728 			if proc.wait() != 0:
 729 				# we couldn't find any ip address
 730 				proc = None
 731 		if proc:
 732 			ips = proc.stdout.read().strip().split("\n")
 733 
 734 			# filter out ips we are not listening on
 735 			if not self.listenIPv6:
 736 				ips = filter(lambda ip: ":" not in ip, ips)
 737 			if not self.listenIPv4:
 738 				ips = filter(lambda ip: "." not in ip, ips)
 739 
 740 			return ips
 741 		return None
 742 
 743 	def setSSLKeys(self, cert, key):
 744 		""" Set SSL cert/key. Can be either path to file or pyssl X509/PKey object. """
 745 		self.cert = cert
 746 		self.key = key
 747 
 748 	def setMaxUploadSize(self, limit):
 749 		""" Set the maximum upload size in byte """
 750 		self.maxUploadSize = limit
 751 
 752 	def setCompression(self, compression):
 753 		""" Set the compression of TarFileHandler """
 754 		if self.serveMode != self.MODE_SINGLETAR:
 755 			raise ServeFileException("Compression mode can only be set in tar-mode.")
 756 		if compression not in TarFileHandler.compressionMethods:
 757 			raise ServeFileException("Compression mode not available.")
 758 		TarFileHandler.compression = compression
 759 
 760 	def genKeyPair(self):
 761 		print("Generating SSL certificate...", end="")
 762 		sys.stdout.flush()
 763 
 764 		pkey = crypto.PKey()
 765 		pkey.generate_key(crypto.TYPE_RSA, 2048)
 766 
 767 		req = crypto.X509Req()
 768 		subj = req.get_subject()
 769 		subj.CN = "127.0.0.1"
 770 		subj.O = "servefile laboratories"
 771 		subj.OU = "servefile"
 772 
 773 		# generate altnames
 774 		altNames = []
 775 		for ip in self.getIPs() + ["127.0.0.1", "::1"]:
 776 			altNames.append("IP:%s" % ip)
 777 		altNames.append("DNS:localhost")
 778 		ext = crypto.X509Extension("subjectAltName", False, ",".join(altNames))
 779 		req.add_extensions([ext])
 780 
 781 		req.set_pubkey(pkey)
 782 		req.sign(pkey, "sha1")
 783 
 784 		cert = crypto.X509()
 785 		# some browsers complain if they see a cert from the same authority
 786 		# with the same serial ==> we just use the seconds as serial.
 787 		cert.set_serial_number(int(time.time()))
 788 		cert.gmtime_adj_notBefore(0)
 789 		cert.gmtime_adj_notAfter(365*24*60*60)
 790 		cert.set_issuer(req.get_subject())
 791 		cert.set_subject(req.get_subject())
 792 		cert.add_extensions([ext])
 793 		cert.set_pubkey(req.get_pubkey())
 794 		cert.sign(pkey, "sha1")
 795 
 796 		self.cert = cert
 797 		self.key = pkey
 798 
 799 		print("done.")
 800 		print("SHA1 fingerprint:", cert.digest("sha1"))
 801 		print("MD5  fingerprint:", cert.digest("md5"))
 802 
 803 	def _getCert(self):
 804 		return self.cert
 805 
 806 	def _getKey(self):
 807 		return self.key
 808 
 809 	def setAuth(self, user, password, realm=None):
 810 		if not user or not password:
 811 			raise ServeFileException("User and password both need to be at least one character.")
 812 		self.auth = base64.b64encode("%s:%s" % (user, password))
 813 		self.authrealm = realm
 814 
 815 	def _createServer(self, handler, withv6=False):
 816 		ThreadedHTTPServer.address_family = socket.AF_INET
 817 		SecureThreadedHTTPServer.address_family = socket.AF_INET
 818 		listenIp = ''
 819 		server = None
 820 
 821 		if withv6:
 822 			listenIp = '::'
 823 			ThreadedHTTPServer.address_family = socket.AF_INET6
 824 			SecureThreadedHTTPServer.address_family = socket.AF_INET6
 825 
 826 		if self.useSSL:
 827 			if not self._getKey():
 828 				self.genKeyPair()
 829 			try:
 830 				server = SecureThreadedHTTPServer(self._getCert(), self._getKey(),
 831 									(listenIp, self.port), handler, bind_and_activate=False)
 832 			except SSL.Error as e:
 833 				raise ServeFileException("SSL error: Could not read SSL public/private key from file(s) (error was: \"%s\")" % (e[0][0][2],))
 834 		else:
 835 			server = ThreadedHTTPServer((listenIp, self.port), handler,
 836 												bind_and_activate=False)
 837 
 838 		if withv6:
 839 			server.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
 840 
 841 		server.server_bind()
 842 		server.server_activate()
 843 
 844 		return server
 845 
 846 	def serve(self):
 847 		self.handler = self._confAndFindHandler()
 848 		self.server = []
 849 
 850 		try:
 851 			currsocktype = "IPv4"
 852 			if self.listenIPv4:
 853 				self.server.append(self._createServer(self.handler))
 854 			currsocktype = "IPv6"
 855 			if self.listenIPv6:
 856 				self.server.append(self._createServer(self.handler, withv6=True))
 857 		except socket.error as e:
 858 			raise ServeFileException("Could not open %s socket: %s" % (currsocktype, e))
 859 
 860 		if self.serveMode != self.MODE_UPLOAD:
 861 			print("Serving \"%s\" at port %d." % (self.target, self.port))
 862 		else:
 863 			print("Serving \"%s\" for uploads at port %d." % (self.target, self.port))
 864 
 865 		# print urls with local network adresses
 866 		print("\nSome addresses %s will be available at:" % \
 867 				("this file" if (self.serveMode != self.MODE_UPLOAD) else "the uploadform", ))
 868 		ips = self.getIPs()
 869 		if not ips or len(ips) == 0 or ips[0] == '':
 870 			print("Could not find any addresses.")
 871 		else:
 872 			pwPart = ""
 873 			if self.auth:
 874 				pwPart = base64.b64decode(self.auth) + "@"
 875 			for ip in ips:
 876 				if ":" in ip:
 877 					ip = "[%s]" % ip
 878 				print("\thttp%s://%s%s:%d/" % (self.useSSL and "s" or "", pwPart, ip, self.port))
 879 		print()
 880 
 881 		try:
 882 			while True:
 883 				(servers, _, _) = select.select(self.server, [], [])
 884 				for server in servers:
 885 					server.handle_request()
 886 		except KeyboardInterrupt:
 887 			for server in self.server:
 888 				server.socket.close()
 889 
 890 		# cleanup potential upload directory
 891 		if self.dirCreated and len(os.listdir(self.target)) == 0:
 892 			# created upload dir was not used
 893 			os.rmdir(self.target)
 894 
 895 	def _confAndFindHandler(self):
 896 		handler = None
 897 		if self.serveMode == self.MODE_SINGLE:
 898 			try:
 899 				testit = open(self.target, 'r')
 900 				testit.close()
 901 			except IOError as e:
 902 				raise ServeFileException("Error: Could not open file, %r" % (str(e),))
 903 			FileHandler.filePath = self.target
 904 			FileHandler.fileName = os.path.basename(self.target)
 905 			FileHandler.fileLength = os.stat(self.target).st_size
 906 			handler = FileHandler
 907 		elif self.serveMode == self.MODE_SINGLETAR:
 908 			self.realTarget = os.path.realpath(self.target)
 909 			if not os.path.exists(self.realTarget):
 910 				raise ServeFileException("Error: Could not open file or directory.")
 911 			TarFileHandler.target = self.realTarget
 912 			TarFileHandler.fileName = os.path.basename(self.realTarget.rstrip("/")) + TarFileHandler.getCompressionExt()
 913 
 914 			handler = TarFileHandler
 915 		elif self.serveMode == self.MODE_UPLOAD:
 916 			if os.path.isdir(self.target):
 917 				print("Warning: Uploading to an already existing directory.")
 918 			elif not os.path.exists(self.target):
 919 				self.dirCreated = True
 920 				try:
 921 					os.mkdir(self.target)
 922 				except (IOError, OSError) as e:
 923 					raise ServeFileException("Error: Could not create directory '%s' for uploads, %r" % (self.target, str(e)))
 924 			else:
 925 				raise ServeFileException("Error: Upload directory already exists and is a file.")
 926 			FilePutter.targetDir = self.target
 927 			FilePutter.maxUploadSize = self.maxUploadSize
 928 			handler = FilePutter
 929 		elif self.serveMode == self.MODE_LISTDIR:
 930 			if not os.path.exists(self.target):
 931 				raise ServeFileException("Error: Could not open file or directory.")
 932 			if not os.path.isdir(self.target):
 933 				raise ServeFileException("Error: '%s' is not a directory." % (self.target,))
 934 			handler = DirListingHandler
 935 			handler.targetDir = self.target
 936 
 937 		if self.auth:
 938 			# do authentication
 939 			AuthenticationHandler.authString = self.auth
 940 			if self.authrealm:
 941 				AuthenticationHandler.realm = self.authrealm
 942 			class AuthenticatedHandler(AuthenticationHandler, handler):
 943 				pass
 944 			handler = AuthenticatedHandler
 945 
 946 		if self.useSSL:
 947 			# secure handler
 948 			@catchSSLErrors
 949 			class AlreadySecuredHandler(SecureHandler, handler):
 950 				pass
 951 			handler = AlreadySecuredHandler
 952 		return handler
 953 
 954 
 955 class AuthenticationHandler():
 956 	# base64 encoded user:password string for authentication
 957 	authString = None
 958 	realm = "Restricted area"
 959 
 960 	def handle_one_request(self):
 961 		""" Overloaded function to handle one request.
 962 
 963 		Before calling the responsible do_METHOD function, check credentials
 964 		"""
 965 		self.raw_requestline = self.rfile.readline()
 966 		if not self.raw_requestline:
 967 			self.close_connection = 1
 968 			return
 969 		if not self.parse_request(): # An error code has been sent, just exit
 970 			return
 971 
 972 		authorized = False
 973 		if "Authorization" in self.headers:
 974 			if self.headers["Authorization"] == ("Basic " + self.authString):
 975 				authorized = True
 976 		if authorized:
 977 			mname = 'do_' + self.command
 978 			if not hasattr(self, mname):
 979 				self.send_error(501, "Unsupported method (%r)" % self.command)
 980 				return
 981 			method = getattr(self, mname)
 982 			method()
 983 		else:
 984 			self.send_response(401)
 985 			self.send_header("WWW-Authenticate", "Basic realm=\"%s\"" % self.realm)
 986 			self.send_header("Connection", "close")
 987 			errorMsg = "<html><head><title>401 - Unauthorized</title></head><body><h1>401 - Unauthorized</h1></body></html>"
 988 			self.send_header("Content-Length", str(len(errorMsg)))
 989 			self.end_headers()
 990 			self.wfile.write(errorMsg)
 991 
 992 
 993 def main():
 994 	parser = argparse.ArgumentParser(description='Serve a single file via HTTP.')
 995 	parser.add_argument('--version', action='version', version='%(prog)s ' + __version__)
 996 	parser.add_argument('target', metavar='file/directory', type=str)
 997 	parser.add_argument('-p', '--port', type=int, default=8080, \
 998 	                    help='Port to listen on')
 999 	parser.add_argument('-u', '--upload', action="store_true", default=False, \
1000 	                    help="Enable uploads to a given directory")
1001 	parser.add_argument('-s', '--max-upload-size', type=str, \
1002 	                    help="Limit upload size in kB. Size modifiers are allowed, e.g. 2G, 12MB, 1B")
1003 	parser.add_argument('-l', '--list-dir', action="store_true", default=False, \
1004 	                    help="Show directory indexes and allow access to all subdirectories")
1005 	parser.add_argument('--ssl', action="store_true", default=False, \
1006 	                    help="Enable SSL. If no key/cert is specified one will be generated")
1007 	parser.add_argument('--key', type=str, \
1008 	                    help="Keyfile to use for SSL. If no cert is given with --cert the keyfile will also be searched for a cert")
1009 	parser.add_argument('--cert', type=str, \
1010 	                    help="Certfile to use for SSL")
1011 	parser.add_argument('-a', '--auth', type=str, metavar='user:password', \
1012 	                    help="Set user and password for HTTP basic authentication")
1013 	parser.add_argument('--realm', type=str, default=None,\
1014 	                    help="Set a realm for HTTP basic authentication")
1015 	parser.add_argument('-t', '--tar', action="store_true", default=False, \
1016 	                    help="Enable on the fly tar creation for given file or directory. Note: Download continuation will not be available")
1017 	parser.add_argument('-c', '--compression', type=str, metavar='method', \
1018 	                    default="none", \
1019 	                    help="Set compression method, only in combination with --tar. Can be one of %s" % ", ".join(TarFileHandler.compressionMethods))
1020 	parser.add_argument('-4', '--ipv4-only', action="store_true", default=False, \
1021 	                    help="Listen on IPv4 only")
1022 	parser.add_argument('-6', '--ipv6-only', action="store_true", default=False, \
1023 	                    help="Listen on IPv6 only")
1024 
1025 	args = parser.parse_args()
1026 	maxUploadSize = 0
1027 
1028 	# check for invalid option combinations/preparse stuff
1029 	if args.max_upload_size and not args.upload:
1030 		print("Error: Maximum upload size can only be specified when in upload mode.")
1031 		sys.exit(1)
1032 
1033 	if args.upload and args.list_dir:
1034 		print("Error: Upload and dirlisting can't be enabled together.")
1035 		sys.exit(1)
1036 
1037 	if args.max_upload_size:
1038 		sizeRe = re.match("^(\d+(?:[,.]\d+)?)(?:([bkmgtpe])(?:(?<!b)b?)?)?$", args.max_upload_size.lower())
1039 		if not sizeRe:
1040 			print("Error: Your max upload size param is broken. Try something like 3M or 2.5Gb.")
1041 			sys.exit(1)
1042 		uploadSize, modifier = sizeRe.groups()
1043 		uploadSize = float(uploadSize.replace(",", "."))
1044 		sizes = ["b", "k", "m", "g", "t", "p", "e"]
1045 		maxUploadSize = int(uploadSize * pow(1024, sizes.index(modifier or "k")))
1046 		if maxUploadSize < 0:
1047 			print("Error: Your max upload size can't be negative")
1048 			sys.exit(1)
1049 
1050 	if args.ssl and not HAVE_SSL:
1051 		print("Error: SSL is not available, please install pyssl (python-openssl).")
1052 		sys.exit(1)
1053 
1054 	if args.cert and not args.key:
1055 		print("Error: Please specify a key along with your cert.")
1056 		sys.exit(1)
1057 
1058 	if not args.ssl and (args.cert or args.key):
1059 		print("Error: You need to enable ssl with --ssl when specifying certs/keys.")
1060 		sys.exit(1)
1061 
1062 	if args.auth:
1063 		dpos = args.auth.find(":")
1064 		if dpos <= 0 or dpos == (len(args.auth)-1):
1065 			print("Error: User and password for HTTP basic authentication need to be both at least one character and have to be separated by a \":\".")
1066 			sys.exit(1)
1067 
1068 	if args.realm and not args.auth:
1069 		print("You can only specify a realm when HTTP basic authentication is enabled.")
1070 		sys.exit(1)
1071 
1072 	if args.compression != "none" and not args.tar:
1073 		print("Error: Please use --tar if you want to tar everything.")
1074 		sys.exit(1)
1075 
1076 	if args.tar and args.upload:
1077 		print("Error: --tar mode will not work with uploads.")
1078 		sys.exit(1)
1079 
1080 	if args.tar and args.list_dir:
1081 		print("Error: --tar mode will not work with directory listings.")
1082 		sys.exit(1)
1083 
1084 	compression = None
1085 	if args.compression:
1086 		if args.compression in TarFileHandler.compressionMethods:
1087 			compression = args.compression
1088 		else:
1089 			print("Error: Compression mode '%s' is unknown." % args.compression)
1090 			sys.exit(1)
1091 
1092 	if args.ipv4_only and args.ipv6_only:
1093 		print("You can't listen both on IPv4 and IPv6 \"only\".")
1094 		sys.exit(1)
1095 
1096 	if args.ipv6_only and not socket.has_ipv6:
1097 		print("Your system does not support IPv6.")
1098 		sys.exit(1)
1099 
1100 	mode = None
1101 	if args.upload:
1102 		mode = ServeFile.MODE_UPLOAD
1103 	elif args.list_dir:
1104 		mode = ServeFile.MODE_LISTDIR
1105 	elif args.tar:
1106 		mode = ServeFile.MODE_SINGLETAR
1107 	else:
1108 		mode = ServeFile.MODE_SINGLE
1109 
1110 	server = None
1111 	try:
1112 		server = ServeFile(args.target, args.port, mode, args.ssl)
1113 		if maxUploadSize > 0:
1114 			server.setMaxUploadSize(maxUploadSize)
1115 		if args.ssl and args.key:
1116 			cert = args.cert or args.key
1117 			server.setSSLKeys(cert, args.key)
1118 		if args.auth:
1119 			user, password = args.auth.split(":", 1)
1120 			server.setAuth(user, password, args.realm)
1121 		if compression and compression != "none":
1122 			server.setCompression(compression)
1123 		if args.ipv4_only or not socket.has_ipv6:
1124 			server.setIPv6(False)
1125 		if args.ipv6_only:
1126 			server.setIPv4(False)
1127 		server.serve()
1128 	except ServeFileException as e:
1129 		print(e)
1130 		sys.exit(1)
1131 	print("Good bye.")
1132 
1133 
1134 if __name__ == '__main__':
1135 	main()
π