Skip to content

Commit

Permalink
Add Encryption, Hashing, and expand Filesystem for better S3 support (#…
Browse files Browse the repository at this point in the history
…85)

An S3 / S3 compatible driver for Filesystem is in the alchemy-aws repo.
  • Loading branch information
joshuawright11 authored Apr 11, 2022
1 parent 70c07e1 commit 49f7e1a
Show file tree
Hide file tree
Showing 32 changed files with 554 additions and 193 deletions.
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

0 comments on commit 49f7e1a

Please sign in to comment.