Skip to content

Commit

Permalink
Add SimpleStaticFileServer with test coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
joshuatz committed Nov 3, 2024
1 parent eaa3bb8 commit 21a19ca
Show file tree
Hide file tree
Showing 2 changed files with 233 additions and 1 deletion.
113 changes: 112 additions & 1 deletion django_utils_lib/requests.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,115 @@
from typing import Dict, Optional
import re
from typing import Dict, Final, List, Optional, Union

import pydantic
from django.conf import settings
from django.contrib.auth.views import redirect_to_login
from django.http import (
FileResponse,
HttpRequest,
HttpResponse,
HttpResponseForbidden,
HttpResponseNotAllowed,
HttpResponseNotFound,
)
from django.urls import re_path
from django.views.static import serve


class SimpleStaticFileServerConfig(pydantic.BaseModel):
auth_required_path_patterns: Optional[List[re.Pattern]] = pydantic.Field(default=None)
"""
A list of URL path patterns that, if matched, should require that the request
be from an authenticated user
"""
forbidden_path_patterns: Optional[List[re.Pattern]] = pydantic.Field(default=None)
"""
A list of URL path patterns that, if matched, should result in a Forbidden response
"""
block_bare_html_access: bool = pydantic.Field(default=True)
"""
Whether or not paths that point directly to HTML files should be blocked.
For example, a settings of `True` would block `/dir/index.html`, but `/dir/`
would be permissible (unless blocked by a different access rule)
"""


class SimpleStaticFileServer:
"""
This is a class to help with serving static files directly with
Django.
For a more robust solution, one should look to using a CDN and/or
standalone static file server with a reverse-proxy in place.
"""

DJANGO_SETTINGS_KEY: Final = "SIMPLE_STATIC_FILE_SERVER_CONFIG"

def __init__(self, config: Optional[SimpleStaticFileServerConfig]) -> None:
if not config:
config = getattr(settings, SimpleStaticFileServer.DJANGO_SETTINGS_KEY, None)
try:
validated_config = SimpleStaticFileServerConfig.model_validate(config)
self.config = validated_config
except pydantic.ValidationError as err:
if config is None:
# Use default config
self.config = SimpleStaticFileServerConfig()
else:
raise ValueError(f"Invalid config for SimpleStaticFileServer: {err}")

def guard_path(self, request: HttpRequest, url_path: str) -> Optional[HttpResponse]:
"""
Checks if a given URL path should have its normal resolution interrupted,
based on the configuration
Returns `None` if the processing chain should be continued as-is, or returns
a `HttpResponse` if the chain should end and the response immediately sent back
"""
# Check for bare access first, since this should be the fastest check
if self.config.block_bare_html_access and url_path.endswith(".html"):
return HttpResponseNotFound()
# Check explicit block list
if any(pattern.search(url_path) for pattern in self.config.forbidden_path_patterns or []):
return HttpResponseForbidden()
# Check for attempted access to an auth-required path from a non-authed user
if self.config.auth_required_path_patterns and not request.user.is_authenticated:
if any(pattern.search(url_path) for pattern in self.config.auth_required_path_patterns):
return redirect_to_login(next=request.get_full_path())

# Pass request forward / noop
return None

def serve_static_path(
self, request: HttpRequest, asset_path: Optional[str] = None
) -> Union[HttpResponse, FileResponse]:
"""
This should be close to a drop-in replacement for `serve` (it wraps it),
with config-based logic for how to handle the request
"""
if request.method not in ["GET", "HEAD", "OPTIONS"]:
return HttpResponseNotAllowed(["GET", "HEAD", "OPTIONS"])
url_path = asset_path or request.path
if (response := self.guard_path(request, url_path)) is not None:
return response
return serve(request, document_root=str(settings.STATIC_ROOT), path=url_path)

def generate_url_patterns(self, ignore_start_strings: Optional[List[str]] = None):
"""
Generates some pattern matchers you can stick in `urlpatterns` (albeit greedy). Should go last.
"""
ignore_start_strings = ignore_start_strings or ["/static/", "/media/"]
negate_start_pattern = "".join([f"(?!{s})" for s in ignore_start_strings])
return [
# Capture paths with extensions, and pass through as-is
re_path(rf"^{negate_start_pattern}(?P<asset_path>.*\..*)$", self.serve_static_path),
# For extension-less paths, try to map to an `index.html`
re_path(
r"^(?P<asset_path>.+/$)",
lambda request, asset_path: self.serve_static_path(request, f"{asset_path}/index.html"),
),
]


def object_to_multipart_dict(obj: Dict, existing_multipart_dict: Optional[dict] = None, key_prefix="") -> Dict:
Expand Down
121 changes: 121 additions & 0 deletions django_utils_lib/tests/test_simplestaticfileserver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import re
from typing import List, Optional, Type, TypedDict, Union, cast
from unittest import mock

import pytest
from django.contrib.auth.models import AnonymousUser
from django.http import FileResponse, HttpResponse, HttpResponseForbidden, HttpResponseNotFound, HttpResponseRedirect
from django.test import RequestFactory

from django_utils_lib.requests import SimpleStaticFileServer, SimpleStaticFileServerConfig


class StaticFileServerTestCase(TypedDict):
config: Optional[SimpleStaticFileServerConfig]
request_paths: List[str]
expected_responses: List[Union[Type[HttpResponse], Type[FileResponse]]]


@pytest.mark.parametrize(
"scenario",
[
# No custom config used - class should use defaults
StaticFileServerTestCase(
config=None,
request_paths=["/index.html", "/music/song.html", "/app/"],
expected_responses=[
# Default config should block bare HTML
HttpResponseNotFound,
HttpResponseNotFound,
# No default auth blocks
FileResponse,
],
),
StaticFileServerTestCase(
config=SimpleStaticFileServerConfig(
block_bare_html_access=False,
forbidden_path_patterns=[re.compile(r"/dogs/.*"), re.compile(r"^/dogs/.*")],
),
request_paths=["/dir/index.html", "/index.html", "/dir/adoption/dogs/fido.html"],
expected_responses=[FileResponse, FileResponse, HttpResponseForbidden],
),
StaticFileServerTestCase(
{
"config": SimpleStaticFileServerConfig(
block_bare_html_access=True,
forbidden_path_patterns=[re.compile(r"sourcemap.js")],
auth_required_path_patterns=[re.compile(r"^/app/")],
),
"request_paths": ["/dir/index.html", "/index.html", "/hello/", "/app/"],
"expected_responses": [HttpResponseNotFound, HttpResponseNotFound, FileResponse, HttpResponseRedirect],
}
),
],
)
@mock.patch("django_utils_lib.requests.redirect_to_login", return_value=HttpResponseRedirect(""))
@mock.patch("django_utils_lib.requests.serve", return_value=FileResponse())
def test_path_guarding(
mock_serve: mock.Mock,
mock_redirect_to_login: mock.Mock,
rf: RequestFactory,
scenario: StaticFileServerTestCase,
):
assert len(scenario["expected_responses"]) == len(scenario["request_paths"])
server = SimpleStaticFileServer(config=scenario["config"])

def check_response(
response: Union[FileResponse, HttpResponse], expected_response: Union[Type[FileResponse], Type[HttpResponse]]
):
assert isinstance(response, expected_response), url_path

# Check that the final action taken by the middleware was correct,
# and the right functions were called
assert mock_serve.called is (True if issubclass(expected_response, FileResponse) else False), url_path
if issubclass(expected_response, HttpResponseRedirect):
assert mock_redirect_to_login.called is True
# Check `next` param was used correctly
assert mock_redirect_to_login.call_args.kwargs.get("next") == url_path

else:
assert mock_redirect_to_login.called is False
assert mock_redirect_to_login.called is (True if issubclass(expected_response, HttpResponseRedirect) else False)

mock_serve.reset_mock()

for url_path, expected_response in zip(scenario["request_paths"], scenario["expected_responses"]):
mock_request = rf.get(url_path)
mock_request.user = AnonymousUser()

check_response(server.serve_static_path(request=mock_request, asset_path=url_path), expected_response)

# Based on request alone
check_response(server.serve_static_path(request=mock_request), expected_response)


@pytest.mark.parametrize(
"ignore_start_strings, expected_patterns",
[
(
None,
[
re.compile(r"^(?!/static/)(?!/media/)(?P<asset_path>.*\..*)$"),
re.compile(r"^(?P<asset_path>.+/$)"),
],
),
(
["/assets/", "/files/"],
[
re.compile(r"^(?!/assets/)(?!/files/)(?P<asset_path>.*\..*)$"),
re.compile(r"^(?P<asset_path>.+/$)"),
],
),
],
)
def test_generate_url_patterns(ignore_start_strings, expected_patterns):
server = SimpleStaticFileServer(config=None)
patterns = server.generate_url_patterns(ignore_start_strings=ignore_start_strings)

assert len(patterns) == len(expected_patterns)
for url_pattern, expected_pattern in zip(patterns, expected_patterns):
re_pattern = cast(re.Pattern, url_pattern.pattern.regex)
assert re_pattern.pattern == expected_pattern.pattern

0 comments on commit 21a19ca

Please sign in to comment.