SebaGeek Veni, vidi, sandwichem cepi.

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