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 10 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
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")
}

public struct ETagHashes: StorageKey {
linus-hologram marked this conversation as resolved.
Show resolved Hide resolved
public typealias Value = [String: FileHash]

public struct FileHash {
var lastModified: Date
var digest: SHA256Digest
}
}

/// 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 @@ -86,10 +98,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
157 changes: 155 additions & 2 deletions Sources/Vapor/Utilities/FileIO.swift
Expand Up @@ -3,6 +3,8 @@ import NIOCore
import NIOHTTP1
import NIOPosix
import Logging
import Crypto
import NIOConcurrencyHelpers

extension Request {
public var fileio: FileIO {
Expand Down Expand Up @@ -121,6 +123,7 @@ public struct FileIO {
/// - 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>")
public func streamFile(
at path: String,
chunkSize: Int = NonBlockingFileIO.defaultChunkSize,
Expand Down Expand Up @@ -156,7 +159,7 @@ public struct FileIO {

// 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 @@ -215,10 +218,134 @@ public struct FileIO {
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. 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 (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
}
// Create empty headers array.
var headers: HTTPHeaders = [:]

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

var eTagFuture: EventLoopFuture<String>

if advancedETagComparison {
eTagFuture = generateETagHash(path: path, lastModified: modifiedAt)
} else {
// Generate ETag value, "HEX value of last modified date" + "-" + "file size"
linus-hologram marked this conversation as resolved.
Show resolved Hide resolved
eTagFuture = request.eventLoop.makeSucceededFuture("\"\(modifiedAt.timeIntervalSince1970)-\(fileSize)\"")
}

return eTagFuture.map { fileETag in
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 @@ -265,11 +392,37 @@ public struct FileIO {
done.whenComplete { _ in
try? fd.close()
}

return done
} catch {
return self.request.eventLoop.makeFailedFuture(error)
}
}

/// 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> {
return NIOLock().withLock {
linus-hologram marked this conversation as resolved.
Show resolved Hide resolved
var hashingDictionary = request.application.storage[FileMiddleware.ETagHashes.self] ?? [:]

if let hash = hashingDictionary[path], hash.lastModified == lastModified {
return request.eventLoop.makeSucceededFuture(hash.digest.hex)
} else {
return collectFile(at: path).map { buffer in
let digest = SHA256.hash(data: buffer.readableBytesView)

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

return digest.hex
}
}
}
}
}

extension HTTPHeaders.Range.Value {
Expand Down
40 changes: 20 additions & 20 deletions Tests/VaporTests/FileTests.swift
Expand Up @@ -9,8 +9,8 @@ final class FileTests: XCTestCase {
let app = Application(.testing)
defer { app.shutdown() }

app.get("file-stream") { req in
return req.fileio.streamFile(at: #file) { result in
app.get("file-stream") { req -> EventLoopFuture<Response> in
linus-hologram marked this conversation as resolved.
Show resolved Hide resolved
return req.fileio.streamFile(at: #file, advancedETagComparison: true) { result in
do {
try result.get()
} catch {
Expand All @@ -30,8 +30,8 @@ final class FileTests: XCTestCase {
let app = Application(.testing)
defer { app.shutdown() }

app.get("file-stream") { req in
return req.fileio.streamFile(at: #file)
app.get("file-stream") { req -> EventLoopFuture<Response> in
return req.fileio.streamFile(at: #file, advancedETagComparison: true)
}

var headers = HTTPHeaders()
Expand All @@ -47,13 +47,13 @@ final class FileTests: XCTestCase {
let app = Application(.testing)
defer { app.shutdown() }

app.get("file-stream") { req -> Response in
app.get("file-stream") { req -> EventLoopFuture<Response> in
var tmpPath: String
repeat {
tmpPath = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).path
} while (FileManager.default.fileExists(atPath: tmpPath))

return req.fileio.streamFile(at: tmpPath) { result in
return req.fileio.streamFile(at: tmpPath, advancedETagComparison: true) { result in
do {
try result.get()
XCTFail("File Stream should have failed")
Expand All @@ -71,8 +71,8 @@ final class FileTests: XCTestCase {
let app = Application(.testing)
defer { app.shutdown() }

app.get("file-stream") { req in
return req.fileio.streamFile(at: #file) { result in
app.get("file-stream") { req -> EventLoopFuture<Response> in
return req.fileio.streamFile(at: #file, advancedETagComparison: true) { result in
do {
try result.get()
} catch {
Expand Down Expand Up @@ -103,8 +103,8 @@ final class FileTests: XCTestCase {
let app = Application(.testing)
defer { app.shutdown() }

app.get("file-stream") { req in
return req.fileio.streamFile(at: #file) { result in
app.get("file-stream") { req -> EventLoopFuture<Response> in
return req.fileio.streamFile(at: #file, advancedETagComparison: true) { result in
do {
try result.get()
} catch {
Expand Down Expand Up @@ -135,8 +135,8 @@ final class FileTests: XCTestCase {
let app = Application(.testing)
defer { app.shutdown() }

app.get("file-stream") { req in
return req.fileio.streamFile(at: #file) { result in
app.get("file-stream") { req -> EventLoopFuture<Response> in
return req.fileio.streamFile(at: #file, advancedETagComparison: true) { result in
do {
try result.get()
} catch {
Expand Down Expand Up @@ -167,8 +167,8 @@ final class FileTests: XCTestCase {
let app = Application(.testing)
defer { app.shutdown() }

app.get("file-stream") { req in
return req.fileio.streamFile(at: #file) { result in
app.get("file-stream") { req -> EventLoopFuture<Response> in
return req.fileio.streamFile(at: #file, advancedETagComparison: true) { result in
do {
try result.get()
} catch {
Expand All @@ -193,8 +193,8 @@ final class FileTests: XCTestCase {
let app = Application(.testing)
defer { app.shutdown() }

app.get("file-stream") { req in
return req.fileio.streamFile(at: #file) { result in
app.get("file-stream") { req -> EventLoopFuture<Response> in
return req.fileio.streamFile(at: #file, advancedETagComparison: true) { result in
do {
try result.get()
} catch {
Expand All @@ -219,8 +219,8 @@ final class FileTests: XCTestCase {
let app = Application(.testing)
defer { app.shutdown() }

app.get("file-stream") { req in
return req.fileio.streamFile(at: #file) { result in
app.get("file-stream") { req -> EventLoopFuture<Response> in
return req.fileio.streamFile(at: #file, advancedETagComparison: true) { result in
do {
try result.get()
} catch {
Expand Down Expand Up @@ -374,8 +374,8 @@ final class FileTests: XCTestCase {
let app = Application(.testing)
defer { app.shutdown() }

app.get("file-stream") { req in
return req.fileio.streamFile(at: #file)
app.get("file-stream") { req -> EventLoopFuture<Response> in
return req.fileio.streamFile(at: #file, advancedETagComparison: true)
linus-hologram marked this conversation as resolved.
Show resolved Hide resolved
}

var headers = HTTPHeaders()
Expand Down
6 changes: 3 additions & 3 deletions Tests/VaporTests/RequestTests.swift
Expand Up @@ -261,13 +261,13 @@ final class RequestTests: XCTestCase {
app.http.client.configuration.redirectConfiguration = .disallow

app.get("redirect_normal") {
$0.redirect(to: "foo", type: .normal)
$0.redirect(to: "foo", redirectType: .normal)
linus-hologram marked this conversation as resolved.
Show resolved Hide resolved
}
app.get("redirect_permanent") {
$0.redirect(to: "foo", type: .permanent)
$0.redirect(to: "foo", redirectType: .permanent)
}
app.post("redirect_temporary") {
$0.redirect(to: "foo", type: .temporary)
$0.redirect(to: "foo", redirectType: .temporary)
}

try app.server.start(address: .hostname("localhost", port: 8080))
Expand Down