Skip to content

Commit 49f7e1a

Browse files
Add Encryption, Hashing, and expand Filesystem for better S3 support (#85)
An S3 / S3 compatible driver for Filesystem is in the alchemy-aws repo.
1 parent 70c07e1 commit 49f7e1a

32 files changed

+554
-193
lines changed

Package.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ let package = Package(
1515
.package(url: "https://github.com/hummingbird-project/hummingbird-core.git", from: "0.13.3"),
1616
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
1717
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"),
18+
.package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "3.0.0"),
1819
.package(url: "https://github.com/vapor/postgres-kit", from: "2.4.0"),
1920
.package(url: "https://github.com/vapor/mysql-kit", from: "4.3.0"),
2021
.package(url: "https://github.com/vapor/sqlite-kit", from: "4.0.0"),
@@ -24,8 +25,8 @@ let package = Package(
2425
.package(url: "https://github.com/alchemy-swift/fusion", .upToNextMinor(from: "0.3.0")),
2526
.package(url: "https://github.com/alchemy-swift/cron.git", from: "2.3.2"),
2627
.package(url: "https://github.com/alchemy-swift/pluralize", from: "1.0.1"),
27-
.package(url: "https://github.com/johnsundell/Plot.git", from: "0.8.0"),
2828
.package(url: "https://github.com/alchemy-swift/RediStack.git", branch: "ssl-support-1.2.0"),
29+
.package(url: "https://github.com/johnsundell/Plot.git", from: "0.8.0"),
2930
.package(url: "https://github.com/onevcat/Rainbow", .upToNextMajor(from: "4.0.0")),
3031
.package(url: "https://github.com/vadymmarkov/Fakery", from: "5.0.0"),
3132
],
@@ -52,6 +53,7 @@ let package = Package(
5253
.product(name: "HummingbirdFoundation", package: "hummingbird"),
5354
.product(name: "HummingbirdHTTP2", package: "hummingbird-core"),
5455
.product(name: "HummingbirdTLS", package: "hummingbird-core"),
56+
.product(name: "Crypto", package: "swift-crypto"),
5557

5658
/// Internal dependencies
5759
.byName(name: "AlchemyC"),

Sources/Alchemy/Alchemy+Papyrus/Endpoint+Request.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ extension Client {
5050
let method = HTTPMethod(rawValue: rawRequest.method)
5151
let fullUrl = try rawRequest.fullURL()
5252
builder = builder.withBaseUrl(fullUrl).withMethod(method)
53-
5453
if let mockedResponse = endpoint.mockedResponse {
5554
let clientRequest = builder.clientRequest
5655
let clientResponse = Client.Response(request: clientRequest, host: "mock", status: .ok, version: .http1_1, headers: [:])

Sources/Alchemy/Auth/BasicAuthable.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ extension BasicAuthable {
7474
/// - Returns: A `Bool` indicating if `password` matched
7575
/// `passwordHash`.
7676
public static func verify(password: String, passwordHash: String) throws -> Bool {
77-
try Bcrypt.verifySync(password, created: passwordHash)
77+
try Hash.verify(password, hash: passwordHash)
7878
}
7979

8080
/// A `Middleware` configured to validate the

Sources/Alchemy/Config/Service.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ extension Service {
4242
// MARK: Resolve shorthand
4343

4444
public static var `default`: Self {
45-
Container.resolveAssert(Self.self, identifier: Database.Identifier.default)
45+
.id(.default)
4646
}
4747

4848
public static func id(_ identifier: Identifier) -> Self {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
@propertyWrapper
2+
public struct Encrypted: ModelProperty {
3+
public var wrappedValue: String
4+
5+
// MARK: ModelProperty
6+
7+
public init(key: String, on row: SQLRowReader) throws {
8+
let encrypted = try row.require(key).string()
9+
guard let data = Data(base64Encoded: encrypted) else {
10+
throw EncryptionError("could not decrypt data; it wasn't base64 encoded")
11+
}
12+
13+
wrappedValue = try Crypt.decrypt(data: data)
14+
}
15+
16+
public func store(key: String, on row: inout SQLRowWriter) throws {
17+
let encrypted = try Crypt.encrypt(string: wrappedValue)
18+
let string = encrypted.base64EncodedString()
19+
row.put(.string(string), at: key)
20+
}
21+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import Crypto
2+
import Foundation
3+
4+
extension SymmetricKey {
5+
public static var app: SymmetricKey = {
6+
guard let appKey: String = Env.APP_KEY else {
7+
fatalError("Unable to load APP_KEY from Environment. Please set an APP_KEY before encrypting any data with `Crypt` or provide a custom `SymmetricKey` using `Crypt(key:)`.")
8+
}
9+
10+
guard let data = Data(base64Encoded: appKey) else {
11+
fatalError("Unable to create encryption key from APP_KEY. Please ensure APP_KEY is a base64 encoded String.")
12+
}
13+
14+
return SymmetricKey(data: data)
15+
}()
16+
}
17+
18+
public struct Encrypter {
19+
private let key: SymmetricKey
20+
21+
public init(key: SymmetricKey) {
22+
self.key = key
23+
}
24+
25+
public func encrypt(string: String) throws -> Data {
26+
try encrypt(data: Data(string.utf8))
27+
}
28+
29+
public func encrypt<D: DataProtocol>(data: D) throws -> Data {
30+
guard let result = try AES.GCM.seal(data, using: key).combined else {
31+
throw EncryptionError("could not encrypt the data")
32+
}
33+
34+
return result
35+
}
36+
37+
public func decrypt(base64Encoded string: String) throws -> String {
38+
guard let data = Data(base64Encoded: string) else {
39+
throw EncryptionError("the string wasn't base64 encoded")
40+
}
41+
42+
return try decrypt(data: data)
43+
}
44+
45+
public func decrypt<D: DataProtocol>(data: D) throws -> String {
46+
let box = try AES.GCM.SealedBox(combined: data)
47+
let data = try AES.GCM.open(box, using: key)
48+
guard let string = String(data: data, encoding: .utf8) else {
49+
throw EncryptionError("could not decrypt the data")
50+
}
51+
52+
return string
53+
}
54+
55+
public static func generateKeyString(size: SymmetricKeySize = .bits256) -> String {
56+
SymmetricKey(size: size).withUnsafeBytes { Data($0) }.base64EncodedString()
57+
}
58+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
public struct EncryptionError: Error {
2+
public let message: String
3+
4+
public init(_ message: String) {
5+
self.message = message
6+
}
7+
}
Lines changed: 116 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,155 @@
11
import MultipartKit
22
import Papyrus
3+
import NIOCore
34

4-
/// Represents a file with a name and binary contents.
5+
// File
56
public struct File: Codable, ResponseConvertible {
6-
// The name of the file, including the extension.
7+
public enum Source {
8+
// The file is stored in a `Filesystem` with the given path.
9+
case filesystem(Filesystem? = nil, path: String)
10+
// The file came with the given ContentType from an HTTP request.
11+
case http(clientContentType: ContentType?)
12+
13+
static var raw: Source {
14+
.http(clientContentType: nil)
15+
}
16+
}
17+
18+
/// The name of this file, including the extension
719
public var name: String
8-
// The size of the file, in bytes.
9-
public let size: Int
10-
// The binary contents of the file.
11-
public var content: ByteContent
20+
/// The source of this file, either from an HTTP request or from a Filesystem.
21+
public var source: Source
22+
public var content: ByteContent?
23+
public let size: Int?
24+
public let clientContentType: ContentType?
1225
/// The path extension of this file.
13-
public var `extension`: String { name.components(separatedBy: ".").last ?? "" }
14-
/// The content type of this file, based on it's extension.
15-
public let contentType: ContentType
26+
public var `extension`: String {
27+
name.components(separatedBy: ".").last ?? ""
28+
}
1629

17-
public init(name: String, contentType: ContentType? = nil, size: Int, content: ByteContent) {
30+
public var contentType: ContentType {
31+
name.components(separatedBy: ".").last.map { ContentType(fileExtension: $0) ?? .octetStream } ?? .octetStream
32+
}
33+
34+
public init(name: String, source: Source, content: ByteContent? = nil, size: Int? = nil) {
1835
self.name = name
19-
self.size = size
36+
self.source = source
2037
self.content = content
21-
let _extension = name.components(separatedBy: ".").last ?? ""
22-
self.contentType = contentType ?? ContentType(fileExtension: _extension) ?? .octetStream
38+
self.size = size
39+
self.clientContentType = nil
2340
}
2441

25-
/// Returns a copy of this file with a new name.
26-
public func named(_ name: String) -> File {
42+
public func _in(_ filesystem: Filesystem) -> File {
2743
var copy = self
28-
copy.name = name
44+
switch source {
45+
case .filesystem(_, let path):
46+
copy.source = .filesystem(filesystem, path: path)
47+
default:
48+
break
49+
}
50+
2951
return copy
3052
}
3153

54+
// MARK: - Accessing Contents
55+
56+
/// get a url for this resource
57+
public func url() throws -> URL {
58+
switch source {
59+
case .filesystem(let filesystem, let path):
60+
return try (filesystem ?? Storage).url(path)
61+
case .http:
62+
throw FileError.urlUnavailable
63+
}
64+
}
65+
66+
/// get temporary url for this resource
67+
public func temporaryUrl(expires: TimeAmount, headers: HTTPHeaders = [:]) async throws -> URL {
68+
switch source {
69+
case .filesystem(let filesystem, let path):
70+
return try await (filesystem ?? Storage).temporaryURL(path, expires: expires, headers: headers)
71+
default:
72+
throw FileError.temporaryUrlNotAvailable
73+
}
74+
}
75+
76+
public func getContent() async throws -> ByteContent {
77+
guard let content = content else {
78+
switch source {
79+
case .http:
80+
throw FileError.contentNotLoaded
81+
case .filesystem(let filesystem, let path):
82+
return try await (filesystem ?? Storage).get(path).getContent()
83+
}
84+
}
85+
86+
return content
87+
}
88+
89+
// MARK: ModelProperty
90+
91+
init(key: String, on row: SQLRowReader) throws {
92+
let name = try row.require(key).string()
93+
self.init(name: name, source: .filesystem(Storage, path: name))
94+
}
95+
96+
func store(key: String, on row: inout SQLRowWriter) throws {
97+
guard case .filesystem(_, let path) = source else {
98+
throw RuneError("currently, only files saved in a `Filesystem` can be stored on a `Model`")
99+
}
100+
101+
row.put(.string(path), at: key)
102+
}
103+
32104
// MARK: - ResponseConvertible
33105

34106
public func response() async throws -> Response {
35-
Response(status: .ok, headers: ["Content-Disposition":"inline; filename=\"\(name)\""])
107+
let content = try await getContent()
108+
return Response(status: .ok, headers: ["Content-Disposition":"inline; filename=\"\(name)\""])
36109
.withBody(content, type: contentType, length: size)
37110
}
38111

39112
public func download() async throws -> Response {
40-
Response(status: .ok, headers: ["Content-Disposition":"attachment; filename=\"\(name)\""])
113+
let content = try await getContent()
114+
return Response(status: .ok, headers: ["Content-Disposition":"attachment; filename=\"\(name)\""])
41115
.withBody(content, type: contentType, length: size)
42116
}
43117

44-
// MARK: - Decodable
45-
46-
enum CodingKeys: String, CodingKey {
47-
case name, size, content
48-
}
118+
// MARK: - Codable
49119

50120
public init(from decoder: Decoder) throws {
51-
let container = try decoder.container(keyedBy: CodingKeys.self)
52-
self.name = try container.decode(String.self, forKey: .name)
53-
self.size = try container.decode(Int.self, forKey: .size)
54-
self.content = .data(try container.decode(Data.self, forKey: .content))
55-
let _extension = name.components(separatedBy: ".").last ?? ""
56-
self.contentType = ContentType(fileExtension: _extension) ?? .octetStream
121+
let container = try decoder.singleValueContainer()
122+
let data = try container.decode(Data.self)
123+
self.name = UUID().uuidString
124+
self.source = .raw
125+
self.content = .data(data)
126+
self.size = data.count
127+
self.clientContentType = nil
57128
}
58129

59-
// MARK: - Encodable
60-
61130
public func encode(to encoder: Encoder) throws {
62-
var container = encoder.container(keyedBy: CodingKeys.self)
63-
try container.encode(name, forKey: .name)
64-
try container.encode(size, forKey: .size)
65-
try container.encode(content.data(), forKey: .content)
131+
var container = encoder.singleValueContainer()
132+
guard let content = content else {
133+
throw FileError.contentNotLoaded
134+
}
135+
136+
try container.encode(content.data())
66137
}
67138
}
68139

69140
// As of now, streamed files aren't possible over request multipart.
70141
extension File: MultipartPartConvertible {
71142
public var multipart: MultipartPart? {
72143
var headers: HTTPHeaders = [:]
73-
headers.contentType = ContentType(fileExtension: `extension`)
144+
headers.contentType = contentType
74145
headers.contentDisposition = HTTPHeaders.ContentDisposition(value: "form-data", name: nil, filename: name)
75146
headers.contentLength = size
76-
return MultipartPart(headers: headers, body: content.buffer)
147+
guard let content = self.content else {
148+
Log.warning("Unable to convert a filesystem reference to a `MultipartPart`. Please load the contents of the file first.")
149+
return nil
150+
}
151+
152+
return MultipartPart(headers: headers, body: content.data())
77153
}
78154

79155
public init?(multipart: MultipartPart) {
@@ -86,6 +162,8 @@ extension File: MultipartPartConvertible {
86162
}
87163

88164
// If there is no filename in the content disposition included (technically not required via RFC 7578) set to a random UUID.
89-
self.init(name: (fileName ?? UUID().uuidString) + fileExtension, contentType: multipart.headers.contentType, size: fileSize, content: .buffer(multipart.body))
165+
let name = (fileName ?? UUID().uuidString) + fileExtension
166+
let contentType = multipart.headers.contentType
167+
self.init(name: name, source: .http(clientContentType: contentType), content: .buffer(multipart.body), size: fileSize)
90168
}
91169
}

0 commit comments

Comments
 (0)