{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} module Simplex.Chat.Archive ( exportArchive, importArchive, deleteStorage, sqlCipherExport, handleDBError, ) where import qualified Codec.Archive.Zip as Z import Control.Monad import Control.Monad.Except import Control.Monad.Reader import Data.Functor (($>)) import qualified Data.Text as T import qualified Database.SQLite3 as SQL import Simplex.Chat.Controller import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), setSQLiteModeWAL, sqlString) import Simplex.Messaging.Util import System.FilePath import UnliftIO.Directory import UnliftIO.Exception (SomeException, bracket, catch) import qualified UnliftIO.Exception as E import UnliftIO.STM import UnliftIO.Temporary archiveAgentDbFile :: String archiveAgentDbFile = "simplex_v1_agent.db" archiveChatDbFile :: String archiveChatDbFile = "simplex_v1_chat.db" archiveFilesFolder :: String archiveFilesFolder = "simplex_v1_files" exportArchive :: ChatMonad m => ArchiveConfig -> m () exportArchive cfg@ArchiveConfig {archivePath, disableCompression} = withTempDir cfg "simplex-chat." $ \dir -> do setMode False StorageFiles {chatDb, agentDb, filesPath} <- storageFiles copyFile chatDb $ dir archiveChatDbFile copyFile agentDb $ dir archiveAgentDbFile setMode True forM_ filesPath $ \fp -> copyDirectoryFiles fp $ dir archiveFilesFolder let method = if disableCompression == Just True then Z.Store else Z.Deflate Z.createArchive archivePath $ Z.packDirRecur method Z.mkEntrySelector dir where -- TODO this won't work on Windows as we close connection there when stopping chat setMode walMode = do ChatController {chatStore, smpAgent} <- ask liftIO $ setSQLiteModeWAL chatStore walMode liftIO $ setSQLiteModeWAL (agentClientStore smpAgent) walMode importArchive :: ChatMonad m => ArchiveConfig -> m [ArchiveError] importArchive cfg@ArchiveConfig {archivePath} = withTempDir cfg "simplex-chat." $ \dir -> do Z.withArchive archivePath $ Z.unpackInto dir StorageFiles {chatDb, agentDb, filesPath} <- storageFiles backup chatDb backup agentDb copyFile (dir archiveChatDbFile) chatDb copyFile (dir archiveAgentDbFile) agentDb copyFiles dir filesPath `E.catch` \(e :: E.SomeException) -> pure [AEImport . ChatError . CEException $ show e] where backup f = whenM (doesFileExist f) $ copyFile f $ f <> ".bak" copyFiles dir filesPath = do let filesDir = dir archiveFilesFolder case filesPath of Just fp -> ifM (doesDirectoryExist filesDir) (copyDirectoryFiles filesDir fp) (pure []) _ -> pure [] withTempDir :: ChatMonad m => ArchiveConfig -> (String -> (FilePath -> m a) -> m a) withTempDir cfg = case parentTempDirectory (cfg :: ArchiveConfig) of Just tmpDir -> withTempDirectory tmpDir _ -> withSystemTempDirectory copyDirectoryFiles :: ChatMonad m => FilePath -> FilePath -> m [ArchiveError] copyDirectoryFiles fromDir toDir = do createDirectoryIfMissing False toDir fs <- listDirectory fromDir foldM copyFileCatchError [] fs where copyFileCatchError fileErrs f = (copyDirectoryFile f $> fileErrs) `E.catch` \(e :: E.SomeException) -> pure (AEImportFile f (ChatError . CEException $ show e) : fileErrs) copyDirectoryFile f = do let fn = takeFileName f f' = fromDir fn whenM (doesFileExist f') $ copyFile f' $ toDir fn deleteStorage :: ChatMonad m => m () deleteStorage = do StorageFiles {chatDb, agentDb, filesPath} <- storageFiles removeFile chatDb removeFile agentDb mapM_ removePathForcibly filesPath tmpPath <- readTVarIO =<< asks tempDirectory mapM_ removePathForcibly tmpPath data StorageFiles = StorageFiles { chatDb :: FilePath, chatEncrypted :: TVar Bool, agentDb :: FilePath, agentEncrypted :: TVar Bool, filesPath :: Maybe FilePath } storageFiles :: ChatMonad m => m StorageFiles storageFiles = do ChatController {chatStore, filesFolder, smpAgent} <- ask let SQLiteStore {dbFilePath = chatDb, dbEncrypted = chatEncrypted} = chatStore SQLiteStore {dbFilePath = agentDb, dbEncrypted = agentEncrypted} = agentClientStore smpAgent filesPath <- readTVarIO filesFolder pure StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted, filesPath} sqlCipherExport :: forall m. ChatMonad m => DBEncryptionConfig -> m () sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = DBEncryptionKey key'} = when (key /= key') $ do fs@StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted} <- storageFiles checkFile `with` fs backup `with` fs (export chatDb chatEncrypted >> export agentDb agentEncrypted) `catchChatError` \e -> tryChatError (restore `with` fs) >> throwError e where action `with` StorageFiles {chatDb, agentDb} = action chatDb >> action agentDb backup f = copyFile f (f <> ".bak") restore f = copyFile (f <> ".bak") f checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f export f dbEnc = do enc <- readTVarIO dbEnc when (enc && null key) $ throwDBError DBErrorEncrypted when (not enc && not (null key)) $ throwDBError DBErrorPlaintext withDB (`SQL.exec` exportSQL) DBErrorExport renameFile (f <> ".exported") f withDB (`SQL.exec` testSQL) DBErrorOpen atomically $ writeTVar dbEnc $ not (null key') where withDB a err = handleDBError err $ bracket (SQL.open $ T.pack f) SQL.close a exportSQL = T.unlines $ keySQL key <> [ "ATTACH DATABASE " <> sqlString (f <> ".exported") <> " AS exported KEY " <> sqlString key' <> ";", "PRAGMA wal_checkpoint(TRUNCATE);", "SELECT sqlcipher_export('exported');", "PRAGMA exported.wal_checkpoint(TRUNCATE);", "DETACH DATABASE exported;" ] testSQL = T.unlines $ keySQL key' <> [ "PRAGMA foreign_keys = ON;", "PRAGMA secure_delete = ON;", "PRAGMA auto_vacuum = FULL;", "SELECT count(*) FROM sqlite_master;" ] keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)] handleDBError :: forall m. ChatMonad m => (SQLiteError -> DatabaseError) -> IO () -> m () handleDBError err a = (liftIO a $> Nothing) `catch` checkSQLError `catch` (\(e :: SomeException) -> sqliteError' e) >>= mapM_ (throwDBError . err) where checkSQLError e = case SQL.sqlError e of SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase _ -> sqliteError' e sqliteError' :: Show e => e -> m (Maybe SQLiteError) sqliteError' = pure . Just . SQLiteError . show