Skip to content

Conversation

@nielsenko
Copy link
Collaborator

@nielsenko nielsenko commented Aug 14, 2025

Description

Static File Handler Overhaul

This PR reimplements the static file serving functionality to provide better HTTP compliance, improved performance, and enhanced security.

Key Improvements

HTTP Compliance & Standards

  • Full support for conditional requests (If-None-Match, If-Modified-Since)
  • Complete HTTP range request implementation including multipart ranges
  • Proper ETag generation using SHA-1 content hashing
  • Correct Last-Modified, Accept-Ranges, and Cache-Control headers
  • Proper HTTP method validation (405 responses for unsupported methods)

Performance Enhancements

  • LRU cache for file metadata to reduce filesystem operations
  • Efficient streaming with proper content-length handling
  • Smart MIME type detection using both file extensions and magic numbers

Security & Reliability

  • Removed directory listing functionality (security concern)
  • Enhanced symbolic link validation to prevent directory traversal
  • Better path sanitization and validation
  • Proper handling of edge cases and malformed requests

API Changes

Simplified Configuration

  • Removed complex configuration options: serveFilesOutsidePath, defaultDocument, listDirectories, useHeaderBytesForContentType
  • Added streamlined options: defaultResponse, mimeResolver, cacheControl
  • New createFileHandler() function for serving single files

Smart Content-Type Handling

  • Default encoding is now null for binary files, utf8 only for text files
  • Default MIME type changed from text/plain to application/octet-stream
  • Added MimeType.isText getter for better content type logic

Enhanced Routing Context

  • Added matchedPath and remainingPath properties to routing middleware
  • Better support for nested routing scenarios

Testing

  • Comprehensive test suite covering all HTTP scenarios
  • Tests for conditional requests, range requests, edge cases, and security
  • Removed tests for deprecated features (directory listings, default documents)

Dependencies

  • Added crypto package for ETag generation
  • Updated dev dependencies to latest versions

This overhaul brings Relic's static file serving up to production standards with proper HTTP semantics, making it suitable for serving static assets in web applications.

Related Issues

Pre-Launch Checklist

Please ensure that your PR meets the following requirements before submitting:

  • This update focuses on a single feature or bug fix. (For multiple fixes, please submit separate PRs.)
  • I have read and followed the Dart Style Guide and formatted the code using dart format.
  • I have referenced at least one issue this PR fixes or is related to.
  • I have updated/added relevant documentation (doc comments with ///), ensuring consistency with existing project documentation.
  • I have added new tests to verify the changes.
  • All existing and new tests pass successfully.
  • I have documented any breaking changes below.

Breaking Changes

  • Directory listing functionality removed entirely
  • createStaticHandler() parameters simplified - some options no longer available
  • Default MIME type and encoding behavior changed
  • createFileHandler() API simplified

Summary by CodeRabbit

  • New Features

    • Static file server: ETag/If-None-Match, If-Modified-Since, single/multipart Range support, configurable Cache-Control, improved MIME detection, 405 for unsupported methods (GET/HEAD allowed).
  • Refactor

    • Static handler rewritten with streaming, per-file metadata and stricter symlink/path behavior; default document is now served via a configurable default handler; body defaults favor binary (application/octet-stream) unless text mime detected; routing exposes matched and remaining path info.
  • Bug Fixes

    • Safer path handling; pathParameters returns empty map when no route matches.
  • Chores

    • Dependency updates (crypto, config, dev tooling).
  • Tests

    • Extensive tests added/updated for ranges, conditional requests, content types, cache-control, not-found and unsupported methods; directory-listing tests removed.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 14, 2025

Important

Review skipped

Auto reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbit review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

📝 Walkthrough

Walkthrough

Rewrote static file serving to use NewContext handlers with secure root resolution, per-path LRU file-info caching, ETag/Last-Modified/Cache-Control support, full Range (single & multipart) handling, MIME resolution via resolver, removed directory-listing, adjusted Body/MimeType defaults, and added routing context accessors and related tests.

Changes

Cohort / File(s) Summary
Static handler overhaul
lib/src/io/static/static_handler.dart
Replaced legacy static serving with NewContext-based handlers, secure root resolution, _FileInfo LRU cache, SHA‑1 ETag generation, conditional handling (If-None-Match / If-Modified-Since), full Range support (single + multipart), multipart boundary streaming, and new API signatures (createStaticHandler, createFileHandler) with mimeResolver and cacheControl.
Directory listing removal
lib/src/io/static/directory_listing.dart, test/static/directory_listing_test.dart
Removed HTML directory-listing implementation and corresponding tests.
Static tests added/updated
test/static/* (multiple files)
Added comprehensive tests for Cache-Control, Content-Type resolution, If-Modified-Since, If-None-Match, If-Range, Range edge cases, multipart ranges, not-found and symlink behavior, unsupported methods; updated tests to new APIs and mime defaults.
Create-file & default-handler test changes
test/static/create_file_handler_test.dart, test/static/default_document_test.dart
Tests migrated to Router/Pipeline routing model; replaced defaultDocument string API usage with defaultHandler (Handler) and adapted expectations.
Body / MimeType changes
lib/src/body/body.dart, lib/src/body/types/mime_type.dart
Changed Body.fromDataStream/fromData defaults: removed default utf8, default mimeType -> octet-stream; encoding inferred from mimeType.isText when unspecified. Added MimeType.isText, == and hashCode.
Routing middleware changes
lib/src/middleware/routing_middleware.dart, test/middleware/routing_middleware_test.dart
Added per-request routing context storing params/matched/remaining paths, decode path before lookup, new RequestContextEx getters matchedPath and remainingPath, updated no-match test to expect empty parameters.
Dependencies & misc
pubspec.yaml, benchmark/benchmark.dart
Added crypto and config deps, bumped cli_tools; imported package:config/config.dart in benchmark.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant C as Client
  participant H as StaticHandler
  participant Cache as FileInfoCache
  participant FS as FileSystem

  C->>H: GET/HEAD /path
  H->>H: normalize & resolve -> resolvedPath
  alt outside root or not a file
    H-->>C: 404 Not Found
  else
    H->>Cache: lookup resolvedPath
    alt cache miss
      H->>FS: stat & read header bytes
      FS-->>H: metadata (size,mtime,header bytes)
      H->>Cache: store _FileInfo (etag,mime,stat)
    end
    H->>H: check If-None-Match / If-Modified-Since
    alt not modified
      H-->>C: 304 Not Modified
    else
      H->>H: parse Range / If-Range
      alt single range
        H->>FS: stream bytes [start..end]
        FS-->>H: byte stream
        H-->>C: 206 Partial Content + Content-Range
      else multiple ranges
        loop per range
          H->>FS: stream segment
          FS-->>H: bytes
        end
        H-->>C: 206 multipart/byteranges (boundary)
      else no range
        H->>FS: stream full file
        FS-->>H: bytes
        H-->>C: 200 OK
      end
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60–90 minutes

Assessment against linked issues

Objective Addressed Explanation
Add ETag, Last-Modified, Cache-Control headers [#8]
Support partial content (Range requests) [#8]
Improve MIME type handling/resolution [#8]
Ensure resolved file path stays within root to prevent traversal [#116]

Assessment against linked issues: Out-of-scope changes

Code Change Explanation
Introduced per-request routing context and new getters (lib/src/middleware/routing_middleware.dart) Routing/context refactor is unrelated to static-file objectives (caching, ranges, MIME, path traversal).
Added package:config/config.dart import (benchmark/benchmark.dart) Benchmark import and dev-dependency changes do not relate to static-serving features or security fixes.
Removal of directory-listing implementation (lib/src/io/static/directory_listing.dart) Deleting directory-listing is not required by #8/#116 and is orthogonal to the stated enhancement/fix objectives.

Possibly related PRs

✨ Finishing Touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbit in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbit in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbit gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbit read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbit help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbit ignore or @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbit summary or @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbit or @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@nielsenko nielsenko self-assigned this Aug 14, 2025
@nielsenko nielsenko changed the title WIP: Support If-None-Match, If-Modified-Since, Range, and If-Range when serving static files feat: Support If-None-Match, If-Modified-Since, Range, and If-Range when serving static files Aug 14, 2025
@nielsenko nielsenko force-pushed the static-files branch 5 times, most recently from 813c988 to aff9012 Compare August 15, 2025 11:50
@nielsenko nielsenko force-pushed the static-files branch 3 times, most recently from 4a5e7e9 to 95156a1 Compare August 21, 2025 20:50
@codecov
Copy link

codecov bot commented Aug 21, 2025

Codecov Report

❌ Patch coverage is 96.82540% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.20%. Comparing base (7371b3d) to head (cae909a).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
lib/src/body/types/mime_type.dart 80.00% 3 Missing ⚠️
lib/src/middleware/routing_middleware.dart 84.61% 2 Missing ⚠️
lib/src/io/static/static_handler.dart 99.37% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #127      +/-   ##
==========================================
+ Coverage   83.40%   84.20%   +0.79%     
==========================================
  Files          84       83       -1     
  Lines        2676     2716      +40     
  Branches     1479     1480       +1     
==========================================
+ Hits         2232     2287      +55     
+ Misses        444      429      -15     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@nielsenko
Copy link
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 22, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
test/static/basic_file_test.dart (1)

153-171: Correct the If-Modified-Since > Last-Modified scenario setup.

This test duplicates the “equal” case; it should set If-Modified-Since to after the file’s last modified time to truly validate the “after” branch.

-      final headers =
-          Headers.build((final mh) => mh.ifModifiedSince = modified);
+      final headers = Headers.build(
+        (final mh) => mh.ifModifiedSince = modified.add(const Duration(seconds: 1)),
+      );
lib/src/middleware/routing_middleware.dart (1)

31-37: Fix late field initialization bug in constructor

ArgumentError.checkNotNull(_toHandler, ...) reads the late field before it’s set, causing a LateInitializationError when T is not a Handler and toHandler is null. Throw explicitly instead of reading the late field.

   _RoutingMiddlewareBuilder(
     this._router, {
     final Handler Function(T)? toHandler,
   }) {
     if (toHandler != null) {
       _toHandler = toHandler;
     } else if (_isSubtype<T, Handler>()) {
       _toHandler = (final x) => x as Handler;
-    }
-    ArgumentError.checkNotNull(_toHandler, 'toHandler');
+    } else {
+      throw ArgumentError(
+        'toHandler must be provided when T is not a Handler.',
+      );
+    }
   }
🧹 Nitpick comments (23)
lib/src/body/types/mime_type.dart (1)

71-72: Broaden text detection beyond only text/* (JSON, XML, JS, form-encoded are text-like).

Relying solely on primaryType == 'text' will treat application/json, application/xml, application/javascript, image/svg+xml (via +xml), and application/*+json|+xml as non-text. If isText informs charset/encoding decisions elsewhere (as the PR summary suggests), this may yield suboptimal defaults and headers for these widely used textual types.

Suggested improvement: expand isText to include common text-like application subtypes and structured syntax suffixes.

-  bool get isText => primaryType == 'text';
+  bool get isText {
+    if (primaryType == 'text') return true;
+    if (primaryType == 'application') {
+      final st = subType.toLowerCase();
+      // Common text-like app types and structured syntax suffixes.
+      return st == 'json' ||
+          st == 'xml' ||
+          st == 'javascript' ||
+          st == 'x-www-form-urlencoded' ||
+          st.endsWith('+json') ||
+          st.endsWith('+xml');
+    }
+    return false;
+  }
test/static/range_edge_cases_test.dart (2)

112-114: Fix test descriptions to match expected 206 status.

Both descriptions say “200 OK” but the assertions correctly expect HttpStatus.partialContent (206). Update the titles to avoid confusion in test output.

-        'then a 200 OK status is returned with correct content', () async {
+        'then a 206 Partial Content status is returned with correct content', () async {

Apply the same change to both tests (“last byte” and “first byte”).

Also applies to: 134-136


155-176: Consider asserting Accept-Ranges for partial responses.

To fully validate Range semantics, add an assertion that Accept-Ranges is present and set to bytes for 206 responses. This catches regressions where the header is omitted.

Example (adjust to your headers API):

expect(response.headers.value('accept-ranges'), 'bytes');
// or, if typed:
expect(response.headers.acceptRanges, 'bytes');

Also applies to: 178-199, 202-224, 226-247

test/static/symbolic_link_test.dart (3)

5-5: Remove now-unused internal import.

package:relic/src/io/static/static_handler.dart appears unused after commenting out the “serveFilesOutsidePath” tests and relying on the public API. Drop it to avoid analyzer warnings.

-import 'package:relic/src/io/static/static_handler.dart';

49-50: Replace temporary skips with tracked context or conditional platform skips.

skip: 'TMP' is opaque and easy to forget. Either:

  • tie the skip message to a tracking issue/PR (“Skipped pending symlink semantics stabilization – see PR #127”), or
  • convert to onPlatform if it’s platform-specific like the Windows case above.

Example:

-        skip: 'TMP',
+        skip: 'Temporarily disabled pending symlink resolution semantics; see PR #127.',

Also applies to: 84-86


95-165: Remove dead tests for deprecated serveFilesOutsidePath.

Given the option was removed in this PR, keeping the whole group commented adds noise. Safer to delete to avoid confusion.

-/*
-  group('Given access outside of root is enabled', () {
-    ...
-  });
-  */
test/middleware/routing_middleware_test.dart (1)

77-85: Update test description to match new semantics (no exception; returns empty map).

The test name still claims a StateError is thrown, but the assertion now expects an empty map via ctx.pathParameters. Align the description with behavior.

-          'Then the next handler is called and pathParameters access throws StateError',
+          'Then the next handler is called and pathParameters is empty',
test/static/basic_file_test.dart (1)

71-79: Fix mismatch between test name and exercised path (encoded vs. unencoded space).

The test description promises an “unencoded space” path, but the request uses %20. Exercise an actual unencoded space to validate routing and URL handling.

-    final response = await makeRequest(handler, '/files/with%20space.txt');
+    final response = await makeRequest(handler, '/files/with space.txt');

If makeRequest requires a pre-encoded URL, consider adjusting the description instead to avoid false signaling.

test/static/unsupported_methods_test.dart (1)

18-106: Good coverage for 405 + Allow; consider DRYing repeated assertions.

The four tests are nearly identical except for the method. Extract a tiny helper to reduce duplication and ease future additions (e.g., PATCH).

// Add near the top of the group:
Future<void> expectMethodNotAllowedFor(Handler handler, String path, RequestMethod method) async {
  final response = await makeRequest(handler, path, method: method);
  expect(response.statusCode, HttpStatus.methodNotAllowed);
  expect(response.body.contentLength, 0);
  expect(await response.readAsString(), isEmpty);
  final allow = response.headers.allow;
  expect(allow, isNotNull);
  expect(allow, containsAll({RequestMethod.get, RequestMethod.head}));
  expect(allow!.length, 2);
}

// Then replace body of each test with:
await expectMethodNotAllowedFor(handler, '/test_file.txt', RequestMethod.post);
test/static/not_found_test.dart (1)

97-123: Guard the symlink test for platforms without symlink support (Windows).

Creating symlinks may fail on Windows or restricted environments. Mark the test as skipped on Windows to avoid flakiness.

-    test(
+    test(
         'Given a symbolic link pointing outside the served directory, '
         'when the request is made, '
         'then a 404 Not Found status is returned', () async {
@@
-    });
+    }, skip: Platform.isWindows);
test/static/if_modified_since_test.dart (1)

18-35: Add coverage for If-None-Match vs If-Modified-Since precedence

HTTP requires If-None-Match to take precedence over If-Modified-Since. Add a test where both headers are present and ETag matches, expecting 304 regardless of the IMS date.

I can draft a companion test that sets both headers and asserts 304. Want me to add it?

test/static/if_none_match_test.dart (2)

18-35: Add wildcard If-None-Match (“*”) case

When the resource exists and If-None-Match is "*", the server should return 304 Not Modified (for safe methods). Add a test to lock in this behavior.

You can add something like:

test('If-None-Match: * yields 304 when resource exists', () async {
  final headers = Headers.build(
    (mh) => mh.ifNoneMatch = const IfNoneMatchHeader.any(),
  );
  final response = await makeRequest(handler, '/test_file.txt', headers: headers);

  expect(response.statusCode, HttpStatus.notModified);
  expect(response.body.contentLength, 0);
  expect(await response.readAsString(), isEmpty);
});

37-53: Consider explicit strong/weak ETag coverage

ETag generation here is presumably strong. It’s useful to add one test asserting behavior with a weak ETag in If-None-Match (e.g., W/"...") to ensure comparison semantics are correct.

Happy to draft a weak-ETag test tailored to the current header types if desired.

test/static/if_range_test.dart (2)

69-71: Assert Content-Range metadata on 206 responses

You already verify status and payload. Adding checks for Content-Range strengthens correctness (start, end, and total size), and guards regressions in range calculations.

If the response exposes headers.contentRange, I can add precise assertions for bytes 0-4/16.


74-91: Add coverage for multi-range + If-Range behavior

Multi-part ranges should behave identically wrt If-Range: return 206 multipart when the validator matches, otherwise 200 with full body. One small test here will close that gap.

test/static/content_type_test.dart (1)

29-76: Optional: Add SVG and WASM cases

Two practical additions: image/svg+xml and application/wasm. These help catch common asset-type regressions.

I can append tests for /icon.svg and /module.wasm with appropriate expected MIME types.

lib/src/middleware/routing_middleware.dart (2)

42-49: Decode path once; current approach is fine, but consider NormalizedPath.from decoded string

You decode with Uri.decodeFull before lookup, which is good. If NormalizedPath offers a canonicalizer from raw path, using it consistently here could simplify future changes. Not required.


50-52: Avoid redundant await on return

No try/catch here; returning the Future directly avoids a micro-async hop.

-        return await handler(ctx);
+        return handler(ctx);
test/static/range_test.dart (3)

6-6: Remove commented-out import statement.

The commented-out import for path package should be removed since it's not being used.

-//import 'package:path/path.dart' as p;

133-152: Consider simplifying the boundary extraction logic.

The _extractBoundary function could be made more concise using regular expressions or simpler string manipulation.

-String? _extractBoundary(final String contentType) {
-  // Split the Content-Type header by ';' to get individual parameters
-  final parts = contentType.split(';');
-
-  // Find the part that starts with 'boundary='
-  final boundaryPart = parts.firstWhere(
-    (final part) => part.trim().startsWith('boundary='),
-    orElse: () => '',
-  );
-
-  if (boundaryPart.isEmpty) {
-    return null;
-  }
-
-  // Extract the boundary string itself and remove any surrounding quotes
-  final boundary =
-      boundaryPart.trim().substring('boundary='.length).replaceAll('"', '');
-
-  return boundary;
-}
+String? _extractBoundary(final String contentType) {
+  final match = RegExp(r'boundary=([^;]+)').firstMatch(contentType);
+  return match?.group(1)?.replaceAll('"', '').trim();
+}

10-130: Consider adding edge case tests for range requests.

While the basic range request scenarios are covered, consider adding tests for:

  • Invalid ranges (e.g., start > end)
  • Out-of-bounds ranges (e.g., start beyond file size)
  • Overlapping ranges in multipart requests
  • Empty range list

Would you like me to generate additional test cases for these edge scenarios to improve test coverage?

lib/src/io/static/static_handler.dart (2)

17-28: Consider making the LRU cache size configurable.

The file info cache is hard-coded to 10,000 entries. For applications serving many files or with memory constraints, it would be beneficial to make this configurable.

Consider adding a static setter or configuration parameter:

class StaticHandlerConfig {
  static int cacheSize = 10000;
}

final _fileInfoCache = LruCache<String, _FileInfo>(StaticHandlerConfig.cacheSize);

273-277: The assert statement on Line 275 may not execute in production.

Assert statements are typically removed in production builds. If this is meant to catch an invalid state in production, consider throwing an exception instead.

-  // Unreachable, ifRange parser would throw on first access
-  assert(false, 'Invalid If-Range header');
-  return false;
+  // This should be unreachable as ifRange parser would throw on first access
+  throw StateError('Invalid If-Range header: missing both ETag and Last-Modified');
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 7371b3d and 306860d.

📒 Files selected for processing (22)
  • benchmark/benchmark.dart (1 hunks)
  • lib/src/body/body.dart (2 hunks)
  • lib/src/body/types/mime_type.dart (1 hunks)
  • lib/src/io/static/directory_listing.dart (0 hunks)
  • lib/src/io/static/static_handler.dart (1 hunks)
  • lib/src/middleware/routing_middleware.dart (4 hunks)
  • pubspec.yaml (2 hunks)
  • test/middleware/routing_middleware_test.dart (1 hunks)
  • test/static/basic_file_test.dart (2 hunks)
  • test/static/cache_control_test.dart (1 hunks)
  • test/static/content_type_test.dart (1 hunks)
  • test/static/create_file_handler_test.dart (0 hunks)
  • test/static/default_document_test.dart (0 hunks)
  • test/static/directory_listing_test.dart (0 hunks)
  • test/static/if_modified_since_test.dart (1 hunks)
  • test/static/if_none_match_test.dart (1 hunks)
  • test/static/if_range_test.dart (1 hunks)
  • test/static/not_found_test.dart (1 hunks)
  • test/static/range_edge_cases_test.dart (1 hunks)
  • test/static/range_test.dart (1 hunks)
  • test/static/symbolic_link_test.dart (4 hunks)
  • test/static/unsupported_methods_test.dart (1 hunks)
💤 Files with no reviewable changes (4)
  • test/static/directory_listing_test.dart
  • test/static/default_document_test.dart
  • test/static/create_file_handler_test.dart
  • lib/src/io/static/directory_listing.dart
🔇 Additional comments (15)
test/static/range_edge_cases_test.dart (3)

21-63: Good negative coverage for malformed Range headers.

The three malformed-input cases look solid and will help prevent silent acceptances of invalid headers. Nice.


65-108: Edge-case coverage is on point (start beyond EOF, suffix > file, end < start).

These assertions align with RFC 7233/9110 expectations. The suffix-range test validating a 206 with length-capped body is especially valuable.


250-266: Nice handling for empty range-list.

Explicitly verifying that an empty range list yields 200 with full content (and no Content-Range) is a useful guard against edge-case regressions.

pubspec.yaml (1)

13-13: Dependencies align with the new functionality.

  • Adding crypto: ^3.0.0 makes sense for ETag (SHA-1) generation.
  • Updating cli_tools and adding config track the new benchmark import.

No issues from me. If you haven’t already, run dart pub outdated locally to confirm no surprising constraints with lints and mime upper-bounds.

Also applies to: 25-26

benchmark/benchmark.dart (1)

9-9: Import of config is correct and matches the enum-based option API.

The types Configuration, OptionDefinition, and ConfigOptionBase resolve cleanly with config ^0.7.x. Looks good.

test/static/basic_file_test.dart (2)

227-229: Migration away from useHeaderBytesForContentType looks correct.

Dropping the deprecated flag in favor of default behavior is consistent with the new API that performs MIME detection via extensions and magic numbers.

Please confirm that createStaticHandler now enables magic-number detection by default for extensionless files. If it’s behind a feature flag or resolver-only path, prefer making the test explicit by injecting a resolver (like below) to avoid regressions when defaults change.

-  final handler = createStaticHandler(
-    d.sandbox, /* useHeaderBytesForContentType: true */
-  );
+  final handler = createStaticHandler(
+    d.sandbox,
+    // Ensure header-bytes (magic number) detection remains explicit in tests
+    mimeResolver: mime.MimeTypeResolver()..addMagicNumber([0x89, 0x50, 0x4E, 0x47], 'image/png'),
+  );

251-253: LGTM: switched to mimeResolver API.

Using mimeResolver with an explicit magic-number definition for WebP is clear and robust.

test/static/cache_control_test.dart (1)

18-70: LGTM: Cache-Control configuration is exercised well.

Tests cover explicit directives, default absence, and no-store. This gives confidence in the header serialization and optional behavior.

lib/src/middleware/routing_middleware.dart (1)

61-69: Nice: safe defaults for new getters

Returning {} / NormalizedPath.empty / decoded current path when no route matched makes the extension ergonomics solid.

lib/src/body/body.dart (2)

69-81: The change to encoding behavior is well-designed.

The automatic UTF-8 encoding for text MIME types while leaving binary data unencoded is a sensible default that aligns with HTTP standards and typical use cases.


84-95: Consistent encoding logic between fromDataStream and fromData.

The encoding resolution logic is consistent across both factory methods, which maintains API predictability.

lib/src/io/static/static_handler.dart (4)

66-74: Good security implementation for symbolic link validation.

The check ensures that symbolic links cannot escape the root directory, preventing path traversal attacks.


182-186: Consider chunked hashing for large files.

The current ETag generation reads the entire file into memory. For very large files, this could cause memory issues.

The current implementation using sha1.bind(file.openRead()) is actually stream-based and efficient. No change needed.


333-370: Excellent implementation of multipart range responses.

The multipart/byteranges implementation correctly handles multiple ranges with proper boundaries, headers, and content sections according to HTTP specifications. The streaming approach with StreamController is efficient for handling large files.


246-249: All If-Range scenarios are covered by existing tests

The codebase includes comprehensive tests for both ETag-based and Last-Modified-based If-Range validation in header parsing and static file handling. No further action is required.

@nielsenko
Copy link
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 25, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
lib/src/io/static/static_handler.dart (1)

373-393: Existing concern resolved: Range calculation is correct.

The range boundary calculation correctly handles both regular ranges and suffix-length requests as per HTTP specifications. The clamping ensures bounds stay within file size limits.

🧹 Nitpick comments (3)
lib/src/io/static/static_handler.dart (3)

17-28: Consider making the LRU cache size configurable.

The hardcoded cache size of 10,000 entries might not be optimal for all deployments. Consider making this configurable to allow users to tune memory usage based on their specific needs.

Add a configuration parameter:

+/// Default LRU cache size for file information.
+const int _defaultFileInfoCacheSize = 10000;
+
 /// Cached file information including MIME type, file stats, and ETag.
 class _FileInfo {
   final MimeType? mimeType;
   final FileStat stat;
   final String etag;

   const _FileInfo(this.mimeType, this.stat, this.etag);
 }

 /// LRU cache for file information to avoid repeated file system operations.
-final _fileInfoCache = LruCache<String, _FileInfo>(10000);
+LruCache<String, _FileInfo> _fileInfoCache = LruCache<String, _FileInfo>(_defaultFileInfoCacheSize);
+
+/// Sets the maximum number of file info entries to cache.
+/// This should be called before creating any static handlers.
+void setFileInfoCacheSize(int size) {
+  _fileInfoCache = LruCache<String, _FileInfo>(size);
+}

183-186: Consider chunked hashing for very large files.

The current ETag generation reads the entire file into memory to compute the SHA-1 hash. For very large files, this could cause memory issues. While the streaming API is used, the entire hash computation still requires processing the full file.

For production environments with large files, consider implementing a more efficient ETag strategy:

 /// Generates an ETag for the file using SHA-1 hash.
 Future<String> _generateETag(final File file) async {
-  final hash = await sha1.bind(file.openRead()).last;
-  return hash.toString();
+  final stat = file.statSync();
+  // For large files, use size and modification time for ETag
+  if (stat.size > 100 * 1024 * 1024) { // 100MB threshold
+    return '${stat.size}-${stat.modified.millisecondsSinceEpoch}';
+  }
+  // For smaller files, use SHA-1 hash
+  final hash = await sha1.bind(file.openRead()).last;
+  return hash.toString();
 }

59-74: Add tests for valid symlinks pointing inside the root

We have comprehensive tests for “symlink pointing outside the served directory” in test/static/not_found_test.dart (around lines 97–119), but no coverage for the case where a symlink points inside the root directory. To fully exercise this security check and prevent regressions:

• Add a test under the same group('Given symbolic links (if supported)', …) that
– Creates a symlink inside the sandbox pointing to a file also inside d.sandbox
– Issues a request for that symlink path
– Asserts a successful (200-OK) or expected file response

• (Optional) Extend coverage with nested symlink scenarios and directory symlinks

This will ensure that safe, in-root symlinks continue to work while still blocking external ones.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 306860d and 734326c.

📒 Files selected for processing (16)
  • lib/src/body/types/mime_type.dart (1 hunks)
  • lib/src/io/static/static_handler.dart (1 hunks)
  • test/middleware/routing_middleware_test.dart (1 hunks)
  • test/static/basic_file_test.dart (3 hunks)
  • test/static/cache_control_test.dart (1 hunks)
  • test/static/content_type_test.dart (1 hunks)
  • test/static/create_file_handler_test.dart (0 hunks)
  • test/static/default_document_test.dart (0 hunks)
  • test/static/if_modified_since_test.dart (1 hunks)
  • test/static/if_none_match_test.dart (1 hunks)
  • test/static/if_range_test.dart (1 hunks)
  • test/static/not_found_test.dart (1 hunks)
  • test/static/range_edge_cases_test.dart (1 hunks)
  • test/static/range_test.dart (1 hunks)
  • test/static/symbolic_link_test.dart (4 hunks)
  • test/static/unsupported_methods_test.dart (1 hunks)
💤 Files with no reviewable changes (2)
  • test/static/create_file_handler_test.dart
  • test/static/default_document_test.dart
🚧 Files skipped from review as they are similar to previous changes (11)
  • test/static/range_test.dart
  • test/static/unsupported_methods_test.dart
  • test/static/content_type_test.dart
  • test/static/if_modified_since_test.dart
  • test/middleware/routing_middleware_test.dart
  • test/static/if_none_match_test.dart
  • test/static/range_edge_cases_test.dart
  • test/static/cache_control_test.dart
  • test/static/if_range_test.dart
  • test/static/not_found_test.dart
  • test/static/symbolic_link_test.dart
🔇 Additional comments (8)
lib/src/body/types/mime_type.dart (2)

71-84: LGTM! Well-designed text classification with comprehensive coverage.

The isText getter correctly identifies text MIME types using primary type checking and handles common text-like application subtypes including JSON, XML, and structured syntax suffixes (+json, +xml). The implementation aligns well with the encoding determination logic in the static file handler.


92-99: LGTM! Proper equality and hash code implementation.

The equality operator and hash code are correctly implemented for value equality based on primary and sub types. This enables proper MIME type comparisons and caching in collections like the LRU cache used in the static file handler.

lib/src/io/static/static_handler.dart (3)

113-140: LGTM! Well-structured file serving with proper HTTP semantics.

The _serveFile function provides excellent separation of concerns: method validation, caching, conditional requests, range requests, and full file serving. The flow is clear and follows HTTP specifications correctly.


243-258: LGTM! Comprehensive range request handling.

The range request handler properly validates If-Range headers and elegantly handles single vs. multiple ranges using pattern matching. The fallback to full file serving for invalid ranges is correct per HTTP specifications.


406-410: Null MIME type handling resolved.

The previous issue with the non-null assertion operator on fileInfo.mimeType! has been fixed. The code now properly handles null MIME types with a fallback to MimeType.octetStream.

test/static/basic_file_test.dart (3)

71-79: LGTM! Test validates unencoded space handling.

The test change correctly validates that the handler can process request paths with unencoded spaces, which is important for URL handling flexibility.


218-225: LGTM! Test aligns with new default MIME type behavior.

The test correctly expects application/octet-stream for unknown file extensions, which aligns with the new secure default behavior in the static handler.


229-231: LGTM! Test parameter updates align with API changes.

The tests have been properly updated to use the new mimeResolver parameter instead of the removed contentTypeResolver and useHeaderBytesForContentType parameters. The commented-out parameters serve as helpful documentation of the API change.

Also applies to: 253-254

@nielsenko
Copy link
Collaborator Author

@coderabbitai Add an issue to make LRU cache size configurable. Will revisit this later

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 25, 2025

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

I'll create an issue to track making the LRU cache size configurable for the static file handler.

@nielsenko I've created an issue to track making the LRU cache size configurable for the static file handler. The issue includes context from the current PR implementation and your request, and has been assigned to you for future consideration.


🏁 Script executed:

#!/bin/bash
gh issue create \
  --title "Make LRU cache size configurable for static file handler" \
  --body "## Description

The static file handler introduced in PR #127 uses a hardcoded LRU cache size of 10,000 entries for caching file metadata (_FileInfo objects containing MimeType, FileStat, and ETag).

## Current Implementation

The cache is currently hardcoded:
\`\`\`dart
static final _fileInfoCache = LruCache<String, _FileInfo>(10000);
\`\`\`

## Requested Enhancement

Make the cache size configurable through the \`createStaticHandler\` function parameters, allowing users to:
- Adjust cache size based on their specific use case and memory constraints
- Disable caching entirely if needed (cache size = 0)
- Set appropriate cache sizes for different deployment scenarios

## Context

- **PR**: #127 
- **Comment**: https://github.com/serverpod/relic/pull/127#issuecomment-$(gh api repos/serverpod/relic/pulls/127/comments --jq '.[-1].id')
- **Requested by**: @nielsenko

## Acceptance Criteria

- [ ] Add optional \`cacheSize\` parameter to \`createStaticHandler\`
- [ ] Support cache size of 0 to disable caching
- [ ] Maintain backward compatibility with reasonable default cache size
- [ ] Update documentation with cache configuration guidance" \
  --assignee nielsenko

Length of output: 1312


@nielsenko Perfect! I've successfully created issue #131 to track making the LRU cache size configurable for the static file handler.

Issue created: #131

The issue includes all the relevant context from PR #127, references your comment, and outlines the enhancement request with clear acceptance criteria. It's been assigned to you for future implementation when you're ready to revisit this improvement.

@nielsenko nielsenko force-pushed the static-files branch 2 times, most recently from 81be31a to f0e442f Compare August 25, 2025 10:12
…rving static files

Allow user to specify Cache-Control header to use

Ensure you can always read matchedPath, remainingPath, and pathParameters on RequestContext, even if no routing setup.
Remaining will be the normalized version of the full path of the request. The others will be empty

Handle url decoding in routing

Upgrade cli_tools dep

Set correct mimetype, if possible

Allow single files to be served as well

Add tests for static_handler
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
test/static/create_file_handler_test.dart (1)

132-146: Duplicate test case detected.

Lines 132-146 and 148-162 appear to be testing the same scenario - both check that a range request with start > end returns a 416 status.

🧹 Nitpick comments (3)
lib/src/io/static/static_handler.dart (3)

27-27: Consider making the LRU cache size configurable.

The hardcoded cache size of 10,000 entries may not be optimal for all use cases. For applications serving many files or with memory constraints, this could be problematic.

Since an issue (#131) has already been created to track this, would you like me to provide an implementation that makes the cache size configurable through a parameter or environment variable?


183-186: Consider performance implications of computing SHA-1 on every cache miss.

Computing SHA-1 hash of entire files on cache misses could cause performance issues for large files. Consider using file metadata (size + modified time) for ETag generation instead, which is a common practice.

For better performance with large files, consider generating ETags from file metadata:

-/// Generates an ETag for the file using SHA-1 hash.
-Future<String> _generateETag(final File file) async {
-  final hash = await sha1.bind(file.openRead()).last;
-  return hash.toString();
+/// Generates an ETag for the file using metadata.
+Future<String> _generateETag(final File file) async {
+  final stat = file.statSync();
+  // Use size and modified time for ETag generation
+  final data = '${stat.size}-${stat.modified.millisecondsSinceEpoch}';
+  final hash = sha1.convert(utf8.encode(data));
+  return hash.toString();
}

121-123: Consider explicit handling of unsupported methods.

While the code correctly returns 405 for unsupported methods, consider adding support for OPTIONS method to enable CORS preflight requests, which is commonly needed for static file servers.

Consider extending method support for OPTIONS:

 /// Checks if the HTTP method is allowed for file serving.
 bool _isMethodAllowed(final RequestMethod method) {
-  return method == RequestMethod.get || method == RequestMethod.head;
+  return method == RequestMethod.get || 
+         method == RequestMethod.head ||
+         method == RequestMethod.options;
 }

And handle OPTIONS in the main handler:

   // Validate HTTP method
   final method = ctx.request.method;
-  if (!_isMethodAllowed(method)) return _methodNotAllowedResponse(ctx);
+  if (method == RequestMethod.options) {
+    return ctx.withResponse(Response.ok(
+      headers: Headers.build((final mh) => mh.allow = [
+        RequestMethod.get,
+        RequestMethod.head,
+        RequestMethod.options,
+      ]),
+    ));
+  }
+  if (!_isMethodAllowed(method)) return _methodNotAllowedResponse(ctx);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 734326c and dae808f.

📒 Files selected for processing (22)
  • benchmark/benchmark.dart (1 hunks)
  • lib/src/body/body.dart (2 hunks)
  • lib/src/body/types/mime_type.dart (1 hunks)
  • lib/src/io/static/directory_listing.dart (0 hunks)
  • lib/src/io/static/static_handler.dart (1 hunks)
  • lib/src/middleware/routing_middleware.dart (4 hunks)
  • pubspec.yaml (2 hunks)
  • test/middleware/routing_middleware_test.dart (1 hunks)
  • test/static/basic_file_test.dart (3 hunks)
  • test/static/cache_control_test.dart (1 hunks)
  • test/static/content_type_test.dart (1 hunks)
  • test/static/create_file_handler_test.dart (7 hunks)
  • test/static/default_document_test.dart (5 hunks)
  • test/static/directory_listing_test.dart (0 hunks)
  • test/static/if_modified_since_test.dart (1 hunks)
  • test/static/if_none_match_test.dart (1 hunks)
  • test/static/if_range_test.dart (1 hunks)
  • test/static/not_found_test.dart (1 hunks)
  • test/static/range_edge_cases_test.dart (1 hunks)
  • test/static/range_test.dart (1 hunks)
  • test/static/symbolic_link_test.dart (1 hunks)
  • test/static/unsupported_methods_test.dart (1 hunks)
💤 Files with no reviewable changes (2)
  • test/static/directory_listing_test.dart
  • lib/src/io/static/directory_listing.dart
🚧 Files skipped from review as they are similar to previous changes (16)
  • pubspec.yaml
  • benchmark/benchmark.dart
  • test/static/if_none_match_test.dart
  • test/static/if_range_test.dart
  • lib/src/body/types/mime_type.dart
  • lib/src/body/body.dart
  • test/static/range_test.dart
  • test/middleware/routing_middleware_test.dart
  • test/static/unsupported_methods_test.dart
  • test/static/not_found_test.dart
  • test/static/range_edge_cases_test.dart
  • lib/src/middleware/routing_middleware.dart
  • test/static/default_document_test.dart
  • test/static/basic_file_test.dart
  • test/static/if_modified_since_test.dart
  • test/static/symbolic_link_test.dart
🔇 Additional comments (5)
lib/src/io/static/static_handler.dart (2)

68-74: Security validation looks good!

The symbolic link validation correctly prevents path traversal attacks by ensuring resolved paths stay within the root directory. The implementation properly handles both regular files and symbolic links.


218-235: Conditional header handling is well-implemented.

The implementation correctly handles both If-None-Match (with wildcard support) and If-Modified-Since headers according to HTTP specifications.

test/static/content_type_test.dart (2)

36-50: Comprehensive MIME type test coverage.

Good coverage of common file types including images, web assets, and WebAssembly. The parameterized test structure makes it easy to add more MIME types as needed.


95-119: Charset handling tests are thorough.

The tests correctly verify that text files include UTF-8 charset while binary files don't, which aligns with HTTP best practices.

test/static/cache_control_test.dart (1)

18-42: Well-structured Cache-Control tests.

The tests properly verify that Cache-Control headers are set correctly with various directives including max-age, public/private cache, and must-revalidate.

@nielsenko nielsenko marked this pull request as ready for review August 26, 2025 06:38
@nielsenko nielsenko requested review from a team and SandPod August 26, 2025 06:38
@SandPod SandPod self-assigned this Aug 26, 2025
Copy link
Contributor

@SandPod SandPod left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Big PR -> a lot of questions.

Overall, it looks good. This will be a solid addition. Would be nice to know what of this is not supported by shelf today.

I had a couple of questions that I wanted to discuss. Some things might be better discussed during a call.

There are also a couple of test fix suggestions.

@nielsenko
Copy link
Collaborator Author

nielsenko commented Aug 26, 2025

... Would be nice to know what of this is not supported by shelf today. ...

  • If-None-Modified request header. The server will hand out ..
  • ETag response headers. We support strong SHA1 etags cached in an ..
  • LRU cache for quick lookup of file metadata (_FileInfo(this.mimeType, this.stat, this.etag)).
  • Multi-range Range headers. Shelf only support single-range request headers
  • If-Range (both etag and lastModified),
  • Proper verb validation (no PUT, POST, etc), with ..
  • Allow response header.
  • Remember to set Accept-Ranges, Last-Modified, ETag header on all replies, not just sometimes.
  • Better error handling in general (in particular around 416).
  • Use typed headers.
  • Higher test coverage (99%+).
  • Maybe more 😄

We do not support linking outside root, which can be enabled in shelf with serveFilesOutsidePath = true.

Copy link
Contributor

@SandPod SandPod left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! 🚀

@nielsenko nielsenko changed the title feat: Support If-None-Match, If-Modified-Since, Range, and If-Range when serving static files refactor: Static File Handler Overhaul Aug 28, 2025
@nielsenko nielsenko merged commit 8d5dbd9 into serverpod:main Aug 28, 2025
26 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Oct 6, 2025
7 tasks
@coderabbitai coderabbitai bot mentioned this pull request Oct 29, 2025
8 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Path traversal vulnerability in static file serving Enhance Support for Serving Static Files

2 participants