add multipart parser

Commit 76df4ad · patx · 2025-02-01T09:59:32-05:00

Changeset
76df4ad48f6f03335526c5f6089128c85cb2aa41
Parents
ea789cf42ccd617c59817ae878c0546266ce02cd

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/MicroPie.py b/MicroPie.py
index 2609c50..d505062 100644
--- a/MicroPie.py
+++ b/MicroPie.py
@@ -30,15 +30,19 @@ WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
 OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
 EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 """
+import asyncio
 import inspect
 import mimetypes
 import os
+import re
 import time
 from typing import Optional, Dict, Any, Union, Tuple, List
 from urllib.parse import parse_qs
 import uuid
 import contextvars
 
+from multipart import PushMultipartParser, MultipartSegment
+
 try:
     from jinja2 import Environment, FileSystemLoader
     JINJA_INSTALLED = True
@@ -127,7 +131,23 @@ class Server:
                                 break
                     content_type = headers_dict.get("content-type", "")
                     if "multipart/form-data" in content_type:
-                        self._parse_multipart(bytes(body_data), content_type, request)
+                        # Extract the boundary from the Content-Type header
+                        match = re.search(r'boundary=([^;]+)', content_type)
+                        if not match:
+                            await self._send_response(
+                                send, status_code=400,
+                                body="400 Bad Request: Boundary not found in Content-Type header"
+                            )
+                            return
+                        boundary = match.group(1).encode("utf-8")  # Convert boundary to bytes
+
+                        # Create a StreamReader and feed it the body data
+                        reader = asyncio.StreamReader()
+                        reader.feed_data(body_data)
+                        reader.feed_eof()
+
+                        # Now call _parse_multipart with the reader and the boundary
+                        await self._parse_multipart(reader, boundary)
                     else:
                         body_str = body_data.decode("utf-8", "ignore")
                         request.body_params = parse_qs(body_str)
@@ -214,67 +234,86 @@ class Server:
                 cookies[k] = v
         return cookies
 
-    def _parse_multipart(self, body: bytes, content_type: str, request: Request) -> None:
-        boundary = None
-        parts = content_type.split(";")
-        for part in parts:
-            part = part.strip()
-            if part.startswith("boundary="):
-                boundary = part.split("=", 1)[1]
-                break
-
-        if not boundary:
-            raise ValueError("Boundary not found in Content-Type header.")
-
-        boundary_bytes = boundary.encode("utf-8")
-        delimiter = b"--" + boundary_bytes
-        sections = body.split(delimiter)
-        for section in sections:
-            if not section or section in (b"--", b"--\r\n"):
-                continue
-            if section.startswith(b"\r\n"):
-                section = section[2:]
-            if section.endswith(b"\r\n"):
-                section = section[:-2]
-            if section == b"--":
-                continue
-
-            try:
-                headers, content = section.split(b"\r\n\r\n", 1)
-            except ValueError:
-                continue
-
-            headers_list = headers.decode("utf-8", "ignore").split("\r\n")
-            header_dict = {}
-            for header_line in headers_list:
-                if ":" in header_line:
-                    key, value = header_line.split(":", 1)
-                    header_dict[key.strip().lower()] = value.strip()
-
-            disposition = header_dict.get("content-disposition", "")
-            disposition_parts = disposition.split(";")
-            disposition_dict = {}
-            for disp_part in disposition_parts:
-                if "=" in disp_part:
-                    k, v = disp_part.strip().split("=", 1)
-                    disposition_dict[k] = v.strip('"')
-
-            name = disposition_dict.get("name")
-            filename = disposition_dict.get("filename")
-
-            if filename:
-                file_content_type = header_dict.get("content-type", "application/octet-stream")
-                request.files[name] = {
-                    "filename": filename,
-                    "content_type": file_content_type,
-                    "data": content
-                }
-            elif name:
-                value = content.decode("utf-8", "ignore")
-                if name in request.body_params:
-                    request.body_params[name].append(value)
-                else:
-                    request.body_params[name] = [value]
+    async def _parse_multipart(self, reader: asyncio.StreamReader, boundary: bytes):
+        """
+        Demonstrates handling multipart/form-data in a more streaming-friendly manner.
+        For large files, data is written to disk instead of stored in memory.
+        """
+        with PushMultipartParser(boundary) as parser:
+            current_field_name = None
+            current_filename = None
+            current_content_type = None
+            current_file = None  # File handle for streaming writes
+            form_value = ""
+
+            # Directory for storing uploaded files:
+            # Adjust if you want a different path or a dynamic approach inside your app.
+            upload_directory = "uploads"
+
+            # Ensure the directory exists
+            os.makedirs(upload_directory, exist_ok=True)
+
+            while not parser.closed:
+                # Read data in chunks from the request stream
+                chunk = await reader.read(65536)  # 64KB per read
+                for result in parser.parse(chunk):
+                    if isinstance(result, MultipartSegment):
+                        # We have a new part: form field or file
+                        current_field_name = result.name
+                        current_filename = result.filename
+                        current_content_type = None
+                        form_value = ""
+
+                        # Parse content-type if present
+                        for header, value in result.headerlist:
+                            if header.lower() == "content-type":
+                                current_content_type = value
+
+                        # If it's a file, open a file handle right away
+                        if current_filename:
+                            safe_filename = f"{uuid.uuid4()}_{current_filename}"
+                            file_path = os.path.join(upload_directory, safe_filename)
+                            current_file = open(file_path, "wb")
+
+                        # Otherwise, treat it as a field (string value).
+                        else:
+                            if current_field_name not in self.request.body_params:
+                                self.request.body_params[current_field_name] = []
+
+                    elif result:
+                        # This chunk is body data for the current part
+                        if current_file:
+                            # If it's a file, write directly to disk
+                            current_file.write(result)
+                        else:
+                            # It's a form field chunk
+                            form_value += result.decode("utf-8", "ignore")
+                    else:
+                        # End of this part
+                        if current_file:
+                            # Close out the file if we're done writing it
+                            current_file.close()
+                            current_file = None
+
+                            # Store reference in self.request.files so the upload handler can use it
+                            # Example structure includes just filename and content type;
+                            # no in-memory data, since we wrote it to disk.
+                            if current_field_name:
+                                self.request.files[current_field_name] = {
+                                    "filename": current_filename,
+                                    "content_type": current_content_type or "application/octet-stream",
+                                    "saved_path": os.path.join(upload_directory, safe_filename),
+                                }
+                        else:
+                            # If it was a form field, add the form value to body_params
+                            if current_field_name:
+                                self.request.body_params[current_field_name].append(form_value)
+
+                        # Reset for the next part
+                        current_field_name = None
+                        current_filename = None
+                        current_content_type = None
+                        form_value = ""
 
     async def _send_response(
         self,
diff --git a/examples/file_uploads_developmental/app.py b/examples/file_uploads/app.py
similarity index 57%
rename from examples/file_uploads_developmental/app.py
rename to examples/file_uploads/app.py
index 180b3dd..e4c9569 100644
--- a/examples/file_uploads_developmental/app.py
+++ b/examples/file_uploads/app.py
@@ -21,18 +21,20 @@ class FileUploadApp(Server):
             </body>
         </html>"""
 
-    async def upload(self, file: Any):
-        """Handles file uploads."""
-        if isinstance(file, dict) and "filename" in file and "data" in file:
+    async def upload(self, file):
+        # Check for streaming-based attributes:
+        if (isinstance(file, dict)
+                and "filename" in file
+                and "saved_path" in file):
             filename = file["filename"]
-            file_data = file["data"]
+            saved_path = file["saved_path"]
 
-            file_path = os.path.join(UPLOAD_DIR, filename)
-            with open(file_path, "wb") as f:
-                f.write(file_data)
+            # Optionally, rename the file or do further checks.
+            # For instance, you might want to store an original name in your DB or process the file.
+            return f"File '{filename}' uploaded successfully, saved to: {saved_path}!"
 
-            return f"File '{filename}' uploaded successfully!"
-        return "No file uploaded.", 400
+        # If file data is missing or doesn't match expected structure, return an error.
+        return 400, "No file uploaded."
 
 # Run the ASGI app
 app = FileUploadApp()
diff --git a/examples/pastebin/app.py b/examples/pastebin/app.py
index b7f51e3..bf133d9 100644
--- a/examples/pastebin/app.py
+++ b/examples/pastebin/app.py
@@ -14,21 +14,20 @@ db = PickleDB('pastes.db')
 class Root(Server):
 
     async def index(self):
-        # Check the HTTP method from the ASGI scope
         if self.request.method == "POST":
             paste_content = self.request.body_params.get('paste_content', [''])[0]
             pid = str(uuid4())
             db.set(pid, escape(paste_content))
             db.save()
-            return self.redirect(f'/paste/{pid}')
-        return await self.render_template('index.html')
+            return self._redirect(f'/paste/{pid}')
+        return await self._render_template('index.html')
 
     async def paste(self, paste_id, delete=None):
         if delete == 'delete':
             db.remove(paste_id)
             db.save()
-            return self.redirect('/')
-        return await self.render_template(
+            return self._redirect('/')
+        return await self._render_template(
             'paste.html',
             paste_id=paste_id,
             paste_content=db.get(paste_id)
diff --git a/examples/socketio/chatroom.py b/examples/socketio/chatroom.py
index eca009c..c660578 100644
--- a/examples/socketio/chatroom.py
+++ b/examples/socketio/chatroom.py
@@ -6,37 +6,8 @@ sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")  # Allow
 
 # Create the MicroPie server
 class MyApp(Server):
-    def index(self):
-        return """
-        <html>
-        <head>
-            <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
-            <script>
-                var socket = io("http://localhost:8000");
-                socket.on("connect", function() {
-                    console.log("Connected to Socket.IO server");
-                });
-                socket.on("message", function(data) {
-                    document.getElementById("output").innerHTML += data + "<br>";
-                });
-                function sendMessage() {
-                    var message = document.getElementById("message").value;
-                    socket.send(message);
-                    document.getElementById("message").value = "";  // Clear input after sending
-                }
-                window.onbeforeunload = function() {
-                    socket.disconnect();
-                };
-            </script>
-        </head>
-        <body>
-            <h1>Socket.IO Chat</h1>
-            <input type="text" id="message" placeholder="Type a message">
-            <button onclick="sendMessage()">Send</button>
-            <div id="output"></div>
-        </body>
-        </html>
-        """
+    async def index(self):
+        return await self._render_template("chat.html")
 
 # Socket.IO event handlers
 @sio.event
@@ -57,4 +28,4 @@ async def message(sid, data):
 
 # Attach Socket.IO to the ASGI app
 asgi_app = MyApp()
-app = socketio.ASGIApp(sio, app)
+app = socketio.ASGIApp(sio, asgi_app)
diff --git a/examples/socketio/templates/chat.html b/examples/socketio/templates/chat.html
new file mode 100644
index 0000000..ecb11f6
--- /dev/null
+++ b/examples/socketio/templates/chat.html
@@ -0,0 +1,28 @@
+<html>
+<head>
+<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
+<script>
+var socket = io("http://localhost:8000");
+socket.on("connect", function() {
+    console.log("Connected to Socket.IO server");
+});
+socket.on("message", function(data) {
+    document.getElementById("output").innerHTML += data + "<br>";
+});
+function sendMessage() {
+    var message = document.getElementById("message").value;
+    socket.send(message);
+    document.getElementById("message").value = "";  // Clear input after sending
+}
+window.onbeforeunload = function() {
+    socket.disconnect();
+};
+</script>
+</head>
+  <body>
+    <h1>Socket.IO Chat</h1>
+    <input type="text" id="message" placeholder="Type a message">
+    <button onclick="sendMessage()">Send</button>
+    <div id="output"></div>
+  </body>
+</html>
diff --git a/examples/socketio/templates/index_stream.html b/examples/socketio/templates/index.html
similarity index 100%
rename from examples/socketio/templates/index_stream.html
rename to examples/socketio/templates/index.html
diff --git a/examples/file_uploads_developmental/MicroPie.py b/examples/socketio/webtrc/MicroPie.py
similarity index 67%
rename from examples/file_uploads_developmental/MicroPie.py
rename to examples/socketio/webtrc/MicroPie.py
index 0bea053..d505062 100644
--- a/examples/file_uploads_developmental/MicroPie.py
+++ b/examples/socketio/webtrc/MicroPie.py
@@ -30,20 +30,18 @@ WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
 OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
 EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 """
+import asyncio
 import inspect
 import mimetypes
 import os
+import re
 import time
 from typing import Optional, Dict, Any, Union, Tuple, List
 from urllib.parse import parse_qs
 import uuid
 import contextvars
 
-try:
-    from multipart import PushMultipartParser, MultipartSegment
-    MULTIP_INSTALLED = True
-except ImportError:
-    MULTIP_INSTALLED = False
+from multipart import PushMultipartParser, MultipartSegment
 
 try:
     from jinja2 import Environment, FileSystemLoader
@@ -79,9 +77,9 @@ class Server:
         return current_request.get()
 
     async def __call__(self, scope, receive, send):
-        await self.asgi_app(scope, receive, send)
+        await self._asgi_app(scope, receive, send)
 
-    async def asgi_app(self, scope: Dict[str, Any], receive: Any, send: Any) -> None:
+    async def _asgi_app(self, scope: Dict[str, Any], receive: Any, send: Any) -> None:
         """ASGI application entrypoint for both HTTP and WebSockets."""
         if scope["type"] == "http":
             request = Request(scope)
@@ -133,10 +131,23 @@ class Server:
                                 break
                     content_type = headers_dict.get("content-type", "")
                     if "multipart/form-data" in content_type:
-                        if MULTIP_INSTALLED:
-                            await self._parse_multipart(receive, content_type, request)
-                        else:
-                            print('Library multipart required in order to parse multipart form data/file uploads')
+                        # Extract the boundary from the Content-Type header
+                        match = re.search(r'boundary=([^;]+)', content_type)
+                        if not match:
+                            await self._send_response(
+                                send, status_code=400,
+                                body="400 Bad Request: Boundary not found in Content-Type header"
+                            )
+                            return
+                        boundary = match.group(1).encode("utf-8")  # Convert boundary to bytes
+
+                        # Create a StreamReader and feed it the body data
+                        reader = asyncio.StreamReader()
+                        reader.feed_data(body_data)
+                        reader.feed_eof()
+
+                        # Now call _parse_multipart with the reader and the boundary
+                        await self._parse_multipart(reader, boundary)
                     else:
                         body_str = body_data.decode("utf-8", "ignore")
                         request.body_params = parse_qs(body_str)
@@ -223,80 +234,86 @@ class Server:
                 cookies[k] = v
         return cookies
 
-    def _parse_multipart(self, body: bytes, content_type: str, request: Request) -> None:
-        boundary = None
-        parts = content_type.split(";")
-        for part in parts:
-            part = part.strip()
-            if part.startswith("boundary="):
-                boundary = part.split("=", 1)[1]
-                break
-
-        if not boundary:
-            raise ValueError("Boundary not found in Content-Type header.")
-
-    async def _parse_multipart(self, receive, content_type: str, request: Request):
-        """Parses multipart form data using PushMultipartParser."""
-
-        # Extract boundary from content_type
-        boundary = None
-        parts = content_type.split(";")
-        for part in parts:
-            part = part.strip()
-            if part.startswith("boundary="):
-                boundary = part.split("=", 1)[1]
-                break
-
-        if not boundary:
-            raise ValueError("Boundary not found in Content-Type header.")
-
-        # Initialize parser
-        parser = PushMultipartParser(boundary)
-        field_name = None
-        file_data = None
-
-        while not parser.closed:
-            # Read next chunk from ASGI receive function
-            msg = await receive()
-            if msg["type"] == "http.request":
-                chunk = msg.get("body", b"")
-
+    async def _parse_multipart(self, reader: asyncio.StreamReader, boundary: bytes):
+        """
+        Demonstrates handling multipart/form-data in a more streaming-friendly manner.
+        For large files, data is written to disk instead of stored in memory.
+        """
+        with PushMultipartParser(boundary) as parser:
+            current_field_name = None
+            current_filename = None
+            current_content_type = None
+            current_file = None  # File handle for streaming writes
+            form_value = ""
+
+            # Directory for storing uploaded files:
+            # Adjust if you want a different path or a dynamic approach inside your app.
+            upload_directory = "uploads"
+
+            # Ensure the directory exists
+            os.makedirs(upload_directory, exist_ok=True)
+
+            while not parser.closed:
+                # Read data in chunks from the request stream
+                chunk = await reader.read(65536)  # 64KB per read
                 for result in parser.parse(chunk):
                     if isinstance(result, MultipartSegment):
-                        # Start of a new multipart segment
-                        field_name = result.name
-                        filename = result.filename
-
-                        if filename:
-                            # It's a file upload
-                            content_type = result.content_type
-                            file_data = bytearray()
-                            request.files[field_name] = {
-                                "filename": filename,
-                                "content_type": content_type,
-                                "data": file_data
-                            }
+                        # We have a new part: form field or file
+                        current_field_name = result.name
+                        current_filename = result.filename
+                        current_content_type = None
+                        form_value = ""
+
+                        # Parse content-type if present
+                        for header, value in result.headerlist:
+                            if header.lower() == "content-type":
+                                current_content_type = value
+
+                        # If it's a file, open a file handle right away
+                        if current_filename:
+                            safe_filename = f"{uuid.uuid4()}_{current_filename}"
+                            file_path = os.path.join(upload_directory, safe_filename)
+                            current_file = open(file_path, "wb")
+
+                        # Otherwise, treat it as a field (string value).
                         else:
-                            # It's a normal form field
-                            request.body_params[field_name] = ""
-
-                    elif isinstance(result, bytearray):
-                        # This is part of the file or form field data
-                        if field_name in request.files:
-                            request.files[field_name]["data"].extend(result)
+                            if current_field_name not in self.request.body_params:
+                                self.request.body_params[current_field_name] = []
+
+                    elif result:
+                        # This chunk is body data for the current part
+                        if current_file:
+                            # If it's a file, write directly to disk
+                            current_file.write(result)
                         else:
-                            request.body_params[field_name] += result.decode("utf-8", "ignore")
-
-                    elif result is None:
-                        # End of a segment, finalize file content if present
-                        if field_name in request.files:
-                            request.files[field_name]["data"] = bytes(request.files[field_name]["data"])
-
-                # Stop if there's no more body content
-                if not msg.get("more_body", False):
-                    break
-
+                            # It's a form field chunk
+                            form_value += result.decode("utf-8", "ignore")
+                    else:
+                        # End of this part
+                        if current_file:
+                            # Close out the file if we're done writing it
+                            current_file.close()
+                            current_file = None
+
+                            # Store reference in self.request.files so the upload handler can use it
+                            # Example structure includes just filename and content type;
+                            # no in-memory data, since we wrote it to disk.
+                            if current_field_name:
+                                self.request.files[current_field_name] = {
+                                    "filename": current_filename,
+                                    "content_type": current_content_type or "application/octet-stream",
+                                    "saved_path": os.path.join(upload_directory, safe_filename),
+                                }
+                        else:
+                            # If it was a form field, add the form value to body_params
+                            if current_field_name:
+                                self.request.body_params[current_field_name].append(form_value)
 
+                        # Reset for the next part
+                        current_field_name = None
+                        current_filename = None
+                        current_content_type = None
+                        form_value = ""
 
     async def _send_response(
         self,
@@ -317,20 +334,27 @@ class Server:
             404: "404 Not Found",
             500: "500 Internal Server Error",
         }
-        # Fallback if not in map
         status_text = status_map.get(status_code, f"{status_code} OK")
 
-        # Ensure there's a Content-Type unless already provided
-        has_content_type = any(h[0].lower() == "content-type" for h in extra_headers)
+        # Ensure extra headers are safe
+        sanitized_headers = []
+        for k, v in extra_headers:
+            if "\n" in k or "\r" in k or "\n" in v or "\r" in v:
+                print(f"Header injection attempt detected: {k}: {v}")
+                continue  # Skip invalid headers
+            sanitized_headers.append((k, v))
+
+        # Ensure Content-Type is set unless explicitly provided
+        has_content_type = any(h[0].lower() == "content-type" for h in sanitized_headers)
         if not has_content_type:
-            extra_headers.append(("Content-Type", "text/html; charset=utf-8"))
+            sanitized_headers.append(("Content-Type", "text/html; charset=utf-8"))
 
-        # Send the initial response start
+        # Send response start
         await send({
             "type": "http.response.start",
             "status": status_code,
             "headers": [
-                (k.encode("latin-1"), v.encode("latin-1")) for k, v in extra_headers
+                (k.encode("latin-1"), v.encode("latin-1")) for k, v in sanitized_headers
             ],
         })
 
@@ -379,13 +403,15 @@ class Server:
             # Convert anything else to string then to bytes
             response_body = str(body).encode("utf-8")
 
+        # Ensure body is properly encoded
+        response_body = body.encode("utf-8") if isinstance(body, str) else body
         await send({
             "type": "http.response.body",
             "body": response_body,
             "more_body": False
         })
 
-    def cleanup_sessions(self) -> None:
+    def _cleanup_sessions(self) -> None:
         now = time.time()
         self.sessions = {
             sid: data
@@ -393,7 +419,7 @@ class Server:
             if data.get("last_access", now) + self.SESSION_TIMEOUT > now
         }
 
-    def redirect(self, location: str) -> Tuple[int, str]:
+    def _redirect(self, location: str) -> Tuple[int, str]:
         return (
             302,
             (
@@ -403,7 +429,7 @@ class Server:
             ),
         )
 
-    async def render_template(self, name: str, **kwargs: Any) -> str:
+    async def _render_template(self, name: str, **kwargs: Any) -> str:
         """
         Async-compatible template rendering using Jinja2.
         """
diff --git a/examples/socketio/webtrc/app.py b/examples/socketio/webtrc/app.py
index 0cac768..4456f09 100644
--- a/examples/socketio/webtrc/app.py
+++ b/examples/socketio/webtrc/app.py
@@ -16,11 +16,11 @@ class MyApp(Server):
     async def stream(self, username: str):
         # Mark the username active, render the streamer template
         active_users.add(username)
-        return await self.render_template("stream.html", username=username)
+        return await self._render_template("stream.html", username=username)
 
     async def watch(self, username: str):
         # Mark the username active, render the watcher template
-        return await self.render_template("watch.html", username=username)
+        return await self._render_template("watch.html", username=username)
 
 #
 # ------------------- Socket.IO Events for Signaling --------------------
diff --git a/examples/twutr/twutr.py b/examples/twutr/twutr.py
index 4955df1..7e60afd 100644
--- a/examples/twutr/twutr.py
+++ b/examples/twutr/twutr.py
@@ -151,20 +151,20 @@ class Twutr(Server):
     async def index(self):
         """Shows the user's timeline (the messages of people they follow, including their own)."""
         if not self.request.session.get('logged_in'):
-            return self.redirect('/public')
+            return self._redirect('/public')
 
         user_id = self.request.session.get('user_id')
         all_messages = get_all_messages_for_user_and_following(user_id)
         all_messages = sort_messages_by_timestamp(all_messages, timestamp_index=2)
 
-        return await self.render_template('timeline.html', messages=all_messages, session=self.request.session)
+        return await self._render_template('timeline.html', messages=all_messages, session=self.request.session)
 
     async def public(self):
         """Displays the latest messages of all users with usernames."""
         all_messages = get_all_messages_from_all_users()
         all_messages = sort_messages_by_timestamp(all_messages, timestamp_index=2)
 
-        return await self.render_template('public.html', messages=all_messages, session=self.request.session)
+        return await self._render_template('public.html', messages=all_messages, session=self.request.session)
 
     async def user(self, username):
         """Displays a specific user's messages."""
@@ -189,7 +189,7 @@ class Twutr(Server):
             followers = user_data.get('followers', [])
             following_count = len(user_data.get('following', []))
 
-            return await self.render_template(
+            return await self._render_template(
                 'user.html',
                 messages=messages,
                 username=username,
@@ -204,7 +204,7 @@ class Twutr(Server):
     def follow(self, username):
         """Follow another user."""
         if not self.request.session.get('logged_in'):
-            return self.redirect('/login')
+            return self._redirect('/login')
 
         username = escape(username)
         current_user = self.request.session.get('user_id')
@@ -213,17 +213,17 @@ class Twutr(Server):
             return "You cannot follow yourself"
 
         update_follow_relationship(current_user, username, follow=True)
-        return self.redirect(f'/user/{username}')
+        return self._redirect(f'/user/{username}')
 
     def unfollow(self, username):
         """Unfollow another user."""
         if not self.request.session.get('logged_in'):
-            return self.redirect('/login')
+            return self._redirect('/login')
 
         current_user = self.request.session.get('user_id')
         update_follow_relationship(current_user, escape(username), follow=False)
 
-        return self.redirect(f'/user/{username}')
+        return self._redirect(f'/user/{username}')
 
     async def list_followers(self, username):
         """Displays the list of followers for a given user."""
@@ -233,7 +233,7 @@ class Twutr(Server):
             return "User not found", 404
 
         followers = user_data.get('followers', [])
-        return await self.render_template(
+        return await self._render_template(
             'list_followers.html',
             username=username,
             followers=followers,
@@ -248,7 +248,7 @@ class Twutr(Server):
             return "User not found", 404
 
         following = user_data.get('following', [])
-        return await self.render_template(
+        return await self._render_template(
             'list_following.html',
             username=username,
             following=following,
@@ -258,7 +258,7 @@ class Twutr(Server):
     def add_message(self):
         """Registers a new message for the logged-in user with custom link and mention handling."""
         if not self.request.session.get('logged_in'):
-            return self.redirect('/login')
+            return self._redirect('/login')
 
         if self.request.method == 'POST':
             message = self.request.body_params.get('message', [''])[0]
@@ -268,7 +268,7 @@ class Twutr(Server):
 
             # Prevent empty message submissions
             if not sanitized_message.strip():
-                return self.render_template('timeline.html', error="Message cannot be empty", session=self.request.session)
+                return self._render_template('timeline.html', error="Message cannot be empty", session=self.request.session)
 
             time_stamp = str(datetime.utcnow().strftime('%m/%d/%Y %I:%M %p'))
             message_tuple = (sanitized_message, time_stamp)
@@ -277,43 +277,43 @@ class Twutr(Server):
             user_data['messages'].append(message_tuple)
             save_user_data(self.request.session.get('user_id'), user_data)
 
-        return self.redirect('/')
+        return self._redirect('/')
 
     async def login(self):
         """Logs the user in."""
         if self.request.session.get('logged_in'):
-            return self.redirect('/')
+            return self._redirect('/')
 
         if self.request.method == 'POST':
             username = escape(self.request.body_params.get('username', [''])[0].strip())
             password = escape(self.request.body_params.get('password', [''])[0].strip())
 
             if not username or not password:
-                return await self.render_template('login.html', error="Fields cannot be empty", session=self.request.session)
+                return await self._render_template('login.html', error="Fields cannot be empty", session=self.request.session)
 
             user = get_user_data(username)
             if not user or user['password'] != password:
-                return await self.render_template('login.html', error="Invalid credentials", session=self.request.session)
+                return await self._render_template('login.html', error="Invalid credentials", session=self.request.session)
 
             self.request.session['user_id'] = username
             self.request.session['logged_in'] = True
-            return self.redirect('/')
+            return self._redirect('/')
 
-        return await self.render_template('login.html', session=self.request.session)
+        return await self._render_template('login.html', session=self.request.session)
 
     async def register(self):
         """Registers a new user."""
         if self.request.session.get('logged_in'):
-            return self.redirect('/')
+            return self._redirect('/')
 
         if self.request.method == 'POST':
             username = escape(self.request.body_params.get('username', [''])[0].strip())
             password = escape(self.request.body_params.get('password', [''])[0].strip())
 
             if not username or not password:
-                return await self.render_template('login.html', error="Fields cannot be empty", session=self.request.session)
+                return await self._render_template('login.html', error="Fields cannot be empty", session=self.request.session)
             if db.get(username):
-                return await self.render_template('register.html', session=self.request.session, error="Username already taken.")
+                return await self._render_template('register.html', session=self.request.session, error="Username already taken.")
 
             db.set(str(username), {
                 'username': username,
@@ -323,15 +323,15 @@ class Twutr(Server):
                 'following': []
             })
             db.save()
-            return self.redirect('/login')
+            return self._redirect('/login')
 
-        return await self.render_template('register.html', session=self.request.session)
+        return await self._render_template('register.html', session=self.request.session)
 
     def logout(self):
         """Logs the user out."""
         if self.request.session.get('logged_in'):
             self.request.session.clear()
-        return self.redirect('/public')
+        return self._redirect('/public')