diff --git a/.gitignore b/.gitignore index 30b8c52..1e6c213 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ doc/api/ coverage.lcov coverage bin + +.cursorrules diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c730e8..31a57bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# Changelog + +## 1.1.2 +- Added support for resumable downloads (RFC9110 compliant) + - Implemented range requests with single and multiple ranges + - Added support for conditional requests with ETags and Last-Modified dates + - Added proper handling of If-Range header + - Added support for suffix ranges and multipart/byteranges responses + ## 1.1.1 - Adding additional crash protection diff --git a/README.md b/README.md index 788eedd..8256197 100644 --- a/README.md +++ b/README.md @@ -550,6 +550,13 @@ However the browser will probably try to render it in browser, and not download You can just set the right headers, but there is a handy little helper that will do it all for you. +The file handler fully supports resumable downloads (RFC9110 compliant), including: +- Range requests for partial content +- Multiple range requests with multipart responses +- Conditional requests using ETags and Last-Modified dates +- Proper handling of If-Range header +- Support for suffix ranges (e.g., last 500 bytes) + See `res.setDownload` below. ```dart diff --git a/lib/src/type_handlers/file_type_handler.dart b/lib/src/type_handlers/file_type_handler.dart index 365478f..a377b6d 100644 --- a/lib/src/type_handlers/file_type_handler.dart +++ b/lib/src/type_handlers/file_type_handler.dart @@ -5,12 +5,187 @@ import '../extensions/response_helpers.dart'; import 'type_handler.dart'; TypeHandler get fileTypeHandler => - TypeHandler((HttpRequest req, HttpResponse res, File file) async { - if (file.existsSync()) { + TypeHandler((HttpRequest req, HttpResponse res, dynamic file) async { + file = file as File; + if (!file.existsSync()) { + throw NotFoundError(); + } + + final fileStats = await file.stat(); + final lastModified = fileStats.modified.toUtc(); + final totalSize = fileStats.size; + + // Generate ETag using size and mtime (strong validator) + final etag = '"${totalSize}_${lastModified.millisecondsSinceEpoch}"'; + + // Set common headers + res.headers.add('accept-ranges', 'bytes'); + res.headers.add('etag', etag); + res.headers.add('last-modified', HttpDate.format(lastModified)); + + // Handle conditional requests (If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since) + final ifMatch = req.headers.value('if-match'); + final ifNoneMatch = req.headers.value('if-none-match'); + final ifModifiedSince = req.headers.value('if-modified-since'); + final ifUnmodifiedSince = req.headers.value('if-unmodified-since'); + + if (ifMatch != null && + ifMatch != '*' && + !ifMatch.split(',').map((e) => e.trim()).contains(etag)) { + res.statusCode = HttpStatus.preconditionFailed; + return res.close(); + } + + if (ifNoneMatch != null) { + if (ifNoneMatch == '*' || + ifNoneMatch.split(',').map((e) => e.trim()).contains(etag)) { + if (req.method == 'GET' || req.method == 'HEAD') { + res.statusCode = HttpStatus.notModified; + return res.close(); + } else { + res.statusCode = HttpStatus.preconditionFailed; + return res.close(); + } + } + } + + if (ifModifiedSince != null) { + try { + final modifiedSince = HttpDate.parse(ifModifiedSince); + if (!lastModified.isAfter(modifiedSince)) { + res.statusCode = HttpStatus.notModified; + return res.close(); + } + } catch (_) { + // Invalid date format, ignore the header + } + } + + if (ifUnmodifiedSince != null) { + try { + final unmodifiedSince = HttpDate.parse(ifUnmodifiedSince); + if (lastModified.isAfter(unmodifiedSince)) { + res.statusCode = HttpStatus.preconditionFailed; + return res.close(); + } + } catch (_) { + // Invalid date format, ignore the header + } + } + + final rangeHeader = req.headers.value('range'); + if (rangeHeader == null || !rangeHeader.startsWith('bytes=')) { res.setContentTypeFromFile(file); await res.addStream(file.openRead()); return res.close(); + } + + // Check If-Range header (RFC9110 Section 13.1.3) + final ifRangeHeader = req.headers.value('if-range'); + if (ifRangeHeader != null) { + bool useRanges = false; + if (ifRangeHeader.startsWith('"') || ifRangeHeader.startsWith('W/"')) { + // It's an ETag + useRanges = ifRangeHeader == etag; + } else { + // It's a Last-Modified date + try { + final rangeDate = HttpDate.parse(ifRangeHeader); + useRanges = !lastModified.isAfter(rangeDate); + } catch (_) { + useRanges = false; + } + } + + if (!useRanges) { + res.setContentTypeFromFile(file); + await res.addStream(file.openRead()); + return res.close(); + } + } + + // Parse range header (format: bytes=range1,range2,...) + final ranges = rangeHeader + .substring(6) + .split(',') + .map((range) { + final parts = range.trim().split('-'); + if (parts.length != 2) return null; + + final startStr = parts[0]; + final endStr = parts[1]; + + try { + int? start, end; + if (startStr.isEmpty) { + // Suffix range (-500) + start = totalSize - int.parse(endStr); + end = totalSize - 1; + if (start < 0) start = 0; + } else { + start = int.parse(startStr); + end = endStr.isEmpty ? totalSize - 1 : int.parse(endStr); + } + + // Validate range values + if (start > end || start < 0 || end >= totalSize) { + return null; + } + + return _Range(start, end); + } catch (e) { + return null; + } + }) + .where((range) => range != null) + .toList(); + + if (ranges.isEmpty) { + res.statusCode = HttpStatus.requestedRangeNotSatisfiable; + res.headers.add('content-range', 'bytes */$totalSize'); + return res.close(); + } + + if (ranges.length == 1) { + // Single range request + final range = ranges[0]!; + final contentLength = range.end - range.start + 1; + + res.statusCode = HttpStatus.partialContent; + res.setContentTypeFromFile(file); + res.headers.add( + 'content-range', 'bytes ${range.start}-${range.end}/$totalSize'); + res.headers.add('content-length', contentLength.toString()); + + await res.addStream(file.openRead(range.start, range.end + 1)); + return res.close(); } else { - throw NotFoundError(); + // Multiple ranges - use multipart/byteranges + final boundary = 'alfred-${DateTime.now().millisecondsSinceEpoch}'; + res.statusCode = HttpStatus.partialContent; + res.headers + .add('content-type', 'multipart/byteranges; boundary=$boundary'); + + final contentType = + res.headers.contentType?.toString() ?? 'application/octet-stream'; + + for (final range in ranges) { + res.write('\r\n--$boundary\r\n'); + res.write('content-type: $contentType\r\n'); + res.write( + 'content-range: bytes ${range!.start}-${range.end}/$totalSize\r\n\r\n'); + + await res.addStream(file.openRead(range.start, range.end + 1)); + } + + res.write('\r\n--$boundary--\r\n'); + return res.close(); } }); + +class _Range { + final int start; + final int end; + + _Range(this.start, this.end); +} diff --git a/pubspec.yaml b/pubspec.yaml index 43d1657..e244204 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: alfred description: A performant, expressjs like web server / rest api framework thats easy to use and has all the bits in one place. -version: 1.1.1 +version: 1.1.2 repository: https://github.com/rknell/alfred environment: diff --git a/test/alfred_test.dart b/test/alfred_test.dart index c7b0de8..7551b7d 100644 --- a/test/alfred_test.dart +++ b/test/alfred_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:alfred/alfred.dart'; import 'package:http/http.dart' as http; @@ -36,6 +37,132 @@ void main() { app.get('/test', (req, res) => File('test/files/image.jpg')); final response = await http.get(Uri.parse('http://localhost:$port/test')); expect(response.headers['content-type'], 'image/jpeg'); + expect(response.bodyBytes.length, greaterThan(0)); + }); + + test('it should support range requests for files', () async { + final file = File('test/files/image.jpg'); + final totalSize = await file.length(); + + app.get('/test', (req, res) => file); + + // Test that server advertises range support + final response = await http.get(Uri.parse('http://localhost:$port/test')); + expect(response.headers['accept-ranges'], 'bytes'); + expect(response.statusCode, 200); + expect(response.bodyBytes.length, totalSize); + + // Test partial content request + final rangeResponse = await http.get( + Uri.parse('http://localhost:$port/test'), + headers: {'Range': 'bytes=0-9'}, + ); + expect(rangeResponse.statusCode, 206); + expect(rangeResponse.headers['content-range'], 'bytes 0-9/$totalSize'); + expect(rangeResponse.headers['content-length'], '10'); + expect(rangeResponse.bodyBytes.length, 10); + + // Test multiple ranges request + final multiRangeResponse = await http.get( + Uri.parse('http://localhost:$port/test'), + headers: {'Range': 'bytes=0-9,20-29,40-49'}, + ); + expect(multiRangeResponse.statusCode, 206); + expect(multiRangeResponse.headers['content-type'], + contains('multipart/byteranges')); + + // Test If-Range with matching ETag + final etag = response.headers['etag']; + final ifRangeResponse = await http.get( + Uri.parse('http://localhost:$port/test'), + headers: { + 'Range': 'bytes=0-9', + 'If-Range': etag ?? '', + }, + ); + expect(ifRangeResponse.statusCode, 206); + + // Test If-Range with non-matching ETag (should return full file) + final ifRangeFullResponse = await http.get( + Uri.parse('http://localhost:$port/test'), + headers: { + 'Range': 'bytes=0-9', + 'If-Range': '"non-matching-etag"', + }, + ); + expect(ifRangeFullResponse.statusCode, 200); + expect(ifRangeFullResponse.bodyBytes.length, totalSize); + + // Test overlapping ranges + final overlapResponse = await http.get( + Uri.parse('http://localhost:$port/test'), + headers: {'Range': 'bytes=0-10,5-15'}, + ); + expect(overlapResponse.statusCode, 206); + expect(overlapResponse.headers['content-type'], + contains('multipart/byteranges')); + + // Test invalid range request + final invalidResponse = await http.get( + Uri.parse('http://localhost:$port/test'), + headers: {'Range': 'bytes=10-5'}, + ); + expect(invalidResponse.statusCode, 416); + expect(invalidResponse.headers['content-range'], 'bytes */$totalSize'); + + // Test unsatisfiable range + final unsatisfiableResponse = await http.get( + Uri.parse('http://localhost:$port/test'), + headers: {'Range': 'bytes=${totalSize + 1}-${totalSize + 10}'}, + ); + expect(unsatisfiableResponse.statusCode, 416); + expect( + unsatisfiableResponse.headers['content-range'], 'bytes */$totalSize'); + }); + + test('it should handle end of file range requests', () async { + final file = File('test/files/image.jpg'); + final totalSize = await file.length(); + + app.get('/test', (req, res) => file); + + // Test range request for last 10 bytes + final response = await http.get( + Uri.parse('http://localhost:$port/test'), + headers: {'Range': 'bytes=${totalSize - 10}-${totalSize - 1}'}, + ); + expect(response.statusCode, 206); + expect(response.headers['content-range'], + 'bytes ${totalSize - 10}-${totalSize - 1}/$totalSize'); + expect(response.headers['content-length'], '10'); + expect(response.bodyBytes.length, 10); + + // Test suffix range request + final suffixResponse = await http.get( + Uri.parse('http://localhost:$port/test'), + headers: {'Range': 'bytes=-500'}, + ); + expect(suffixResponse.statusCode, 206); + final expectedLength = min(500, totalSize); + expect(suffixResponse.headers['content-length'], expectedLength.toString()); + }); + + test('it should handle open-ended range requests', () async { + final file = File('test/files/image.jpg'); + final totalSize = await file.length(); + + app.get('/test', (req, res) => file); + + // Test range request from specific byte to end + final response = await http.get( + Uri.parse('http://localhost:$port/test'), + headers: {'Range': 'bytes=10-'}, + ); + expect(response.statusCode, 206); + expect(response.headers['content-range'], + 'bytes 10-${totalSize - 1}/$totalSize'); + expect(response.headers['content-length'], '${totalSize - 10}'); + expect(response.bodyBytes.length, totalSize - 10); }); test('it should return a pdf', () async { diff --git a/tool/templates/README.md b/tool/templates/README.md index 0717588..ac5b23b 100644 --- a/tool/templates/README.md +++ b/tool/templates/README.md @@ -236,6 +236,13 @@ However the browser will probably try to render it in browser, and not download You can just set the right headers, but there is a handy little helper that will do it all for you. +The file handler fully supports resumable downloads (RFC9110 compliant), including: +- Range requests for partial content +- Multiple range requests with multipart responses +- Conditional requests using ETags and Last-Modified dates +- Proper handling of If-Range header +- Support for suffix ranges (e.g., last 500 bytes) + See `res.setDownload` below. @code example/example_file_downloads.dart