From cc6af3ea3e5468f5084fcb861f6a6221181ad7e1 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Tue, 19 Mar 2024 12:51:44 +0000 Subject: [PATCH 1/7] Use generic for file loading in FileMiddleware This will allow for writing custom file loaders that cache file contents or use another source than the local file system for files --- .../Hummingbird/Files/FileMiddleware.swift | 275 +++++++++--------- Sources/Hummingbird/Files/FileProvider.swift | 54 ++++ .../Hummingbird/Files/LocalFileSystem.swift | 98 +++++++ .../FileMiddlewareTests.swift | 12 + 4 files changed, 307 insertions(+), 132 deletions(-) create mode 100644 Sources/Hummingbird/Files/FileProvider.swift create mode 100644 Sources/Hummingbird/Files/LocalFileSystem.swift diff --git a/Sources/Hummingbird/Files/FileMiddleware.swift b/Sources/Hummingbird/Files/FileMiddleware.swift index 2905af0e..f3f2a9b4 100644 --- a/Sources/Hummingbird/Files/FileMiddleware.swift +++ b/Sources/Hummingbird/Files/FileMiddleware.swift @@ -2,7 +2,7 @@ // // This source file is part of the Hummingbird server framework project // -// Copyright (c) 2021-2023 the Hummingbird authors +// Copyright (c) 2021-2024 the Hummingbird authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -32,180 +32,89 @@ import NIOPosix /// "if-modified-since", "if-none-match", "if-range" and 'range" headers. It will output "content-length", /// "modified-date", "eTag", "content-type", "cache-control" and "content-range" headers where /// they are relevant. -public struct FileMiddleware: RouterMiddleware { +public struct FileMiddleware: RouterMiddleware { struct IsDirectoryError: Error {} - let rootFolder: URL - let threadPool: NIOThreadPool - let fileIO: FileIO let cacheControl: CacheControl let searchForIndexHtml: Bool + let fileProvider: Provider /// Create FileMiddleware /// - Parameters: /// - rootFolder: Root folder to look for files /// - cacheControl: What cache control headers to include in response - /// - indexHtml: Should we look for index.html in folders - /// - application: Application we are attaching to + /// - searchForIndexHtml: Should we look for index.html in folders + /// - threadPool: ThreadPool used by file loading + /// - logger: Logger used to output file information public init( _ rootFolder: String = "public", cacheControl: CacheControl = .init([]), searchForIndexHtml: Bool = false, threadPool: NIOThreadPool = NIOThreadPool.singleton, logger: Logger = Logger(label: "FileMiddleware") - ) { - self.rootFolder = URL(fileURLWithPath: rootFolder) - self.threadPool = threadPool - self.fileIO = .init(threadPool: threadPool) + ) where Provider == LocalFileSystem { self.cacheControl = cacheControl self.searchForIndexHtml = searchForIndexHtml + self.fileProvider = LocalFileSystem( + rootFolder: rootFolder, + threadPool: threadPool, + logger: logger + ) + } - let workingFolder: String - if rootFolder.first == "/" { - workingFolder = "" - } else { - if let cwd = getcwd(nil, Int(PATH_MAX)) { - workingFolder = String(cString: cwd) + "/" - free(cwd) - } else { - workingFolder = "./" - } - } - logger.info("FileMiddleware serving from \(workingFolder)\(rootFolder)") + /// Create FileMiddleware using custom ``FileProvider``. + /// - Parameters: + /// - fileProvider: File provider + /// - cacheControl: What cache control headers to include in response + /// - indexHtml: Should we look for index.html in folders + public init( + fileProvider: Provider, + cacheControl: CacheControl = .init([]), + searchForIndexHtml: Bool = false + ) { + self.cacheControl = cacheControl + self.searchForIndexHtml = searchForIndexHtml + self.fileProvider = fileProvider } + /// Handle request public func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response { do { return try await next(request, context) } catch { + // Guard that error is HTTP error notFound guard let httpError = error as? HTTPError, httpError.status == .notFound else { throw error } + // Remove percent encoding from URI path guard let path = request.uri.path.removingPercentEncoding else { throw HTTPError(.badRequest) } + // file paths that contain ".." are considered illegal guard !path.contains("..") else { throw HTTPError(.badRequest) } - let fileResult = try await self.threadPool.runIfActive { () -> FileResult in - var fullPath = self.rootFolder.appendingPathComponent(path) - - let modificationDate: Date? - let contentSize: Int? - do { - let attributes = try FileManager.default.attributesOfItem(atPath: fullPath.relativePath) - // if file is a directory seach and `searchForIndexHtml` is set to true - // then search for index.html in directory - if let fileType = attributes[.type] as? FileAttributeType, fileType == .typeDirectory { - guard self.searchForIndexHtml else { throw IsDirectoryError() } - fullPath = fullPath.appendingPathComponent("index.html") - let attributes = try FileManager.default.attributesOfItem(atPath: fullPath.relativePath) - modificationDate = attributes[.modificationDate] as? Date - contentSize = attributes[.size] as? Int - } else { - modificationDate = attributes[.modificationDate] as? Date - contentSize = attributes[.size] as? Int - } - } catch { - throw HTTPError(.notFound) - } - let eTag = createETag([ - String(describing: modificationDate?.timeIntervalSince1970 ?? 0), - String(describing: contentSize ?? 0), - ]) - - // construct headers - var headers = HTTPFields() - - // content-length - if let contentSize { - headers[.contentLength] = String(describing: contentSize) - } - // modified-date - var modificationDateString: String? - if let modificationDate { - modificationDateString = DateCache.rfc1123Formatter.string(from: modificationDate) - headers[.lastModified] = modificationDateString! - } - // eTag (constructed from modification date and content size) - headers[.eTag] = eTag - - // content-type - if let extPointIndex = path.lastIndex(of: ".") { - let extIndex = path.index(after: extPointIndex) - let ext = String(path.suffix(from: extIndex)) - if let contentType = MediaType.getMediaType(forExtension: ext) { - headers[.contentType] = contentType.description - } - } - - headers[.acceptRanges] = "bytes" - - // cache-control - if let cacheControlValue = self.cacheControl.getCacheControlHeader(for: path) { - headers[.cacheControl] = cacheControlValue - } - - // verify if-none-match. No need to verify if-match as this is used for state changing - // operations. Also the eTag we generate is considered weak. - let ifNoneMatch = request.headers[values: .ifNoneMatch] - if ifNoneMatch.count > 0 { - for match in ifNoneMatch { - if eTag == match { - return .notModified(headers) - } - } - } - // verify if-modified-since - else if let ifModifiedSince = request.headers[.ifModifiedSince], - let modificationDate - { - if let ifModifiedSinceDate = DateCache.rfc1123Formatter.date(from: ifModifiedSince) { - // round modification date of file down to seconds for comparison - let modificationDateTimeInterval = modificationDate.timeIntervalSince1970.rounded(.down) - let ifModifiedSinceDateTimeInterval = ifModifiedSinceDate.timeIntervalSince1970 - if modificationDateTimeInterval <= ifModifiedSinceDateTimeInterval { - return .notModified(headers) - } - } - } - - if let rangeHeader = request.headers[.range] { - guard let range = getRangeFromHeaderValue(rangeHeader) else { - throw HTTPError(.rangeNotSatisfiable) - } - // range request conditional on etag or modified date being equal to value in if-range - if let ifRange = request.headers[.ifRange], ifRange != headers[.eTag], ifRange != headers[.lastModified] { - // do nothing and drop down to returning full file - } else { - if let contentSize { - let lowerBound = max(range.lowerBound, 0) - let upperBound = min(range.upperBound, contentSize - 1) - headers[.contentRange] = "bytes \(lowerBound)-\(upperBound)/\(contentSize)" - // override content-length set above - headers[.contentLength] = String(describing: upperBound - lowerBound + 1) - } - return .loadFile(fullPath.relativePath, headers, range) - } - } - return .loadFile(fullPath.relativePath, headers, nil) - } + let fullPath = self.fileProvider.getFullPath(path) + // get file attributes and actual file path (It might be an index.html) + let (actualPath, attributes) = try await self.getFileAttributes(path: fullPath) + // get how we should respond + let fileResult = try await self.constructResponse(path: actualPath, attributes: attributes, request: request) switch fileResult { case .notModified(let headers): return Response(status: .notModified, headers: headers) - case .loadFile(let fullPath, let headers, let range): + case .loadFile(let headers, let range): switch request.method { case .get: if let range { - let body = try await self.fileIO.loadFile(path: fullPath, range: range, context: context) + let body = try await self.fileProvider.loadFile(path: actualPath, range: range, context: context) return Response(status: .partialContent, headers: headers, body: body) } - let body = try await self.fileIO.loadFile(path: fullPath, context: context) + let body = try await self.fileProvider.loadFile(path: actualPath, context: context) return Response(status: .ok, headers: headers, body: body) case .head: @@ -217,15 +126,109 @@ public struct FileMiddleware: RouterMiddleware { } } } +} +extension FileMiddleware { /// Whether to return data from the file or a not modified response private enum FileResult { case notModified(HTTPFields) - case loadFile(String, HTTPFields, ClosedRange?) + case loadFile(HTTPFields, ClosedRange?) + } + + /// Return file attributes, and actual file path + private func getFileAttributes(path: String) async throws -> (path: String, attributes: FileAttributes) { + guard let attributes = try await self.fileProvider.getAttributes(path: path) else { + throw HTTPError(.notFound) + } + // if file is a directory seach and `searchForIndexHtml` is set to true + // then search for index.html in directory + if attributes.isFolder { + guard self.searchForIndexHtml else { throw IsDirectoryError() } + let indexPath = self.appendingPathComponent(path, "index.html") + guard let indexAttributes = try await self.fileProvider.getAttributes(path: indexPath) else { + throw HTTPError(.notFound) + } + return (path: indexPath, attributes: indexAttributes) + } else { + return (path: path, attributes: attributes) + } + } + + /// Parse request headers and generate response headers + private func constructResponse(path: String, attributes: FileAttributes, request: Request) async throws -> FileResult { + let eTag = self.createETag([ + String(describing: attributes.modificationDate.timeIntervalSince1970), + String(describing: attributes.size), + ]) + + // construct headers + var headers = HTTPFields() + + // content-length + headers[.contentLength] = String(describing: attributes.size) + // modified-date + let modificationDateString = DateCache.rfc1123Formatter.string(from: attributes.modificationDate) + headers[.lastModified] = modificationDateString + // eTag (constructed from modification date and content size) + headers[.eTag] = eTag + + // content-type + if let extPointIndex = path.lastIndex(of: ".") { + let extIndex = path.index(after: extPointIndex) + let ext = String(path.suffix(from: extIndex)) + if let contentType = MediaType.getMediaType(forExtension: ext) { + headers[.contentType] = contentType.description + } + } + + headers[.acceptRanges] = "bytes" + + // cache-control + if let cacheControlValue = self.cacheControl.getCacheControlHeader(for: path) { + headers[.cacheControl] = cacheControlValue + } + + // verify if-none-match. No need to verify if-match as this is used for state changing + // operations. Also the eTag we generate is considered weak. + let ifNoneMatch = request.headers[values: .ifNoneMatch] + if ifNoneMatch.count > 0 { + for match in ifNoneMatch { + if eTag == match { + return .notModified(headers) + } + } + } + // verify if-modified-since + else if let ifModifiedSince = request.headers[.ifModifiedSince] { + if let ifModifiedSinceDate = DateCache.rfc1123Formatter.date(from: ifModifiedSince) { + // round modification date of file down to seconds for comparison + let modificationDateTimeInterval = attributes.modificationDate.timeIntervalSince1970.rounded(.down) + let ifModifiedSinceDateTimeInterval = ifModifiedSinceDate.timeIntervalSince1970 + if modificationDateTimeInterval <= ifModifiedSinceDateTimeInterval { + return .notModified(headers) + } + } + } + + if let rangeHeader = request.headers[.range] { + guard let range = getRangeFromHeaderValue(rangeHeader) else { + throw HTTPError(.rangeNotSatisfiable) + } + // range request conditional on etag or modified date being equal to value in if-range + if let ifRange = request.headers[.ifRange], ifRange != headers[.eTag], ifRange != headers[.lastModified] { + // do nothing and drop down to returning full file + } else { + let lowerBound = max(range.lowerBound, 0) + let upperBound = min(range.upperBound, attributes.size - 1) + headers[.contentRange] = "bytes \(lowerBound)-\(upperBound)/\(attributes.size)" + // override content-length set above + headers[.contentLength] = String(describing: upperBound - lowerBound + 1) + return .loadFile(headers, range) + } + } + return .loadFile(headers, nil) } -} -extension FileMiddleware { /// Convert "bytes=value-value" range header into `ClosedRange` /// /// Also supports open ended ranges @@ -282,6 +285,14 @@ extension FileMiddleware { return "W/\"\(buffer.hexDigest())\"" } + + private func appendingPathComponent(_ root: String, _ component: String) -> String { + if root.last == "/" { + return "\(root)\(component)" + } else { + return "\(root)/\(component)" + } + } } extension Sequence { diff --git a/Sources/Hummingbird/Files/FileProvider.swift b/Sources/Hummingbird/Files/FileProvider.swift new file mode 100644 index 00000000..c09499a2 --- /dev/null +++ b/Sources/Hummingbird/Files/FileProvider.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Hummingbird server framework project +// +// Copyright (c) 2024 the Hummingbird authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import NIOPosix + +/// File attributes required by ``FileMiddleware`` +public struct FileAttributes: Sendable { + /// Is file a folder + public let isFolder: Bool + /// Size of file + public let size: Int + /// Last time file was modified + public let modificationDate: Date +} + +/// Protocol for file provider type used by ``FileMiddleware`` +public protocol FileProvider: Sendable { + /// Get full path name + /// - Parameter path: path from URI + /// - Returns: Full path + func getFullPath(_ path: String) -> String + + /// Get file attributes + /// - Parameter path: Full path to file + /// - Returns: File attributes + func getAttributes(path: String) async throws -> FileAttributes? + + /// Return a reponse body that will write the file body + /// - Parameters: + /// - path: Full path to file + /// - context: Request context + /// - Returns: Response body + func loadFile(path: String, context: some BaseRequestContext) async throws -> ResponseBody + + /// Return a reponse body that will write a partial file body + /// - Parameters: + /// - path: Full path to file + /// - range: Part of file to return + /// - context: Request context + /// - Returns: Response body + func loadFile(path: String, range: ClosedRange, context: some BaseRequestContext) async throws -> ResponseBody +} diff --git a/Sources/Hummingbird/Files/LocalFileSystem.swift b/Sources/Hummingbird/Files/LocalFileSystem.swift new file mode 100644 index 00000000..52f01198 --- /dev/null +++ b/Sources/Hummingbird/Files/LocalFileSystem.swift @@ -0,0 +1,98 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Hummingbird server framework project +// +// Copyright (c) 2024 the Hummingbird authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import Logging +import NIOPosix + +/// Local file system file provider. All file accesses are relative to a root folder +public struct LocalFileSystem: FileProvider { + let rootFolder: String + let fileIO: FileIO + + /// Initialize LocalFileSystem FileProvider + /// - Parameters: + /// - rootFolder: Root folder to serve files from + /// - threadPool: Thread pool used when loading files + /// - logger: Logger to output root folder information + init(rootFolder: String, threadPool: NIOThreadPool, logger: Logger) { + if rootFolder.last != "/" { + self.rootFolder = "\(rootFolder)/" + } else { + self.rootFolder = rootFolder + } + self.fileIO = .init(threadPool: threadPool) + + let workingFolder: String + if rootFolder.first == "/" { + workingFolder = "" + } else { + if let cwd = getcwd(nil, Int(PATH_MAX)) { + workingFolder = String(cString: cwd) + "/" + free(cwd) + } else { + workingFolder = "./" + } + } + logger.info("Serving files from \(workingFolder)\(rootFolder)") + } + + /// Get full path name with local file system root prefixed + /// - Parameter path: path from URI + /// - Returns: Full path + public func getFullPath(_ path: String) -> String { + if path.first == "/" { + return "\(self.rootFolder)\(path.dropFirst())" + } else { + return "\(self.rootFolder)\(path)" + } + } + + /// Get file attributes + /// - Parameter path: Full path to file + /// - Returns: File attributes + public func getAttributes(path: String) async throws -> FileAttributes? { + do { + let lstat = try await self.fileIO.fileIO.lstat(path: path) + let isFolder = (lstat.st_mode & S_IFMT) == S_IFDIR + let modificationDate = Double(lstat.st_mtimespec.tv_sec) + (Double(lstat.st_mtimespec.tv_nsec) / 1_000_000_000.0) + return .init( + isFolder: isFolder, + size: numericCast(lstat.st_size), + modificationDate: Date(timeIntervalSince1970: modificationDate) + ) + } catch { + return nil + } + } + + /// Return a reponse body that will write the file body + /// - Parameters: + /// - path: Full path to file + /// - context: Request context + /// - Returns: Response body + public func loadFile(path: String, context: some BaseRequestContext) async throws -> ResponseBody { + try await self.fileIO.loadFile(path: path, context: context) + } + + /// Return a reponse body that will write a partial file body + /// - Parameters: + /// - path: Full path to file + /// - range: Part of file to return + /// - context: Request context + /// - Returns: Response body + public func loadFile(path: String, range: ClosedRange, context: some BaseRequestContext) async throws -> ResponseBody { + try await self.fileIO.loadFile(path: path, range: range, context: context) + } +} diff --git a/Tests/HummingbirdTests/FileMiddlewareTests.swift b/Tests/HummingbirdTests/FileMiddlewareTests.swift index afb95ccf..f0b4534b 100644 --- a/Tests/HummingbirdTests/FileMiddlewareTests.swift +++ b/Tests/HummingbirdTests/FileMiddlewareTests.swift @@ -53,6 +53,18 @@ class FileMiddlewareTests: XCTestCase { } } + func testNotAFile() async throws { + let router = Router() + router.middlewares.add(FileMiddleware(".")) + let app = Application(responder: router.buildResponder()) + + try await app.test(.router) { client in + try await client.execute(uri: "missed.jpg", method: .get) { response in + XCTAssertEqual(response.status, .notFound) + } + } + } + func testReadLargeFile() async throws { let router = Router() router.middlewares.add(FileMiddleware(".")) From 0da62c5606cbcc6b7b3edef09978d2c616e44c6f Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Tue, 19 Mar 2024 13:11:20 +0000 Subject: [PATCH 2/7] Add testCustomFileProvider --- Sources/Hummingbird/Files/FileProvider.swift | 7 +++ .../FileMiddlewareTests.swift | 44 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/Sources/Hummingbird/Files/FileProvider.swift b/Sources/Hummingbird/Files/FileProvider.swift index c09499a2..ef3bfc29 100644 --- a/Sources/Hummingbird/Files/FileProvider.swift +++ b/Sources/Hummingbird/Files/FileProvider.swift @@ -23,6 +23,13 @@ public struct FileAttributes: Sendable { public let size: Int /// Last time file was modified public let modificationDate: Date + + /// Initialize FileAttributes + public init(isFolder: Bool, size: Int, modificationDate: Date) { + self.isFolder = isFolder + self.size = size + self.modificationDate = modificationDate + } } /// Protocol for file provider type used by ``FileMiddleware`` diff --git a/Tests/HummingbirdTests/FileMiddlewareTests.swift b/Tests/HummingbirdTests/FileMiddlewareTests.swift index f0b4534b..546d9e7e 100644 --- a/Tests/HummingbirdTests/FileMiddlewareTests.swift +++ b/Tests/HummingbirdTests/FileMiddlewareTests.swift @@ -315,4 +315,48 @@ class FileMiddlewareTests: XCTestCase { } } } + + func testCustomFileProvider() async throws { + // basic file provider + struct MemoryFileProvider: FileProvider { + init() { + self.files = [:] + } + + func getFullPath(_ path: String) -> String { + return path + } + + func getAttributes(path: String) async throws -> Hummingbird.FileAttributes? { + guard let file = files[path] else { return nil } + return .init(isFolder: false, size: file.readableBytes, modificationDate: Date.distantPast) + } + + func loadFile(path: String, context: some Hummingbird.BaseRequestContext) async throws -> ResponseBody { + guard let file = files[path] else { throw HTTPError(.notFound) } + return .init(byteBuffer: file) + } + + func loadFile(path: String, range: ClosedRange, context: some Hummingbird.BaseRequestContext) async throws -> ResponseBody { + guard let file = files[path] else { throw HTTPError(.notFound) } + guard let slice = file.getSlice(at: range.lowerBound, length: range.count) else { throw HTTPError(.rangeNotSatisfiable) } + return .init(byteBuffer: slice) + } + + var files: [String: ByteBuffer] + } + + var fileProvider = MemoryFileProvider() + fileProvider.files["test"] = ByteBuffer(string: "Test this") + + let router = Router() + router.middlewares.add(FileMiddleware(fileProvider: fileProvider)) + let app = Application(router: router) + try await app.test(.router) { client in + try await client.execute(uri: "test", method: .get) { response in + XCTAssertEqual(response.status, .ok) + XCTAssertEqual(String(buffer: response.body), "Test this") + } + } + } } From 739dd1e5bc081db9688959a7c5e9bb1d4527ea6f Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Tue, 19 Mar 2024 14:39:23 +0000 Subject: [PATCH 3/7] Fix Linux compile error --- Sources/Hummingbird/Files/FileMiddleware.swift | 4 ---- Sources/Hummingbird/Files/LocalFileSystem.swift | 4 ++++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Hummingbird/Files/FileMiddleware.swift b/Sources/Hummingbird/Files/FileMiddleware.swift index f3f2a9b4..dad026f6 100644 --- a/Sources/Hummingbird/Files/FileMiddleware.swift +++ b/Sources/Hummingbird/Files/FileMiddleware.swift @@ -12,11 +12,7 @@ // //===----------------------------------------------------------------------===// -#if os(Linux) -@preconcurrency import Foundation -#else import Foundation -#endif import HTTPTypes import Logging import NIOCore diff --git a/Sources/Hummingbird/Files/LocalFileSystem.swift b/Sources/Hummingbird/Files/LocalFileSystem.swift index 52f01198..82472db2 100644 --- a/Sources/Hummingbird/Files/LocalFileSystem.swift +++ b/Sources/Hummingbird/Files/LocalFileSystem.swift @@ -66,7 +66,11 @@ public struct LocalFileSystem: FileProvider { do { let lstat = try await self.fileIO.fileIO.lstat(path: path) let isFolder = (lstat.st_mode & S_IFMT) == S_IFDIR + #if os(Linux) + let modificationDate = Double(lstat.st_mtim.tv_sec) + (Double(lstat.st_mtim.tv_nsec) / 1_000_000_000.0) + #else let modificationDate = Double(lstat.st_mtimespec.tv_sec) + (Double(lstat.st_mtimespec.tv_nsec) / 1_000_000_000.0) + #endif return .init( isFolder: isFolder, size: numericCast(lstat.st_size), From f86dd3233548af92c692e63baae2c1c2ad8bb8fa Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Tue, 19 Mar 2024 15:13:11 +0000 Subject: [PATCH 4/7] Replace NSRegularExpression with Parser --- .../Hummingbird/Files/FileMiddleware.swift | 49 ++++++++----------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/Sources/Hummingbird/Files/FileMiddleware.swift b/Sources/Hummingbird/Files/FileMiddleware.swift index dad026f6..849577cd 100644 --- a/Sources/Hummingbird/Files/FileMiddleware.swift +++ b/Sources/Hummingbird/Files/FileMiddleware.swift @@ -14,6 +14,7 @@ import Foundation import HTTPTypes +import HummingbirdCore import Logging import NIOCore import NIOPosix @@ -229,37 +230,27 @@ extension FileMiddleware { /// /// Also supports open ended ranges private func getRangeFromHeaderValue(_ header: String) -> ClosedRange? { - let groups = self.matchRegex(header, expression: "^bytes=([\\d]*)-([\\d]*)$") - guard groups.count == 3 else { return nil } - - if groups[1] == "" { - guard let upperBound = Int(groups[2]) else { return nil } - return 0...upperBound - } else if groups[2] == "" { - guard let lowerBound = Int(groups[1]) else { return nil } - return lowerBound...Int.max - } else { - guard let lowerBound = Int(groups[1]), - let upperBound = Int(groups[2]) else { return nil } - return lowerBound...upperBound - } - } - - private func matchRegex(_ string: String, expression: String) -> [Substring] { - let nsRange = NSRange(string.startIndex.. String { From e47572de4fef03127b5728866cda4015d7fb8d3c Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Tue, 19 Mar 2024 15:16:22 +0000 Subject: [PATCH 5/7] swiftformat --- Sources/Hummingbird/Files/FileMiddleware.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Hummingbird/Files/FileMiddleware.swift b/Sources/Hummingbird/Files/FileMiddleware.swift index 849577cd..a2488b40 100644 --- a/Sources/Hummingbird/Files/FileMiddleware.swift +++ b/Sources/Hummingbird/Files/FileMiddleware.swift @@ -231,7 +231,7 @@ extension FileMiddleware { /// Also supports open ended ranges private func getRangeFromHeaderValue(_ header: String) -> ClosedRange? { do { - var parser = Parser(header) + var parser = Parser(header) guard try parser.read("bytes=") else { return nil } let lower = parser.read { $0.properties.numericType == .decimal }.string guard try parser.read("-") else { return nil } @@ -245,7 +245,7 @@ extension FileMiddleware { return lowerBound...Int.max } else { guard let lowerBound = Int(lower), - let upperBound = Int(upper) else { return nil } + let upperBound = Int(upper) else { return nil } return lowerBound...upperBound } } catch { From 5760018ab8c95a86f3f7ff4c0bced03434a75044 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Fri, 22 Mar 2024 14:57:22 +0000 Subject: [PATCH 6/7] Add FileAttributes associatedtype to FileProvider --- .../Hummingbird/Files/FileMiddleware.swift | 18 ++++++++++++++--- Sources/Hummingbird/Files/FileProvider.swift | 20 +++---------------- .../Hummingbird/Files/LocalFileSystem.swift | 19 +++++++++++++++++- .../FileMiddlewareTests.swift | 14 +++++++++---- 4 files changed, 46 insertions(+), 25 deletions(-) diff --git a/Sources/Hummingbird/Files/FileMiddleware.swift b/Sources/Hummingbird/Files/FileMiddleware.swift index a2488b40..0a7bcb69 100644 --- a/Sources/Hummingbird/Files/FileMiddleware.swift +++ b/Sources/Hummingbird/Files/FileMiddleware.swift @@ -19,6 +19,18 @@ import Logging import NIOCore import NIOPosix +/// Protocol for all the file attributes required by ``FileMiddleware`` +/// +/// Requirements for the FileAttributes of the ``FileProvider`` you use with your FileMiddleware +public protocol FileMiddlewareFileAttributes { + /// Is file a folder + var isFolder: Bool { get } + /// Size of file + var size: Int { get } + /// Last time file was modified + var modificationDate: Date { get } +} + /// Middleware for serving static files. /// /// If router returns a 404 ie a route was not found then this middleware will treat the request @@ -29,7 +41,7 @@ import NIOPosix /// "if-modified-since", "if-none-match", "if-range" and 'range" headers. It will output "content-length", /// "modified-date", "eTag", "content-type", "cache-control" and "content-range" headers where /// they are relevant. -public struct FileMiddleware: RouterMiddleware { +public struct FileMiddleware: RouterMiddleware where Provider.FileAttributes: FileMiddlewareFileAttributes { struct IsDirectoryError: Error {} let cacheControl: CacheControl @@ -133,7 +145,7 @@ extension FileMiddleware { } /// Return file attributes, and actual file path - private func getFileAttributes(path: String) async throws -> (path: String, attributes: FileAttributes) { + private func getFileAttributes(path: String) async throws -> (path: String, attributes: Provider.FileAttributes) { guard let attributes = try await self.fileProvider.getAttributes(path: path) else { throw HTTPError(.notFound) } @@ -152,7 +164,7 @@ extension FileMiddleware { } /// Parse request headers and generate response headers - private func constructResponse(path: String, attributes: FileAttributes, request: Request) async throws -> FileResult { + private func constructResponse(path: String, attributes: Provider.FileAttributes, request: Request) async throws -> FileResult { let eTag = self.createETag([ String(describing: attributes.modificationDate.timeIntervalSince1970), String(describing: attributes.size), diff --git a/Sources/Hummingbird/Files/FileProvider.swift b/Sources/Hummingbird/Files/FileProvider.swift index ef3bfc29..c48b546a 100644 --- a/Sources/Hummingbird/Files/FileProvider.swift +++ b/Sources/Hummingbird/Files/FileProvider.swift @@ -15,25 +15,11 @@ import Foundation import NIOPosix -/// File attributes required by ``FileMiddleware`` -public struct FileAttributes: Sendable { - /// Is file a folder - public let isFolder: Bool - /// Size of file - public let size: Int - /// Last time file was modified - public let modificationDate: Date - - /// Initialize FileAttributes - public init(isFolder: Bool, size: Int, modificationDate: Date) { - self.isFolder = isFolder - self.size = size - self.modificationDate = modificationDate - } -} - /// Protocol for file provider type used by ``FileMiddleware`` public protocol FileProvider: Sendable { + /// File attributes type + associatedtype FileAttributes + /// Get full path name /// - Parameter path: path from URI /// - Returns: Full path diff --git a/Sources/Hummingbird/Files/LocalFileSystem.swift b/Sources/Hummingbird/Files/LocalFileSystem.swift index 82472db2..7cd7d57b 100644 --- a/Sources/Hummingbird/Files/LocalFileSystem.swift +++ b/Sources/Hummingbird/Files/LocalFileSystem.swift @@ -16,8 +16,25 @@ import Foundation import Logging import NIOPosix -/// Local file system file provider. All file accesses are relative to a root folder +/// Local file system file provider used by FileMiddleware. All file accesses are relative to a root folder public struct LocalFileSystem: FileProvider { + /// File attributes required by ``FileMiddleware`` + public struct FileAttributes: Sendable, FileMiddlewareFileAttributes { + /// Is file a folder + public let isFolder: Bool + /// Size of file + public let size: Int + /// Last time file was modified + public let modificationDate: Date + + /// Initialize FileAttributes + public init(isFolder: Bool, size: Int, modificationDate: Date) { + self.isFolder = isFolder + self.size = size + self.modificationDate = modificationDate + } + } + let rootFolder: String let fileIO: FileIO diff --git a/Tests/HummingbirdTests/FileMiddlewareTests.swift b/Tests/HummingbirdTests/FileMiddlewareTests.swift index 546d9e7e..8c62b37f 100644 --- a/Tests/HummingbirdTests/FileMiddlewareTests.swift +++ b/Tests/HummingbirdTests/FileMiddlewareTests.swift @@ -319,6 +319,12 @@ class FileMiddlewareTests: XCTestCase { func testCustomFileProvider() async throws { // basic file provider struct MemoryFileProvider: FileProvider { + struct FileAttributes: FileMiddlewareFileAttributes { + var isFolder: Bool { false } + var modificationDate: Date { .distantPast } + let size: Int + } + init() { self.files = [:] } @@ -327,17 +333,17 @@ class FileMiddlewareTests: XCTestCase { return path } - func getAttributes(path: String) async throws -> Hummingbird.FileAttributes? { + func getAttributes(path: String) async throws -> FileAttributes? { guard let file = files[path] else { return nil } - return .init(isFolder: false, size: file.readableBytes, modificationDate: Date.distantPast) + return .init(size: file.readableBytes) } - func loadFile(path: String, context: some Hummingbird.BaseRequestContext) async throws -> ResponseBody { + func loadFile(path: String, context: some BaseRequestContext) async throws -> ResponseBody { guard let file = files[path] else { throw HTTPError(.notFound) } return .init(byteBuffer: file) } - func loadFile(path: String, range: ClosedRange, context: some Hummingbird.BaseRequestContext) async throws -> ResponseBody { + func loadFile(path: String, range: ClosedRange, context: some BaseRequestContext) async throws -> ResponseBody { guard let file = files[path] else { throw HTTPError(.notFound) } guard let slice = file.getSlice(at: range.lowerBound, length: range.count) else { throw HTTPError(.rangeNotSatisfiable) } return .init(byteBuffer: slice) From 6fcee2644b2e35983ed3fd503e4f50fca739998e Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Fri, 22 Mar 2024 15:54:18 +0000 Subject: [PATCH 7/7] Remove public from LocalFileSystem.FileAttributes.init --- Sources/Hummingbird/Files/LocalFileSystem.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Hummingbird/Files/LocalFileSystem.swift b/Sources/Hummingbird/Files/LocalFileSystem.swift index 7cd7d57b..7cae7e25 100644 --- a/Sources/Hummingbird/Files/LocalFileSystem.swift +++ b/Sources/Hummingbird/Files/LocalFileSystem.swift @@ -28,7 +28,7 @@ public struct LocalFileSystem: FileProvider { public let modificationDate: Date /// Initialize FileAttributes - public init(isFolder: Bool, size: Int, modificationDate: Date) { + init(isFolder: Bool, size: Int, modificationDate: Date) { self.isFolder = isFolder self.size = size self.modificationDate = modificationDate