import 'dart:async'; import 'dart:io'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import "package:photos/module/upload/model/multipart.dart"; import 'package:sqflite/sqflite.dart'; import "package:sqflite_migration/sqflite_migration.dart"; class UploadLocksDB { static const _databaseName = "ente.upload_locks.db"; static const _uploadLocksTable = ( table: "upload_locks", columnID: "id", columnOwner: "owner", columnTime: "time", ); static const _trackUploadTable = ( table: "track_uploads", columnID: "id", columnLocalID: "local_id", columnFileHash: "file_hash", columnCollectionID: "collection_id", columnEncryptedFileName: "encrypted_file_name", columnEncryptedFileSize: "encrypted_file_size", columnEncryptedFileKey: "encrypted_file_key", columnFileEncryptionNonce: "file_encryption_nonce", columnKeyEncryptionNonce: "key_encryption_nonce", columnObjectKey: "object_key", columnCompleteUrl: "complete_url", columnStatus: "status", columnPartSize: "part_size", columnLastAttemptedAt: "last_attempted_at", columnCreatedAt: "created_at", ); static const _partsTable = ( table: "upload_parts", columnObjectKey: "object_key", columnPartNumber: "part_number", columnPartUrl: "part_url", columnPartETag: "part_etag", columnPartStatus: "part_status", ); static final initializationScript = [ ..._createUploadLocksTable(), ]; static final migrationScripts = [ ..._createTrackUploadsTable(), ]; final dbConfig = MigrationConfig( initializationScript: initializationScript, migrationScripts: migrationScripts, ); UploadLocksDB._privateConstructor(); static final UploadLocksDB instance = UploadLocksDB._privateConstructor(); static Future? _dbFuture; Future get database async { _dbFuture ??= _initDatabase(); return _dbFuture!; } Future _initDatabase() async { final Directory documentsDirectory = await getApplicationDocumentsDirectory(); final String path = join(documentsDirectory.path, _databaseName); return await openDatabaseWithMigration(path, dbConfig); } static List _createUploadLocksTable() { return [ ''' CREATE TABLE ${_uploadLocksTable.table} ( ${_uploadLocksTable.columnID} TEXT PRIMARY KEY NOT NULL, ${_uploadLocksTable.columnOwner} TEXT NOT NULL, ${_uploadLocksTable.columnTime} TEXT NOT NULL ) ''', ]; } static List _createTrackUploadsTable() { return [ ''' CREATE TABLE IF NOT EXISTS ${_trackUploadTable.table} ( ${_trackUploadTable.columnID} INTEGER PRIMARY KEY, ${_trackUploadTable.columnLocalID} TEXT NOT NULL, ${_trackUploadTable.columnFileHash} TEXT NOT NULL, ${_trackUploadTable.columnCollectionID} INTEGER NOT NULL, ${_trackUploadTable.columnEncryptedFileName} TEXT NOT NULL, ${_trackUploadTable.columnEncryptedFileSize} INTEGER NOT NULL, ${_trackUploadTable.columnEncryptedFileKey} TEXT NOT NULL, ${_trackUploadTable.columnFileEncryptionNonce} TEXT NOT NULL, ${_trackUploadTable.columnKeyEncryptionNonce} TEXT NOT NULL, ${_trackUploadTable.columnObjectKey} TEXT NOT NULL, ${_trackUploadTable.columnCompleteUrl} TEXT NOT NULL, ${_trackUploadTable.columnStatus} TEXT DEFAULT '${MultipartStatus.pending.name}' NOT NULL, ${_trackUploadTable.columnPartSize} INTEGER NOT NULL, ${_trackUploadTable.columnLastAttemptedAt} INTEGER NOT NULL, ${_trackUploadTable.columnCreatedAt} INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL ) ''', ''' CREATE TABLE IF NOT EXISTS ${_partsTable.table} ( ${_partsTable.columnObjectKey} TEXT NOT NULL REFERENCES ${_trackUploadTable.table}(${_trackUploadTable.columnObjectKey}) ON DELETE CASCADE, ${_partsTable.columnPartNumber} INTEGER NOT NULL, ${_partsTable.columnPartUrl} TEXT NOT NULL, ${_partsTable.columnPartETag} TEXT, ${_partsTable.columnPartStatus} TEXT NOT NULL, PRIMARY KEY (${_partsTable.columnObjectKey}, ${_partsTable.columnPartNumber}) ) ''', ]; } Future clearTable() async { final db = await instance.database; await db.delete(_uploadLocksTable.table); await db.delete(_trackUploadTable.table); await db.delete(_partsTable.table); } Future acquireLock(String id, String owner, int time) async { final db = await instance.database; final row = {}; row[_uploadLocksTable.columnID] = id; row[_uploadLocksTable.columnOwner] = owner; row[_uploadLocksTable.columnTime] = time; await db.insert( _uploadLocksTable.table, row, conflictAlgorithm: ConflictAlgorithm.fail, ); } Future isLocked(String id, String owner) async { final db = await instance.database; final rows = await db.query( _uploadLocksTable.table, where: '${_uploadLocksTable.columnID} = ? AND ${_uploadLocksTable.columnOwner} = ?', whereArgs: [id, owner], ); return rows.length == 1; } Future releaseLock(String id, String owner) async { final db = await instance.database; return db.delete( _uploadLocksTable.table, where: '${_uploadLocksTable.columnID} = ? AND ${_uploadLocksTable.columnOwner} = ?', whereArgs: [id, owner], ); } Future releaseLocksAcquiredByOwnerBefore(String owner, int time) async { final db = await instance.database; return db.delete( _uploadLocksTable.table, where: '${_uploadLocksTable.columnOwner} = ? AND ${_uploadLocksTable.columnTime} < ?', whereArgs: [owner, time], ); } Future releaseAllLocksAcquiredBefore(int time) async { final db = await instance.database; return db.delete( _uploadLocksTable.table, where: '${_uploadLocksTable.columnTime} < ?', whereArgs: [time], ); } Future<({String encryptedFileKey, String fileNonce, String keyNonce})> getFileEncryptionData( String localId, String fileHash, int collectionID, ) async { final db = await instance.database; final rows = await db.query( _trackUploadTable.table, where: '${_trackUploadTable.columnLocalID} = ?' ' AND ${_trackUploadTable.columnFileHash} = ?' ' AND ${_trackUploadTable.columnCollectionID} = ?', whereArgs: [localId, fileHash, collectionID], ); if (rows.isEmpty) { throw Exception("No cached links found for $localId and $fileHash"); } final row = rows.first; return ( encryptedFileKey: row[_trackUploadTable.columnEncryptedFileKey] as String, fileNonce: row[_trackUploadTable.columnFileEncryptionNonce] as String, keyNonce: row[_trackUploadTable.columnKeyEncryptionNonce] as String, ); } Future updateLastAttempted( String localId, String fileHash, int collectionID, ) async { final db = await instance.database; await db.update( _trackUploadTable.table, { _trackUploadTable.columnLastAttemptedAt: DateTime.now().millisecondsSinceEpoch, }, where: '${_trackUploadTable.columnLocalID} = ?' ' AND ${_trackUploadTable.columnFileHash} = ?' ' AND ${_trackUploadTable.columnCollectionID} = ?', whereArgs: [ localId, fileHash, collectionID, ], ); } Future getCachedLinks( String localId, String fileHash, int collectionID, ) async { final db = await instance.database; final rows = await db.query( _trackUploadTable.table, where: '${_trackUploadTable.columnLocalID} = ?' ' AND ${_trackUploadTable.columnFileHash} = ?' ' AND ${_trackUploadTable.columnCollectionID} = ?', whereArgs: [localId, fileHash, collectionID], ); if (rows.isEmpty) { throw Exception("No cached links found for $localId and $fileHash"); } final row = rows.first; final objectKey = row[_trackUploadTable.columnObjectKey] as String; final partsStatus = await db.query( _partsTable.table, where: '${_partsTable.columnObjectKey} = ?', whereArgs: [objectKey], ); final List partUploadStatus = []; final List partsURLs = List.generate( partsStatus.length, (index) => "", ); final Map partETags = {}; for (final part in partsStatus) { final partNumber = part[_partsTable.columnPartNumber] as int; final partUrl = part[_partsTable.columnPartUrl] as String; final partStatus = part[_partsTable.columnPartStatus] as String; partsURLs[partNumber] = partUrl; if (part[_partsTable.columnPartETag] != null) { partETags[partNumber] = part[_partsTable.columnPartETag] as String; } partUploadStatus.add(partStatus == "uploaded"); } final urls = MultipartUploadURLs( objectKey: objectKey, completeURL: row[_trackUploadTable.columnCompleteUrl] as String, partsURLs: partsURLs, ); return MultipartInfo( urls: urls, status: MultipartStatus.values .byName(row[_trackUploadTable.columnStatus] as String), partUploadStatus: partUploadStatus, partETags: partETags, partSize: row[_trackUploadTable.columnPartSize] as int, ); } Future createTrackUploadsEntry( String localId, String fileHash, int collectionID, MultipartUploadURLs urls, String encryptedFileName, int fileSize, String fileKey, String fileNonce, String keyNonce, { required int partSize, }) async { final db = await UploadLocksDB.instance.database; final objectKey = urls.objectKey; await db.insert( _trackUploadTable.table, { _trackUploadTable.columnLocalID: localId, _trackUploadTable.columnFileHash: fileHash, _trackUploadTable.columnCollectionID: collectionID, _trackUploadTable.columnObjectKey: objectKey, _trackUploadTable.columnCompleteUrl: urls.completeURL, _trackUploadTable.columnEncryptedFileName: encryptedFileName, _trackUploadTable.columnEncryptedFileSize: fileSize, _trackUploadTable.columnEncryptedFileKey: fileKey, _trackUploadTable.columnFileEncryptionNonce: fileNonce, _trackUploadTable.columnKeyEncryptionNonce: keyNonce, _trackUploadTable.columnPartSize: partSize, _trackUploadTable.columnLastAttemptedAt: DateTime.now().millisecondsSinceEpoch, }, ); final partsURLs = urls.partsURLs; final partsLength = partsURLs.length; for (int i = 0; i < partsLength; i++) { await db.insert( _partsTable.table, { _partsTable.columnObjectKey: objectKey, _partsTable.columnPartNumber: i, _partsTable.columnPartUrl: partsURLs[i], _partsTable.columnPartStatus: PartStatus.pending.name, }, ); } } Future updatePartStatus( String objectKey, int partNumber, String etag, ) async { final db = await instance.database; await db.update( _partsTable.table, { _partsTable.columnPartStatus: PartStatus.uploaded.name, _partsTable.columnPartETag: etag, }, where: '${_partsTable.columnObjectKey} = ? AND ${_partsTable.columnPartNumber} = ?', whereArgs: [objectKey, partNumber], ); } Future updateTrackUploadStatus( String objectKey, MultipartStatus status, ) async { final db = await instance.database; await db.update( _trackUploadTable.table, { _trackUploadTable.columnStatus: status.name, }, where: '${_trackUploadTable.columnObjectKey} = ?', whereArgs: [objectKey], ); } Future deleteMultipartTrack( String localId, ) async { final db = await instance.database; return await db.delete( _trackUploadTable.table, where: '${_trackUploadTable.columnLocalID} = ?', whereArgs: [localId], ); } // getFileNameToLastAttemptedAtMap returns a map of encrypted file name to last attempted at time Future> getFileNameToLastAttemptedAtMap() { return instance.database.then((db) async { final rows = await db.query( _trackUploadTable.table, columns: [ _trackUploadTable.columnEncryptedFileName, _trackUploadTable.columnLastAttemptedAt, ], ); final map = {}; for (final row in rows) { map[row[_trackUploadTable.columnEncryptedFileName] as String] = row[_trackUploadTable.columnLastAttemptedAt] as int; } return map; }); } Future getEncryptedFileName( String localId, String fileHash, int collectionID, ) { return instance.database.then((db) async { final rows = await db.query( _trackUploadTable.table, where: '${_trackUploadTable.columnLocalID} = ?' ' AND ${_trackUploadTable.columnFileHash} = ?' ' AND ${_trackUploadTable.columnCollectionID} = ?', whereArgs: [localId, fileHash, collectionID], ); if (rows.isEmpty) { return null; } final row = rows.first; return row[_trackUploadTable.columnEncryptedFileName] as String; }); } }