nc-photos/app/lib/entity/file/data_source.dart
2022-06-08 13:05:40 +08:00

703 lines
21 KiB
Dart

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/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:tuple/tuple.dart';
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 = await 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>? metadata,
OrNull<bool>? isArchived,
OrNull<DateTime>? 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<void> _compatUpgrade(Account account, List<File> 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) async {
_log.info("[list] ${dir.path}");
final dbItems = await 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<String, dynamic>());
return Tuple2(
dirEntry.dir,
await Future.wait(
dirEntry.children.map((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");
} else {
return fileItem;
}
}),
eagerError: true,
),
);
},
);
// we need to add dir to match the remote query
return [
dbItems.item1,
...(await dbItems.item2.computeAll(_covertAppDbFile2Entry))
.where((f) => _validateFile(f))
];
}
@override
listSingle(Account account, File f) {
_log.info("[listSingle] ${f.path}");
throw UnimplementedError();
}
/// List files with date between [fromEpochMs] (inclusive) and [toEpochMs]
/// (exclusive)
Future<List<File>> 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>()
.map((i) => AppDbFile2Entry.fromJson(i.cast<String, dynamic>()))
.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>? metadata,
OrNull<bool>? isArchived,
OrNull<DateTime>? 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>? metadata,
OrNull<bool>? isArchived,
OrNull<DateTime>? 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<int, File> prepareFileMap(List<File> knownFiles) => knownFiles
.where((f) => f.fileId != null)
.map((e) => MapEntry(e.fileId!, e))
.run((obj) => Map.fromEntries(obj));
Future<List<File>> 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<void> _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<Map>();
},
);
if (dirItems == null) {
// no cache
return;
}
final dirs = dirItems
.map((i) => AppDbDirEntry.fromJson(i.cast<String, dynamic>()))
.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<List<int>>(
[], (previousValue, element) => previousValue + element);
final needQuery = <int>[];
final files = <File>[];
// 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 dbItems = await appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly),
(transaction) async {
final store = transaction.objectStore(AppDb.file2StoreName);
return await Future.wait(needQuery.map((id) =>
store.getObject(AppDbFile2Entry.toPrimaryKey(account, id))));
},
);
files.addAll(
await dbItems.whereType<Map>().computeAll(_covertAppDbFile2Entry));
}
_fileCache.addEntries(files.map((f) => MapEntry(f.fileId!, f)));
_log.info(
"[_cacheDir] Cached ${files.length} files under ${logFilename(dir.path)}");
}
List<File> _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<int, File> knownFiles;
final _dirCache = <String, AppDbDirEntry>{};
final _fileCache = <int, File>{};
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;
}
File _covertAppDbFile2Entry(Map json) =>
AppDbFile2Entry.fromJson(json.cast<String, dynamic>()).file;