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