From 958587713c55de799deed4e1fd4eee21ccf09f92 Mon Sep 17 00:00:00 2001 From: Anton Khirnov Date: Mon, 4 Jan 2021 22:41:52 +0100 Subject: Initial commit. --- fshare.py | 310 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100755 fshare.py (limited to 'fshare.py') 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 +# +# 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 . + +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() -- cgit v1.2.3