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