]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java
5cef1b338a58bd4f392e416b56508014fcb282ba
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / storage / groups / GroupStore.java
1 package org.asamk.signal.manager.storage.groups;
2
3 import com.fasterxml.jackson.core.JsonGenerator;
4 import com.fasterxml.jackson.core.JsonParser;
5 import com.fasterxml.jackson.databind.DeserializationContext;
6 import com.fasterxml.jackson.databind.JsonDeserializer;
7 import com.fasterxml.jackson.databind.JsonNode;
8 import com.fasterxml.jackson.databind.JsonSerializer;
9 import com.fasterxml.jackson.databind.SerializerProvider;
10 import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
11 import com.fasterxml.jackson.databind.annotation.JsonSerialize;
12
13 import org.asamk.signal.manager.groups.GroupId;
14 import org.asamk.signal.manager.groups.GroupIdV1;
15 import org.asamk.signal.manager.groups.GroupIdV2;
16 import org.asamk.signal.manager.groups.GroupUtils;
17 import org.asamk.signal.manager.storage.recipients.RecipientAddress;
18 import org.asamk.signal.manager.storage.recipients.RecipientId;
19 import org.asamk.signal.manager.storage.recipients.RecipientResolver;
20 import org.asamk.signal.manager.util.IOUtils;
21 import org.signal.storageservice.protos.groups.local.DecryptedGroup;
22 import org.signal.zkgroup.InvalidInputException;
23 import org.signal.zkgroup.groups.GroupMasterKey;
24 import org.slf4j.Logger;
25 import org.slf4j.LoggerFactory;
26 import org.whispersystems.signalservice.api.util.UuidUtil;
27 import org.whispersystems.signalservice.internal.util.Hex;
28
29 import java.io.File;
30 import java.io.FileInputStream;
31 import java.io.FileOutputStream;
32 import java.io.IOException;
33 import java.nio.file.Files;
34 import java.util.ArrayList;
35 import java.util.Base64;
36 import java.util.HashMap;
37 import java.util.List;
38 import java.util.Map;
39 import java.util.stream.Collectors;
40
41 public class GroupStore {
42
43 private final static Logger logger = LoggerFactory.getLogger(GroupStore.class);
44
45 private final File groupCachePath;
46 private final Map<GroupId, GroupInfo> groups;
47 private final RecipientResolver recipientResolver;
48 private final Saver saver;
49
50 private GroupStore(
51 final File groupCachePath,
52 final Map<GroupId, GroupInfo> groups,
53 final RecipientResolver recipientResolver,
54 final Saver saver
55 ) {
56 this.groupCachePath = groupCachePath;
57 this.groups = groups;
58 this.recipientResolver = recipientResolver;
59 this.saver = saver;
60 }
61
62 public GroupStore(
63 final File groupCachePath, final RecipientResolver recipientResolver, final Saver saver
64 ) {
65 this.groups = new HashMap<>();
66 this.groupCachePath = groupCachePath;
67 this.recipientResolver = recipientResolver;
68 this.saver = saver;
69 }
70
71 public static GroupStore fromStorage(
72 final Storage storage,
73 final File groupCachePath,
74 final RecipientResolver recipientResolver,
75 final Saver saver
76 ) {
77 final var groups = storage.groups.stream().map(g -> {
78 if (g instanceof Storage.GroupV1 g1) {
79 final var members = g1.members.stream().map(m -> {
80 if (m.recipientId == null) {
81 return recipientResolver.resolveRecipient(new RecipientAddress(UuidUtil.parseOrNull(m.uuid),
82 m.number));
83 }
84
85 return RecipientId.of(m.recipientId);
86 }).collect(Collectors.toSet());
87
88 return new GroupInfoV1(GroupIdV1.fromBase64(g1.groupId),
89 g1.expectedV2Id == null ? null : GroupIdV2.fromBase64(g1.expectedV2Id),
90 g1.name,
91 members,
92 g1.color,
93 g1.messageExpirationTime,
94 g1.blocked,
95 g1.archived);
96 }
97
98 final var g2 = (Storage.GroupV2) g;
99 var groupId = GroupIdV2.fromBase64(g2.groupId);
100 GroupMasterKey masterKey;
101 try {
102 masterKey = new GroupMasterKey(Base64.getDecoder().decode(g2.masterKey));
103 } catch (InvalidInputException | IllegalArgumentException e) {
104 throw new AssertionError("Invalid master key for group " + groupId.toBase64());
105 }
106
107 return new GroupInfoV2(groupId, masterKey, g2.blocked, g2.permissionDenied);
108 }).collect(Collectors.toMap(GroupInfo::getGroupId, g -> g));
109
110 return new GroupStore(groupCachePath, groups, recipientResolver, saver);
111 }
112
113 public void updateGroup(GroupInfo group) {
114 final Storage storage;
115 synchronized (groups) {
116 groups.put(group.getGroupId(), group);
117 if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() != null) {
118 try {
119 IOUtils.createPrivateDirectories(groupCachePath);
120 try (var stream = new FileOutputStream(getGroupV2File(group.getGroupId()))) {
121 ((GroupInfoV2) group).getGroup().writeTo(stream);
122 }
123 final var groupFileLegacy = getGroupV2FileLegacy(group.getGroupId());
124 if (groupFileLegacy.exists()) {
125 try {
126 Files.delete(groupFileLegacy.toPath());
127 } catch (IOException e) {
128 logger.error("Failed to delete legacy group file {}: {}", groupFileLegacy, e.getMessage());
129 }
130 }
131 } catch (IOException e) {
132 logger.warn("Failed to cache group, ignoring: {}", e.getMessage());
133 }
134 }
135 storage = toStorageLocked();
136 }
137 saver.save(storage);
138 }
139
140 public void deleteGroupV1(GroupIdV1 groupIdV1) {
141 deleteGroup(groupIdV1);
142 }
143
144 public void deleteGroup(GroupId groupId) {
145 final Storage storage;
146 synchronized (groups) {
147 groups.remove(groupId);
148 storage = toStorageLocked();
149 }
150 saver.save(storage);
151 }
152
153 public GroupInfo getGroup(GroupId groupId) {
154 synchronized (groups) {
155 return getGroupLocked(groupId);
156 }
157 }
158
159 public GroupInfoV1 getOrCreateGroupV1(GroupIdV1 groupId) {
160 synchronized (groups) {
161 var group = getGroupLocked(groupId);
162 if (group instanceof GroupInfoV1) {
163 return (GroupInfoV1) group;
164 }
165
166 if (group == null) {
167 return new GroupInfoV1(groupId);
168 }
169
170 return null;
171 }
172 }
173
174 public List<GroupInfo> getGroups() {
175 synchronized (groups) {
176 final var groups = this.groups.values();
177 for (var group : groups) {
178 loadDecryptedGroupLocked(group);
179 }
180 return new ArrayList<>(groups);
181 }
182 }
183
184 public void mergeRecipients(final RecipientId recipientId, final RecipientId toBeMergedRecipientId) {
185 synchronized (groups) {
186 var modified = false;
187 for (var group : this.groups.values()) {
188 if (group instanceof GroupInfoV1 groupV1) {
189 if (groupV1.isMember(toBeMergedRecipientId)) {
190 groupV1.removeMember(toBeMergedRecipientId);
191 groupV1.addMembers(List.of(recipientId));
192 modified = true;
193 }
194 }
195 }
196 if (modified) {
197 saver.save(toStorageLocked());
198 }
199 }
200 }
201
202 private GroupInfo getGroupLocked(final GroupId groupId) {
203 var group = groups.get(groupId);
204 if (group == null) {
205 if (groupId instanceof GroupIdV1) {
206 group = getGroupByV1IdLocked((GroupIdV1) groupId);
207 } else if (groupId instanceof GroupIdV2) {
208 group = getGroupV1ByV2IdLocked((GroupIdV2) groupId);
209 }
210 }
211 loadDecryptedGroupLocked(group);
212 return group;
213 }
214
215 private GroupInfo getGroupByV1IdLocked(final GroupIdV1 groupId) {
216 return groups.get(GroupUtils.getGroupIdV2(groupId));
217 }
218
219 private GroupInfoV1 getGroupV1ByV2IdLocked(GroupIdV2 groupIdV2) {
220 for (var g : groups.values()) {
221 if (g instanceof GroupInfoV1 gv1) {
222 if (groupIdV2.equals(gv1.getExpectedV2Id())) {
223 return gv1;
224 }
225 }
226 }
227 return null;
228 }
229
230 private void loadDecryptedGroupLocked(final GroupInfo group) {
231 if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() == null) {
232 var groupFile = getGroupV2File(group.getGroupId());
233 if (!groupFile.exists()) {
234 groupFile = getGroupV2FileLegacy(group.getGroupId());
235 }
236 if (!groupFile.exists()) {
237 return;
238 }
239 try (var stream = new FileInputStream(groupFile)) {
240 ((GroupInfoV2) group).setGroup(DecryptedGroup.parseFrom(stream), recipientResolver);
241 } catch (IOException ignored) {
242 }
243 }
244 }
245
246 private File getGroupV2FileLegacy(final GroupId groupId) {
247 return new File(groupCachePath, Hex.toStringCondensed(groupId.serialize()));
248 }
249
250 private File getGroupV2File(final GroupId groupId) {
251 return new File(groupCachePath, groupId.toBase64().replace("/", "_"));
252 }
253
254 private Storage toStorageLocked() {
255 return new Storage(groups.values().stream().map(g -> {
256 if (g instanceof GroupInfoV1 g1) {
257 return new Storage.GroupV1(g1.getGroupId().toBase64(),
258 g1.getExpectedV2Id().toBase64(),
259 g1.name,
260 g1.color,
261 g1.messageExpirationTime,
262 g1.blocked,
263 g1.archived,
264 g1.members.stream()
265 .map(m -> new Storage.GroupV1.Member(m.getId(), null, null))
266 .collect(Collectors.toList()));
267 }
268
269 final var g2 = (GroupInfoV2) g;
270 return new Storage.GroupV2(g2.getGroupId().toBase64(),
271 Base64.getEncoder().encodeToString(g2.getMasterKey().serialize()),
272 g2.isBlocked(),
273 g2.isPermissionDenied());
274 }).collect(Collectors.toList()));
275 }
276
277 public static class Storage {
278
279 @JsonDeserialize(using = GroupsDeserializer.class)
280 public List<Storage.Group> groups;
281
282 // For deserialization
283 public Storage() {
284 }
285
286 public Storage(final List<Storage.Group> groups) {
287 this.groups = groups;
288 }
289
290 private abstract static class Group {
291
292 }
293
294 private static class GroupV1 extends Group {
295
296 public String groupId;
297 public String expectedV2Id;
298 public String name;
299 public String color;
300 public int messageExpirationTime;
301 public boolean blocked;
302 public boolean archived;
303
304 @JsonDeserialize(using = MembersDeserializer.class)
305 @JsonSerialize(using = MembersSerializer.class)
306 public List<Member> members;
307
308 // For deserialization
309 public GroupV1() {
310 }
311
312 public GroupV1(
313 final String groupId,
314 final String expectedV2Id,
315 final String name,
316 final String color,
317 final int messageExpirationTime,
318 final boolean blocked,
319 final boolean archived,
320 final List<Member> members
321 ) {
322 this.groupId = groupId;
323 this.expectedV2Id = expectedV2Id;
324 this.name = name;
325 this.color = color;
326 this.messageExpirationTime = messageExpirationTime;
327 this.blocked = blocked;
328 this.archived = archived;
329 this.members = members;
330 }
331
332 private static final class Member {
333
334 public Long recipientId;
335
336 public String uuid;
337
338 public String number;
339
340 Member(Long recipientId, final String uuid, final String number) {
341 this.recipientId = recipientId;
342 this.uuid = uuid;
343 this.number = number;
344 }
345 }
346
347 private static final class JsonRecipientAddress {
348
349 public String uuid;
350
351 public String number;
352
353 // For deserialization
354 public JsonRecipientAddress() {
355 }
356
357 JsonRecipientAddress(final String uuid, final String number) {
358 this.uuid = uuid;
359 this.number = number;
360 }
361 }
362
363 private static class MembersSerializer extends JsonSerializer<List<Member>> {
364
365 @Override
366 public void serialize(
367 final List<Member> value, final JsonGenerator jgen, final SerializerProvider provider
368 ) throws IOException {
369 jgen.writeStartArray(value.size());
370 for (var address : value) {
371 if (address.recipientId != null) {
372 jgen.writeNumber(address.recipientId);
373 } else if (address.uuid != null) {
374 jgen.writeObject(new JsonRecipientAddress(address.uuid, address.number));
375 } else {
376 jgen.writeString(address.number);
377 }
378 }
379 jgen.writeEndArray();
380 }
381 }
382
383 private static class MembersDeserializer extends JsonDeserializer<List<Member>> {
384
385 @Override
386 public List<Member> deserialize(
387 JsonParser jsonParser, DeserializationContext deserializationContext
388 ) throws IOException {
389 var addresses = new ArrayList<Member>();
390 JsonNode node = jsonParser.getCodec().readTree(jsonParser);
391 for (var n : node) {
392 if (n.isTextual()) {
393 addresses.add(new Member(null, null, n.textValue()));
394 } else if (n.isNumber()) {
395 addresses.add(new Member(n.numberValue().longValue(), null, null));
396 } else {
397 var address = jsonParser.getCodec().treeToValue(n, JsonRecipientAddress.class);
398 addresses.add(new Member(null, address.uuid, address.number));
399 }
400 }
401
402 return addresses;
403 }
404 }
405 }
406
407 private static class GroupV2 extends Group {
408
409 public String groupId;
410 public String masterKey;
411 public boolean blocked;
412 public boolean permissionDenied;
413
414 // For deserialization
415 private GroupV2() {
416 }
417
418 public GroupV2(
419 final String groupId, final String masterKey, final boolean blocked, final boolean permissionDenied
420 ) {
421 this.groupId = groupId;
422 this.masterKey = masterKey;
423 this.blocked = blocked;
424 this.permissionDenied = permissionDenied;
425 }
426 }
427
428 }
429
430 private static class GroupsDeserializer extends JsonDeserializer<List<Storage.Group>> {
431
432 @Override
433 public List<Storage.Group> deserialize(
434 JsonParser jsonParser, DeserializationContext deserializationContext
435 ) throws IOException {
436 var groups = new ArrayList<Storage.Group>();
437 JsonNode node = jsonParser.getCodec().readTree(jsonParser);
438 for (var n : node) {
439 Storage.Group g;
440 if (n.hasNonNull("masterKey")) {
441 // a v2 group
442 g = jsonParser.getCodec().treeToValue(n, Storage.GroupV2.class);
443 } else {
444 g = jsonParser.getCodec().treeToValue(n, Storage.GroupV1.class);
445 }
446 groups.add(g);
447 }
448
449 return groups;
450 }
451 }
452
453 public interface Saver {
454
455 void save(Storage storage);
456 }
457 }