Files
moxxy/lib/service/stickers.dart

646 lines
19 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:archive/archive.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:path/path.dart' as p;
class StickersService {
final Logger _log = Logger('StickersService');
/// Computes the total amount of storage occupied by the stickers in the sticker
/// pack identified by id [id].
/// NOTE that if a sticker does not indicate a file size, i.e. the "size" column is
/// NULL, then a size of 0 is assumed.
Future<int> getStickerPackSizeById(String id) async {
final db = GetIt.I.get<DatabaseService>().database;
final result = await db.rawQuery(
'''
SELECT
SUM(size) AS size
FROM
$fileMetadataTable as fmt
WHERE
path IS NOT NULL AND
EXISTS (
SELECT
id
FROM
$stickersTable
WHERE
file_metadata_id = fmt.id AND
stickerPackId = ?
)
''',
[id],
);
_log.finest('Cumulative size for $id: $result');
return result.first['size'] as int? ?? 0;
}
Future<StickerPack?> getStickerPackById(String id) async {
final db = GetIt.I.get<DatabaseService>().database;
final rawPack = await db.query(
stickerPacksTable,
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rawPack.isEmpty) return null;
final rawStickers = await db.rawQuery(
'''
SELECT
sticker.*,
fm.id AS fm_id,
fm.path AS fm_path,
fm.sourceUrls AS fm_sourceUrls,
fm.mimeType AS fm_mimeType,
fm.thumbnailType AS fm_thumbnailType,
fm.thumbnailData AS fm_thumbnailData,
fm.width AS fm_width,
fm.height AS fm_height,
fm.plaintextHashes AS fm_plaintextHashes,
fm.encryptionKey AS fm_encryptionKey,
fm.encryptionIv AS fm_encryptionIv,
fm.encryptionScheme AS fm_encryptionScheme,
fm.cipherTextHashes AS fm_cipherTextHashes,
fm.filename AS fm_filename,
fm.size AS fm_size
FROM
(SELECT
*
FROM
$stickersTable
WHERE
stickerPackId = ?
) AS sticker
JOIN
$fileMetadataTable fm
ON
sticker.file_metadata_id = fm.id;
''',
[id],
);
final stickerPack = StickerPack.fromDatabaseJson(
rawPack.first,
rawStickers.map((sticker) {
return Sticker.fromDatabaseJson(
sticker,
FileMetadata.fromDatabaseJson(
getPrefixedSubMap(sticker, 'fm_'),
),
);
}).toList(),
).copyWith(
size: await getStickerPackSizeById(id),
);
return stickerPack;
}
Future<void> removeStickerPack(String id) async {
final db = GetIt.I.get<DatabaseService>().database;
final pack = await getStickerPackById(id);
assert(pack != null, 'The sticker pack must exist');
// Delete the files
for (final sticker in pack!.stickers) {
if (sticker.fileMetadata.path == null) {
continue;
}
await GetIt.I.get<FilesService>().updateFileMetadata(
sticker.fileMetadata.id,
path: null,
);
final file = File(sticker.fileMetadata.path!);
if (file.existsSync()) {
await file.delete();
}
}
// Remove from the database
await db.delete(
stickersTable,
where: 'stickerPackId = ?',
whereArgs: [id],
);
await db.delete(
stickerPacksTable,
where: 'id = ?',
whereArgs: [id],
);
// Retract from PubSub
final state = await GetIt.I.get<XmppStateService>().getXmppState();
final result = await GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
.retractStickerPack(moxxmpp.JID.fromString(state.jid!), id);
if (result.isType<moxxmpp.PubSubError>()) {
_log.severe('Failed to retract sticker pack');
}
}
Future<void> _publishStickerPack(moxxmpp.StickerPack pack) async {
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
final state = await GetIt.I.get<XmppStateService>().getXmppState();
final result = await GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
.publishStickerPack(
moxxmpp.JID.fromString(state.jid!),
pack,
accessModel: prefs.isStickersNodePublic ? 'open' : null,
);
if (result.isType<moxxmpp.PubSubError>()) {
_log.severe('Failed to publish sticker pack');
}
}
Future<void> importFromPubSubWithEvent(
moxxmpp.JID jid,
String stickerPackId,
) async {
final stickerPack = await importFromPubSub(jid, stickerPackId);
if (stickerPack == null) return;
sendEvent(
StickerPackAddedEvent(
stickerPack: stickerPack,
),
);
}
/// Takes the jid of the host [jid] and the id [stickerPackId] of the sticker pack
/// and tries to fetch and install it, including publishing on our own PubSub node.
///
/// On success, returns the installed StickerPack. On failure, returns null.
Future<StickerPack?> importFromPubSub(
moxxmpp.JID jid,
String stickerPackId,
) async {
final result = await GetIt.I
.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
.fetchStickerPack(jid.toBare(), stickerPackId);
if (result.isType<moxxmpp.PubSubError>()) {
_log.warning('Failed to fetch sticker pack $jid:$stickerPackId');
return null;
}
final stickerPackRaw = StickerPack.fromMoxxmpp(
result.get<moxxmpp.StickerPack>(),
false,
);
// Install the sticker pack
return installFromPubSub(stickerPackRaw);
}
Future<void> _addStickerPackFromData(StickerPack pack) async {
await GetIt.I.get<DatabaseService>().database.insert(
stickerPacksTable,
pack.toDatabaseJson(),
);
}
Future<Sticker> _addStickerFromData(
String id,
String stickerPackId,
String desc,
Map<String, String> suggests,
FileMetadata fileMetadata,
) async {
final s = Sticker(
id,
stickerPackId,
desc,
suggests,
fileMetadata,
);
await GetIt.I.get<DatabaseService>().database.insert(
stickersTable,
s.toDatabaseJson(),
);
return s;
}
Future<StickerPack?> installFromPubSub(StickerPack remotePack) async {
assert(!remotePack.local, 'Sticker pack must be remote');
var success = true;
final stickers = List<Sticker>.from(remotePack.stickers);
for (var i = 0; i < stickers.length; i++) {
final sticker = stickers[i];
final stickerPath = await computeCachedPathForFile(
sticker.fileMetadata.filename,
sticker.fileMetadata.plaintextHashes,
);
// Get file metadata
final fs = GetIt.I.get<FilesService>();
final fileMetadataRaw = await fs.createFileMetadataIfRequired(
MediaFileLocation(
sticker.fileMetadata.sourceUrls!,
p.basename(stickerPath),
null,
null,
null,
sticker.fileMetadata.plaintextHashes,
null,
sticker.fileMetadata.size,
),
sticker.fileMetadata.mimeType,
sticker.fileMetadata.size,
sticker.fileMetadata.width != null &&
sticker.fileMetadata.height != null
? Size(
sticker.fileMetadata.width!.toDouble(),
sticker.fileMetadata.height!.toDouble(),
)
: null,
// TODO(Unknown): Maybe consider the thumbnails one day
null,
null,
path: stickerPath,
);
if (!fileMetadataRaw.retrieved &&
fileMetadataRaw.fileMetadata.path == null) {
final downloadStatusCode = await downloadFile(
Uri.parse(sticker.fileMetadata.sourceUrls!.first),
stickerPath,
(_, __) {},
);
if (!isRequestOkay(downloadStatusCode)) {
_log.severe('Request not okay: $downloadStatusCode');
success = false;
break;
}
}
var fm = fileMetadataRaw.fileMetadata;
if (fileMetadataRaw.fileMetadata.size == null) {
// Determine the file size of the sticker.
fm = await fs.updateFileMetadata(
fileMetadataRaw.fileMetadata.id,
size: File(stickerPath).lengthSync(),
);
}
stickers[i] = await _addStickerFromData(
getStrongestHashFromMap(sticker.fileMetadata.plaintextHashes) ??
DateTime.now().millisecondsSinceEpoch.toString(),
remotePack.hashValue,
sticker.desc,
sticker.suggests,
fm,
);
}
if (!success) {
_log.severe('Import failed');
return null;
}
// Add the sticker pack to the database
await _addStickerPackFromData(remotePack);
// Publish but don't block
unawaited(
_publishStickerPack(remotePack.toMoxxmpp()),
);
return remotePack.copyWith(
stickers: stickers,
local: true,
);
}
/// Imports a sticker pack from [path].
/// The format is as follows:
/// - The file MUST be an uncompressed tar archive
/// - All files must be at the top level of the archive
/// - A file 'urn.xmpp.stickers.0.xml' must exist and must contain only the <pack /> element
/// - The File Metadata Elements must also contain a <name /> element
/// - The file referenced by the <name/> element must also exist on the archive's top level
Future<StickerPack?> importFromFile(String path) async {
final archiveBytes = await File(path).readAsBytes();
final archive = TarDecoder().decodeBytes(archiveBytes);
final metadata = archive.findFile('urn.xmpp.stickers.0.xml');
if (metadata == null) {
_log.severe('Invalid sticker pack: No metadata file');
return null;
}
moxxmpp.StickerPack packRaw;
try {
final content = utf8.decode(metadata.content as List<int>);
final node = moxxmpp.XMLNode.fromString(content);
packRaw = moxxmpp.StickerPack.fromXML(
'',
node,
hashAvailable: false,
);
} catch (ex) {
_log.severe('Invalid sticker pack description: $ex');
return null;
}
if (packRaw.restricted) {
_log.severe('Invalid sticker pack: Restricted');
return null;
}
for (final sticker in packRaw.stickers) {
final filename = sticker.metadata.name;
if (filename == null) {
_log.severe('Invalid sticker pack: One sticker has no <name/>');
return null;
}
final stickerFile = archive.findFile(filename);
if (stickerFile == null) {
_log.severe(
'Invalid sticker pack: $filename does not exist in archive',
);
return null;
}
}
final pack = packRaw.copyWithId(
moxxmpp.HashFunction.sha256,
await packRaw.getHash(moxxmpp.HashFunction.sha256),
);
_log.finest('New sticker pack identifier: sha256:${pack.id}');
if (await getStickerPackById(pack.id) != null) {
_log.severe('Invalid sticker pack: Already exists');
return null;
}
final stickerDirPath = await getStickerPackPath(
pack.hashAlgorithm.toName(),
pack.hashValue,
);
final stickerDir = Directory(stickerDirPath);
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
// Create the sticker pack first
final stickerPack = StickerPack(
pack.hashValue,
pack.name,
pack.summary,
[],
pack.hashAlgorithm.toName(),
pack.hashValue,
pack.restricted,
true,
DateTime.now().millisecondsSinceEpoch,
0,
);
await _addStickerPackFromData(stickerPack);
// Add all stickers
var size = 0;
final stickers = List<Sticker>.empty(growable: true);
final fs = GetIt.I.get<FilesService>();
for (final sticker in pack.stickers) {
// Get the "path" to the sticker
final stickerPath = await computeCachedPathForFile(
sticker.metadata.name!,
sticker.metadata.hashes,
);
// Get metadata
final urlSources = sticker.sources
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList();
final fileMetadataRaw = await fs.createFileMetadataIfRequired(
MediaFileLocation(
urlSources,
p.basename(stickerPath),
null,
null,
null,
sticker.metadata.hashes,
null,
sticker.metadata.size,
),
sticker.metadata.mediaType,
sticker.metadata.size,
sticker.metadata.width != null && sticker.metadata.height != null
? Size(
sticker.metadata.width!.toDouble(),
sticker.metadata.height!.toDouble(),
)
: null,
// TODO(Unknown): Maybe consider the thumbnails one day
null,
null,
path: stickerPath,
);
// Only copy the sticker to storage if we don't already have it
var fm = fileMetadataRaw.fileMetadata;
if (!fileMetadataRaw.retrieved ||
fileMetadataRaw.fileMetadata.path == null) {
_log.finest(
'Copying sticker ${sticker.metadata.name!} to media storage',
);
final stickerFile = archive.findFile(sticker.metadata.name!)!;
final file = File(stickerPath);
await file.writeAsBytes(
stickerFile.content as List<int>,
);
// Update the File Metadata entry
fm = await fs.updateFileMetadata(
fm.id,
size: file.lengthSync(),
path: stickerPath,
);
size += file.lengthSync();
} else {
_log.finest(
'Not copying sticker ${sticker.metadata.name!} as we already have it',
);
}
// Check if the sticker has size
if (fm.size == null) {
_log.finest(
'Sticker ${sticker.metadata.name!} has no size. Calculating it',
);
// Update the File Metadata entry
fm = await fs.updateFileMetadata(
fm.id,
size: File(stickerPath).lengthSync(),
);
size += fm.size!;
}
stickers.add(
await _addStickerFromData(
getStrongestHashFromMap(sticker.metadata.hashes) ??
DateTime.now().millisecondsSinceEpoch.toString(),
pack.hashValue,
sticker.metadata.desc!,
sticker.suggests,
fm,
),
);
}
final stickerPackWithStickers = stickerPack.copyWith(
stickers: stickers,
size: size,
);
_log.info(
'Sticker pack ${stickerPack.id} successfully added to the database',
);
// Publish but don't block
unawaited(_publishStickerPack(pack));
return stickerPackWithStickers;
}
/// Returns a paginated list of sticker packs.
/// [includeStickers] controls whether the stickers for a given sticker pack are
/// fetched from the database. Setting this to false, i.e. not loading the stickers,
/// can be useful, for example, when we're only interested in listing the sticker
/// packs without the stickers being visible.
Future<List<StickerPack>> getPaginatedStickerPacks(
bool olderThan,
int? timestamp,
bool includeStickers,
) async {
final db = GetIt.I.get<DatabaseService>().database;
final comparator = olderThan ? '<' : '>';
final query = timestamp != null ? 'addedTimestamp $comparator ?' : null;
final stickerPacksRaw = await db.query(
stickerPacksTable,
where: query,
orderBy: 'addedTimestamp DESC',
limit: stickerPackPaginationSize,
);
final stickerPacks = List<StickerPack>.empty(growable: true);
for (final pack in stickerPacksRaw) {
// Query the stickers
List<Map<String, Object?>> stickersRaw;
if (includeStickers) {
stickersRaw = await db.rawQuery(
'''
SELECT
st.*,
fm.id AS fm_id,
fm.path AS fm_path,
fm.sourceUrls AS fm_sourceUrls,
fm.mimeType AS fm_mimeType,
fm.thumbnailType AS fm_thumbnailType,
fm.thumbnailData AS fm_thumbnailData,
fm.width AS fm_width,
fm.height AS fm_height,
fm.plaintextHashes AS fm_plaintextHashes,
fm.encryptionKey AS fm_encryptionKey,
fm.encryptionIv AS fm_encryptionIv,
fm.encryptionScheme AS fm_encryptionScheme,
fm.cipherTextHashes AS fm_cipherTextHashes,
fm.filename AS fm_filename,
fm.size AS fm_size
FROM
$stickersTable AS st,
$fileMetadataTable AS fm
WHERE
st.stickerPackId = ? AND
st.file_metadata_id = fm.id
''',
[
pack['id']! as String,
],
);
} else {
stickersRaw = List<Map<String, Object?>>.empty();
}
final stickerPack = StickerPack.fromDatabaseJson(
pack,
stickersRaw.map((sticker) {
return Sticker.fromDatabaseJson(
sticker,
FileMetadata.fromDatabaseJson(
getPrefixedSubMap(sticker, 'fm_'),
),
);
}).toList(),
);
/// If stickers were not requested, we still have to get the size of the
/// sticker pack anyway.
int size;
if (includeStickers && stickerPack.stickers.isNotEmpty) {
size = stickerPack.stickers
.map((sticker) => sticker.fileMetadata.size ?? 0)
.reduce((value, element) => value + element);
} else {
final sizeResult = await db.rawQuery(
'''
SELECT
SUM(fm.size) as size
FROM
$fileMetadataTable as fm,
$stickersTable as st
WHERE
st.stickerPackId = ? AND
st.file_metadata_id = fm.id
''',
[pack['id']! as String],
);
size = sizeResult.first['size'] as int? ?? 0;
}
stickerPacks.add(
stickerPack.copyWith(
size: size,
),
);
}
return stickerPacks;
}
}