Skip to content

Commit 8b67254

Browse files
committed
chore: Now using timestamped tombstones to store deleted TOTPs.
1 parent dc77dd5 commit 8b67254

13 files changed

Lines changed: 348 additions & 165 deletions

File tree

drift_schemas/app/drift_schema_v2.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,16 @@
169169
"default_dart": null,
170170
"default_client_dart": null,
171171
"dsl_features": []
172+
},
173+
{
174+
"name": "deleted_at",
175+
"getter_name": "deletedAt",
176+
"moor_type": "dateTime",
177+
"nullable": false,
178+
"customConstraints": null,
179+
"default_dart": "const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')",
180+
"default_client_dart": null,
181+
"dsl_features": []
172182
}
173183
],
174184
"is_virtual": false,
@@ -324,7 +334,7 @@
324334
"sql": [
325335
{
326336
"dialect": "sqlite",
327-
"sql": "CREATE TABLE IF NOT EXISTS \"deleted_totps\" (\"uuid\" TEXT NOT NULL, PRIMARY KEY (\"uuid\"));"
337+
"sql": "CREATE TABLE IF NOT EXISTS \"deleted_totps\" (\"uuid\" TEXT NOT NULL, \"deleted_at\" INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)), PRIMARY KEY (\"uuid\"));"
328338
}
329339
]
330340
},

lib/model/backend/request/request.dart

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ class InvalidJsonResponse extends LocalizableException {
7676
/// Represents a backend request with a body.
7777
mixin BackendWithBodyRequest<T extends BackendResponse> on BackendRequest<T> {
7878
/// The body.
79-
Object? get body => null;
79+
Object? get jsonBody => null;
8080

8181
/// The encoding.
8282
Encoding? get encoding => null;
@@ -112,7 +112,7 @@ abstract class BackendPostRequest<T extends BackendResponse> extends BackendRequ
112112
if (headers != null) ...headers,
113113
'Content-Type': 'application/json',
114114
},
115-
body: jsonEncode(body),
115+
body: jsonEncode(jsonBody),
116116
encoding: encoding,
117117
);
118118
}
@@ -132,7 +132,7 @@ abstract class BackendDeleteRequest<T extends BackendResponse> extends BackendRe
132132
if (headers != null) ...headers,
133133
'Content-Type': 'application/json',
134134
},
135-
body: jsonEncode(body),
135+
body: jsonEncode(jsonBody),
136136
encoding: encoding,
137137
);
138138
}
@@ -190,7 +190,7 @@ class RefreshSessionRequest extends BackendPostRequest<RefreshSessionResponse> {
190190
);
191191

192192
@override
193-
Object? get body => {'refreshToken': refreshToken};
193+
Object? get jsonBody => {'refreshToken': refreshToken};
194194

195195
@override
196196
RefreshSessionResponse _toResponseIfNoError(dynamic data) => RefreshSessionResponse.fromJson(data);
@@ -210,7 +210,7 @@ class UserLogoutRequest extends BackendPostRequest<UserLogoutResponse> {
210210
);
211211

212212
@override
213-
Object? get body => {'refreshToken': refreshToken};
213+
Object? get jsonBody => {'refreshToken': refreshToken};
214214

215215
@override
216216
UserLogoutResponse _toResponseIfNoError(dynamic data) => UserLogoutResponse.fromJson(data);
@@ -234,7 +234,7 @@ class EmailConfirmRequest extends BackendPostRequest<EmailConfirmResponse> {
234234
);
235235

236236
@override
237-
Object? get body => {
237+
Object? get jsonBody => {
238238
'email': email,
239239
'verificationCode': verificationCode,
240240
};
@@ -261,7 +261,7 @@ class EmailConfirmationCancelRequest extends BackendPostRequest<EmailConfirmatio
261261
);
262262

263263
@override
264-
Object? get body => {
264+
Object? get jsonBody => {
265265
'email': email,
266266
'cancelCode': cancelCode,
267267
};
@@ -288,7 +288,7 @@ class ProviderLoginRequest extends BackendPostRequest<ProviderLoginResponse> {
288288
);
289289

290290
@override
291-
Object? get body => {
291+
Object? get jsonBody => {
292292
'authorizationCode': authorizationCode,
293293
if (codeVerifier != null) 'codeVerifier': codeVerifier,
294294
};
@@ -316,7 +316,7 @@ class ProviderLinkRequest extends BackendPostRequest<ProviderLinkResponse> {
316316
);
317317

318318
@override
319-
Object? get body => {
319+
Object? get jsonBody => {
320320
'authorizationCode': authorizationCode,
321321
if (codeVerifier != null) 'codeVerifier': codeVerifier,
322322
};
@@ -353,7 +353,7 @@ class SynchronizationPushRequest extends BackendPostRequest<SynchronizationPushR
353353
);
354354

355355
@override
356-
Object? get body => [
356+
Object? get jsonBody => [
357357
for (PushOperation operation in operations) operation.toJson(httpRequest: true),
358358
];
359359

@@ -363,21 +363,29 @@ class SynchronizationPushRequest extends BackendPostRequest<SynchronizationPushR
363363

364364
/// A request that allows to pull TOTPs.
365365
class SynchronizationPullRequest extends BackendPostRequest<SynchronizationPullResponse> {
366-
/// The timestamps.
367-
final Map<String, DateTime> timestamps;
366+
/// The TOTPs that are still active.
367+
final Map<String, DateTime> active;
368+
369+
/// The TOTPs that are still deleted.
370+
final Map<String, DateTime> deleted;
368371

369372
/// Creates a new synchronization pull request instance.
370373
const SynchronizationPullRequest({
371-
this.timestamps = const {},
374+
this.active = const {},
375+
this.deleted = const {},
372376
}) : super(
373377
route: '/totps/sync/pull',
374378
needsAuthorization: true,
375379
);
376380

377381
@override
378-
Object? get body => {
379-
for (String uuid in timestamps.keys) uuid: timestamps[uuid]!.millisecondsSinceEpoch,
380-
};
382+
Object? get jsonBody {
383+
MapEntry<String, dynamic> convert(String key, DateTime value) => MapEntry(key, value.millisecondsSinceEpoch);
384+
return {
385+
'active': active.map(convert),
386+
'deleted': deleted.map(convert),
387+
};
388+
}
381389

382390
@override
383391
SynchronizationPullResponse _toResponseIfNoError(dynamic data) => SynchronizationPullResponse.fromJson(data);

lib/model/backend/request/response.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,21 +189,21 @@ class SynchronizationPullResponse extends BackendResponse {
189189
final List<Totp> updates;
190190

191191
/// The TOTPs to delete.
192-
final List<String> deletes;
192+
final Map<String, DateTime> deletes;
193193

194194
/// Creates a new synchronization pull response instance.
195195
const SynchronizationPullResponse({
196196
this.inserts = const [],
197197
this.updates = const [],
198-
this.deletes = const [],
198+
this.deletes = const {},
199199
});
200200

201201
/// Creates a new synchronization pull response instance from a JSON map.
202202
SynchronizationPullResponse.fromJson(Map<String, dynamic> json)
203203
: this(
204204
inserts: _totpListFromJson(json['inserts']),
205205
updates: _totpListFromJson(json['updates']),
206-
deletes: (json['deletes'] as List).cast<String>(),
206+
deletes: (json['deletes'] as Map).map((key, value) => MapEntry(key, DateTime.fromMillisecondsSinceEpoch(value))),
207207
);
208208

209209
/// Creates a new synchronization pull response instance from a JSON map.

lib/model/backend/synchronization/push/operation.dart

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:equatable/equatable.dart';
2+
import 'package:open_authenticator/model/database/database.dart';
23
import 'package:open_authenticator/model/totp/json.dart';
34
import 'package:open_authenticator/model/totp/totp.dart';
45
import 'package:uuid/uuid.dart';
@@ -89,7 +90,7 @@ class SetTotpsPushOperation extends PushOperation<Map<String, dynamic>> {
8990
}
9091

9192
/// Represents a `delete` push operation.
92-
class DeleteTotpsPushOperation extends PushOperation<List<String>> {
93+
class DeleteTotpsPushOperation extends PushOperation<Map<String, int>> {
9394
/// Creates a new `delete` push operation instance from a raw payload.
9495
DeleteTotpsPushOperation.raw({
9596
super.uuid,
@@ -100,11 +101,13 @@ class DeleteTotpsPushOperation extends PushOperation<List<String>> {
100101
/// Creates a new `delete` push operation instance from a UUIDs list.
101102
DeleteTotpsPushOperation({
102103
String? uuid,
103-
required List<String> uuids,
104+
required DeletedTotpMap tombstones,
104105
DateTime? createdAt,
105106
}) : this.raw(
106107
uuid: uuid,
107-
payload: uuids,
108+
payload: {
109+
for (MapEntry<String, DateTime> entry in tombstones.entries) entry.key: entry.value.millisecondsSinceEpoch,
110+
},
108111
createdAt: createdAt,
109112
);
110113

@@ -113,11 +116,50 @@ class DeleteTotpsPushOperation extends PushOperation<List<String>> {
113116

114117
@override
115118
DeleteTotpsPushOperation copyWith({
116-
List<String>? payload,
119+
Map<String, int>? payload,
117120
DateTime? createdAt,
118121
}) => DeleteTotpsPushOperation.raw(
119122
uuid: uuid,
120123
payload: payload ?? this.payload,
121124
createdAt: createdAt ?? this.createdAt,
122125
);
123126
}
127+
128+
/// Allows to compact a list of push operations.
129+
extension Compact on List<PushOperation> {
130+
/// Compacts the current push operations while preserving their relative order.
131+
/// Only the latest operation for each TOTP UUID is kept.
132+
List<PushOperation> get compacted {
133+
if (isEmpty) {
134+
return [];
135+
}
136+
137+
Set<String> processedTotpUuids = {};
138+
List<PushOperation> result = [];
139+
140+
for (PushOperation operation in reversed) {
141+
switch (operation) {
142+
case SetTotpsPushOperation(:final payload):
143+
Map<String, dynamic> newPayload = {
144+
for (MapEntry<String, dynamic> entry in payload.entries)
145+
if (processedTotpUuids.add(entry.key)) entry.key: entry.value,
146+
};
147+
if (newPayload.isNotEmpty) {
148+
result.add(operation.copyWith(payload: newPayload));
149+
}
150+
break;
151+
case DeleteTotpsPushOperation(:final payload):
152+
Map<String, int> newPayload = {
153+
for (MapEntry<String, int> entry in payload.entries)
154+
if (processedTotpUuids.add(entry.key)) entry.key: entry.value,
155+
};
156+
if (newPayload.isNotEmpty) {
157+
result.add(operation.copyWith(payload: newPayload));
158+
}
159+
break;
160+
}
161+
}
162+
163+
return result.reversed.toList();
164+
}
165+
}

lib/model/backend/synchronization/push/result.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ enum PushOperationErrorKind {
105105
/// The update timestamp is invalid.
106106
invalidUpdateTimestamp(isPermanent: true),
107107

108+
/// The TOTP has been deleted more recently.
109+
deletedTotp(isPermanent: true),
110+
111+
/// The delete timestamp is invalid.
112+
invalidDeleteTimestamp(isPermanent: true),
113+
108114
/// The max TOTPs count has been exceeded.
109115
maxCountExceeded,
110116

lib/model/backend/synchronization/queue.dart

Lines changed: 18 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class PushOperationsQueue extends AsyncNotifier<List<PushOperation>> {
5353
}
5454
}
5555
List<PushOperation> operations = await future;
56-
List<PushOperation> compactedOperations = _compact(operations);
56+
List<PushOperation> compactedOperations = operations.compacted;
5757
AppDatabase database = ref.read(appDatabaseProvider);
5858
if (compactedOperations.length != operations.length) {
5959
await database.replacePendingBackendPushOperations(compactedOperations);
@@ -78,38 +78,6 @@ class PushOperationsQueue extends AsyncNotifier<List<PushOperation>> {
7878
return const ResultSuccess();
7979
}
8080

81-
/// Compacts the push operations list.
82-
List<PushOperation> _compact(List<PushOperation> operations) {
83-
if (operations.isEmpty) {
84-
return [];
85-
}
86-
87-
Set<String> processedTotpUuids = {};
88-
List<PushOperation> result = [];
89-
90-
for (PushOperation operation in operations.reversed) {
91-
switch (operation) {
92-
case SetTotpsPushOperation(:final payload):
93-
Map<String, dynamic> newPayload = {
94-
for (MapEntry<String, dynamic> entry in payload.entries)
95-
if (processedTotpUuids.add(entry.key)) entry.key: entry.value,
96-
};
97-
if (newPayload.isNotEmpty) {
98-
result.add(operation.copyWith(payload: newPayload));
99-
}
100-
break;
101-
case DeleteTotpsPushOperation(:final payload):
102-
List<String> newPayload = payload.where((uuid) => processedTotpUuids.add(uuid)).toList();
103-
if (newPayload.isNotEmpty) {
104-
result.add(operation.copyWith(payload: newPayload));
105-
}
106-
break;
107-
}
108-
}
109-
110-
return result.reversed.toList();
111-
}
112-
11381
/// Triggered when the database updates.
11482
void _onDatabaseUpdate(List<PushOperation> operations) {
11583
if (ref.mounted) {
@@ -297,33 +265,46 @@ class SynchronizationController extends Notifier<SynchronizationStatus> {
297265
return const ResultCancelled();
298266
}
299267
}
268+
300269
List<Totp> totps = await ref.read(totpRepositoryProvider.future);
270+
DeletedTotpMap deletedTotps = await ref.read(appDatabaseProvider).getDeletedTotps();
301271
Result<SynchronizationPullResponse> result = await ref
302272
.read(backendClientProvider.notifier)
303273
.sendHttpRequest(
304274
SynchronizationPullRequest(
305-
timestamps: {
275+
active: {
306276
for (Totp totp in totps) totp.uuid: totp.updatedAt,
307277
},
278+
deleted: deletedTotps,
308279
),
309280
);
281+
310282
if (result is! ResultSuccess<SynchronizationPullResponse>) {
311283
return result;
312284
}
313285

314286
TotpRepository repository = ref.read(totpRepositoryProvider.notifier);
315-
await repository.addTotps(
287+
Result addResult = await repository.addTotps(
316288
result.value.inserts,
317289
fromNetwork: true,
318290
);
319-
await repository.updateTotps(
291+
if (addResult is! ResultSuccess) {
292+
return addResult;
293+
}
294+
Result updateResult = await repository.updateTotps(
320295
result.value.updates,
321296
fromNetwork: true,
322297
);
323-
await repository.deleteTotps(
298+
if (updateResult is! ResultSuccess) {
299+
return updateResult;
300+
}
301+
Result deleteResult = await repository.deleteTotps(
324302
result.value.deletes,
325303
fromNetwork: true,
326304
);
305+
if (deleteResult is! ResultSuccess) {
306+
return deleteResult;
307+
}
327308
return const ResultSuccess();
328309
}
329310
}

0 commit comments

Comments
 (0)