Line data Source code
1 : /*
2 : * Copyright (C) 2004-2025 Savoir-faire Linux Inc.
3 : *
4 : * This program is free software: you can redistribute it and/or modify
5 : * it under the terms of the GNU General Public License as published by
6 : * the Free Software Foundation, either version 3 of the License, or
7 : * (at your option) any later version.
8 : *
9 : * This program is distributed in the hope that it will be useful,
10 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 : * GNU General Public License for more details.
13 : *
14 : * You should have received a copy of the GNU General Public License
15 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 : */
17 :
18 : #include "conversationrepository.h"
19 :
20 : #include "account_const.h"
21 : #include "base64.h"
22 : #include "jamiaccount.h"
23 : #include "fileutils.h"
24 : #include "gittransport.h"
25 : #include "string_utils.h"
26 : #include "client/ring_signal.h"
27 : #include "vcard.h"
28 : #include "json_utils.h"
29 :
30 : #include <algorithm>
31 : #include <iterator>
32 : #include <ctime>
33 : #include <fstream>
34 : #include <future>
35 : #include <json/json.h>
36 : #include <regex>
37 : #include <exception>
38 : #include <optional>
39 : #include <git2/diff.h>
40 :
41 : using namespace std::string_view_literals;
42 : constexpr auto DIFF_REGEX = " +\\| +[0-9]+.*"sv;
43 : constexpr size_t MAX_FETCH_SIZE {256 * 1024 * 1024}; // 256Mb
44 :
45 : namespace jami {
46 :
47 : #ifdef LIBJAMI_TEST
48 : bool ConversationRepository::DISABLE_RESET = false;
49 : #endif
50 :
51 : static const std::regex regex_display_name("<|>");
52 :
53 : inline std::string_view
54 6164 : as_view(const git_blob* blob)
55 : {
56 6164 : return std::string_view(static_cast<const char*>(git_blob_rawcontent(blob)), git_blob_rawsize(blob));
57 : }
58 : inline std::string_view
59 6164 : as_view(const GitObject& blob)
60 : {
61 6164 : return as_view(reinterpret_cast<git_blob*>(blob.get()));
62 : }
63 :
64 : class ConversationRepository::Impl
65 : {
66 : public:
67 426 : Impl(const std::shared_ptr<JamiAccount>& account, const std::string& id)
68 426 : : account_(account)
69 426 : , id_(id)
70 426 : , accountId_(account->getAccountID())
71 426 : , userId_(account->getUsername())
72 852 : , deviceId_(account->currentDeviceId())
73 : {
74 426 : conversationDataPath_ = fileutils::get_data_dir() / accountId_ / "conversation_data" / id_;
75 426 : membersCache_ = conversationDataPath_ / "members";
76 426 : checkLocks();
77 426 : loadMembers();
78 426 : if (members_.empty()) {
79 412 : initMembers();
80 : }
81 435 : }
82 :
83 426 : void checkLocks()
84 : {
85 426 : auto repo = repository();
86 426 : if (!repo)
87 0 : throw std::logic_error("Invalid git repository");
88 :
89 426 : std::filesystem::path repoPath = git_repository_path(repo.get());
90 426 : std::error_code ec;
91 :
92 426 : auto indexPath = std::filesystem::path(repoPath / "index.lock");
93 426 : if (std::filesystem::exists(indexPath, ec)) {
94 0 : JAMI_WARNING("[Account {}] [Conversation {}] Conversation is locked, removing lock {}",
95 : accountId_,
96 : id_,
97 : indexPath);
98 0 : std::filesystem::remove(indexPath, ec);
99 0 : if (ec)
100 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to remove lock {}: {}",
101 : accountId_,
102 : id_,
103 : indexPath,
104 : ec.message());
105 : }
106 :
107 852 : auto refPath = std::filesystem::path(repoPath / "refs" / "heads" / "main.lock");
108 426 : if (std::filesystem::exists(refPath)) {
109 0 : JAMI_WARNING("[Account {}] [Conversation {}] Conversation is locked, removing lock {}",
110 : accountId_,
111 : id_,
112 : refPath);
113 0 : std::filesystem::remove(refPath, ec);
114 0 : if (ec)
115 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to remove lock {}: {}",
116 : accountId_,
117 : id_,
118 : refPath,
119 : ec.message());
120 : }
121 :
122 852 : auto remotePath = std::filesystem::path(repoPath / "refs" / "remotes");
123 1484 : for (const auto& fileIt : std::filesystem::directory_iterator(remotePath, ec)) {
124 206 : auto refPath = fileIt.path() / "main.lock";
125 206 : if (std::filesystem::exists(refPath, ec)) {
126 0 : JAMI_WARNING("[Account {}] [Conversation {}] Conversation is locked for remote {}, removing lock",
127 : accountId_,
128 : id_,
129 : fileIt.path().filename());
130 0 : std::filesystem::remove(refPath, ec);
131 0 : if (ec)
132 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to remove lock {}: {}",
133 : accountId_,
134 : id_,
135 : refPath,
136 : ec.message());
137 : }
138 632 : }
139 :
140 426 : auto err = git_repository_state_cleanup(repo.get());
141 426 : if (err < 0) {
142 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to clean up the repository: {}",
143 : accountId_,
144 : id_,
145 : git_error_last()->message);
146 : }
147 426 : }
148 :
149 426 : void loadMembers()
150 : {
151 : try {
152 : // read file
153 838 : auto file = fileutils::loadFile(membersCache_);
154 : // load values
155 14 : msgpack::object_handle oh = msgpack::unpack((const char*) file.data(), file.size());
156 14 : std::lock_guard lk {membersMtx_};
157 14 : oh.get().convert(members_);
158 426 : } catch (const std::exception& e) {
159 412 : }
160 426 : }
161 : // Note: membersMtx_ needs to be locked when calling saveMembers
162 1492 : void saveMembers()
163 : {
164 1492 : std::ofstream file(membersCache_, std::ios::trunc | std::ios::binary);
165 1492 : msgpack::pack(file, members_);
166 :
167 1492 : if (onMembersChanged_) {
168 1080 : std::set<std::string> memberUris;
169 14028 : for (const auto& member : members_) {
170 12949 : memberUris.emplace(member.uri);
171 : }
172 1080 : onMembersChanged_(memberUris);
173 1080 : }
174 1492 : }
175 :
176 : OnMembersChanged onMembersChanged_ {};
177 :
178 : // NOTE! We use temporary GitRepository to avoid keeping the file opened
179 : // TODO: check why git_remote_fetch() leaves pack-data opened
180 47237 : GitRepository repository() const
181 : {
182 94483 : auto path = fmt::format("{}/{}/conversations/{}", fileutils::get_data_dir().string(), accountId_, id_);
183 47247 : git_repository* repo = nullptr;
184 47247 : auto err = git_repository_open(&repo, path.c_str());
185 47250 : if (err < 0) {
186 0 : JAMI_ERROR("Unable to open Git repository: {} ({})", path, git_error_last()->message);
187 0 : return {nullptr, git_repository_free};
188 : }
189 47250 : return {std::move(repo), git_repository_free};
190 47238 : }
191 :
192 505 : std::string getDisplayName() const
193 : {
194 505 : auto shared = account_.lock();
195 505 : if (!shared)
196 0 : return {};
197 505 : auto name = shared->getDisplayName();
198 505 : if (name.empty())
199 0 : name = deviceId_;
200 505 : return std::regex_replace(name, regex_display_name, "");
201 505 : }
202 :
203 : GitSignature signature();
204 : bool mergeFastforward(const git_oid* target_oid, int is_unborn);
205 : std::string createMergeCommit(git_index* index, const std::string& wanted_ref);
206 :
207 : bool validCommits(const std::vector<ConversationCommit>& commits) const;
208 : bool checkValidUserDiff(const std::string& userDevice,
209 : const std::string& commitId,
210 : const std::string& parentId) const;
211 : bool checkVote(const std::string& userDevice, const std::string& commitId, const std::string& parentId) const;
212 : bool checkEdit(const std::string& userDevice, const ConversationCommit& commit) const;
213 : bool isValidUserAtCommit(const std::string& userDevice,
214 : const std::string& commitId,
215 : const git_buf& sig,
216 : const git_buf& sig_data) const;
217 : bool checkInitialCommit(const std::string& userDevice,
218 : const std::string& commitId,
219 : const std::string& commitMsg) const;
220 : bool checkValidAdd(const std::string& userDevice,
221 : const std::string& uriMember,
222 : const std::string& commitid,
223 : const std::string& parentId) const;
224 : bool checkValidJoins(const std::string& userDevice,
225 : const std::string& uriMember,
226 : const std::string& commitid,
227 : const std::string& parentId) const;
228 : bool checkValidRemove(const std::string& userDevice,
229 : const std::string& uriMember,
230 : const std::string& commitid,
231 : const std::string& parentId) const;
232 : bool checkValidVoteResolution(const std::string& userDevice,
233 : const std::string& uriMember,
234 : const std::string& commitId,
235 : const std::string& parentId,
236 : const std::string& voteType) const;
237 : bool checkValidProfileUpdate(const std::string& userDevice,
238 : const std::string& commitid,
239 : const std::string& parentId) const;
240 : bool checkValidMergeCommit(const std::string& mergeId, const std::vector<std::string>& parents) const;
241 : std::optional<std::set<std::string_view>> getDeltaPathsFromDiff(const GitDiff& diff) const;
242 :
243 : bool add(const std::string& path);
244 : void addUserDevice();
245 : void resetHard();
246 : // Verify that the device in the repository is still valid
247 : bool validateDevice();
248 : std::string commit(const std::string& msg, bool verifyDevice = true);
249 : std::string commitMessage(const std::string& msg, bool verifyDevice = true);
250 : ConversationMode mode() const;
251 :
252 : // NOTE! GitDiff needs to be deleted before repo
253 : GitDiff diff(git_repository* repo, const std::string& idNew, const std::string& idOld) const;
254 : std::string diffStats(const std::string& newId, const std::string& oldId) const;
255 : std::string diffStats(const GitDiff& diff) const;
256 :
257 : std::vector<ConversationCommit> behind(const std::string& from) const;
258 : void forEachCommit(PreConditionCb&& preCondition,
259 : std::function<void(ConversationCommit&&)>&& emplaceCb,
260 : PostConditionCb&& postCondition,
261 : const std::string& from = "",
262 : bool logIfNotFound = true) const;
263 : std::vector<ConversationCommit> log(const LogOptions& options) const;
264 :
265 : GitObject fileAtTree(const std::string& path, const GitTree& tree) const;
266 : GitObject memberCertificate(std::string_view memberUri, const GitTree& tree) const;
267 : // NOTE! GitDiff needs to be deleted before repo
268 : GitTree treeAtCommit(git_repository* repo, const std::string& commitId) const;
269 :
270 : std::vector<std::string> getInitialMembers() const;
271 :
272 : bool resolveBan(const std::string_view type, const std::string& uri);
273 : bool resolveUnban(const std::string_view type, const std::string& uri);
274 :
275 : std::weak_ptr<JamiAccount> account_;
276 : const std::string id_;
277 : const std::string accountId_;
278 : const std::string userId_;
279 : const std::string deviceId_;
280 : mutable std::optional<ConversationMode> mode_ {};
281 :
282 : // Members utils
283 : mutable std::mutex membersMtx_ {};
284 : std::vector<ConversationMember> members_ {};
285 :
286 1347 : std::vector<ConversationMember> members() const
287 : {
288 1347 : std::lock_guard lk(membersMtx_);
289 2694 : return members_;
290 1347 : }
291 :
292 : std::filesystem::path conversationDataPath_ {};
293 : std::filesystem::path membersCache_ {};
294 :
295 512 : std::map<std::string, std::vector<DeviceId>> devices(bool ignoreExpired = true) const
296 : {
297 512 : auto acc = account_.lock();
298 512 : auto repo = repository();
299 512 : if (!repo or !acc)
300 0 : return {};
301 512 : std::map<std::string, std::vector<DeviceId>> memberDevices;
302 512 : std::string deviceDir = fmt::format("{}devices/", git_repository_workdir(repo.get()));
303 512 : std::error_code ec;
304 2557 : for (const auto& fileIt : std::filesystem::directory_iterator(deviceDir, ec)) {
305 : try {
306 2042 : auto cert = std::make_shared<dht::crypto::Certificate>(fileutils::loadFile(fileIt.path()));
307 1021 : if (!cert)
308 0 : continue;
309 1021 : if (ignoreExpired && cert->getExpiration() < std::chrono::system_clock::now())
310 1 : continue;
311 1020 : auto issuerUid = cert->getIssuerUID();
312 1020 : if (!acc->certStore().getCertificate(issuerUid)) {
313 : // Check that parentCert
314 1 : auto memberFile = fmt::format("{}members/{}.crt", git_repository_workdir(repo.get()), issuerUid);
315 1 : auto adminFile = fmt::format("{}admins/{}.crt", git_repository_workdir(repo.get()), issuerUid);
316 2 : auto parentCert = std::make_shared<dht::crypto::Certificate>(dhtnet::fileutils::loadFile(
317 2 : std::filesystem::is_regular_file(memberFile, ec) ? memberFile : adminFile));
318 0 : if (parentCert && (ignoreExpired || parentCert->getExpiration() < std::chrono::system_clock::now()))
319 0 : acc->certStore().pinCertificate(parentCert,
320 : true); // Pin certificate to local store if not already done
321 2 : }
322 1019 : if (!acc->certStore().getCertificate(cert->getPublicKey().getLongId().toString())) {
323 0 : acc->certStore().pinCertificate(cert,
324 : true); // Pin certificate to local store if not already done
325 : }
326 1019 : memberDevices[cert->getIssuerUID()].emplace_back(cert->getPublicKey().getLongId());
327 :
328 1023 : } catch (const std::exception&) {
329 1 : }
330 512 : }
331 512 : return memberDevices;
332 512 : }
333 :
334 14678 : std::optional<ConversationCommit> getCommit(const std::string& commitId, bool logIfNotFound = true) const
335 : {
336 14678 : LogOptions options;
337 14677 : options.from = commitId;
338 14677 : options.nbOfCommits = 1;
339 14677 : options.logIfNotFound = logIfNotFound;
340 14677 : auto commits = log(options);
341 14674 : if (commits.empty())
342 3109 : return std::nullopt;
343 11568 : return std::move(commits[0]);
344 14676 : }
345 :
346 : bool resolveConflicts(git_index* index, const std::string& other_id);
347 :
348 2917 : std::set<std::string> memberUris(std::string_view filter, const std::set<MemberRole>& filteredRoles) const
349 : {
350 2917 : std::lock_guard lk(membersMtx_);
351 2918 : std::set<std::string> ret;
352 32885 : for (const auto& member : members_) {
353 29966 : if ((filteredRoles.find(member.role) != filteredRoles.end())
354 29965 : or (not filter.empty() and filter == member.uri))
355 1837 : continue;
356 28130 : ret.emplace(member.uri);
357 : }
358 5836 : return ret;
359 2918 : }
360 :
361 : void initMembers();
362 :
363 : std::optional<std::map<std::string, std::string>> convCommitToMap(const ConversationCommit& commit) const;
364 :
365 : // Permissions
366 : MemberRole updateProfilePermLvl_ {MemberRole::ADMIN};
367 :
368 : /**
369 : * Retrieve the user related to a device using the account's certificate store.
370 : * @note deviceToUri_ is used to cache result and avoid always loading the certificate
371 : */
372 34081 : std::string uriFromDevice(const std::string& deviceId, const std::string& commitId = "") const
373 : {
374 : // Check if we have the device in cache.
375 34081 : std::lock_guard lk(deviceToUriMtx_);
376 34090 : auto it = deviceToUri_.find(deviceId);
377 34086 : if (it != deviceToUri_.end())
378 32720 : return it->second;
379 :
380 1363 : auto acc = account_.lock();
381 1363 : if (!acc)
382 0 : return {};
383 :
384 1363 : auto cert = acc->certStore().getCertificate(deviceId);
385 1363 : if (!cert || !cert->issuer) {
386 26 : if (!commitId.empty()) {
387 26 : std::string uri = uriFromDeviceAtCommit(deviceId, commitId);
388 26 : if (!uri.empty()) {
389 25 : deviceToUri_.insert({deviceId, uri});
390 25 : return uri;
391 : }
392 26 : }
393 : // Not pinned, so load certificate from repo
394 1 : auto repo = repository();
395 1 : if (!repo)
396 0 : return {};
397 2 : auto deviceFile = std::filesystem::path(git_repository_workdir(repo.get())) / "devices"
398 3 : / fmt::format("{}.crt", deviceId);
399 1 : if (!std::filesystem::is_regular_file(deviceFile))
400 1 : return {};
401 : try {
402 0 : cert = std::make_shared<dht::crypto::Certificate>(fileutils::loadFile(deviceFile));
403 0 : } catch (const std::exception&) {
404 0 : JAMI_WARNING("Unable to load certificate from {}", deviceFile);
405 0 : }
406 0 : if (!cert)
407 0 : return {};
408 2 : }
409 1337 : auto issuerUid = cert->issuer ? cert->issuer->getId().toString() : cert->getIssuerUID();
410 1337 : if (issuerUid.empty())
411 0 : return {};
412 :
413 1337 : deviceToUri_.insert({deviceId, issuerUid});
414 1337 : return issuerUid;
415 34086 : }
416 : mutable std::mutex deviceToUriMtx_;
417 : mutable std::map<std::string, std::string> deviceToUri_;
418 :
419 : /**
420 : * Retrieve the user related to a device using certificate directly from the repository at a
421 : * specific commit.
422 : * @note Prefer uriFromDevice() if possible as it uses the cache.
423 : */
424 26 : std::string uriFromDeviceAtCommit(const std::string& deviceId, const std::string& commitId) const
425 : {
426 26 : auto repo = repository();
427 26 : if (!repo)
428 0 : return {};
429 26 : auto tree = treeAtCommit(repo.get(), commitId);
430 0 : auto deviceFile = fmt::format("devices/{}.crt", deviceId);
431 26 : auto blob_device = fileAtTree(deviceFile, tree);
432 26 : if (!blob_device) {
433 4 : JAMI_ERROR("{} announced but not found", deviceId);
434 1 : return {};
435 : }
436 25 : auto deviceCert = dht::crypto::Certificate(as_view(blob_device));
437 25 : return deviceCert.getIssuerUID();
438 26 : }
439 :
440 : /**
441 : * Verify that a certificate modification is correct
442 : * @param certPath Where the certificate is saved (relative path)
443 : * @param userUri Account we want for this certificate
444 : * @param oldCert Previous certificate. getId() should return the same id as the new
445 : * certificate.
446 : * @note There is a few exception because JAMS certificates are buggy right now
447 : */
448 378 : bool verifyCertificate(std::string_view certContent,
449 : const std::string& userUri,
450 : std::string_view oldCert = ""sv) const
451 : {
452 378 : auto cert = dht::crypto::Certificate(certContent);
453 378 : auto isDeviceCertificate = cert.getId().toString() != userUri;
454 378 : auto issuerUid = cert.getIssuerUID();
455 378 : if (isDeviceCertificate && issuerUid.empty()) {
456 : // Err for Jams certificates
457 0 : JAMI_ERROR("Empty issuer for {}", cert.getId().toString());
458 : }
459 378 : if (!oldCert.empty()) {
460 3 : auto deviceCert = dht::crypto::Certificate(oldCert);
461 3 : if (isDeviceCertificate) {
462 2 : if (issuerUid != deviceCert.getIssuerUID()) {
463 : // NOTE: Here, because JAMS certificate can be incorrectly formatted, there is
464 : // just one valid possibility: passing from an empty issuer to
465 : // the valid issuer.
466 1 : if (issuerUid != userUri) {
467 4 : JAMI_ERROR("Device certificate with a bad issuer {}", cert.getId().toString());
468 1 : return false;
469 : }
470 : }
471 1 : } else if (cert.getId().toString() != userUri) {
472 0 : JAMI_ERROR("Certificate with a bad ID {}", cert.getId().toString());
473 0 : return false;
474 : }
475 2 : if (cert.getId() != deviceCert.getId()) {
476 0 : JAMI_ERROR("Certificate with a bad ID {}", cert.getId().toString());
477 0 : return false;
478 : }
479 2 : return true;
480 3 : }
481 :
482 : // If it's a device certificate, we need to verify that the issuer is not modified
483 375 : if (isDeviceCertificate) {
484 : // Check that issuer is the one we want.
485 : // NOTE: Still one case due to incorrectly formatted certificates from JAMS
486 189 : if (issuerUid != userUri && !issuerUid.empty()) {
487 4 : JAMI_ERROR("Device certificate with a bad issuer {}", cert.getId().toString());
488 1 : return false;
489 : }
490 186 : } else if (cert.getId().toString() != userUri) {
491 0 : JAMI_ERROR("Certificate with a bad ID {}", cert.getId().toString());
492 0 : return false;
493 : }
494 :
495 374 : return true;
496 378 : }
497 :
498 : std::mutex opMtx_; // Mutex for operations
499 : };
500 :
501 : /////////////////////////////////////////////////////////////////////////////////
502 :
503 : /**
504 : * Creates an empty repository
505 : * @param path Path of the new repository
506 : * @return The libgit2's managed repository
507 : */
508 : GitRepository
509 199 : create_empty_repository(const std::string& path)
510 : {
511 199 : git_repository* repo = nullptr;
512 : git_repository_init_options opts;
513 199 : git_repository_init_options_init(&opts, GIT_REPOSITORY_INIT_OPTIONS_VERSION);
514 199 : opts.flags |= GIT_REPOSITORY_INIT_MKPATH;
515 199 : opts.initial_head = "main";
516 199 : if (git_repository_init_ext(&repo, path.c_str(), &opts) < 0) {
517 0 : JAMI_ERROR("Unable to create a git repository in {}", path);
518 : }
519 199 : return {std::move(repo), git_repository_free};
520 : }
521 :
522 : /**
523 : * Add all files to index
524 : * @param repo
525 : * @return if operation is successful
526 : */
527 : bool
528 376 : git_add_all(git_repository* repo)
529 : {
530 : // git add -A
531 376 : git_index* index_ptr = nullptr;
532 376 : if (git_repository_index(&index_ptr, repo) < 0) {
533 0 : JAMI_ERROR("Unable to open repository index");
534 0 : return false;
535 : }
536 376 : GitIndex index {index_ptr, git_index_free};
537 376 : git_strarray array {nullptr, 0};
538 376 : git_index_add_all(index.get(), &array, 0, nullptr, nullptr);
539 376 : git_index_write(index.get());
540 376 : git_strarray_dispose(&array);
541 376 : return true;
542 376 : }
543 :
544 : /**
545 : * Adds initial files. This adds the certificate of the account in the /admins directory
546 : * the device's key in /devices and the CRLs in /CRLs.
547 : * @param repo The repository
548 : * @return if files were added successfully
549 : */
550 : bool
551 199 : add_initial_files(GitRepository& repo,
552 : const std::shared_ptr<JamiAccount>& account,
553 : ConversationMode mode,
554 : const std::string& otherMember = "")
555 : {
556 199 : auto deviceId = account->currentDeviceId();
557 199 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
558 199 : auto adminsPath = repoPath / MemberPath::ADMINS;
559 199 : auto devicesPath = repoPath / MemberPath::DEVICES;
560 199 : auto invitedPath = repoPath / MemberPath::INVITED;
561 398 : auto crlsPath = repoPath / "CRLs" / deviceId;
562 :
563 199 : if (!dhtnet::fileutils::recursive_mkdir(adminsPath, 0700)) {
564 0 : JAMI_ERROR("Error when creating {}. Abort create conversations", adminsPath);
565 0 : return false;
566 : }
567 :
568 199 : auto cert = account->identity().second;
569 199 : auto deviceCert = cert->toString(false);
570 199 : auto parentCert = cert->issuer;
571 199 : if (!parentCert) {
572 0 : JAMI_ERROR("Parent cert is null");
573 0 : return false;
574 : }
575 :
576 : // /admins
577 597 : auto adminPath = adminsPath / fmt::format("{}.crt", parentCert->getId().toString());
578 199 : std::ofstream file(adminPath, std::ios::trunc | std::ios::binary);
579 199 : if (!file.is_open()) {
580 0 : JAMI_ERROR("Unable to write data to {}", adminPath);
581 0 : return false;
582 : }
583 199 : file << parentCert->toString(true);
584 199 : file.close();
585 :
586 199 : if (!dhtnet::fileutils::recursive_mkdir(devicesPath, 0700)) {
587 0 : JAMI_ERROR("Error when creating {}. Abort create conversations", devicesPath);
588 0 : return false;
589 : }
590 :
591 : // /devices
592 398 : auto devicePath = devicesPath / fmt::format("{}.crt", deviceId);
593 199 : file = std::ofstream(devicePath, std::ios::trunc | std::ios::binary);
594 199 : if (!file.is_open()) {
595 0 : JAMI_ERROR("Unable to write data to {}", devicePath);
596 0 : return false;
597 : }
598 199 : file << deviceCert;
599 199 : file.close();
600 :
601 199 : if (!dhtnet::fileutils::recursive_mkdir(crlsPath, 0700)) {
602 0 : JAMI_ERROR("Error when creating {}. Abort create conversations", crlsPath);
603 0 : return false;
604 : }
605 :
606 : // /CRLs
607 199 : for (const auto& crl : account->identity().second->getRevocationLists()) {
608 0 : if (!crl)
609 0 : continue;
610 0 : auto crlPath = crlsPath / deviceId / (dht::toHex(crl->getNumber()) + ".crl");
611 0 : std::ofstream file(crlPath, std::ios::trunc | std::ios::binary);
612 0 : if (!file.is_open()) {
613 0 : JAMI_ERROR("Unable to write data to {}", crlPath);
614 0 : return false;
615 : }
616 0 : file << crl->toString();
617 0 : file.close();
618 199 : }
619 :
620 : // /invited for one to one
621 199 : if (mode == ConversationMode::ONE_TO_ONE) {
622 69 : if (!dhtnet::fileutils::recursive_mkdir(invitedPath, 0700)) {
623 0 : JAMI_ERROR("Error when creating {}.", invitedPath);
624 0 : return false;
625 : }
626 69 : auto invitedMemberPath = invitedPath / otherMember;
627 69 : if (std::filesystem::is_regular_file(invitedMemberPath)) {
628 0 : JAMI_WARNING("Member {} already present", otherMember);
629 0 : return false;
630 : }
631 :
632 69 : std::ofstream file(invitedMemberPath, std::ios::trunc | std::ios::binary);
633 69 : if (!file.is_open()) {
634 0 : JAMI_ERROR("Unable to write data to {}", invitedMemberPath);
635 0 : return false;
636 : }
637 69 : }
638 :
639 199 : if (!git_add_all(repo.get())) {
640 0 : return false;
641 : }
642 :
643 796 : JAMI_LOG("Initial files added in {}", repoPath);
644 199 : return true;
645 199 : }
646 :
647 : /**
648 : * Sign and create the initial commit
649 : * @param repo The Git repository
650 : * @param account The account who signs
651 : * @param mode The mode
652 : * @param otherMember If one to one
653 : * @return The first commit hash or empty if failed
654 : */
655 : std::string
656 199 : initial_commit(GitRepository& repo,
657 : const std::shared_ptr<JamiAccount>& account,
658 : ConversationMode mode,
659 : const std::string& otherMember = "")
660 : {
661 199 : auto deviceId = std::string(account->currentDeviceId());
662 199 : auto name = account->getDisplayName();
663 199 : if (name.empty())
664 0 : name = deviceId;
665 199 : name = std::regex_replace(name, regex_display_name, "");
666 :
667 199 : git_signature* sig_ptr = nullptr;
668 199 : git_index* index_ptr = nullptr;
669 : git_oid tree_id, commit_id;
670 199 : git_tree* tree_ptr = nullptr;
671 :
672 : // Sign commit's buffer
673 199 : if (git_signature_new(&sig_ptr, name.c_str(), deviceId.c_str(), std::time(nullptr), 0) < 0) {
674 1 : if (git_signature_new(&sig_ptr, deviceId.c_str(), deviceId.c_str(), std::time(nullptr), 0) < 0) {
675 0 : JAMI_ERROR("Unable to create a commit signature.");
676 0 : return {};
677 : }
678 : }
679 199 : GitSignature sig {sig_ptr, git_signature_free};
680 :
681 199 : if (git_repository_index(&index_ptr, repo.get()) < 0) {
682 0 : JAMI_ERROR("Unable to open the repository index");
683 0 : return {};
684 : }
685 199 : GitIndex index {index_ptr, git_index_free};
686 :
687 199 : if (git_index_write_tree(&tree_id, index.get()) < 0) {
688 0 : JAMI_ERROR("Unable to write initial tree from index");
689 0 : return {};
690 : }
691 :
692 199 : if (git_tree_lookup(&tree_ptr, repo.get(), &tree_id) < 0) {
693 0 : JAMI_ERROR("Unable to look up the initial tree");
694 0 : return {};
695 : }
696 199 : GitTree tree = {tree_ptr, git_tree_free};
697 :
698 199 : Json::Value json;
699 199 : json["mode"] = static_cast<int>(mode);
700 199 : if (mode == ConversationMode::ONE_TO_ONE) {
701 69 : json["invited"] = otherMember;
702 : }
703 199 : json["type"] = "initial";
704 :
705 199 : git_buf to_sign = {};
706 398 : if (git_commit_create_buffer(
707 597 : &to_sign, repo.get(), sig.get(), sig.get(), nullptr, json::toString(json).c_str(), tree.get(), 0, nullptr)
708 199 : < 0) {
709 0 : JAMI_ERROR("Unable to create initial buffer");
710 0 : return {};
711 : }
712 :
713 199 : std::string signed_str = base64::encode(account->identity().first->sign((const uint8_t*) to_sign.ptr, to_sign.size));
714 :
715 : // git commit -S
716 199 : if (git_commit_create_with_signature(&commit_id, repo.get(), to_sign.ptr, signed_str.c_str(), "signature") < 0) {
717 0 : git_buf_dispose(&to_sign);
718 0 : JAMI_ERROR("Unable to sign the initial commit");
719 0 : return {};
720 : }
721 199 : git_buf_dispose(&to_sign);
722 :
723 : // Move commit to main branch
724 199 : git_commit* commit = nullptr;
725 199 : if (git_commit_lookup(&commit, repo.get(), &commit_id) == 0) {
726 199 : git_reference* ref = nullptr;
727 199 : git_branch_create(&ref, repo.get(), "main", commit, true);
728 199 : git_commit_free(commit);
729 199 : git_reference_free(ref);
730 : }
731 :
732 199 : auto commit_str = git_oid_tostr_s(&commit_id);
733 199 : if (commit_str)
734 199 : return commit_str;
735 0 : return {};
736 199 : }
737 :
738 : //////////////////////////////////
739 :
740 : GitSignature
741 505 : ConversationRepository::Impl::signature()
742 : {
743 505 : auto name = getDisplayName();
744 505 : if (name.empty()) {
745 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to create a commit signature: no name set", accountId_, id_);
746 0 : return {nullptr, git_signature_free};
747 : }
748 :
749 505 : git_signature* sig_ptr = nullptr;
750 : // Sign commit's buffer
751 505 : if (git_signature_new(&sig_ptr, name.c_str(), deviceId_.c_str(), std::time(nullptr), 0) < 0) {
752 : // Maybe the display name is invalid (like " ") - try without
753 1 : int err = git_signature_new(&sig_ptr, deviceId_.c_str(), deviceId_.c_str(), std::time(nullptr), 0);
754 1 : if (err < 0) {
755 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to create a commit signature: {}", accountId_, id_, err);
756 0 : return {nullptr, git_signature_free};
757 : }
758 : }
759 505 : return {sig_ptr, git_signature_free};
760 505 : }
761 :
762 : std::string
763 14 : ConversationRepository::Impl::createMergeCommit(git_index* index, const std::string& wanted_ref)
764 : {
765 14 : if (!validateDevice()) {
766 0 : JAMI_ERROR("[Account {}] [Conversation {}] Invalid device. Not migrated?", accountId_, id_);
767 0 : return {};
768 : }
769 : // The merge will occur between current HEAD and wanted_ref
770 14 : git_reference* head_ref_ptr = nullptr;
771 14 : auto repo = repository();
772 14 : if (!repo || git_repository_head(&head_ref_ptr, repo.get()) < 0) {
773 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get HEAD reference", accountId_, id_);
774 0 : return {};
775 : }
776 14 : GitReference head_ref {head_ref_ptr, git_reference_free};
777 :
778 : // Maybe that's a ref, so DWIM it
779 14 : git_reference* merge_ref_ptr = nullptr;
780 14 : git_reference_dwim(&merge_ref_ptr, repo.get(), wanted_ref.c_str());
781 14 : GitReference merge_ref {merge_ref_ptr, git_reference_free};
782 :
783 14 : GitSignature sig {signature()};
784 :
785 : // Prepare a standard merge commit message
786 14 : const char* msg_target = nullptr;
787 14 : if (merge_ref) {
788 0 : git_branch_name(&msg_target, merge_ref.get());
789 : } else {
790 14 : msg_target = wanted_ref.c_str();
791 : }
792 :
793 14 : auto commitMsg = fmt::format("Merge {} '{}'", merge_ref ? "branch" : "commit", msg_target);
794 :
795 : // Set up our parent commits
796 56 : GitCommit parents[2] {{nullptr, git_commit_free}, {nullptr, git_commit_free}};
797 14 : git_commit* parent = nullptr;
798 14 : if (git_reference_peel((git_object**) &parent, head_ref.get(), GIT_OBJ_COMMIT) < 0) {
799 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to peel HEAD reference", accountId_, id_);
800 0 : return {};
801 : }
802 14 : parents[0] = {parent, git_commit_free};
803 : git_oid commit_id;
804 14 : if (git_oid_fromstr(&commit_id, wanted_ref.c_str()) < 0) {
805 0 : return {};
806 : }
807 14 : git_annotated_commit* annotated_ptr = nullptr;
808 14 : if (git_annotated_commit_lookup(&annotated_ptr, repo.get(), &commit_id) < 0) {
809 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up commit {}", accountId_, id_, wanted_ref);
810 0 : return {};
811 : }
812 14 : GitAnnotatedCommit annotated {annotated_ptr, git_annotated_commit_free};
813 14 : if (git_commit_lookup(&parent, repo.get(), git_annotated_commit_id(annotated.get())) < 0) {
814 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up commit {}", accountId_, id_, wanted_ref);
815 0 : return {};
816 : }
817 14 : parents[1] = {parent, git_commit_free};
818 :
819 : // Prepare our commit tree
820 : git_oid tree_oid;
821 14 : git_tree* tree_ptr = nullptr;
822 14 : if (git_index_write_tree_to(&tree_oid, index, repo.get()) < 0) {
823 0 : const git_error* err = giterr_last();
824 0 : if (err)
825 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to write index: {}", accountId_, id_, err->message);
826 0 : return {};
827 : }
828 14 : if (git_tree_lookup(&tree_ptr, repo.get(), &tree_oid) < 0) {
829 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up tree", accountId_, id_);
830 0 : return {};
831 : }
832 14 : GitTree tree = {tree_ptr, git_tree_free};
833 :
834 : // Commit
835 14 : git_buf to_sign = {};
836 : // The last argument of git_commit_create_buffer is of type
837 : // 'const git_commit **' in all versions of libgit2 except 1.8.0,
838 : // 1.8.1 and 1.8.3, in which it is of type 'git_commit *const *'.
839 : #if LIBGIT2_VER_MAJOR == 1 && LIBGIT2_VER_MINOR == 8 \
840 : && (LIBGIT2_VER_REVISION == 0 || LIBGIT2_VER_REVISION == 1 || LIBGIT2_VER_REVISION == 3)
841 14 : git_commit* const parents_ptr[2] {parents[0].get(), parents[1].get()};
842 : #else
843 : const git_commit* parents_ptr[2] {parents[0].get(), parents[1].get()};
844 : #endif
845 28 : if (git_commit_create_buffer(
846 28 : &to_sign, repo.get(), sig.get(), sig.get(), nullptr, commitMsg.c_str(), tree.get(), 2, &parents_ptr[0])
847 14 : < 0) {
848 0 : const git_error* err = giterr_last();
849 0 : if (err)
850 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to create commit buffer: {}",
851 : accountId_,
852 : id_,
853 : err->message);
854 0 : return {};
855 : }
856 :
857 14 : auto account = account_.lock();
858 14 : if (!account)
859 0 : return {};
860 : // git commit -S
861 14 : auto to_sign_vec = std::vector<uint8_t>(to_sign.ptr, to_sign.ptr + to_sign.size);
862 14 : auto signed_buf = account->identity().first->sign(to_sign_vec);
863 14 : std::string signed_str = base64::encode(signed_buf);
864 : git_oid commit_oid;
865 14 : if (git_commit_create_with_signature(&commit_oid, repo.get(), to_sign.ptr, signed_str.c_str(), "signature") < 0) {
866 0 : git_buf_dispose(&to_sign);
867 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to sign commit", accountId_, id_);
868 0 : return {};
869 : }
870 14 : git_buf_dispose(&to_sign);
871 :
872 14 : auto commit_str = git_oid_tostr_s(&commit_oid);
873 14 : if (commit_str) {
874 56 : JAMI_LOG("[Account {}] [Conversation {}] New merge commit added with id: {}", accountId_, id_, commit_str);
875 : // Move commit to main branch
876 14 : git_reference* ref_ptr = nullptr;
877 14 : if (git_reference_create(&ref_ptr, repo.get(), "refs/heads/main", &commit_oid, true, nullptr) < 0) {
878 0 : const git_error* err = giterr_last();
879 0 : if (err) {
880 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to move commit to main: {}",
881 : accountId_,
882 : id_,
883 : err->message);
884 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_, id_, ECOMMIT, err->message);
885 : }
886 0 : return {};
887 : }
888 14 : git_reference_free(ref_ptr);
889 : }
890 :
891 : // We're done merging. Clean up the repository state and index
892 14 : git_repository_state_cleanup(repo.get());
893 :
894 14 : git_object* target_ptr = nullptr;
895 14 : if (git_object_lookup(&target_ptr, repo.get(), &commit_oid, GIT_OBJ_COMMIT) != 0) {
896 0 : const git_error* err = giterr_last();
897 0 : if (err)
898 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to look up OID {}: {}",
899 : accountId_,
900 : id_,
901 : git_oid_tostr_s(&commit_oid),
902 : err->message);
903 0 : return {};
904 : }
905 14 : GitObject target {target_ptr, git_object_free};
906 :
907 14 : git_reset(repo.get(), target.get(), GIT_RESET_HARD, nullptr);
908 :
909 14 : return commit_str ? commit_str : "";
910 70 : }
911 :
912 : bool
913 895 : ConversationRepository::Impl::mergeFastforward(const git_oid* target_oid, int is_unborn)
914 : {
915 : // Initialize target
916 895 : git_reference* target_ref_ptr = nullptr;
917 895 : auto repo = repository();
918 895 : if (!repo) {
919 0 : JAMI_ERROR("[Account {}] [Conversation {}] No repository found", accountId_, id_);
920 0 : return false;
921 : }
922 895 : if (is_unborn) {
923 0 : git_reference* head_ref_ptr = nullptr;
924 : // HEAD reference is unborn, lookup manually so we don't try to resolve it
925 0 : if (git_reference_lookup(&head_ref_ptr, repo.get(), "HEAD") < 0) {
926 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to look up HEAD ref", accountId_, id_);
927 0 : return false;
928 : }
929 0 : GitReference head_ref {head_ref_ptr, git_reference_free};
930 :
931 : // Grab the reference HEAD should be pointing to
932 0 : const auto* symbolic_ref = git_reference_symbolic_target(head_ref.get());
933 :
934 : // Create our main reference on the target OID
935 0 : if (git_reference_create(&target_ref_ptr, repo.get(), symbolic_ref, target_oid, 0, nullptr) < 0) {
936 0 : const git_error* err = giterr_last();
937 0 : if (err)
938 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to create main reference: {}",
939 : accountId_,
940 : id_,
941 : err->message);
942 0 : return false;
943 : }
944 :
945 895 : } else if (git_repository_head(&target_ref_ptr, repo.get()) < 0) {
946 : // HEAD exists, just look up and resolve
947 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to get HEAD reference", accountId_, id_);
948 0 : return false;
949 : }
950 895 : GitReference target_ref {target_ref_ptr, git_reference_free};
951 :
952 : // Look up the target object
953 895 : git_object* target_ptr = nullptr;
954 895 : if (git_object_lookup(&target_ptr, repo.get(), target_oid, GIT_OBJ_COMMIT) != 0) {
955 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to look up OID {}",
956 : accountId_,
957 : id_,
958 : git_oid_tostr_s(target_oid));
959 0 : return false;
960 : }
961 895 : GitObject target {target_ptr, git_object_free};
962 :
963 : // Checkout the result so the workdir is in the expected state
964 : git_checkout_options ff_checkout_options;
965 895 : git_checkout_init_options(&ff_checkout_options, GIT_CHECKOUT_OPTIONS_VERSION);
966 895 : ff_checkout_options.checkout_strategy = GIT_CHECKOUT_SAFE;
967 895 : if (git_checkout_tree(repo.get(), target.get(), &ff_checkout_options) != 0) {
968 0 : if (auto err = git_error_last())
969 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to checkout HEAD reference: {}",
970 : accountId_,
971 : id_,
972 : err->message);
973 : else
974 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to checkout HEAD reference: unknown error",
975 : accountId_,
976 : id_);
977 0 : return false;
978 : }
979 :
980 : // Move the target reference to the target OID
981 : git_reference* new_target_ref;
982 895 : if (git_reference_set_target(&new_target_ref, target_ref.get(), target_oid, nullptr) < 0) {
983 0 : JAMI_ERROR("[Account {}] [Conversation {}] failed to move HEAD reference", accountId_, id_);
984 0 : return false;
985 : }
986 895 : git_reference_free(new_target_ref);
987 :
988 895 : return true;
989 895 : }
990 :
991 : bool
992 321 : ConversationRepository::Impl::add(const std::string& path)
993 : {
994 321 : auto repo = repository();
995 321 : if (!repo)
996 0 : return false;
997 321 : git_index* index_ptr = nullptr;
998 321 : if (git_repository_index(&index_ptr, repo.get()) < 0) {
999 0 : JAMI_ERROR("Unable to open repository index");
1000 0 : return false;
1001 : }
1002 321 : GitIndex index {index_ptr, git_index_free};
1003 321 : if (git_index_add_bypath(index.get(), path.c_str()) != 0) {
1004 0 : const git_error* err = giterr_last();
1005 0 : if (err)
1006 0 : JAMI_ERROR("Error when adding file: {}", err->message);
1007 0 : return false;
1008 : }
1009 321 : return git_index_write(index.get()) == 0;
1010 321 : }
1011 :
1012 : bool
1013 158 : ConversationRepository::Impl::checkValidUserDiff(const std::string& userDevice,
1014 : const std::string& commitId,
1015 : const std::string& parentId) const
1016 : {
1017 : // Retrieve tree for recent commit
1018 158 : auto repo = repository();
1019 158 : if (!repo)
1020 0 : return false;
1021 : // Here, we check that a file device is modified or not.
1022 158 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1023 158 : if (changedFiles.size() == 0)
1024 150 : return true;
1025 :
1026 : // If a certificate is modified (in the changedFiles), it MUST be a certificate from the user
1027 : // Retrieve userUri
1028 8 : auto treeNew = treeAtCommit(repo.get(), commitId);
1029 8 : auto userUri = uriFromDevice(userDevice, commitId);
1030 8 : if (userUri.empty())
1031 0 : return false;
1032 :
1033 8 : std::string userDeviceFile = fmt::format("devices/{}.crt", userDevice);
1034 8 : std::string adminsFile = fmt::format("admins/{}.crt", userUri);
1035 0 : std::string membersFile = fmt::format("members/{}.crt", userUri);
1036 8 : auto treeOld = treeAtCommit(repo.get(), parentId);
1037 8 : if (not treeNew or not treeOld)
1038 0 : return false;
1039 12 : for (const auto& changedFile : changedFiles) {
1040 9 : if (changedFile == adminsFile || changedFile == membersFile) {
1041 : // In this case, we should verify it's not added (normal commit, not a member change)
1042 : // but only updated
1043 1 : auto oldFile = fileAtTree(changedFile, treeOld);
1044 1 : if (!oldFile) {
1045 0 : JAMI_ERROR("Invalid file modified: {}", changedFile);
1046 0 : return false;
1047 : }
1048 1 : auto newFile = fileAtTree(changedFile, treeNew);
1049 1 : if (!verifyCertificate(as_view(newFile), userUri, as_view(oldFile))) {
1050 0 : JAMI_ERROR("Invalid certificate {}", changedFile);
1051 0 : return false;
1052 : }
1053 9 : } else if (changedFile == userDeviceFile) {
1054 : // In this case, device is added or modified (certificate expiration)
1055 4 : auto oldFile = fileAtTree(changedFile, treeOld);
1056 4 : std::string_view oldCert;
1057 4 : if (oldFile)
1058 2 : oldCert = as_view(oldFile);
1059 4 : auto newFile = fileAtTree(changedFile, treeNew);
1060 4 : if (!verifyCertificate(as_view(newFile), userUri, oldCert)) {
1061 4 : JAMI_ERROR("Invalid certificate {}", changedFile);
1062 1 : return false;
1063 : }
1064 5 : } else {
1065 : // Invalid file detected
1066 16 : JAMI_ERROR("Invalid add file detected: {} {}", changedFile, (int) mode());
1067 4 : return false;
1068 : }
1069 : }
1070 :
1071 3 : return true;
1072 158 : }
1073 :
1074 : bool
1075 2 : ConversationRepository::Impl::checkEdit(const std::string& userDevice, const ConversationCommit& commit) const
1076 : {
1077 2 : auto repo = repository();
1078 2 : if (!repo)
1079 0 : return false;
1080 2 : auto userUri = uriFromDevice(userDevice, commit.id);
1081 2 : if (userUri.empty())
1082 0 : return false;
1083 : // Check that edited commit is found, for the same author, and editable (plain/text)
1084 2 : auto commitMap = convCommitToMap(commit);
1085 2 : if (commitMap == std::nullopt) {
1086 0 : return false;
1087 : }
1088 4 : auto editedId = commitMap->at("edit");
1089 2 : auto editedCommit = getCommit(editedId);
1090 2 : if (editedCommit == std::nullopt) {
1091 0 : JAMI_ERROR("Commit {:s} not found", editedId);
1092 0 : return false;
1093 : }
1094 2 : auto editedCommitMap = convCommitToMap(*editedCommit);
1095 4 : if (editedCommitMap == std::nullopt or editedCommitMap->at("author").empty()
1096 4 : or editedCommitMap->at("author") != commitMap->at("author") or commitMap->at("author") != userUri) {
1097 0 : JAMI_ERROR("Edited commit {:s} got a different author ({:s})", editedId, commit.id);
1098 0 : return false;
1099 : }
1100 2 : if (editedCommitMap->at("type") == "text/plain") {
1101 1 : return true;
1102 : }
1103 1 : if (editedCommitMap->at("type") == "application/data-transfer+json") {
1104 0 : if (editedCommitMap->find("tid") != editedCommitMap->end())
1105 0 : return true;
1106 : }
1107 4 : JAMI_ERROR("Edited commit {:s} is not valid!", editedId);
1108 1 : return false;
1109 2 : }
1110 :
1111 : bool
1112 9 : ConversationRepository::Impl::checkVote(const std::string& userDevice,
1113 : const std::string& commitId,
1114 : const std::string& parentId) const
1115 : {
1116 : // Check that maximum deviceFile and a vote is added
1117 9 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1118 9 : if (changedFiles.size() == 0) {
1119 2 : return true;
1120 7 : } else if (changedFiles.size() > 2) {
1121 0 : return false;
1122 : }
1123 : // If modified, it's the first commit of a device, we check
1124 : // that the file wasn't there previously. And the vote MUST be added
1125 7 : std::string deviceFile = "";
1126 7 : std::string votedFile = "";
1127 14 : for (const auto& changedFile : changedFiles) {
1128 : // NOTE: libgit2 return a diff with /, not DIR_SEPARATOR_DIR
1129 9 : if (changedFile == fmt::format("devices/{}.crt", userDevice)) {
1130 2 : deviceFile = changedFile;
1131 7 : } else if (changedFile.find("votes") == 0) {
1132 5 : votedFile = changedFile;
1133 : } else {
1134 : // Invalid file detected
1135 8 : JAMI_ERROR("Invalid vote file detected: {}", changedFile);
1136 2 : return false;
1137 : }
1138 : }
1139 :
1140 5 : if (votedFile.empty()) {
1141 0 : JAMI_WARNING("No vote detected for commit {}", commitId);
1142 0 : return false;
1143 : }
1144 :
1145 5 : auto repo = repository();
1146 5 : if (!repo)
1147 0 : return false;
1148 5 : auto treeNew = treeAtCommit(repo.get(), commitId);
1149 5 : auto treeOld = treeAtCommit(repo.get(), parentId);
1150 5 : if (not treeNew or not treeOld)
1151 0 : return false;
1152 :
1153 5 : auto userUri = uriFromDevice(userDevice, commitId);
1154 5 : if (userUri.empty())
1155 0 : return false;
1156 : // Check that voter is admin
1157 0 : auto adminFile = fmt::format("admins/{}.crt", userUri);
1158 :
1159 5 : if (!fileAtTree(adminFile, treeOld)) {
1160 0 : JAMI_ERROR("Vote from non admin: {}", userUri);
1161 0 : return false;
1162 : }
1163 :
1164 : // Check votedFile path
1165 5 : static const std::regex regex_votes("votes.(\\w+).(members|devices|admins|invited).(\\w+).(\\w+)");
1166 5 : std::svmatch base_match;
1167 5 : if (!std::regex_match(votedFile, base_match, regex_votes) or base_match.size() != 5) {
1168 0 : JAMI_WARNING("Invalid votes path: {}", votedFile);
1169 0 : return false;
1170 : }
1171 :
1172 5 : std::string_view matchedUri = svsub_match_view(base_match[4]);
1173 5 : if (matchedUri != userUri) {
1174 0 : JAMI_ERROR("Admin voted for other user: {:s} vs {:s}", userUri, matchedUri);
1175 0 : return false;
1176 : }
1177 5 : std::string_view votedUri = svsub_match_view(base_match[3]);
1178 5 : std::string_view type = svsub_match_view(base_match[2]);
1179 5 : std::string_view voteType = svsub_match_view(base_match[1]);
1180 5 : if (voteType != "ban" && voteType != "unban") {
1181 0 : JAMI_ERROR("Unrecognized vote {:s}", voteType);
1182 0 : return false;
1183 : }
1184 :
1185 : // Check that vote file is empty and wasn't modified
1186 5 : if (fileAtTree(votedFile, treeOld)) {
1187 0 : JAMI_ERROR("Invalid voted file modified: {:s}", votedFile);
1188 0 : return false;
1189 : }
1190 5 : auto vote = fileAtTree(votedFile, treeNew);
1191 5 : if (!vote) {
1192 0 : JAMI_ERROR("No vote file found for: {:s}", userUri);
1193 0 : return false;
1194 : }
1195 5 : auto voteContent = as_view(vote);
1196 5 : if (!voteContent.empty()) {
1197 0 : JAMI_ERROR("Vote file not empty: {:s}", votedFile);
1198 0 : return false;
1199 : }
1200 :
1201 : // Check that peer voted is only other device or other member
1202 5 : if (type != "devices") {
1203 5 : if (votedUri == userUri) {
1204 0 : JAMI_ERROR("Detected vote for self: {:s}", votedUri);
1205 0 : return false;
1206 : }
1207 5 : if (voteType == "ban") {
1208 : // file in members or admin or invited
1209 0 : auto invitedFile = fmt::format("invited/{}", votedUri);
1210 5 : if (!memberCertificate(votedUri, treeOld) && !fileAtTree(invitedFile, treeOld)) {
1211 0 : JAMI_ERROR("No member file found for vote: {:s}", votedUri);
1212 0 : return false;
1213 : }
1214 5 : }
1215 : } else {
1216 : // Check not current device
1217 0 : if (votedUri == userDevice) {
1218 0 : JAMI_ERROR("Detected vote for self: {:s}", votedUri);
1219 0 : return false;
1220 : }
1221 : // File in devices
1222 0 : deviceFile = fmt::format("devices/{}.crt", votedUri);
1223 0 : if (!fileAtTree(deviceFile, treeOld)) {
1224 0 : JAMI_ERROR("No device file found for vote: {:s}", votedUri);
1225 0 : return false;
1226 : }
1227 : }
1228 :
1229 5 : return true;
1230 9 : }
1231 :
1232 : bool
1233 754 : ConversationRepository::Impl::checkValidAdd(const std::string& userDevice,
1234 : const std::string& uriMember,
1235 : const std::string& commitId,
1236 : const std::string& parentId) const
1237 : {
1238 754 : auto repo = repository();
1239 754 : if (not repo)
1240 0 : return false;
1241 :
1242 : // std::string repoPath = git_repository_workdir(repo.get());
1243 754 : if (mode() == ConversationMode::ONE_TO_ONE) {
1244 1 : auto initialMembers = getInitialMembers();
1245 1 : auto it = std::find(initialMembers.begin(), initialMembers.end(), uriMember);
1246 1 : if (it == initialMembers.end()) {
1247 4 : JAMI_ERROR("Invalid add in one to one conversation: {}", uriMember);
1248 1 : return false;
1249 : }
1250 1 : }
1251 :
1252 753 : auto userUri = uriFromDevice(userDevice, commitId);
1253 753 : if (userUri.empty())
1254 0 : return false;
1255 :
1256 : // Check that only /invited/uri.crt is added & deviceFile & CRLs
1257 753 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1258 753 : if (changedFiles.size() == 0) {
1259 0 : return false;
1260 753 : } else if (changedFiles.size() > 3) {
1261 0 : return false;
1262 : }
1263 :
1264 : // Check that user added is not sender
1265 753 : if (userUri == uriMember) {
1266 0 : JAMI_ERROR("Member tried to add self: {}", userUri);
1267 0 : return false;
1268 : }
1269 :
1270 : // If modified, it's the first commit of a device, we check
1271 : // that the file wasn't there previously. And the member MUST be added
1272 : // NOTE: libgit2 return a diff with /, not DIR_SEPARATOR_DIR
1273 753 : std::string deviceFile = "";
1274 753 : std::string invitedFile = "";
1275 1506 : std::string crlFile = std::string("CRLs/") + userUri;
1276 1505 : for (const auto& changedFile : changedFiles) {
1277 754 : if (changedFile == std::string("devices/") + userDevice + ".crt") {
1278 1 : deviceFile = changedFile;
1279 753 : } else if (changedFile == std::string("invited/") + uriMember) {
1280 751 : invitedFile = changedFile;
1281 2 : } else if (changedFile == crlFile) {
1282 : // Nothing to do
1283 : } else {
1284 : // Invalid file detected
1285 8 : JAMI_ERROR("Invalid add file detected: {}", changedFile);
1286 2 : return false;
1287 : }
1288 : }
1289 :
1290 751 : auto treeOld = treeAtCommit(repo.get(), parentId);
1291 751 : if (not treeOld)
1292 0 : return false;
1293 :
1294 751 : auto treeNew = treeAtCommit(repo.get(), commitId);
1295 751 : auto blob_invite = fileAtTree(invitedFile, treeNew);
1296 751 : if (!blob_invite) {
1297 0 : JAMI_ERROR("Invitation not found for commit {}", commitId);
1298 0 : return false;
1299 : }
1300 :
1301 751 : auto invitation = as_view(blob_invite);
1302 751 : if (!invitation.empty()) {
1303 0 : JAMI_ERROR("Invitation not empty for commit {}", commitId);
1304 0 : return false;
1305 : }
1306 :
1307 : // Check that user not in /banned
1308 : std::string bannedFile = fmt::format("{}/{}/{}.crt",
1309 1502 : MemberPath::BANNED.string(),
1310 751 : MemberPath::MEMBERS.string(),
1311 751 : uriMember);
1312 751 : if (fileAtTree(bannedFile, treeOld)) {
1313 0 : JAMI_ERROR("Tried to add banned member: {}", bannedFile);
1314 0 : return false;
1315 : }
1316 :
1317 751 : return true;
1318 754 : }
1319 :
1320 : bool
1321 788 : ConversationRepository::Impl::checkValidJoins(const std::string& userDevice,
1322 : const std::string& uriMember,
1323 : const std::string& commitId,
1324 : const std::string& parentId) const
1325 : {
1326 : // Check no other files changed
1327 1576 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1328 788 : auto invitedFile = fmt::format("invited/{}", uriMember);
1329 788 : auto membersFile = fmt::format("members/{}.crt", uriMember);
1330 0 : auto deviceFile = fmt::format("devices/{}.crt", userDevice);
1331 :
1332 3146 : for (auto& file : changedFiles) {
1333 2358 : if (file != invitedFile && file != membersFile && file != deviceFile) {
1334 0 : JAMI_ERROR("Unwanted file {} found", file);
1335 0 : return false;
1336 : }
1337 : }
1338 :
1339 : // Retrieve tree for commits
1340 788 : auto repo = repository();
1341 788 : assert(repo);
1342 788 : auto treeNew = treeAtCommit(repo.get(), commitId);
1343 788 : auto treeOld = treeAtCommit(repo.get(), parentId);
1344 788 : if (not treeNew or not treeOld)
1345 0 : return false;
1346 :
1347 : // Check /invited
1348 788 : if (fileAtTree(invitedFile, treeNew)) {
1349 8 : JAMI_ERROR("{} invited not removed", uriMember);
1350 2 : return false;
1351 : }
1352 786 : if (!fileAtTree(invitedFile, treeOld)) {
1353 4 : JAMI_ERROR("{} invited not found", uriMember);
1354 1 : return false;
1355 : }
1356 :
1357 : // Check /members added
1358 785 : if (!fileAtTree(membersFile, treeNew)) {
1359 0 : JAMI_ERROR("{} members not found", uriMember);
1360 0 : return false;
1361 : }
1362 785 : if (fileAtTree(membersFile, treeOld)) {
1363 0 : JAMI_ERROR("{} members found too soon", uriMember);
1364 0 : return false;
1365 : }
1366 :
1367 : // Check /devices added
1368 785 : if (!fileAtTree(deviceFile, treeNew)) {
1369 0 : JAMI_ERROR("{} devices not found", uriMember);
1370 0 : return false;
1371 : }
1372 :
1373 : // Check certificate
1374 785 : auto blob_device = fileAtTree(deviceFile, treeNew);
1375 785 : if (!blob_device) {
1376 0 : JAMI_ERROR("{} announced but not found", deviceFile);
1377 0 : return false;
1378 : }
1379 785 : auto deviceCert = dht::crypto::Certificate(as_view(blob_device));
1380 785 : auto blob_member = fileAtTree(membersFile, treeNew);
1381 785 : if (!blob_member) {
1382 0 : JAMI_ERROR("{} announced but not found", userDevice);
1383 0 : return false;
1384 : }
1385 785 : auto memberCert = dht::crypto::Certificate(as_view(blob_member));
1386 785 : if (memberCert.getId().toString() != deviceCert.getIssuerUID() || deviceCert.getIssuerUID() != uriMember) {
1387 0 : JAMI_ERROR("Incorrect device certificate {} for user {}", userDevice, uriMember);
1388 0 : return false;
1389 : }
1390 :
1391 785 : return true;
1392 788 : }
1393 :
1394 : bool
1395 1 : ConversationRepository::Impl::checkValidRemove(const std::string& userDevice,
1396 : const std::string& uriMember,
1397 : const std::string& commitId,
1398 : const std::string& parentId) const
1399 : {
1400 : // Retrieve tree for recent commit
1401 1 : auto repo = repository();
1402 1 : if (!repo)
1403 0 : return false;
1404 1 : auto treeOld = treeAtCommit(repo.get(), parentId);
1405 1 : if (not treeOld)
1406 0 : return false;
1407 :
1408 2 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1409 : // NOTE: libgit2 return a diff with /, not DIR_SEPARATOR_DIR
1410 1 : std::string deviceFile = fmt::format("devices/{}.crt", userDevice);
1411 1 : std::string adminFile = fmt::format("admins/{}.crt", uriMember);
1412 1 : std::string memberFile = fmt::format("members/{}.crt", uriMember);
1413 1 : std::string crlFile = fmt::format("CRLs/{}", uriMember);
1414 0 : std::string invitedFile = fmt::format("invited/{}", uriMember);
1415 1 : std::vector<std::string> devicesRemoved;
1416 :
1417 : // Check that no weird file is added nor removed
1418 1 : static const std::regex regex_devices("devices.(\\w+)\\.crt");
1419 1 : std::smatch base_match;
1420 3 : for (const auto& f : changedFiles) {
1421 2 : if (f == deviceFile || f == adminFile || f == memberFile || f == crlFile || f == invitedFile) {
1422 : // Ignore
1423 2 : continue;
1424 0 : } else if (std::regex_match(f, base_match, regex_devices)) {
1425 0 : if (base_match.size() == 2)
1426 0 : devicesRemoved.emplace_back(base_match[1]);
1427 : } else {
1428 0 : JAMI_ERROR("Unwanted changed file detected: {}", f);
1429 0 : return false;
1430 : }
1431 : }
1432 :
1433 : // Check that removed devices are for removed member (or directly uriMember)
1434 1 : for (const auto& deviceUri : devicesRemoved) {
1435 0 : deviceFile = fmt::format("devices/{}.crt", deviceUri);
1436 0 : auto blob_device = fileAtTree(deviceFile, treeOld);
1437 0 : if (!blob_device) {
1438 0 : JAMI_ERROR("Device not found added ({})", deviceFile);
1439 0 : return false;
1440 : }
1441 0 : auto deviceCert = dht::crypto::Certificate(as_view(blob_device));
1442 0 : auto userUri = deviceCert.getIssuerUID();
1443 :
1444 0 : if (uriMember != userUri and uriMember != deviceUri /* If device is removed */) {
1445 0 : JAMI_ERROR("Device removed but not for removed user ({})", deviceFile);
1446 0 : return false;
1447 : }
1448 0 : }
1449 :
1450 1 : return true;
1451 1 : }
1452 :
1453 : bool
1454 9 : ConversationRepository::Impl::checkValidVoteResolution(const std::string& userDevice,
1455 : const std::string& uriMember,
1456 : const std::string& commitId,
1457 : const std::string& parentId,
1458 : const std::string& voteType) const
1459 : {
1460 : // Retrieve tree for recent commit
1461 9 : auto repo = repository();
1462 9 : if (!repo)
1463 0 : return false;
1464 9 : auto treeOld = treeAtCommit(repo.get(), parentId);
1465 9 : if (not treeOld)
1466 0 : return false;
1467 :
1468 18 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1469 : // NOTE: libgit2 return a diff with /, not DIR_SEPARATOR_DIR
1470 9 : std::string deviceFile = fmt::format("devices/{}.crt", userDevice);
1471 9 : std::string adminFile = fmt::format("admins/{}.crt", uriMember);
1472 9 : std::string memberFile = fmt::format("members/{}.crt", uriMember);
1473 9 : std::string crlFile = fmt::format("CRLs/{}", uriMember);
1474 0 : std::string invitedFile = fmt::format("invited/{}", uriMember);
1475 9 : std::vector<std::string> voters;
1476 9 : std::vector<std::string> devicesRemoved;
1477 9 : std::vector<std::string> bannedFiles;
1478 : // Check that no weird file is added nor removed
1479 :
1480 18 : const std::regex regex_votes("votes." + voteType + ".(members|devices|admins|invited).(\\w+).(\\w+)");
1481 9 : static const std::regex regex_devices("devices.(\\w+)\\.crt");
1482 9 : static const std::regex regex_banned("banned.(members|devices|admins).(\\w+)\\.crt");
1483 9 : static const std::regex regex_banned_invited("banned.(invited).(\\w+)");
1484 9 : std::smatch base_match;
1485 24 : for (const auto& f : changedFiles) {
1486 17 : if (f == deviceFile || f == adminFile || f == memberFile || f == crlFile || f == invitedFile) {
1487 : // Ignore
1488 5 : continue;
1489 12 : } else if (std::regex_match(f, base_match, regex_votes)) {
1490 5 : if (base_match.size() != 4 or base_match[2] != uriMember) {
1491 0 : JAMI_ERROR("Invalid vote file detected: {}", f);
1492 2 : return false;
1493 : }
1494 5 : voters.emplace_back(base_match[3]);
1495 : // Check that votes were not added here
1496 5 : if (!fileAtTree(f, treeOld)) {
1497 0 : JAMI_ERROR("invalid vote added ({})", f);
1498 0 : return false;
1499 : }
1500 7 : } else if (std::regex_match(f, base_match, regex_devices)) {
1501 0 : if (base_match.size() == 2)
1502 0 : devicesRemoved.emplace_back(base_match[1]);
1503 7 : } else if (std::regex_match(f, base_match, regex_banned)
1504 7 : || std::regex_match(f, base_match, regex_banned_invited)) {
1505 5 : bannedFiles.emplace_back(f);
1506 5 : if (base_match.size() != 3 or base_match[2] != uriMember) {
1507 0 : JAMI_ERROR("Invalid banned file detected : {}", f);
1508 0 : return false;
1509 : }
1510 : } else {
1511 8 : JAMI_ERROR("Unwanted changed file detected: {}", f);
1512 2 : return false;
1513 : }
1514 : }
1515 :
1516 : // Check that removed devices are for removed member (or directly uriMember)
1517 7 : for (const auto& deviceUri : devicesRemoved) {
1518 0 : deviceFile = fmt::format("devices/{}.crt", deviceUri);
1519 0 : if (voteType == "ban") {
1520 : // If we ban a device, it should be there before
1521 0 : if (!fileAtTree(deviceFile, treeOld)) {
1522 0 : JAMI_ERROR("Device not found added ({})", deviceFile);
1523 0 : return false;
1524 : }
1525 0 : } else if (voteType == "unban") {
1526 : // If we unban a device, it should not be there before
1527 0 : if (fileAtTree(deviceFile, treeOld)) {
1528 0 : JAMI_ERROR("Device not found added ({})", deviceFile);
1529 0 : return false;
1530 : }
1531 : }
1532 0 : if (uriMember != uriFromDevice(deviceUri) and uriMember != deviceUri /* If device is removed */) {
1533 0 : JAMI_ERROR("Device removed but not for removed user ({})", deviceFile);
1534 0 : return false;
1535 : }
1536 : }
1537 :
1538 7 : auto userUri = uriFromDevice(userDevice, commitId);
1539 7 : if (userUri.empty())
1540 0 : return false;
1541 :
1542 : // Check that voters are admins
1543 7 : adminFile = fmt::format("admins/{}.crt", userUri);
1544 7 : if (!fileAtTree(adminFile, treeOld)) {
1545 4 : JAMI_ERROR("admin file ({}) not found", adminFile);
1546 1 : return false;
1547 : }
1548 :
1549 : // If not for self check that vote is valid and not added
1550 6 : auto nbAdmins = 0;
1551 6 : auto nbVotes = 0;
1552 6 : std::string repoPath = git_repository_workdir(repo.get());
1553 12 : for (const auto& certificate : dhtnet::fileutils::readDirectory(repoPath + "admins")) {
1554 6 : if (certificate.find(".crt") == std::string::npos) {
1555 0 : JAMI_WARNING("Incorrect file found: {}", certificate);
1556 0 : continue;
1557 0 : }
1558 6 : nbAdmins += 1;
1559 12 : auto adminUri = certificate.substr(0, certificate.size() - std::string(".crt").size());
1560 6 : if (std::find(voters.begin(), voters.end(), adminUri) != voters.end()) {
1561 5 : nbVotes += 1;
1562 : }
1563 12 : }
1564 :
1565 6 : if (nbAdmins == 0 or (static_cast<double>(nbVotes) / static_cast<double>(nbAdmins)) < .5) {
1566 4 : JAMI_ERROR("Incomplete vote detected (commit: {})", commitId);
1567 1 : return false;
1568 : }
1569 :
1570 : // If not for self check that member or device certificate is moved to banned/
1571 5 : return !bannedFiles.empty();
1572 9 : }
1573 :
1574 : bool
1575 7 : ConversationRepository::Impl::checkValidProfileUpdate(const std::string& userDevice,
1576 : const std::string& commitId,
1577 : const std::string& parentId) const
1578 : {
1579 : // Retrieve tree for recent commit
1580 7 : auto repo = repository();
1581 7 : if (!repo)
1582 0 : return false;
1583 7 : auto treeNew = treeAtCommit(repo.get(), commitId);
1584 7 : auto treeOld = treeAtCommit(repo.get(), parentId);
1585 7 : if (not treeNew or not treeOld)
1586 0 : return false;
1587 :
1588 7 : auto userUri = uriFromDevice(userDevice, commitId);
1589 7 : if (userUri.empty())
1590 0 : return false;
1591 :
1592 : // Check if profile is changed by an user with correct privilege
1593 7 : auto valid = false;
1594 7 : if (updateProfilePermLvl_ == MemberRole::ADMIN) {
1595 0 : std::string adminFile = fmt::format("admins/{}.crt", userUri);
1596 7 : auto adminCert = fileAtTree(adminFile, treeNew);
1597 7 : valid |= adminCert != nullptr;
1598 7 : }
1599 7 : if (updateProfilePermLvl_ >= MemberRole::MEMBER) {
1600 0 : std::string memberFile = fmt::format("members/{}.crt", userUri);
1601 0 : auto memberCert = fileAtTree(memberFile, treeNew);
1602 0 : valid |= memberCert != nullptr;
1603 0 : }
1604 :
1605 7 : if (!valid) {
1606 4 : JAMI_ERROR("Profile changed from unauthorized user: {} ({})", userDevice, userUri);
1607 1 : return false;
1608 : }
1609 :
1610 12 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, parentId));
1611 : // Check that no weird file is added nor removed
1612 0 : std::string userDeviceFile = fmt::format("devices/{}.crt", userDevice);
1613 12 : for (const auto& f : changedFiles) {
1614 7 : if (f == "profile.vcf") {
1615 : // Ignore
1616 2 : } else if (f == userDeviceFile) {
1617 : // In this case, device is added or modified (certificate expiration)
1618 1 : auto oldFile = fileAtTree(f, treeOld);
1619 1 : std::string_view oldCert;
1620 1 : if (oldFile)
1621 0 : oldCert = as_view(oldFile);
1622 1 : auto newFile = fileAtTree(f, treeNew);
1623 1 : if (!verifyCertificate(as_view(newFile), userUri, oldCert)) {
1624 0 : JAMI_ERROR("Invalid certificate {}", f);
1625 0 : return false;
1626 : }
1627 1 : } else {
1628 4 : JAMI_ERROR("Unwanted changed file detected: {}", f);
1629 1 : return false;
1630 : }
1631 : }
1632 5 : return true;
1633 7 : }
1634 :
1635 : /**
1636 : * @brief Get the deltas from a git diff
1637 : * @param diff The diff object to extract deltas from
1638 : * @return The set of git_diff deltas extracted from the diff
1639 : */
1640 : std::optional<std::set<std::string_view>>
1641 14 : ConversationRepository::Impl::getDeltaPathsFromDiff(const GitDiff& diff) const
1642 : {
1643 14 : std::set<std::string_view> deltas_set = {};
1644 48 : for (size_t delta_idx = 0, delta_count = git_diff_num_deltas(diff.get()); delta_idx < delta_count; delta_idx++) {
1645 34 : const git_diff_delta* delta = git_diff_get_delta(diff.get(), delta_idx);
1646 34 : if (!delta) {
1647 0 : JAMI_LOG("[Account {}] [Conversation {}] Index of delta out of range!", accountId_, id_);
1648 0 : return std::nullopt;
1649 : }
1650 :
1651 34 : deltas_set.emplace(std::string_view(delta->old_file.path));
1652 34 : deltas_set.emplace(std::string_view(delta->new_file.path));
1653 : }
1654 14 : return deltas_set;
1655 14 : }
1656 :
1657 : /**
1658 : * @brief Validate the merge commit by ensuring the absence of invalid files
1659 : * @param mergeId The id of the merge commit
1660 : * @param parents The two commit IDs of parent's of the merge commit
1661 : * @return bool Whether or not invalid files were found in the merge commit
1662 : */
1663 : bool
1664 7 : ConversationRepository::Impl::checkValidMergeCommit(const std::string& mergeId,
1665 : const std::vector<std::string>& parents) const
1666 : {
1667 : // Get the repository associated with this implementation
1668 7 : auto repo = repository();
1669 7 : if (!repo)
1670 0 : return false;
1671 :
1672 : // Check for exactly two parents
1673 7 : if (static_cast<int>(parents.size()) != 2)
1674 0 : return false;
1675 :
1676 : // Get the tree of the merge commit
1677 7 : GitTree merge_commit_tree = treeAtCommit(repo.get(), mergeId);
1678 :
1679 : // Get the diff of the merge commit and the first parent
1680 7 : GitTree first_tree = treeAtCommit(repo.get(), parents[0]);
1681 7 : git_diff* diff_merge_tree_to_first_tree = nullptr;
1682 7 : if (git_diff_tree_to_tree(&diff_merge_tree_to_first_tree,
1683 : repo.get(),
1684 : first_tree.get(),
1685 : merge_commit_tree.get(),
1686 : nullptr)
1687 7 : < 0) {
1688 0 : const git_error* err = giterr_last();
1689 0 : if (err)
1690 0 : JAMI_ERROR("[Account {}] [Conversation {}] Failed to git diff of merge and first parent "
1691 : "failed: {}",
1692 : accountId_,
1693 : id_,
1694 : err->message);
1695 0 : return false;
1696 : }
1697 7 : GitDiff first_diff = GitDiff {diff_merge_tree_to_first_tree, git_diff_free};
1698 :
1699 : // Get the diff of the merge commit and the second parent
1700 7 : GitTree second_tree = treeAtCommit(repo.get(), parents[1]);
1701 7 : git_diff* diff_merge_tree_to_second_tree = nullptr;
1702 7 : if (git_diff_tree_to_tree(&diff_merge_tree_to_second_tree,
1703 : repo.get(),
1704 : second_tree.get(),
1705 : merge_commit_tree.get(),
1706 : nullptr)
1707 7 : < 0) {
1708 0 : const git_error* err = giterr_last();
1709 0 : if (err)
1710 0 : JAMI_ERROR("[Account {}] [Conversation {}] Failed to git diff of merge and second parent "
1711 : "failed: {}",
1712 : accountId_,
1713 : id_,
1714 : err->message);
1715 0 : return false;
1716 : }
1717 7 : GitDiff second_diff = GitDiff {diff_merge_tree_to_second_tree, git_diff_free};
1718 :
1719 : // Get the deltas of the first parent's commit
1720 7 : auto first_parent_deltas_set = getDeltaPathsFromDiff(first_diff);
1721 : // Get the deltas of the second parent's commit
1722 7 : auto second_parent_deltas_set = getDeltaPathsFromDiff(second_diff);
1723 7 : if (first_parent_deltas_set == std::nullopt || second_parent_deltas_set == std::nullopt) {
1724 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get deltas from diffs for merge commit {}",
1725 : accountId_,
1726 : id_,
1727 : mergeId);
1728 0 : return false;
1729 : }
1730 : // Get the intersection of the deltas of both parents
1731 7 : std::set<std::string_view> parent_deltas_intersection_set = {};
1732 7 : std::set_intersection(first_parent_deltas_set->begin(),
1733 : first_parent_deltas_set->end(),
1734 : second_parent_deltas_set->begin(),
1735 : second_parent_deltas_set->end(),
1736 : std::inserter(parent_deltas_intersection_set, parent_deltas_intersection_set.begin()));
1737 :
1738 : // The intersection of the set of diffs of both the parents of the merge commit should be be the
1739 : // empty set (i.e. no deltas in the intersection vector). This ensures that no malicious files
1740 : // have been added into the merge commit itself.
1741 7 : if (not parent_deltas_intersection_set.empty()) {
1742 1 : return false;
1743 : }
1744 6 : return true;
1745 7 : }
1746 :
1747 : bool
1748 1717 : ConversationRepository::Impl::isValidUserAtCommit(const std::string& userDevice,
1749 : const std::string& commitId,
1750 : const git_buf& sig,
1751 : const git_buf& sig_data) const
1752 : {
1753 1717 : auto acc = account_.lock();
1754 1717 : if (!acc)
1755 0 : return false;
1756 1717 : auto cert = acc->certStore().getCertificate(userDevice);
1757 1717 : auto hasPinnedCert = cert and cert->issuer;
1758 1717 : auto repo = repository();
1759 1717 : if (not repo)
1760 0 : return false;
1761 :
1762 : // Retrieve tree for commit
1763 1717 : auto tree = treeAtCommit(repo.get(), commitId);
1764 1717 : if (not tree)
1765 0 : return false;
1766 :
1767 : // Check that /devices/userDevice.crt exists
1768 0 : std::string deviceFile = fmt::format("devices/{}.crt", userDevice);
1769 1717 : auto blob_device = fileAtTree(deviceFile, tree);
1770 1717 : if (!blob_device) {
1771 4 : JAMI_ERROR("{} announced but not found", deviceFile);
1772 1 : return false;
1773 : }
1774 1716 : auto deviceCert = dht::crypto::Certificate(as_view(blob_device));
1775 1716 : auto userUri = deviceCert.getIssuerUID();
1776 1716 : if (userUri.empty()) {
1777 0 : JAMI_ERROR("{} got no issuer UID", deviceFile);
1778 0 : if (not hasPinnedCert) {
1779 0 : return false;
1780 : } else {
1781 : // HACK: JAMS device's certificate does not contains any issuer
1782 : // So, getIssuerUID() will be empty here, so there is no way
1783 : // to get the userURI from this certificate.
1784 : // Uses pinned certificate if one.
1785 0 : userUri = cert->issuer->getId().toString();
1786 : }
1787 : }
1788 :
1789 : // Check that /(members|admins)/userUri.crt exists
1790 1716 : auto blob_parent = memberCertificate(userUri, tree);
1791 1716 : if (not blob_parent) {
1792 0 : JAMI_ERROR("Certificate not found for {}", userUri);
1793 0 : return false;
1794 : }
1795 :
1796 : // Check that certificates were still valid
1797 1716 : auto parentCert = dht::crypto::Certificate(as_view(blob_parent));
1798 :
1799 : git_oid oid;
1800 1716 : git_commit* commit_ptr = nullptr;
1801 1716 : if (git_oid_fromstr(&oid, commitId.c_str()) < 0 || git_commit_lookup(&commit_ptr, repo.get(), &oid) < 0) {
1802 0 : JAMI_WARNING("Failed to look up commit {}", commitId);
1803 0 : return false;
1804 : }
1805 1716 : GitCommit commit = {commit_ptr, git_commit_free};
1806 :
1807 1716 : auto commitTime = std::chrono::system_clock::from_time_t(git_commit_time(commit.get()));
1808 1716 : if (deviceCert.getExpiration() < commitTime) {
1809 0 : JAMI_ERROR("Certificate {} expired", deviceCert.getId().toString());
1810 0 : return false;
1811 : }
1812 1716 : if (parentCert.getExpiration() < commitTime) {
1813 0 : JAMI_ERROR("Certificate {} expired", parentCert.getId().toString());
1814 0 : return false;
1815 : }
1816 :
1817 : // Verify the signature (git verify-commit)
1818 1716 : auto pk = base64::decode(std::string_view(sig.ptr, sig.size));
1819 1716 : bool valid_signature = deviceCert.getPublicKey().checkSignature(reinterpret_cast<const uint8_t*>(sig_data.ptr),
1820 1716 : sig_data.size,
1821 1716 : pk.data(),
1822 : pk.size());
1823 :
1824 1716 : if (!valid_signature) {
1825 4 : JAMI_WARNING("Commit {} not signed by device {}.", git_oid_tostr_s(&oid), userDevice);
1826 1 : return false;
1827 : }
1828 :
1829 1715 : auto res = parentCert.getId().toString() == userUri;
1830 1715 : if (res && not hasPinnedCert) {
1831 72 : acc->certStore().pinCertificate(std::move(deviceCert));
1832 72 : acc->certStore().pinCertificate(std::move(parentCert));
1833 : }
1834 1715 : return res;
1835 1717 : }
1836 :
1837 : bool
1838 187 : ConversationRepository::Impl::checkInitialCommit(const std::string& userDevice,
1839 : const std::string& commitId,
1840 : const std::string& commitMsg) const
1841 : {
1842 187 : auto account = account_.lock();
1843 187 : auto repo = repository();
1844 187 : if (not account or not repo) {
1845 0 : JAMI_WARNING("Invalid repository detected");
1846 0 : return false;
1847 : }
1848 :
1849 187 : auto treeNew = treeAtCommit(repo.get(), commitId);
1850 187 : auto userUri = uriFromDevice(userDevice, commitId);
1851 187 : if (userUri.empty())
1852 0 : return false;
1853 :
1854 374 : auto changedFiles = ConversationRepository::changedFiles(diffStats(commitId, ""));
1855 : // NOTE: libgit2 return a diff with /, not DIR_SEPARATOR_DIR
1856 :
1857 : try {
1858 187 : mode();
1859 0 : } catch (...) {
1860 0 : JAMI_ERROR("Invalid mode detected for commit: {}", commitId);
1861 0 : return false;
1862 0 : }
1863 :
1864 187 : std::string invited = {};
1865 187 : if (mode_ == ConversationMode::ONE_TO_ONE) {
1866 59 : Json::Value cm;
1867 59 : if (json::parse(commitMsg, cm)) {
1868 59 : invited = cm["invited"].asString();
1869 : }
1870 59 : }
1871 :
1872 187 : auto hasDevice = false, hasAdmin = false;
1873 187 : std::string adminsFile = fmt::format("admins/{}.crt", userUri);
1874 187 : std::string deviceFile = fmt::format("devices/{}.crt", userDevice);
1875 187 : std::string crlFile = fmt::format("CRLs/{}", userUri);
1876 0 : std::string invitedFile = fmt::format("invited/{}", invited);
1877 :
1878 : // Check that admin cert is added
1879 : // Check that device cert is added
1880 : // Check CRLs added
1881 : // Check that no other file is added
1882 : // Check if invited file present for one to one.
1883 617 : for (const auto& changedFile : changedFiles) {
1884 432 : if (changedFile == adminsFile) {
1885 186 : hasAdmin = true;
1886 186 : auto newFile = fileAtTree(changedFile, treeNew);
1887 186 : if (!verifyCertificate(as_view(newFile), userUri)) {
1888 0 : JAMI_ERROR("Invalid certificate found {}", changedFile);
1889 0 : return false;
1890 : }
1891 432 : } else if (changedFile == deviceFile) {
1892 186 : hasDevice = true;
1893 186 : auto newFile = fileAtTree(changedFile, treeNew);
1894 186 : if (!verifyCertificate(as_view(newFile), userUri)) {
1895 4 : JAMI_ERROR("Invalid certificate found {}", changedFile);
1896 1 : return false;
1897 : }
1898 246 : } else if (changedFile == crlFile || changedFile == invitedFile) {
1899 : // Nothing to do
1900 59 : continue;
1901 : } else {
1902 : // Invalid file detected
1903 4 : JAMI_ERROR("Invalid add file detected: {} {}", changedFile, (int) *mode_);
1904 1 : return false;
1905 : }
1906 : }
1907 :
1908 185 : return hasDevice && hasAdmin;
1909 187 : }
1910 :
1911 : bool
1912 510 : ConversationRepository::Impl::validateDevice()
1913 : {
1914 510 : auto repo = repository();
1915 510 : auto account = account_.lock();
1916 510 : if (!account || !repo) {
1917 0 : JAMI_WARNING("[Account {}] [Conversation {}] Invalid repository detected", accountId_, id_);
1918 0 : return false;
1919 : }
1920 510 : auto path = fmt::format("devices/{}.crt", deviceId_);
1921 510 : std::filesystem::path devicePath = git_repository_workdir(repo.get());
1922 510 : devicePath /= path;
1923 510 : if (!std::filesystem::is_regular_file(devicePath)) {
1924 0 : JAMI_WARNING("[Account {}] [Conversation {}] Unable to find file {}", accountId_, id_, devicePath);
1925 0 : return false;
1926 : }
1927 :
1928 510 : auto wrongDeviceFile = false;
1929 : try {
1930 1020 : auto deviceCert = dht::crypto::Certificate(fileutils::loadFile(devicePath));
1931 510 : wrongDeviceFile = !account->isValidAccountDevice(deviceCert);
1932 510 : } catch (const std::exception&) {
1933 0 : wrongDeviceFile = true;
1934 0 : }
1935 510 : if (wrongDeviceFile) {
1936 12 : JAMI_WARNING(
1937 : "[Account {}] [Conversation {}] Device certificate is no longer valid. Attempting to update certificate.",
1938 : accountId_,
1939 : id_);
1940 : // Replace certificate with current cert
1941 3 : auto cert = account->identity().second;
1942 3 : if (!cert || !account->isValidAccountDevice(*cert)) {
1943 0 : JAMI_ERROR("[Account {}] [Conversation {}] Current device's certificate is invalid. A migration is needed",
1944 : accountId_,
1945 : id_);
1946 0 : return false;
1947 : }
1948 3 : std::ofstream file(devicePath, std::ios::trunc | std::ios::binary);
1949 3 : if (!file.is_open()) {
1950 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to write data to {}", accountId_, id_, devicePath);
1951 0 : return false;
1952 : }
1953 3 : file << cert->toString(false);
1954 3 : file.close();
1955 3 : if (!add(path)) {
1956 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to add file {}", accountId_, id_, devicePath);
1957 0 : return false;
1958 : }
1959 3 : }
1960 :
1961 : // Check account cert (a new device can be added but account certifcate can be the old one!)
1962 510 : auto adminPath = fmt::format("admins/{}.crt", userId_);
1963 510 : auto memberPath = fmt::format("members/{}.crt", userId_);
1964 510 : std::filesystem::path parentPath = git_repository_workdir(repo.get());
1965 510 : std::filesystem::path relativeParentPath;
1966 510 : if (std::filesystem::is_regular_file(parentPath / adminPath))
1967 301 : relativeParentPath = adminPath;
1968 209 : else if (std::filesystem::is_regular_file(parentPath / memberPath))
1969 208 : relativeParentPath = memberPath;
1970 510 : parentPath /= relativeParentPath;
1971 510 : if (relativeParentPath.empty()) {
1972 4 : JAMI_ERROR("[Account {}] [Conversation {}] Invalid parent path (not in members or admins)", accountId_, id_);
1973 1 : return false;
1974 : }
1975 509 : wrongDeviceFile = false;
1976 : try {
1977 1018 : auto parentCert = dht::crypto::Certificate(fileutils::loadFile(parentPath));
1978 509 : wrongDeviceFile = !account->isValidAccountDevice(parentCert);
1979 509 : } catch (const std::exception&) {
1980 0 : wrongDeviceFile = true;
1981 0 : }
1982 509 : if (wrongDeviceFile) {
1983 4 : JAMI_WARNING(
1984 : "[Account {}] [Conversation {}] Account certificate is no longer valid. Attempting to update certificate.",
1985 : accountId_,
1986 : id_);
1987 1 : auto cert = account->identity().second;
1988 1 : auto newCert = cert->issuer;
1989 1 : if (newCert && std::filesystem::is_regular_file(parentPath)) {
1990 1 : std::ofstream file(parentPath, std::ios::trunc | std::ios::binary);
1991 1 : if (!file.is_open()) {
1992 0 : JAMI_ERROR("Unable to write data to {}", path);
1993 0 : return false;
1994 : }
1995 1 : file << newCert->toString(true);
1996 1 : file.close();
1997 1 : if (!add(relativeParentPath.string())) {
1998 0 : JAMI_WARNING("Unable to add file {}", path);
1999 0 : return false;
2000 : }
2001 1 : }
2002 1 : }
2003 :
2004 509 : return true;
2005 510 : }
2006 :
2007 : std::string
2008 491 : ConversationRepository::Impl::commit(const std::string& msg, bool verifyDevice)
2009 : {
2010 491 : if (verifyDevice && !validateDevice()) {
2011 4 : JAMI_ERROR("[Account {}] [Conversation {}] commit failed: Invalid device", accountId_, id_);
2012 1 : return {};
2013 : }
2014 490 : GitSignature sig = signature();
2015 490 : if (!sig) {
2016 0 : JAMI_ERROR("[Account {}] [Conversation {}] commit failed: Unable to generate signature", accountId_, id_);
2017 0 : return {};
2018 : }
2019 490 : auto account = account_.lock();
2020 :
2021 : // Retrieve current index
2022 490 : git_index* index_ptr = nullptr;
2023 490 : auto repo = repository();
2024 490 : if (!repo)
2025 0 : return {};
2026 490 : if (git_repository_index(&index_ptr, repo.get()) < 0) {
2027 0 : JAMI_ERROR("[Account {}] [Conversation {}] commit failed: Unable to open repository index", accountId_, id_);
2028 0 : return {};
2029 : }
2030 490 : GitIndex index {index_ptr, git_index_free};
2031 :
2032 : git_oid tree_id;
2033 490 : if (git_index_write_tree(&tree_id, index.get()) < 0) {
2034 0 : JAMI_ERROR("[Account {}] [Conversation {}] commit failed: Unable to write initial tree from index",
2035 : accountId_,
2036 : id_);
2037 0 : return {};
2038 : }
2039 :
2040 490 : git_tree* tree_ptr = nullptr;
2041 490 : if (git_tree_lookup(&tree_ptr, repo.get(), &tree_id) < 0) {
2042 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up initial tree", accountId_, id_);
2043 0 : return {};
2044 : }
2045 490 : GitTree tree = {tree_ptr, git_tree_free};
2046 :
2047 : git_oid commit_id;
2048 490 : if (git_reference_name_to_id(&commit_id, repo.get(), "HEAD") < 0) {
2049 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get reference for HEAD", accountId_, id_);
2050 0 : return {};
2051 : }
2052 :
2053 490 : git_commit* head_ptr = nullptr;
2054 490 : if (git_commit_lookup(&head_ptr, repo.get(), &commit_id) < 0) {
2055 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up HEAD commit", accountId_, id_);
2056 0 : return {};
2057 : }
2058 490 : GitCommit head_commit {head_ptr, git_commit_free};
2059 :
2060 490 : git_buf to_sign = {};
2061 : // The last argument of git_commit_create_buffer is of type
2062 : // 'const git_commit **' in all versions of libgit2 except 1.8.0,
2063 : // 1.8.1 and 1.8.3, in which it is of type 'git_commit *const *'.
2064 : #if LIBGIT2_VER_MAJOR == 1 && LIBGIT2_VER_MINOR == 8 \
2065 : && (LIBGIT2_VER_REVISION == 0 || LIBGIT2_VER_REVISION == 1 || LIBGIT2_VER_REVISION == 3)
2066 490 : git_commit* const head_ref[1] = {head_commit.get()};
2067 : #else
2068 : const git_commit* head_ref[1] = {head_commit.get()};
2069 : #endif
2070 980 : if (git_commit_create_buffer(
2071 980 : &to_sign, repo.get(), sig.get(), sig.get(), nullptr, msg.c_str(), tree.get(), 1, &head_ref[0])
2072 490 : < 0) {
2073 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to create commit buffer", accountId_, id_);
2074 0 : return {};
2075 : }
2076 :
2077 : // git commit -S
2078 490 : auto to_sign_vec = std::vector<uint8_t>(to_sign.ptr, to_sign.ptr + to_sign.size);
2079 490 : auto signed_buf = account->identity().first->sign(to_sign_vec);
2080 490 : std::string signed_str = base64::encode(signed_buf);
2081 490 : if (git_commit_create_with_signature(&commit_id, repo.get(), to_sign.ptr, signed_str.c_str(), "signature") < 0) {
2082 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to sign commit", accountId_, id_);
2083 0 : git_buf_dispose(&to_sign);
2084 0 : return {};
2085 : }
2086 490 : git_buf_dispose(&to_sign);
2087 :
2088 : // Move commit to main branch
2089 490 : git_reference* ref_ptr = nullptr;
2090 490 : if (git_reference_create(&ref_ptr, repo.get(), "refs/heads/main", &commit_id, true, nullptr) < 0) {
2091 0 : const git_error* err = giterr_last();
2092 0 : if (err) {
2093 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to move commit to main: {}",
2094 : accountId_,
2095 : id_,
2096 : err->message);
2097 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_, id_, ECOMMIT, err->message);
2098 : }
2099 0 : return {};
2100 : }
2101 490 : git_reference_free(ref_ptr);
2102 :
2103 490 : auto commit_str = git_oid_tostr_s(&commit_id);
2104 490 : if (commit_str) {
2105 1960 : JAMI_LOG("[Account {}] [Conversation {}] New message added with id: {}", accountId_, id_, commit_str);
2106 : }
2107 490 : return commit_str ? commit_str : "";
2108 490 : }
2109 :
2110 : ConversationMode
2111 7415 : ConversationRepository::Impl::mode() const
2112 : {
2113 : // If already retrieved, return it, else get it from first commit
2114 7415 : if (mode_ != std::nullopt)
2115 7000 : return *mode_;
2116 :
2117 414 : LogOptions options;
2118 414 : options.from = id_;
2119 414 : options.nbOfCommits = 1;
2120 414 : auto lastMsg = log(options);
2121 414 : if (lastMsg.size() == 0) {
2122 1 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_, id_, EINVALIDMODE, "No initial commit");
2123 1 : throw std::logic_error("Unable to retrieve first commit");
2124 : }
2125 413 : auto commitMsg = lastMsg[0].commit_msg;
2126 :
2127 413 : Json::Value root;
2128 413 : if (!json::parse(commitMsg, root)) {
2129 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_, id_, EINVALIDMODE, "No initial commit");
2130 0 : throw std::logic_error("Unable to retrieve first commit");
2131 : }
2132 413 : if (!root.isMember("mode")) {
2133 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_, id_, EINVALIDMODE, "No mode detected");
2134 0 : throw std::logic_error("No mode detected for initial commit");
2135 : }
2136 413 : int mode = root["mode"].asInt();
2137 :
2138 413 : switch (mode) {
2139 134 : case 0:
2140 134 : mode_ = ConversationMode::ONE_TO_ONE;
2141 134 : break;
2142 6 : case 1:
2143 6 : mode_ = ConversationMode::ADMIN_INVITES_ONLY;
2144 6 : break;
2145 273 : case 2:
2146 273 : mode_ = ConversationMode::INVITES_ONLY;
2147 273 : break;
2148 0 : case 3:
2149 0 : mode_ = ConversationMode::PUBLIC;
2150 0 : break;
2151 0 : default:
2152 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
2153 0 : id_,
2154 : EINVALIDMODE,
2155 : "Incorrect mode detected");
2156 0 : throw std::logic_error("Incorrect mode detected");
2157 : }
2158 413 : return *mode_;
2159 415 : }
2160 :
2161 : std::string
2162 2819 : ConversationRepository::Impl::diffStats(const std::string& newId, const std::string& oldId) const
2163 : {
2164 2819 : if (auto repo = repository()) {
2165 2819 : if (auto d = diff(repo.get(), newId, oldId))
2166 2818 : return diffStats(d);
2167 2819 : }
2168 1 : return {};
2169 : }
2170 :
2171 : GitDiff
2172 2819 : ConversationRepository::Impl::diff(git_repository* repo, const std::string& idNew, const std::string& idOld) const
2173 : {
2174 2819 : if (!repo) {
2175 0 : JAMI_ERROR("Unable to get reference for HEAD");
2176 0 : return {nullptr, git_diff_free};
2177 : }
2178 :
2179 : // Retrieve tree for commit new
2180 : git_oid oid;
2181 2819 : git_commit* commitNew = nullptr;
2182 2819 : if (idNew == "HEAD") {
2183 0 : if (git_reference_name_to_id(&oid, repo, "HEAD") < 0) {
2184 0 : JAMI_ERROR("Unable to get reference for HEAD");
2185 0 : return {nullptr, git_diff_free};
2186 : }
2187 :
2188 0 : if (git_commit_lookup(&commitNew, repo, &oid) < 0) {
2189 0 : JAMI_ERROR("Unable to look up HEAD commit");
2190 0 : return {nullptr, git_diff_free};
2191 : }
2192 : } else {
2193 2819 : if (git_oid_fromstr(&oid, idNew.c_str()) < 0 || git_commit_lookup(&commitNew, repo, &oid) < 0) {
2194 1 : GitCommit new_commit = {commitNew, git_commit_free};
2195 4 : JAMI_WARNING("Failed to look up commit {}", idNew);
2196 1 : return {nullptr, git_diff_free};
2197 1 : }
2198 : }
2199 2818 : GitCommit new_commit = {commitNew, git_commit_free};
2200 :
2201 2818 : git_tree* tNew = nullptr;
2202 2818 : if (git_commit_tree(&tNew, new_commit.get()) < 0) {
2203 0 : JAMI_ERROR("Unable to look up initial tree");
2204 0 : return {nullptr, git_diff_free};
2205 : }
2206 2818 : GitTree treeNew = {tNew, git_tree_free};
2207 :
2208 2818 : git_diff* diff_ptr = nullptr;
2209 2818 : if (idOld.empty()) {
2210 188 : if (git_diff_tree_to_tree(&diff_ptr, repo, nullptr, treeNew.get(), {}) < 0) {
2211 0 : JAMI_ERROR("Unable to get diff to empty repository");
2212 0 : return {nullptr, git_diff_free};
2213 : }
2214 188 : return {diff_ptr, git_diff_free};
2215 : }
2216 :
2217 : // Retrieve tree for commit old
2218 2630 : git_commit* commitOld = nullptr;
2219 2630 : if (git_oid_fromstr(&oid, idOld.c_str()) < 0 || git_commit_lookup(&commitOld, repo, &oid) < 0) {
2220 0 : JAMI_WARNING("Failed to look up commit {}", idOld);
2221 0 : return {nullptr, git_diff_free};
2222 : }
2223 2630 : GitCommit old_commit {commitOld, git_commit_free};
2224 :
2225 2630 : git_tree* tOld = nullptr;
2226 2630 : if (git_commit_tree(&tOld, old_commit.get()) < 0) {
2227 0 : JAMI_ERROR("Unable to look up initial tree");
2228 0 : return {nullptr, git_diff_free};
2229 : }
2230 2630 : GitTree treeOld = {tOld, git_tree_free};
2231 :
2232 : // Calc diff
2233 2629 : if (git_diff_tree_to_tree(&diff_ptr, repo, treeOld.get(), treeNew.get(), {}) < 0) {
2234 0 : JAMI_ERROR("Unable to get diff between {} and {}", idOld, idNew);
2235 0 : return {nullptr, git_diff_free};
2236 : }
2237 2630 : return {diff_ptr, git_diff_free};
2238 2818 : }
2239 :
2240 : std::vector<ConversationCommit>
2241 1769 : ConversationRepository::Impl::behind(const std::string& from) const
2242 : {
2243 : git_oid oid_local, oid_head, oid_remote;
2244 1769 : auto repo = repository();
2245 1769 : if (!repo)
2246 0 : return {};
2247 1769 : if (git_reference_name_to_id(&oid_local, repo.get(), "HEAD") < 0) {
2248 0 : JAMI_ERROR("Unable to get reference for HEAD");
2249 0 : return {};
2250 : }
2251 1769 : oid_head = oid_local;
2252 1769 : std::string head = git_oid_tostr_s(&oid_head);
2253 1769 : if (git_oid_fromstr(&oid_remote, from.c_str()) < 0) {
2254 0 : JAMI_ERROR("Unable to get reference for commit {}", from);
2255 0 : return {};
2256 : }
2257 :
2258 : git_oidarray bases;
2259 1769 : if (git_merge_bases(&bases, repo.get(), &oid_local, &oid_remote) != 0) {
2260 0 : JAMI_ERROR("Unable to get any merge base for commit {} and {}", from, head);
2261 0 : return {};
2262 : }
2263 3488 : for (std::size_t i = 0; i < bases.count; ++i) {
2264 1769 : std::string oid = git_oid_tostr_s(&bases.ids[i]);
2265 1769 : if (oid != head) {
2266 50 : oid_local = bases.ids[i];
2267 50 : break;
2268 : }
2269 1769 : }
2270 1769 : git_oidarray_free(&bases);
2271 1769 : std::string to = git_oid_tostr_s(&oid_local);
2272 1769 : if (to == from)
2273 846 : return {};
2274 1846 : return log(LogOptions {from, to});
2275 1769 : }
2276 :
2277 : void
2278 19743 : ConversationRepository::Impl::forEachCommit(PreConditionCb&& preCondition,
2279 : std::function<void(ConversationCommit&&)>&& emplaceCb,
2280 : PostConditionCb&& postCondition,
2281 : const std::string& from,
2282 : bool logIfNotFound) const
2283 : {
2284 : git_oid oid, oidFrom, oidMerge;
2285 :
2286 : // NOTE! Start from head to get all merge possibilities and correct linearized parent.
2287 19743 : auto repo = repository();
2288 19748 : if (!repo or git_reference_name_to_id(&oid, repo.get(), "HEAD") < 0) {
2289 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get reference for HEAD", accountId_, id_);
2290 0 : return;
2291 : }
2292 :
2293 19749 : if (from != "" && git_oid_fromstr(&oidFrom, from.c_str()) == 0) {
2294 17324 : auto isMergeBase = git_merge_base(&oidMerge, repo.get(), &oid, &oidFrom) == 0
2295 17327 : && git_oid_equal(&oidMerge, &oidFrom);
2296 17327 : if (!isMergeBase) {
2297 : // We're logging a non merged branch, so, take this one instead of HEAD
2298 5957 : oid = oidFrom;
2299 : }
2300 : }
2301 :
2302 19750 : git_revwalk* walker_ptr = nullptr;
2303 19750 : if (git_revwalk_new(&walker_ptr, repo.get()) < 0 || git_revwalk_push(walker_ptr, &oid) < 0) {
2304 3112 : GitRevWalker walker {walker_ptr, git_revwalk_free};
2305 : // This fail can be permitted in the case we check if a commit exists before pulling (so can fail
2306 : // there). Only log if the fail is unwanted.
2307 3112 : if (logIfNotFound)
2308 6696 : JAMI_DEBUG("[Account {}] [Conversation {}] Unable to init revwalker from {}", accountId_, id_, from);
2309 3112 : return;
2310 3112 : }
2311 :
2312 16633 : GitRevWalker walker {walker_ptr, git_revwalk_free};
2313 16630 : git_revwalk_sorting(walker.get(), GIT_SORT_TOPOLOGICAL | GIT_SORT_TIME);
2314 :
2315 41188 : for (auto idx = 0u; !git_revwalk_next(&oid, walker.get()); ++idx) {
2316 39765 : git_commit* commit_ptr = nullptr;
2317 39765 : std::string id = git_oid_tostr_s(&oid);
2318 39760 : if (git_commit_lookup(&commit_ptr, repo.get(), &oid) < 0) {
2319 0 : JAMI_WARNING("[Account {}] [Conversation {}] Failed to look up commit {}", accountId_, id_, id);
2320 0 : break;
2321 : }
2322 39757 : GitCommit commit {commit_ptr, git_commit_free};
2323 :
2324 39753 : const git_signature* sig = git_commit_author(commit.get());
2325 39753 : GitAuthor author;
2326 39744 : author.name = sig->name;
2327 39757 : author.email = sig->email;
2328 :
2329 39763 : std::vector<std::string> parents;
2330 39742 : auto parentsCount = git_commit_parentcount(commit.get());
2331 77665 : for (unsigned int p = 0; p < parentsCount; ++p) {
2332 37923 : std::string parent {};
2333 37912 : const git_oid* pid = git_commit_parent_id(commit.get(), p);
2334 37924 : if (pid) {
2335 37923 : parent = git_oid_tostr_s(pid);
2336 37934 : parents.emplace_back(parent);
2337 : }
2338 37903 : }
2339 :
2340 39742 : auto result = preCondition(id, author, commit);
2341 39744 : if (result == CallbackResult::Skip)
2342 1152 : continue;
2343 38592 : else if (result == CallbackResult::Break)
2344 14917 : break;
2345 :
2346 23675 : ConversationCommit cc;
2347 23675 : cc.id = id;
2348 23691 : cc.commit_msg = git_commit_message(commit.get());
2349 23691 : cc.author = std::move(author);
2350 23682 : cc.parents = std::move(parents);
2351 23677 : git_buf signature = {}, signed_data = {};
2352 23677 : if (git_commit_extract_signature(&signature, &signed_data, repo.get(), &oid, "signature") < 0) {
2353 4 : JAMI_WARNING("[Account {}] [Conversation {}] Unable to extract signature for commit {}",
2354 : accountId_,
2355 : id_,
2356 : id);
2357 : } else {
2358 23694 : cc.signature = base64::decode(std::string_view(signature.ptr, signature.size));
2359 23684 : cc.signed_content = std::vector<uint8_t>(signed_data.ptr, signed_data.ptr + signed_data.size);
2360 : }
2361 23687 : git_buf_dispose(&signature);
2362 23687 : git_buf_dispose(&signed_data);
2363 23694 : cc.timestamp = git_commit_time(commit.get());
2364 :
2365 23690 : auto post = postCondition(id, cc.author, cc);
2366 23689 : emplaceCb(std::move(cc));
2367 :
2368 23684 : if (post)
2369 273 : break;
2370 89037 : }
2371 19742 : }
2372 :
2373 : std::vector<ConversationCommit>
2374 18207 : ConversationRepository::Impl::log(const LogOptions& options) const
2375 : {
2376 18207 : std::vector<ConversationCommit> commits {};
2377 18204 : auto startLogging = options.from == "";
2378 18205 : auto breakLogging = false;
2379 18204 : forEachCommit(
2380 31259 : [&](const auto& id, const auto& author, const auto& commit) {
2381 31259 : if (!commits.empty()) {
2382 : // Set linearized parent
2383 15046 : commits.rbegin()->linearized_parent = id;
2384 : }
2385 31258 : if (options.skipMerge && git_commit_parentcount(commit.get()) > 1) {
2386 0 : return CallbackResult::Skip;
2387 : }
2388 31262 : if ((options.nbOfCommits != 0 && commits.size() == options.nbOfCommits))
2389 13276 : return CallbackResult::Break; // Stop logging
2390 17987 : if (breakLogging)
2391 0 : return CallbackResult::Break; // Stop logging
2392 17987 : if (id == options.to) {
2393 923 : if (options.includeTo)
2394 0 : breakLogging = true; // For the next commit
2395 : else
2396 923 : return CallbackResult::Break; // Stop logging
2397 : }
2398 :
2399 17069 : if (!startLogging && options.from != "" && options.from == id)
2400 13105 : startLogging = true;
2401 17070 : if (!startLogging)
2402 1127 : return CallbackResult::Skip; // Start logging after this one
2403 :
2404 15943 : if (options.fastLog) {
2405 0 : if (options.authorUri != "") {
2406 0 : if (options.authorUri == uriFromDevice(author.email)) {
2407 0 : return CallbackResult::Break; // Found author, stop
2408 : }
2409 : }
2410 : // Used to only count commit
2411 0 : commits.emplace(commits.end(), ConversationCommit {});
2412 0 : return CallbackResult::Skip;
2413 : }
2414 :
2415 15943 : return CallbackResult::Ok; // Continue
2416 : },
2417 15937 : [&](auto&& cc) { commits.emplace(commits.end(), std::forward<decltype(cc)>(cc)); },
2418 15942 : [](auto, auto, auto) { return false; },
2419 18205 : options.from,
2420 18205 : options.logIfNotFound);
2421 36408 : return commits;
2422 0 : }
2423 :
2424 : GitObject
2425 11776 : ConversationRepository::Impl::fileAtTree(const std::string& path, const GitTree& tree) const
2426 : {
2427 11776 : git_object* blob_ptr = nullptr;
2428 11776 : if (git_object_lookup_bypath(&blob_ptr, reinterpret_cast<git_object*>(tree.get()), path.c_str(), GIT_OBJECT_BLOB)
2429 11776 : != 0) {
2430 3228 : return GitObject {nullptr, git_object_free};
2431 : }
2432 8548 : return GitObject {blob_ptr, git_object_free};
2433 : }
2434 :
2435 : GitObject
2436 1721 : ConversationRepository::Impl::memberCertificate(std::string_view memberUri, const GitTree& tree) const
2437 : {
2438 1721 : auto blob = fileAtTree(fmt::format("members/{}.crt", memberUri), tree);
2439 1721 : if (not blob)
2440 890 : blob = fileAtTree(fmt::format("admins/{}.crt", memberUri), tree);
2441 1721 : return blob;
2442 0 : }
2443 :
2444 : GitTree
2445 5079 : ConversationRepository::Impl::treeAtCommit(git_repository* repo, const std::string& commitId) const
2446 : {
2447 : git_oid oid;
2448 5079 : git_commit* commit = nullptr;
2449 5079 : if (git_oid_fromstr(&oid, commitId.c_str()) < 0 || git_commit_lookup(&commit, repo, &oid) < 0) {
2450 0 : JAMI_WARNING("[Account {}] [Conversation {}] Failed to look up commit {}", accountId_, id_, commitId);
2451 0 : return GitTree {nullptr, git_tree_free};
2452 : }
2453 5078 : GitCommit gc = {commit, git_commit_free};
2454 5078 : git_tree* tree = nullptr;
2455 5078 : if (git_commit_tree(&tree, gc.get()) < 0) {
2456 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up initial tree", accountId_, id_);
2457 0 : return GitTree {nullptr, git_tree_free};
2458 : }
2459 5079 : return GitTree {tree, git_tree_free};
2460 5079 : }
2461 :
2462 : std::vector<std::string>
2463 198 : ConversationRepository::Impl::getInitialMembers() const
2464 : {
2465 198 : auto acc = account_.lock();
2466 198 : if (!acc)
2467 0 : return {};
2468 198 : LogOptions options;
2469 198 : options.from = id_;
2470 198 : options.nbOfCommits = 1;
2471 198 : auto firstCommit = log(options);
2472 198 : if (firstCommit.size() == 0) {
2473 0 : return {};
2474 : }
2475 198 : auto commit = firstCommit[0];
2476 :
2477 198 : auto authorDevice = commit.author.email;
2478 198 : auto cert = acc->certStore().getCertificate(authorDevice);
2479 198 : if (!cert || !cert->issuer)
2480 3 : return {};
2481 195 : auto authorId = cert->issuer->getId().toString();
2482 195 : if (mode() == ConversationMode::ONE_TO_ONE) {
2483 195 : Json::Value root;
2484 195 : if (!json::parse(commit.commit_msg, root)) {
2485 0 : return {authorId};
2486 : }
2487 195 : if (root.isMember("invited") && root["invited"].asString() != authorId)
2488 582 : return {authorId, root["invited"].asString()};
2489 195 : }
2490 2 : return {authorId};
2491 198 : }
2492 :
2493 : bool
2494 1 : ConversationRepository::Impl::resolveConflicts(git_index* index, const std::string& other_id)
2495 : {
2496 1 : git_index_conflict_iterator* conflict_iterator = nullptr;
2497 1 : const git_index_entry* ancestor_out = nullptr;
2498 1 : const git_index_entry* our_out = nullptr;
2499 1 : const git_index_entry* their_out = nullptr;
2500 :
2501 1 : git_index_conflict_iterator_new(&conflict_iterator, index);
2502 1 : GitIndexConflictIterator ci {conflict_iterator, git_index_conflict_iterator_free};
2503 :
2504 : git_oid head_commit_id;
2505 1 : auto repo = repository();
2506 1 : if (!repo || git_reference_name_to_id(&head_commit_id, repo.get(), "HEAD") < 0) {
2507 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get reference for HEAD", accountId_, id_);
2508 0 : return false;
2509 : }
2510 1 : auto commit_str = git_oid_tostr_s(&head_commit_id);
2511 1 : if (!commit_str)
2512 0 : return false;
2513 1 : auto useRemote = (other_id > commit_str); // Choose by commit version
2514 :
2515 : // NOTE: for now, only authorize conflicts on "profile.vcf"
2516 1 : std::vector<git_index_entry> new_entries;
2517 2 : while (git_index_conflict_next(&ancestor_out, &our_out, &their_out, ci.get()) != GIT_ITEROVER) {
2518 1 : if (ancestor_out && ancestor_out->path && our_out && our_out->path && their_out && their_out->path) {
2519 1 : if (std::string_view(ancestor_out->path) == "profile.vcf"sv) {
2520 : // Checkout the wanted version. Copy the index_entry.
2521 1 : git_index_entry resolution = useRemote ? *their_out : *our_out;
2522 1 : resolution.flags &= GIT_INDEX_STAGE_NORMAL;
2523 1 : if (!(resolution.flags & GIT_IDXENTRY_VALID))
2524 1 : resolution.flags |= GIT_IDXENTRY_VALID;
2525 : // NOTE: do no git_index_add yet, wait for after full conflict checks
2526 1 : new_entries.push_back(resolution);
2527 1 : continue;
2528 1 : }
2529 0 : JAMI_ERROR("Conflict detected on a file that is not authorized: {}", ancestor_out->path);
2530 0 : return false;
2531 : }
2532 0 : return false;
2533 : }
2534 :
2535 2 : for (auto& entry : new_entries)
2536 1 : git_index_add(index, &entry);
2537 1 : git_index_conflict_cleanup(index);
2538 :
2539 : // Checkout and clean up
2540 : git_checkout_options opt;
2541 1 : git_checkout_options_init(&opt, GIT_CHECKOUT_OPTIONS_VERSION);
2542 1 : opt.checkout_strategy |= GIT_CHECKOUT_FORCE;
2543 1 : opt.checkout_strategy |= GIT_CHECKOUT_ALLOW_CONFLICTS;
2544 1 : if (other_id > commit_str)
2545 0 : opt.checkout_strategy |= GIT_CHECKOUT_USE_THEIRS;
2546 : else
2547 1 : opt.checkout_strategy |= GIT_CHECKOUT_USE_OURS;
2548 :
2549 1 : if (git_checkout_index(repo.get(), index, &opt) < 0) {
2550 0 : const git_error* err = giterr_last();
2551 0 : if (err)
2552 0 : JAMI_ERROR("Unable to checkout index: {}", err->message);
2553 0 : return false;
2554 : }
2555 :
2556 1 : return true;
2557 1 : }
2558 :
2559 : void
2560 1182 : ConversationRepository::Impl::initMembers()
2561 : {
2562 : using std::filesystem::path;
2563 1182 : auto repo = repository();
2564 1182 : if (!repo)
2565 0 : throw std::logic_error("Invalid Git repository");
2566 :
2567 1182 : std::vector<std::string> uris;
2568 1182 : std::lock_guard lk(membersMtx_);
2569 1182 : members_.clear();
2570 1182 : path repoPath = git_repository_workdir(repo.get());
2571 :
2572 0 : static const std::vector<std::pair<MemberRole, path>> paths = {{MemberRole::ADMIN, MemberPath::ADMINS},
2573 0 : {MemberRole::MEMBER, MemberPath::MEMBERS},
2574 0 : {MemberRole::INVITED, MemberPath::INVITED},
2575 0 : {MemberRole::BANNED,
2576 26 : MemberPath::BANNED / MemberPath::MEMBERS},
2577 0 : {MemberRole::BANNED,
2578 1260 : MemberPath::BANNED / MemberPath::INVITED}};
2579 :
2580 1182 : std::error_code ec;
2581 7091 : for (const auto& [role, p] : paths) {
2582 30423 : for (const auto& f : std::filesystem::directory_iterator(repoPath / p, ec)) {
2583 12694 : auto uri = f.path().stem().string();
2584 12696 : if (std::find(uris.begin(), uris.end(), uri) == uris.end()) {
2585 12693 : members_.emplace_back(ConversationMember {uri, role});
2586 12693 : uris.emplace_back(uri);
2587 : }
2588 18603 : }
2589 : }
2590 :
2591 1182 : if (mode() == ConversationMode::ONE_TO_ONE) {
2592 494 : for (const auto& member : getInitialMembers()) {
2593 327 : if (std::find(uris.begin(), uris.end(), member) == uris.end()) {
2594 : // If member is in the initial commit, but not in invited, this means that user left.
2595 0 : members_.emplace_back(ConversationMember {member, MemberRole::LEFT});
2596 : }
2597 167 : }
2598 : }
2599 1181 : saveMembers();
2600 1185 : }
2601 :
2602 : std::optional<std::map<std::string, std::string>>
2603 19965 : ConversationRepository::Impl::convCommitToMap(const ConversationCommit& commit) const
2604 : {
2605 19965 : auto authorId = uriFromDevice(commit.author.email, commit.id);
2606 19972 : if (authorId.empty()) {
2607 4 : JAMI_ERROR("[Account {}] [Conversation {}] Invalid author ID for commit {}", accountId_, id_, commit.id);
2608 1 : return std::nullopt;
2609 : }
2610 19971 : std::string parents;
2611 19971 : auto parentsSize = commit.parents.size();
2612 39209 : for (std::size_t i = 0; i < parentsSize; ++i) {
2613 19238 : parents += commit.parents[i];
2614 19239 : if (i != parentsSize - 1)
2615 43 : parents += ",";
2616 : }
2617 19971 : std::string type {};
2618 19970 : if (parentsSize > 1)
2619 43 : type = "merge";
2620 19970 : std::string body {};
2621 19971 : std::map<std::string, std::string> message;
2622 19970 : if (type.empty()) {
2623 19927 : Json::Value cm;
2624 19926 : if (json::parse(commit.commit_msg, cm)) {
2625 78380 : for (auto const& id : cm.getMemberNames()) {
2626 58442 : if (id == "type") {
2627 19924 : type = cm[id].asString();
2628 19928 : continue;
2629 : }
2630 38526 : message.insert({id, cm[id].asString()});
2631 19920 : }
2632 : }
2633 19923 : }
2634 19971 : if (type.empty()) {
2635 0 : return std::nullopt;
2636 19969 : } else if (type == "application/data-transfer+json") {
2637 : // Avoid the client to do the concatenation
2638 140 : auto tid = message["tid"];
2639 70 : if (not tid.empty()) {
2640 69 : auto extension = fileutils::getFileExtension(message["displayName"]);
2641 69 : if (!extension.empty())
2642 0 : message["fileId"] = fmt::format("{}_{}.{}", commit.id, tid, extension);
2643 : else
2644 138 : message["fileId"] = fmt::format("{}_{}", commit.id, tid);
2645 : } else {
2646 1 : message["fileId"] = "";
2647 : }
2648 70 : }
2649 19969 : message["id"] = commit.id;
2650 19970 : message["parents"] = parents;
2651 19969 : message["linearizedParent"] = commit.linearized_parent;
2652 19968 : message["author"] = authorId;
2653 19968 : message["type"] = type;
2654 19966 : message["timestamp"] = std::to_string(commit.timestamp);
2655 :
2656 19965 : return message;
2657 19969 : }
2658 :
2659 : std::string
2660 2817 : ConversationRepository::Impl::diffStats(const GitDiff& diff) const
2661 : {
2662 2817 : git_diff_stats* stats_ptr = nullptr;
2663 2817 : if (git_diff_get_stats(&stats_ptr, diff.get()) < 0) {
2664 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get diff stats", accountId_, id_);
2665 0 : return {};
2666 : }
2667 2818 : GitDiffStats stats = {stats_ptr, git_diff_stats_free};
2668 :
2669 2818 : git_diff_stats_format_t format = GIT_DIFF_STATS_FULL;
2670 2818 : git_buf statsBuf = {};
2671 2818 : if (git_diff_stats_to_buf(&statsBuf, stats.get(), format, 80) < 0) {
2672 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to format diff stats", accountId_, id_);
2673 0 : return {};
2674 : }
2675 :
2676 2818 : auto res = std::string(statsBuf.ptr, statsBuf.ptr + statsBuf.size);
2677 2817 : git_buf_dispose(&statsBuf);
2678 2818 : return res;
2679 2817 : }
2680 :
2681 : //////////////////////////////////
2682 :
2683 : std::unique_ptr<ConversationRepository>
2684 199 : ConversationRepository::createConversation(const std::shared_ptr<JamiAccount>& account,
2685 : ConversationMode mode,
2686 : const std::string& otherMember)
2687 : {
2688 : // Create temporary directory because we are unable to know the first hash for now
2689 199 : std::uniform_int_distribution<uint64_t> dist;
2690 398 : auto conversationsPath = fileutils::get_data_dir() / account->getAccountID() / "conversations";
2691 199 : dhtnet::fileutils::check_dir(conversationsPath);
2692 398 : auto tmpPath = conversationsPath / std::to_string(dist(account->rand));
2693 199 : if (std::filesystem::is_directory(tmpPath)) {
2694 0 : JAMI_ERROR("{} already exists. Abort create conversations", tmpPath);
2695 0 : return {};
2696 : }
2697 199 : if (!dhtnet::fileutils::recursive_mkdir(tmpPath, 0700)) {
2698 0 : JAMI_ERROR("An error occurred when creating {}. Abort create conversations.", tmpPath);
2699 0 : return {};
2700 : }
2701 199 : auto repo = create_empty_repository(tmpPath.string());
2702 199 : if (!repo) {
2703 0 : return {};
2704 : }
2705 :
2706 : // Add initial files
2707 199 : if (!add_initial_files(repo, account, mode, otherMember)) {
2708 0 : JAMI_ERROR("An error occurred while adding the initial files.");
2709 0 : dhtnet::fileutils::removeAll(tmpPath, true);
2710 0 : return {};
2711 : }
2712 :
2713 : // Commit changes
2714 199 : auto id = initial_commit(repo, account, mode, otherMember);
2715 199 : if (id.empty()) {
2716 0 : JAMI_ERROR("Unable to create initial commit in {}", tmpPath);
2717 0 : dhtnet::fileutils::removeAll(tmpPath, true);
2718 0 : return {};
2719 : }
2720 :
2721 : // Move to wanted directory
2722 199 : auto newPath = conversationsPath / id;
2723 199 : std::error_code ec;
2724 199 : std::filesystem::rename(tmpPath, newPath, ec);
2725 199 : if (ec) {
2726 0 : JAMI_ERROR("Unable to move {} in {}: {}", tmpPath, newPath, ec.message());
2727 0 : dhtnet::fileutils::removeAll(tmpPath, true);
2728 0 : return {};
2729 : }
2730 :
2731 796 : JAMI_LOG("New conversation initialized in {}", newPath);
2732 :
2733 199 : return std::make_unique<ConversationRepository>(account, id);
2734 199 : }
2735 :
2736 : std::pair<std::unique_ptr<ConversationRepository>, std::vector<ConversationCommit>>
2737 191 : ConversationRepository::cloneConversation(const std::shared_ptr<JamiAccount>& account,
2738 : const std::string& deviceId,
2739 : const std::string& conversationId)
2740 : {
2741 : // Verify conversationId is not empty to avoid deleting the entire conversations directory
2742 191 : if (conversationId.empty()) {
2743 0 : JAMI_ERROR("[Account {}] Clone conversation with empty conversationId", account->getAccountID());
2744 0 : return {};
2745 : }
2746 :
2747 382 : auto conversationsPath = fileutils::get_data_dir() / account->getAccountID() / "conversations";
2748 191 : dhtnet::fileutils::check_dir(conversationsPath);
2749 382 : auto path = conversationsPath / conversationId;
2750 0 : auto url = fmt::format("git://{}/{}", deviceId, conversationId);
2751 :
2752 : git_clone_options clone_options;
2753 191 : git_clone_options_init(&clone_options, GIT_CLONE_OPTIONS_VERSION);
2754 191 : git_fetch_options_init(&clone_options.fetch_opts, GIT_FETCH_OPTIONS_VERSION);
2755 191 : clone_options.fetch_opts.callbacks.transfer_progress = [](const git_indexer_progress* stats, void*) {
2756 : // Uncomment to get advancment
2757 : // if (stats->received_objects % 500 == 0 || stats->received_objects == stats->total_objects)
2758 : // JAMI_DEBUG("{}/{} {}kb", stats->received_objects, stats->total_objects,
2759 : // stats->received_bytes/1024);
2760 : // If a pack is more than 256Mb, it's anormal.
2761 0 : if (stats->received_bytes > MAX_FETCH_SIZE) {
2762 0 : JAMI_ERROR("Abort fetching repository, the fetch is too big: {} bytes ({}/{})",
2763 : stats->received_bytes,
2764 : stats->received_objects,
2765 : stats->total_objects);
2766 0 : return -1;
2767 : }
2768 0 : return 0;
2769 191 : };
2770 :
2771 191 : if (std::filesystem::is_directory(path)) {
2772 : // If a crash occurs during a previous clone, just in case
2773 0 : JAMI_WARNING("[Account {}] [Conversation {}] Removing non empty directory {}",
2774 : account->getAccountID(),
2775 : conversationId,
2776 : path);
2777 0 : if (dhtnet::fileutils::removeAll(path, true) != 0)
2778 0 : return {};
2779 : }
2780 :
2781 764 : JAMI_DEBUG("[Account {}] [Conversation {}] Start clone of {:s} to {}",
2782 : account->getAccountID(),
2783 : conversationId,
2784 : url,
2785 : path);
2786 191 : git_repository* rep = nullptr;
2787 191 : git_clone_options opts = GIT_CLONE_OPTIONS_INIT;
2788 191 : opts.fetch_opts.follow_redirects = GIT_REMOTE_REDIRECT_NONE;
2789 191 : if (auto err = git_clone(&rep, url.c_str(), path.string().c_str(), &opts)) {
2790 1 : if (const git_error* gerr = giterr_last())
2791 4 : JAMI_ERROR("[Account {}] [Conversation {}] Error when retrieving remote conversation: {:s} {}",
2792 : account->getAccountID(),
2793 : conversationId,
2794 : gerr->message,
2795 : path);
2796 : else
2797 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unknown error {:d} when retrieving remote conversation",
2798 : account->getAccountID(),
2799 : conversationId,
2800 : err);
2801 1 : return {};
2802 : }
2803 190 : git_repository_free(rep);
2804 190 : auto repo = std::make_unique<ConversationRepository>(account, conversationId);
2805 189 : repo->pinCertificates(true); // need to load certificates to validate unknown members
2806 189 : auto [commitsToValidate, valid] = repo->validClone();
2807 189 : if (!valid) {
2808 4 : repo->erase();
2809 16 : JAMI_ERROR("[Account {}] [Conversation {}] An error occurred while validating remote conversation.",
2810 : account->getAccountID(),
2811 : conversationId);
2812 4 : return {};
2813 : }
2814 740 : JAMI_LOG("[Account {}] [Conversation {}] New conversation cloned in {}",
2815 : account->getAccountID(),
2816 : conversationId,
2817 : path);
2818 185 : return {std::move(repo), std::move(commitsToValidate)};
2819 193 : }
2820 :
2821 : bool
2822 1963 : ConversationRepository::Impl::validCommits(const std::vector<ConversationCommit>& commitsToValidate) const
2823 : {
2824 1963 : auto repo = repository();
2825 :
2826 3861 : for (const auto& commit : commitsToValidate) {
2827 1924 : auto userDevice = commit.author.email;
2828 1924 : auto validUserAtCommit = commit.id;
2829 :
2830 : git_oid oid;
2831 1924 : git_commit* commit_ptr = nullptr;
2832 1924 : if (git_oid_fromstr(&oid, validUserAtCommit.c_str()) < 0
2833 1924 : || git_commit_lookup(&commit_ptr, repo.get(), &oid) < 0) {
2834 0 : JAMI_WARNING("Failed to look up commit {}", validUserAtCommit.c_str());
2835 : }
2836 : struct GitBufDeleter
2837 : {
2838 3848 : void operator()(git_buf* b) const { git_buf_dispose(b); }
2839 : };
2840 1924 : std::unique_ptr<git_buf, GitBufDeleter> sig(new git_buf {});
2841 1924 : std::unique_ptr<git_buf, GitBufDeleter> sig_data(new git_buf {});
2842 :
2843 : // Extract the signature block and signature content from the commit
2844 1924 : int sig_extract_res = git_commit_extract_signature(sig.get(), sig_data.get(), repo.get(), &oid, "signature");
2845 1924 : if (sig_extract_res != 0) {
2846 0 : switch (sig_extract_res) {
2847 0 : case GIT_ERROR_INVALID:
2848 0 : JAMI_ERROR("Error, the commit ID ({}) does not correspond to a commit.", validUserAtCommit);
2849 0 : break;
2850 0 : case GIT_ERROR_OBJECT:
2851 0 : JAMI_ERROR("Error, the commit ID ({}) does not have a signature.", validUserAtCommit);
2852 0 : break;
2853 0 : default:
2854 0 : JAMI_ERROR("An unknown error occurred while extracting signature for commit ID {}.", validUserAtCommit);
2855 0 : break;
2856 : }
2857 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
2858 0 : id_,
2859 : EVALIDFETCH,
2860 : "Malformed commit");
2861 0 : return false;
2862 : }
2863 :
2864 1924 : if (commit.parents.size() == 0) {
2865 187 : if (!checkInitialCommit(userDevice, commit.id, commit.commit_msg)) {
2866 8 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed initial commit {}. Please "
2867 : "ensure that you are using the latest "
2868 : "version of Jami, or that one of your contacts is not performing any "
2869 : "unwanted actions.",
2870 : accountId_,
2871 : id_,
2872 : commit.id);
2873 2 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
2874 2 : id_,
2875 : EVALIDFETCH,
2876 : "Malformed initial commit");
2877 2 : return false;
2878 : }
2879 1737 : } else if (commit.parents.size() == 1) {
2880 1730 : std::string type = {}, editId = {};
2881 1730 : Json::Value cm;
2882 1730 : if (json::parse(commit.commit_msg, cm)) {
2883 1730 : type = cm["type"].asString();
2884 1730 : editId = cm["edit"].asString();
2885 : } else {
2886 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
2887 0 : id_,
2888 : EVALIDFETCH,
2889 : "Malformed commit");
2890 0 : return false;
2891 : }
2892 :
2893 1730 : if (type == "vote") {
2894 : // Check that vote is valid
2895 9 : if (!checkVote(userDevice, commit.id, commit.parents[0])) {
2896 8 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed vote commit {}. Please "
2897 : "ensure that you are using the latest "
2898 : "version of Jami, or that one of your contacts is not performing "
2899 : "any unwanted actions.",
2900 : accountId_,
2901 : id_,
2902 : commit.id);
2903 :
2904 2 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
2905 2 : id_,
2906 : EVALIDFETCH,
2907 : "Malformed vote");
2908 2 : return false;
2909 : }
2910 1721 : } else if (type == "member") {
2911 1554 : std::string action = cm["action"].asString();
2912 1554 : std::string uriMember = cm["uri"].asString();
2913 :
2914 1554 : dht::InfoHash h(uriMember);
2915 1554 : if (not h) {
2916 8 : JAMI_WARNING("[Account {}] [Conversation {}] Commit {} with invalid member URI {}. Please ensure "
2917 : "that you are using the latest version of Jami, or that one of your contacts is not "
2918 : "performing any unwanted actions.",
2919 : accountId_,
2920 : id_,
2921 : commit.id,
2922 : uriMember);
2923 :
2924 2 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
2925 2 : id_,
2926 : EVALIDFETCH,
2927 : "Invalid member URI");
2928 2 : return false;
2929 : }
2930 1552 : if (action == "add") {
2931 754 : if (!checkValidAdd(userDevice, uriMember, commit.id, commit.parents[0])) {
2932 12 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed add commit {}. Please ensure that you "
2933 : "are using the latest version of Jami, or that one of your contacts is not "
2934 : "performing any unwanted actions.",
2935 : accountId_,
2936 : id_,
2937 : commit.id);
2938 :
2939 3 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
2940 3 : id_,
2941 : EVALIDFETCH,
2942 : "Malformed add member commit");
2943 3 : return false;
2944 : }
2945 798 : } else if (action == "join") {
2946 788 : if (!checkValidJoins(userDevice, uriMember, commit.id, commit.parents[0])) {
2947 12 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed joins commit {}. "
2948 : "Please ensure that you are using the latest "
2949 : "version of Jami, or that one of your contacts is not "
2950 : "performing any unwanted actions.",
2951 : accountId_,
2952 : id_,
2953 : commit.id);
2954 :
2955 3 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
2956 3 : id_,
2957 : EVALIDFETCH,
2958 : "Malformed join member commit");
2959 3 : return false;
2960 : }
2961 10 : } else if (action == "remove") {
2962 : // In this case, we remove the user. So if self, the user will not be
2963 : // valid for this commit. Check previous commit
2964 1 : validUserAtCommit = commit.parents[0];
2965 1 : if (!checkValidRemove(userDevice, uriMember, commit.id, commit.parents[0])) {
2966 0 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed removes commit {}. "
2967 : "Please ensure that you are using the latest "
2968 : "version of Jami, or that one of your contacts is not "
2969 : "performing any unwanted actions.",
2970 : accountId_,
2971 : id_,
2972 : commit.id);
2973 :
2974 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
2975 0 : id_,
2976 : EVALIDFETCH,
2977 : "Malformed remove member commit");
2978 0 : return false;
2979 : }
2980 9 : } else if (action == "ban" || action == "unban") {
2981 : // Note device.size() == "member".size()
2982 9 : if (!checkValidVoteResolution(userDevice, uriMember, commit.id, commit.parents[0], action)) {
2983 16 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed removes commit {}. "
2984 : "Please ensure that you are using the latest "
2985 : "version of Jami, or that one of your contacts is not "
2986 : "performing any unwanted actions.",
2987 : accountId_,
2988 : id_,
2989 : commit.id);
2990 :
2991 4 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
2992 4 : id_,
2993 : EVALIDFETCH,
2994 : "Malformed ban member commit");
2995 4 : return false;
2996 : }
2997 : } else {
2998 0 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed member commit {} with "
2999 : "action {}. Please ensure that you are using the latest "
3000 : "version of Jami, or that one of your contacts is not performing "
3001 : "any unwanted actions.",
3002 : accountId_,
3003 : id_,
3004 : commit.id,
3005 : action);
3006 :
3007 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3008 0 : id_,
3009 : EVALIDFETCH,
3010 : "Malformed member commit");
3011 0 : return false;
3012 : }
3013 1733 : } else if (type == "application/update-profile") {
3014 7 : if (!checkValidProfileUpdate(userDevice, commit.id, commit.parents[0])) {
3015 8 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed profile updates commit "
3016 : "{}. Please ensure that you are using the latest "
3017 : "version of Jami, or that one of your contacts is not performing "
3018 : "any unwanted actions.",
3019 : accountId_,
3020 : id_,
3021 : commit.id);
3022 :
3023 2 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3024 2 : id_,
3025 : EVALIDFETCH,
3026 : "Malformed profile updates commit");
3027 2 : return false;
3028 : }
3029 160 : } else if (type == "application/edited-message" || !editId.empty()) {
3030 2 : if (!checkEdit(userDevice, commit)) {
3031 4 : JAMI_ERROR("Commit {:s} malformed", commit.id);
3032 :
3033 1 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3034 1 : id_,
3035 : EVALIDFETCH,
3036 : "Malformed edit commit");
3037 1 : return false;
3038 : }
3039 : } else {
3040 : // Note: accept all mimetype here, as we can have new mimetypes
3041 : // Just avoid to add weird files
3042 : // Check that no weird file is added outside device cert nor removed
3043 158 : if (!checkValidUserDiff(userDevice, commit.id, commit.parents[0])) {
3044 20 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed {} commit {}. Please "
3045 : "ensure that you are using the latest "
3046 : "version of Jami, or that one of your contacts is not performing "
3047 : "any unwanted actions.",
3048 : accountId_,
3049 : id_,
3050 : type,
3051 : commit.id);
3052 :
3053 5 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3054 5 : id_,
3055 : EVALIDFETCH,
3056 : "Malformed commit");
3057 5 : return false;
3058 : }
3059 : }
3060 : // For all commits, check that the user is valid.
3061 : // So, the user certificate MUST be in /members or /admins
3062 : // and device cert MUST be in /devices
3063 1708 : if (!isValidUserAtCommit(userDevice, validUserAtCommit, *sig, *sig_data)) {
3064 4 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed commit {}. Please ensure "
3065 : "that you are using the latest "
3066 : "version of Jami, or that one of your contacts is not performing any "
3067 : "unwanted actions. {}",
3068 : accountId_,
3069 : id_,
3070 : validUserAtCommit,
3071 : commit.commit_msg);
3072 1 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3073 1 : id_,
3074 : EVALIDFETCH,
3075 : "Invalid user");
3076 1 : return false;
3077 : }
3078 1776 : } else {
3079 : // For all commits, check that the user is valid.
3080 : // So, the user certificate MUST be in /members or /admins
3081 : // and device cert MUST be in /devices
3082 7 : if (!isValidUserAtCommit(userDevice, validUserAtCommit, *sig, *sig_data)) {
3083 0 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed commit {}.Please ensure "
3084 : "that you are using the latest "
3085 : "version of Jami, or that one of your contacts is not performing any "
3086 : "unwanted actions. {}",
3087 : accountId_,
3088 : id_,
3089 : validUserAtCommit,
3090 : commit.commit_msg);
3091 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3092 0 : id_,
3093 : EVALIDFETCH,
3094 : "Malformed commit");
3095 0 : return false;
3096 : }
3097 :
3098 7 : if (!checkValidMergeCommit(commit.id, commit.parents)) {
3099 4 : JAMI_WARNING("[Account {}] [Conversation {}] Malformed merge commit {}. Please "
3100 : "ensure that you are using the latest "
3101 : "version of Jami, or that one of your contacts is not performing "
3102 : "any unwanted actions.",
3103 : accountId_,
3104 : id_,
3105 : commit.id);
3106 :
3107 1 : emitSignal<libjami::ConversationSignal::OnConversationError>(accountId_,
3108 1 : id_,
3109 : EVALIDFETCH,
3110 : "Malformed merge commit");
3111 1 : return false;
3112 : }
3113 : }
3114 7591 : JAMI_DEBUG("[Account {}] [Conversation {}] Validate commit {}", accountId_, id_, commit.id);
3115 2002 : }
3116 1937 : return true;
3117 1963 : }
3118 :
3119 : /////////////////////////////////////////////////////////////////////////////////
3120 :
3121 426 : ConversationRepository::ConversationRepository(const std::shared_ptr<JamiAccount>& account, const std::string& id)
3122 426 : : pimpl_ {new Impl {account, id}}
3123 425 : {}
3124 :
3125 425 : ConversationRepository::~ConversationRepository() = default;
3126 :
3127 : const std::string&
3128 17813 : ConversationRepository::id() const
3129 : {
3130 17813 : return pimpl_->id_;
3131 : }
3132 :
3133 : std::string
3134 135 : ConversationRepository::addMember(const std::string& uri)
3135 : {
3136 135 : std::lock_guard lkOp(pimpl_->opMtx_);
3137 135 : pimpl_->resetHard();
3138 135 : auto repo = pimpl_->repository();
3139 135 : if (not repo)
3140 0 : return {};
3141 :
3142 : // First, we need to add the member file to the repository if not present
3143 135 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
3144 :
3145 135 : std::filesystem::path invitedPath = repoPath / MemberPath::INVITED;
3146 135 : if (!dhtnet::fileutils::recursive_mkdir(invitedPath, 0700)) {
3147 0 : JAMI_ERROR("Error when creating {}.", invitedPath);
3148 0 : return {};
3149 : }
3150 135 : std::filesystem::path devicePath = invitedPath / uri;
3151 135 : if (std::filesystem::is_regular_file(devicePath)) {
3152 0 : JAMI_WARNING("Member {} already present!", uri);
3153 0 : return {};
3154 : }
3155 :
3156 135 : std::ofstream file(devicePath, std::ios::trunc | std::ios::binary);
3157 135 : if (!file.is_open()) {
3158 0 : JAMI_ERROR("Unable to write data to {}", devicePath);
3159 0 : return {};
3160 : }
3161 135 : std::string path = "invited/" + uri;
3162 135 : if (!pimpl_->add(path))
3163 0 : return {};
3164 :
3165 : {
3166 135 : std::lock_guard lk(pimpl_->membersMtx_);
3167 135 : pimpl_->members_.emplace_back(ConversationMember {uri, MemberRole::INVITED});
3168 135 : pimpl_->saveMembers();
3169 135 : }
3170 :
3171 135 : Json::Value json;
3172 135 : json["action"] = "add";
3173 135 : json["uri"] = uri;
3174 135 : json["type"] = "member";
3175 270 : return pimpl_->commit(json::toString(json));
3176 135 : }
3177 :
3178 : void
3179 389 : ConversationRepository::onMembersChanged(OnMembersChanged&& cb)
3180 : {
3181 389 : pimpl_->onMembersChanged_ = std::move(cb);
3182 389 : }
3183 :
3184 : std::string
3185 1 : ConversationRepository::amend(const std::string& id, const std::string& msg)
3186 : {
3187 1 : GitSignature sig = pimpl_->signature();
3188 1 : if (!sig)
3189 0 : return {};
3190 :
3191 : git_oid tree_id, commit_id;
3192 1 : git_commit* commit_ptr = nullptr;
3193 1 : auto repo = pimpl_->repository();
3194 1 : if (!repo || git_oid_fromstr(&tree_id, id.c_str()) < 0 || git_commit_lookup(&commit_ptr, repo.get(), &tree_id) < 0) {
3195 0 : GitCommit commit {commit_ptr, git_commit_free};
3196 0 : JAMI_WARNING("Failed to look up commit {}", id);
3197 0 : return {};
3198 0 : }
3199 1 : GitCommit commit {commit_ptr, git_commit_free};
3200 :
3201 1 : if (git_commit_amend(&commit_id, commit.get(), nullptr, sig.get(), sig.get(), nullptr, msg.c_str(), nullptr) < 0) {
3202 0 : const git_error* err = giterr_last();
3203 0 : if (err)
3204 0 : JAMI_ERROR("Unable to amend commit: {}", err->message);
3205 0 : return {};
3206 : }
3207 :
3208 : // Move commit to main branch
3209 1 : git_reference* ref_ptr = nullptr;
3210 1 : if (git_reference_create(&ref_ptr, repo.get(), "refs/heads/main", &commit_id, true, nullptr) < 0) {
3211 0 : const git_error* err = giterr_last();
3212 0 : if (err) {
3213 0 : JAMI_ERROR("Unable to move commit to main: {}", err->message);
3214 0 : emitSignal<libjami::ConversationSignal::OnConversationError>(pimpl_->accountId_,
3215 0 : pimpl_->id_,
3216 : ECOMMIT,
3217 0 : err->message);
3218 : }
3219 0 : return {};
3220 : }
3221 1 : git_reference_free(ref_ptr);
3222 :
3223 1 : auto commit_str = git_oid_tostr_s(&commit_id);
3224 1 : if (commit_str) {
3225 4 : JAMI_DEBUG("Commit {} amended (new ID: {})", id, commit_str);
3226 1 : return commit_str;
3227 : }
3228 0 : return {};
3229 1 : }
3230 :
3231 : bool
3232 1794 : ConversationRepository::fetch(const std::string& remoteDeviceId)
3233 : {
3234 1794 : std::lock_guard lkOp(pimpl_->opMtx_);
3235 1794 : pimpl_->resetHard();
3236 : // Fetch distant repository
3237 1794 : git_remote* remote_ptr = nullptr;
3238 : git_fetch_options fetch_opts;
3239 1794 : git_fetch_options_init(&fetch_opts, GIT_FETCH_OPTIONS_VERSION);
3240 1793 : fetch_opts.follow_redirects = GIT_REMOTE_REDIRECT_NONE;
3241 :
3242 1793 : LogOptions options;
3243 1794 : options.nbOfCommits = 1;
3244 1794 : auto lastMsg = log(options);
3245 1793 : if (lastMsg.size() == 0)
3246 0 : return false;
3247 1793 : auto lastCommit = lastMsg[0].id;
3248 :
3249 : // Assert that repository exists
3250 1794 : auto repo = pimpl_->repository();
3251 1794 : if (!repo)
3252 0 : return false;
3253 1794 : auto res = git_remote_lookup(&remote_ptr, repo.get(), remoteDeviceId.c_str());
3254 1794 : if (res != 0) {
3255 741 : if (res != GIT_ENOTFOUND) {
3256 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up for remote {}",
3257 : pimpl_->accountId_,
3258 : pimpl_->id_,
3259 : remoteDeviceId);
3260 0 : return false;
3261 : }
3262 741 : std::string channelName = fmt::format("git://{}/{}", remoteDeviceId, pimpl_->id_);
3263 741 : if (git_remote_create(&remote_ptr, repo.get(), remoteDeviceId.c_str(), channelName.c_str()) < 0) {
3264 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to create remote for repository",
3265 : pimpl_->accountId_,
3266 : pimpl_->id_);
3267 0 : return false;
3268 : }
3269 741 : }
3270 1794 : GitRemote remote {remote_ptr, git_remote_free};
3271 :
3272 33677 : fetch_opts.callbacks.transfer_progress = [](const git_indexer_progress* stats, void*) {
3273 : // Uncomment to get advancment
3274 : // if (stats->received_objects % 500 == 0 || stats->received_objects == stats->total_objects)
3275 : // JAMI_DEBUG("{}/{} {}kb", stats->received_objects, stats->total_objects,
3276 : // stats->received_bytes/1024);
3277 : // If a pack is more than 256Mb, it's anormal.
3278 31883 : if (stats->received_bytes > MAX_FETCH_SIZE) {
3279 0 : JAMI_ERROR("Abort fetching repository, the fetch is too big: {} bytes ({}/{})",
3280 : stats->received_bytes,
3281 : stats->received_objects,
3282 : stats->total_objects);
3283 0 : return -1;
3284 : }
3285 31883 : return 0;
3286 1794 : };
3287 1794 : if (git_remote_fetch(remote.get(), nullptr, &fetch_opts, "fetch") < 0) {
3288 24 : const git_error* err = giterr_last();
3289 24 : if (err) {
3290 96 : JAMI_WARNING("[Account {}] [Conversation {}] Unable to fetch remote repository: {:s}",
3291 : pimpl_->accountId_,
3292 : pimpl_->id_,
3293 : err->message);
3294 : }
3295 24 : return false;
3296 : }
3297 :
3298 1770 : return true;
3299 1794 : }
3300 :
3301 : std::string
3302 3539 : ConversationRepository::remoteHead(const std::string& remoteDeviceId, const std::string& branch) const
3303 : {
3304 3539 : git_remote* remote_ptr = nullptr;
3305 3539 : auto repo = pimpl_->repository();
3306 3538 : if (!repo || git_remote_lookup(&remote_ptr, repo.get(), remoteDeviceId.c_str()) < 0) {
3307 4 : JAMI_WARNING("No remote found with ID: {}", remoteDeviceId);
3308 1 : return {};
3309 : }
3310 3538 : GitRemote remote {remote_ptr, git_remote_free};
3311 :
3312 3538 : git_reference* head_ref_ptr = nullptr;
3313 7075 : std::string remoteHead = "refs/remotes/" + remoteDeviceId + "/" + branch;
3314 : git_oid commit_id;
3315 3538 : if (git_reference_name_to_id(&commit_id, repo.get(), remoteHead.c_str()) < 0) {
3316 0 : const git_error* err = giterr_last();
3317 0 : if (err)
3318 0 : JAMI_ERROR("failed to look up {} ref: {}", remoteHead, err->message);
3319 0 : return {};
3320 : }
3321 3538 : GitReference head_ref {head_ref_ptr, git_reference_free};
3322 :
3323 3538 : auto commit_str = git_oid_tostr_s(&commit_id);
3324 3538 : if (!commit_str)
3325 0 : return {};
3326 3538 : return commit_str;
3327 3539 : }
3328 :
3329 : void
3330 349 : ConversationRepository::Impl::addUserDevice()
3331 : {
3332 349 : auto account = account_.lock();
3333 349 : if (!account)
3334 0 : return;
3335 :
3336 : // First, we need to add device file to the repository if not present
3337 349 : auto repo = repository();
3338 349 : if (!repo)
3339 0 : return;
3340 : // NOTE: libgit2 uses / for files
3341 349 : std::string path = fmt::format("devices/{}.crt", deviceId_);
3342 349 : std::filesystem::path devicePath = git_repository_workdir(repo.get()) + path;
3343 349 : if (!std::filesystem::is_regular_file(devicePath)) {
3344 161 : std::ofstream file(devicePath, std::ios::trunc | std::ios::binary);
3345 161 : if (!file.is_open()) {
3346 0 : JAMI_ERROR("Unable to write data to {}", devicePath);
3347 0 : return;
3348 : }
3349 161 : auto cert = account->identity().second;
3350 161 : auto deviceCert = cert->toString(false);
3351 161 : file << deviceCert;
3352 161 : file.close();
3353 :
3354 161 : if (!add(path))
3355 0 : JAMI_WARNING("Unable to add file {}", devicePath);
3356 161 : }
3357 349 : }
3358 :
3359 : void
3360 3226 : ConversationRepository::Impl::resetHard()
3361 : {
3362 : #ifdef LIBJAMI_TEST
3363 3226 : if (DISABLE_RESET)
3364 456 : return;
3365 : #endif
3366 2770 : auto repo = repository();
3367 2770 : if (!repo)
3368 0 : return;
3369 2770 : git_object* head_commit_obj = nullptr;
3370 2770 : auto error = git_revparse_single(&head_commit_obj, repo.get(), "HEAD");
3371 2770 : if (error < 0) {
3372 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get HEAD commit: {}", accountId_, id_, error);
3373 0 : return;
3374 : }
3375 2770 : GitObject target {head_commit_obj, git_object_free};
3376 2770 : git_reset(repo.get(), head_commit_obj, GIT_RESET_HARD, nullptr);
3377 2769 : }
3378 :
3379 : std::string
3380 159 : ConversationRepository::commitMessage(const std::string& msg, bool verifyDevice)
3381 : {
3382 159 : std::lock_guard lkOp(pimpl_->opMtx_);
3383 159 : pimpl_->resetHard();
3384 318 : return pimpl_->commitMessage(msg, verifyDevice);
3385 159 : }
3386 :
3387 : std::string
3388 349 : ConversationRepository::Impl::commitMessage(const std::string& msg, bool verifyDevice)
3389 : {
3390 349 : addUserDevice();
3391 349 : return commit(msg, verifyDevice);
3392 : }
3393 :
3394 : std::vector<std::string>
3395 0 : ConversationRepository::commitMessages(const std::vector<std::string>& msgs)
3396 : {
3397 0 : pimpl_->addUserDevice();
3398 0 : std::vector<std::string> ret;
3399 0 : ret.reserve(msgs.size());
3400 0 : for (const auto& msg : msgs)
3401 0 : ret.emplace_back(pimpl_->commit(msg));
3402 0 : return ret;
3403 0 : }
3404 :
3405 : std::vector<ConversationCommit>
3406 1994 : ConversationRepository::log(const LogOptions& options) const
3407 : {
3408 1994 : return pimpl_->log(options);
3409 : }
3410 :
3411 : void
3412 1542 : ConversationRepository::log(PreConditionCb&& preCondition,
3413 : std::function<void(ConversationCommit&&)>&& emplaceCb,
3414 : PostConditionCb&& postCondition,
3415 : const std::string& from,
3416 : bool logIfNotFound) const
3417 : {
3418 1542 : pimpl_->forEachCommit(std::move(preCondition), std::move(emplaceCb), std::move(postCondition), from, logIfNotFound);
3419 1542 : }
3420 :
3421 : std::optional<ConversationCommit>
3422 14675 : ConversationRepository::getCommit(const std::string& commitId, bool logIfNotFound) const
3423 : {
3424 14675 : return pimpl_->getCommit(commitId, logIfNotFound);
3425 : }
3426 :
3427 : std::pair<bool, std::string>
3428 909 : ConversationRepository::merge(const std::string& merge_id, bool force)
3429 : {
3430 909 : std::lock_guard lkOp(pimpl_->opMtx_);
3431 909 : pimpl_->resetHard();
3432 : // First, the repository must be in a clean state
3433 909 : auto repo = pimpl_->repository();
3434 909 : if (!repo) {
3435 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to merge without repo", pimpl_->accountId_, pimpl_->id_);
3436 0 : return {false, ""};
3437 : }
3438 909 : int state = git_repository_state(repo.get());
3439 909 : if (state != GIT_REPOSITORY_STATE_NONE) {
3440 0 : pimpl_->resetHard();
3441 0 : int state = git_repository_state(repo.get());
3442 0 : if (state != GIT_REPOSITORY_STATE_NONE) {
3443 0 : JAMI_ERROR("[Account {}] [Conversation {}] Merge operation aborted: repository is in unexpected state {}",
3444 : pimpl_->accountId_,
3445 : pimpl_->id_,
3446 : state);
3447 0 : return {false, ""};
3448 : }
3449 : }
3450 : // Checkout main (to do a `git_merge branch`)
3451 909 : if (git_repository_set_head(repo.get(), "refs/heads/main") < 0) {
3452 0 : JAMI_ERROR("[Account {}] [Conversation {}] Merge operation aborted: unable to checkout main branch",
3453 : pimpl_->accountId_,
3454 : pimpl_->id_);
3455 0 : return {false, ""};
3456 : }
3457 :
3458 : // Then check that merge_id exists
3459 : git_oid commit_id;
3460 909 : if (git_oid_fromstr(&commit_id, merge_id.c_str()) < 0) {
3461 0 : JAMI_ERROR("[Account {}] [Conversation {}] Merge operation aborted: unable to look up commit {}",
3462 : pimpl_->accountId_,
3463 : pimpl_->id_,
3464 : merge_id);
3465 0 : return {false, ""};
3466 : }
3467 909 : git_annotated_commit* annotated_ptr = nullptr;
3468 909 : if (git_annotated_commit_lookup(&annotated_ptr, repo.get(), &commit_id) < 0) {
3469 0 : JAMI_ERROR("[Account {}] [Conversation {}] Merge operation aborted: unable to look up commit {}",
3470 : pimpl_->accountId_,
3471 : pimpl_->id_,
3472 : merge_id);
3473 0 : return {false, ""};
3474 : }
3475 909 : GitAnnotatedCommit annotated {annotated_ptr, git_annotated_commit_free};
3476 :
3477 : // Now, we can analyze the type of merge required
3478 : git_merge_analysis_t analysis;
3479 : git_merge_preference_t preference;
3480 909 : const git_annotated_commit* const_annotated = annotated.get();
3481 909 : if (git_merge_analysis(&analysis, &preference, repo.get(), &const_annotated, 1) < 0) {
3482 0 : JAMI_ERROR("[Account {}] [Conversation {}] Merge operation aborted: repository analysis failed",
3483 : pimpl_->accountId_,
3484 : pimpl_->id_);
3485 0 : return {false, ""};
3486 : }
3487 :
3488 : // Handle easy merges
3489 909 : if (analysis & GIT_MERGE_ANALYSIS_UP_TO_DATE) {
3490 0 : JAMI_LOG("Already up-to-date");
3491 0 : return {true, ""};
3492 909 : } else if (analysis & GIT_MERGE_ANALYSIS_UNBORN
3493 909 : || (analysis & GIT_MERGE_ANALYSIS_FASTFORWARD && !(preference & GIT_MERGE_PREFERENCE_NO_FASTFORWARD))) {
3494 895 : if (analysis & GIT_MERGE_ANALYSIS_UNBORN)
3495 0 : JAMI_LOG("[Account {}] [Conversation {}] Merge analysis result: Unborn", pimpl_->accountId_, pimpl_->id_);
3496 : else
3497 3580 : JAMI_LOG("[Account {}] [Conversation {}] Merge analysis result: Fast-forward",
3498 : pimpl_->accountId_,
3499 : pimpl_->id_);
3500 895 : const auto* target_oid = git_annotated_commit_id(annotated.get());
3501 :
3502 895 : if (!pimpl_->mergeFastforward(target_oid, (analysis & GIT_MERGE_ANALYSIS_UNBORN))) {
3503 0 : const git_error* err = giterr_last();
3504 0 : if (err)
3505 0 : JAMI_ERROR("[Account {}] [Conversation {}] Fast forward merge failed: {}",
3506 : pimpl_->accountId_,
3507 : pimpl_->id_,
3508 : err->message);
3509 0 : return {false, ""};
3510 : }
3511 895 : return {true, ""}; // fast forward so no commit generated;
3512 : }
3513 :
3514 14 : if (!pimpl_->validateDevice() && !force) {
3515 0 : JAMI_ERROR("[Account {}] [Conversation {}] Invalid device. Not migrated?", pimpl_->accountId_, pimpl_->id_);
3516 0 : return {false, ""};
3517 : }
3518 :
3519 : // Else we want to check for conflicts
3520 : git_oid head_commit_id;
3521 14 : if (git_reference_name_to_id(&head_commit_id, repo.get(), "HEAD") < 0) {
3522 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to get reference for HEAD", pimpl_->accountId_, pimpl_->id_);
3523 0 : return {false, ""};
3524 : }
3525 :
3526 14 : git_commit* head_ptr = nullptr;
3527 14 : if (git_commit_lookup(&head_ptr, repo.get(), &head_commit_id) < 0) {
3528 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up HEAD commit", pimpl_->accountId_, pimpl_->id_);
3529 0 : return {false, ""};
3530 : }
3531 14 : GitCommit head_commit {head_ptr, git_commit_free};
3532 :
3533 14 : git_commit* other__ptr = nullptr;
3534 14 : if (git_commit_lookup(&other__ptr, repo.get(), &commit_id) < 0) {
3535 0 : JAMI_ERROR("[Account {}] [Conversation {}] Unable to look up HEAD commit", pimpl_->accountId_, pimpl_->id_);
3536 0 : return {false, ""};
3537 : }
3538 14 : GitCommit other_commit {other__ptr, git_commit_free};
3539 :
3540 : git_merge_options merge_opts;
3541 14 : git_merge_options_init(&merge_opts, GIT_MERGE_OPTIONS_VERSION);
3542 14 : merge_opts.recursion_limit = 2;
3543 14 : git_index* index_ptr = nullptr;
3544 14 : if (git_merge_commits(&index_ptr, repo.get(), head_commit.get(), other_commit.get(), &merge_opts) < 0) {
3545 0 : const git_error* err = giterr_last();
3546 0 : if (err)
3547 0 : JAMI_ERROR("[Account {}] [Conversation {}] Git merge failed: {}",
3548 : pimpl_->accountId_,
3549 : pimpl_->id_,
3550 : err->message);
3551 0 : return {false, ""};
3552 : }
3553 14 : GitIndex index {index_ptr, git_index_free};
3554 14 : if (git_index_has_conflicts(index.get())) {
3555 4 : JAMI_LOG("Some conflicts were detected during the merge operations. Resolution phase.");
3556 1 : if (!pimpl_->resolveConflicts(index.get(), merge_id) or !git_add_all(repo.get())) {
3557 0 : JAMI_ERROR("Merge operation aborted; Unable to automatically resolve conflicts");
3558 0 : return {false, ""};
3559 : }
3560 : }
3561 14 : auto result = pimpl_->createMergeCommit(index.get(), merge_id);
3562 56 : JAMI_LOG("Merge done between {} and main", merge_id);
3563 :
3564 14 : return {!result.empty(), result};
3565 909 : }
3566 :
3567 : std::string
3568 15 : ConversationRepository::mergeBase(const std::string& from, const std::string& to) const
3569 : {
3570 15 : if (auto repo = pimpl_->repository()) {
3571 : git_oid oid, oidFrom, oidMerge;
3572 15 : git_oid_fromstr(&oidFrom, from.c_str());
3573 15 : git_oid_fromstr(&oid, to.c_str());
3574 15 : git_merge_base(&oidMerge, repo.get(), &oid, &oidFrom);
3575 15 : if (auto* commit_str = git_oid_tostr_s(&oidMerge))
3576 15 : return commit_str;
3577 15 : }
3578 0 : return {};
3579 : }
3580 :
3581 : std::string
3582 908 : ConversationRepository::diffStats(const std::string& newId, const std::string& oldId) const
3583 : {
3584 908 : return pimpl_->diffStats(newId, oldId);
3585 : }
3586 :
3587 : std::vector<std::string>
3588 2819 : ConversationRepository::changedFiles(std::string_view diffStats)
3589 : {
3590 2819 : static const std::regex re(" +\\| +[0-9]+.*");
3591 2819 : std::vector<std::string> changedFiles;
3592 2819 : std::string_view line;
3593 10941 : while (jami::getline(diffStats, line)) {
3594 8122 : std::svmatch match;
3595 8122 : if (!std::regex_search(line, match, re) && match.size() == 0)
3596 2818 : continue;
3597 5304 : changedFiles.emplace_back(std::regex_replace(std::string {line}, re, "").substr(1));
3598 8122 : }
3599 5638 : return changedFiles;
3600 0 : }
3601 :
3602 : std::string
3603 186 : ConversationRepository::join()
3604 : {
3605 186 : std::lock_guard lkOp(pimpl_->opMtx_);
3606 186 : pimpl_->resetHard();
3607 : // Check that not already member
3608 186 : auto repo = pimpl_->repository();
3609 186 : if (!repo)
3610 0 : return {};
3611 186 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
3612 186 : auto account = pimpl_->account_.lock();
3613 186 : if (!account)
3614 0 : return {};
3615 186 : auto cert = account->identity().second;
3616 186 : auto parentCert = cert->issuer;
3617 186 : if (!parentCert) {
3618 0 : JAMI_ERROR("Parent cert is null!");
3619 0 : return {};
3620 : }
3621 186 : auto uri = parentCert->getId().toString();
3622 186 : auto membersPath = repoPath / MemberPath::MEMBERS;
3623 372 : auto memberFile = membersPath / (uri + ".crt");
3624 372 : auto adminsPath = repoPath / MemberPath::ADMINS / (uri + ".crt");
3625 186 : if (std::filesystem::is_regular_file(memberFile) or std::filesystem::is_regular_file(adminsPath)) {
3626 : // Already member, nothing to commit
3627 30 : return {};
3628 : }
3629 : // Remove invited/uri.crt
3630 156 : auto invitedPath = repoPath / MemberPath::INVITED;
3631 156 : dhtnet::fileutils::remove(fileutils::getFullPath(invitedPath, uri));
3632 : // Add members/uri.crt
3633 156 : if (!dhtnet::fileutils::recursive_mkdir(membersPath, 0700)) {
3634 0 : JAMI_ERROR("Error when creating {}. Abort create conversations", membersPath);
3635 0 : return {};
3636 : }
3637 156 : std::ofstream file(memberFile, std::ios::trunc | std::ios::binary);
3638 156 : if (!file.is_open()) {
3639 0 : JAMI_ERROR("Unable to write data to {}", memberFile);
3640 0 : return {};
3641 : }
3642 156 : file << parentCert->toString(true);
3643 156 : file.close();
3644 : // git add -A
3645 156 : if (!git_add_all(repo.get())) {
3646 0 : return {};
3647 : }
3648 156 : Json::Value json;
3649 156 : json["action"] = "join";
3650 156 : json["uri"] = uri;
3651 156 : json["type"] = "member";
3652 :
3653 : {
3654 156 : std::lock_guard lk(pimpl_->membersMtx_);
3655 156 : auto updated = false;
3656 :
3657 629 : for (auto& member : pimpl_->members_) {
3658 629 : if (member.uri == uri) {
3659 156 : updated = true;
3660 156 : member.role = MemberRole::MEMBER;
3661 156 : break;
3662 : }
3663 : }
3664 156 : if (!updated)
3665 0 : pimpl_->members_.emplace_back(ConversationMember {uri, MemberRole::MEMBER});
3666 156 : pimpl_->saveMembers();
3667 156 : }
3668 :
3669 312 : return pimpl_->commitMessage(json::toString(json));
3670 186 : }
3671 :
3672 : std::string
3673 7 : ConversationRepository::leave()
3674 : {
3675 7 : std::lock_guard lkOp(pimpl_->opMtx_);
3676 7 : pimpl_->resetHard();
3677 : // TODO: simplify
3678 7 : auto account = pimpl_->account_.lock();
3679 7 : auto repo = pimpl_->repository();
3680 7 : if (!account || !repo)
3681 0 : return {};
3682 :
3683 : // Remove related files
3684 7 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
3685 7 : auto crt = fmt::format("{}.crt", pimpl_->userId_);
3686 14 : auto adminFile = repoPath / MemberPath::ADMINS / crt;
3687 14 : auto memberFile = repoPath / MemberPath::MEMBERS / crt;
3688 7 : auto crlsPath = repoPath / "CRLs";
3689 7 : std::error_code ec;
3690 :
3691 7 : if (std::filesystem::is_regular_file(adminFile, ec)) {
3692 7 : std::filesystem::remove(adminFile, ec);
3693 : }
3694 :
3695 7 : if (std::filesystem::is_regular_file(memberFile, ec)) {
3696 0 : std::filesystem::remove(memberFile, ec);
3697 : }
3698 :
3699 : // /CRLs
3700 7 : for (const auto& crl : account->identity().second->getRevocationLists()) {
3701 0 : if (!crl)
3702 0 : continue;
3703 0 : auto crlPath = crlsPath / pimpl_->deviceId_ / fmt::format("{}.crl", dht::toHex(crl->getNumber()));
3704 0 : if (std::filesystem::is_regular_file(crlPath, ec)) {
3705 0 : std::filesystem::remove(crlPath, ec);
3706 : }
3707 7 : }
3708 :
3709 : // Devices
3710 29 : for (const auto& certificate : std::filesystem::directory_iterator(repoPath / "devices", ec)) {
3711 8 : if (certificate.is_regular_file(ec)) {
3712 : try {
3713 16 : crypto::Certificate cert(fileutils::loadFile(certificate.path()));
3714 8 : if (cert.getIssuerUID() == pimpl_->userId_)
3715 7 : std::filesystem::remove(certificate.path(), ec);
3716 8 : } catch (...) {
3717 0 : continue;
3718 0 : }
3719 : }
3720 7 : }
3721 :
3722 7 : if (!git_add_all(repo.get())) {
3723 0 : return {};
3724 : }
3725 :
3726 7 : Json::Value json;
3727 7 : json["action"] = "remove";
3728 7 : json["uri"] = pimpl_->userId_;
3729 7 : json["type"] = "member";
3730 :
3731 : {
3732 7 : std::lock_guard lk(pimpl_->membersMtx_);
3733 21 : pimpl_->members_.erase(std::remove_if(pimpl_->members_.begin(),
3734 7 : pimpl_->members_.end(),
3735 9 : [&](auto& member) { return member.uri == pimpl_->userId_; }),
3736 7 : pimpl_->members_.end());
3737 7 : pimpl_->saveMembers();
3738 7 : }
3739 :
3740 14 : return pimpl_->commit(json::toString(json), false);
3741 7 : }
3742 :
3743 : void
3744 25 : ConversationRepository::erase()
3745 : {
3746 : // First, we need to add the member file to the repository if not present
3747 25 : if (auto repo = pimpl_->repository()) {
3748 25 : std::string repoPath = git_repository_workdir(repo.get());
3749 100 : JAMI_LOG("Erasing {}", repoPath);
3750 25 : dhtnet::fileutils::removeAll(repoPath, true);
3751 50 : }
3752 25 : }
3753 :
3754 : ConversationMode
3755 5093 : ConversationRepository::mode() const
3756 : {
3757 5093 : return pimpl_->mode();
3758 : }
3759 :
3760 : std::string
3761 13 : ConversationRepository::voteKick(const std::string& uri, const std::string& type)
3762 : {
3763 13 : std::lock_guard lkOp(pimpl_->opMtx_);
3764 13 : pimpl_->resetHard();
3765 13 : auto repo = pimpl_->repository();
3766 13 : auto account = pimpl_->account_.lock();
3767 13 : if (!account || !repo)
3768 0 : return {};
3769 13 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
3770 13 : auto cert = account->identity().second;
3771 13 : if (!cert || !cert->issuer)
3772 0 : return {};
3773 13 : auto adminUri = cert->issuer->getId().toString();
3774 13 : if (adminUri == uri) {
3775 4 : JAMI_WARNING("Admin tried to ban theirself");
3776 1 : return {};
3777 : }
3778 :
3779 24 : auto oldFile = repoPath / type / (uri + (type != "invited" ? ".crt" : ""));
3780 12 : if (!std::filesystem::is_regular_file(oldFile)) {
3781 0 : JAMI_WARNING("Didn't found file for {} with type {}", uri, type);
3782 0 : return {};
3783 : }
3784 :
3785 0 : auto relativeVotePath = fmt::format("votes/ban/{}/{}", type, uri);
3786 12 : auto voteDirectory = repoPath / relativeVotePath;
3787 12 : if (!dhtnet::fileutils::recursive_mkdir(voteDirectory, 0700)) {
3788 0 : JAMI_ERROR("Error when creating {}. Abort vote", voteDirectory);
3789 0 : return {};
3790 : }
3791 12 : auto votePath = fileutils::getFullPath(voteDirectory, adminUri);
3792 12 : std::ofstream voteFile(votePath, std::ios::trunc | std::ios::binary);
3793 12 : if (!voteFile.is_open()) {
3794 0 : JAMI_ERROR("Unable to write data to {}", votePath);
3795 0 : return {};
3796 : }
3797 12 : voteFile.close();
3798 :
3799 0 : auto toAdd = fmt::format("{}/{}", relativeVotePath, adminUri);
3800 12 : if (!pimpl_->add(toAdd))
3801 0 : return {};
3802 :
3803 12 : Json::Value json;
3804 12 : json["uri"] = uri;
3805 12 : json["type"] = "vote";
3806 24 : return pimpl_->commitMessage(json::toString(json));
3807 13 : }
3808 :
3809 : std::string
3810 1 : ConversationRepository::voteUnban(const std::string& uri, const std::string_view type)
3811 : {
3812 1 : std::lock_guard lkOp(pimpl_->opMtx_);
3813 1 : pimpl_->resetHard();
3814 1 : auto repo = pimpl_->repository();
3815 1 : auto account = pimpl_->account_.lock();
3816 1 : if (!account || !repo)
3817 0 : return {};
3818 1 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
3819 1 : auto cert = account->identity().second;
3820 1 : if (!cert || !cert->issuer)
3821 0 : return {};
3822 1 : auto adminUri = cert->issuer->getId().toString();
3823 :
3824 0 : auto relativeVotePath = fmt::format("votes/unban/{}/{}", type, uri);
3825 1 : auto voteDirectory = repoPath / relativeVotePath;
3826 1 : if (!dhtnet::fileutils::recursive_mkdir(voteDirectory, 0700)) {
3827 0 : JAMI_ERROR("Error when creating {}. Abort vote", voteDirectory);
3828 0 : return {};
3829 : }
3830 1 : auto votePath = voteDirectory / adminUri;
3831 1 : std::ofstream voteFile(votePath, std::ios::trunc | std::ios::binary);
3832 1 : if (!voteFile.is_open()) {
3833 0 : JAMI_ERROR("Unable to write data to {}", votePath);
3834 0 : return {};
3835 : }
3836 1 : voteFile.close();
3837 :
3838 2 : auto toAdd = fileutils::getFullPath(relativeVotePath, adminUri).string();
3839 1 : if (!pimpl_->add(toAdd.c_str()))
3840 0 : return {};
3841 :
3842 1 : Json::Value json;
3843 1 : json["uri"] = uri;
3844 1 : json["type"] = "vote";
3845 2 : return pimpl_->commitMessage(json::toString(json));
3846 1 : }
3847 :
3848 : bool
3849 12 : ConversationRepository::Impl::resolveBan(const std::string_view type, const std::string& uri)
3850 : {
3851 12 : auto repo = repository();
3852 12 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
3853 12 : auto bannedPath = repoPath / "banned";
3854 12 : auto devicesPath = repoPath / "devices";
3855 : // Move from device or members file into banned
3856 12 : auto crtStr = uri + (type != "invited" ? ".crt" : "");
3857 24 : auto originFilePath = repoPath / type / crtStr;
3858 :
3859 12 : auto destPath = bannedPath / type;
3860 12 : auto destFilePath = destPath / crtStr;
3861 12 : if (!dhtnet::fileutils::recursive_mkdir(destPath, 0700)) {
3862 0 : JAMI_ERROR("An error occurred while creating the {} directory. Abort resolving vote.", destPath);
3863 0 : return false;
3864 : }
3865 :
3866 12 : std::error_code ec;
3867 12 : std::filesystem::rename(originFilePath, destFilePath, ec);
3868 12 : if (ec) {
3869 0 : JAMI_ERROR("An error occurred while moving the {} origin file path to the {} destination "
3870 : "file path. Abort resolving vote.",
3871 : originFilePath,
3872 : destFilePath);
3873 0 : return false;
3874 : }
3875 :
3876 : // If members, remove related devices and mark as banned
3877 12 : if (type != "devices") {
3878 12 : std::error_code ec;
3879 64 : for (const auto& certificate : std::filesystem::directory_iterator(devicesPath, ec)) {
3880 28 : auto certPath = certificate.path();
3881 : try {
3882 56 : crypto::Certificate cert(fileutils::loadFile(certPath));
3883 28 : if (auto issuer = cert.issuer)
3884 0 : if (issuer->getPublicKey().getId().to_view() == uri)
3885 28 : dhtnet::fileutils::remove(certPath, true);
3886 28 : } catch (...) {
3887 0 : continue;
3888 0 : }
3889 40 : }
3890 12 : std::lock_guard lk(membersMtx_);
3891 12 : auto updated = false;
3892 :
3893 28 : for (auto& member : members_) {
3894 28 : if (member.uri == uri) {
3895 12 : updated = true;
3896 12 : member.role = MemberRole::BANNED;
3897 12 : break;
3898 : }
3899 : }
3900 12 : if (!updated)
3901 0 : members_.emplace_back(ConversationMember {uri, MemberRole::BANNED});
3902 12 : saveMembers();
3903 12 : }
3904 12 : return true;
3905 12 : }
3906 :
3907 : bool
3908 1 : ConversationRepository::Impl::resolveUnban(const std::string_view type, const std::string& uri)
3909 : {
3910 1 : auto repo = repository();
3911 1 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
3912 1 : auto bannedPath = repoPath / "banned";
3913 1 : auto crtStr = uri + (type != "invited" ? ".crt" : "");
3914 2 : auto originFilePath = bannedPath / type / crtStr;
3915 1 : auto destPath = repoPath / type;
3916 1 : auto destFilePath = destPath / crtStr;
3917 1 : if (!dhtnet::fileutils::recursive_mkdir(destPath, 0700)) {
3918 0 : JAMI_ERROR("An error occurred while creating the {} destination path. Abort resolving vote.", destPath);
3919 0 : return false;
3920 : }
3921 1 : std::error_code ec;
3922 1 : std::filesystem::rename(originFilePath, destFilePath, ec);
3923 1 : if (ec) {
3924 0 : JAMI_ERROR("Error when moving {} to {}. Abort resolving vote.", originFilePath, destFilePath);
3925 0 : return false;
3926 : }
3927 :
3928 1 : std::lock_guard lk(membersMtx_);
3929 1 : auto updated = false;
3930 :
3931 1 : auto role = MemberRole::MEMBER;
3932 1 : if (type == "invited")
3933 0 : role = MemberRole::INVITED;
3934 1 : else if (type == "admins")
3935 0 : role = MemberRole::ADMIN;
3936 :
3937 2 : for (auto& member : members_) {
3938 2 : if (member.uri == uri) {
3939 1 : updated = true;
3940 1 : member.role = role;
3941 1 : break;
3942 : }
3943 : }
3944 1 : if (!updated)
3945 0 : members_.emplace_back(ConversationMember {uri, role});
3946 1 : saveMembers();
3947 1 : return true;
3948 1 : }
3949 :
3950 : std::string
3951 13 : ConversationRepository::resolveVote(const std::string& uri, const std::string_view type, const std::string& voteType)
3952 : {
3953 13 : std::lock_guard lkOp(pimpl_->opMtx_);
3954 13 : pimpl_->resetHard();
3955 : // Count ratio admin/votes
3956 13 : auto nbAdmins = 0, nbVotes = 0;
3957 : // For each admin, check if voted
3958 13 : auto repo = pimpl_->repository();
3959 13 : if (!repo)
3960 0 : return {};
3961 13 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
3962 13 : auto adminsPath = repoPath / MemberPath::ADMINS;
3963 26 : auto voteDirectory = repoPath / "votes" / voteType / type / uri;
3964 26 : for (const auto& certificate : dhtnet::fileutils::readDirectory(adminsPath)) {
3965 13 : if (certificate.find(".crt") == std::string::npos) {
3966 0 : JAMI_WARNING("Incorrect file found: {}/{}", adminsPath, certificate);
3967 0 : continue;
3968 0 : }
3969 26 : auto adminUri = certificate.substr(0, certificate.size() - std::string(".crt").size());
3970 13 : nbAdmins += 1;
3971 13 : if (std::filesystem::is_regular_file(fileutils::getFullPath(voteDirectory, adminUri)))
3972 13 : nbVotes += 1;
3973 26 : }
3974 :
3975 13 : if (nbAdmins > 0 && (static_cast<double>(nbVotes) / static_cast<double>(nbAdmins)) > .5) {
3976 52 : JAMI_WARNING("More than half of the admins voted to ban {}, applying the ban.", uri);
3977 :
3978 : // Remove vote directory
3979 13 : dhtnet::fileutils::removeAll(voteDirectory, true);
3980 :
3981 13 : if (voteType == "ban") {
3982 12 : if (!pimpl_->resolveBan(type, uri))
3983 0 : return {};
3984 1 : } else if (voteType == "unban") {
3985 1 : if (!pimpl_->resolveUnban(type, uri))
3986 0 : return {};
3987 : }
3988 :
3989 : // Commit
3990 13 : if (!git_add_all(repo.get()))
3991 0 : return {};
3992 :
3993 13 : Json::Value json;
3994 13 : json["action"] = voteType;
3995 13 : json["uri"] = uri;
3996 13 : json["type"] = "member";
3997 26 : return pimpl_->commitMessage(json::toString(json));
3998 13 : }
3999 :
4000 : // If vote nok
4001 0 : return {};
4002 13 : }
4003 :
4004 : std::pair<std::vector<ConversationCommit>, bool>
4005 1769 : ConversationRepository::validFetch(const std::string& remoteDevice) const
4006 : {
4007 3538 : auto newCommit = remoteHead(remoteDevice);
4008 1769 : if (not pimpl_ or newCommit.empty())
4009 0 : return {{}, false};
4010 1769 : auto commitsToValidate = pimpl_->behind(newCommit);
4011 1769 : std::reverse(std::begin(commitsToValidate), std::end(commitsToValidate));
4012 1769 : auto isValid = pimpl_->validCommits(commitsToValidate);
4013 1769 : if (isValid)
4014 1752 : return {commitsToValidate, false};
4015 17 : return {{}, true};
4016 1769 : }
4017 :
4018 : std::pair<std::vector<ConversationCommit>, bool>
4019 189 : ConversationRepository::validClone() const
4020 : {
4021 189 : auto commits = log({});
4022 189 : if (!pimpl_->validCommits(commits))
4023 4 : return {{}, false};
4024 185 : return {std::move(commits), true};
4025 189 : }
4026 :
4027 : bool
4028 2 : ConversationRepository::isValidUserAtCommit(const std::string& userDevice,
4029 : const std::string& commitId,
4030 : const git_buf& sig,
4031 : const git_buf& sig_data) const
4032 : {
4033 2 : return pimpl_->isValidUserAtCommit(userDevice, commitId, sig, sig_data);
4034 : }
4035 :
4036 : bool
4037 5 : ConversationRepository::validCommits(const std::vector<ConversationCommit>& commitsToValidate) const
4038 : {
4039 5 : return pimpl_->validCommits(commitsToValidate);
4040 : }
4041 :
4042 : void
4043 863 : ConversationRepository::removeBranchWith(const std::string& remoteDevice)
4044 : {
4045 863 : git_remote* remote_ptr = nullptr;
4046 863 : auto repo = pimpl_->repository();
4047 863 : if (!repo || git_remote_lookup(&remote_ptr, repo.get(), remoteDevice.c_str()) < 0) {
4048 0 : JAMI_WARNING("No remote found with id: {}", remoteDevice);
4049 0 : return;
4050 : }
4051 863 : GitRemote remote {remote_ptr, git_remote_free};
4052 :
4053 863 : git_remote_prune(remote.get(), nullptr);
4054 863 : }
4055 :
4056 : std::vector<std::string>
4057 30 : ConversationRepository::getInitialMembers() const
4058 : {
4059 30 : return pimpl_->getInitialMembers();
4060 : }
4061 :
4062 : std::vector<ConversationMember>
4063 1347 : ConversationRepository::members() const
4064 : {
4065 1347 : return pimpl_->members();
4066 : }
4067 :
4068 : std::set<std::string>
4069 2918 : ConversationRepository::memberUris(std::string_view filter, const std::set<MemberRole>& filteredRoles) const
4070 : {
4071 2918 : return pimpl_->memberUris(filter, filteredRoles);
4072 : }
4073 :
4074 : std::map<std::string, std::vector<DeviceId>>
4075 512 : ConversationRepository::devices(bool ignoreExpired) const
4076 : {
4077 512 : return pimpl_->devices(ignoreExpired);
4078 : }
4079 :
4080 : void
4081 770 : ConversationRepository::refreshMembers() const
4082 : {
4083 : try {
4084 770 : pimpl_->initMembers();
4085 0 : } catch (...) {
4086 0 : }
4087 770 : }
4088 :
4089 : void
4090 189 : ConversationRepository::pinCertificates(bool blocking)
4091 : {
4092 189 : auto acc = pimpl_->account_.lock();
4093 189 : auto repo = pimpl_->repository();
4094 189 : if (!repo or !acc)
4095 0 : return;
4096 :
4097 189 : std::string repoPath = git_repository_workdir(repo.get());
4098 189 : std::vector<std::string> paths = {repoPath + MemberPath::ADMINS.string(),
4099 378 : repoPath + MemberPath::MEMBERS.string(),
4100 1323 : repoPath + MemberPath::DEVICES.string()};
4101 :
4102 756 : for (const auto& path : paths) {
4103 567 : if (blocking) {
4104 567 : std::promise<bool> p;
4105 567 : std::future<bool> f = p.get_future();
4106 1134 : acc->certStore().pinCertificatePath(path, [&](auto /* certs */) { p.set_value(true); });
4107 567 : f.wait();
4108 567 : } else {
4109 0 : acc->certStore().pinCertificatePath(path, {});
4110 : }
4111 : }
4112 189 : }
4113 :
4114 : std::string
4115 13149 : ConversationRepository::uriFromDevice(const std::string& deviceId) const
4116 : {
4117 13149 : return pimpl_->uriFromDevice(deviceId);
4118 : }
4119 :
4120 : std::string
4121 9 : ConversationRepository::updateInfos(const std::map<std::string, std::string>& profile)
4122 : {
4123 9 : std::lock_guard lkOp(pimpl_->opMtx_);
4124 9 : pimpl_->resetHard();
4125 9 : auto valid = false;
4126 : {
4127 9 : std::lock_guard lk(pimpl_->membersMtx_);
4128 10 : for (const auto& member : pimpl_->members_) {
4129 10 : if (member.uri == pimpl_->userId_) {
4130 9 : valid = member.role <= pimpl_->updateProfilePermLvl_;
4131 9 : break;
4132 : }
4133 : }
4134 9 : }
4135 9 : if (!valid) {
4136 4 : JAMI_ERROR("Insufficient permission to update information.");
4137 1 : emitSignal<libjami::ConversationSignal::OnConversationError>(pimpl_->accountId_,
4138 1 : pimpl_->id_,
4139 : EUNAUTHORIZED,
4140 : "Insufficient permission to update information.");
4141 1 : return {};
4142 : }
4143 :
4144 8 : auto infosMap = infos();
4145 19 : for (const auto& [k, v] : profile) {
4146 11 : infosMap[k] = v;
4147 : }
4148 8 : auto repo = pimpl_->repository();
4149 8 : if (!repo)
4150 0 : return {};
4151 8 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
4152 8 : auto profilePath = repoPath / "profile.vcf";
4153 8 : std::ofstream file(profilePath, std::ios::trunc | std::ios::binary);
4154 8 : if (!file.is_open()) {
4155 0 : JAMI_ERROR("Unable to write data to {}", profilePath);
4156 0 : return {};
4157 : }
4158 :
4159 32 : auto addKey = [&](auto property, auto key) {
4160 32 : auto it = infosMap.find(std::string(key));
4161 32 : if (it != infosMap.end()) {
4162 11 : file << property;
4163 11 : file << ":";
4164 11 : file << it->second;
4165 11 : file << vCard::Delimiter::END_LINE_TOKEN;
4166 : }
4167 32 : };
4168 :
4169 8 : file << vCard::Delimiter::BEGIN_TOKEN;
4170 8 : file << vCard::Delimiter::END_LINE_TOKEN;
4171 8 : file << vCard::Property::VCARD_VERSION;
4172 8 : file << ":2.1";
4173 8 : file << vCard::Delimiter::END_LINE_TOKEN;
4174 8 : addKey(vCard::Property::FORMATTED_NAME, vCard::Value::TITLE);
4175 8 : addKey(vCard::Property::DESCRIPTION, vCard::Value::DESCRIPTION);
4176 8 : file << vCard::Property::PHOTO;
4177 8 : file << vCard::Delimiter::SEPARATOR_TOKEN;
4178 8 : file << vCard::Property::BASE64;
4179 8 : auto avatarIt = infosMap.find(std::string(vCard::Value::AVATAR));
4180 8 : if (avatarIt != infosMap.end()) {
4181 : // TODO: type=png? store another way?
4182 0 : file << ":";
4183 0 : file << avatarIt->second;
4184 : }
4185 8 : file << vCard::Delimiter::END_LINE_TOKEN;
4186 8 : addKey(vCard::Property::RDV_ACCOUNT, vCard::Value::RDV_ACCOUNT);
4187 8 : file << vCard::Delimiter::END_LINE_TOKEN;
4188 8 : addKey(vCard::Property::RDV_DEVICE, vCard::Value::RDV_DEVICE);
4189 8 : file << vCard::Delimiter::END_LINE_TOKEN;
4190 8 : file << vCard::Delimiter::END_TOKEN;
4191 8 : file.close();
4192 :
4193 8 : if (!pimpl_->add("profile.vcf"))
4194 0 : return {};
4195 8 : Json::Value json;
4196 8 : json["type"] = "application/update-profile";
4197 16 : return pimpl_->commitMessage(json::toString(json));
4198 9 : }
4199 :
4200 : std::map<std::string, std::string>
4201 336 : ConversationRepository::infos() const
4202 : {
4203 336 : if (auto repo = pimpl_->repository()) {
4204 : try {
4205 336 : std::filesystem::path repoPath = git_repository_workdir(repo.get());
4206 336 : auto profilePath = repoPath / "profile.vcf";
4207 336 : std::map<std::string, std::string> result;
4208 336 : std::error_code ec;
4209 336 : if (std::filesystem::is_regular_file(profilePath, ec)) {
4210 25 : auto content = fileutils::loadFile(profilePath);
4211 50 : result = ConversationRepository::infosFromVCard(
4212 75 : vCard::utils::toMap(std::string_view {(const char*) content.data(), content.size()}));
4213 25 : }
4214 336 : result["mode"] = std::to_string(static_cast<int>(mode()));
4215 336 : return result;
4216 336 : } catch (...) {
4217 0 : }
4218 336 : }
4219 0 : return {};
4220 : }
4221 :
4222 : std::map<std::string, std::string>
4223 101 : ConversationRepository::infosFromVCard(vCard::utils::VCardData&& details)
4224 : {
4225 101 : std::map<std::string, std::string> result;
4226 217 : for (auto&& [k, v] : details) {
4227 116 : if (k == vCard::Property::FORMATTED_NAME) {
4228 15 : result["title"] = std::move(v);
4229 101 : } else if (k == vCard::Property::DESCRIPTION) {
4230 1 : result["description"] = std::move(v);
4231 100 : } else if (k.find(vCard::Property::PHOTO) == 0) {
4232 0 : result["avatar"] = std::move(v);
4233 100 : } else if (k.find(vCard::Property::RDV_ACCOUNT) == 0) {
4234 11 : result["rdvAccount"] = std::move(v);
4235 89 : } else if (k.find(vCard::Property::RDV_DEVICE) == 0) {
4236 11 : result["rdvDevice"] = std::move(v);
4237 : }
4238 : }
4239 101 : return result;
4240 0 : }
4241 :
4242 : std::string
4243 1770 : ConversationRepository::getHead() const
4244 : {
4245 1770 : if (auto repo = pimpl_->repository()) {
4246 : git_oid commit_id;
4247 1770 : if (git_reference_name_to_id(&commit_id, repo.get(), "HEAD") < 0) {
4248 0 : JAMI_ERROR("Unable to get reference for HEAD");
4249 1770 : return {};
4250 : }
4251 1770 : if (auto commit_str = git_oid_tostr_s(&commit_id))
4252 1770 : return commit_str;
4253 1770 : }
4254 0 : return {};
4255 : }
4256 :
4257 : std::optional<std::map<std::string, std::string>>
4258 17702 : ConversationRepository::convCommitToMap(const ConversationCommit& commit) const
4259 : {
4260 17702 : return pimpl_->convCommitToMap(commit);
4261 : }
4262 :
4263 : std::vector<std::map<std::string, std::string>>
4264 1401 : ConversationRepository::convCommitsToMap(const std::vector<ConversationCommit>& commits) const
4265 : {
4266 1401 : std::vector<std::map<std::string, std::string>> result = {};
4267 1401 : result.reserve(commits.size());
4268 3661 : for (const auto& commit : commits) {
4269 2260 : if (auto message = pimpl_->convCommitToMap(commit))
4270 2260 : result.emplace_back(*message);
4271 : }
4272 1401 : return result;
4273 0 : }
4274 :
4275 : } // namespace jami
|