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