From 4ec61f4c43812444ba0ea645fc27f121f38c6315 Mon Sep 17 00:00:00 2001 From: Jonah Aden <151240959+adenjonah@users.noreply.github.com> Date: Tue, 14 May 2024 18:20:05 -0700 Subject: [PATCH] added rover map and feed --- __pycache__/server.cpython-312.pyc | Bin 8399 -> 8399 bytes .../site-packages/multipart/__init__.py | 16 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 561 bytes .../__pycache__/decoders.cpython-312.pyc | Bin 0 -> 7573 bytes .../__pycache__/exceptions.cpython-312.pyc | Bin 0 -> 1761 bytes .../__pycache__/multipart.cpython-312.pyc | Bin 0 -> 65417 bytes .../site-packages/multipart/decoders.py | 171 ++ .../site-packages/multipart/exceptions.py | 34 + .../site-packages/multipart/multipart.py | 1911 +++++++++++++++++ .../INSTALLER | 1 + .../python_multipart-0.0.9.dist-info/METADATA | 70 + .../python_multipart-0.0.9.dist-info/RECORD | 14 + .../REQUESTED | 0 .../python_multipart-0.0.9.dist-info/WHEEL | 4 + .../licenses/LICENSE.txt | 14 + src/pages-style/ingressegress.css | 1 - src/pages-style/rover.css | 98 +- src/pages/constant/rover.js | 26 - src/pages/focus/rover.js | 21 +- tss_data.json | 2 +- 20 files changed, 2313 insertions(+), 70 deletions(-) create mode 100644 myenv/lib/python3.12/site-packages/multipart/__init__.py create mode 100644 myenv/lib/python3.12/site-packages/multipart/__pycache__/__init__.cpython-312.pyc create mode 100644 myenv/lib/python3.12/site-packages/multipart/__pycache__/decoders.cpython-312.pyc create mode 100644 myenv/lib/python3.12/site-packages/multipart/__pycache__/exceptions.cpython-312.pyc create mode 100644 myenv/lib/python3.12/site-packages/multipart/__pycache__/multipart.cpython-312.pyc create mode 100644 myenv/lib/python3.12/site-packages/multipart/decoders.py create mode 100644 myenv/lib/python3.12/site-packages/multipart/exceptions.py create mode 100644 myenv/lib/python3.12/site-packages/multipart/multipart.py create mode 100644 myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/INSTALLER create mode 100644 myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/METADATA create mode 100644 myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/RECORD create mode 100644 myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/REQUESTED create mode 100644 myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/WHEEL create mode 100644 myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/licenses/LICENSE.txt delete mode 100644 src/pages/constant/rover.js diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 52cb5a41f7ba59d74ffa754159732f6d602c3ca0..e618b9885c70cc84c37dfb677ff90cd15e3543ce 100644 GIT binary patch delta 20 acmX@_c;1owG%qg~0}$l$x@_b=q5uFthy{57 delta 20 acmX@_c;1owG%qg~0}$vvaNfv$L;(Ooyao&a diff --git a/myenv/lib/python3.12/site-packages/multipart/__init__.py b/myenv/lib/python3.12/site-packages/multipart/__init__.py new file mode 100644 index 0000000..dc13f13 --- /dev/null +++ b/myenv/lib/python3.12/site-packages/multipart/__init__.py @@ -0,0 +1,16 @@ +# This is the canonical package information. +__author__ = "Andrew Dunham" +__license__ = "Apache" +__copyright__ = "Copyright (c) 2012-2013, Andrew Dunham" +__version__ = "0.0.9" + +from .multipart import FormParser, MultipartParser, OctetStreamParser, QuerystringParser, create_form_parser, parse_form + +__all__ = ( + "FormParser", + "MultipartParser", + "OctetStreamParser", + "QuerystringParser", + "create_form_parser", + "parse_form", +) diff --git a/myenv/lib/python3.12/site-packages/multipart/__pycache__/__init__.cpython-312.pyc b/myenv/lib/python3.12/site-packages/multipart/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a61f54bce44b67160275f4609814a3d7360e6867 GIT binary patch literal 561 zcmY*VO>fgc5M3v+o!Gb#5S%#VR1pbh6Nf{Q5K@(j4~g=j(92#{uDy*HylZQ{E_F`f zSMVG77u@*)L_Q&LK-?--TzX<$5+vsEcHYdJ*{AvHc@Bb@{GOeBF%bIEkZrU!$;BQd zpHPeliVb4KEz*kHq#bu+GwleIn8G5Ku!$|Yq$_%)CmiA!C^m^(Swnm2?aNrP4}*hJ zCZVXU{XC2kw%na%d8POw(Sv)@a1e|qfe$dfKlo3-G{>Xy=;1GLhlW<|Jjs-J9jbz< z`ubs+YMzHmZwmcak!E_X6$`hT{+p7is?dtd#YS~Ag1%<-1U6BaZdN&0?^WsL@LKIq z8kRcA6s5IGY04ue3kJlaG}_w!+dE~d;F$!{6I(l~17?-kfYpHAK(~P&KyAbJG^O-2 zx&VqU7x)M~6!={x!vr5>Q7M?z1%Ar)v+@|vkDkAs`@z%?9{4W~XEQ7+CQot7k8xgs zNjV)&0$gy-e7NlUaKQ@v&kClLORgy$<<({Pk;vjQWslV@__w)eu?ukCHVostcX#Ei gckZm*^^Luix8AwEa(|+;Z|Lm2W4o=7w(5iQ5A?ySPXGV_ literal 0 HcmV?d00001 diff --git a/myenv/lib/python3.12/site-packages/multipart/__pycache__/decoders.cpython-312.pyc b/myenv/lib/python3.12/site-packages/multipart/__pycache__/decoders.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c22df1cb80c7a9e3cf2350f1b628dfb9e73a3888 GIT binary patch literal 7573 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 I_FxDSL{;?rP4iu3Rg;yh zX*!{8nup{B13B}Ib+}0wTVY_i#!P5OJ`1EU<~{i`+AvnvU#+bc$0v&8lf^d+)6<4u zXTf`;>TMXcx^%-}qBJ&c2v4$N&9b+xO(qOKs!9*Sk_OwgSxtIjAWF45#~dhw5{L|# zJ<>=U95br$%XJ!o<%>EwdO2NB71E8o(kYh7T5~-?+c(ljAd*Ezw4pRBP*~)aCzwNb zTozF2GR_dRf<)UL*Q>HTg@KZK!6p@<&!h`pNx?t=z*sC~IEK!#+ISXC4ossbtW*S( zycZG0s1Ln?mccV(5X>I=rR$m8aX%Rx{xlbF#a)+^zoJNPEwnW5P{30PASEhl%=Rjt zolujmB`*!2b}%isWcU(Yc96WgMYvG3=XRcIdFUfIoH>@RbD% zX@XN&pm9?us)oRJEx(o&@Qx>4tZ<60qtlqdbeVf%sw} z*T}!Ms!@D#+=A3Usftx45{WdjORFk0^|h|TKFdwT&Gb1(4OI790_QlTX7ld&5@Lr`|`L+U~2ZG|N7kDZ{eCYwLkRr&MBOpQc?c}Wgx1n literal 0 HcmV?d00001 diff --git a/myenv/lib/python3.12/site-packages/multipart/__pycache__/multipart.cpython-312.pyc b/myenv/lib/python3.12/site-packages/multipart/__pycache__/multipart.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..96db73079ea343e0df5b30a53e919cf5b79a79bc GIT binary patch literal 65417 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 L22JACmXD+sXdBAHf0 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 0: + data = self.cache + data + + # Slice off a string that's a multiple of 4. + decode_len = (len(data) // 4) * 4 + val = data[:decode_len] + + # Decode and write, if we have any. + if len(val) > 0: + try: + decoded = base64.b64decode(val) + except binascii.Error: + raise DecodeError("There was an error raised while decoding base64-encoded data.") + + self.underlying.write(decoded) + + # Get the remaining bytes and save in our cache. + remaining_len = len(data) % 4 + if remaining_len > 0: + self.cache = data[-remaining_len:] + else: + self.cache = b"" + + # Return the length of the data to indicate no error. + return len(data) + + def close(self): + """Close this decoder. If the underlying object has a `close()` + method, this function will call it. + """ + if hasattr(self.underlying, "close"): + self.underlying.close() + + def finalize(self): + """Finalize this object. This should be called when no more data + should be written to the stream. This function can raise a + :class:`multipart.exceptions.DecodeError` if there is some remaining + data in the cache. + + If the underlying object has a `finalize()` method, this function will + call it. + """ + if len(self.cache) > 0: + raise DecodeError( + "There are %d bytes remaining in the Base64Decoder cache when finalize() is called" % len(self.cache) + ) + + if hasattr(self.underlying, "finalize"): + self.underlying.finalize() + + def __repr__(self): + return f"{self.__class__.__name__}(underlying={self.underlying!r})" + + +class QuotedPrintableDecoder: + """This object provides an interface to decode a stream of quoted-printable + data. It is instantiated with an "underlying object", in the same manner + as the :class:`multipart.decoders.Base64Decoder` class. This class behaves + in exactly the same way, including maintaining a cache of quoted-printable + chunks. + + :param underlying: the underlying object to pass writes to + """ + + def __init__(self, underlying): + self.cache = b"" + self.underlying = underlying + + def write(self, data): + """Takes any input data provided, decodes it as quoted-printable, and + passes it on to the underlying object. + + :param data: quoted-printable data to decode + """ + # Prepend any cache info to our data. + if len(self.cache) > 0: + data = self.cache + data + + # If the last 2 characters have an '=' sign in it, then we won't be + # able to decode the encoded value and we'll need to save it for the + # next decoding step. + if data[-2:].find(b"=") != -1: + enc, rest = data[:-2], data[-2:] + else: + enc = data + rest = b"" + + # Encode and write, if we have data. + if len(enc) > 0: + self.underlying.write(binascii.a2b_qp(enc)) + + # Save remaining in cache. + self.cache = rest + return len(data) + + def close(self): + """Close this decoder. If the underlying object has a `close()` + method, this function will call it. + """ + if hasattr(self.underlying, "close"): + self.underlying.close() + + def finalize(self): + """Finalize this object. This should be called when no more data + should be written to the stream. This function will not raise any + exceptions, but it may write more data to the underlying object if + there is data remaining in the cache. + + If the underlying object has a `finalize()` method, this function will + call it. + """ + # If we have a cache, write and then remove it. + if len(self.cache) > 0: + self.underlying.write(binascii.a2b_qp(self.cache)) + self.cache = b"" + + # Finalize our underlying stream. + if hasattr(self.underlying, "finalize"): + self.underlying.finalize() + + def __repr__(self): + return f"{self.__class__.__name__}(underlying={self.underlying!r})" diff --git a/myenv/lib/python3.12/site-packages/multipart/exceptions.py b/myenv/lib/python3.12/site-packages/multipart/exceptions.py new file mode 100644 index 0000000..cc3671f --- /dev/null +++ b/myenv/lib/python3.12/site-packages/multipart/exceptions.py @@ -0,0 +1,34 @@ +class FormParserError(ValueError): + """Base error class for our form parser.""" + + +class ParseError(FormParserError): + """This exception (or a subclass) is raised when there is an error while + parsing something. + """ + + #: This is the offset in the input data chunk (*NOT* the overall stream) in + #: which the parse error occurred. It will be -1 if not specified. + offset = -1 + + +class MultipartParseError(ParseError): + """This is a specific error that is raised when the MultipartParser detects + an error while parsing. + """ + + +class QuerystringParseError(ParseError): + """This is a specific error that is raised when the QuerystringParser + detects an error while parsing. + """ + + +class DecodeError(ParseError): + """This exception is raised when there is a decoding error - for example + with the Base64Decoder or QuotedPrintableDecoder. + """ + + +class FileError(FormParserError, OSError): + """Exception class for problems with the File class.""" diff --git a/myenv/lib/python3.12/site-packages/multipart/multipart.py b/myenv/lib/python3.12/site-packages/multipart/multipart.py new file mode 100644 index 0000000..221bb71 --- /dev/null +++ b/myenv/lib/python3.12/site-packages/multipart/multipart.py @@ -0,0 +1,1911 @@ +from __future__ import annotations + +import logging +import os +import shutil +import sys +import tempfile +from email.message import Message +from enum import IntEnum +from io import BytesIO +from numbers import Number +from typing import TYPE_CHECKING + +from .decoders import Base64Decoder, QuotedPrintableDecoder +from .exceptions import FileError, FormParserError, MultipartParseError, QuerystringParseError + +if TYPE_CHECKING: # pragma: no cover + from typing import Callable, TypedDict + + class QuerystringCallbacks(TypedDict, total=False): + on_field_start: Callable[[], None] + on_field_name: Callable[[bytes, int, int], None] + on_field_data: Callable[[bytes, int, int], None] + on_field_end: Callable[[], None] + on_end: Callable[[], None] + + class OctetStreamCallbacks(TypedDict, total=False): + on_start: Callable[[], None] + on_data: Callable[[bytes, int, int], None] + on_end: Callable[[], None] + + class MultipartCallbacks(TypedDict, total=False): + on_part_begin: Callable[[], None] + on_part_data: Callable[[bytes, int, int], None] + on_part_end: Callable[[], None] + on_headers_begin: Callable[[], None] + on_header_field: Callable[[bytes, int, int], None] + on_header_value: Callable[[bytes, int, int], None] + on_header_end: Callable[[], None] + on_headers_finished: Callable[[], None] + on_end: Callable[[], None] + + class FormParserConfig(TypedDict, total=False): + UPLOAD_DIR: str | None + UPLOAD_KEEP_FILENAME: bool + UPLOAD_KEEP_EXTENSIONS: bool + UPLOAD_ERROR_ON_BAD_CTE: bool + MAX_MEMORY_FILE_SIZE: int + MAX_BODY_SIZE: float + + class FileConfig(TypedDict, total=False): + UPLOAD_DIR: str | None + UPLOAD_DELETE_TMP: bool + UPLOAD_KEEP_FILENAME: bool + UPLOAD_KEEP_EXTENSIONS: bool + MAX_MEMORY_FILE_SIZE: int + + +# Unique missing object. +_missing = object() + + +class QuerystringState(IntEnum): + """Querystring parser states. + + These are used to keep track of the state of the parser, and are used to determine + what to do when new data is encountered. + """ + + BEFORE_FIELD = 0 + FIELD_NAME = 1 + FIELD_DATA = 2 + + +class MultipartState(IntEnum): + """Multipart parser states. + + These are used to keep track of the state of the parser, and are used to determine + what to do when new data is encountered. + """ + + START = 0 + START_BOUNDARY = 1 + HEADER_FIELD_START = 2 + HEADER_FIELD = 3 + HEADER_VALUE_START = 4 + HEADER_VALUE = 5 + HEADER_VALUE_ALMOST_DONE = 6 + HEADERS_ALMOST_DONE = 7 + PART_DATA_START = 8 + PART_DATA = 9 + PART_DATA_END = 10 + END = 11 + + +# Flags for the multipart parser. +FLAG_PART_BOUNDARY = 1 +FLAG_LAST_BOUNDARY = 2 + +# Get constants. Since iterating over a str on Python 2 gives you a 1-length +# string, but iterating over a bytes object on Python 3 gives you an integer, +# we need to save these constants. +CR = b"\r"[0] +LF = b"\n"[0] +COLON = b":"[0] +SPACE = b" "[0] +HYPHEN = b"-"[0] +AMPERSAND = b"&"[0] +SEMICOLON = b";"[0] +LOWER_A = b"a"[0] +LOWER_Z = b"z"[0] +NULL = b"\x00"[0] + + +# Lower-casing a character is different, because of the difference between +# str on Py2, and bytes on Py3. Same with getting the ordinal value of a byte, +# and joining a list of bytes together. +# These functions abstract that. +def lower_char(c): + return c | 0x20 + + +def ord_char(c): + return c + + +def join_bytes(b): + return bytes(list(b)) + + +def parse_options_header(value: str | bytes) -> tuple[bytes, dict[bytes, bytes]]: + """ + Parses a Content-Type header into a value in the following format: + (content_type, {parameters}) + """ + # Uses email.message.Message to parse the header as described in PEP 594. + # Ref: https://peps.python.org/pep-0594/#cgi + if not value: + return (b"", {}) + + # If we are passed bytes, we assume that it conforms to WSGI, encoding in latin-1. + if isinstance(value, bytes): # pragma: no cover + value = value.decode("latin-1") + + # For types + assert isinstance(value, str), "Value should be a string by now" + + # If we have no options, return the string as-is. + if ";" not in value: + return (value.lower().strip().encode("latin-1"), {}) + + # Split at the first semicolon, to get our value and then options. + # ctype, rest = value.split(b';', 1) + message = Message() + message["content-type"] = value + params = message.get_params() + # If there were no parameters, this would have already returned above + assert params, "At least the content type value should be present" + ctype = params.pop(0)[0].encode("latin-1") + options = {} + for param in params: + key, value = param + # If the value returned from get_params() is a 3-tuple, the last + # element corresponds to the value. + # See: https://docs.python.org/3/library/email.compat32-message.html + if isinstance(value, tuple): + value = value[-1] + # If the value is a filename, we need to fix a bug on IE6 that sends + # the full file path instead of the filename. + if key == "filename": + if value[1:3] == ":\\" or value[:2] == "\\\\": + value = value.split("\\")[-1] + options[key.encode("latin-1")] = value.encode("latin-1") + return ctype, options + + +class Field: + """A Field object represents a (parsed) form field. It represents a single + field with a corresponding name and value. + + The name that a :class:`Field` will be instantiated with is the same name + that would be found in the following HTML:: + + + + This class defines two methods, :meth:`on_data` and :meth:`on_end`, that + will be called when data is written to the Field, and when the Field is + finalized, respectively. + + :param name: the name of the form field + """ + + def __init__(self, name: str): + self._name = name + self._value: list[bytes] = [] + + # We cache the joined version of _value for speed. + self._cache = _missing + + @classmethod + def from_value(cls, name: str, value: bytes | None) -> Field: + """Create an instance of a :class:`Field`, and set the corresponding + value - either None or an actual value. This method will also + finalize the Field itself. + + :param name: the name of the form field + :param value: the value of the form field - either a bytestring or + None + """ + + f = cls(name) + if value is None: + f.set_none() + else: + f.write(value) + f.finalize() + return f + + def write(self, data: bytes) -> int: + """Write some data into the form field. + + :param data: a bytestring + """ + return self.on_data(data) + + def on_data(self, data: bytes) -> int: + """This method is a callback that will be called whenever data is + written to the Field. + + :param data: a bytestring + """ + self._value.append(data) + self._cache = _missing + return len(data) + + def on_end(self) -> None: + """This method is called whenever the Field is finalized.""" + if self._cache is _missing: + self._cache = b"".join(self._value) + + def finalize(self) -> None: + """Finalize the form field.""" + self.on_end() + + def close(self) -> None: + """Close the Field object. This will free any underlying cache.""" + # Free our value array. + if self._cache is _missing: + self._cache = b"".join(self._value) + + del self._value + + def set_none(self) -> None: + """Some fields in a querystring can possibly have a value of None - for + example, the string "foo&bar=&baz=asdf" will have a field with the + name "foo" and value None, one with name "bar" and value "", and one + with name "baz" and value "asdf". Since the write() interface doesn't + support writing None, this function will set the field value to None. + """ + self._cache = None + + @property + def field_name(self) -> str: + """This property returns the name of the field.""" + return self._name + + @property + def value(self): + """This property returns the value of the form field.""" + if self._cache is _missing: + self._cache = b"".join(self._value) + + return self._cache + + def __eq__(self, other: object) -> bool: + if isinstance(other, Field): + return self.field_name == other.field_name and self.value == other.value + else: + return NotImplemented + + def __repr__(self) -> str: + if len(self.value) > 97: + # We get the repr, and then insert three dots before the final + # quote. + v = repr(self.value[:97])[:-1] + "...'" + else: + v = repr(self.value) + + return "{}(field_name={!r}, value={})".format(self.__class__.__name__, self.field_name, v) + + +class File: + """This class represents an uploaded file. It handles writing file data to + either an in-memory file or a temporary file on-disk, if the optional + threshold is passed. + + There are some options that can be passed to the File to change behavior + of the class. Valid options are as follows: + + .. list-table:: + :widths: 15 5 5 30 + :header-rows: 1 + + * - Name + - Type + - Default + - Description + * - UPLOAD_DIR + - `str` + - None + - The directory to store uploaded files in. If this is None, a + temporary file will be created in the system's standard location. + * - UPLOAD_DELETE_TMP + - `bool` + - True + - Delete automatically created TMP file + * - UPLOAD_KEEP_FILENAME + - `bool` + - False + - Whether or not to keep the filename of the uploaded file. If True, + then the filename will be converted to a safe representation (e.g. + by removing any invalid path segments), and then saved with the + same name). Otherwise, a temporary name will be used. + * - UPLOAD_KEEP_EXTENSIONS + - `bool` + - False + - Whether or not to keep the uploaded file's extension. If False, the + file will be saved with the default temporary extension (usually + ".tmp"). Otherwise, the file's extension will be maintained. Note + that this will properly combine with the UPLOAD_KEEP_FILENAME + setting. + * - MAX_MEMORY_FILE_SIZE + - `int` + - 1 MiB + - The maximum number of bytes of a File to keep in memory. By + default, the contents of a File are kept into memory until a certain + limit is reached, after which the contents of the File are written + to a temporary file. This behavior can be disabled by setting this + value to an appropriately large value (or, for example, infinity, + such as `float('inf')`. + + :param file_name: The name of the file that this :class:`File` represents + + :param field_name: The field name that uploaded this file. Note that this + can be None, if, for example, the file was uploaded + with Content-Type application/octet-stream + + :param config: The configuration for this File. See above for valid + configuration keys and their corresponding values. + """ + + def __init__(self, file_name: bytes | None, field_name: bytes | None = None, config: FileConfig = {}): + # Save configuration, set other variables default. + self.logger = logging.getLogger(__name__) + self._config = config + self._in_memory = True + self._bytes_written = 0 + self._fileobj = BytesIO() + + # Save the provided field/file name. + self._field_name = field_name + self._file_name = file_name + + # Our actual file name is None by default, since, depending on our + # config, we may not actually use the provided name. + self._actual_file_name = None + + # Split the extension from the filename. + if file_name is not None: + base, ext = os.path.splitext(file_name) + self._file_base = base + self._ext = ext + + @property + def field_name(self) -> bytes | None: + """The form field associated with this file. May be None if there isn't + one, for example when we have an application/octet-stream upload. + """ + return self._field_name + + @property + def file_name(self) -> bytes | None: + """The file name given in the upload request.""" + return self._file_name + + @property + def actual_file_name(self): + """The file name that this file is saved as. Will be None if it's not + currently saved on disk. + """ + return self._actual_file_name + + @property + def file_object(self): + """The file object that we're currently writing to. Note that this + will either be an instance of a :class:`io.BytesIO`, or a regular file + object. + """ + return self._fileobj + + @property + def size(self): + """The total size of this file, counted as the number of bytes that + currently have been written to the file. + """ + return self._bytes_written + + @property + def in_memory(self) -> bool: + """A boolean representing whether or not this file object is currently + stored in-memory or on-disk. + """ + return self._in_memory + + def flush_to_disk(self) -> None: + """If the file is already on-disk, do nothing. Otherwise, copy from + the in-memory buffer to a disk file, and then reassign our internal + file object to this new disk file. + + Note that if you attempt to flush a file that is already on-disk, a + warning will be logged to this module's logger. + """ + if not self._in_memory: + self.logger.warning("Trying to flush to disk when we're not in memory") + return + + # Go back to the start of our file. + self._fileobj.seek(0) + + # Open a new file. + new_file = self._get_disk_file() + + # Copy the file objects. + shutil.copyfileobj(self._fileobj, new_file) + + # Seek to the new position in our new file. + new_file.seek(self._bytes_written) + + # Reassign the fileobject. + old_fileobj = self._fileobj + self._fileobj = new_file + + # We're no longer in memory. + self._in_memory = False + + # Close the old file object. + old_fileobj.close() + + def _get_disk_file(self): + """This function is responsible for getting a file object on-disk for us.""" + self.logger.info("Opening a file on disk") + + file_dir = self._config.get("UPLOAD_DIR") + keep_filename = self._config.get("UPLOAD_KEEP_FILENAME", False) + keep_extensions = self._config.get("UPLOAD_KEEP_EXTENSIONS", False) + delete_tmp = self._config.get("UPLOAD_DELETE_TMP", True) + + # If we have a directory and are to keep the filename... + if file_dir is not None and keep_filename: + self.logger.info("Saving with filename in: %r", file_dir) + + # Build our filename. + # TODO: what happens if we don't have a filename? + fname = self._file_base + if keep_extensions: + fname = fname + self._ext + + path = os.path.join(file_dir, fname) + try: + self.logger.info("Opening file: %r", path) + tmp_file = open(path, "w+b") + except OSError: + tmp_file = None + + self.logger.exception("Error opening temporary file") + raise FileError("Error opening temporary file: %r" % path) + else: + # Build options array. + # Note that on Python 3, tempfile doesn't support byte names. We + # encode our paths using the default filesystem encoding. + options = {} + if keep_extensions: + ext = self._ext + if isinstance(ext, bytes): + ext = ext.decode(sys.getfilesystemencoding()) + + options["suffix"] = ext + if file_dir is not None: + d = file_dir + if isinstance(d, bytes): + d = d.decode(sys.getfilesystemencoding()) + + options["dir"] = d + options["delete"] = delete_tmp + + # Create a temporary (named) file with the appropriate settings. + self.logger.info("Creating a temporary file with options: %r", options) + try: + tmp_file = tempfile.NamedTemporaryFile(**options) + except OSError: + self.logger.exception("Error creating named temporary file") + raise FileError("Error creating named temporary file") + + fname = tmp_file.name + + # Encode filename as bytes. + if isinstance(fname, str): + fname = fname.encode(sys.getfilesystemencoding()) + + self._actual_file_name = fname + return tmp_file + + def write(self, data: bytes): + """Write some data to the File. + + :param data: a bytestring + """ + return self.on_data(data) + + def on_data(self, data: bytes): + """This method is a callback that will be called whenever data is + written to the File. + + :param data: a bytestring + """ + pos = self._fileobj.tell() + bwritten = self._fileobj.write(data) + # true file objects write returns None + if bwritten is None: + bwritten = self._fileobj.tell() - pos + + # If the bytes written isn't the same as the length, just return. + if bwritten != len(data): + self.logger.warning("bwritten != len(data) (%d != %d)", bwritten, len(data)) + return bwritten + + # Keep track of how many bytes we've written. + self._bytes_written += bwritten + + # If we're in-memory and are over our limit, we create a file. + if ( + self._in_memory + and self._config.get("MAX_MEMORY_FILE_SIZE") is not None + and (self._bytes_written > self._config.get("MAX_MEMORY_FILE_SIZE")) + ): + self.logger.info("Flushing to disk") + self.flush_to_disk() + + # Return the number of bytes written. + return bwritten + + def on_end(self) -> None: + """This method is called whenever the Field is finalized.""" + # Flush the underlying file object + self._fileobj.flush() + + def finalize(self) -> None: + """Finalize the form file. This will not close the underlying file, + but simply signal that we are finished writing to the File. + """ + self.on_end() + + def close(self) -> None: + """Close the File object. This will actually close the underlying + file object (whether it's a :class:`io.BytesIO` or an actual file + object). + """ + self._fileobj.close() + + def __repr__(self) -> str: + return "{}(file_name={!r}, field_name={!r})".format(self.__class__.__name__, self.file_name, self.field_name) + + +class BaseParser: + """This class is the base class for all parsers. It contains the logic for + calling and adding callbacks. + + A callback can be one of two different forms. "Notification callbacks" are + callbacks that are called when something happens - for example, when a new + part of a multipart message is encountered by the parser. "Data callbacks" + are called when we get some sort of data - for example, part of the body of + a multipart chunk. Notification callbacks are called with no parameters, + whereas data callbacks are called with three, as follows:: + + data_callback(data, start, end) + + The "data" parameter is a bytestring (i.e. "foo" on Python 2, or b"foo" on + Python 3). "start" and "end" are integer indexes into the "data" string + that represent the data of interest. Thus, in a data callback, the slice + `data[start:end]` represents the data that the callback is "interested in". + The callback is not passed a copy of the data, since copying severely hurts + performance. + """ + + def __init__(self): + self.logger = logging.getLogger(__name__) + + def callback(self, name: str, data=None, start=None, end=None): + """This function calls a provided callback with some data. If the + callback is not set, will do nothing. + + :param name: The name of the callback to call (as a string). + + :param data: Data to pass to the callback. If None, then it is + assumed that the callback is a notification callback, + and no parameters are given. + + :param end: An integer that is passed to the data callback. + + :param start: An integer that is passed to the data callback. + """ + name = "on_" + name + func = self.callbacks.get(name) + if func is None: + return + + # Depending on whether we're given a buffer... + if data is not None: + # Don't do anything if we have start == end. + if start is not None and start == end: + return + + self.logger.debug("Calling %s with data[%d:%d]", name, start, end) + func(data, start, end) + else: + self.logger.debug("Calling %s with no data", name) + func() + + def set_callback(self, name: str, new_func): + """Update the function for a callback. Removes from the callbacks dict + if new_func is None. + + :param name: The name of the callback to call (as a string). + + :param new_func: The new function for the callback. If None, then the + callback will be removed (with no error if it does not + exist). + """ + if new_func is None: + self.callbacks.pop("on_" + name, None) + else: + self.callbacks["on_" + name] = new_func + + def close(self): + pass # pragma: no cover + + def finalize(self): + pass # pragma: no cover + + def __repr__(self): + return "%s()" % self.__class__.__name__ + + +class OctetStreamParser(BaseParser): + """This parser parses an octet-stream request body and calls callbacks when + incoming data is received. Callbacks are as follows: + + .. list-table:: + :widths: 15 10 30 + :header-rows: 1 + + * - Callback Name + - Parameters + - Description + * - on_start + - None + - Called when the first data is parsed. + * - on_data + - data, start, end + - Called for each data chunk that is parsed. + * - on_end + - None + - Called when the parser is finished parsing all data. + + :param callbacks: A dictionary of callbacks. See the documentation for + :class:`BaseParser`. + + :param max_size: The maximum size of body to parse. Defaults to infinity - + i.e. unbounded. + """ + + def __init__(self, callbacks: OctetStreamCallbacks = {}, max_size=float("inf")): + super().__init__() + self.callbacks = callbacks + self._started = False + + if not isinstance(max_size, Number) or max_size < 1: + raise ValueError("max_size must be a positive number, not %r" % max_size) + self.max_size = max_size + self._current_size = 0 + + def write(self, data: bytes): + """Write some data to the parser, which will perform size verification, + and then pass the data to the underlying callback. + + :param data: a bytestring + """ + if not self._started: + self.callback("start") + self._started = True + + # Truncate data length. + data_len = len(data) + if (self._current_size + data_len) > self.max_size: + # We truncate the length of data that we are to process. + new_size = int(self.max_size - self._current_size) + self.logger.warning( + "Current size is %d (max %d), so truncating data length from %d to %d", + self._current_size, + self.max_size, + data_len, + new_size, + ) + data_len = new_size + + # Increment size, then callback, in case there's an exception. + self._current_size += data_len + self.callback("data", data, 0, data_len) + return data_len + + def finalize(self) -> None: + """Finalize this parser, which signals to that we are finished parsing, + and sends the on_end callback. + """ + self.callback("end") + + def __repr__(self) -> str: + return "%s()" % self.__class__.__name__ + + +class QuerystringParser(BaseParser): + """This is a streaming querystring parser. It will consume data, and call + the callbacks given when it has data. + + .. list-table:: + :widths: 15 10 30 + :header-rows: 1 + + * - Callback Name + - Parameters + - Description + * - on_field_start + - None + - Called when a new field is encountered. + * - on_field_name + - data, start, end + - Called when a portion of a field's name is encountered. + * - on_field_data + - data, start, end + - Called when a portion of a field's data is encountered. + * - on_field_end + - None + - Called when the end of a field is encountered. + * - on_end + - None + - Called when the parser is finished parsing all data. + + :param callbacks: A dictionary of callbacks. See the documentation for + :class:`BaseParser`. + + :param strict_parsing: Whether or not to parse the body strictly. Defaults + to False. If this is set to True, then the behavior + of the parser changes as the following: if a field + has a value with an equal sign (e.g. "foo=bar", or + "foo="), it is always included. If a field has no + equals sign (e.g. "...&name&..."), it will be + treated as an error if 'strict_parsing' is True, + otherwise included. If an error is encountered, + then a + :class:`multipart.exceptions.QuerystringParseError` + will be raised. + + :param max_size: The maximum size of body to parse. Defaults to infinity - + i.e. unbounded. + """ + + state: QuerystringState + + def __init__(self, callbacks: QuerystringCallbacks = {}, strict_parsing: bool = False, max_size=float("inf")): + super().__init__() + self.state = QuerystringState.BEFORE_FIELD + self._found_sep = False + + self.callbacks = callbacks + + # Max-size stuff + if not isinstance(max_size, Number) or max_size < 1: + raise ValueError("max_size must be a positive number, not %r" % max_size) + self.max_size = max_size + self._current_size = 0 + + # Should parsing be strict? + self.strict_parsing = strict_parsing + + def write(self, data: bytes) -> int: + """Write some data to the parser, which will perform size verification, + parse into either a field name or value, and then pass the + corresponding data to the underlying callback. If an error is + encountered while parsing, a QuerystringParseError will be raised. The + "offset" attribute of the raised exception will be set to the offset in + the input data chunk (NOT the overall stream) that caused the error. + + :param data: a bytestring + """ + # Handle sizing. + data_len = len(data) + if (self._current_size + data_len) > self.max_size: + # We truncate the length of data that we are to process. + new_size = int(self.max_size - self._current_size) + self.logger.warning( + "Current size is %d (max %d), so truncating data length from %d to %d", + self._current_size, + self.max_size, + data_len, + new_size, + ) + data_len = new_size + + l = 0 + try: + l = self._internal_write(data, data_len) + finally: + self._current_size += l + + return l + + def _internal_write(self, data: bytes, length: int) -> int: + state = self.state + strict_parsing = self.strict_parsing + found_sep = self._found_sep + + i = 0 + while i < length: + ch = data[i] + + # Depending on our state... + if state == QuerystringState.BEFORE_FIELD: + # If the 'found_sep' flag is set, we've already encountered + # and skipped a single separator. If so, we check our strict + # parsing flag and decide what to do. Otherwise, we haven't + # yet reached a separator, and thus, if we do, we need to skip + # it as it will be the boundary between fields that's supposed + # to be there. + if ch == AMPERSAND or ch == SEMICOLON: + if found_sep: + # If we're parsing strictly, we disallow blank chunks. + if strict_parsing: + e = QuerystringParseError("Skipping duplicate ampersand/semicolon at %d" % i) + e.offset = i + raise e + else: + self.logger.debug("Skipping duplicate ampersand/semicolon at %d", i) + else: + # This case is when we're skipping the (first) + # separator between fields, so we just set our flag + # and continue on. + found_sep = True + else: + # Emit a field-start event, and go to that state. Also, + # reset the "found_sep" flag, for the next time we get to + # this state. + self.callback("field_start") + i -= 1 + state = QuerystringState.FIELD_NAME + found_sep = False + + elif state == QuerystringState.FIELD_NAME: + # Try and find a separator - we ensure that, if we do, we only + # look for the equal sign before it. + sep_pos = data.find(b"&", i) + if sep_pos == -1: + sep_pos = data.find(b";", i) + + # See if we can find an equals sign in the remaining data. If + # so, we can immediately emit the field name and jump to the + # data state. + if sep_pos != -1: + equals_pos = data.find(b"=", i, sep_pos) + else: + equals_pos = data.find(b"=", i) + + if equals_pos != -1: + # Emit this name. + self.callback("field_name", data, i, equals_pos) + + # Jump i to this position. Note that it will then have 1 + # added to it below, which means the next iteration of this + # loop will inspect the character after the equals sign. + i = equals_pos + state = QuerystringState.FIELD_DATA + else: + # No equals sign found. + if not strict_parsing: + # See also comments in the QuerystringState.FIELD_DATA case below. + # If we found the separator, we emit the name and just + # end - there's no data callback at all (not even with + # a blank value). + if sep_pos != -1: + self.callback("field_name", data, i, sep_pos) + self.callback("field_end") + + i = sep_pos - 1 + state = QuerystringState.BEFORE_FIELD + else: + # Otherwise, no separator in this block, so the + # rest of this chunk must be a name. + self.callback("field_name", data, i, length) + i = length + + else: + # We're parsing strictly. If we find a separator, + # this is an error - we require an equals sign. + if sep_pos != -1: + e = QuerystringParseError( + "When strict_parsing is True, we require an " + "equals sign in all field chunks. Did not " + "find one in the chunk that starts at %d" % (i,) + ) + e.offset = i + raise e + + # No separator in the rest of this chunk, so it's just + # a field name. + self.callback("field_name", data, i, length) + i = length + + elif state == QuerystringState.FIELD_DATA: + # Try finding either an ampersand or a semicolon after this + # position. + sep_pos = data.find(b"&", i) + if sep_pos == -1: + sep_pos = data.find(b";", i) + + # If we found it, callback this bit as data and then go back + # to expecting to find a field. + if sep_pos != -1: + self.callback("field_data", data, i, sep_pos) + self.callback("field_end") + + # Note that we go to the separator, which brings us to the + # "before field" state. This allows us to properly emit + # "field_start" events only when we actually have data for + # a field of some sort. + i = sep_pos - 1 + state = QuerystringState.BEFORE_FIELD + + # Otherwise, emit the rest as data and finish. + else: + self.callback("field_data", data, i, length) + i = length + + else: # pragma: no cover (error case) + msg = "Reached an unknown state %d at %d" % (state, i) + self.logger.warning(msg) + e = QuerystringParseError(msg) + e.offset = i + raise e + + i += 1 + + self.state = state + self._found_sep = found_sep + return len(data) + + def finalize(self) -> None: + """Finalize this parser, which signals to that we are finished parsing, + if we're still in the middle of a field, an on_field_end callback, and + then the on_end callback. + """ + # If we're currently in the middle of a field, we finish it. + if self.state == QuerystringState.FIELD_DATA: + self.callback("field_end") + self.callback("end") + + def __repr__(self) -> str: + return "{}(strict_parsing={!r}, max_size={!r})".format( + self.__class__.__name__, self.strict_parsing, self.max_size + ) + + +class MultipartParser(BaseParser): + """This class is a streaming multipart/form-data parser. + + .. list-table:: + :widths: 15 10 30 + :header-rows: 1 + + * - Callback Name + - Parameters + - Description + * - on_part_begin + - None + - Called when a new part of the multipart message is encountered. + * - on_part_data + - data, start, end + - Called when a portion of a part's data is encountered. + * - on_part_end + - None + - Called when the end of a part is reached. + * - on_header_begin + - None + - Called when we've found a new header in a part of a multipart + message + * - on_header_field + - data, start, end + - Called each time an additional portion of a header is read (i.e. the + part of the header that is before the colon; the "Foo" in + "Foo: Bar"). + * - on_header_value + - data, start, end + - Called when we get data for a header. + * - on_header_end + - None + - Called when the current header is finished - i.e. we've reached the + newline at the end of the header. + * - on_headers_finished + - None + - Called when all headers are finished, and before the part data + starts. + * - on_end + - None + - Called when the parser is finished parsing all data. + + + :param boundary: The multipart boundary. This is required, and must match + what is given in the HTTP request - usually in the + Content-Type header. + + :param callbacks: A dictionary of callbacks. See the documentation for + :class:`BaseParser`. + + :param max_size: The maximum size of body to parse. Defaults to infinity - + i.e. unbounded. + """ + + def __init__(self, boundary: bytes | str, callbacks: MultipartCallbacks = {}, max_size=float("inf")): + # Initialize parser state. + super().__init__() + self.state = MultipartState.START + self.index = self.flags = 0 + + self.callbacks = callbacks + + if not isinstance(max_size, Number) or max_size < 1: + raise ValueError("max_size must be a positive number, not %r" % max_size) + self.max_size = max_size + self._current_size = 0 + + # Setup marks. These are used to track the state of data received. + self.marks = {} + + # TODO: Actually use this rather than the dumb version we currently use + # # Precompute the skip table for the Boyer-Moore-Horspool algorithm. + # skip = [len(boundary) for x in range(256)] + # for i in range(len(boundary) - 1): + # skip[ord_char(boundary[i])] = len(boundary) - i - 1 + # + # # We use a tuple since it's a constant, and marginally faster. + # self.skip = tuple(skip) + + # Save our boundary. + if isinstance(boundary, str): # pragma: no cover + boundary = boundary.encode("latin-1") + self.boundary = b"\r\n--" + boundary + + # Get a set of characters that belong to our boundary. + self.boundary_chars = frozenset(self.boundary) + + # We also create a lookbehind list. + # Note: the +8 is since we can have, at maximum, "\r\n--" + boundary + + # "--\r\n" at the final boundary, and the length of '\r\n--' and + # '--\r\n' is 8 bytes. + self.lookbehind = [NULL for x in range(len(boundary) + 8)] + + def write(self, data: bytes) -> int: + """Write some data to the parser, which will perform size verification, + and then parse the data into the appropriate location (e.g. header, + data, etc.), and pass this on to the underlying callback. If an error + is encountered, a MultipartParseError will be raised. The "offset" + attribute on the raised exception will be set to the offset of the byte + in the input chunk that caused the error. + + :param data: a bytestring + """ + # Handle sizing. + data_len = len(data) + if (self._current_size + data_len) > self.max_size: + # We truncate the length of data that we are to process. + new_size = int(self.max_size - self._current_size) + self.logger.warning( + "Current size is %d (max %d), so truncating data length from %d to %d", + self._current_size, + self.max_size, + data_len, + new_size, + ) + data_len = new_size + + l = 0 + try: + l = self._internal_write(data, data_len) + finally: + self._current_size += l + + return l + + def _internal_write(self, data: bytes, length: int) -> int: + # Get values from locals. + boundary = self.boundary + + # Get our state, flags and index. These are persisted between calls to + # this function. + state = self.state + index = self.index + flags = self.flags + + # Our index defaults to 0. + i = 0 + + # Set a mark. + def set_mark(name): + self.marks[name] = i + + # Remove a mark. + def delete_mark(name, reset=False): + self.marks.pop(name, None) + + # Helper function that makes calling a callback with data easier. The + # 'remaining' parameter will callback from the marked value until the + # end of the buffer, and reset the mark, instead of deleting it. This + # is used at the end of the function to call our callbacks with any + # remaining data in this chunk. + def data_callback(name, remaining=False): + marked_index = self.marks.get(name) + if marked_index is None: + return + + # If we're getting remaining data, we ignore the current i value + # and just call with the remaining data. + if remaining: + self.callback(name, data, marked_index, length) + self.marks[name] = 0 + + # Otherwise, we call it from the mark to the current byte we're + # processing. + else: + self.callback(name, data, marked_index, i) + self.marks.pop(name, None) + + # For each byte... + while i < length: + c = data[i] + + if state == MultipartState.START: + # Skip leading newlines + if c == CR or c == LF: + i += 1 + self.logger.debug("Skipping leading CR/LF at %d", i) + continue + + # index is used as in index into our boundary. Set to 0. + index = 0 + + # Move to the next state, but decrement i so that we re-process + # this character. + state = MultipartState.START_BOUNDARY + i -= 1 + + elif state == MultipartState.START_BOUNDARY: + # Check to ensure that the last 2 characters in our boundary + # are CRLF. + if index == len(boundary) - 2: + if c != CR: + # Error! + msg = "Did not find CR at end of boundary (%d)" % (i,) + self.logger.warning(msg) + e = MultipartParseError(msg) + e.offset = i + raise e + + index += 1 + + elif index == len(boundary) - 2 + 1: + if c != LF: + msg = "Did not find LF at end of boundary (%d)" % (i,) + self.logger.warning(msg) + e = MultipartParseError(msg) + e.offset = i + raise e + + # The index is now used for indexing into our boundary. + index = 0 + + # Callback for the start of a part. + self.callback("part_begin") + + # Move to the next character and state. + state = MultipartState.HEADER_FIELD_START + + else: + # Check to ensure our boundary matches + if c != boundary[index + 2]: + msg = "Did not find boundary character %r at index " "%d" % (c, index + 2) + self.logger.warning(msg) + e = MultipartParseError(msg) + e.offset = i + raise e + + # Increment index into boundary and continue. + index += 1 + + elif state == MultipartState.HEADER_FIELD_START: + # Mark the start of a header field here, reset the index, and + # continue parsing our header field. + index = 0 + + # Set a mark of our header field. + set_mark("header_field") + + # Move to parsing header fields. + state = MultipartState.HEADER_FIELD + i -= 1 + + elif state == MultipartState.HEADER_FIELD: + # If we've reached a CR at the beginning of a header, it means + # that we've reached the second of 2 newlines, and so there are + # no more headers to parse. + if c == CR: + delete_mark("header_field") + state = MultipartState.HEADERS_ALMOST_DONE + i += 1 + continue + + # Increment our index in the header. + index += 1 + + # Do nothing if we encounter a hyphen. + if c == HYPHEN: + pass + + # If we've reached a colon, we're done with this header. + elif c == COLON: + # A 0-length header is an error. + if index == 1: + msg = "Found 0-length header at %d" % (i,) + self.logger.warning(msg) + e = MultipartParseError(msg) + e.offset = i + raise e + + # Call our callback with the header field. + data_callback("header_field") + + # Move to parsing the header value. + state = MultipartState.HEADER_VALUE_START + + else: + # Lower-case this character, and ensure that it is in fact + # a valid letter. If not, it's an error. + cl = lower_char(c) + if cl < LOWER_A or cl > LOWER_Z: + msg = "Found non-alphanumeric character %r in " "header at %d" % (c, i) + self.logger.warning(msg) + e = MultipartParseError(msg) + e.offset = i + raise e + + elif state == MultipartState.HEADER_VALUE_START: + # Skip leading spaces. + if c == SPACE: + i += 1 + continue + + # Mark the start of the header value. + set_mark("header_value") + + # Move to the header-value state, reprocessing this character. + state = MultipartState.HEADER_VALUE + i -= 1 + + elif state == MultipartState.HEADER_VALUE: + # If we've got a CR, we're nearly done our headers. Otherwise, + # we do nothing and just move past this character. + if c == CR: + data_callback("header_value") + self.callback("header_end") + state = MultipartState.HEADER_VALUE_ALMOST_DONE + + elif state == MultipartState.HEADER_VALUE_ALMOST_DONE: + # The last character should be a LF. If not, it's an error. + if c != LF: + msg = "Did not find LF character at end of header " "(found %r)" % (c,) + self.logger.warning(msg) + e = MultipartParseError(msg) + e.offset = i + raise e + + # Move back to the start of another header. Note that if that + # state detects ANOTHER newline, it'll trigger the end of our + # headers. + state = MultipartState.HEADER_FIELD_START + + elif state == MultipartState.HEADERS_ALMOST_DONE: + # We're almost done our headers. This is reached when we parse + # a CR at the beginning of a header, so our next character + # should be a LF, or it's an error. + if c != LF: + msg = f"Did not find LF at end of headers (found {c!r})" + self.logger.warning(msg) + e = MultipartParseError(msg) + e.offset = i + raise e + + self.callback("headers_finished") + state = MultipartState.PART_DATA_START + + elif state == MultipartState.PART_DATA_START: + # Mark the start of our part data. + set_mark("part_data") + + # Start processing part data, including this character. + state = MultipartState.PART_DATA + i -= 1 + + elif state == MultipartState.PART_DATA: + # We're processing our part data right now. During this, we + # need to efficiently search for our boundary, since any data + # on any number of lines can be a part of the current data. + # We use the Boyer-Moore-Horspool algorithm to efficiently + # search through the remainder of the buffer looking for our + # boundary. + + # Save the current value of our index. We use this in case we + # find part of a boundary, but it doesn't match fully. + prev_index = index + + # Set up variables. + boundary_length = len(boundary) + boundary_end = boundary_length - 1 + data_length = length + boundary_chars = self.boundary_chars + + # If our index is 0, we're starting a new part, so start our + # search. + if index == 0: + # Search forward until we either hit the end of our buffer, + # or reach a character that's in our boundary. + i += boundary_end + while i < data_length - 1 and data[i] not in boundary_chars: + i += boundary_length + + # Reset i back the length of our boundary, which is the + # earliest possible location that could be our match (i.e. + # if we've just broken out of our loop since we saw the + # last character in our boundary) + i -= boundary_end + c = data[i] + + # Now, we have a couple of cases here. If our index is before + # the end of the boundary... + if index < boundary_length: + # If the character matches... + if boundary[index] == c: + # If we found a match for our boundary, we send the + # existing data. + if index == 0: + data_callback("part_data") + + # The current character matches, so continue! + index += 1 + else: + index = 0 + + # Our index is equal to the length of our boundary! + elif index == boundary_length: + # First we increment it. + index += 1 + + # Now, if we've reached a newline, we need to set this as + # the potential end of our boundary. + if c == CR: + flags |= FLAG_PART_BOUNDARY + + # Otherwise, if this is a hyphen, we might be at the last + # of all boundaries. + elif c == HYPHEN: + flags |= FLAG_LAST_BOUNDARY + + # Otherwise, we reset our index, since this isn't either a + # newline or a hyphen. + else: + index = 0 + + # Our index is right after the part boundary, which should be + # a LF. + elif index == boundary_length + 1: + # If we're at a part boundary (i.e. we've seen a CR + # character already)... + if flags & FLAG_PART_BOUNDARY: + # We need a LF character next. + if c == LF: + # Unset the part boundary flag. + flags &= ~FLAG_PART_BOUNDARY + + # Callback indicating that we've reached the end of + # a part, and are starting a new one. + self.callback("part_end") + self.callback("part_begin") + + # Move to parsing new headers. + index = 0 + state = MultipartState.HEADER_FIELD_START + i += 1 + continue + + # We didn't find an LF character, so no match. Reset + # our index and clear our flag. + index = 0 + flags &= ~FLAG_PART_BOUNDARY + + # Otherwise, if we're at the last boundary (i.e. we've + # seen a hyphen already)... + elif flags & FLAG_LAST_BOUNDARY: + # We need a second hyphen here. + if c == HYPHEN: + # Callback to end the current part, and then the + # message. + self.callback("part_end") + self.callback("end") + state = MultipartState.END + else: + # No match, so reset index. + index = 0 + + # If we have an index, we need to keep this byte for later, in + # case we can't match the full boundary. + if index > 0: + self.lookbehind[index - 1] = c + + # Otherwise, our index is 0. If the previous index is not, it + # means we reset something, and we need to take the data we + # thought was part of our boundary and send it along as actual + # data. + elif prev_index > 0: + # Callback to write the saved data. + lb_data = join_bytes(self.lookbehind) + self.callback("part_data", lb_data, 0, prev_index) + + # Overwrite our previous index. + prev_index = 0 + + # Re-set our mark for part data. + set_mark("part_data") + + # Re-consider the current character, since this could be + # the start of the boundary itself. + i -= 1 + + elif state == MultipartState.END: + # Do nothing and just consume a byte in the end state. + if c not in (CR, LF): + self.logger.warning("Consuming a byte '0x%x' in the end state", c) + + else: # pragma: no cover (error case) + # We got into a strange state somehow! Just stop processing. + msg = "Reached an unknown state %d at %d" % (state, i) + self.logger.warning(msg) + e = MultipartParseError(msg) + e.offset = i + raise e + + # Move to the next byte. + i += 1 + + # We call our callbacks with any remaining data. Note that we pass + # the 'remaining' flag, which sets the mark back to 0 instead of + # deleting it, if it's found. This is because, if the mark is found + # at this point, we assume that there's data for one of these things + # that has been parsed, but not yet emitted. And, as such, it implies + # that we haven't yet reached the end of this 'thing'. So, by setting + # the mark to 0, we cause any data callbacks that take place in future + # calls to this function to start from the beginning of that buffer. + data_callback("header_field", True) + data_callback("header_value", True) + data_callback("part_data", True) + + # Save values to locals. + self.state = state + self.index = index + self.flags = flags + + # Return our data length to indicate no errors, and that we processed + # all of it. + return length + + def finalize(self) -> None: + """Finalize this parser, which signals to that we are finished parsing. + + Note: It does not currently, but in the future, it will verify that we + are in the final state of the parser (i.e. the end of the multipart + message is well-formed), and, if not, throw an error. + """ + # TODO: verify that we're in the state MultipartState.END, otherwise throw an + # error or otherwise state that we're not finished parsing. + pass + + def __repr__(self): + return f"{self.__class__.__name__}(boundary={self.boundary!r})" + + +class FormParser: + """This class is the all-in-one form parser. Given all the information + necessary to parse a form, it will instantiate the correct parser, create + the proper :class:`Field` and :class:`File` classes to store the data that + is parsed, and call the two given callbacks with each field and file as + they become available. + + :param content_type: The Content-Type of the incoming request. This is + used to select the appropriate parser. + + :param on_field: The callback to call when a field has been parsed and is + ready for usage. See above for parameters. + + :param on_file: The callback to call when a file has been parsed and is + ready for usage. See above for parameters. + + :param on_end: An optional callback to call when all fields and files in a + request has been parsed. Can be None. + + :param boundary: If the request is a multipart/form-data request, this + should be the boundary of the request, as given in the + Content-Type header, as a bytestring. + + :param file_name: If the request is of type application/octet-stream, then + the body of the request will not contain any information + about the uploaded file. In such cases, you can provide + the file name of the uploaded file manually. + + :param FileClass: The class to use for uploaded files. Defaults to + :class:`File`, but you can provide your own class if you + wish to customize behaviour. The class will be + instantiated as FileClass(file_name, field_name), and it + must provide the following functions:: + file_instance.write(data) + file_instance.finalize() + file_instance.close() + + :param FieldClass: The class to use for uploaded fields. Defaults to + :class:`Field`, but you can provide your own class if + you wish to customize behaviour. The class will be + instantiated as FieldClass(field_name), and it must + provide the following functions:: + field_instance.write(data) + field_instance.finalize() + field_instance.close() + + :param config: Configuration to use for this FormParser. The default + values are taken from the DEFAULT_CONFIG value, and then + any keys present in this dictionary will overwrite the + default values. + + """ + + #: This is the default configuration for our form parser. + #: Note: all file sizes should be in bytes. + DEFAULT_CONFIG: FormParserConfig = { + "MAX_BODY_SIZE": float("inf"), + "MAX_MEMORY_FILE_SIZE": 1 * 1024 * 1024, + "UPLOAD_DIR": None, + "UPLOAD_KEEP_FILENAME": False, + "UPLOAD_KEEP_EXTENSIONS": False, + # Error on invalid Content-Transfer-Encoding? + "UPLOAD_ERROR_ON_BAD_CTE": False, + } + + def __init__( + self, + content_type, + on_field, + on_file, + on_end=None, + boundary=None, + file_name=None, + FileClass=File, + FieldClass=Field, + config: FormParserConfig = {}, + ): + self.logger = logging.getLogger(__name__) + + # Save variables. + self.content_type = content_type + self.boundary = boundary + self.bytes_received = 0 + self.parser = None + + # Save callbacks. + self.on_field = on_field + self.on_file = on_file + self.on_end = on_end + + # Save classes. + self.FileClass = File + self.FieldClass = Field + + # Set configuration options. + self.config = self.DEFAULT_CONFIG.copy() + self.config.update(config) + + # Depending on the Content-Type, we instantiate the correct parser. + if content_type == "application/octet-stream": + # Work around the lack of 'nonlocal' in Py2 + class vars: + f = None + + def on_start() -> None: + vars.f = FileClass(file_name, None, config=self.config) + + def on_data(data: bytes, start: int, end: int) -> None: + vars.f.write(data[start:end]) + + def on_end() -> None: + # Finalize the file itself. + vars.f.finalize() + + # Call our callback. + on_file(vars.f) + + # Call the on-end callback. + if self.on_end is not None: + self.on_end() + + # Instantiate an octet-stream parser + parser = OctetStreamParser( + callbacks={"on_start": on_start, "on_data": on_data, "on_end": on_end}, + max_size=self.config["MAX_BODY_SIZE"], + ) + + elif content_type == "application/x-www-form-urlencoded" or content_type == "application/x-url-encoded": + name_buffer: list[bytes] = [] + + class vars: + f = None + + def on_field_start() -> None: + pass + + def on_field_name(data: bytes, start: int, end: int) -> None: + name_buffer.append(data[start:end]) + + def on_field_data(data: bytes, start: int, end: int) -> None: + if vars.f is None: + vars.f = FieldClass(b"".join(name_buffer)) + del name_buffer[:] + vars.f.write(data[start:end]) + + def on_field_end() -> None: + # Finalize and call callback. + if vars.f is None: + # If we get here, it's because there was no field data. + # We create a field, set it to None, and then continue. + vars.f = FieldClass(b"".join(name_buffer)) + del name_buffer[:] + vars.f.set_none() + + vars.f.finalize() + on_field(vars.f) + vars.f = None + + def on_end() -> None: + if self.on_end is not None: + self.on_end() + + # Instantiate parser. + parser = QuerystringParser( + callbacks={ + "on_field_start": on_field_start, + "on_field_name": on_field_name, + "on_field_data": on_field_data, + "on_field_end": on_field_end, + "on_end": on_end, + }, + max_size=self.config["MAX_BODY_SIZE"], + ) + + elif content_type == "multipart/form-data": + if boundary is None: + self.logger.error("No boundary given") + raise FormParserError("No boundary given") + + header_name: list[bytes] = [] + header_value: list[bytes] = [] + headers = {} + + # No 'nonlocal' on Python 2 :-( + class vars: + f = None + writer = None + is_file = False + + def on_part_begin(): + pass + + def on_part_data(data: bytes, start: int, end: int): + bytes_processed = vars.writer.write(data[start:end]) + # TODO: check for error here. + return bytes_processed + + def on_part_end() -> None: + vars.f.finalize() + if vars.is_file: + on_file(vars.f) + else: + on_field(vars.f) + + def on_header_field(data: bytes, start: int, end: int): + header_name.append(data[start:end]) + + def on_header_value(data: bytes, start: int, end: int): + header_value.append(data[start:end]) + + def on_header_end(): + headers[b"".join(header_name)] = b"".join(header_value) + del header_name[:] + del header_value[:] + + def on_headers_finished() -> None: + # Reset the 'is file' flag. + vars.is_file = False + + # Parse the content-disposition header. + # TODO: handle mixed case + content_disp = headers.get(b"Content-Disposition") + disp, options = parse_options_header(content_disp) + + # Get the field and filename. + field_name = options.get(b"name") + file_name = options.get(b"filename") + # TODO: check for errors + + # Create the proper class. + if file_name is None: + vars.f = FieldClass(field_name) + else: + vars.f = FileClass(file_name, field_name, config=self.config) + vars.is_file = True + + # Parse the given Content-Transfer-Encoding to determine what + # we need to do with the incoming data. + # TODO: check that we properly handle 8bit / 7bit encoding. + transfer_encoding = headers.get(b"Content-Transfer-Encoding", b"7bit") + + if transfer_encoding == b"binary" or transfer_encoding == b"8bit" or transfer_encoding == b"7bit": + vars.writer = vars.f + + elif transfer_encoding == b"base64": + vars.writer = Base64Decoder(vars.f) + + elif transfer_encoding == b"quoted-printable": + vars.writer = QuotedPrintableDecoder(vars.f) + + else: + self.logger.warning("Unknown Content-Transfer-Encoding: %r", transfer_encoding) + if self.config["UPLOAD_ERROR_ON_BAD_CTE"]: + raise FormParserError('Unknown Content-Transfer-Encoding "{}"'.format(transfer_encoding)) + else: + # If we aren't erroring, then we just treat this as an + # unencoded Content-Transfer-Encoding. + vars.writer = vars.f + + def on_end() -> None: + vars.writer.finalize() + if self.on_end is not None: + self.on_end() + + # Instantiate a multipart parser. + parser = MultipartParser( + boundary, + callbacks={ + "on_part_begin": on_part_begin, + "on_part_data": on_part_data, + "on_part_end": on_part_end, + "on_header_field": on_header_field, + "on_header_value": on_header_value, + "on_header_end": on_header_end, + "on_headers_finished": on_headers_finished, + "on_end": on_end, + }, + max_size=self.config["MAX_BODY_SIZE"], + ) + + else: + self.logger.warning("Unknown Content-Type: %r", content_type) + raise FormParserError("Unknown Content-Type: {}".format(content_type)) + + self.parser = parser + + def write(self, data: bytes): + """Write some data. The parser will forward this to the appropriate + underlying parser. + + :param data: a bytestring + """ + self.bytes_received += len(data) + # TODO: check the parser's return value for errors? + return self.parser.write(data) + + def finalize(self) -> None: + """Finalize the parser.""" + if self.parser is not None and hasattr(self.parser, "finalize"): + self.parser.finalize() + + def close(self) -> None: + """Close the parser.""" + if self.parser is not None and hasattr(self.parser, "close"): + self.parser.close() + + def __repr__(self) -> str: + return "{}(content_type={!r}, parser={!r})".format(self.__class__.__name__, self.content_type, self.parser) + + +def create_form_parser(headers, on_field, on_file, trust_x_headers=False, config={}): + """This function is a helper function to aid in creating a FormParser + instances. Given a dictionary-like headers object, it will determine + the correct information needed, instantiate a FormParser with the + appropriate values and given callbacks, and then return the corresponding + parser. + + :param headers: A dictionary-like object of HTTP headers. The only + required header is Content-Type. + + :param on_field: Callback to call with each parsed field. + + :param on_file: Callback to call with each parsed file. + + :param trust_x_headers: Whether or not to trust information received from + certain X-Headers - for example, the file name from + X-File-Name. + + :param config: Configuration variables to pass to the FormParser. + """ + content_type = headers.get("Content-Type") + if content_type is None: + logging.getLogger(__name__).warning("No Content-Type header given") + raise ValueError("No Content-Type header given!") + + # Boundaries are optional (the FormParser will raise if one is needed + # but not given). + content_type, params = parse_options_header(content_type) + boundary = params.get(b"boundary") + + # We need content_type to be a string, not a bytes object. + content_type = content_type.decode("latin-1") + + # File names are optional. + file_name = headers.get("X-File-Name") + + # Instantiate a form parser. + form_parser = FormParser(content_type, on_field, on_file, boundary=boundary, file_name=file_name, config=config) + + # Return our parser. + return form_parser + + +def parse_form(headers, input_stream, on_field, on_file, chunk_size=1048576, **kwargs): + """This function is useful if you just want to parse a request body, + without too much work. Pass it a dictionary-like object of the request's + headers, and a file-like object for the input stream, along with two + callbacks that will get called whenever a field or file is parsed. + + :param headers: A dictionary-like object of HTTP headers. The only + required header is Content-Type. + + :param input_stream: A file-like object that represents the request body. + The read() method must return bytestrings. + + :param on_field: Callback to call with each parsed field. + + :param on_file: Callback to call with each parsed file. + + :param chunk_size: The maximum size to read from the input stream and write + to the parser at one time. Defaults to 1 MiB. + """ + + # Create our form parser. + parser = create_form_parser(headers, on_field, on_file) + + # Read chunks of 100KiB and write to the parser, but never read more than + # the given Content-Length, if any. + content_length = headers.get("Content-Length") + if content_length is not None: + content_length = int(content_length) + else: + content_length = float("inf") + bytes_read = 0 + + while True: + # Read only up to the Content-Length given. + max_readable = min(content_length - bytes_read, 1048576) + buff = input_stream.read(max_readable) + + # Write to the parser and update our length. + parser.write(buff) + bytes_read += len(buff) + + # If we get a buffer that's smaller than the size requested, or if we + # have read up to our content length, we're done. + if len(buff) != max_readable or bytes_read == content_length: + break + + # Tell our parser that we're done writing data. + parser.finalize() diff --git a/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/INSTALLER b/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/METADATA b/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/METADATA new file mode 100644 index 0000000..6d3c746 --- /dev/null +++ b/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/METADATA @@ -0,0 +1,70 @@ +Metadata-Version: 2.1 +Name: python-multipart +Version: 0.0.9 +Summary: A streaming multipart parser for Python +Project-URL: Homepage, https://github.com/andrew-d/python-multipart +Project-URL: Documentation, https://andrew-d.github.io/python-multipart/ +Project-URL: Changelog, https://github.com/andrew-d/python-multipart/blob/master/CHANGELOG.md +Project-URL: Source, https://github.com/andrew-d/python-multipart +Author-email: Andrew Dunham +License-Expression: Apache-2.0 +License-File: LICENSE.txt +Classifier: Development Status :: 5 - Production/Stable +Classifier: Environment :: Web Environment +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: Apache Software License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Requires-Python: >=3.8 +Provides-Extra: dev +Requires-Dist: atomicwrites==1.4.1; extra == 'dev' +Requires-Dist: attrs==23.2.0; extra == 'dev' +Requires-Dist: coverage==7.4.1; extra == 'dev' +Requires-Dist: hatch; extra == 'dev' +Requires-Dist: invoke==2.2.0; extra == 'dev' +Requires-Dist: more-itertools==10.2.0; extra == 'dev' +Requires-Dist: pbr==6.0.0; extra == 'dev' +Requires-Dist: pluggy==1.4.0; extra == 'dev' +Requires-Dist: py==1.11.0; extra == 'dev' +Requires-Dist: pytest-cov==4.1.0; extra == 'dev' +Requires-Dist: pytest-timeout==2.2.0; extra == 'dev' +Requires-Dist: pytest==8.0.0; extra == 'dev' +Requires-Dist: pyyaml==6.0.1; extra == 'dev' +Requires-Dist: ruff==0.2.1; extra == 'dev' +Description-Content-Type: text/x-rst + +================== + Python-Multipart +================== + +.. image:: https://github.com/andrew-d/python-multipart/actions/workflows/test.yaml/badge.svg + :target: https://github.com/andrew-d/python-multipart/actions + + +python-multipart is an Apache2 licensed streaming multipart parser for Python. +Test coverage is currently 100%. +Documentation is available `here`_. + +.. _here: https://andrew-d.github.io/python-multipart/ + +Why? +---- + +Because streaming uploads are awesome for large files. + +How to Test +----------- + +If you want to test: + +.. code-block:: bash + + $ pip install '.[dev]' + $ inv test diff --git a/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/RECORD b/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/RECORD new file mode 100644 index 0000000..d914195 --- /dev/null +++ b/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/RECORD @@ -0,0 +1,14 @@ +multipart/__init__.py,sha256=Z_EnZFoG_zmw22n7BomPmnVSCCl4XVNDYVWL8hry6Sc,448 +multipart/__pycache__/__init__.cpython-312.pyc,, +multipart/__pycache__/decoders.cpython-312.pyc,, +multipart/__pycache__/exceptions.cpython-312.pyc,, +multipart/__pycache__/multipart.cpython-312.pyc,, +multipart/decoders.py,sha256=A4SQHOwFRNzCfr5Fx0iOYpS8USTxE9ofYbL9kOJywHs,6038 +multipart/exceptions.py,sha256=a9buSOv_eiHZoukEJhdWX9LJYSJ6t7XOK3ZEaWoQZlk,992 +multipart/multipart.py,sha256=sThvJ7TSPQc1tjCfXMgqQduT3yKsmxhwzNZnmL5S7KY,72841 +python_multipart-0.0.9.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +python_multipart-0.0.9.dist-info/METADATA,sha256=5BDtS_h0qAxUX55VWiuCb7FZj6WZRd51l6YenNy4Its,2528 +python_multipart-0.0.9.dist-info/RECORD,, +python_multipart-0.0.9.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +python_multipart-0.0.9.dist-info/WHEEL,sha256=TJPnKdtrSue7xZ_AVGkp9YXcvDrobsjBds1du3Nx6dc,87 +python_multipart-0.0.9.dist-info/licenses/LICENSE.txt,sha256=qOgzF2zWF9rwC51tOfoVyo7evG0WQwec0vSJPAwom-I,556 diff --git a/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/REQUESTED b/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/REQUESTED new file mode 100644 index 0000000..e69de29 diff --git a/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/WHEEL b/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/WHEEL new file mode 100644 index 0000000..5998f3a --- /dev/null +++ b/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: hatchling 1.21.1 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/licenses/LICENSE.txt b/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/licenses/LICENSE.txt new file mode 100644 index 0000000..303a1bf --- /dev/null +++ b/myenv/lib/python3.12/site-packages/python_multipart-0.0.9.dist-info/licenses/LICENSE.txt @@ -0,0 +1,14 @@ +Copyright 2012, Andrew Dunham + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + diff --git a/src/pages-style/ingressegress.css b/src/pages-style/ingressegress.css index 166fa09..b1a9770 100644 --- a/src/pages-style/ingressegress.css +++ b/src/pages-style/ingressegress.css @@ -1,5 +1,4 @@ - .left-column { flex: 1; padding: 20px; diff --git a/src/pages-style/rover.css b/src/pages-style/rover.css index 0e9d0fc..2722f8c 100644 --- a/src/pages-style/rover.css +++ b/src/pages-style/rover.css @@ -1,29 +1,69 @@ -.rover-page { - display: flex; - flex-direction: column; -} - - .header-rover { - text-align: center; - } - - .content-rover { - display: flex; - flex-direction: row; - } - - .column-rover { - flex: 1; - display: flex; - flex-direction: column; /* Ensure column layout for contents */ - align-items: center; /* Center contents horizontally */ - } - - .gif-container-rover { - width: 100%; /* Take full width of the column */ - min-height: 300px; /* Set a minimum height to ensure visibility */ - display: flex; - justify-content: center; /* Center content horizontally */ - align-items: center; /* Center content vertically */ - } - \ No newline at end of file +.pagecontainer { + display: grid; + grid-template-rows: auto 1fr; + width: 100vw; +} + +.header-rover { + text-align: center; + padding: 20px; + background-color: #f8f9fa; /* Light gray */ +} + +.content-rover { + display: grid; + grid-template-columns: 60% 40%; + height: 100%; +} + +.left-column-rover { + display: grid; + grid-template-rows: 50% 50%; + border-right: 1px solid #ddd; + height: 100%; +} + +.rover-cam { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: #eef; /* Light blue */ + overflow: hidden; + position: relative; +} + +.rover-camera-container { + position: relative; + width: 100%; + height: 100%; +} + +.video-container { + width: 100%; + height: calc(100% - 50px); /* Adjust height to leave space for the button */ + display: flex; + align-items: center; + justify-content: center; +} + +.video-container video, +.video-container img { + max-width: 100%; + max-height: 100%; +} + +.button-container { + display: flex; + justify-content: center; + align-items: center; + height: 50px; +} + +.map { + background-color: #dde; /* Light green */ +} + +.right-column-rover { + background-color: #f8f9fa; /* Light gray */ +} diff --git a/src/pages/constant/rover.js b/src/pages/constant/rover.js deleted file mode 100644 index f8d0524..0000000 --- a/src/pages/constant/rover.js +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import RoverCam from "../../components/RoverCamera.js"; - -function rover() { - return ( -
-
-

Rover

-
-
- -
-
- -
-
- ); -} - -export default rover; \ No newline at end of file diff --git a/src/pages/focus/rover.js b/src/pages/focus/rover.js index 5112f63..70ce3ab 100644 --- a/src/pages/focus/rover.js +++ b/src/pages/focus/rover.js @@ -1,25 +1,20 @@ import React from 'react'; import '../../pages-style/rover.css'; -import '../../pages-style/page.css' -import Map from '../../components/Map.js' +import '../../pages-style/page.css'; +import Map from '../../components/Map.js'; +import RoverCam from "../../components/RoverCamera.js"; function Rover() { return (
-
-

Rover

-

This page will display the rover's collected samples, location, and camera data

-
-
-

Rover location and camera feed

-
- +
+
+
+
-
-

Rover sample data

-
+
Rover sample data
); diff --git a/tss_data.json b/tss_data.json index ac7be7e..6e38287 100644 --- a/tss_data.json +++ b/tss_data.json @@ -1 +1 @@ -{"timestamp": "2024-05-14T15:07:59.148488", "eva": {"eva": {"started": true, "paused": false, "completed": false, "total_time": 247, "uia": {"started": false, "completed": false, "time": 0}, "dcu": {"started": false, "completed": false, "time": 0}, "rover": {"started": false, "completed": false, "time": 0}, "spec": {"started": false, "completed": false, "time": 0}}}, "telemetry": {"telemetry": {"eva_time": 247, "eva1": {"batt_time_left": 5077.148926, "oxy_pri_storage": 23.755802, "oxy_sec_storage": 13.202492, "oxy_pri_pressure": 0.004397, "oxy_sec_pressure": 396.079468, "oxy_time_left": 3991, "heart_rate": 90.0, "oxy_consumption": 0.101168, "co2_production": 0.102651, "suit_pressure_oxy": 3.072328, "suit_pressure_co2": 0.001759, "suit_pressure_other": 11.5542, "suit_pressure_total": 14.628288, "fan_pri_rpm": 0.0, "fan_sec_rpm": 30001.0, "helmet_pressure_co2": 0.095776, "scrubber_a_co2_storage": 0.0, "scrubber_b_co2_storage": 27.898479, "temperature": 64.291092, "coolant_ml": 20.508068, "coolant_gas_pressure": 0.0, "coolant_liquid_pressure": 101.422012}, "eva2": {"batt_time_left": 3384.893799, "oxy_pri_storage": 24.231962, "oxy_sec_storage": 17.132097, "oxy_pri_pressure": 0.003345, "oxy_sec_pressure": 513.966675, "oxy_time_left": 4467, "heart_rate": 90.0, "oxy_consumption": 0.096568, "co2_production": 0.098817, "suit_pressure_oxy": 3.072317, "suit_pressure_cO2": 0.002359, "suit_pressure_other": 11.5542, "suit_pressure_total": 14.628876, "fan_pri_rpm": 0.0, "fan_sec_rpm": 30001.0, "helmet_pressure_co2": 0.095784, "scrubber_a_co2_storage": 0.0, "scrubber_b_co2_storage": 27.811237, "temperature": 83.518425, "coolant_ml": 22.034748, "coolant_gas_pressure": 49.807564, "coolant_liquid_pressure": 91.754539}}}} \ No newline at end of file +{"timestamp": "2024-05-14T18:20:05.277757", "eva": {"eva": {"started": true, "paused": false, "completed": false, "total_time": 5681, "uia": {"started": false, "completed": false, "time": 0}, "dcu": {"started": false, "completed": false, "time": 0}, "rover": {"started": false, "completed": false, "time": 0}, "spec": {"started": false, "completed": false, "time": 0}}}, "telemetry": {"telemetry": {"eva_time": 5681, "eva1": {"batt_time_left": 5077.148926, "oxy_pri_storage": 23.755802, "oxy_sec_storage": 0.0, "oxy_pri_pressure": 0.008578, "oxy_sec_pressure": 0.0, "oxy_time_left": 2565, "heart_rate": 90.0, "oxy_consumption": 0.101359, "co2_production": 0.097345, "suit_pressure_oxy": 3.072397, "suit_pressure_co2": 3.92512, "suit_pressure_other": 11.5542, "suit_pressure_total": 18.551716, "fan_pri_rpm": 0.0, "fan_sec_rpm": 30001.0, "helmet_pressure_co2": 3.601424, "scrubber_a_co2_storage": 0.0, "scrubber_b_co2_storage": 100.095428, "temperature": 70.417351, "coolant_ml": 20.508068, "coolant_gas_pressure": 0.0, "coolant_liquid_pressure": 111.086449}, "eva2": {"batt_time_left": 3384.893799, "oxy_pri_storage": 24.231962, "oxy_sec_storage": 0.0, "oxy_pri_pressure": 0.006075, "oxy_sec_pressure": 0.0, "oxy_time_left": 2617, "heart_rate": 90.0, "oxy_consumption": 0.097586, "co2_production": 0.092443, "suit_pressure_oxy": 3.072405, "suit_pressure_cO2": 3.924976, "suit_pressure_other": 11.5542, "suit_pressure_total": 18.551582, "fan_pri_rpm": 0.0, "fan_sec_rpm": 30001.0, "helmet_pressure_co2": 3.601968, "scrubber_a_co2_storage": 0.0, "scrubber_b_co2_storage": 100.08062, "temperature": 81.031525, "coolant_ml": 22.034748, "coolant_gas_pressure": 14.167668, "coolant_liquid_pressure": 123.179192}}}} \ No newline at end of file