diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 7414595..9b4b321 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,20 @@ +# Version 0.8.1 + ## Client-side Changes: + * Clicking on text files should show in UTF-8 + ## Server-side Changes: + 1. Added cookies + 2. Introduced cache_control in send_file() + 3. Fixed multiple send_response() code issue + 4. Added send_header_string() + 5. Text files now have `charset=utf-8` in header content-type + ## Fixes: + * text was not showing in UTF-8 + ## TODO: + * make sidebar and user management + * drag and drop upload in entire page (show drop here popup like GITHUB) + + + # Version 0.8.0 ## Client-side Changes: * Zip page shows more info (Calculating notice) diff --git a/VERSION b/VERSION index a3df0a6..6f4eebd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 +0.8.1 diff --git a/dev_src/local_server_pyrobox.py b/dev_src/local_server_pyrobox.py index b419550..9249875 100644 --- a/dev_src/local_server_pyrobox.py +++ b/dev_src/local_server_pyrobox.py @@ -1,4 +1,5 @@ -enc = "utf-8" + + # TODO # ---------------------------------------------------------------- @@ -37,6 +38,7 @@ __version__ = __version__ true = T = True false = F = False +enc = "utf-8" ########################################### # ADD COMMAND LINE ARGUMENTS @@ -48,6 +50,7 @@ logger.info(tools.text_box("Server Config", *({i: getattr(cli_args, i)} for i in vars(cli_args)))) ########################################### +# config.dev_mode = False pt.pt_config.dev_mode = config.dev_mode config.MAIN_FILE = os.path.abspath(__file__) diff --git a/setup.cfg b/setup.cfg index bf30bd4..d15c5a4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pyrobox -version = 0.8.0 +version = 0.8.1 author = Rasan author_email= wwwqweasd147@gmail.com description = Personal DropBox for Private Network diff --git a/src/pyroboxCore.py b/src/pyroboxCore.py index cdd9b79..f914b67 100644 --- a/src/pyroboxCore.py +++ b/src/pyroboxCore.py @@ -5,6 +5,7 @@ import base64 import re from http import HTTPStatus +from http.cookies import SimpleCookie from functools import partial import contextlib import urllib.request @@ -29,8 +30,8 @@ import atexit import os -__version__ = "0.8.0" +__version__ = "0.8.1" enc = "utf-8" __all__ = [ "HTTPServer", "ThreadingHTTPServer", "BaseHTTPRequestHandler", @@ -87,7 +88,7 @@ def __init__(self): # RUNNING SERVER STATS self.ftp_dir = self.get_default_dir() - self.dev_mode = False + self.dev_mode = True self.ASSETS = False # if you want to use assets folder, set this to True self.ASSETS_dir = os.path.join(self.MAIN_FILE_dir, "/../assets/") self.reload = False @@ -133,7 +134,7 @@ def clear_temp(self): for i in self.temp_file: try: os.remove(i) - except: + except OSError: pass def get_os(self): @@ -195,7 +196,10 @@ def __init__(self): "udash": "_" } - def term_width(self): + @staticmethod + def term_width(): + """ Return CLI screen size (if not found, returns default value) + """ return shutil.get_terminal_size()[0] def text_box(self, *text, style="equal", sep=" "): @@ -211,7 +215,11 @@ def text_box(self, *text, style="equal", sep=" "): tt += i.center(term_col) + '\n' return (f"\n\n{s*term_col}\n{tt}{s*term_col}\n\n") - def random_string(self, length=10): + @staticmethod + def random_string(length=10): + """Generates a random string + length : length of string + """ letters = string.ascii_lowercase return ''.join(random.choice(letters) for i in range(length)) @@ -241,7 +249,7 @@ def reload_server(): )) try: os.execl(sys.executable, sys.executable, file, *sys.argv[1:]) - except: + except OSError: traceback.print_exc() sys.exit(0) @@ -347,14 +355,14 @@ def parse_byte_range(byte_range): m = BYTE_RANGE_RE.match(byte_range) if not m: - raise ValueError('Invalid byte range %s' % byte_range) + raise ValueError(f'Invalid byte range {byte_range}') # first, last = [x and int(x) for x in m.groups()] # first, last = map((lambda x: int(x) if x else None), m.groups()) if last and last < first: - raise ValueError('Invalid byte range %s' % byte_range) + raise ValueError(f'Invalid byte range { byte_range}') return first, last # ---------------------------x-------------------------------- @@ -459,6 +467,10 @@ def parse_request(self): self.command = '' # set in case of error on the first line self.request_version = version = self.default_request_version self.close_connection = True + self.header_flushed = False # true when headers are flushed by self.flush_headers() + self.response_code_sent = False # true when response code (>=200) is sent by self.send_response() + + requestline = str(self.raw_requestline, 'iso-8859-1') requestline = requestline.rstrip('\r\n') self.requestline = requestline @@ -543,6 +555,14 @@ def parse_request(self): elif (conntype.lower() == 'keep-alive' and self.protocol_version >= "HTTP/1.1"): self.close_connection = False + + # Load cookies from request + # Uses standard SimpleCookie + # doc: https://docs.python.org/3/library/http.cookies.html + self.cookie = SimpleCookie() + self.cookie.load(self.headers.get('Cookie', "")) + # print(tools.text_box("Cookie: ", self.cookie)) + # Examine the headers and look for an Expect directive expect = self.headers.get('Expect', "") if (expect.lower() == "100-continue" and @@ -726,6 +746,12 @@ def send_response(self, code, message=None): version and the current date. """ + if self.response_code_sent: + return + + if not code//100 ==1: # 1xx - Informational (allowes multiple responses) + self.response_code_sent = True + self.log_request(code) self.send_response_only(code, message) self.send_header('Server', self.version_string()) @@ -744,6 +770,15 @@ def send_response_only(self, code, message=None): self._headers_buffer.append(("%s %d %s\r\n" % (self.protocol_version, code, message)).encode( 'utf-8', 'strict')) + + def send_header_string(self, lines:str): + """Send a header multiline string to the headers buffer.""" + for i in lines.split("\r\n"): + if not i: + continue + tag, _, msg = i.partition(":") + self.send_header(tag.strip(), msg.strip()) + def send_header(self, keyword, value): """Send a MIME header to the headers buffer.""" @@ -766,10 +801,19 @@ def end_headers(self): self.flush_headers() def flush_headers(self): + """Flush the headers buffer.""" + if self.header_flushed: + try: + raise RuntimeError("Headers already flushed") + except RuntimeError: + traceback.print_exc() + return if hasattr(self, '_headers_buffer'): self.wfile.write(b"".join(self._headers_buffer)) self._headers_buffer = [] + self.header_flushed = True + def log_request(self, code='-', size='-'): """Log an accepted request. @@ -911,9 +955,9 @@ class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): extensions_map = mimetypes.types_map.copy() extensions_map.update({ '': 'application/octet-stream', # Default - '.py': 'text/plain', - '.c': 'text/plain', - '.h': 'text/plain', + '.py': 'text/x-python', + '.c': 'text/x-c', + '.h': 'text/x-c', '.css': 'text/css', '.gz': 'application/gzip', @@ -948,20 +992,20 @@ def __init__(self, *args, directory=None, **kwargs): def do_GET(self): """Serve a GET request.""" try: - f = self.send_head() + resp = self.send_head() except Exception as e: traceback.print_exc() self.send_error(500, str(e)) return - if f: + if resp: try: - self.copyfile(f, self.wfile) + self.copyfile(resp, self.wfile) except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError) as e: self.log_info(tools.text_box(e.__class__.__name__, e, "\nby ", self.address_string())) finally: - f.close() + resp.close() def do_(self): '''incase of errored request''' @@ -1017,13 +1061,13 @@ def test_req(self, url='', hasQ=(), QV={}, fragent='', url_regex=''): ''' if url_regex and not re.search("^"+url_regex+'$', self.url_path): return False - elif url and url != self.url_path: + if url and url != self.url_path: return False if isinstance(hasQ, str): hasQ = (hasQ,) - if hasQ and self.query(*hasQ) == False: + if hasQ and self.query(*hasQ) is False: return False if QV: for k, v in QV.items(): @@ -1039,15 +1083,16 @@ def test_req(self, url='', hasQ=(), QV={}, fragent='', url_regex=''): def do_HEAD(self): """Serve a HEAD request.""" + resp = None try: - f = self.send_head() + resp = self.send_head() except Exception as e: traceback.print_exc() self.send_error(500, str(e)) return - - if f: - f.close() + finally: + if resp: + resp.close() def do_POST(self): """Serve a POST request.""" @@ -1063,21 +1108,21 @@ def do_POST(self): for case, func in self.handlers['POST']: if self.test_req(*case): try: - f = func(self, url_path=url_path, query=query, + resp = func(self, url_path=url_path, query=query, fragment=fragment, path=path, spathsplit=spathsplit) except PostError: traceback.print_exc() # break if error is raised and send BAD_REQUEST (at end of loop) break - if f: + if resp: try: - self.copyfile(f, self.wfile) + self.copyfile(resp, self.wfile) except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError) as e: logger.info(tools.text_box( e.__class__.__name__, e, "\nby ", [self.address_string()])) finally: - f.close() + resp.close() return return self.send_error(HTTPStatus.BAD_REQUEST, "Invalid request.") @@ -1093,7 +1138,8 @@ def do_POST(self): def redirect(self, location): '''redirect to location''' - self.send_response(HTTPStatus.FOUND) + print("REDIRECT ", location) + self.send_response(302) self.send_header("Location", location) self.end_headers() @@ -1106,23 +1152,23 @@ def return_txt(self, code, msg, content_type="text/html; charset=utf-8"): else: encoded = msg - f = io.BytesIO() - f.write(encoded) - f.seek(0) + box = io.BytesIO() + box.write(encoded) + box.seek(0) self.send_response(code) - self.send_header("Content-type", content_type) + self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(encoded))) self.end_headers() - return f + return box def send_txt(self, code, msg, content_type="text/html; charset=utf-8"): '''sends the head and file to client''' - f = self.return_txt(code, msg, content_type) + file = self.return_txt(code, msg, content_type) if self.command == "HEAD": return # to avoid sending file on get request - self.copyfile(f, self.wfile) - f.close() + self.copyfile(file, self.wfile) + file.close() def send_text(self, code, msg, content_type="text/html; charset=utf-8"): '''proxy to send_txt''' @@ -1133,23 +1179,27 @@ def send_json(self, obj): obj: json-able object or json.dumps() string""" if not isinstance(obj, str): obj = json.dumps(obj, indent=1) - f = self.return_txt(200, obj, content_type="application/json") + file = self.return_txt(200, obj, content_type="application/json") if self.command == "HEAD": return # to avoid sending file on get request - self.copyfile(f, self.wfile) - f.close() + self.copyfile(file, self.wfile) + file.close() - def return_file(self, path, filename=None, download=False): - f = None + def return_file(self, path, filename=None, download=False, cache_control=""): + file = None is_attachment = "attachment;" if (self.query("dl") or download) else "" first, last = 0, None try: ctype = self.guess_type(path) + + # make sure texts are sent as utf-8 + if ctype.startswith("text/"): + ctype += "; charset=utf-8" - f = open(path, 'rb') - fs = os.fstat(f.fileno()) + file = open(path, 'rb') + fs = os.fstat(file.fileno()) file_len = fs[6] # Use browser cache if possible @@ -1177,7 +1227,7 @@ def return_file(self, path, filename=None, download=False): if last_modif <= ims: self.send_response(HTTPStatus.NOT_MODIFIED) self.end_headers() - f.close() + file.close() return None @@ -1194,27 +1244,30 @@ def return_file(self, path, filename=None, download=False): return None self.send_response(206) - self.send_header('Content-Type', ctype) self.send_header('Accept-Ranges', 'bytes') response_length = last - first + 1 self.send_header('Content-Range', - 'bytes %s-%s/%s' % (first, last, file_len)) + f'bytes {first}-{last}/{file_len}') self.send_header('Content-Length', str(response_length)) else: self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(file_len)) + + if cache_control: + self.send_header("Cache-Control", cache_control) self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) + self.send_header("Content-Type", ctype) self.send_header("Content-Disposition", is_attachment+' filename="%s"' % (os.path.basename(path) if filename is None else filename)) self.end_headers() - return f + return file except PermissionError: self.send_error(HTTPStatus.FORBIDDEN, "Permission denied") @@ -1230,16 +1283,17 @@ def return_file(self, path, filename=None, download=False): # if f and not f.closed(): f.close() raise - def send_file(self, path, filename=None, download=False): + def send_file(self, path, filename=None, download=False, cache_control=''): '''sends the head and file to client''' - f = self.return_file(path, filename, download) + file = self.return_file(path, filename, download, cache_control) if self.command == "HEAD": return # to avoid sending file on get request try: - self.copyfile(f, self.wfile) + self.copyfile(file, self.wfile) finally: - f.close() - + file.close() + + def send_head(self): """Common code for GET and HEAD commands. @@ -1344,13 +1398,15 @@ def copyfile(self, source, outputfile): to copy binary data as well. """ + try: + # check if file readable + source.read(1) + source.seek(0) + except OSError as e: + traceback.print_exc() + raise e if not self.range: - try: - source.read(1) - except: - traceback.print_exc() - source.seek(0) shutil.copyfileobj(source, outputfile) else: diff --git a/src/server.py b/src/server.py index d774b5e..86a67e2 100644 --- a/src/server.py +++ b/src/server.py @@ -8,6 +8,7 @@ # * ADD MORE FILE TYPES # * ADD SEARCH + import html from string import Template import os @@ -15,26 +16,18 @@ import posixpath import shutil -import time import datetime import importlib.util -import re import urllib.parse import urllib.request -import threading - import subprocess -import tempfile -import random -import string import json from http import HTTPStatus import traceback -import atexit from .pyroboxCore import config, logger, SimpleHTTPRequestHandler as SH_base, DealPostData as DPD, run as run_server, tools, reload_server, __version__ @@ -59,6 +52,7 @@ logger.info(tools.text_box("Server Config", *({i: getattr(cli_args, i)} for i in vars(cli_args)))) ########################################### +config.dev_mode = False pt.pt_config.dev_mode = config.dev_mode config.MAIN_FILE = os.path.abspath(__file__)