mirror of https://github.com/oxen-io/session-ios
Started work on GRDB logic and migrations
Setup a migration pattern Setup the database configuration and security Started defining the database schema Started working on the migrations for SessionSnodeKitpull/612/head
parent
1a6c34e3b8
commit
529e416dd1
@ -0,0 +1,6 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum Legacy {
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
// TODO: Remove/Move these
|
||||
struct Place: Codable, FetchableRecord, PersistableRecord, ColumnExpressible {
|
||||
static var databaseTableName: String { "place" }
|
||||
|
||||
public enum Columns: String, CodingKey, ColumnExpression {
|
||||
case id
|
||||
case name
|
||||
}
|
||||
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct Setting: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
static var databaseTableName: String { "settings" }
|
||||
|
||||
public enum Columns: String, CodingKey, ColumnExpression {
|
||||
case key
|
||||
case value
|
||||
}
|
||||
|
||||
let key: String
|
||||
let value: Data
|
||||
}
|
||||
|
||||
enum _001_InitialSetupMigration: Migration {
|
||||
static let identifier: String = "initialSetup"
|
||||
|
||||
static func migrate(_ db: Database) throws {
|
||||
try db.create(table: Setting.self) { t in
|
||||
t.column(.key, .text)
|
||||
.notNull()
|
||||
.unique(onConflict: .abort)
|
||||
.primaryKey()
|
||||
t.column(.value, .blob).notNull()
|
||||
}
|
||||
|
||||
try db.create(table: Place.self) { t in
|
||||
t.column(.id, .text).notNull().primaryKey()
|
||||
t.column(.name, .text).notNull()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
enum _002_YDBToGRDBMigration: Migration {
|
||||
static let identifier: String = "YDBToGRDBMigration"
|
||||
|
||||
static func migrate(_ db: Database) throws {
|
||||
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum Legacy {
|
||||
// MARK: - Collections and Keys
|
||||
|
||||
internal static let swarmCollectionPrefix = "LokiSwarmCollection-"
|
||||
internal static let snodePoolCollection = "LokiSnodePoolCollection"
|
||||
internal static let onionRequestPathCollection = "LokiOnionRequestPathCollection"
|
||||
internal static let lastSnodePoolRefreshDateCollection = "LokiLastSnodePoolRefreshDateCollection"
|
||||
internal static let lastMessageHashCollection = "LokiLastMessageHashCollection" // TODO: Remove this one? (make it a query??)
|
||||
internal static let receivedMessagesCollection = "LokiReceivedMessagesCollection"
|
||||
// TODO: - "lastSnodePoolRefreshDate"
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
public typealias LegacyOnionRequestAPIPath = [Snode]
|
||||
|
||||
@objc(Snode)
|
||||
public final class Snode: NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
|
||||
public let address: String
|
||||
public let port: UInt16
|
||||
public let publicKeySet: KeySet
|
||||
|
||||
public var ip: String {
|
||||
guard let range = address.range(of: "https://"), range.lowerBound == address.startIndex else { return address }
|
||||
return String(address[range.upperBound..<address.endIndex])
|
||||
}
|
||||
|
||||
// MARK: Nested Types
|
||||
public enum Method : String {
|
||||
case getSwarm = "get_snodes_for_pubkey"
|
||||
case getMessages = "retrieve"
|
||||
case sendMessage = "store"
|
||||
case deleteMessage = "delete"
|
||||
case oxenDaemonRPCCall = "oxend_request"
|
||||
case getInfo = "info"
|
||||
case clearAllData = "delete_all"
|
||||
}
|
||||
|
||||
public struct KeySet {
|
||||
public let ed25519Key: String
|
||||
public let x25519Key: String
|
||||
}
|
||||
|
||||
// MARK: Initialization
|
||||
internal init(address: String, port: UInt16, publicKeySet: KeySet) {
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.publicKeySet = publicKeySet
|
||||
}
|
||||
|
||||
// MARK: Coding
|
||||
public init?(coder: NSCoder) {
|
||||
address = coder.decodeObject(forKey: "address") as! String
|
||||
port = coder.decodeObject(forKey: "port") as! UInt16
|
||||
guard let idKey = coder.decodeObject(forKey: "idKey") as? String,
|
||||
let encryptionKey = coder.decodeObject(forKey: "encryptionKey") as? String else { return nil }
|
||||
publicKeySet = KeySet(ed25519Key: idKey, x25519Key: encryptionKey)
|
||||
super.init()
|
||||
}
|
||||
|
||||
public func encode(with coder: NSCoder) {
|
||||
coder.encode(address, forKey: "address")
|
||||
coder.encode(port, forKey: "port")
|
||||
coder.encode(publicKeySet.ed25519Key, forKey: "idKey")
|
||||
coder.encode(publicKeySet.x25519Key, forKey: "encryptionKey")
|
||||
}
|
||||
|
||||
override public func isEqual(_ other: Any?) -> Bool {
|
||||
guard let other = other as? Snode else { return false }
|
||||
return address == other.address && port == other.port
|
||||
}
|
||||
|
||||
override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:)
|
||||
return address.hashValue ^ port.hashValue
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
enum _001_InitialSetupMigration: Migration {
|
||||
static let identifier: String = "initialSetup"
|
||||
|
||||
static func migrate(_ db: Database) throws {
|
||||
try db.create(table: Snode.self) { t in
|
||||
t.column(.address, .text).notNull()
|
||||
t.column(.port, .integer).notNull()
|
||||
t.column(.ed25519PublicKey, .text).notNull()
|
||||
t.column(.x25519PublicKey, .text).notNull()
|
||||
|
||||
t.primaryKey([.address, .port])
|
||||
}
|
||||
|
||||
try db.create(table: SnodeSet.self) { t in
|
||||
t.column(.key, .text).notNull()
|
||||
t.column(.nodeIndex, .integer).notNull()
|
||||
t.column(.address, .text).notNull()
|
||||
t.column(.port, .integer).notNull()
|
||||
|
||||
t.foreignKey(
|
||||
[.address, .port],
|
||||
references: Snode.self,
|
||||
columns: [.address, .port],
|
||||
onDelete: .cascade
|
||||
)
|
||||
t.primaryKey([.key, .nodeIndex])
|
||||
}
|
||||
|
||||
try db.create(table: SnodeReceivedMessageInfo.self) { t in
|
||||
t.column(.key, .text)
|
||||
.notNull()
|
||||
.indexed()
|
||||
t.column(.hash, .text).notNull()
|
||||
t.column(.expirationDateMs, .integer)
|
||||
.notNull()
|
||||
.indexed()
|
||||
|
||||
t.primaryKey([.key, .hash])
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,138 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
enum _002_YDBToGRDBMigration: Migration {
|
||||
static let identifier: String = "YDBToGRDBMigration"
|
||||
|
||||
// TODO: Autorelease pool???
|
||||
static func migrate(_ db: Database) throws {
|
||||
// MARK: - OnionRequestPath, Snode Pool & Swarm
|
||||
|
||||
// Note: Want to exclude the Snode's we already added from the 'onionRequestPathResult'
|
||||
var snodeResult: Set<Legacy.Snode> = []
|
||||
var snodeSetResult: [String: Set<Legacy.Snode>] = [:]
|
||||
|
||||
Storage.read { transaction in
|
||||
// Process the OnionRequestPaths
|
||||
if
|
||||
let path0Snode0 = transaction.object(forKey: "0-0", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode,
|
||||
let path0Snode1 = transaction.object(forKey: "0-1", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode,
|
||||
let path0Snode2 = transaction.object(forKey: "0-2", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode
|
||||
{
|
||||
snodeResult.insert(path0Snode0)
|
||||
snodeResult.insert(path0Snode1)
|
||||
snodeResult.insert(path0Snode2)
|
||||
snodeSetResult["\(SnodeSet.onionRequestPathPrefix)0"] = [ path0Snode0, path0Snode1, path0Snode2 ]
|
||||
|
||||
if
|
||||
let path1Snode0 = transaction.object(forKey: "1-0", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode,
|
||||
let path1Snode1 = transaction.object(forKey: "1-1", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode,
|
||||
let path1Snode2 = transaction.object(forKey: "1-2", inCollection: Legacy.onionRequestPathCollection) as? Legacy.Snode
|
||||
{
|
||||
snodeResult.insert(path1Snode0)
|
||||
snodeResult.insert(path1Snode1)
|
||||
snodeResult.insert(path1Snode2)
|
||||
snodeSetResult["\(SnodeSet.onionRequestPathPrefix)1"] = [ path1Snode0, path1Snode1, path1Snode2 ]
|
||||
}
|
||||
}
|
||||
|
||||
// Process the SnodePool
|
||||
transaction.enumerateKeysAndObjects(inCollection: Legacy.snodePoolCollection) { _, object, _ in
|
||||
guard let snode = object as? Legacy.Snode else { return }
|
||||
snodeResult.insert(snode)
|
||||
}
|
||||
|
||||
// Process the Swarms
|
||||
var swarmCollections: Set<String> = []
|
||||
|
||||
transaction.enumerateCollections { collectionName, _ in
|
||||
if collectionName.starts(with: Legacy.swarmCollectionPrefix) {
|
||||
swarmCollections.insert(collectionName.substring(from: Legacy.swarmCollectionPrefix.count))
|
||||
}
|
||||
}
|
||||
|
||||
for swarmCollection in swarmCollections {
|
||||
let collection: String = "\(Legacy.swarmCollectionPrefix)\(swarmCollection)"
|
||||
|
||||
transaction.enumerateKeysAndObjects(inCollection: collection) { _, object, _ in
|
||||
guard let snode = object as? Legacy.Snode else { return }
|
||||
snodeResult.insert(snode)
|
||||
snodeSetResult[swarmCollection] = (snodeSetResult[swarmCollection] ?? Set()).inserting(snode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try snodeResult.forEach { legacySnode in
|
||||
try Snode(
|
||||
address: legacySnode.address,
|
||||
port: legacySnode.port,
|
||||
ed25519PublicKey: legacySnode.publicKeySet.ed25519Key,
|
||||
x25519PublicKey: legacySnode.publicKeySet.x25519Key
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
try snodeSetResult.forEach { key, legacySnodeSet in
|
||||
try legacySnodeSet.enumerated().forEach { nodeIndex, legacySnode in
|
||||
// Note: In this case the 'nodeIndex' is irrelivant
|
||||
try SnodeSet(
|
||||
key: key,
|
||||
nodeIndex: UInt(nodeIndex),
|
||||
address: legacySnode.address,
|
||||
port: legacySnode.port
|
||||
).insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This
|
||||
// public func setLastSnodePoolRefreshDate(to date: Date, using transaction: Any) {
|
||||
// (transaction as! YapDatabaseReadWriteTransaction).setObject(date, forKey: "lastSnodePoolRefreshDate", inCollection: Storage.lastSnodePoolRefreshDateCollection)
|
||||
// }
|
||||
|
||||
print("RAWR")
|
||||
|
||||
// MARK: - Received Messages & Last Message Hash
|
||||
|
||||
var lastMessageResults: [String: (hash: String, json: JSON)] = [:]
|
||||
var receivedMessageResults: [String: Set<String>] = [:]
|
||||
|
||||
Storage.read { transaction in
|
||||
// Extract the received message hashes
|
||||
transaction.enumerateKeysAndObjects(inCollection: Legacy.receivedMessagesCollection) { key, object, _ in
|
||||
guard let hashSet = object as? Set<String> else { return }
|
||||
receivedMessageResults[key] = hashSet
|
||||
}
|
||||
|
||||
// Retrieve the last message info
|
||||
transaction.enumerateKeysAndObjects(inCollection: Legacy.lastMessageHashCollection) { key, object, _ in
|
||||
guard let lastMessageJson = object as? JSON else { return }
|
||||
guard let lastMessageHash: String = lastMessageJson["hash"] as? String else { return }
|
||||
|
||||
// Note: We remove the value from 'receivedMessageResults' as we don't want to default it's
|
||||
// expiration value to 0
|
||||
lastMessageResults[key] = (lastMessageHash, lastMessageJson)
|
||||
receivedMessageResults[key] = receivedMessageResults[key]?.removing(lastMessageHash)
|
||||
}
|
||||
}
|
||||
|
||||
try receivedMessageResults.forEach { key, hashes in
|
||||
try hashes.forEach { hash in
|
||||
try SnodeReceivedMessageInfo(
|
||||
key: key,
|
||||
hash: hash,
|
||||
expirationDateMs: 0
|
||||
).insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
try lastMessageResults.forEach { key, data in
|
||||
try SnodeReceivedMessageInfo(
|
||||
key: key,
|
||||
hash: data.hash,
|
||||
expirationDateMs: ((data.json["expirationDate"] as? Int64) ?? 0)
|
||||
).insert(db)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public struct Snode: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, Hashable {
|
||||
public static var databaseTableName: String { "snode" }
|
||||
|
||||
public enum Columns: String, CodingKey, ColumnExpression {
|
||||
case address
|
||||
case port
|
||||
case ed25519PublicKey
|
||||
case x25519PublicKey
|
||||
}
|
||||
|
||||
let address: String
|
||||
let port: UInt16
|
||||
let ed25519PublicKey: String
|
||||
let x25519PublicKey: String
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
struct SnodeReceivedMessageInfo: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
static var databaseTableName: String { "snodeReceivedMessageInfo" }
|
||||
|
||||
public enum Columns: String, CodingKey, ColumnExpression {
|
||||
case key
|
||||
case hash
|
||||
case expirationDateMs
|
||||
}
|
||||
|
||||
let key: String
|
||||
let hash: String
|
||||
let expirationDateMs: Int64
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
struct SnodeSet: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
static var databaseTableName: String { "snodeSet" }
|
||||
static let nodes = hasMany(Snode.self)
|
||||
static let onionRequestPathPrefix = "OnionRequestPath-"
|
||||
|
||||
public enum Columns: String, CodingKey, ColumnExpression {
|
||||
case key
|
||||
case nodeIndex
|
||||
case address
|
||||
case port
|
||||
}
|
||||
|
||||
let key: String
|
||||
let nodeIndex: UInt
|
||||
let address: String
|
||||
let port: UInt16
|
||||
|
||||
var nodes: QueryInterfaceRequest<Snode> {
|
||||
request(for: SnodeSet.nodes)
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
@ -1,65 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public final class Snode : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
|
||||
public let address: String
|
||||
public let port: UInt16
|
||||
public let publicKeySet: KeySet
|
||||
|
||||
public var ip: String {
|
||||
address.removingPrefix("https://")
|
||||
}
|
||||
|
||||
// MARK: Nested Types
|
||||
public enum Method : String {
|
||||
case getSwarm = "get_snodes_for_pubkey"
|
||||
case getMessages = "retrieve"
|
||||
case sendMessage = "store"
|
||||
case deleteMessage = "delete"
|
||||
case oxenDaemonRPCCall = "oxend_request"
|
||||
case getInfo = "info"
|
||||
case clearAllData = "delete_all"
|
||||
}
|
||||
|
||||
public struct KeySet {
|
||||
public let ed25519Key: String
|
||||
public let x25519Key: String
|
||||
}
|
||||
|
||||
// MARK: Initialization
|
||||
internal init(address: String, port: UInt16, publicKeySet: KeySet) {
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.publicKeySet = publicKeySet
|
||||
}
|
||||
|
||||
// MARK: Coding
|
||||
public init?(coder: NSCoder) {
|
||||
address = coder.decodeObject(forKey: "address") as! String
|
||||
port = coder.decodeObject(forKey: "port") as! UInt16
|
||||
guard let idKey = coder.decodeObject(forKey: "idKey") as? String,
|
||||
let encryptionKey = coder.decodeObject(forKey: "encryptionKey") as? String else { return nil }
|
||||
publicKeySet = KeySet(ed25519Key: idKey, x25519Key: encryptionKey)
|
||||
super.init()
|
||||
}
|
||||
|
||||
public func encode(with coder: NSCoder) {
|
||||
coder.encode(address, forKey: "address")
|
||||
coder.encode(port, forKey: "port")
|
||||
coder.encode(publicKeySet.ed25519Key, forKey: "idKey")
|
||||
coder.encode(publicKeySet.x25519Key, forKey: "encryptionKey")
|
||||
}
|
||||
|
||||
// MARK: Equality
|
||||
override public func isEqual(_ other: Any?) -> Bool {
|
||||
guard let other = other as? Snode else { return false }
|
||||
return address == other.address && port == other.port
|
||||
}
|
||||
|
||||
// MARK: Hashing
|
||||
override public var hash: Int { // Override NSObject.hash and not Hashable.hashValue or Hashable.hash(into:)
|
||||
return address.hashValue ^ port.hashValue
|
||||
}
|
||||
|
||||
// MARK: Description
|
||||
override public var description: String { return "\(address):\(port)" }
|
||||
}
|
@ -0,0 +1,224 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SignalCoreKit
|
||||
|
||||
enum GRDBStorageError: Error { // TODO: Rename to `StorageError`
|
||||
case invalidKeySpec
|
||||
}
|
||||
|
||||
// TODO: Protocol for storage (just need to have 'read' and 'write' methods and mock 'Database'?
|
||||
|
||||
// TODO: Rename to `Storage`
|
||||
public final class GRDBStorage {
|
||||
public static var shared: GRDBStorage! // TODO: Figure out how/if we want to do this
|
||||
|
||||
private static let dbFileName: String = "Session.sqlite"
|
||||
private static let keychainService: String = "TSKeyChainService"
|
||||
private static let dbCipherKeySpecKey: String = "GRDBDatabaseCipherKeySpec"
|
||||
private static let kSQLCipherKeySpecLength: Int32 = 48
|
||||
|
||||
private static var sharedDatabaseDirectoryPath: String { "\(OWSFileSystem.appSharedDataDirectoryPath())/database" }
|
||||
private static var databasePath: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)" }
|
||||
private static var databasePathShm: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-shm" }
|
||||
private static var databasePathWal: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-wal" }
|
||||
|
||||
private let dbPool: DatabasePool
|
||||
private let migrator: DatabaseMigrator
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init?(
|
||||
migrations: [TargetMigrations]
|
||||
) throws {
|
||||
print("RAWR START \("\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)")")
|
||||
GRDBStorage.deleteDatabaseFiles() // TODO: Remove this
|
||||
try! GRDBStorage.deleteDbKeys() // TODO: Remove this
|
||||
|
||||
// Create the database directory if needed and ensure it's protection level is set before attempting to
|
||||
// create the database KeySpec or the database itself
|
||||
OWSFileSystem.ensureDirectoryExists(GRDBStorage.sharedDatabaseDirectoryPath)
|
||||
OWSFileSystem.protectFileOrFolder(atPath: GRDBStorage.sharedDatabaseDirectoryPath)
|
||||
|
||||
// Generate the database KeySpec if needed (this MUST be done before we try to access the database
|
||||
// as a different thread might attempt to access the database before the key is successfully created)
|
||||
//
|
||||
// Note: We reset the bytes immediately after generation to ensure the database key doesn't hang
|
||||
// around in memory unintentionally
|
||||
var tmpKeySpec: Data = GRDBStorage.getOrGenerateDatabaseKeySpec()
|
||||
tmpKeySpec.resetBytes(in: 0..<tmpKeySpec.count)
|
||||
|
||||
// Configure the database and create the DatabasePool for interacting with the database
|
||||
var config = Configuration()
|
||||
config.maximumReaderCount = 10 // Increase the max read connection limit - Default is 5
|
||||
config.prepareDatabase { db in
|
||||
var keySpec: Data = GRDBStorage.getOrGenerateDatabaseKeySpec()
|
||||
defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
|
||||
// Use a raw key spec, where the 96 hexadecimal digits are provided
|
||||
// (i.e. 64 hex for the 256 bit key, followed by 32 hex for the 128 bit salt)
|
||||
// using explicit BLOB syntax, e.g.:
|
||||
//
|
||||
// x'98483C6EB40B6C31A448C22A66DED3B5E5E8D5119CAC8327B655C8B5C483648101010101010101010101010101010101'
|
||||
keySpec = try (keySpec.toHexString().data(using: .utf8) ?? { throw GRDBStorageError.invalidKeySpec }())
|
||||
keySpec.insert(contentsOf: [120, 39], at: 0) // "x'" prefix
|
||||
keySpec.append(39) // "'" suffix
|
||||
|
||||
try db.usePassphrase(keySpec)
|
||||
|
||||
// According to the SQLCipher docs iOS needs the 'cipher_plaintext_header_size' value set to at least
|
||||
// 32 as iOS extends special privileges to the database and needs this header to be in plaintext
|
||||
// to determine the file type
|
||||
//
|
||||
// For more info see: https://www.zetetic.net/sqlcipher/sqlcipher-api/#cipher_plaintext_header_size
|
||||
try db.execute(sql: "PRAGMA cipher_plaintext_header_size = 32")
|
||||
}
|
||||
|
||||
// Create the DatabasePool to allow us to connect to the database
|
||||
dbPool = try DatabasePool(
|
||||
path: "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)",
|
||||
configuration: config
|
||||
)
|
||||
|
||||
// Setup and run any required migrations
|
||||
migrator = {
|
||||
var migrator: DatabaseMigrator = DatabaseMigrator()
|
||||
migrations
|
||||
.sorted()
|
||||
.reduce(into: [[(identifier: TargetMigrations.Identifier, migrations: TargetMigrations.MigrationSet)]]()) { result, next in
|
||||
next.migrations.enumerated().forEach { index, migrationSet in
|
||||
if result.count <= index {
|
||||
result.append([])
|
||||
}
|
||||
|
||||
result[index] = (result[index] + [(next.identifier, migrationSet)])
|
||||
}
|
||||
}
|
||||
.compactMap { $0 }
|
||||
.forEach { sortedMigrationInfo in
|
||||
sortedMigrationInfo.forEach { migrationInfo in
|
||||
migrationInfo.migrations.forEach { migration in
|
||||
migrator.registerMigration(migrationInfo.identifier, migration: migration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return migrator
|
||||
}()
|
||||
try! migrator.migrate(dbPool)
|
||||
|
||||
GRDBStorage.shared = self // TODO: Fix this
|
||||
}
|
||||
|
||||
// MARK: - Security
|
||||
|
||||
private static func getDatabaseCipherKeySpec() throws -> Data {
|
||||
return try CurrentAppContext().keychainStorage().data(forService: keychainService, key: dbCipherKeySpecKey)
|
||||
}
|
||||
|
||||
@discardableResult private static func getOrGenerateDatabaseKeySpec() -> Data {
|
||||
do {
|
||||
var keySpec: Data = try getDatabaseCipherKeySpec()
|
||||
defer { keySpec.resetBytes(in: 0..<keySpec.count) }
|
||||
|
||||
guard keySpec.count == kSQLCipherKeySpecLength else { throw GRDBStorageError.invalidKeySpec }
|
||||
|
||||
return keySpec
|
||||
}
|
||||
catch {
|
||||
print("RAWR \(error.localizedDescription), \((error as? KeychainStorageError)?.code), \(errSecItemNotFound)")
|
||||
|
||||
switch (error, (error as? KeychainStorageError)?.code) {
|
||||
// TODO: Are there other errors we know about that indicate an invalid keychain?
|
||||
// errSecNotAvailable: OSStatus { get } /* No keychain is available. You may need to restart your computer. */
|
||||
// public var errSecNoSuchKeychain
|
||||
|
||||
//errSecInteractionNotAllowed
|
||||
|
||||
case (GRDBStorageError.invalidKeySpec, _):
|
||||
// For these cases it means either the keySpec or the keychain has become corrupt so in order to
|
||||
// get back to a "known good state" and behave like a new install we need to reset the storage
|
||||
// and regenerate the key
|
||||
// TODO: Check what this 'isRunningTests' does (use the approach to check if XCTTestCase exists instead?)
|
||||
if !CurrentAppContext().isRunningTests {
|
||||
// Try to reset app by deleting database.
|
||||
resetAllStorage()
|
||||
}
|
||||
fallthrough
|
||||
|
||||
case (_, errSecItemNotFound):
|
||||
// No keySpec was found so we need to generate a new one
|
||||
do {
|
||||
var keySpec: Data = Randomness.generateRandomBytes(kSQLCipherKeySpecLength)
|
||||
defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
|
||||
|
||||
try CurrentAppContext().keychainStorage().set(data: keySpec, service: keychainService, key: dbCipherKeySpecKey)
|
||||
print("RAWR new keySpec generated and saved")
|
||||
return keySpec
|
||||
}
|
||||
catch {
|
||||
Thread.sleep(forTimeInterval: 15) // Sleep to allow any background behaviours to complete
|
||||
fatalError("Setting keychain value failed with error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
default:
|
||||
// Because we use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, the keychain will be inaccessible
|
||||
// after device restart until device is unlocked for the first time. If the app receives a push
|
||||
// notification, we won't be able to access the keychain to process that notification, so we should
|
||||
// just terminate by throwing an uncaught exception
|
||||
if CurrentAppContext().isMainApp || CurrentAppContext().isInBackground() {
|
||||
let appState: UIApplication.State = CurrentAppContext().reportedApplicationState
|
||||
|
||||
// In this case we should have already detected the situation earlier and exited gracefully (in the
|
||||
// app delegate) using isDatabasePasswordAccessible, but we want to stop the app running here anyway
|
||||
Thread.sleep(forTimeInterval: 5) // Sleep to allow any background behaviours to complete
|
||||
fatalError("CipherKeySpec inaccessible. New install or no unlock since device restart?, ApplicationState: \(NSStringForUIApplicationState(appState))")
|
||||
}
|
||||
|
||||
Thread.sleep(forTimeInterval: 5) // Sleep to allow any background behaviours to complete
|
||||
fatalError("CipherKeySpec inaccessible; not main app.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File Management
|
||||
|
||||
private static func resetAllStorage() {
|
||||
NotificationCenter.default.post(name: .resetStorage, object: nil)
|
||||
|
||||
// This might be redundant but in the spirit of thoroughness...
|
||||
self.deleteDatabaseFiles()
|
||||
|
||||
try? self.deleteDbKeys()
|
||||
|
||||
if CurrentAppContext().isMainApp {
|
||||
// TSAttachmentStream.deleteAttachments()
|
||||
}
|
||||
|
||||
// TODO: Delete Profiles on Disk?
|
||||
}
|
||||
|
||||
private static func deleteDatabaseFiles() {
|
||||
OWSFileSystem.deleteFile(databasePath)
|
||||
OWSFileSystem.deleteFile(databasePathShm)
|
||||
OWSFileSystem.deleteFile(databasePathWal)
|
||||
}
|
||||
|
||||
private static func deleteDbKeys() throws {
|
||||
try CurrentAppContext().keychainStorage().remove(service: keychainService, key: dbCipherKeySpecKey)
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func write<T>(updates: (Database) throws -> T) throws -> T {
|
||||
return try dbPool.write(updates)
|
||||
}
|
||||
|
||||
public func writeAsync<T>(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Result<T, Error>) -> Void) {
|
||||
dbPool.asyncWrite(updates, completion: completion)
|
||||
}
|
||||
|
||||
public func read<T>(_ value: (Database) throws -> T) throws -> T {
|
||||
return try dbPool.read(value)
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
public protocol ColumnExpressible {
|
||||
associatedtype Columns: ColumnExpression
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
public protocol Migration {
|
||||
static var identifier: String { get }
|
||||
|
||||
static func migrate(_ db: Database) throws
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
@ -0,0 +1,59 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct TargetMigrations: Comparable {
|
||||
/// This identifier is used to determine the order each set of migrations should run in.
|
||||
///
|
||||
/// All migrations within a specific set will run first, followed by all migrations for the same set index in
|
||||
/// the next `Identifier` before moving on to the next `MigrationSet`. So given the migrations:
|
||||
///
|
||||
/// `{a: [1], [2, 3]}, {b: [4, 5], [6]}`
|
||||
///
|
||||
/// the migrations will run in the following order:
|
||||
///
|
||||
/// `a1, b4, b5, a2, a3, b6`
|
||||
public enum Identifier: String, CaseIterable, Comparable {
|
||||
// WARNING: The string version of these cases are used as migration identifiers so
|
||||
// changing them will result in the migrations running again
|
||||
case snodeKit
|
||||
case messagingKit
|
||||
|
||||
public static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
let lhsIndex: Int = (Identifier.allCases.firstIndex(of: lhs) ?? Identifier.allCases.count)
|
||||
let rhsIndex: Int = (Identifier.allCases.firstIndex(of: rhs) ?? Identifier.allCases.count)
|
||||
|
||||
return (lhsIndex < rhsIndex)
|
||||
}
|
||||
}
|
||||
|
||||
public typealias MigrationSet = [Migration.Type]
|
||||
|
||||
let identifier: Identifier
|
||||
let migrations: [MigrationSet]
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
identifier: Identifier,
|
||||
migrations: [MigrationSet]
|
||||
) {
|
||||
self.identifier = identifier
|
||||
self.migrations = migrations
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
public static func == (lhs: TargetMigrations, rhs: TargetMigrations) -> Bool {
|
||||
return (
|
||||
lhs.identifier == rhs.identifier &&
|
||||
lhs.migrations.count == rhs.migrations.count
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Comparable
|
||||
|
||||
public static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
return (lhs.identifier < rhs.identifier)
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
/// This is a convenience wrapper around the GRDB `TableDefinition` class which allows for shorthand
|
||||
/// when creating tables
|
||||
public class TypedTableDefinition<T> where T: TableRecord, T: ColumnExpressible {
|
||||
let definition: TableDefinition
|
||||
|
||||
init(definition: TableDefinition) {
|
||||
self.definition = definition
|
||||
}
|
||||
|
||||
@discardableResult public func column(_ key: T.Columns, _ type: Database.ColumnType? = nil) -> ColumnDefinition {
|
||||
return definition.column(key.name, type)
|
||||
}
|
||||
|
||||
public func primaryKey(_ columns: [T.Columns], onConflict: Database.ConflictResolution? = nil) {
|
||||
definition.primaryKey(columns.map { $0.name }, onConflict: onConflict)
|
||||
}
|
||||
|
||||
public func foreignKey<Other>(_ columns: [T.Columns], references table: Other.Type, columns destinationColumns: [Other.Columns]? = nil, onDelete: Database.ForeignKeyAction? = nil, onUpdate: Database.ForeignKeyAction? = nil, deferred: Bool = false) where Other: TableRecord, Other: ColumnExpressible {
|
||||
return definition.foreignKey(
|
||||
columns.map { $0.name },
|
||||
references: table.databaseTableName,
|
||||
columns: destinationColumns?.map { $0.name },
|
||||
onDelete: onDelete,
|
||||
onUpdate: onUpdate,
|
||||
deferred: deferred
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
public extension ColumnDefinition {
|
||||
@discardableResult func references<T>(
|
||||
_ table: T.Type,
|
||||
column: T.Columns? = nil,
|
||||
onDelete deleteAction: Database.ForeignKeyAction? = nil,
|
||||
onUpdate updateAction: Database.ForeignKeyAction? = nil,
|
||||
deferred: Bool = false
|
||||
) -> Self where T: TableRecord, T: ColumnExpressible {
|
||||
return references(
|
||||
T.databaseTableName,
|
||||
column: column?.name,
|
||||
onDelete: deleteAction,
|
||||
onUpdate: updateAction,
|
||||
deferred: deferred
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
public extension Database {
|
||||
func create<T>(
|
||||
table: T.Type,
|
||||
options: TableOptions = [],
|
||||
body: (TypedTableDefinition<T>) throws -> Void
|
||||
) throws where T: TableRecord, T: ColumnExpressible {
|
||||
try create(table: T.databaseTableName, options: options) { tableDefinition in
|
||||
let typedDefinition: TypedTableDefinition<T> = TypedTableDefinition(definition: tableDefinition)
|
||||
|
||||
try body(typedDefinition)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
public extension DatabaseMigrator {
|
||||
mutating func registerMigration(_ identifier: TargetMigrations.Identifier, migration: Migration.Type, foreignKeyChecks: ForeignKeyChecks = .deferred) {
|
||||
self.registerMigration("\(identifier).\(migration.identifier)", migrate: migration.migrate)
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Notification.Name {
|
||||
static let resetStorage = Notification.Name("resetStorage")
|
||||
}
|
||||
|
||||
@objc public extension NSNotification {
|
||||
@objc static let resetStorage = Notification.Name.resetStorage.rawValue as NSString
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Set {
|
||||
func inserting(_ value: Element) -> Set<Element> {
|
||||
var updatedSet: Set<Element> = self
|
||||
updatedSet.insert(value)
|
||||
|
||||
return updatedSet
|
||||
}
|
||||
|
||||
func removing(_ value: Element) -> Set<Element> {
|
||||
var updatedSet: Set<Element> = self
|
||||
updatedSet.remove(value)
|
||||
|
||||
return updatedSet
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue