pax_global_header00006660000000000000000000000064143636063160014522gustar00rootroot0000000000000052 comment=f668fc3fe6525746524bc0463578a86d23055d2c servefile-0.5.4/000077500000000000000000000000001436360631600135145ustar00rootroot00000000000000servefile-0.5.4/.github/000077500000000000000000000000001436360631600150545ustar00rootroot00000000000000servefile-0.5.4/.github/workflows/000077500000000000000000000000001436360631600171115ustar00rootroot00000000000000servefile-0.5.4/.github/workflows/run-tox.yml000066400000000000000000000013741436360631600212550ustar00rootroot00000000000000name: 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" servefile-0.5.4/.gitignore000066400000000000000000000000771436360631600155100ustar00rootroot00000000000000MANIFEST dist/ *.pyc __pycache__ *.swp servefile.egg-info .tox servefile-0.5.4/ChangeLog000066400000000000000000000052201436360631600152650ustar00rootroot00000000000000servefile changelog =================== 2023-01-23 v0.5.4 ----------------- 0.5.4 released * 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 wrong/outdated pyopenssl package names * tests are now using a free non-default port to avoid clashes; if wished the ports can be set from outside by specifying the environment variables SERVEFILE_DEFAULT_PORT and SERVEFILE_SECONDARY_PORT * 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 ----------------- 0.5.1 released * version bump for broken pypi release 2020-10-29 v0.5.0 ----------------- 0.5.0 released * python3 support * test suite * fixed an endless redirect loop when serving ../ * added sorting for list view * added lzma/xz as compression method 2015-11-10 v0.4.4 ----------------- 0.4.4 released * prefer using TLS1.2/TLS1 with --ssl if available * issue v3 certificates for self signed certificates with --ssl * removed lots of unnecessary error output * fixed a bug where wrong ranges were used on a HEAD request in directory listing mode * fixed a bug where directory listing mode allowed path traversal 2013-12-28 v0.4.3 ----------------- 0.4.3 released * display user/password in url-list when authentication is used * various directory-listing patches by Sebastian Pipping * case-insensitive sorting * sort directories to top * hide .. in top directory * better error reporting/exception handling patch by Robert Buchholz * properly tell clients that http keep-alive is not available 2012-06-27 v0.4.2 ----------------- 0.4.2 released * new directory listing + nicer index * IPv6 support * basic auth realm configurable * various bugfixes 2012-05-04 v0.4.1 ----------------- 0.4.1 released * tar + compression feature * compression * shows fingerprint for self generated certs * added manpage 2012-04-16 v0.4.0 ----------------- 0.4.0 released * SSL capabilities * Automatic creation of self signed certificates * HTTP basic auth * HEAD support * POST/Multipart upload support * PUT/POST upload support 2012-04-05 v0.3.2 ----------------- 0.3.2 released * argparse servefile-0.5.4/MANIFEST.in000066400000000000000000000000461436360631600152520ustar00rootroot00000000000000include ChangeLog include servefile.1 servefile-0.5.4/README.md000066400000000000000000000021331436360631600147720ustar00rootroot00000000000000Servefile ========= Serve files from shell via a small HTTP server. The server redirects all HTTP requests to the file, so only IP and port must be given to another user to access the file. Its main purpose is to quickly send a file to users in your local network, independent of their current setup (OS/software). Besides that it also supports uploads, SSL, HTTP basic auth and directory listings. Features: * serve single file * serve a directory with directory index * file upload via webinterface * HTTPS with on the fly generated self signed SSL certificates * HTTP basic authentication * serving files/directories as on request generated tar files Install ------- Via pip ```shell pip install servefile ``` After installation either execute `servefile --help` or `python -m servefile --help` Standalone: If you don't have pip available just copy `servefile/servefile.py` onto the target machine, make it executable and you are ready to go. ```shell $ wget https://raw.githubusercontent.com/sebageek/servefile/master/servefile/servefile.py -O servefile $ chmod +x servefile $ ./servefile --help ``` servefile-0.5.4/servefile.1000066400000000000000000000100211436360631600155540ustar00rootroot00000000000000.TH SERVEFILE 1 "January 2023" "servefile 0.5.4" "User Commands" .SH NAME servefile \- small HTTP-Server for temporary file transfer .SH SYNOPSIS .B servefile [\fI\-h\fR\fR] [\fI\-\-version\fR] [\fI\-p PORT\fR] [\fI\-u\fR] [\fI\-s MAX_UPLOAD_SIZE\fR] [\fI\-l\fR] .IP [\fI\-\-ssl\fR] [\fI\-\-key KEY\fR] [\fI\-\-cert CERT\fR] [\fI\-a user:password\fR] \fIfile/directory\fR .SH DISCLAIMER Do not use this as a normal web server. This server is optimized for running a short time and to send files to other people, not for doing high-performance static file serving. .SH DESCRIPTION Servefile is a small HTTP-server intended for temporary file transfer mostly in the local network. It aims to make transferring single files as painless as possible and to replace tar/netcat solutions. With just a file as argument servefile serves just that one file and redirects all HTTP requests to that file. Uploads can be done with curl, wget (see EXAMPLES) or a normal browser. In upload mode with \fB\-u\fR servefile creates a directory and saves all uploaded files into that directory. When uploading with curl or wget the filename is extracted from the path part of the url used for the upload. For SSL support pyopenssl (python3-openssl) needs to be installed. If no key and cert is given, servefile will generate a key pair for you and display its fingerprint. In \fB--tar\fR mode the given file or directory will be packed on (each) request and piped to the client through the HTTP connection, thus serving always the latest content of the directory and preventing temporary file creaton. Tar files will be created containing only the lowest directory name from the full path, so using /path/to/dir/ as \fIfile/directory\fR argument will create a tar file starting with the dir/ directory. When giving a file as argument, only the file without any path will be in the tarfile. Symlinks will not be dereferenced. .SH COMMAND SUMMARY .SS "positional arguments:" .TP \fIfile/directory\fR file or directory (with \fB\-l\fR or \fB\-u\fR) which should be served or uploaded to .SS "optional arguments:" .TP \fB\-h\fR, \fB\-\-help\fR Show a help message and exit .TP \fB\-\-version\fR Show program's version number and exit .TP \fB\-p\fR PORT, \fB\-\-port\fR PORT Port to listen on .TP \fB\-u\fR, \fB\-\-upload\fR Enable uploads to a given directory .TP \fB\-s\fR MAX_UPLOAD_SIZE, \fB\-\-max\-upload\-size\fR MAX_UPLOAD_SIZE Limit upload size in kB. Size modifiers are allowed, e.g. 2G, 12MB, 1B. .TP \fB\-l\fR, \fB\-\-list\-dir\fR Show directory indexes and allow access to all subdirectories .TP \fB\-\-ssl\fR Enable SSL. If no key/cert is specified one will be generated. .TP \fB\-\-key\fR KEY Key file to use for SSL. If no cert is given with \fB\-\-cert\fR the key file will also be searched for a cert .TP \fB\-\-cert\fR CERT Certfile to use for SSL .TP \fB\-a\fR user:password, \fB\-\-auth\fR user:password Set user and password for HTTP basic authentication .TP \fB\-\-realm\fR REALM Set a realm for HTTP basic authentication. This is an arbitrary string which is displayed when doing HTTP basic authentication .TP \fB\-t\fR, \fB\-\-tar\fR Enable on the fly tar creation for given file or directory. Note: Download continuation will not be available. .TP \fB\-c\fR method, \fB\-\-compression\fR method Set compression method, only in combination with \fB\-\-tar\fR. Can be one of none, gzip, bzip2, xz. .TP \fB\-4\fR, \fB\-\-ipv4\-only\fR Listen on IPv4 only .TP \fB\-6\fR, \fB\-\-ipv6\-only\fR Listen on IPv6 only .SH EXAMPLES Serving a single file with SSL and HTTP Basic auth: .IP servefile \-\-ssl \-\-auth foo:bar the_file .PP Enabling uploads to a directory: .IP servefile \-u dir/ .PP Uploading file foo as bar to servefile via command line: .PP curl \-X PUT http://ip:port/bar \-\-data-binary @foo curl \-X POST http://ip:port/bar \-\-data-binary @foo wget http://ip:port/bar \-\-post-file=foo .PP Serving a on the fly generated tar.gz file of a directory: .IP servefile \-\-tar \-c gzip path/to/dir .PP .SH AUTHOR servefile is developed by Sebastian Lohff servefile-0.5.4/servefile/000077500000000000000000000000001436360631600155005ustar00rootroot00000000000000servefile-0.5.4/servefile/__init__.py000066400000000000000000000000001436360631600175770ustar00rootroot00000000000000servefile-0.5.4/servefile/__main__.py000066400000000000000000000000521436360631600175670ustar00rootroot00000000000000from . import servefile servefile.main() servefile-0.5.4/servefile/servefile.py000077500000000000000000001370171436360631600200520ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- # Licensed under GNU General Public License v3 or later # Written by Sebastian Lohff (seba@seba-geek.de) # http://seba-geek.de/stuff/servefile/ from __future__ import print_function __version__ = '0.5.4' import argparse import base64 import datetime import io import mimetypes import os import re import select import socket from subprocess import Popen, PIPE import sys import tempfile import time import warnings # fix imports for python2/python3 try: import BaseHTTPServer import SocketServer from urllib import quote, unquote except ImportError: # both have different names in python3 import http.server as BaseHTTPServer import socketserver as SocketServer from urllib.parse import quote, unquote # only activate SSL if available HAVE_SSL = False try: from OpenSSL import SSL, crypto HAVE_SSL = True except ImportError: pass with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=DeprecationWarning) # scheduled for removal in python3.13, used for FieldStorage import cgi def getDateStrNow(): """ Get the current time formatted for HTTP header """ now = datetime.datetime.fromtimestamp(time.mktime(time.gmtime())) return now.strftime("%a, %d %b %Y %H:%M:%S GMT") class FileBaseHandler(BaseHTTPServer.BaseHTTPRequestHandler): fileName = None blockSize = 1024 * 1024 server_version = "servefile/" + __version__ def checkAndDoRedirect(self, fileName=None): """ If request didn't request self.fileName redirect to self.fileName. Returns True if a redirect was issued. """ if not fileName: fileName = self.fileName if unquote(self.path) != "/" + fileName: self.send_response(302) self.send_header('Location', '/' + quote(fileName)) self.end_headers() return True return False def sendContentHeaders(self, fileName, fileLength, lastModified=None): """ Send default Content headers for given fileName and fileLength. If no lastModified is given the current date is taken. If fileLength is lesser than 0 no Content-Length will be sent.""" if not lastModified: lastModified = getDateStrNow() if fileLength >= 0: self.send_header('Content-Length', str(fileLength)) self.send_header('Connection', 'close') self.send_header('Last-Modified', lastModified) self.send_header('Content-Type', 'application/octet-stream') self.send_header('Content-Disposition', 'attachment; filename="%s"' % fileName) self.send_header('Content-Transfer-Encoding', 'binary') def isRangeRequest(self): """ Return True if partial content is requestet """ return "Range" in self.headers def handleRangeRequest(self, fileLength): """ Find out and handle continuing downloads. Returns a tuple of a boolean, if this is a valid range request, and a range. When the requested range is out of range, range is set to None. """ fromto = None if self.isRangeRequest(): cont = self.headers.get("Range").split("=") if len(cont) > 1 and cont[0] == 'bytes': fromto = cont[1].split('-') if len(fromto) > 1: if fromto[1] == '': fromto[1] = fileLength - 1 try: fromto[0] = int(fromto[0]) fromto[1] = int(fromto[1]) except ValueError: return (False, None) 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) self.send_response(416) self.send_header('Content-Range', 'bytes */%d' % fileLength) self.end_headers() return (True, None) return (True, fromto) # broken request or no range header return (False, None) def sendFile(self, filePath, fileLength=None, lastModified=None): """ Send file with continuation support. filePath: path to file to be sent fileLength: length of file (if None is given this will be found out) lastModified: time the file was last modified, None for "now" """ if not fileLength: fileLength = os.stat(filePath).st_size (responseCode, myfile) = self.getFileHandle(filePath) if not myfile: self.send_response(responseCode) self.end_headers() return (continueDownload, fromto) = self.handleRangeRequest(fileLength) if continueDownload: if not fromto: # we are done return True # now we can wind the file *brrrrrr* myfile.seek(fromto[0]) if fromto is not None: self.send_response(216) self.send_header('Content-Range', 'bytes %d-%d/%d' % (fromto[0], fromto[1], fileLength)) fileLength = fromto[1] - fromto[0] + 1 else: self.send_response(200) fileName = self.fileName if not fileName: fileName = os.path.basename(filePath) self.sendContentHeaders(fileName, fileLength, lastModified) self.end_headers() block = self.getChunk(myfile, fromto) while block: self.wfile.write(block) block = self.getChunk(myfile, fromto) myfile.close() print("%s finished downloading %s" % (self.client_address[0], filePath)) return True def getChunk(self, myfile, fromto): if fromto and myfile.tell() + self.blockSize >= fromto[1]: readsize = fromto[1] - myfile.tell() + 1 else: readsize = self.blockSize return myfile.read(readsize) def getFileHandle(self, path): """ Get handle to a file. Return a tuple of HTTP response code and file handle. If the handle couldn't be acquired it is set to None and an appropriate HTTP error code is returned. """ myfile = None responseCode = 200 try: myfile = open(path, 'rb') except IOError as e: responseCode = self.getResponseForErrno(e.errno) return (responseCode, myfile) def getFileLength(self, path): """ Get length of a file. Return a tuple of HTTP response code and file length. If filelength couldn't be determined, it is set to -1 and an appropriate HTTP error code is returned. """ fileSize = -1 responseCode = 200 try: fileSize = os.stat(path).st_size except IOError as e: responseCode = self.getResponseForErrno(e.errno) return (responseCode, fileSize) def getResponseForErrno(self, errno): """ Return HTTP response code for an IOError errno """ if errno == errno.ENOENT: return 404 elif errno == errno.EACCESS: return 403 else: return 500 class FileHandler(FileBaseHandler): filePath = "/dev/null" fileLength = 0 startTime = getDateStrNow() def do_HEAD(self): if self.checkAndDoRedirect(): return self.send_response(200) self.sendContentHeaders(self.fileName, self.fileLength, self.startTime) self.end_headers() def do_GET(self): if self.checkAndDoRedirect(): return self.sendFile(self.filePath, self.fileLength, self.startTime) class TarFileHandler(FileBaseHandler): target = None compression = "none" compressionMethods = ("none", "gzip", "bzip2", "xz") def do_HEAD(self): if self.checkAndDoRedirect(): return self.send_response(200) self.sendContentHeaders(self.fileName, -1) self.end_headers() def do_GET(self): if self.checkAndDoRedirect(): return tarCmd = Popen(self.getCompressionCmd(), stdout=PIPE) # give the process a short time to find out if it can # pack/compress the file time.sleep(0.05) if tarCmd.poll() is not None and tarCmd.poll() != 0: # something went wrong print("Error while compressing '%s'. Aborting request." % self.target) self.send_response(500) self.end_headers() return self.send_response(200) self.sendContentHeaders(self.fileName, -1) self.end_headers() block = True while block and block != '': block = tarCmd.stdout.read(self.blockSize) if block and block != '': self.wfile.write(block) print("%s finished downloading" % (self.client_address[0])) def getCompressionCmd(self): if self.compression == "none": cmd = ["tar", "-c"] elif self.compression == "gzip": cmd = ["tar", "-cz"] elif self.compression == "bzip2": cmd = ["tar", "-cj"] elif self.compression == "xz": cmd = ["tar", "-cJ"] else: raise ValueError("Unknown compression mode '%s'." % self.compression) dirname = os.path.basename(self.target.rstrip("/")) chdirTo = os.path.dirname(self.target.rstrip("/")) if chdirTo != '': cmd.extend(["-C", chdirTo]) cmd.append(dirname) return cmd @staticmethod def getCompressionExt(): if TarFileHandler.compression == "none": return ".tar" elif TarFileHandler.compression == "gzip": return ".tar.gz" elif TarFileHandler.compression == "bzip2": return ".tar.bz2" elif TarFileHandler.compression == "xz": return ".tar.xz" raise ValueError("Unknown compression mode '%s'." % TarFileHandler.compression) class DirListingHandler(FileBaseHandler): """ DOCUMENTATION MISSING """ targetDir = None def do_HEAD(self): self.getFileOrDirectory(head=True) def do_GET(self): self.getFileOrDirectory(head=False) def getFileOrDirectory(self, head=False): """ Send file or directory index, depending on requested path """ path = self.getCleanPath() # check if path is in current serving directory currBaseDir = self.targetDir + os.path.sep requestPath = os.path.normpath(os.path.join(currBaseDir, path)) + os.path.sep if not requestPath.startswith(currBaseDir): self.send_response(301) self.send_header("Location", '/') self.end_headers() return if os.path.isdir(path): if not self.path.endswith('/'): self.send_response(301) self.send_header("Location", self.path + '/') self.end_headers() else: self.sendDirectoryListing(path, head) elif os.path.isfile(path): if head: (response, length) = self.getFileLength(path) if length < 0: self.send_response(response) self.end_headers() else: self.send_response(200) self.sendContentHeaders(path, length) self.end_headers() else: self.sendFile(path, head) else: self.send_response(404) errorMsg = """ 404 Not Found

Not Found

The requestet URL %s was not found on this server

Back to / """ % self.escapeHTML(unquote(self.path)) self.send_header("Content-Length", str(len(errorMsg))) self.send_header('Connection', 'close') self.end_headers() if not head: self.wfile.write(errorMsg.encode()) def escapeHTML(self, htmlstr): entities = [("<", "<"), (">", ">")] for src, dst in entities: htmlstr = htmlstr.replace(src, dst) return htmlstr def _appendToListing(self, content, item, itemPath, stat, is_dir): # Strings to display on directory listing lastModifiedDate = datetime.datetime.fromtimestamp(stat.st_mtime) lastModified = lastModifiedDate.strftime("%Y-%m-%d %H:%M") fileSize = "%.1f%s" % self.convertSize(stat.st_size) (fileType, _) = mimetypes.guess_type(itemPath) if not fileType: fileType = "-" if is_dir: item += "/" fileType = "Directory" content.append(""" %s %s %s %s """ % (quote(item), item, lastModified, fileSize, fileType)) def sendDirectoryListing(self, path, head): """ Generate a directorylisting for path and send it """ header = """ Index of %(path)s

Index of %(path)s

""" % {'path': os.path.normpath(unquote(self.path))} # noqa: E501 footer = """
Name Last Modified Size Type
""" % {'version': __version__} content = [] dir_items = list() file_items = list() for item in [".."] + sorted(os.listdir(path), key=lambda x: x.lower()): # create path to item itemPath = os.path.join(path, item) # Hide "../" in listing of the (virtual) root directory if item == '..' and path == DirListingHandler.targetDir.rstrip('/') + '/': continue # try to stat file for size, last modified... continue on error stat = None try: stat = os.stat(itemPath) except IOError: continue if os.path.isdir(itemPath): target_items = dir_items else: target_items = file_items target_items.append((item, itemPath, stat)) # Directories first, then files for (tuple_list, is_dir) in ((dir_items, True), (file_items, False)): for (item, itemPath, stat) in tuple_list: self._appendToListing(content, item, itemPath, stat, is_dir=is_dir) listing = header + "\n".join(content) + footer # write listing self.send_response(200) self.send_header("Content-Type", "text/html") if head: self.end_headers() return self.send_header("Content-Length", str(len(listing))) self.send_header('Connection', 'close') self.end_headers() if sys.version_info.major >= 3: listing = listing.encode() self.wfile.write(listing) def convertSize(self, size): for ext in "KMGT": size /= 1024.0 if size < 1024.0: break if ext == "K" and size < 0.1: size = 0.1 return (size, ext.strip()) def getCleanPath(self): urlPath = os.path.normpath(unquote(self.path)).strip("/") path = os.path.join(self.targetDir, urlPath) return path class FilePutter(BaseHTTPServer.BaseHTTPRequestHandler): """ Simple HTTP Server which allows uploading to a specified directory either via multipart/form-data or POST/PUT requests containing the file. """ targetDir = None maxUploadSize = 0 blockSize = 1024 * 1024 uploadPage = """

""" def do_GET(self): """ Answer every GET request with the upload form """ self.sendResponse(200, self.uploadPage) def do_POST(self): """ Upload a file via POST If the content-type is multipart/form-data it checks for the file field and saves the data to disk. For other content-types it just calls do_PUT and is handled as such except for the http response code. Files can be uploaded with wget --post-file=path/to/file or curl -X POST -d @file . """ length = self.getContentLength() if length < 0: return print(self.headers) ctype = self.headers.get('Content-Type') # check for multipart/form-data. if not (ctype and ctype.lower().startswith("multipart/form-data")): # not a normal multipart request ==> handle as PUT request return self.do_PUT(fromPost=True) # create FieldStorage object for multipart parsing env = os.environ env['REQUEST_METHOD'] = "POST" targetDir = self.targetDir 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.") return destFileName = self.getTargetName(fstorage["file"].filename) if destFileName == "": self.sendResponse(400, "Filename was empty or invalid") return # put the file at the right place, send 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") bytesLeft = length while bytesLeft > 0: bytesToRead = min(self.blockSize, bytesLeft) target.write(fstorage["file"].file.read(bytesToRead)) bytesLeft -= bytesToRead target.close() self.sendResponse(200, "OK! Thanks for uploading") print("Received file '%s' from %s." % (destFileName, self.client_address[0])) def do_PUT(self, fromPost=False): """ Upload a file via PUT The request path is used as filename, so uploading a file to the url http://host:8080/testfile will cause the file to be named testfile. If no filename is given, a random name will be generated. Files can be uploaded with e.g. curl -T file . """ length = self.getContentLength() if length < 0: return fileName = unquote(self.path) if fileName == "/": # if no filename was given we have to generate one fileName = str(time.time()) cleanFileName = self.getTargetName(fileName) if cleanFileName == "": self.sendResponse(400, "Filename was invalid") return # Sometimes clients want to be told to continue with their transfer if self.headers.get("Expect") == "100-continue": self.send_response(100) self.end_headers() target = open(cleanFileName, "wb") bytesLeft = int(self.headers['Content-Length']) while bytesLeft > 0: bytesToRead = min(self.blockSize, bytesLeft) target.write(self.rfile.read(bytesToRead)) bytesLeft -= bytesToRead target.close() self.sendResponse(200 if fromPost else 201, "OK!") def getContentLength(self): length = 0 try: length = int(self.headers['Content-Length']) except (ValueError, KeyError): pass if length <= 0: self.sendResponse(411, "Content-Length was invalid or not set.") return -1 if self.maxUploadSize > 0 and length > self.maxUploadSize: self.sendResponse(413, "Your file was too big! Maximum allowed size is %d byte. back" % self.maxUploadSize) return -1 return length def sendResponse(self, code, msg): """ Send a HTTP response with HTTP statuscode code and message msg, providing the correct content-length. """ self.send_response(code) self.send_header('Content-Type', 'text/html') self.send_header('Content-Length', str(len(msg))) self.send_header('Connection', 'close') self.end_headers() self.wfile.write(msg.encode()) def getTargetName(self, fname): """ Generate a clean and secure filename. This function takes a filename and strips all the slashes out of it. If the file already exists in the target directory, a (NUM) will be appended, so no file will be overwritten. """ cleanFileName = fname.replace("/", "") if cleanFileName == "": return "" destFileName = os.path.join(self.targetDir, cleanFileName) if not os.path.exists(destFileName): return destFileName else: i = 1 extraDestFileName = destFileName + "(%s)" % i while os.path.exists(extraDestFileName): i += 1 extraDestFileName = destFileName + "(%s)" % i return extraDestFileName # never reached class ThreadedHTTPServer(SocketServer.ThreadingMixIn, BaseHTTPServer.HTTPServer): def handle_error(self, request, client_address): _, exc_value, _ = sys.exc_info() print("%s ABORTED transmission (Reason: %s)" % (client_address[0], exc_value)) def catchSSLErrors(BaseSSLClass): """ Class decorator which catches SSL errors and prints them. """ class X(BaseSSLClass): def handle_one_request(self, *args, **kwargs): try: BaseSSLClass.handle_one_request(self, *args, **kwargs) except SSL.Error as e: if str(e) == "": print("%s SSL error (empty error message)" % (self.client_address[0],)) else: print("%s SSL error: %s" % (self.client_address[0], e)) return X class SecureThreadedHTTPServer(ThreadedHTTPServer): def __init__(self, pubKey, privKey, server_address, RequestHandlerClass, bind_and_activate=True): ThreadedHTTPServer.__init__(self, server_address, RequestHandlerClass, bind_and_activate) # choose TLS1.2 or TLS1, if available sslMethod = None if hasattr(SSL, "TLSv1_2_METHOD"): sslMethod = SSL.TLSv1_2_METHOD elif hasattr(SSL, "TLSv1_METHOD"): sslMethod = SSL.TLSv1_METHOD else: # only SSLv23 available print("Warning: Only SSLv2/SSLv3 is available, connection might be insecure.") sslMethod = SSL.SSLv23_METHOD ctx = SSL.Context(sslMethod) if type(pubKey) is crypto.X509 and type(privKey) is crypto.PKey: ctx.use_certificate(pubKey) ctx.use_privatekey(privKey) else: ctx.use_certificate_file(pubKey) ctx.use_privatekey_file(privKey) self.bsocket = socket.socket(self.address_family, self.socket_type) self.socket = SSL.Connection(ctx, self.bsocket) if bind_and_activate: self.server_bind() self.server_activate() def shutdown_request(self, request): try: request.shutdown() except SSL.Error: # ignore SSL errors on connection shutdown pass class SecureHandler(): def setup(self): self.connection = self.request if sys.version_info[0] > 2: # python3 SocketIO (replacement for socket._fileobject) raw_read_sock = socket.SocketIO(self.request, 'rb') raw_write_sock = socket.SocketIO(self.request, 'wb') rbufsize = self.rbufsize > 0 and self.rbufsize or io.DEFAULT_BUFFER_SIZE wbufsize = self.wbufsize > 0 and self.wbufsize or io.DEFAULT_BUFFER_SIZE self.rfile = io.BufferedReader(raw_read_sock, rbufsize) self.wfile = io.BufferedWriter(raw_write_sock, wbufsize) else: # python2 does not have SocketIO self.rfile = socket._fileobject(self.request, "rb", self.rbufsize) self.wfile = socket._fileobject(self.request, "wb", self.wbufsize) class ServeFileException(Exception): pass class ServeFile(): """ Main class to manage everything. """ _NUM_MODES = 4 (MODE_SINGLE, MODE_SINGLETAR, MODE_UPLOAD, MODE_LISTDIR) = range(_NUM_MODES) def __init__(self, target, port=8080, serveMode=0, useSSL=False): self.target = target self.port = port self.serveMode = serveMode self.dirCreated = False self.useSSL = useSSL self.cert = self.key = None self.auth = None self.maxUploadSize = 0 self.listenIPv4 = True self.listenIPv6 = True if self.serveMode not in range(self._NUM_MODES): self.serveMode = None raise ValueError("Unknown serve mode, needs to be MODE_SINGLE, " "MODE_SINGLETAR, MODE_UPLOAD or MODE_DIRLIST.") def setIPv4(self, ipv4): """ En- or disable ipv4 """ self.listenIPv4 = ipv4 def setIPv6(self, ipv6): """ En- or disable ipv6 """ self.listenIPv6 = ipv6 def getIPs(self): """ Get IPs from all interfaces via ip or ifconfig. """ # ip and ifconfig sometimes are located in /sbin/ os.environ['PATH'] += ':/sbin:/usr/sbin' proc = Popen(r"ip addr|" r"sed -n -e 's/.*inet6\{0,1\} \([0-9.a-fA-F:]\+\).*/\1/ p'|" r"grep -v '^fe80\|^127.0.0.1\|^::1'", shell=True, stdout=PIPE, stderr=PIPE) if proc.wait() != 0: # ip failed somehow, falling back to ifconfig oldLang = os.environ.get("LC_ALL", None) os.environ['LC_ALL'] = "C" proc = Popen(r"ifconfig|" r"sed -n 's/.*inet6\{0,1\}\( addr:\)\{0,1\} \{0,1\}\([0-9a-fA-F.:]*\).*/" r"\2/p'|" r"grep -v '^fe80\|^127.0.0.1\|^::1'", shell=True, stdout=PIPE, stderr=PIPE) if oldLang: os.environ['LC_ALL'] = oldLang else: del os.environ['LC_ALL'] if proc.wait() != 0: # we couldn't find any ip address proc = None if proc: ips = proc.stdout.read().decode().strip().split("\n") # filter out ips we are not listening on if not self.listenIPv6: ips = [ip for ip in ips if '.' in ip] if not self.listenIPv4: ips = [ip for ip in ips if ':' in ip] return ips return None def setSSLKeys(self, cert, key): """ Set SSL cert/key. Can be either path to file or pyopenssl X509/PKey object. """ self.cert = cert self.key = key def setMaxUploadSize(self, limit): """ Set the maximum upload size in byte """ self.maxUploadSize = limit def setCompression(self, compression): """ Set the compression of TarFileHandler """ if self.serveMode != self.MODE_SINGLETAR: raise ServeFileException("Compression mode can only be set in tar-mode.") if compression not in TarFileHandler.compressionMethods: raise ServeFileException("Compression mode not available.") TarFileHandler.compression = compression def genKeyPair(self): print("Generating SSL certificate...", end="") sys.stdout.flush() pkey = crypto.PKey() pkey.generate_key(crypto.TYPE_RSA, 2048) req = crypto.X509Req() subj = req.get_subject() subj.CN = "127.0.0.1" subj.O = "servefile laboratories" # noqa: E741 subj.OU = "servefile" # generate altnames altNames = [] for ip in self.getIPs() + ["127.0.0.1", "::1"]: altNames.append("IP:%s" % ip) altNames.append("DNS:localhost") ext = crypto.X509Extension(b"subjectAltName", False, (",".join(altNames)).encode()) req.add_extensions([ext]) req.set_pubkey(pkey) req.sign(pkey, "sha1") cert = crypto.X509() # Mozilla only accepts v3 certificates with v3 extensions, not v1 cert.set_version(0x2) # some browsers complain if they see a cert from the same authority # with the same serial ==> we just use the seconds as serial. cert.set_serial_number(int(time.time())) cert.gmtime_adj_notBefore(0) cert.gmtime_adj_notAfter(365 * 24 * 60 * 60) cert.set_issuer(req.get_subject()) cert.set_subject(req.get_subject()) cert.add_extensions([ext]) cert.set_pubkey(req.get_pubkey()) cert.sign(pkey, "sha1") self.cert = cert self.key = pkey print("done.") print("SHA1 fingerprint:", cert.digest("sha1").decode()) print("MD5 fingerprint:", cert.digest("md5").decode()) def _getCert(self): return self.cert def _getKey(self): return self.key def setAuth(self, user, password, realm=None): if not user or not password: raise ServeFileException("User and password both need to be at least one character.") self.auth = base64.b64encode(("%s:%s" % (user, password)).encode()).decode() self.authrealm = realm def _createServer(self, handler, withv6=False): ThreadedHTTPServer.address_family = socket.AF_INET SecureThreadedHTTPServer.address_family = socket.AF_INET listenIp = '' server = None if withv6: listenIp = '::' ThreadedHTTPServer.address_family = socket.AF_INET6 SecureThreadedHTTPServer.address_family = socket.AF_INET6 if self.useSSL: if not self._getKey(): self.genKeyPair() try: server = SecureThreadedHTTPServer(self._getCert(), self._getKey(), (listenIp, self.port), handler, bind_and_activate=False) except SSL.Error as e: raise ServeFileException("SSL error: Could not read SSL public/private key " "from file(s) (error was: \"%s\")" % (e[0][0][2],)) else: server = ThreadedHTTPServer((listenIp, self.port), handler, bind_and_activate=False) if withv6: server.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) server.server_bind() server.server_activate() return server def serve(self): self.handler = self._confAndFindHandler() self.server = [] try: currsocktype = "IPv4" if self.listenIPv4: self.server.append(self._createServer(self.handler)) currsocktype = "IPv6" if self.listenIPv6: self.server.append(self._createServer(self.handler, withv6=True)) except socket.error as e: raise ServeFileException("Could not open %s socket: %s" % (currsocktype, e)) if self.serveMode != self.MODE_UPLOAD: print("Serving \"%s\" at port %d." % (self.target, self.port)) else: print("Serving \"%s\" for uploads at port %d." % (self.target, self.port)) # print urls with local network adresses print("\nSome addresses %s will be available at:" % ("this file" if (self.serveMode != self.MODE_UPLOAD) else "the uploadform", )) ips = self.getIPs() if not ips or len(ips) == 0 or ips[0] == '': print("Could not find any addresses.") else: pwPart = "" if self.auth: pwPart = base64.b64decode(self.auth).decode() + "@" for ip in ips: if ":" in ip: ip = "[%s]" % ip print("\thttp%s://%s%s:%d/" % (self.useSSL and "s" or "", pwPart, ip, self.port)) print() try: while True: (servers, _, _) = select.select(self.server, [], []) for server in servers: server.handle_request() except KeyboardInterrupt: for server in self.server: server.socket.close() # cleanup potential upload directory if self.dirCreated and len(os.listdir(self.target)) == 0: # created upload dir was not used os.rmdir(self.target) def _confAndFindHandler(self): handler = None if self.serveMode == self.MODE_SINGLE: try: testit = open(self.target, 'r') testit.close() except IOError as e: raise ServeFileException("Error: Could not open file, %r" % (str(e),)) FileHandler.filePath = self.target FileHandler.fileName = os.path.basename(self.target) FileHandler.fileLength = os.stat(self.target).st_size handler = FileHandler elif self.serveMode == self.MODE_SINGLETAR: self.realTarget = os.path.realpath(self.target) if not os.path.exists(self.realTarget): raise ServeFileException("Error: Could not open file or directory.") TarFileHandler.target = self.realTarget TarFileHandler.fileName = os.path.basename(self.realTarget.rstrip("/")) + TarFileHandler.getCompressionExt() handler = TarFileHandler elif self.serveMode == self.MODE_UPLOAD: if os.path.isdir(self.target): print("Warning: Uploading to an already existing directory.") elif not os.path.exists(self.target): self.dirCreated = True try: os.mkdir(self.target) except (IOError, OSError) as e: raise ServeFileException("Error: Could not create directory '%s' for uploads, %r" % (self.target, str(e))) else: raise ServeFileException("Error: Upload directory already exists and is a file.") FilePutter.targetDir = os.path.abspath(self.target) FilePutter.maxUploadSize = self.maxUploadSize handler = FilePutter elif self.serveMode == self.MODE_LISTDIR: if not os.path.exists(self.target): raise ServeFileException("Error: Could not open file or directory.") if not os.path.isdir(self.target): raise ServeFileException("Error: '%s' is not a directory." % (self.target,)) handler = DirListingHandler handler.targetDir = os.path.abspath(self.target) if self.auth: # do authentication AuthenticationHandler.authString = self.auth if self.authrealm: AuthenticationHandler.realm = self.authrealm class AuthenticatedHandler(AuthenticationHandler, handler): pass handler = AuthenticatedHandler if self.useSSL: # secure handler @catchSSLErrors class AlreadySecuredHandler(SecureHandler, handler): pass handler = AlreadySecuredHandler return handler class AuthenticationHandler(): # base64 encoded user:password string for authentication authString = None realm = "Restricted area" def handle_one_request(self): """ Overloaded function to handle one request. Before calling the responsible do_METHOD function, check credentials """ self.raw_requestline = self.rfile.readline() if not self.raw_requestline: self.close_connection = 1 return if not self.parse_request(): # An error code has been sent, just exit return authorized = False if "Authorization" in self.headers: if self.headers["Authorization"] == ("Basic " + self.authString): authorized = True if authorized: mname = 'do_' + self.command if not hasattr(self, mname): self.send_error(501, "Unsupported method (%r)" % self.command) return method = getattr(self, mname) method() else: self.send_response(401) self.send_header("WWW-Authenticate", "Basic realm=\"%s\"" % self.realm) self.send_header("Connection", "close") errorMsg = ("401 - Unauthorized" "

401 - Unauthorized

") self.send_header("Content-Length", str(len(errorMsg))) self.end_headers() self.wfile.write(errorMsg.encode()) def main(): 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('target', metavar='file/directory', type=str) parser.add_argument('-p', '--port', type=int, default=8080, help='Port to listen on') parser.add_argument('-u', '--upload', action="store_true", default=False, help="Enable uploads to a given directory") parser.add_argument('-s', '--max-upload-size', type=str, 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, help="Show directory indexes and allow access to all subdirectories") parser.add_argument('--ssl', action="store_true", default=False, help="Enable SSL. If no key/cert is specified one will be generated") parser.add_argument('--key', type=str, help="Keyfile to use for SSL. If no cert is given with --cert the keyfile " "will also be searched for a cert") parser.add_argument('--cert', type=str, help="Certfile to use for SSL") parser.add_argument('-a', '--auth', type=str, metavar='user:password', help="Set user and password for HTTP basic authentication") parser.add_argument('--realm', type=str, default=None, help="Set a realm for HTTP basic authentication") parser.add_argument('-t', '--tar', action="store_true", default=False, help="Enable on the fly tar creation for given file or directory. " "Note: Download continuation will not be available") parser.add_argument('-c', '--compression', type=str, metavar='method', default="none", help="Set compression method, only in combination with --tar. " "Can be one of %s" % ", ".join(TarFileHandler.compressionMethods)) parser.add_argument('-4', '--ipv4-only', action="store_true", default=False, help="Listen on IPv4 only") parser.add_argument('-6', '--ipv6-only', action="store_true", default=False, help="Listen on IPv6 only") args = parser.parse_args() maxUploadSize = 0 # check for invalid option combinations/preparse stuff if args.max_upload_size and not args.upload: print("Error: Maximum upload size can only be specified when in upload mode.") sys.exit(1) if args.upload and args.list_dir: print("Error: Upload and dirlisting can't be enabled together.") sys.exit(1) if args.max_upload_size: sizeRe = re.match(r"^(\d+(?:[,.]\d+)?)(?:([bkmgtpe])(?:(? 0: server.setMaxUploadSize(maxUploadSize) if args.ssl and args.key: cert = args.cert or args.key server.setSSLKeys(cert, args.key) if args.auth: user, password = args.auth.split(":", 1) server.setAuth(user, password, args.realm) if compression and compression != "none": server.setCompression(compression) if args.ipv4_only or not socket.has_ipv6: server.setIPv6(False) if args.ipv6_only: server.setIPv4(False) server.serve() except ServeFileException as e: print(e) sys.exit(1) print("Good bye.") if __name__ == '__main__': main() servefile-0.5.4/setup.py000077500000000000000000000033221436360631600152310ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import setup with open("README.md") as f: long_description = f.read() setup( name='servefile', description='Serve files from shell via a small HTTP server', long_description=long_description, long_description_content_type='text/markdown', platforms='posix', version='0.5.4', license='GPLv3 or later', url='https://github.com/sebageek/servefile/', author='Sebastian Lohff', author_email='seba@someserver.de', install_requires=['pyopenssl'], tests_require=[ 'pathlib2; python_version<"3"', 'pytest', 'requests', ], packages=["servefile"], entry_points={ "console_scripts": [ "servefile = servefile.servefile:main", ], }, classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Console', 'Intended Audience :: End Users/Desktop', 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 'Natural Language :: English', 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Topic :: Communications', 'Topic :: Communications :: File Sharing', 'Topic :: Internet', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Internet :: WWW/HTTP :: HTTP Servers', 'Topic :: Utilities', ], ) servefile-0.5.4/tests/000077500000000000000000000000001436360631600146565ustar00rootroot00000000000000servefile-0.5.4/tests/test_servefile.py000066400000000000000000000352051436360631600202600ustar00rootroot00000000000000# -*- coding: utf-8 -*- import io import os import pytest import requests import socket import subprocess import sys import tarfile import time import urllib3 from requests.exceptions import ConnectionError # crudly written to learn more about pytest and to have a base for refactoring if sys.version_info.major >= 3: from pathlib import Path from urllib.parse import quote connrefused_exc = ConnectionRefusedError else: from pathlib2 import Path from urllib import quote connrefused_exc = socket.error def _get_port_from_env(var_name, default): port = int(os.environ.get(var_name, default)) if port == 0: # do a one-time port selection for a free port, use it for all tests s = socket.socket() s.bind(('', 0)) port = s.getsockname()[1] s.close() return port SERVEFILE_DEFAULT_PORT = _get_port_from_env('SERVEFILE_DEFAULT_PORT', 0) SERVEFILE_SECONDARY_PORT = _get_port_from_env('SERVEFILE_SECONDARY_PORT', 0) @pytest.fixture def run_servefile(): instances = [] def _run_servefile(args, **kwargs): if not isinstance(args, list): args = [args] if kwargs.pop('standalone', None): # directly call servefile.py servefile_path = [str(Path(__file__).parent.parent / 'servefile' / 'servefile.py')] else: # call servefile as python module servefile_path = ['-m', 'servefile'] # 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: args.extend(['-p', str(SERVEFILE_DEFAULT_PORT)]) print("running {} with args {}".format(", ".join(servefile_path), args)) p = subprocess.Popen([sys.executable] + servefile_path + args, **kwargs) instances.append(p) return p yield _run_servefile for instance in instances: try: instance.terminate() except OSError: pass instance.wait() @pytest.fixture def datadir(tmp_path): def _datadir(data, path=None): path = path or tmp_path for k, v in data.items(): if isinstance(v, dict): new_path = path / k new_path.mkdir() _datadir(v, new_path) else: if hasattr(v, 'decode'): v = v.decode('utf-8') # python2 compability (path / k).write_text(v) return path return _datadir def make_request(path='/', host='localhost', port=SERVEFILE_DEFAULT_PORT, method='get', protocol='http', encoding='utf-8', **kwargs): url = '{}://{}:{}{}'.format(protocol, host, port, path) print('Calling {} on {} with {}'.format(method, url, kwargs)) r = getattr(requests, method)(url, **kwargs) if r.encoding is None and encoding: r.encoding = encoding return r def check_download(expected_data=None, path='/', fname=None, **kwargs): if fname is None: fname = os.path.basename(path) r = make_request(path, **kwargs) assert r.status_code == 200 assert r.text == expected_data assert r.headers.get('Content-Type') == 'application/octet-stream' if fname: assert r.headers.get('Content-Disposition') == 'attachment; filename="{}"'.format(fname) assert r.headers.get('Content-Transfer-Encoding') == 'binary' 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): # 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.wait() version = s.stdout.readline().decode().strip() # python2 is deprecated, but we still want our tests to run for it # CryptographyDeprecationWarnings get in the way for this if 'CryptographyDeprecationWarning' in version: s.stdout.readline() # ignore "from x import y" line version = s.stdout.readline().decode().strip() # hardcode version as string until servefile is a module assert version == 'servefile 0.5.4' def test_version(run_servefile): _test_version(run_servefile, standalone=False) def test_version_standalone(run_servefile): # test if servefile also works by calling servefile.py directly _test_version(run_servefile, standalone=True) def test_correct_headers(run_servefile, datadir): data = "NOOT NOOT" p = datadir({'testfile': data}) / 'testfile' run_servefile(str(p)) r = _retry_while(ConnectionError, make_request)() assert r.status_code == 200 assert r.headers.get('Content-Type') == 'application/octet-stream' assert r.headers.get('Content-Disposition') == 'attachment; filename="testfile"' assert r.headers.get('Content-Transfer-Encoding') == 'binary' def test_redirect_and_download(run_servefile, datadir): data = "NOOT NOOT" p = datadir({'testfile': data}) / 'testfile' run_servefile(str(p)) # redirect r = _retry_while(ConnectionError, make_request)(allow_redirects=False) assert r.status_code == 302 assert r.headers.get('Location') == '/testfile' # normal download 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): data = "NOOT NOOT" p = datadir({'testfile': data}) / 'testfile' run_servefile([str(p), '-p', str(SERVEFILE_SECONDARY_PORT)]) _retry_while(ConnectionError, check_download)(data, fname='testfile', port=SERVEFILE_SECONDARY_PORT) def test_ipv4_only(run_servefile, datadir): data = "NOOT NOOT" p = datadir({'testfile': data}) / 'testfile' run_servefile([str(p), '-4']) _retry_while(ConnectionError, check_download)(data, fname='testfile', host='127.0.0.1') sock = socket.socket(socket.AF_INET6) with pytest.raises(connrefused_exc): sock.connect(("::1", SERVEFILE_DEFAULT_PORT)) def test_big_download(run_servefile, datadir): # test with about 10 mb of data data = "x" * (10 * 1024 ** 2) p = datadir({'testfile': data}) / 'testfile' run_servefile(str(p)) _retry_while(ConnectionError, check_download)(data, fname='testfile') def test_authentication(run_servefile, datadir): data = "NOOT NOOT" p = datadir({'testfile': data}) / 'testfile' run_servefile([str(p), '-a', 'user:password']) for auth in [('foo', 'bar'), ('user', 'wrong'), ('unknown', 'password')]: r = _retry_while(ConnectionError, make_request)(auth=auth) assert '401 - Unauthorized' in r.text assert r.status_code == 401 _retry_while(ConnectionError, check_download)(data, fname='testfile', auth=('user', 'password')) def test_serve_directory(run_servefile, datadir): d = { 'foo': {'kratzbaum': 'cat', 'I like Cats!': 'kitteh', '&&&&&&&': 'wheee'}, 'bar': {'thisisaverylongfilenamefortestingthatthisstillworksproperly': 'jup!'}, 'noot': 'still data in here', 'bigfile': 'x' * (10 * 1024 ** 2), 'möwe': 'KRAKRAKRAKA', } p = datadir(d) run_servefile([str(p), '-l']) # check if all files are in directory listing # (could be made more sophisticated with beautifulsoup) for path in '/', '/../': r = _retry_while(ConnectionError, make_request)(path) for k in d: assert quote(k) in r.text for fname, content in d['foo'].items(): _retry_while(ConnectionError, check_download)(content, '/foo/' + fname) r = make_request('/unknown') assert r.status_code == 404 # download check_download('jup!', '/bar/thisisaverylongfilenamefortestingthatthisstillworksproperly') def test_serve_relative_directory(run_servefile, datadir): d = { 'foo': {'kratzbaum': 'cat', 'I like Cats!': 'kitteh', '&&&&&&&': 'wheee'}, 'bar': {'thisisaverylongfilenamefortestingthatthisstillworksproperly': 'jup!'}, 'noot': 'still data in here', 'bigfile': 'x' * (10 * 1024 ** 2), } p = datadir(d) run_servefile(['../', '-l'], cwd=os.path.join(str(p), 'foo')) # check if all files are in directory listing # (could be made more sophisticated with beautifulsoup) for path in '/', '/../': r = _retry_while(ConnectionError, make_request)(path) for k in d: assert k in r.text for fname, content in d['foo'].items(): check_download(content, '/foo/' + fname) r = make_request('/unknown') assert r.status_code == 404 # download check_download('jup!', '/bar/thisisaverylongfilenamefortestingthatthisstillworksproperly') def test_upload(run_servefile, tmp_path): data = ('this is my live now\n' 'uploading strings to servers\n' 'so very joyful') uploaddir = tmp_path / 'upload' # check that uploaddir does not exist before servefile is started assert not uploaddir.is_dir() 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 assert uploaddir.is_dir() # upload file files = {'file': ('haiku.txt', data)} r = make_request(method='post', files=files) assert 'Thanks' in r.text assert r.status_code == 200 with open(str(uploaddir / 'haiku.txt')) as f: assert f.read() == data # upload file AGAIN!! (and check it is available unter a different name) files = {'file': ('haiku.txt', data)} r = make_request(method='post', files=files) assert r.status_code == 200 with open(str(uploaddir / 'haiku.txt(1)')) as f: 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): uploaddir = tmp_path / 'upload' run_servefile(['-s', '2kb', '-u', str(uploaddir)]) # upload file that is too big files = {'file': ('toobig', "x" * 2049)} r = _retry_while(ConnectionError, make_request)(method='post', files=files) assert 'Your file was too big' in r.text assert r.status_code == 413 assert not (uploaddir / 'toobig').exists() # upload file that should fit # the size has to be smaller than 2kb, as the sent size also includes mime-headers files = {'file': ('justright', "x" * 1900)} r = make_request(method='post', files=files) 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): d = { 'foo': { 'bar': 'hello testmode my old friend', 'baz': 'you came to test me once again', } } p = datadir(d) run_servefile(['-t', str(p / 'foo')]) # test redirect? # test contents of tar file r = _retry_while(ConnectionError, make_request)() assert r.status_code == 200 tar = tarfile.open(fileobj=io.BytesIO(r.content)) assert len(tar.getmembers()) == 3 assert tar.getmember('foo').isdir() for filename, content in d['foo'].items(): info = tar.getmember('foo/{}'.format(filename)) assert info.isfile assert tar.extractfile(info.path).read().decode() == content def test_tar_compression(run_servefile, datadir): d = {'foo': 'blubb'} p = datadir(d) run_servefile(['-c', 'gzip', '-t', str(p / 'foo')]) r = _retry_while(ConnectionError, make_request)() assert r.status_code == 200 tar = tarfile.open(fileobj=io.BytesIO(r.content), mode='r:gz') assert len(tar.getmembers()) == 1 def test_https(run_servefile, datadir): data = "NOOT NOOT" p = datadir({'testfile': data}) / 'testfile' run_servefile(['--ssl', str(p)]) # fingerprint = None # while not fingerprint: # line = s.stdout.readline() # print(line) # # if we find this line we went too far... # assert not line.startswith("Some addresses this file will be available at") # if line.startswith("SHA1 fingerprint"): # fingerprint = line.replace("SHA1 fingerprint: ", "").strip() # break # assert fingerprint urllib3.disable_warnings() _retry_while(ConnectionError, check_download)(data, protocol='https', verify=False) def test_https_big_download(run_servefile, datadir): # test with about 10 mb of data data = "x" * (10 * 1024 ** 2) p = datadir({'testfile': data}) / 'testfile' run_servefile(['--ssl', str(p)]) urllib3.disable_warnings() _retry_while(ConnectionError, check_download)(data, protocol='https', verify=False) def test_abort_download(run_servefile, datadir): data = "x" * (10 * 1024 ** 2) p = datadir({'testfile': data}) / 'testfile' env = os.environ.copy() env['PYTHONUNBUFFERED'] = '1' proc = run_servefile(str(p), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) # provoke a connection abort # hopefully the buffers will not fill up with all of the 10mb sock = socket.socket(socket.AF_INET) _retry_while(connrefused_exc, sock.connect)(("localhost", SERVEFILE_DEFAULT_PORT)) sock.send(b"GET /testfile HTTP/1.0\n\n") resp = sock.recv(100) assert resp != b'' sock.close() time.sleep(0.1) proc.kill() out = proc.stdout.read().decode() assert "127.0.0.1 ABORTED transmission" in out servefile-0.5.4/tox.ini000066400000000000000000000006311436360631600150270ustar00rootroot00000000000000[tox] envlist = py27,py37,py38,py39,py310,py311,pep8 [testenv] deps = pathlib2; python_version<"3" pytest requests flake8 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