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