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

Add Encryption, Hashing, and expand Filesystem for better S3 support #85

Merged
merged 20 commits into from
Apr 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ let package = Package(
.package(url: "https://github.com/hummingbird-project/hummingbird-core.git", from: "0.13.3"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "3.0.0"),
.package(url: "https://github.com/vapor/postgres-kit", from: "2.4.0"),
.package(url: "https://github.com/vapor/mysql-kit", from: "4.3.0"),
.package(url: "https://github.com/vapor/sqlite-kit", from: "4.0.0"),
Expand All @@ -24,8 +25,8 @@ let package = Package(
.package(url: "https://github.com/alchemy-swift/fusion", .upToNextMinor(from: "0.3.0")),
.package(url: "https://github.com/alchemy-swift/cron.git", from: "2.3.2"),
.package(url: "https://github.com/alchemy-swift/pluralize", from: "1.0.1"),
.package(url: "https://github.com/johnsundell/Plot.git", from: "0.8.0"),
.package(url: "https://github.com/alchemy-swift/RediStack.git", branch: "ssl-support-1.2.0"),
.package(url: "https://github.com/johnsundell/Plot.git", from: "0.8.0"),
.package(url: "https://github.com/onevcat/Rainbow", .upToNextMajor(from: "4.0.0")),
.package(url: "https://github.com/vadymmarkov/Fakery", from: "5.0.0"),
],
Expand All @@ -52,6 +53,7 @@ let package = Package(
.product(name: "HummingbirdFoundation", package: "hummingbird"),
.product(name: "HummingbirdHTTP2", package: "hummingbird-core"),
.product(name: "HummingbirdTLS", package: "hummingbird-core"),
.product(name: "Crypto", package: "swift-crypto"),

/// Internal dependencies
.byName(name: "AlchemyC"),
Expand Down
1 change: 0 additions & 1 deletion Sources/Alchemy/Alchemy+Papyrus/Endpoint+Request.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ extension Client {
let method = HTTPMethod(rawValue: rawRequest.method)
let fullUrl = try rawRequest.fullURL()
builder = builder.withBaseUrl(fullUrl).withMethod(method)

if let mockedResponse = endpoint.mockedResponse {
let clientRequest = builder.clientRequest
let clientResponse = Client.Response(request: clientRequest, host: "mock", status: .ok, version: .http1_1, headers: [:])
Expand Down
2 changes: 1 addition & 1 deletion Sources/Alchemy/Auth/BasicAuthable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ extension BasicAuthable {
/// - Returns: A `Bool` indicating if `password` matched
/// `passwordHash`.
public static func verify(password: String, passwordHash: String) throws -> Bool {
try Bcrypt.verifySync(password, created: passwordHash)
try Hash.verify(password, hash: passwordHash)
}

/// A `Middleware` configured to validate the
Expand Down
2 changes: 1 addition & 1 deletion Sources/Alchemy/Config/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ extension Service {
// MARK: Resolve shorthand

public static var `default`: Self {
Container.resolveAssert(Self.self, identifier: Database.Identifier.default)
.id(.default)
}

public static func id(_ identifier: Identifier) -> Self {
Expand Down
21 changes: 21 additions & 0 deletions Sources/Alchemy/Encryption/Encrypted.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@propertyWrapper
public struct Encrypted: ModelProperty {
public var wrappedValue: String

// MARK: ModelProperty

public init(key: String, on row: SQLRowReader) throws {
let encrypted = try row.require(key).string()
guard let data = Data(base64Encoded: encrypted) else {
throw EncryptionError("could not decrypt data; it wasn't base64 encoded")
}

wrappedValue = try Crypt.decrypt(data: data)
}

public func store(key: String, on row: inout SQLRowWriter) throws {
let encrypted = try Crypt.encrypt(string: wrappedValue)
let string = encrypted.base64EncodedString()
row.put(.string(string), at: key)
}
}
58 changes: 58 additions & 0 deletions Sources/Alchemy/Encryption/Encrypter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Crypto
import Foundation

extension SymmetricKey {
public static var app: SymmetricKey = {
guard let appKey: String = Env.APP_KEY else {
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:)`.")
}

guard let data = Data(base64Encoded: appKey) else {
fatalError("Unable to create encryption key from APP_KEY. Please ensure APP_KEY is a base64 encoded String.")
}

return SymmetricKey(data: data)
}()
}

public struct Encrypter {
private let key: SymmetricKey

public init(key: SymmetricKey) {
self.key = key
}

public func encrypt(string: String) throws -> Data {
try encrypt(data: Data(string.utf8))
}

public func encrypt<D: DataProtocol>(data: D) throws -> Data {
guard let result = try AES.GCM.seal(data, using: key).combined else {
throw EncryptionError("could not encrypt the data")
}

return result
}

public func decrypt(base64Encoded string: String) throws -> String {
guard let data = Data(base64Encoded: string) else {
throw EncryptionError("the string wasn't base64 encoded")
}

return try decrypt(data: data)
}

public func decrypt<D: DataProtocol>(data: D) throws -> String {
let box = try AES.GCM.SealedBox(combined: data)
let data = try AES.GCM.open(box, using: key)
guard let string = String(data: data, encoding: .utf8) else {
throw EncryptionError("could not decrypt the data")
}

return string
}

public static func generateKeyString(size: SymmetricKeySize = .bits256) -> String {
SymmetricKey(size: size).withUnsafeBytes { Data($0) }.base64EncodedString()
}
}
7 changes: 7 additions & 0 deletions Sources/Alchemy/Encryption/EncryptionError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
public struct EncryptionError: Error {
public let message: String

public init(_ message: String) {
self.message = message
}
}
154 changes: 116 additions & 38 deletions Sources/Alchemy/Filesystem/File.swift
Original file line number Diff line number Diff line change
@@ -1,79 +1,155 @@
import MultipartKit
import Papyrus
import NIOCore

/// Represents a file with a name and binary contents.
// File
public struct File: Codable, ResponseConvertible {
// The name of the file, including the extension.
public enum Source {
// The file is stored in a `Filesystem` with the given path.
case filesystem(Filesystem? = nil, path: String)
// The file came with the given ContentType from an HTTP request.
case http(clientContentType: ContentType?)

static var raw: Source {
.http(clientContentType: nil)
}
}

/// The name of this file, including the extension
public var name: String
// The size of the file, in bytes.
public let size: Int
// The binary contents of the file.
public var content: ByteContent
/// The source of this file, either from an HTTP request or from a Filesystem.
public var source: Source
public var content: ByteContent?
public let size: Int?
public let clientContentType: ContentType?
/// The path extension of this file.
public var `extension`: String { name.components(separatedBy: ".").last ?? "" }
/// The content type of this file, based on it's extension.
public let contentType: ContentType
public var `extension`: String {
name.components(separatedBy: ".").last ?? ""
}

public init(name: String, contentType: ContentType? = nil, size: Int, content: ByteContent) {
public var contentType: ContentType {
name.components(separatedBy: ".").last.map { ContentType(fileExtension: $0) ?? .octetStream } ?? .octetStream
}

public init(name: String, source: Source, content: ByteContent? = nil, size: Int? = nil) {
self.name = name
self.size = size
self.source = source
self.content = content
let _extension = name.components(separatedBy: ".").last ?? ""
self.contentType = contentType ?? ContentType(fileExtension: _extension) ?? .octetStream
self.size = size
self.clientContentType = nil
}

/// Returns a copy of this file with a new name.
public func named(_ name: String) -> File {
public func _in(_ filesystem: Filesystem) -> File {
var copy = self
copy.name = name
switch source {
case .filesystem(_, let path):
copy.source = .filesystem(filesystem, path: path)
default:
break
}

return copy
}

// MARK: - Accessing Contents

/// get a url for this resource
public func url() throws -> URL {
switch source {
case .filesystem(let filesystem, let path):
return try (filesystem ?? Storage).url(path)
case .http:
throw FileError.urlUnavailable
}
}

/// get temporary url for this resource
public func temporaryUrl(expires: TimeAmount, headers: HTTPHeaders = [:]) async throws -> URL {
switch source {
case .filesystem(let filesystem, let path):
return try await (filesystem ?? Storage).temporaryURL(path, expires: expires, headers: headers)
default:
throw FileError.temporaryUrlNotAvailable
}
}

public func getContent() async throws -> ByteContent {
guard let content = content else {
switch source {
case .http:
throw FileError.contentNotLoaded
case .filesystem(let filesystem, let path):
return try await (filesystem ?? Storage).get(path).getContent()
}
}

return content
}

// MARK: ModelProperty

init(key: String, on row: SQLRowReader) throws {
let name = try row.require(key).string()
self.init(name: name, source: .filesystem(Storage, path: name))
}

func store(key: String, on row: inout SQLRowWriter) throws {
guard case .filesystem(_, let path) = source else {
throw RuneError("currently, only files saved in a `Filesystem` can be stored on a `Model`")
}

row.put(.string(path), at: key)
}

// MARK: - ResponseConvertible

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

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

// MARK: - Decodable

enum CodingKeys: String, CodingKey {
case name, size, content
}
// MARK: - Codable

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)
self.size = try container.decode(Int.self, forKey: .size)
self.content = .data(try container.decode(Data.self, forKey: .content))
let _extension = name.components(separatedBy: ".").last ?? ""
self.contentType = ContentType(fileExtension: _extension) ?? .octetStream
let container = try decoder.singleValueContainer()
let data = try container.decode(Data.self)
self.name = UUID().uuidString
self.source = .raw
self.content = .data(data)
self.size = data.count
self.clientContentType = nil
}

// MARK: - Encodable

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(size, forKey: .size)
try container.encode(content.data(), forKey: .content)
var container = encoder.singleValueContainer()
guard let content = content else {
throw FileError.contentNotLoaded
}

try container.encode(content.data())
}
}

// As of now, streamed files aren't possible over request multipart.
extension File: MultipartPartConvertible {
public var multipart: MultipartPart? {
var headers: HTTPHeaders = [:]
headers.contentType = ContentType(fileExtension: `extension`)
headers.contentType = contentType
headers.contentDisposition = HTTPHeaders.ContentDisposition(value: "form-data", name: nil, filename: name)
headers.contentLength = size
return MultipartPart(headers: headers, body: content.buffer)
guard let content = self.content else {
Log.warning("Unable to convert a filesystem reference to a `MultipartPart`. Please load the contents of the file first.")
return nil
}

return MultipartPart(headers: headers, body: content.data())
}

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

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