]> nmode's Git Repositories - signal-cli/blob - lib/src/main/java/org/asamk/signal/manager/storage/accounts/AccountsStore.java
a0fa301224c5b6a4a47597addc9da021a5d5852b
[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.api.ServiceEnvironment;
7 import org.asamk.signal.manager.storage.SignalAccount;
8 import org.asamk.signal.manager.storage.Utils;
9 import org.asamk.signal.manager.util.IOUtils;
10 import org.slf4j.Logger;
11 import org.slf4j.LoggerFactory;
12 import org.whispersystems.signalservice.api.push.ServiceId.ACI;
13 import org.whispersystems.signalservice.api.util.PhoneNumberFormatter;
14
15 import java.io.ByteArrayInputStream;
16 import java.io.ByteArrayOutputStream;
17 import java.io.File;
18 import java.io.IOException;
19 import java.io.RandomAccessFile;
20 import java.nio.channels.Channels;
21 import java.nio.channels.FileChannel;
22 import java.nio.channels.FileLock;
23 import java.util.Arrays;
24 import java.util.List;
25 import java.util.Objects;
26 import java.util.Random;
27 import java.util.Set;
28 import java.util.function.Function;
29 import java.util.stream.Collectors;
30 import java.util.stream.Stream;
31
32 public class AccountsStore {
33
34 private static final int MINIMUM_STORAGE_VERSION = 1;
35 private static final int CURRENT_STORAGE_VERSION = 2;
36 private static final Logger logger = LoggerFactory.getLogger(AccountsStore.class);
37 private final ObjectMapper objectMapper = Utils.createStorageObjectMapper();
38
39 private final File dataPath;
40 private final String serviceEnvironment;
41 private final AccountLoader accountLoader;
42
43 public AccountsStore(
44 final File dataPath,
45 final ServiceEnvironment serviceEnvironment,
46 final AccountLoader accountLoader
47 ) throws IOException {
48 this.dataPath = dataPath;
49 this.serviceEnvironment = getServiceEnvironmentString(serviceEnvironment);
50 this.accountLoader = accountLoader;
51 if (!getAccountsFile().exists()) {
52 createInitialAccounts();
53 }
54 }
55
56 public synchronized Set<String> getAllNumbers() throws IOException {
57 return readAccounts().stream()
58 .map(AccountsStorage.Account::number)
59 .filter(Objects::nonNull)
60 .collect(Collectors.toSet());
61 }
62
63 public synchronized Set<AccountsStorage.Account> getAllAccounts() throws IOException {
64 return readAccounts().stream()
65 .filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment()))
66 .filter(a -> a.number() != null)
67 .collect(Collectors.toSet());
68 }
69
70 public synchronized String getPathByNumber(String number) throws IOException {
71 return readAccounts().stream()
72 .filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment()))
73 .filter(a -> number.equals(a.number()))
74 .map(AccountsStorage.Account::path)
75 .findFirst()
76 .orElse(null);
77 }
78
79 public synchronized String getPathByAci(ACI aci) throws IOException {
80 return readAccounts().stream()
81 .filter(a -> a.environment() == null || serviceEnvironment.equals(a.environment()))
82 .filter(a -> aci.toString().equals(a.uuid()))
83 .map(AccountsStorage.Account::path)
84 .findFirst()
85 .orElse(null);
86 }
87
88 public synchronized void updateAccount(String path, String number, ACI aci) {
89 updateAccounts(accounts -> accounts.stream().map(a -> {
90 if (a.environment() != null && !serviceEnvironment.equals(a.environment())) {
91 return a;
92 }
93
94 if (path.equals(a.path())) {
95 return new AccountsStorage.Account(a.path(),
96 serviceEnvironment,
97 number,
98 aci == null ? null : aci.toString());
99 }
100
101 if (number != null && number.equals(a.number())) {
102 return new AccountsStorage.Account(a.path(), a.environment(), null, a.uuid());
103 }
104 if (aci != null && aci.toString().equals(a.toString())) {
105 return new AccountsStorage.Account(a.path(), a.environment(), a.number(), null);
106 }
107
108 return a;
109 }).toList());
110 }
111
112 public synchronized String addAccount(String number, ACI aci) {
113 final var accountPath = generateNewAccountPath();
114 final var account = new AccountsStorage.Account(accountPath,
115 serviceEnvironment,
116 number,
117 aci == null ? null : aci.toString());
118 updateAccounts(accounts -> {
119 final var existingAccounts = accounts.stream().map(a -> {
120 if (a.environment() != null && !serviceEnvironment.equals(a.environment())) {
121 return a;
122 }
123
124 if (number != null && number.equals(a.number())) {
125 return new AccountsStorage.Account(a.path(), a.environment(), null, a.uuid());
126 }
127 if (aci != null && aci.toString().equals(a.uuid())) {
128 return new AccountsStorage.Account(a.path(), a.environment(), a.number(), null);
129 }
130
131 return a;
132 });
133 return Stream.concat(existingAccounts, Stream.of(account)).toList();
134 });
135 return accountPath;
136 }
137
138 public void removeAccount(final String accountPath) {
139 updateAccounts(accounts -> accounts.stream().filter(a -> !(
140 (a.environment() == null || serviceEnvironment.equals(a.environment())) && a.path().equals(accountPath)
141 )).toList());
142 }
143
144 private String generateNewAccountPath() {
145 return new Random().ints(100000, 1000000)
146 .mapToObj(String::valueOf)
147 .filter(n -> !new File(dataPath, n).exists() && !new File(dataPath, n + ".d").exists())
148 .findFirst()
149 .get();
150 }
151
152 private File getAccountsFile() {
153 return new File(dataPath, "accounts.json");
154 }
155
156 private void createInitialAccounts() throws IOException {
157 final var legacyAccountPaths = getLegacyAccountPaths();
158 final var accountsStorage = new AccountsStorage(legacyAccountPaths.stream()
159 .map(number -> new AccountsStorage.Account(number, null, number, null))
160 .toList(), CURRENT_STORAGE_VERSION);
161
162 IOUtils.createPrivateDirectories(dataPath);
163 var fileName = getAccountsFile();
164 if (!fileName.exists()) {
165 IOUtils.createPrivateFile(fileName);
166 }
167
168 final var pair = openFileChannel(getAccountsFile());
169 try (final var fileChannel = pair.first(); final var lock = pair.second()) {
170 saveAccountsLocked(fileChannel, accountsStorage);
171 }
172 }
173
174 private Set<String> getLegacyAccountPaths() {
175 final var files = dataPath.listFiles();
176
177 if (files == null) {
178 return Set.of();
179 }
180
181 return Arrays.stream(files)
182 .filter(File::isFile)
183 .map(File::getName)
184 .filter(file -> PhoneNumberFormatter.isValidNumber(file, null))
185 .collect(Collectors.toSet());
186 }
187
188 private List<AccountsStorage.Account> readAccounts() throws IOException {
189 final var pair = openFileChannel(getAccountsFile());
190 try (final var fileChannel = pair.first(); final var lock = pair.second()) {
191 final var storage = readAccountsLocked(fileChannel);
192
193 var accountsVersion = storage.version() == null ? 1 : storage.version();
194 if (accountsVersion > CURRENT_STORAGE_VERSION) {
195 throw new IOException("Accounts file was created by a more recent version: " + accountsVersion);
196 } else if (accountsVersion < MINIMUM_STORAGE_VERSION) {
197 throw new IOException("Accounts file was created by a no longer supported older version: "
198 + accountsVersion);
199 } else if (accountsVersion < CURRENT_STORAGE_VERSION) {
200 return upgradeAccountsFile(fileChannel, storage, accountsVersion).accounts();
201 }
202 return storage.accounts();
203 }
204 }
205
206 private AccountsStorage upgradeAccountsFile(
207 final FileChannel fileChannel,
208 final AccountsStorage storage,
209 final int accountsVersion
210 ) {
211 try {
212 List<AccountsStorage.Account> newAccounts = storage.accounts();
213 if (accountsVersion < 2) {
214 // add environment field
215 newAccounts = newAccounts.stream().map(a -> {
216 if (a.environment() != null) {
217 return a;
218 }
219 try (final var account = accountLoader.loadAccountOrNull(a.path())) {
220 if (account == null || account.getServiceEnvironment() == null) {
221 return a;
222 }
223 return new AccountsStorage.Account(a.path(),
224 getServiceEnvironmentString(account.getServiceEnvironment()),
225 a.number(),
226 a.uuid());
227 }
228 }).toList();
229 }
230 final var newStorage = new AccountsStorage(newAccounts, CURRENT_STORAGE_VERSION);
231 saveAccountsLocked(fileChannel, newStorage);
232 return newStorage;
233 } catch (Exception e) {
234 logger.warn("Failed to upgrade accounts file", e);
235 return storage;
236 }
237 }
238
239 private void updateAccounts(Function<List<AccountsStorage.Account>, List<AccountsStorage.Account>> updater) {
240 try {
241 final var pair = openFileChannel(getAccountsFile());
242 try (final var fileChannel = pair.first(); final var lock = pair.second()) {
243 final var accountsStorage = readAccountsLocked(fileChannel);
244 final var newAccountsStorage = updater.apply(accountsStorage.accounts());
245 saveAccountsLocked(fileChannel, new AccountsStorage(newAccountsStorage, CURRENT_STORAGE_VERSION));
246 }
247 } catch (IOException e) {
248 logger.error("Failed to update accounts list", e);
249 }
250 }
251
252 private AccountsStorage readAccountsLocked(FileChannel fileChannel) throws IOException {
253 fileChannel.position(0);
254 final var inputStream = Channels.newInputStream(fileChannel);
255 return objectMapper.readValue(inputStream, AccountsStorage.class);
256 }
257
258 private void saveAccountsLocked(FileChannel fileChannel, AccountsStorage accountsStorage) throws IOException {
259 try (var output = new ByteArrayOutputStream()) {
260 // Write to memory first to prevent corrupting the file in case of serialization errors
261 objectMapper.writeValue(output, accountsStorage);
262 var input = new ByteArrayInputStream(output.toByteArray());
263 fileChannel.position(0);
264 input.transferTo(Channels.newOutputStream(fileChannel));
265 fileChannel.truncate(fileChannel.position());
266 fileChannel.force(false);
267 }
268 }
269
270 private static Pair<FileChannel, FileLock> openFileChannel(File fileName) throws IOException {
271 var fileChannel = new RandomAccessFile(fileName, "rw").getChannel();
272 var lock = fileChannel.tryLock();
273 if (lock == null) {
274 logger.info("Config file is in use by another instance, waiting…");
275 lock = fileChannel.lock();
276 logger.info("Config file lock acquired.");
277 }
278 return new Pair<>(fileChannel, lock);
279 }
280
281 private String getServiceEnvironmentString(final ServiceEnvironment serviceEnvironment) {
282 return switch (serviceEnvironment) {
283 case LIVE -> "LIVE";
284 case STAGING -> "STAGING";
285 };
286 }
287
288 public interface AccountLoader {
289
290 SignalAccount loadAccountOrNull(String accountPath);
291 }
292 }