]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStore.java
ea2f0a1b5e9d70fd5a9043ae2f8233ed4248e785
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / storage / accounts / AccountsStore.java
1 package org.asamk.signal.manager.storage.accounts;
2
3 import com.fasterxml.jackson.databind.ObjectMapper;
4
5 import org.asamk.signal.manager.api.Pair;
6 import org.asamk.signal.manager.storage.Utils;
7 import org.asamk.signal.manager.util.IOUtils;
8 import org.slf4j.Logger;
9 import org.slf4j.LoggerFactory;
10 import org.whispersystems.signalservice.api.push.ACI;
11 import org.whispersystems.signalservice.api.util.PhoneNumberFormatter;
12
13 import java.io.ByteArrayInputStream;
14 import java.io.ByteArrayOutputStream;
15 import java.io.File;
16 import java.io.IOException;
17 import java.io.RandomAccessFile;
18 import java.nio.channels.Channels;
19 import java.nio.channels.FileChannel;
20 import java.nio.channels.FileLock;
21 import java.util.Arrays;
22 import java.util.List;
23 import java.util.Objects;
24 import java.util.Random;
25 import java.util.Set;
26 import java.util.function.Function;
27 import java.util.stream.Collectors;
28 import java.util.stream.Stream;
29
30 public class AccountsStore {
31
32 private final static Logger logger = LoggerFactory.getLogger(AccountsStore.class);
33 private final ObjectMapper objectMapper = Utils.createStorageObjectMapper();
34
35 private final File dataPath;
36
37 public AccountsStore(final File dataPath) throws IOException {
38 this.dataPath = dataPath;
39 if (!getAccountsFile().exists()) {
40 createInitialAccounts();
41 }
42 }
43
44 public synchronized Set<String> getAllNumbers() {
45 return readAccounts().stream()
46 .map(AccountsStorage.Account::number)
47 .filter(Objects::nonNull)
48 .collect(Collectors.toSet());
49 }
50
51 public synchronized Set<AccountsStorage.Account> getAllAccounts() {
52 return readAccounts().stream().filter(a -> a.number() != null).collect(Collectors.toSet());
53 }
54
55 public synchronized String getPathByNumber(String number) {
56 return readAccounts().stream()
57 .filter(a -> number.equals(a.number()))
58 .map(AccountsStorage.Account::path)
59 .findFirst()
60 .orElse(null);
61 }
62
63 public synchronized String getPathByAci(ACI aci) {
64 return readAccounts().stream()
65 .filter(a -> aci.toString().equals(a.uuid()))
66 .map(AccountsStorage.Account::path)
67 .findFirst()
68 .orElse(null);
69 }
70
71 public synchronized void updateAccount(String path, String number, ACI aci) {
72 updateAccounts(accounts -> accounts.stream().map(a -> {
73 if (path.equals(a.path())) {
74 return new AccountsStorage.Account(a.path(), number, aci == null ? null : aci.toString());
75 }
76
77 if (number != null && number.equals(a.number())) {
78 return new AccountsStorage.Account(a.path(), null, a.uuid());
79 }
80 if (aci != null && aci.toString().equals(a.toString())) {
81 return new AccountsStorage.Account(a.path(), a.number(), null);
82 }
83
84 return a;
85 }).toList());
86 }
87
88 public synchronized String addAccount(String number, ACI aci) {
89 final var accountPath = generateNewAccountPath();
90 final var account = new AccountsStorage.Account(accountPath, number, aci == null ? null : aci.toString());
91 updateAccounts(accounts -> {
92 final var existingAccounts = accounts.stream().map(a -> {
93 if (number != null && number.equals(a.number())) {
94 return new AccountsStorage.Account(a.path(), null, a.uuid());
95 }
96 if (aci != null && aci.toString().equals(a.toString())) {
97 return new AccountsStorage.Account(a.path(), a.number(), null);
98 }
99
100 return a;
101 });
102 return Stream.concat(existingAccounts, Stream.of(account)).toList();
103 });
104 return accountPath;
105 }
106
107 public void removeAccount(final String accountPath) {
108 updateAccounts(accounts -> accounts.stream().filter(a -> !a.path().equals(accountPath)).toList());
109 }
110
111 private String generateNewAccountPath() {
112 return new Random().ints(100000, 1000000)
113 .mapToObj(String::valueOf)
114 .filter(n -> !new File(dataPath, n).exists() && !new File(dataPath, n + ".d").exists())
115 .findFirst()
116 .get();
117 }
118
119 private File getAccountsFile() {
120 return new File(dataPath, "accounts.json");
121 }
122
123 private void createInitialAccounts() throws IOException {
124 final var legacyAccountPaths = getLegacyAccountPaths();
125 final var accountsStorage = new AccountsStorage(legacyAccountPaths.stream()
126 .map(number -> new AccountsStorage.Account(number, number, null))
127 .toList());
128
129 IOUtils.createPrivateDirectories(dataPath);
130 var fileName = getAccountsFile();
131 if (!fileName.exists()) {
132 IOUtils.createPrivateFile(fileName);
133 }
134
135 final var pair = openFileChannel(getAccountsFile());
136 try (final var fileChannel = pair.first(); final var lock = pair.second()) {
137 saveAccountsLocked(fileChannel, accountsStorage);
138 }
139 }
140
141 private Set<String> getLegacyAccountPaths() {
142 final var files = dataPath.listFiles();
143
144 if (files == null) {
145 return Set.of();
146 }
147
148 return Arrays.stream(files)
149 .filter(File::isFile)
150 .map(File::getName)
151 .filter(file -> PhoneNumberFormatter.isValidNumber(file, null))
152 .collect(Collectors.toSet());
153 }
154
155 private List<AccountsStorage.Account> readAccounts() {
156 try {
157 final var pair = openFileChannel(getAccountsFile());
158 try (final var fileChannel = pair.first(); final var lock = pair.second()) {
159 return readAccountsLocked(fileChannel).accounts();
160 }
161 } catch (IOException e) {
162 logger.error("Failed to read accounts list", e);
163 return List.of();
164 }
165 }
166
167 private void updateAccounts(Function<List<AccountsStorage.Account>, List<AccountsStorage.Account>> updater) {
168 try {
169 final var pair = openFileChannel(getAccountsFile());
170 try (final var fileChannel = pair.first(); final var lock = pair.second()) {
171 final var accountsStorage = readAccountsLocked(fileChannel);
172 final var newAccountsStorage = updater.apply(accountsStorage.accounts());
173 saveAccountsLocked(fileChannel, new AccountsStorage(newAccountsStorage));
174 }
175 } catch (IOException e) {
176 logger.error("Failed to update accounts list", e);
177 }
178 }
179
180 private AccountsStorage readAccountsLocked(FileChannel fileChannel) throws IOException {
181 fileChannel.position(0);
182 final var inputStream = Channels.newInputStream(fileChannel);
183 return objectMapper.readValue(inputStream, AccountsStorage.class);
184 }
185
186 private void saveAccountsLocked(FileChannel fileChannel, AccountsStorage accountsStorage) throws IOException {
187 try {
188 try (var output = new ByteArrayOutputStream()) {
189 // Write to memory first to prevent corrupting the file in case of serialization errors
190 objectMapper.writeValue(output, accountsStorage);
191 var input = new ByteArrayInputStream(output.toByteArray());
192 fileChannel.position(0);
193 input.transferTo(Channels.newOutputStream(fileChannel));
194 fileChannel.truncate(fileChannel.position());
195 fileChannel.force(false);
196 }
197 } catch (Exception e) {
198 logger.error("Error saving accounts file: {}", e.getMessage(), e);
199 }
200 }
201
202 private static Pair<FileChannel, FileLock> openFileChannel(File fileName) throws IOException {
203 var fileChannel = new RandomAccessFile(fileName, "rw").getChannel();
204 var lock = fileChannel.tryLock();
205 if (lock == null) {
206 logger.info("Config file is in use by another instance, waiting…");
207 lock = fileChannel.lock();
208 logger.info("Config file lock acquired.");
209 }
210 return new Pair<>(fileChannel, lock);
211 }
212 }