Compare commits

..

1 Commits

Author SHA1 Message Date
Sebastian Lohff b5a9c52ed1 Allow ports for tests to be specified via env
SERVEFILE_DEFAULT_PORT and SERVEFILE_SECONDARY_PORT can be used to
specify ports used in the servefile tests. This can be useful if the
default port 8080 and the secondary port 8081 (for the -p test) are
already in use. To allow automatic choosing of a free port 0 can be
specified to tell the test code to automatically select a free port.
2021-03-11 00:50:07 +01:00
7 changed files with 998 additions and 1173 deletions

View File

@ -1,37 +0,0 @@
name: Run Tox
on:
push:
branches:
- master
pull_request:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python: [2.7, 3.7, 3.8, 3.9, "3.10", 3.11]
steps:
- uses: actions/checkout@v2
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python }}
- name: Install Tox
run: pip install tox
- name: Run Tox
run: tox -e py
pep8:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup python
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install Tox
run: pip install tox
- name: Run Tox pep8
run: "tox -e pep8"

View File

@ -1,41 +1,15 @@
servefile changelog servefile changelog
=================== ===================
2023-01-23 v0.5.4
-----------------
0.5.4 released Unreleased
----------
* code reformatting for better maintainability
* upload to uploaddir instead of /tmp for large files
* add python3.10 / python3.11 support
* drop python3.6 support
2021-11-18 v0.5.3
-----------------
0.5.3 released
* improved test performance
2021-09-08 v0.5.2
-----------------
0.5.2 released
* fixed bug where exception was shown on transmission abort with python3 * fixed bug where exception was shown on transmission abort with python3
* fixed wrong/outdated pyopenssl package names * fixed wrong/outdated pyopenssl package names
* tests are now using a free non-default port to avoid clashes; if * ports for running servefile tests can now be specified via the
wished the ports can be set from outside by specifying the
environment variables SERVEFILE_DEFAULT_PORT and environment variables SERVEFILE_DEFAULT_PORT and
SERVEFILE_SECONDARY_PORT SERVEFILE_SECONDARY_PORT, use 0 for automatic selection
* fixed broken redirect when filename contained umlauts or other characters
that should have been quoted
* fixed broken special char handling in directory listing for python2
* drop python3.5 support
* fixed PUT uploads with python3 and documented PUT-uploads with curl
2020-10-30 v0.5.1 2020-10-30 v0.5.1

View File

@ -1,4 +1,4 @@
.TH SERVEFILE 1 "January 2023" "servefile 0.5.4" "User Commands" .TH SERVEFILE 1 "September 2020" "servefile 0.5.1" "User Commands"
.SH NAME .SH NAME
servefile \- small HTTP-Server for temporary file transfer servefile \- small HTTP-Server for temporary file transfer

View File

@ -7,10 +7,11 @@
from __future__ import print_function from __future__ import print_function
__version__ = '0.5.4' __version__ = '0.5.1'
import argparse import argparse
import base64 import base64
import cgi
import datetime import datetime
import io import io
import mimetypes import mimetypes
@ -20,9 +21,7 @@ import select
import socket import socket
from subprocess import Popen, PIPE from subprocess import Popen, PIPE
import sys import sys
import tempfile
import time import time
import warnings
# fix imports for python2/python3 # fix imports for python2/python3
try: try:
@ -43,18 +42,11 @@ try:
except ImportError: except ImportError:
pass pass
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)
# scheduled for removal in python3.13, used for FieldStorage
import cgi
def getDateStrNow(): def getDateStrNow():
""" Get the current time formatted for HTTP header """ """ Get the current time formatted for HTTP header """
now = datetime.datetime.fromtimestamp(time.mktime(time.gmtime())) now = datetime.datetime.fromtimestamp(time.mktime(time.gmtime()))
return now.strftime("%a, %d %b %Y %H:%M:%S GMT") return now.strftime("%a, %d %b %Y %H:%M:%S GMT")
class FileBaseHandler(BaseHTTPServer.BaseHTTPRequestHandler): class FileBaseHandler(BaseHTTPServer.BaseHTTPRequestHandler):
fileName = None fileName = None
blockSize = 1024 * 1024 blockSize = 1024 * 1024
@ -68,7 +60,7 @@ class FileBaseHandler(BaseHTTPServer.BaseHTTPRequestHandler):
fileName = self.fileName fileName = self.fileName
if unquote(self.path) != "/" + fileName: if unquote(self.path) != "/" + fileName:
self.send_response(302) self.send_response(302)
self.send_header('Location', '/' + quote(fileName)) self.send_header('Location', '/' + fileName)
self.end_headers() self.end_headers()
return True return True
return False return False
@ -114,7 +106,7 @@ class FileBaseHandler(BaseHTTPServer.BaseHTTPRequestHandler):
except ValueError: except ValueError:
return (False, None) return (False, None)
if fromto[0] >= fileLength or fromto[0] < 0 or fromto[1] >= fileLength or fromto[1] - fromto[0] < 0: if fromto[0] >= fileLength or fromto[0] < 0 or fromto[1] >= fileLength or fromto[1]-fromto[0] < 0:
# oops, already done! (requested range out of range) # oops, already done! (requested range out of range)
self.send_response(416) self.send_response(416)
self.send_header('Content-Range', 'bytes */%d' % fileLength) self.send_header('Content-Range', 'bytes */%d' % fileLength)
@ -149,7 +141,7 @@ class FileBaseHandler(BaseHTTPServer.BaseHTTPRequestHandler):
# now we can wind the file *brrrrrr* # now we can wind the file *brrrrrr*
myfile.seek(fromto[0]) myfile.seek(fromto[0])
if fromto is not None: if fromto != None:
self.send_response(216) self.send_response(216)
self.send_header('Content-Range', 'bytes %d-%d/%d' % (fromto[0], fromto[1], fileLength)) self.send_header('Content-Range', 'bytes %d-%d/%d' % (fromto[0], fromto[1], fileLength))
fileLength = fromto[1] - fromto[0] + 1 fileLength = fromto[1] - fromto[0] + 1
@ -170,8 +162,8 @@ class FileBaseHandler(BaseHTTPServer.BaseHTTPRequestHandler):
return True return True
def getChunk(self, myfile, fromto): def getChunk(self, myfile, fromto):
if fromto and myfile.tell() + self.blockSize >= fromto[1]: if fromto and myfile.tell()+self.blockSize >= fromto[1]:
readsize = fromto[1] - myfile.tell() + 1 readsize = fromto[1]-myfile.tell()+1
else: else:
readsize = self.blockSize readsize = self.blockSize
return myfile.read(readsize) return myfile.read(readsize)
@ -254,7 +246,7 @@ class TarFileHandler(FileBaseHandler):
# give the process a short time to find out if it can # give the process a short time to find out if it can
# pack/compress the file # pack/compress the file
time.sleep(0.05) time.sleep(0.05)
if tarCmd.poll() is not None and tarCmd.poll() != 0: if tarCmd.poll() != None and tarCmd.poll() != 0:
# something went wrong # something went wrong
print("Error while compressing '%s'. Aborting request." % self.target) print("Error while compressing '%s'. Aborting request." % self.target)
self.send_response(500) self.send_response(500)
@ -424,7 +416,7 @@ class DirListingHandler(FileBaseHandler):
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
""" % {'path': os.path.normpath(unquote(self.path))} # noqa: E501 """ % {'path': os.path.normpath(unquote(self.path))}
footer = """</tbody></table></div> footer = """</tbody></table></div>
<div class="footer"><a href="http://seba-geek.de/stuff/servefile/">servefile %(version)s</a></div> <div class="footer"><a href="http://seba-geek.de/stuff/servefile/">servefile %(version)s</a></div>
<script> <script>
@ -510,7 +502,7 @@ class DirListingHandler(FileBaseHandler):
dir_items = list() dir_items = list()
file_items = list() file_items = list()
for item in [".."] + sorted(os.listdir(path), key=lambda x: x.lower()): for item in [".."] + sorted(os.listdir(path), key=lambda x:x.lower()):
# create path to item # create path to item
itemPath = os.path.join(path, item) itemPath = os.path.join(path, item)
@ -532,7 +524,10 @@ class DirListingHandler(FileBaseHandler):
target_items.append((item, itemPath, stat)) target_items.append((item, itemPath, stat))
# Directories first, then files # Directories first, then files
for (tuple_list, is_dir) in ((dir_items, True), (file_items, False)): for (tuple_list, is_dir) in (
(dir_items, True),
(file_items, False),
):
for (item, itemPath, stat) in tuple_list: for (item, itemPath, stat) in tuple_list:
self._appendToListing(content, item, itemPath, stat, is_dir=is_dir) self._appendToListing(content, item, itemPath, stat, is_dir=is_dir)
@ -547,9 +542,7 @@ class DirListingHandler(FileBaseHandler):
self.send_header("Content-Length", str(len(listing))) self.send_header("Content-Length", str(len(listing)))
self.send_header('Connection', 'close') self.send_header('Connection', 'close')
self.end_headers() self.end_headers()
if sys.version_info.major >= 3: self.wfile.write(listing.encode())
listing = listing.encode()
self.wfile.write(listing)
def convertSize(self, size): def convertSize(self, size):
for ext in "KMGT": for ext in "KMGT":
@ -614,25 +607,8 @@ class FilePutter(BaseHTTPServer.BaseHTTPRequestHandler):
# create FieldStorage object for multipart parsing # create FieldStorage object for multipart parsing
env = os.environ env = os.environ
env['REQUEST_METHOD'] = "POST" env['REQUEST_METHOD'] = "POST"
targetDir = self.targetDir fstorage = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ=env)
if not "file" in fstorage:
class CustomFieldStorage(cgi.FieldStorage):
def make_file(self, *args, **kwargs):
"""Overwritten to use a named file and the upload directory
Python 2.7 has an unused "binary" argument while Python 3 does
not have any arguments. Python 2.7 does not have a
self._binary_file attribute.
"""
if sys.version_info.major == 2 or self._binary_file:
return tempfile.NamedTemporaryFile("wb+", dir=targetDir)
else:
return tempfile.NamedTemporaryFile(
"w+", encoding=self.encoding, newline='\n', dir=targetDir)
fstorage = CustomFieldStorage(fp=self.rfile, headers=self.headers, environ=env)
if "file" not in fstorage:
self.sendResponse(400, "No file found in request.") self.sendResponse(400, "No file found in request.")
return return
@ -641,14 +617,7 @@ class FilePutter(BaseHTTPServer.BaseHTTPRequestHandler):
self.sendResponse(400, "Filename was empty or invalid") self.sendResponse(400, "Filename was empty or invalid")
return return
# put the file at the right place, send 200 afterwards # write file down to disk, send a 200 afterwards
if getattr(fstorage["file"].file, "name", None):
# the sent file was large, so we can just hard link the temporary
# file and are done
os.link(fstorage["file"].file.name, destFileName)
else:
# write file to disk. it was small enough so no temporary file was
# created
target = open(destFileName, "wb") target = open(destFileName, "wb")
bytesLeft = length bytesLeft = length
while bytesLeft > 0: while bytesLeft > 0:
@ -666,7 +635,7 @@ class FilePutter(BaseHTTPServer.BaseHTTPRequestHandler):
http://host:8080/testfile will cause the file to be named testfile. If http://host:8080/testfile will cause the file to be named testfile. If
no filename is given, a random name will be generated. no filename is given, a random name will be generated.
Files can be uploaded with e.g. curl -T file <url> . Files can be uploaded with e.g. curl -X POST -d @file <url> .
""" """
length = self.getContentLength() length = self.getContentLength()
if length < 0: if length < 0:
@ -683,11 +652,11 @@ class FilePutter(BaseHTTPServer.BaseHTTPRequestHandler):
return return
# Sometimes clients want to be told to continue with their transfer # Sometimes clients want to be told to continue with their transfer
if self.headers.get("Expect") == "100-continue": if self.headers.getheader("Expect") == "100-continue":
self.send_response(100) self.send_response(100)
self.end_headers() self.end_headers()
target = open(cleanFileName, "wb") target = open(cleanFileName, "w")
bytesLeft = int(self.headers['Content-Length']) bytesLeft = int(self.headers['Content-Length'])
while bytesLeft > 0: while bytesLeft > 0:
bytesToRead = min(self.blockSize, bytesLeft) bytesToRead = min(self.blockSize, bytesLeft)
@ -706,8 +675,7 @@ class FilePutter(BaseHTTPServer.BaseHTTPRequestHandler):
self.sendResponse(411, "Content-Length was invalid or not set.") self.sendResponse(411, "Content-Length was invalid or not set.")
return -1 return -1
if self.maxUploadSize > 0 and length > self.maxUploadSize: if self.maxUploadSize > 0 and length > self.maxUploadSize:
self.sendResponse(413, "Your file was too big! Maximum allowed size is %d byte. <a href=\"/\">back</a>" % self.sendResponse(413, "Your file was too big! Maximum allowed size is %d byte. <a href=\"/\">back</a>" % self.maxUploadSize)
self.maxUploadSize)
return -1 return -1
return length return length
@ -744,7 +712,6 @@ class FilePutter(BaseHTTPServer.BaseHTTPRequestHandler):
return extraDestFileName return extraDestFileName
# never reached # never reached
class ThreadedHTTPServer(SocketServer.ThreadingMixIn, BaseHTTPServer.HTTPServer): class ThreadedHTTPServer(SocketServer.ThreadingMixIn, BaseHTTPServer.HTTPServer):
def handle_error(self, request, client_address): def handle_error(self, request, client_address):
_, exc_value, _ = sys.exc_info() _, exc_value, _ = sys.exc_info()
@ -820,7 +787,6 @@ class SecureHandler():
self.rfile = socket._fileobject(self.request, "rb", self.rbufsize) self.rfile = socket._fileobject(self.request, "rb", self.rbufsize)
self.wfile = socket._fileobject(self.request, "wb", self.wbufsize) self.wfile = socket._fileobject(self.request, "wb", self.wbufsize)
class ServeFileException(Exception): class ServeFileException(Exception):
pass pass
@ -845,8 +811,7 @@ class ServeFile():
if self.serveMode not in range(self._NUM_MODES): if self.serveMode not in range(self._NUM_MODES):
self.serveMode = None self.serveMode = None
raise ValueError("Unknown serve mode, needs to be MODE_SINGLE, " raise ValueError("Unknown serve mode, needs to be MODE_SINGLE, MODE_SINGLETAR, MODE_UPLOAD or MODE_DIRLIST.")
"MODE_SINGLETAR, MODE_UPLOAD or MODE_DIRLIST.")
def setIPv4(self, ipv4): def setIPv4(self, ipv4):
""" En- or disable ipv4 """ """ En- or disable ipv4 """
@ -860,23 +825,23 @@ class ServeFile():
""" Get IPs from all interfaces via ip or ifconfig. """ """ Get IPs from all interfaces via ip or ifconfig. """
# ip and ifconfig sometimes are located in /sbin/ # ip and ifconfig sometimes are located in /sbin/
os.environ['PATH'] += ':/sbin:/usr/sbin' os.environ['PATH'] += ':/sbin:/usr/sbin'
proc = Popen(r"ip addr|" proc = Popen(r"ip addr|" + \
r"sed -n -e 's/.*inet6\{0,1\} \([0-9.a-fA-F:]\+\).*/\1/ p'|" "sed -n -e 's/.*inet6\{0,1\} \([0-9.a-fA-F:]\+\).*/\\1/ p'|" + \
r"grep -v '^fe80\|^127.0.0.1\|^::1'", "grep -v '^fe80\|^127.0.0.1\|^::1'", \
shell=True, stdout=PIPE, stderr=PIPE) shell=True, stdout=PIPE, stderr=PIPE)
if proc.wait() != 0: if proc.wait() != 0:
# ip failed somehow, falling back to ifconfig # ip failed somehow, falling back to ifconfig
oldLang = os.environ.get("LC_ALL", None) oldLang = os.environ.get("LC_ALL", None)
os.environ['LC_ALL'] = "C" os.environ['LC_ALL'] = "C"
proc = Popen(r"ifconfig|" proc = Popen(r"ifconfig|" + \
r"sed -n 's/.*inet6\{0,1\}\( addr:\)\{0,1\} \{0,1\}\([0-9a-fA-F.:]*\).*/" "sed -n 's/.*inet6\{0,1\}\( addr:\)\{0,1\} \{0,1\}\([0-9a-fA-F.:]*\).*/" + \
r"\2/p'|" "\\2/p'|" + \
r"grep -v '^fe80\|^127.0.0.1\|^::1'", "grep -v '^fe80\|^127.0.0.1\|^::1'", \
shell=True, stdout=PIPE, stderr=PIPE) shell=True, stdout=PIPE, stderr=PIPE)
if oldLang: if oldLang:
os.environ['LC_ALL'] = oldLang os.environ['LC_ALL'] = oldLang
else: else:
del os.environ['LC_ALL'] del(os.environ['LC_ALL'])
if proc.wait() != 0: if proc.wait() != 0:
# we couldn't find any ip address # we couldn't find any ip address
proc = None proc = None
@ -919,7 +884,7 @@ class ServeFile():
req = crypto.X509Req() req = crypto.X509Req()
subj = req.get_subject() subj = req.get_subject()
subj.CN = "127.0.0.1" subj.CN = "127.0.0.1"
subj.O = "servefile laboratories" # noqa: E741 subj.O = "servefile laboratories"
subj.OU = "servefile" subj.OU = "servefile"
# generate altnames # generate altnames
@ -940,7 +905,7 @@ class ServeFile():
# with the same serial ==> we just use the seconds as serial. # with the same serial ==> we just use the seconds as serial.
cert.set_serial_number(int(time.time())) cert.set_serial_number(int(time.time()))
cert.gmtime_adj_notBefore(0) cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(365 * 24 * 60 * 60) cert.gmtime_adj_notAfter(365*24*60*60)
cert.set_issuer(req.get_subject()) cert.set_issuer(req.get_subject())
cert.set_subject(req.get_subject()) cert.set_subject(req.get_subject())
cert.add_extensions([ext]) cert.add_extensions([ext])
@ -984,8 +949,7 @@ class ServeFile():
server = SecureThreadedHTTPServer(self._getCert(), self._getKey(), server = SecureThreadedHTTPServer(self._getCert(), self._getKey(),
(listenIp, self.port), handler, bind_and_activate=False) (listenIp, self.port), handler, bind_and_activate=False)
except SSL.Error as e: except SSL.Error as e:
raise ServeFileException("SSL error: Could not read SSL public/private key " raise ServeFileException("SSL error: Could not read SSL public/private key from file(s) (error was: \"%s\")" % (e[0][0][2],))
"from file(s) (error was: \"%s\")" % (e[0][0][2],))
else: else:
server = ThreadedHTTPServer((listenIp, self.port), handler, server = ThreadedHTTPServer((listenIp, self.port), handler,
bind_and_activate=False) bind_and_activate=False)
@ -1018,7 +982,7 @@ class ServeFile():
print("Serving \"%s\" for uploads at port %d." % (self.target, self.port)) print("Serving \"%s\" for uploads at port %d." % (self.target, self.port))
# print urls with local network adresses # print urls with local network adresses
print("\nSome addresses %s will be available at:" % print("\nSome addresses %s will be available at:" % \
("this file" if (self.serveMode != self.MODE_UPLOAD) else "the uploadform", )) ("this file" if (self.serveMode != self.MODE_UPLOAD) else "the uploadform", ))
ips = self.getIPs() ips = self.getIPs()
if not ips or len(ips) == 0 or ips[0] == '': if not ips or len(ips) == 0 or ips[0] == '':
@ -1075,8 +1039,7 @@ class ServeFile():
try: try:
os.mkdir(self.target) os.mkdir(self.target)
except (IOError, OSError) as e: except (IOError, OSError) as e:
raise ServeFileException("Error: Could not create directory '%s' for uploads, %r" % raise ServeFileException("Error: Could not create directory '%s' for uploads, %r" % (self.target, str(e)))
(self.target, str(e)))
else: else:
raise ServeFileException("Error: Upload directory already exists and is a file.") raise ServeFileException("Error: Upload directory already exists and is a file.")
FilePutter.targetDir = os.path.abspath(self.target) FilePutter.targetDir = os.path.abspath(self.target)
@ -1095,7 +1058,6 @@ class ServeFile():
AuthenticationHandler.authString = self.auth AuthenticationHandler.authString = self.auth
if self.authrealm: if self.authrealm:
AuthenticationHandler.realm = self.authrealm AuthenticationHandler.realm = self.authrealm
class AuthenticatedHandler(AuthenticationHandler, handler): class AuthenticatedHandler(AuthenticationHandler, handler):
pass pass
handler = AuthenticatedHandler handler = AuthenticatedHandler
@ -1141,8 +1103,7 @@ class AuthenticationHandler():
self.send_response(401) self.send_response(401)
self.send_header("WWW-Authenticate", "Basic realm=\"%s\"" % self.realm) self.send_header("WWW-Authenticate", "Basic realm=\"%s\"" % self.realm)
self.send_header("Connection", "close") self.send_header("Connection", "close")
errorMsg = ("<html><head><title>401 - Unauthorized</title></head>" errorMsg = "<html><head><title>401 - Unauthorized</title></head><body><h1>401 - Unauthorized</h1></body></html>"
"<body><h1>401 - Unauthorized</h1></body></html>")
self.send_header("Content-Length", str(len(errorMsg))) self.send_header("Content-Length", str(len(errorMsg)))
self.end_headers() self.end_headers()
self.wfile.write(errorMsg.encode()) self.wfile.write(errorMsg.encode())
@ -1152,35 +1113,32 @@ def main():
parser = argparse.ArgumentParser(prog='servefile', description='Serve a single file via HTTP.') parser = argparse.ArgumentParser(prog='servefile', description='Serve a single file via HTTP.')
parser.add_argument('--version', action='version', version='%(prog)s ' + __version__) parser.add_argument('--version', action='version', version='%(prog)s ' + __version__)
parser.add_argument('target', metavar='file/directory', type=str) parser.add_argument('target', metavar='file/directory', type=str)
parser.add_argument('-p', '--port', type=int, default=8080, parser.add_argument('-p', '--port', type=int, default=8080, \
help='Port to listen on') help='Port to listen on')
parser.add_argument('-u', '--upload', action="store_true", default=False, parser.add_argument('-u', '--upload', action="store_true", default=False, \
help="Enable uploads to a given directory") help="Enable uploads to a given directory")
parser.add_argument('-s', '--max-upload-size', type=str, parser.add_argument('-s', '--max-upload-size', type=str, \
help="Limit upload size in kB. Size modifiers are allowed, e.g. 2G, 12MB, 1B") help="Limit upload size in kB. Size modifiers are allowed, e.g. 2G, 12MB, 1B")
parser.add_argument('-l', '--list-dir', action="store_true", default=False, parser.add_argument('-l', '--list-dir', action="store_true", default=False, \
help="Show directory indexes and allow access to all subdirectories") help="Show directory indexes and allow access to all subdirectories")
parser.add_argument('--ssl', action="store_true", default=False, parser.add_argument('--ssl', action="store_true", default=False, \
help="Enable SSL. If no key/cert is specified one will be generated") help="Enable SSL. If no key/cert is specified one will be generated")
parser.add_argument('--key', type=str, parser.add_argument('--key', type=str, \
help="Keyfile to use for SSL. If no cert is given with --cert the keyfile " help="Keyfile to use for SSL. If no cert is given with --cert the keyfile will also be searched for a cert")
"will also be searched for a cert") parser.add_argument('--cert', type=str, \
parser.add_argument('--cert', type=str,
help="Certfile to use for SSL") help="Certfile to use for SSL")
parser.add_argument('-a', '--auth', type=str, metavar='user:password', parser.add_argument('-a', '--auth', type=str, metavar='user:password', \
help="Set user and password for HTTP basic authentication") help="Set user and password for HTTP basic authentication")
parser.add_argument('--realm', type=str, default=None, parser.add_argument('--realm', type=str, default=None,\
help="Set a realm for HTTP basic authentication") help="Set a realm for HTTP basic authentication")
parser.add_argument('-t', '--tar', action="store_true", default=False, parser.add_argument('-t', '--tar', action="store_true", default=False, \
help="Enable on the fly tar creation for given file or directory. " help="Enable on the fly tar creation for given file or directory. Note: Download continuation will not be available")
"Note: Download continuation will not be available") parser.add_argument('-c', '--compression', type=str, metavar='method', \
parser.add_argument('-c', '--compression', type=str, metavar='method', default="none", \
default="none", help="Set compression method, only in combination with --tar. Can be one of %s" % ", ".join(TarFileHandler.compressionMethods))
help="Set compression method, only in combination with --tar. " parser.add_argument('-4', '--ipv4-only', action="store_true", default=False, \
"Can be one of %s" % ", ".join(TarFileHandler.compressionMethods))
parser.add_argument('-4', '--ipv4-only', action="store_true", default=False,
help="Listen on IPv4 only") help="Listen on IPv4 only")
parser.add_argument('-6', '--ipv6-only', action="store_true", default=False, parser.add_argument('-6', '--ipv6-only', action="store_true", default=False, \
help="Listen on IPv6 only") help="Listen on IPv6 only")
args = parser.parse_args() args = parser.parse_args()
@ -1196,7 +1154,7 @@ def main():
sys.exit(1) sys.exit(1)
if args.max_upload_size: if args.max_upload_size:
sizeRe = re.match(r"^(\d+(?:[,.]\d+)?)(?:([bkmgtpe])(?:(?<!b)b?)?)?$", args.max_upload_size.lower()) sizeRe = re.match("^(\d+(?:[,.]\d+)?)(?:([bkmgtpe])(?:(?<!b)b?)?)?$", args.max_upload_size.lower())
if not sizeRe: if not sizeRe:
print("Error: Your max upload size param is broken. Try something like 3M or 2.5Gb.") print("Error: Your max upload size param is broken. Try something like 3M or 2.5Gb.")
sys.exit(1) sys.exit(1)
@ -1222,9 +1180,8 @@ def main():
if args.auth: if args.auth:
dpos = args.auth.find(":") dpos = args.auth.find(":")
if dpos <= 0 or dpos == (len(args.auth) - 1): if dpos <= 0 or dpos == (len(args.auth)-1):
print("Error: User and password for HTTP basic authentication need to be both " print("Error: User and password for HTTP basic authentication need to be both at least one character and have to be separated by a \":\".")
"at least one character and have to be separated by a \":\".")
sys.exit(1) sys.exit(1)
if args.realm and not args.auth: if args.realm and not args.auth:
@ -1295,3 +1252,4 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View File

@ -11,7 +11,7 @@ setup(
long_description=long_description, long_description=long_description,
long_description_content_type='text/markdown', long_description_content_type='text/markdown',
platforms='posix', platforms='posix',
version='0.5.4', version='0.5.1',
license='GPLv3 or later', license='GPLv3 or later',
url='https://github.com/sebageek/servefile/', url='https://github.com/sebageek/servefile/',
author='Sebastian Lohff', author='Sebastian Lohff',
@ -38,11 +38,10 @@ setup(
'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Topic :: Communications', 'Topic :: Communications',
'Topic :: Communications :: File Sharing', 'Topic :: Communications :: File Sharing',
'Topic :: Internet', 'Topic :: Internet',

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
import io import io
import os import os
import pytest import pytest
@ -9,18 +8,15 @@ import sys
import tarfile import tarfile
import time import time
import urllib3 import urllib3
from requests.exceptions import ConnectionError
# crudly written to learn more about pytest and to have a base for refactoring # crudly written to learn more about pytest and to have a base for refactoring
if sys.version_info.major >= 3: if sys.version_info.major >= 3:
from pathlib import Path from pathlib import Path
from urllib.parse import quote
connrefused_exc = ConnectionRefusedError connrefused_exc = ConnectionRefusedError
else: else:
from pathlib2 import Path from pathlib2 import Path
from urllib import quote
connrefused_exc = socket.error connrefused_exc = socket.error
@ -35,8 +31,8 @@ def _get_port_from_env(var_name, default):
return port return port
SERVEFILE_DEFAULT_PORT = _get_port_from_env('SERVEFILE_DEFAULT_PORT', 0) SERVEFILE_DEFAULT_PORT = _get_port_from_env('SERVEFILE_DEFAULT_PORT', 8080)
SERVEFILE_SECONDARY_PORT = _get_port_from_env('SERVEFILE_SECONDARY_PORT', 0) SERVEFILE_SECONDARY_PORT = _get_port_from_env('SERVEFILE_SECONDARY_PORT', 8081)
@pytest.fixture @pytest.fixture
@ -54,11 +50,12 @@ def run_servefile():
servefile_path = ['-m', 'servefile'] servefile_path = ['-m', 'servefile']
# use non-default default port, if one is given via env (and none via args) # use non-default default port, if one is given via env (and none via args)
if '-p' not in args and '--port' not in args: if '-p' not in args and SERVEFILE_DEFAULT_PORT != 8080:
args.extend(['-p', str(SERVEFILE_DEFAULT_PORT)]) args.extend(['-p', str(SERVEFILE_DEFAULT_PORT)])
print("running {} with args {}".format(", ".join(servefile_path), args)) print("running {} with args {}".format(", ".join(servefile_path), args))
p = subprocess.Popen([sys.executable] + servefile_path + args, **kwargs) p = subprocess.Popen([sys.executable] + servefile_path + args, **kwargs)
time.sleep(kwargs.get('timeout', 0.3))
instances.append(p) instances.append(p)
return p return p
@ -84,26 +81,23 @@ def datadir(tmp_path):
_datadir(v, new_path) _datadir(v, new_path)
else: else:
if hasattr(v, 'decode'): if hasattr(v, 'decode'):
v = v.decode('utf-8') # python2 compability v = v.decode() # python2 compability
(path / k).write_text(v) (path / k).write_text(v)
return path return path
return _datadir return _datadir
def make_request(path='/', host='localhost', port=SERVEFILE_DEFAULT_PORT, method='get', protocol='http', def make_request(path='/', host='localhost', port=SERVEFILE_DEFAULT_PORT, method='get', protocol='http', timeout=5,
encoding='utf-8', **kwargs): **kwargs):
url = '{}://{}:{}{}'.format(protocol, host, port, path) url = '{}://{}:{}{}'.format(protocol, host, port, path)
print('Calling {} on {} with {}'.format(method, url, kwargs)) print('Calling {} on {} with {}'.format(method, url, kwargs))
r = getattr(requests, method)(url, **kwargs) r = getattr(requests, method)(url, **kwargs)
if r.encoding is None and encoding:
r.encoding = encoding
return r return r
def check_download(expected_data=None, path='/', fname=None, **kwargs): def check_download(expected_data=None, path='/', fname=None, status_code=200, **kwargs):
if fname is None: if fname is None:
fname = os.path.basename(path) fname = os.path.basename(path)
r = make_request(path, **kwargs) r = make_request(path, **kwargs)
@ -117,22 +111,6 @@ def check_download(expected_data=None, path='/', fname=None, **kwargs):
return r # for additional tests return r # for additional tests
def _retry_while(exception, function, timeout=2):
now = time.time # float seconds since epoch
def wrapped(*args, **kwargs):
timeout_after = now() + timeout
while True:
try:
return function(*args, **kwargs)
except exception:
if now() >= timeout_after:
raise
time.sleep(0.1)
return wrapped
def _test_version(run_servefile, standalone): def _test_version(run_servefile, standalone):
# we expect the version on stdout (python3.4+) or stderr(python2.6-3.3) # we expect the version on stdout (python3.4+) or stderr(python2.6-3.3)
s = run_servefile('--version', standalone=standalone, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) s = run_servefile('--version', standalone=standalone, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
@ -146,7 +124,7 @@ def _test_version(run_servefile, standalone):
version = s.stdout.readline().decode().strip() version = s.stdout.readline().decode().strip()
# hardcode version as string until servefile is a module # hardcode version as string until servefile is a module
assert version == 'servefile 0.5.4' assert version == 'servefile 0.5.1'
def test_version(run_servefile): def test_version(run_servefile):
@ -163,7 +141,7 @@ def test_correct_headers(run_servefile, datadir):
p = datadir({'testfile': data}) / 'testfile' p = datadir({'testfile': data}) / 'testfile'
run_servefile(str(p)) run_servefile(str(p))
r = _retry_while(ConnectionError, make_request)() r = make_request()
assert r.status_code == 200 assert r.status_code == 200
assert r.headers.get('Content-Type') == 'application/octet-stream' assert r.headers.get('Content-Type') == 'application/octet-stream'
assert r.headers.get('Content-Disposition') == 'attachment; filename="testfile"' assert r.headers.get('Content-Disposition') == 'attachment; filename="testfile"'
@ -176,7 +154,7 @@ def test_redirect_and_download(run_servefile, datadir):
run_servefile(str(p)) run_servefile(str(p))
# redirect # redirect
r = _retry_while(ConnectionError, make_request)(allow_redirects=False) r = make_request(allow_redirects=False)
assert r.status_code == 302 assert r.status_code == 302
assert r.headers.get('Location') == '/testfile' assert r.headers.get('Location') == '/testfile'
@ -184,29 +162,12 @@ def test_redirect_and_download(run_servefile, datadir):
check_download(data, fname='testfile') check_download(data, fname='testfile')
def test_redirect_and_download_with_umlaut(run_servefile, datadir):
data = "NÖÖT NÖÖT"
filename = "tästføile"
p = datadir({filename: data}) / filename
run_servefile(str(p))
# redirect
r = _retry_while(ConnectionError, make_request)(allow_redirects=False)
assert r.status_code == 302
assert r.headers.get('Location') == '/{}'.format(quote(filename))
# normal download
if sys.version_info.major < 3:
data = unicode(data, 'utf-8')
check_download(data, fname=filename)
def test_specify_port(run_servefile, datadir): def test_specify_port(run_servefile, datadir):
data = "NOOT NOOT" data = "NOOT NOOT"
p = datadir({'testfile': data}) / 'testfile' p = datadir({'testfile': data}) / 'testfile'
run_servefile([str(p), '-p', str(SERVEFILE_SECONDARY_PORT)]) run_servefile([str(p), '-p', str(SERVEFILE_SECONDARY_PORT)])
_retry_while(ConnectionError, check_download)(data, fname='testfile', port=SERVEFILE_SECONDARY_PORT) check_download(data, fname='testfile', port=SERVEFILE_SECONDARY_PORT)
def test_ipv4_only(run_servefile, datadir): def test_ipv4_only(run_servefile, datadir):
@ -214,7 +175,7 @@ def test_ipv4_only(run_servefile, datadir):
p = datadir({'testfile': data}) / 'testfile' p = datadir({'testfile': data}) / 'testfile'
run_servefile([str(p), '-4']) run_servefile([str(p), '-4'])
_retry_while(ConnectionError, check_download)(data, fname='testfile', host='127.0.0.1') check_download(data, fname='testfile', host='127.0.0.1')
sock = socket.socket(socket.AF_INET6) sock = socket.socket(socket.AF_INET6)
with pytest.raises(connrefused_exc): with pytest.raises(connrefused_exc):
@ -227,7 +188,7 @@ def test_big_download(run_servefile, datadir):
p = datadir({'testfile': data}) / 'testfile' p = datadir({'testfile': data}) / 'testfile'
run_servefile(str(p)) run_servefile(str(p))
_retry_while(ConnectionError, check_download)(data, fname='testfile') check_download(data, fname='testfile')
def test_authentication(run_servefile, datadir): def test_authentication(run_servefile, datadir):
@ -236,11 +197,11 @@ def test_authentication(run_servefile, datadir):
run_servefile([str(p), '-a', 'user:password']) run_servefile([str(p), '-a', 'user:password'])
for auth in [('foo', 'bar'), ('user', 'wrong'), ('unknown', 'password')]: for auth in [('foo', 'bar'), ('user', 'wrong'), ('unknown', 'password')]:
r = _retry_while(ConnectionError, make_request)(auth=auth) r = make_request(auth=auth)
assert '401 - Unauthorized' in r.text assert '401 - Unauthorized' in r.text
assert r.status_code == 401 assert r.status_code == 401
_retry_while(ConnectionError, check_download)(data, fname='testfile', auth=('user', 'password')) check_download(data, fname='testfile', auth=('user', 'password'))
def test_serve_directory(run_servefile, datadir): def test_serve_directory(run_servefile, datadir):
@ -249,7 +210,6 @@ def test_serve_directory(run_servefile, datadir):
'bar': {'thisisaverylongfilenamefortestingthatthisstillworksproperly': 'jup!'}, 'bar': {'thisisaverylongfilenamefortestingthatthisstillworksproperly': 'jup!'},
'noot': 'still data in here', 'noot': 'still data in here',
'bigfile': 'x' * (10 * 1024 ** 2), 'bigfile': 'x' * (10 * 1024 ** 2),
'möwe': 'KRAKRAKRAKA',
} }
p = datadir(d) p = datadir(d)
run_servefile([str(p), '-l']) run_servefile([str(p), '-l'])
@ -257,12 +217,12 @@ def test_serve_directory(run_servefile, datadir):
# check if all files are in directory listing # check if all files are in directory listing
# (could be made more sophisticated with beautifulsoup) # (could be made more sophisticated with beautifulsoup)
for path in '/', '/../': for path in '/', '/../':
r = _retry_while(ConnectionError, make_request)(path) r = make_request(path)
for k in d: for k in d:
assert quote(k) in r.text assert k in r.text
for fname, content in d['foo'].items(): for fname, content in d['foo'].items():
_retry_while(ConnectionError, check_download)(content, '/foo/' + fname) check_download(content, '/foo/' + fname)
r = make_request('/unknown') r = make_request('/unknown')
assert r.status_code == 404 assert r.status_code == 404
@ -284,7 +244,7 @@ def test_serve_relative_directory(run_servefile, datadir):
# check if all files are in directory listing # check if all files are in directory listing
# (could be made more sophisticated with beautifulsoup) # (could be made more sophisticated with beautifulsoup)
for path in '/', '/../': for path in '/', '/../':
r = _retry_while(ConnectionError, make_request)(path) r = make_request(path)
for k in d: for k in d:
assert k in r.text assert k in r.text
@ -308,14 +268,14 @@ def test_upload(run_servefile, tmp_path):
run_servefile(['-u', str(uploaddir)]) run_servefile(['-u', str(uploaddir)])
# check upload form present
r = _retry_while(ConnectionError, make_request)()
assert r.status_code == 200
assert 'multipart/form-data' in r.text
# check that servefile created the directory # check that servefile created the directory
assert uploaddir.is_dir() assert uploaddir.is_dir()
# check upload form present
r = make_request()
assert r.status_code == 200
assert 'multipart/form-data' in r.text
# upload file # upload file
files = {'file': ('haiku.txt', data)} files = {'file': ('haiku.txt', data)}
r = make_request(method='post', files=files) r = make_request(method='post', files=files)
@ -331,13 +291,6 @@ def test_upload(run_servefile, tmp_path):
with open(str(uploaddir / 'haiku.txt(1)')) as f: with open(str(uploaddir / 'haiku.txt(1)')) as f:
assert f.read() == data assert f.read() == data
# upload file using PUT
r = make_request("/haiku.txt", method='put', data=data)
assert r.status_code == 201
assert 'OK!' in r.text
with open(str(uploaddir / 'haiku.txt(2)')) as f:
assert f.read() == data
def test_upload_size_limit(run_servefile, tmp_path): def test_upload_size_limit(run_servefile, tmp_path):
uploaddir = tmp_path / 'upload' uploaddir = tmp_path / 'upload'
@ -345,7 +298,7 @@ def test_upload_size_limit(run_servefile, tmp_path):
# upload file that is too big # upload file that is too big
files = {'file': ('toobig', "x" * 2049)} files = {'file': ('toobig', "x" * 2049)}
r = _retry_while(ConnectionError, make_request)(method='post', files=files) r = make_request(method='post', files=files)
assert 'Your file was too big' in r.text assert 'Your file was too big' in r.text
assert r.status_code == 413 assert r.status_code == 413
assert not (uploaddir / 'toobig').exists() assert not (uploaddir / 'toobig').exists()
@ -357,20 +310,6 @@ def test_upload_size_limit(run_servefile, tmp_path):
assert r.status_code == 200 assert r.status_code == 200
def test_upload_large_file(run_servefile, tmp_path):
# small files end up in BytesIO while large files get temporary files. this
# test makes sure we hit the large file codepath at least once
uploaddir = tmp_path / 'upload'
run_servefile(['-u', str(uploaddir)])
data = "asdf" * 1024
files = {'file': ('more_data.txt', data)}
r = _retry_while(ConnectionError, make_request)(method='post', files=files)
assert r.status_code == 200
with open(str(uploaddir / 'more_data.txt')) as f:
assert f.read() == data
def test_tar_mode(run_servefile, datadir): def test_tar_mode(run_servefile, datadir):
d = { d = {
'foo': { 'foo': {
@ -384,7 +323,7 @@ def test_tar_mode(run_servefile, datadir):
# test redirect? # test redirect?
# test contents of tar file # test contents of tar file
r = _retry_while(ConnectionError, make_request)() r = make_request()
assert r.status_code == 200 assert r.status_code == 200
tar = tarfile.open(fileobj=io.BytesIO(r.content)) tar = tarfile.open(fileobj=io.BytesIO(r.content))
assert len(tar.getmembers()) == 3 assert len(tar.getmembers()) == 3
@ -400,7 +339,7 @@ def test_tar_compression(run_servefile, datadir):
p = datadir(d) p = datadir(d)
run_servefile(['-c', 'gzip', '-t', str(p / 'foo')]) run_servefile(['-c', 'gzip', '-t', str(p / 'foo')])
r = _retry_while(ConnectionError, make_request)() r = make_request()
assert r.status_code == 200 assert r.status_code == 200
tar = tarfile.open(fileobj=io.BytesIO(r.content), mode='r:gz') tar = tarfile.open(fileobj=io.BytesIO(r.content), mode='r:gz')
assert len(tar.getmembers()) == 1 assert len(tar.getmembers()) == 1
@ -410,6 +349,7 @@ def test_https(run_servefile, datadir):
data = "NOOT NOOT" data = "NOOT NOOT"
p = datadir({'testfile': data}) / 'testfile' p = datadir({'testfile': data}) / 'testfile'
run_servefile(['--ssl', str(p)]) run_servefile(['--ssl', str(p)])
time.sleep(0.2) # time for generating ssl certificates
# fingerprint = None # fingerprint = None
# while not fingerprint: # while not fingerprint:
@ -424,7 +364,7 @@ def test_https(run_servefile, datadir):
# assert fingerprint # assert fingerprint
urllib3.disable_warnings() urllib3.disable_warnings()
_retry_while(ConnectionError, check_download)(data, protocol='https', verify=False) check_download(data, protocol='https', verify=False)
def test_https_big_download(run_servefile, datadir): def test_https_big_download(run_servefile, datadir):
@ -432,9 +372,10 @@ def test_https_big_download(run_servefile, datadir):
data = "x" * (10 * 1024 ** 2) data = "x" * (10 * 1024 ** 2)
p = datadir({'testfile': data}) / 'testfile' p = datadir({'testfile': data}) / 'testfile'
run_servefile(['--ssl', str(p)]) run_servefile(['--ssl', str(p)])
time.sleep(0.2) # time for generating ssl certificates
urllib3.disable_warnings() urllib3.disable_warnings()
_retry_while(ConnectionError, check_download)(data, protocol='https', verify=False) check_download(data, protocol='https', verify=False)
def test_abort_download(run_servefile, datadir): def test_abort_download(run_servefile, datadir):
@ -447,7 +388,7 @@ def test_abort_download(run_servefile, datadir):
# provoke a connection abort # provoke a connection abort
# hopefully the buffers will not fill up with all of the 10mb # hopefully the buffers will not fill up with all of the 10mb
sock = socket.socket(socket.AF_INET) sock = socket.socket(socket.AF_INET)
_retry_while(connrefused_exc, sock.connect)(("localhost", SERVEFILE_DEFAULT_PORT)) sock.connect(("localhost", SERVEFILE_DEFAULT_PORT))
sock.send(b"GET /testfile HTTP/1.0\n\n") sock.send(b"GET /testfile HTTP/1.0\n\n")
resp = sock.recv(100) resp = sock.recv(100)
assert resp != b'' assert resp != b''

14
tox.ini
View File

@ -1,19 +1,9 @@
[tox] [tox]
envlist = py27,py37,py38,py39,py310,py311,pep8 envlist = py27,py36,py37,py38
[testenv] [testenv]
deps = deps =
pathlib2; python_version<"3" pathlib2; python_version<"3"
pytest pytest
requests requests
flake8 commands = pytest --tb=short {posargs}
commands = pytest -v --tb=short {posargs}
[testenv:pep8]
commands = flake8 servefile/ {posargs}
[flake8]
show-source = True
max-line-length = 120
ignore = E123,E125,E241,E402,E741,W503,W504,H301
exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,build