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