Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Advanced ETag Comparison now supported #3015

Merged
merged 23 commits into from Apr 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6392916
Update FileIO.swift
linus-hologram May 4, 2023
e041d35
Merge branch 'vapor:main' into main
linus-hologram May 4, 2023
baa7fc4
Introduced new streamFile method
linus-hologram May 14, 2023
b36e2db
Merge branch 'main' of https://github.com/linus-hologram/vapor
linus-hologram May 14, 2023
19b942e
Updated Unit Tests
linus-hologram May 14, 2023
7933b99
Added more documentation
linus-hologram May 14, 2023
e2c3e4e
Removed unnecessary CryptoKit import
linus-hologram May 15, 2023
f554ecc
Merge branch 'main' into main
linus-hologram May 15, 2023
09507c1
added closure return types
linus-hologram May 15, 2023
1f6e0aa
Merge branch 'main' of https://github.com/linus-hologram/vapor
linus-hologram May 15, 2023
7236cc7
Merge branch 'vapor:main' into main
linus-hologram May 20, 2023
f192a76
incorporated first round of @0xTim's feedback
linus-hologram May 20, 2023
37f16d1
added test cases to account for advanced/simple etags
linus-hologram May 25, 2023
c0207f4
Merge branch 'vapor:main' into main
linus-hologram May 25, 2023
e83c3c8
Merge branch 'main' into main
linus-hologram Jun 15, 2023
798e40d
Incorporated PR comments
linus-hologram Apr 19, 2024
0dc971d
Added test for legacy streamFile
linus-hologram Apr 19, 2024
7badd00
Merge branch 'main' of https://github.com/vapor/vapor
linus-hologram Apr 19, 2024
8631e42
Deprecated method to silence warnings
linus-hologram Apr 19, 2024
23a36b3
Warning fixes
linus-hologram Apr 19, 2024
0a9b49f
following @gwynne's style advice :)
linus-hologram Apr 19, 2024
52bbc35
Merge branch 'main' into main
0xTim Apr 19, 2024
49869ae
Merge branch 'main' into main
gwynne Apr 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 3 additions & 3 deletions Sources/Vapor/HTTP/Headers/HTTPHeaders+ContentRange.swift
Expand Up @@ -5,7 +5,7 @@ extension HTTPHeaders {

/// The unit in which `ContentRange`s and `Range`s are specified. This is usually `bytes`.
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range
public enum RangeUnit: Equatable {
public enum RangeUnit: Sendable, Equatable {
case bytes
case custom(value: String)

Expand All @@ -21,7 +21,7 @@ extension HTTPHeaders {

/// Represents the HTTP `Range` request header.
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range
public struct Range: Equatable {
public struct Range: Sendable, Equatable {
public let unit: RangeUnit
public let ranges: [HTTPHeaders.Range.Value]

Expand Down Expand Up @@ -134,7 +134,7 @@ extension HTTPHeaders.Range {
/// Represents one value of the `Range` request header.
///
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range
public enum Value: Equatable {
public enum Value: Sendable, Equatable {
///Integer with single trailing dash, e.g. `25-`
case start(value: Int)
///Integer with single leading dash, e.g. `-25`
Expand Down
21 changes: 16 additions & 5 deletions Sources/Vapor/Middleware/FileMiddleware.swift
Expand Up @@ -9,7 +9,8 @@ public final class FileMiddleware: Middleware {
private let publicDirectory: String
private let defaultFile: String?
private let directoryAction: DirectoryAction

private let advancedETagComparison: Bool

public struct BundleSetupError: Equatable, Error {

/// The description of this error.
Expand All @@ -22,17 +23,28 @@ public final class FileMiddleware: Middleware {
public static let publicDirectoryIsNotAFolder: Self = .init(description: "Cannot find any actual folder for the given Public Directory")
}

struct ETagHashes: StorageKey {
public typealias Value = [String: FileHash]

public struct FileHash {
let lastModified: Date
let digestHex: String
}
}

/// Creates a new `FileMiddleware`.
///
/// - parameters:
/// - publicDirectory: The public directory to serve files from.
/// - defaultFile: The name of the default file to look for and serve if a request hits any public directory. Starting with `/` implies
/// an absolute path from the public directory root. If `nil`, no default files are served.
/// - directoryAction: Determines the action to take when the request doesn't have a trailing slash but matches a directory.
public init(publicDirectory: String, defaultFile: String? = nil, directoryAction: DirectoryAction = .none) {
/// - advancedETagComparison: The method used when ETags are generated. If true, a byte-by-byte hash is created (and cached), otherwise a simple comparison based on the file's last modified date and size.
public init(publicDirectory: String, defaultFile: String? = nil, directoryAction: DirectoryAction = .none, advancedETagComparison: Bool = true) {
self.publicDirectory = publicDirectory.addTrailingSlash()
self.defaultFile = defaultFile
self.directoryAction = directoryAction
self.advancedETagComparison = advancedETagComparison
}

public func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture<Response> {
Expand Down Expand Up @@ -88,10 +100,9 @@ public final class FileMiddleware: Middleware {
return next.respond(to: request)
}
}

// stream the file
let res = request.fileio.streamFile(at: absPath)
return request.eventLoop.makeSucceededFuture(res)
return request.fileio.streamFile(at: absPath, advancedETagComparison: advancedETagComparison)
}

/// Creates a new `FileMiddleware` for a server contained in an Xcode Project.
Expand Down
153 changes: 151 additions & 2 deletions Sources/Vapor/Utilities/FileIO.swift
Expand Up @@ -3,6 +3,7 @@ import NIOCore
import NIOHTTP1
import NIOPosix
import Logging
import Crypto
import NIOConcurrencyHelpers

extension Request {
Expand Down Expand Up @@ -122,6 +123,7 @@ public struct FileIO: Sendable {
/// - mediaType: HTTPMediaType, if not specified, will be created from file extension.
/// - onCompleted: Closure to be run on completion of stream.
/// - returns: A `200 OK` response containing the file stream and appropriate headers.
@available(*, deprecated, message: "Use the new `streamFile` method which returns EventLoopFuture<Response>")
@preconcurrency public func streamFile(
at path: String,
chunkSize: Int = NonBlockingFileIO.defaultChunkSize,
Expand Down Expand Up @@ -157,7 +159,7 @@ public struct FileIO: Sendable {

// Respond with lastModified header
headers.lastModified = HTTPHeaders.LastModified(value: modifiedAt)

// Generate ETag value, "HEX value of last modified date" + "-" + "file size"
let fileETag = "\"\(modifiedAt.timeIntervalSince1970)-\(fileSize)\""
headers.replaceOrAdd(name: .eTag, value: fileETag)
Expand Down Expand Up @@ -218,10 +220,137 @@ public struct FileIO: Sendable {
onCompleted(result)
}
}, count: byteCount, byteBufferAllocator: request.byteBufferAllocator)

return response
}

/// Generates a chunked `Response` for the specified file. This method respects values in
linus-hologram marked this conversation as resolved.
Show resolved Hide resolved
/// the `"ETag"` header and is capable of responding `304 Not Modified` if the file in question
/// has not been modified since last served. If `advancedETagComparison` is set to true,
/// the response will have its ETag field set to a byte-by-byte hash of the requested file. If set to false, a simple ETag consisting of the last modified date and file size
/// will be used. This method will also set the `"Content-Type"` header
/// automatically if an appropriate `MediaType` can be found for the file's suffix.
///
/// router.get("file-stream") { req in
/// return req.fileio.streamFile(at: "/path/to/file.txt")
/// }
///
/// - parameters:
/// - path: Path to file on the disk.
/// - chunkSize: Maximum size for the file data chunks.
/// - mediaType: HTTPMediaType, if not specified, will be created from file extension.
/// - advancedETagComparison: The method used when ETags are generated. If true, a byte-by-byte hash is created (and cached), otherwise a simple comparison based on the file's last modified date and size.
/// - onCompleted: Closure to be run on completion of stream.
/// - returns: A `200 OK` response containing the file stream and appropriate headers.
public func streamFile(
at path: String,
chunkSize: Int = NonBlockingFileIO.defaultChunkSize,
mediaType: HTTPMediaType? = nil,
advancedETagComparison: Bool,
onCompleted: @escaping @Sendable (Result<Void, Error>) -> () = { _ in }
) -> EventLoopFuture<Response> {
// Get file attributes for this file.
guard
let attributes = try? FileManager.default.attributesOfItem(atPath: path),
let modifiedAt = attributes[.modificationDate] as? Date,
let fileSize = (attributes[.size] as? NSNumber)?.intValue
else {
return request.eventLoop.makeSucceededFuture(Response(status: .internalServerError))
}

let contentRange: HTTPHeaders.Range?
if let rangeFromHeaders = request.headers.range {
if rangeFromHeaders.unit == .bytes && rangeFromHeaders.ranges.count == 1 {
contentRange = rangeFromHeaders
} else {
contentRange = nil
}
} else if request.headers.contains(name: .range) {
// Range header was supplied but could not be parsed i.e. it was invalid
request.logger.debug("Range header was provided in request but was invalid")
let response = Response(status: .badRequest)
return request.eventLoop.makeSucceededFuture(response)
} else {
contentRange = nil
}

var eTagFuture: EventLoopFuture<String>

if advancedETagComparison {
eTagFuture = generateETagHash(path: path, lastModified: modifiedAt)
} else {
// Generate ETag value, "last modified date in epoch time" + "-" + "file size"
eTagFuture = request.eventLoop.makeSucceededFuture("\"\(modifiedAt.timeIntervalSince1970)-\(fileSize)\"")
}

return eTagFuture.map { fileETag in
// Create empty headers array.
var headers: HTTPHeaders = [:]

// Respond with lastModified header
headers.lastModified = HTTPHeaders.LastModified(value: modifiedAt)

headers.replaceOrAdd(name: .eTag, value: fileETag)

// Check if file has been cached already and return NotModified response if the etags match
if fileETag == request.headers.first(name: .ifNoneMatch) {
// Per RFC 9110 here: https://www.rfc-editor.org/rfc/rfc9110.html#status.304
// and here: https://www.rfc-editor.org/rfc/rfc9110.html#name-content-encoding
// A 304 response MUST include the ETag header and a Content-Length header matching what the original resource's content length would have been were this a 200 response.
headers.replaceOrAdd(name: .contentLength, value: fileSize.description)
return Response(status: .notModified, version: .http1_1, headersNoUpdate: headers, body: .empty)
}

// Create the HTTP response.
let response = Response(status: .ok, headers: headers)
let offset: Int64
let byteCount: Int
if let contentRange = contentRange {
response.status = .partialContent
response.headers.add(name: .accept, value: contentRange.unit.serialize())
if let firstRange = contentRange.ranges.first {
do {
let range = try firstRange.asResponseContentRange(limit: fileSize)
response.headers.contentRange = HTTPHeaders.ContentRange(unit: contentRange.unit, range: range)
(offset, byteCount) = try firstRange.asByteBufferBounds(withMaxSize: fileSize, logger: request.logger)
} catch {
let response = Response(status: .badRequest)
return response
}
} else {
offset = 0
byteCount = fileSize
}
} else {
offset = 0
byteCount = fileSize
}
// Set Content-Type header based on the media type
// Only set Content-Type if file not modified and returned above.
if
let fileExtension = path.components(separatedBy: ".").last,
let type = mediaType ?? HTTPMediaType.fileExtension(fileExtension)
{
response.headers.contentType = type
}
response.body = .init(stream: { stream in
self.read(path: path, fromOffset: offset, byteCount: byteCount, chunkSize: chunkSize) { chunk in
return stream.write(.buffer(chunk))
}.whenComplete { result in
switch result {
case .failure(let error):
stream.write(.error(error), promise: nil)
case .success:
stream.write(.end, promise: nil)
}
onCompleted(result)
}
}, count: byteCount, byteBufferAllocator: request.byteBufferAllocator)

return response
}
}

/// Private read method. `onRead` closure uses ByteBuffer and expects future return.
/// There may be use in publicizing this in the future for reads that must be async.
private func read(
Expand Down Expand Up @@ -279,6 +408,26 @@ public struct FileIO: Sendable {
}
}
}

/// Generates a fresh ETag for a file or returns its currently cached one.
/// - Parameters:
/// - path: The file's path.
/// - lastModified: When the file was last modified.
/// - Returns: An `EventLoopFuture<String>` which holds the ETag.
private func generateETagHash(path: String, lastModified: Date) -> EventLoopFuture<String> {
if let hash = request.application.storage[FileMiddleware.ETagHashes.self]?[path], hash.lastModified == lastModified {
return request.eventLoop.makeSucceededFuture(hash.digestHex)
} else {
return collectFile(at: path).map { buffer in
let digest = SHA256.hash(data: buffer.readableBytesView)

// update hash in dictionary
request.application.storage[FileMiddleware.ETagHashes.self]?[path] = FileMiddleware.ETagHashes.FileHash(lastModified: lastModified, digestHex: digest.hex)

return digest.hex
}
}
}
}

extension HTTPHeaders.Range.Value {
Expand Down