diff --git a/Sources/Hummingbird/Files/FileMiddleware.swift b/Sources/Hummingbird/Files/FileMiddleware.swift index 2905af0e..0a7bcb69 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 @@ -12,16 +12,25 @@ // //===----------------------------------------------------------------------===// -#if os(Linux) -@preconcurrency import Foundation -#else import Foundation -#endif import HTTPTypes +import HummingbirdCore 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 @@ -32,180 +41,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 where Provider.FileAttributes: FileMiddlewareFileAttributes { 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,50 +135,134 @@ 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?) } -} -extension FileMiddleware { - /// Convert "bytes=value-value" range header into `ClosedRange` - /// - /// 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 + /// Return file attributes, and actual file path + 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) + } + // 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 { - guard let lowerBound = Int(groups[1]), - let upperBound = Int(groups[2]) else { return nil } - return lowerBound...upperBound + return (path: path, attributes: attributes) } } - private func matchRegex(_ string: String, expression: String) -> [Substring] { - let nsRange = NSRange(string.startIndex.. 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) + } - var groups: [Substring] = [] - groups.reserveCapacity(firstMatch.numberOfRanges) - for i in 0..` + /// + /// Also supports open ended ranges + private func getRangeFromHeaderValue(_ header: String) -> ClosedRange? { + do { + 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 } + let upper = parser.read { $0.properties.numericType == .decimal }.string + + if lower == "" { + guard let upperBound = Int(upper) else { return nil } + return 0...upperBound + } else if upper == "" { + guard let lowerBound = Int(lower) else { return nil } + return lowerBound...Int.max + } else { + guard let lowerBound = Int(lower), + let upperBound = Int(upper) else { return nil } + return lowerBound...upperBound + } + } catch { + return nil } - return groups } private func createETag(_ strings: [String]) -> String { @@ -282,6 +284,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..c48b546a --- /dev/null +++ b/Sources/Hummingbird/Files/FileProvider.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// 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 + 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..7cae7e25 --- /dev/null +++ b/Sources/Hummingbird/Files/LocalFileSystem.swift @@ -0,0 +1,119 @@ +//===----------------------------------------------------------------------===// +// +// 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 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 + init(isFolder: Bool, size: Int, modificationDate: Date) { + self.isFolder = isFolder + self.size = size + self.modificationDate = modificationDate + } + } + + 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 + #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), + 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..8c62b37f 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(".")) @@ -303,4 +315,54 @@ 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 = [:] + } + + func getFullPath(_ path: String) -> String { + return path + } + + func getAttributes(path: String) async throws -> FileAttributes? { + guard let file = files[path] else { return nil } + return .init(size: file.readableBytes) + } + + 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 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") + } + } + } }