import 'dart:convert'; import 'dart:typed_data'; import 'package:idb_shim/idb_client.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api.dart'; import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/file_cache_manager.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/webdav_response_parser.dart'; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/touch_token_manager.dart'; import 'package:nc_photos/use_case/compat/v32.dart'; import 'package:path/path.dart' as path_lib; import 'package:uuid/uuid.dart'; import 'package:xml/xml.dart'; class FileWebdavDataSource implements FileDataSource { const FileWebdavDataSource(); @override list( Account account, File dir, { int? depth, }) async { _log.fine("[list] ${dir.path}"); final response = await Api(account).files().propfind( path: dir.path, depth: depth, getlastmodified: 1, resourcetype: 1, getetag: 1, getcontenttype: 1, getcontentlength: 1, hasPreview: 1, fileid: 1, favorite: 1, ownerId: 1, trashbinFilename: 1, trashbinOriginalLocation: 1, trashbinDeletionTime: 1, customNamespaces: { "com.nkming.nc_photos": "app", }, customProperties: [ "app:metadata", "app:is-archived", "app:override-date-time" ], ); if (!response.isGood) { _log.severe("[list] Failed requesting server: $response"); throw ApiException( response: response, message: "Failed communicating with server: ${response.statusCode}"); } final xml = XmlDocument.parse(response.body); var files = WebdavResponseParser().parseFiles(xml); // _log.fine("[list] Parsed files: [$files]"); bool hasNoMediaMarker = false; files = files .forEachLazy((f) { if (file_util.isNoMediaMarker(f)) { hasNoMediaMarker = true; } }) .where((f) => _validateFile(f)) .map((e) { if (e.metadata == null || e.metadata!.fileEtag == e.etag) { return e; } else { _log.info("[list] Ignore outdated metadata for ${e.path}"); return e.copyWith(metadata: OrNull(null)); } }) .toList(); await _compatUpgrade(account, files); if (hasNoMediaMarker) { // return only the marker and the dir itself return files .where((f) => dir.compareServerIdentity(f) || file_util.isNoMediaMarker(f)) .toList(); } else { return files; } } @override listSingle(Account account, File f) async { _log.info("[listSingle] ${f.path}"); return (await list(account, f, depth: 0)).first; } @override remove(Account account, File f) async { _log.info("[remove] ${f.path}"); final response = await Api(account).files().delete(path: f.path); if (!response.isGood) { _log.severe("[remove] Failed requesting server: $response"); throw ApiException( response: response, message: "Failed communicating with server: ${response.statusCode}"); } } @override getBinary(Account account, File f) async { _log.info("[getBinary] ${f.path}"); final response = await Api(account).files().get(path: f.path); if (!response.isGood) { _log.severe("[getBinary] Failed requesting server: $response"); throw ApiException( response: response, message: "Failed communicating with server: ${response.statusCode}"); } return response.body; } @override putBinary(Account account, String path, Uint8List content) async { _log.info("[putBinary] $path"); final response = await Api(account).files().put(path: path, content: content); if (!response.isGood) { _log.severe("[putBinary] Failed requesting server: $response"); throw ApiException( response: response, message: "Failed communicating with server: ${response.statusCode}"); } } @override updateProperty( Account account, File f, { OrNull? metadata, OrNull? isArchived, OrNull? overrideDateTime, bool? favorite, }) async { _log.info("[updateProperty] ${f.path}"); if (metadata?.obj != null && metadata!.obj!.fileEtag != f.etag) { _log.warning( "[updateProperty] Metadata etag mismatch (metadata: ${metadata.obj!.fileEtag}, file: ${f.etag})"); } final setProps = { if (metadata?.obj != null) "app:metadata": jsonEncode(metadata!.obj!.toJson()), if (isArchived?.obj != null) "app:is-archived": isArchived!.obj, if (overrideDateTime?.obj != null) "app:override-date-time": overrideDateTime!.obj!.toUtc().toIso8601String(), if (favorite != null) "oc:favorite": favorite ? 1 : 0, }; final removeProps = [ if (OrNull.isSetNull(metadata)) "app:metadata", if (OrNull.isSetNull(isArchived)) "app:is-archived", if (OrNull.isSetNull(overrideDateTime)) "app:override-date-time", ]; final response = await Api(account).files().proppatch( path: f.path, namespaces: { "com.nkming.nc_photos": "app", "http://owncloud.org/ns": "oc", }, set: setProps.isNotEmpty ? setProps : null, remove: removeProps.isNotEmpty ? removeProps : null, ); if (!response.isGood) { _log.severe("[updateProperty] Failed requesting server: $response"); throw ApiException( response: response, message: "Failed communicating with server: ${response.statusCode}"); } } @override copy( Account account, File f, String destination, { bool? shouldOverwrite, }) async { _log.info("[copy] ${f.path} to $destination"); final response = await Api(account).files().copy( path: f.path, destinationUrl: "${account.url}/$destination", overwrite: shouldOverwrite, ); if (!response.isGood) { _log.severe("[copy] Failed requesting sever: $response"); throw ApiException( response: response, message: "Failed communicating with server: ${response.statusCode}"); } else if (response.statusCode == 204) { // conflict throw ApiException( response: response, message: "Failed communicating with server: ${response.statusCode}"); } } @override move( Account account, File f, String destination, { bool? shouldOverwrite, }) async { _log.info("[move] ${f.path} to $destination"); final response = await Api(account).files().move( path: f.path, destinationUrl: "${account.url}/$destination", overwrite: shouldOverwrite, ); if (!response.isGood) { _log.severe("[move] Failed requesting sever: $response"); throw ApiException( response: response, message: "Failed communicating with server: ${response.statusCode}"); } } @override createDir(Account account, String path) async { _log.info("[createDir] $path"); final response = await Api(account).files().mkcol( path: path, ); if (!response.isGood) { _log.severe("[createDir] Failed requesting sever: $response"); throw ApiException( response: response, message: "Failed communicating with server: ${response.statusCode}"); } } Future _compatUpgrade(Account account, List files) async { for (final f in files.where((element) => element.metadata?.exif != null)) { if (CompatV32.isExifNeedMigration(f.metadata!.exif!)) { final newExif = CompatV32.migrateExif(f.metadata!.exif!, f.path); await updateProperty( account, f, metadata: OrNull(f.metadata!.copyWith( exif: newExif, )), ); } } } static final _log = Logger("entity.file.data_source.FileWebdavDataSource"); } class FileAppDbDataSource implements FileDataSource { const FileAppDbDataSource(this.appDb); @override list(Account account, File dir) { _log.info("[list] ${dir.path}"); return appDb.use( (db) => db.transaction( [AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadOnly), (transaction) async { final fileStore = transaction.objectStore(AppDb.file2StoreName); final dirStore = transaction.objectStore(AppDb.dirStoreName); final dirItem = await dirStore .getObject(AppDbDirEntry.toPrimaryKeyForDir(account, dir)) as Map?; if (dirItem == null) { throw CacheNotFoundException("No entry: ${dir.path}"); } final dirEntry = AppDbDirEntry.fromJson(dirItem.cast()); final entries = await dirEntry.children.mapStream((c) async { final fileItem = await fileStore .getObject(AppDbFile2Entry.toPrimaryKey(account, c)) as Map?; if (fileItem == null) { _log.warning( "[list] Missing file ($c) in db for dir: ${logFilename(dir.path)}"); throw CacheNotFoundException("No entry for dir child: $c"); } return AppDbFile2Entry.fromJson(fileItem.cast()); }, k.simultaneousQuery).toList(); // we need to add dir to match the remote query return [dirEntry.dir] + entries.map((e) => e.file).where((f) => _validateFile(f)).toList(); }, ); } @override listSingle(Account account, File f) { _log.info("[listSingle] ${f.path}"); throw UnimplementedError(); } /// List files with date between [fromEpochMs] (inclusive) and [toEpochMs] /// (exclusive) Future> listByDate( Account account, int fromEpochMs, int toEpochMs) async { _log.info("[listByDate] [$fromEpochMs, $toEpochMs]"); final items = await appDb.use( (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), (transaction) async { final fileStore = transaction.objectStore(AppDb.file2StoreName); final dateTimeEpochMsIndex = fileStore.index(AppDbFile2Entry.dateTimeEpochMsIndexName); final range = KeyRange.bound( AppDbFile2Entry.toDateTimeEpochMsIndexKey(account, fromEpochMs), AppDbFile2Entry.toDateTimeEpochMsIndexKey(account, toEpochMs), false, true, ); return await dateTimeEpochMsIndex.getAll(range); }, ); return items .cast() .map((i) => AppDbFile2Entry.fromJson(i.cast())) .map((e) => e.file) .where((f) => _validateFile(f)) .toList(); } /// Remove a file/dir from database @override remove(Account account, File f) { _log.info("[remove] ${f.path}"); return FileCacheRemover(appDb)(account, f); } @override getBinary(Account account, File f) { _log.info("[getBinary] ${f.path}"); throw UnimplementedError(); } @override putBinary(Account account, String path, Uint8List content) async { _log.info("[putBinary] $path"); // do nothing, we currently don't store file contents locally } @override updateProperty( Account account, File f, { OrNull? metadata, OrNull? isArchived, OrNull? overrideDateTime, bool? favorite, }) { _log.info("[updateProperty] ${f.path}"); return appDb.use( (db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite), (transaction) async { // update file store final newFile = f.copyWith( metadata: metadata, isArchived: isArchived, overrideDateTime: overrideDateTime, isFavorite: favorite, ); final fileStore = transaction.objectStore(AppDb.file2StoreName); await fileStore.put(AppDbFile2Entry.fromFile(account, newFile).toJson(), AppDbFile2Entry.toPrimaryKeyForFile(account, newFile)); }, ); } @override copy( Account account, File f, String destination, { bool? shouldOverwrite, }) async { // do nothing } @override move( Account account, File f, String destination, { bool? shouldOverwrite, }) async { // do nothing } @override createDir(Account account, String path) async { // do nothing } final AppDb appDb; static final _log = Logger("entity.file.data_source.FileAppDbDataSource"); } class FileCachedDataSource implements FileDataSource { FileCachedDataSource( this.appDb, { this.shouldCheckCache = false, this.forwardCacheManager, }) : _appDbSrc = FileAppDbDataSource(appDb); @override list(Account account, File dir) async { final cacheLoader = FileCacheLoader( appDb: appDb, appDbSrc: _appDbSrc, remoteSrc: _remoteSrc, shouldCheckCache: shouldCheckCache, forwardCacheManager: forwardCacheManager, ); final cache = await cacheLoader(account, dir); if (cacheLoader.isGood) { return cache!; } // no cache or outdated try { final remote = await _remoteSrc.list(account, dir); await FileCacheUpdater(appDb)(account, dir, remote: remote, cache: cache); if (shouldCheckCache) { // update our local touch token to match the remote one const tokenManager = TouchTokenManager(); try { await tokenManager.setLocalToken( account, dir, cacheLoader.remoteTouchToken); } catch (e, stacktrace) { _log.shout("[list] Failed while setLocalToken", e, stacktrace); // ignore error } } return remote; } on ApiException catch (e) { if (e.response.statusCode == 404) { _log.info("[list] File removed: $dir"); _appDbSrc.remove(account, dir); return []; } else if (e.response.statusCode == 403) { _log.info("[list] E2E encrypted dir: $dir"); _appDbSrc.remove(account, dir); return []; } else { rethrow; } } } @override listSingle(Account account, File f) { return _remoteSrc.listSingle(account, f); } @override remove(Account account, File f) async { await _appDbSrc.remove(account, f); await _remoteSrc.remove(account, f); } @override getBinary(Account account, File f) { return _remoteSrc.getBinary(account, f); } @override putBinary(Account account, String path, Uint8List content) async { await _remoteSrc.putBinary(account, path, content); } @override updateProperty( Account account, File f, { OrNull? metadata, OrNull? isArchived, OrNull? overrideDateTime, bool? favorite, }) async { await _remoteSrc .updateProperty( account, f, metadata: metadata, isArchived: isArchived, overrideDateTime: overrideDateTime, favorite: favorite, ) .then((_) => _appDbSrc.updateProperty( account, f, metadata: metadata, isArchived: isArchived, overrideDateTime: overrideDateTime, favorite: favorite, )); // generate a new random token final token = const Uuid().v4().replaceAll("-", ""); const tokenManager = TouchTokenManager(); final dir = File(path: path_lib.dirname(f.path)); await tokenManager.setLocalToken(account, dir, token); final fileRepo = FileRepo(this); await tokenManager.setRemoteToken(fileRepo, account, dir, token); _log.info( "[updateMetadata] New touch token '$token' for dir '${dir.path}'"); } @override copy( Account account, File f, String destination, { bool? shouldOverwrite, }) async { await _remoteSrc.copy(account, f, destination, shouldOverwrite: shouldOverwrite); } @override move( Account account, File f, String destination, { bool? shouldOverwrite, }) async { await _remoteSrc.move(account, f, destination, shouldOverwrite: shouldOverwrite); } @override createDir(Account account, String path) async { await _remoteSrc.createDir(account, path); } final AppDb appDb; final bool shouldCheckCache; final FileForwardCacheManager? forwardCacheManager; final _remoteSrc = const FileWebdavDataSource(); final FileAppDbDataSource _appDbSrc; static final _log = Logger("entity.file.data_source.FileCachedDataSource"); } /// Forward cache for listing AppDb dirs /// /// It's very expensive to list a dir and its sub-dirs one by one in multiple /// queries. This class will instead query every sub-dirs when a new dir is /// passed to us in one transaction. For this reason, this should only be used /// when it's necessary to query everything class FileForwardCacheManager { FileForwardCacheManager(this.appDb, this.knownFiles); /// Transform a list of files to a map suitable to be passed as the /// [knownFiles] argument static Map prepareFileMap(List knownFiles) => knownFiles .where((f) => f.fileId != null) .map((e) => MapEntry(e.fileId!, e)) .run((obj) => Map.fromEntries(obj)); Future> list(Account account, File dir) async { // check cache final dirKey = AppDbDirEntry.toPrimaryKeyForDir(account, dir); final cachedDir = _dirCache[dirKey]; if (cachedDir != null) { _log.fine("[list] Returning data from cache: ${logFilename(dir.path)}"); return _withDirEntry(cachedDir); } // no cache, query everything under [dir] _log.info( "[list] No cache and querying everything under ${logFilename(dir.path)}"); await _cacheDir(account, dir); final cachedDir2 = _dirCache[dirKey]; if (cachedDir2 == null) { throw CacheNotFoundException("No entry: ${dir.path}"); } return _withDirEntry(cachedDir2); } Future _cacheDir(Account account, File dir) async { final dirItems = await appDb.use( (db) => db.transaction(AppDb.dirStoreName, idbModeReadOnly), (transaction) async { final store = transaction.objectStore(AppDb.dirStoreName); final dirItem = await store .getObject(AppDbDirEntry.toPrimaryKeyForDir(account, dir)) as Map?; if (dirItem == null) { return null; } final range = KeyRange.bound( AppDbDirEntry.toPrimaryLowerKeyForSubDirs(account, dir), AppDbDirEntry.toPrimaryUpperKeyForSubDirs(account, dir), ); return [dirItem] + (await store.getAll(range)).cast(); }, ); if (dirItems == null) { // no cache return; } final dirs = dirItems .map((i) => AppDbDirEntry.fromJson(i.cast())) .toList(); _dirCache.addEntries(dirs.map( (e) => MapEntry(AppDbDirEntry.toPrimaryKeyForDir(account, e.dir), e))); _log.info( "[_cacheDir] Cached ${dirs.length} dirs under ${logFilename(dir.path)}"); // cache files final fileIds = dirs.map((e) => e.children).fold>( [], (previousValue, element) => previousValue + element); final needQuery = []; final files = []; // check files already known to us if (knownFiles.isNotEmpty) { for (final id in fileIds) { final f = knownFiles[id]; if (f != null) { files.add(f); } else { needQuery.add(id); } } } else { needQuery.addAll(fileIds); } _log.info( "[_cacheDir] ${files.length} files known, ${needQuery.length} files need querying"); // query other files if (needQuery.isNotEmpty) { final fileItems = await appDb.use( (db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly), (transaction) async { final store = transaction.objectStore(AppDb.file2StoreName); return await needQuery .mapStream( (id) => store .getObject(AppDbFile2Entry.toPrimaryKey(account, id)), k.simultaneousQuery) .toList(); }, ); files.addAll(fileItems.cast().whereType().map( (i) => AppDbFile2Entry.fromJson(i.cast()).file)); } _fileCache.addEntries(files.map((f) => MapEntry(f.fileId!, f))); _log.info( "[_cacheDir] Cached ${files.length} files under ${logFilename(dir.path)}"); } List _withDirEntry(AppDbDirEntry dirEntry) { return [dirEntry.dir] + dirEntry.children.map((id) { try { return _fileCache[id]!; } catch (_) { _log.warning( "[list] Missing file ($id) in db for dir: ${logFilename(dirEntry.dir.path)}"); throw CacheNotFoundException("No entry for dir child: $id"); } }).toList(); } final AppDb appDb; final Map knownFiles; final _dirCache = {}; final _fileCache = {}; static final _log = Logger("entity.file.data_source.FileForwardCacheManager"); } bool _validateFile(File f) { // See: https://gitlab.com/nkming2/nc-photos/-/issues/9 return f.lastModified != null; }