aboutsummaryrefslogtreecommitdiff
path: root/fshare.py
diff options
context:
space:
mode:
authorAnton Khirnov <anton@khirnov.net>2021-01-04 22:41:52 +0100
committerAnton Khirnov <anton@khirnov.net>2021-01-04 22:41:52 +0100
commit958587713c55de799deed4e1fd4eee21ccf09f92 (patch)
treebda02717fc4c062fd3f51778cde659acf6179d6b /fshare.py
Initial commit.
Diffstat (limited to 'fshare.py')
-rwxr-xr-xfshare.py310
1 files changed, 310 insertions, 0 deletions
diff --git a/fshare.py b/fshare.py
new file mode 100755
index 0000000..ccd3ae6
--- /dev/null
+++ b/fshare.py
@@ -0,0 +1,310 @@
+#!/usr/bin/python3
+
+# a HTTP server for sharing files
+
+# Copyright 2019-2020 Anton Khirnov <anton@khirnov.net>
+#
+# fshare is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# fshare is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# fshare. If not, see <http://www.gnu.org/licenses/>.
+
+import argparse
+import hmac
+import json
+import os
+import os.path
+from http import HTTPStatus
+import http.server as hs
+import logging
+import secrets
+import shutil
+import socket
+import sys
+import tempfile
+from urllib import parse as urlparse
+
+# TODO: detect and store mime types
+
+class PersistentState:
+ key = None
+
+ def __init__(self, path):
+ if not os.path.exists(path):
+ data = { 'key' : secrets.token_hex(16) }
+ with open(path, 'x') as f:
+ json.dump(data, f, indent = 4)
+
+ with open(path, 'r') as f:
+ data = json.load(f)
+
+ self.key = bytes.fromhex(data['key'])
+
+class HTTPChunkedRequestReader:
+
+ _stream = None
+ _eof = False
+
+ _logger = None
+
+ def __init__(self, stream, logger):
+ self._stream = stream
+ self._logger = logger
+
+ def read(self):
+ if self._eof:
+ return bytes()
+
+ l = self._stream.readline().decode('ascii', errors = 'replace')
+ self._logger.debug('reading chunk: chunksize %s', l)
+
+ try:
+ chunk_size = int(l.split(';')[0], 16)
+ except ValueError:
+ raise IOError('Invalid chunksize line: %s' % l)
+ if chunk_size < 0:
+ raise IOError('Invalid negative chunksize: %d' % chunk_size)
+ if chunk_size == 0:
+ self._eof = True
+ return bytes()
+
+ data = bytes()
+ remainder = chunk_size
+ while remainder > 0:
+ read = self._stream.read(remainder)
+ if len(read) == 0:
+ raise IOError('Premature EOF')
+
+ data += read
+ remainder -= len(read)
+
+ term_line = self._stream.readline().decode('ascii', errors = 'replace')
+ if term_line != '\r\n':
+ raise IOError('Invalid chunk terminator: %s' % term_line)
+
+ return data
+
+class HTTPRequestReader:
+
+ _stream = None
+ _remainder = 0
+ _eof = False
+
+ def __init__(self, stream, request_size):
+ self._stream = stream
+ self._remainder = request_size
+ self._eof = request_size == 0
+
+ def read(self):
+ if self._eof:
+ return bytes()
+
+ read = self._stream.read1(self._remainder)
+ if len(read) == 0:
+ raise IOError('Premature EOF')
+
+ self._remainder -= len(read)
+ self._eof = self._remainder <= 0
+
+ return read
+
+class FShareRequestHandler(hs.BaseHTTPRequestHandler):
+ # required for chunked transfer
+ protocol_version = "HTTP/1.1"
+
+ _logger = None
+
+ def __init__(self, *args, **kwargs):
+ server = args[2]
+ self._logger = server._logger.getChild('requesthandler')
+
+ super().__init__(*args, **kwargs)
+
+ def _process_path(self, encoded_path):
+ # decode percent-encoding
+ path = urlparse.unquote(encoded_path, encoding = 'ascii')
+
+ # normalize the path
+ path = os.path.normpath(path)
+
+ # make sure the path doesn't point outside of our root
+ if path.startswith('..'):
+ raise PermissionError('Invalid path')
+
+ return path
+
+ def _log_request(self):
+ self._logger.info('%s: %s', str(self.client_address), self.requestline)
+ self._logger.debug('headers:\n%s', self.headers)
+
+ def do_GET(self):
+ self._log_request()
+
+ fname = self._process_path(self.path)
+ path = '/'.join((self.server.data_dir, fname))
+ self._logger.info('serve file: %s', fname)
+
+ try:
+ infile = open(path, 'rb')
+ except OSError:
+ return self.send_error(HTTPStatus.NOT_FOUND)
+
+ try:
+ stat = os.fstat(infile.fileno())
+
+ self.send_response(HTTPStatus.OK)
+ self.send_header('Content-Length', str(stat.st_size))
+ self.end_headers()
+
+ shutil.copyfileobj(infile, self.wfile)
+ finally:
+ infile.close()
+
+ def do_POST(self):
+ self._log_request()
+
+ if 'Transfer-Encoding' in self.headers:
+ if self.headers['Transfer-Encoding'] != 'chunked':
+ return self.send_error(HTTPStatus.NOT_IMPLEMENTED,
+ 'Unsupported Transfer-Encoding: %s' %
+ self.headers['Transfer-Encoding'])
+ infile = HTTPChunkedRequestReader(self.rfile, self._logger.getChild('chreader'))
+ elif 'Content-Length' in self.headers:
+ infile = HTTPRequestReader(self.rfile, int(self.headers['Content-Length']))
+ else:
+ return self.send_error(HTTPStatus.BAD_REQUEST)
+
+ h = hmac.new(self.server.state.key, digestmod = 'SHA256')
+
+ temp_fd, temp_path = tempfile.mkstemp(suffix = '.tmp', dir = self.server.data_dir)
+ try:
+ while True:
+ data = infile.read()
+ if len(data) == 0:
+ self._logger.debug('Finished reading')
+ break
+
+ written = os.write(temp_fd, data)
+ if written < len(data):
+ raise IOError('partial write: %d < %d' % (written, len(data)))
+
+ h.update(data)
+
+ self._logger.debug('streamed %d bytes', len(data))
+
+ os.close(temp_fd)
+
+ fname = h.hexdigest()
+ self._logger.info('Received file: %s', fname)
+
+ outpath = '/'.join((self.server.data_dir, fname))
+ if os.path.exists(outpath):
+ retcode = HTTPStatus.NO_CONTENT
+ os.remove(temp_path)
+ else:
+ retcode = HTTPStatus.CREATED
+ os.replace(temp_path, outpath)
+ finally:
+ if os.path.exists(temp_path):
+ os.remove(temp_path)
+
+ try:
+ host = self.headers['host']
+ except KeyError:
+ host = 'host.missing'
+
+ reply = ('https://%s/%s' % (host, fname)).encode('ascii')
+
+ self.send_response(retcode)
+ self.send_header('Content-Length', '%d' % len(reply))
+ self.end_headers()
+
+ self.wfile.write(reply)
+
+ def do_PUT(self):
+ return self.do_POST()
+
+ def do_DELETE(self):
+ self._log_request()
+
+ fname = self._process_path(self.path)
+
+ path = '/'.join((self.server.data_dir, fname))
+ try:
+ os.remove(targetpath)
+ except FileNotFoundError:
+ self._logger.error('DELETE request for non-existing file: %s' %
+ local_path.decode('utf-8', errors = 'backslashreplace'))
+ self.send_error(HTTPStatus.NOT_FOUND)
+ return
+
+ self.send_response(HTTPStatus.NO_CONTENT)
+ self.send_header('Content-Length', '0')
+ self.end_headers()
+
+class FShareServer(hs.ThreadingHTTPServer):
+
+ data_dir = None
+ state = None
+
+ _logger = None
+
+ def __init__(self, address, force_v4, force_v6, state, data_dir, logger):
+ self.data_dir = data_dir
+ self.state = state
+ self._logger = logger
+
+ family = None
+ if force_v4:
+ family = socket.AF_INET
+ elif force_v6:
+ family = socket.AF_INET6
+
+ if family is None and len(address[0]):
+ try:
+ family, _, _, _, _ = socket.getaddrinfo(*address)[0]
+ except IndexError:
+ pass
+
+ if family is None:
+ family = socket.AF_INET6
+
+ self.address_family = family
+
+ super().__init__(address, FShareRequestHandler)
+
+# parse commandline arguments
+parser = argparse.ArgumentParser('fshare server')
+
+parser.add_argument('-a', '--address', default = 'localhost')
+parser.add_argument('-p', '--port', type = int, default = 5400)
+
+group = parser.add_mutually_exclusive_group()
+group.add_argument('-4', '--ipv4', action = 'store_true')
+group.add_argument('-6', '--ipv6', action = 'store_true')
+
+parser.add_argument('-l', '--loglevel', default = 'WARNING')
+
+parser.add_argument('state_file')
+parser.add_argument('data_dir')
+
+args = parser.parse_args(sys.argv[1:])
+
+# configure logging
+logging.basicConfig(stream = sys.stderr, level = args.loglevel)
+logger = logging.getLogger(os.path.basename(sys.argv[0]))
+
+# read the state file
+state = PersistentState(args.state_file)
+
+# launch the server
+server = FShareServer((args.address, args.port), args.ipv4, args.ipv6,
+ state, args.data_dir, logger)
+server.serve_forever()