]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java
cfa39bc9aaf4921477b3f351cb34671b7c17f42c
[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.id(), 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 record Storage(@JsonDeserialize(using = GroupsDeserializer.class) List<Object> groups) {
278
279 private record GroupV1(
280 String groupId,
281 String expectedV2Id,
282 String name,
283 String color,
284 int messageExpirationTime,
285 boolean blocked,
286 boolean archived,
287 @JsonDeserialize(using = MembersDeserializer.class) @JsonSerialize(using = MembersSerializer.class) List<Member> members
288 ) {
289
290 private record Member(Long recipientId, String uuid, String number) {}
291
292 private record JsonRecipientAddress(String uuid, String number) {}
293
294 private static class MembersSerializer extends JsonSerializer<List<Member>> {
295
296 @Override
297 public void serialize(
298 final List<Member> value, final JsonGenerator jgen, final SerializerProvider provider
299 ) throws IOException {
300 jgen.writeStartArray(null, value.size());
301 for (var address : value) {
302 if (address.recipientId != null) {
303 jgen.writeNumber(address.recipientId);
304 } else if (address.uuid != null) {
305 jgen.writeObject(new JsonRecipientAddress(address.uuid, address.number));
306 } else {
307 jgen.writeString(address.number);
308 }
309 }
310 jgen.writeEndArray();
311 }
312 }
313
314 private static class MembersDeserializer extends JsonDeserializer<List<Member>> {
315
316 @Override
317 public List<Member> deserialize(
318 JsonParser jsonParser, DeserializationContext deserializationContext
319 ) throws IOException {
320 var addresses = new ArrayList<Member>();
321 JsonNode node = jsonParser.getCodec().readTree(jsonParser);
322 for (var n : node) {
323 if (n.isTextual()) {
324 addresses.add(new Member(null, null, n.textValue()));
325 } else if (n.isNumber()) {
326 addresses.add(new Member(n.numberValue().longValue(), null, null));
327 } else {
328 var address = jsonParser.getCodec().treeToValue(n, JsonRecipientAddress.class);
329 addresses.add(new Member(null, address.uuid, address.number));
330 }
331 }
332
333 return addresses;
334 }
335 }
336 }
337
338 private record GroupV2(String groupId, String masterKey, boolean blocked, boolean permissionDenied) {}
339 }
340
341 private static class GroupsDeserializer extends JsonDeserializer<List<Object>> {
342
343 @Override
344 public List<Object> deserialize(
345 JsonParser jsonParser, DeserializationContext deserializationContext
346 ) throws IOException {
347 var groups = new ArrayList<>();
348 JsonNode node = jsonParser.getCodec().readTree(jsonParser);
349 for (var n : node) {
350 Object g;
351 if (n.hasNonNull("masterKey")) {
352 // a v2 group
353 g = jsonParser.getCodec().treeToValue(n, Storage.GroupV2.class);
354 } else {
355 g = jsonParser.getCodec().treeToValue(n, Storage.GroupV1.class);
356 }
357 groups.add(g);
358 }
359
360 return groups;
361 }
362 }
363
364 public interface Saver {
365
366 void save(Storage storage);
367 }
368 }