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