#!/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 logging.handlers 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() src_fname = self._process_path(self.path) ext = os.path.splitext(src_fname)[1] 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) dst_fname = h.hexdigest() + ext self._logger.info('Received file: %s', dst_fname) outpath = '/'.join((self.server.data_dir, dst_fname)) if os.path.exists(outpath): retcode = HTTPStatus.OK 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, dst_fname)).encode('ascii') self.send_response(retcode) self.send_header('Content-Type', 'text/plain') 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('-d', '--debug', action = 'store_true', help = 'log to stderr') parser.add_argument('state_file') parser.add_argument('data_dir') args = parser.parse_args(sys.argv[1:]) # configure logging progname = os.path.basename(sys.argv[0]) logging.basicConfig(stream = sys.stderr, level = args.loglevel) logger = logging.getLogger(progname) formatter = logging.Formatter(fmt = progname + ': %(message)s') syslog = logging.handlers.SysLogHandler('/dev/log') handlers = [syslog] if args.debug: handlers.append(logging.StreamHandler()) for h in handlers: h.setFormatter(formatter) logger.addHandler(h) # log uncaught top-level exception def excepthook(t, v, tb, logger = logger): logger.error('Uncaught top-level exception', exc_info = (t, v, tb)) sys.excepthook = excepthook # 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()