-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
/
FileMiddleware.swift
179 lines (152 loc) · 7.41 KB
/
FileMiddleware.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
import Foundation
import NIOCore
/// Serves static files from a public directory.
///
/// `FileMiddleware` will default to `DirectoryConfig`'s working directory with `"/Public"` appended.
public final class FileMiddleware: Middleware {
/// The public directory. Guaranteed to end with a slash.
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.
let description: String
/// Cannot generate Bundle Resource URL
public static let bundleResourceURLIsNil: Self = .init(description: "Cannot generate Bundle Resource URL: Bundle Resource URL is nil")
/// Cannot find any actual folder for the given Public Directory
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.
/// - 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 = false) {
self.publicDirectory = publicDirectory.addTrailingSlash()
self.defaultFile = defaultFile
self.directoryAction = directoryAction
self.advancedETagComparison = advancedETagComparison
}
public func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture<Response> {
// make a copy of the percent-decoded path
guard var path = request.url.path.removingPercentEncoding else {
return request.eventLoop.makeFailedFuture(Abort(.badRequest))
}
// path must be relative.
path = path.removeLeadingSlashes()
// protect against relative paths
guard !path.contains("../") else {
return request.eventLoop.makeFailedFuture(Abort(.forbidden))
}
// create absolute path
var absPath = self.publicDirectory + path
// check if path exists and whether it is a directory
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: absPath, isDirectory: &isDir) else {
return next.respond(to: request)
}
if isDir.boolValue {
guard absPath.hasSuffix("/") else {
switch directoryAction.kind {
case .redirect:
var redirectUrl = request.url
redirectUrl.path += "/"
return request.eventLoop.future(
request.redirect(to: redirectUrl.string, redirectType: .permanent)
)
case .none:
return next.respond(to: request)
}
}
// If a directory, check for the default file
guard let defaultFile = defaultFile else {
return next.respond(to: request)
}
if defaultFile.isAbsolute() {
absPath = self.publicDirectory + defaultFile.removeLeadingSlashes()
} else {
absPath = absPath + defaultFile
}
// If the default file doesn't exist, pass on request
guard FileManager.default.fileExists(atPath: absPath) else {
return next.respond(to: request)
}
}
// stream the file
return request.fileio.streamFile(at: absPath, advancedETagComparison: advancedETagComparison)
}
/// Creates a new `FileMiddleware` for a server contained in an Xcode Project.
///
/// - parameters:
/// - bundle: The Bundle which contains the files to serve.
/// - 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.
///
/// - important: Make sure the public directory you wish to serve files from is included in the `Copy Bundle Resources` build phase of your project
/// - returns: A fully qualified FileMiddleware if the given `publicDirectory` can be served, throws a `BundleSetupError` otherwise
public convenience init(
bundle: Bundle,
publicDirectory: String = "Public",
defaultFile: String? = nil,
directoryAction: DirectoryAction = .none
) throws {
guard let bundleResourceURL = bundle.resourceURL else {
throw BundleSetupError.bundleResourceURLIsNil
}
let publicDirectoryURL = bundleResourceURL.appendingPathComponent(publicDirectory.removeLeadingSlashes())
guard (try? publicDirectoryURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true else {
throw BundleSetupError.publicDirectoryIsNotAFolder
}
self.init(publicDirectory: publicDirectoryURL.path, defaultFile: defaultFile, directoryAction: directoryAction)
}
/// Possible actions to take when the request doesn't have a trailing slash but matches a directory
public struct DirectoryAction: Sendable {
let kind: Kind
/// Indicates that the request should be passed through the middleware
public static var none: DirectoryAction {
return Self(kind: .none)
}
/// Indicates that a redirect to the same url with a trailing slash should be returned.
public static var redirect: DirectoryAction {
return Self(kind: .redirect)
}
enum Kind {
case none
case redirect
}
}
}
fileprivate extension String {
/// Determines if input path is absolute based on a leading slash
func isAbsolute() -> Bool {
return self.hasPrefix("/")
}
/// Makes a path relative by removing all leading slashes
func removeLeadingSlashes() -> String {
var newPath = self
while newPath.hasPrefix("/") {
newPath.removeFirst()
}
return newPath
}
/// Adds a trailing slash to the path if one is not already present
func addTrailingSlash() -> String {
var newPath = self
if !newPath.hasSuffix("/") {
newPath += "/"
}
return newPath
}
}