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 : #pragma once
18 : #include "def.h"
19 : #include "vcard.h"
20 : #include "git_def.h"
21 : #include "jamidht/commit_message.h"
22 :
23 : #include <opendht/default_types.h>
24 :
25 : #include <optional>
26 : #include <memory>
27 : #include <stdexcept>
28 : #include <string>
29 : #include <vector>
30 :
31 : namespace jami {
32 :
33 : using DeviceId = dht::PkId;
34 :
35 : constexpr auto EFETCH = 1;
36 : constexpr auto EINVALIDMODE = 2;
37 : constexpr auto EVALIDFETCH = 3;
38 : constexpr auto EUNAUTHORIZED = 4;
39 : constexpr auto ECOMMIT = 5;
40 : constexpr auto EUNRECOVERABLE = 6;
41 :
42 : class JamiAccount;
43 :
44 : /**
45 : * Exception thrown when a cloned conversation repository fails commit validation.
46 : * Unlike network errors, this failure is permanent: the remote history is immutable,
47 : * so cloning the same conversation again will fail the same way.
48 : */
49 : class InvalidRepositoryError : public std::runtime_error
50 : {
51 : public:
52 3 : explicit InvalidRepositoryError(const std::string& what)
53 3 : : std::runtime_error(what)
54 3 : {}
55 : };
56 :
57 : struct LogOptions
58 : {
59 : std::string from {};
60 : std::string to {};
61 : uint64_t nbOfCommits {0}; // maximum number of commits wanted
62 : bool skipMerge {false}; // Do not include merge commits in the log. Used by the module to get
63 : // last interaction without potential merges
64 : bool includeTo {false}; // If we want or not the "to" commit [from-to] or [from-to)
65 : bool fastLog {false}; // Do not parse content, used mostly to count
66 : bool logIfNotFound {true}; // Add a warning in the log if commit is not found
67 :
68 : std::string authorUri {}; // filter commits from author
69 : };
70 :
71 : struct Filter
72 : {
73 : std::string author;
74 : std::string lastId;
75 : std::string regexSearch;
76 : std::string type;
77 : int64_t after {0};
78 : int64_t before {0};
79 : uint32_t maxResult {0};
80 : bool caseSensitive {false};
81 : };
82 :
83 : struct GitAuthor
84 : {
85 : std::string name {};
86 : std::string email {};
87 : };
88 :
89 : struct ConversationCommit
90 : {
91 : std::string id {};
92 : std::vector<std::string> parents {};
93 : GitAuthor author {};
94 : std::vector<uint8_t> signed_content {};
95 : std::vector<uint8_t> signature {};
96 : CommitMessage commitMsg {};
97 : std::string linearized_parent {};
98 : std::string authorId {};
99 : int64_t timestamp {0};
100 : };
101 :
102 : enum class MemberRole { ADMIN = 0, MEMBER, INVITED, BANNED, LEFT };
103 :
104 : namespace MemberPath {
105 :
106 : static const std::filesystem::path ADMINS {"admins"};
107 : static const std::filesystem::path MEMBERS {"members"};
108 : static const std::filesystem::path INVITED {"invited"};
109 : static const std::filesystem::path BANNED {"banned"};
110 : static const std::filesystem::path DEVICES {"devices"};
111 :
112 : } // namespace MemberPath
113 :
114 : struct ConversationMember
115 : {
116 : std::string uri;
117 : MemberRole role;
118 :
119 227 : std::map<std::string, std::string> map() const
120 : {
121 227 : std::string rolestr;
122 227 : if (role == MemberRole::ADMIN) {
123 113 : rolestr = "admin";
124 114 : } else if (role == MemberRole::MEMBER) {
125 71 : rolestr = "member";
126 43 : } else if (role == MemberRole::INVITED) {
127 36 : rolestr = "invited";
128 7 : } else if (role == MemberRole::BANNED) {
129 7 : rolestr = "banned";
130 0 : } else if (role == MemberRole::LEFT) {
131 0 : rolestr = "left"; // For one to one
132 : }
133 :
134 1135 : return {{"uri", uri}, {"role", rolestr}};
135 454 : }
136 14231 : MSGPACK_DEFINE(uri, role)
137 : };
138 :
139 : enum class CallbackResult { Skip, Break, Ok };
140 :
141 : using PreConditionCb = std::function<CallbackResult(const std::string&, const GitAuthor&, const GitCommit&)>;
142 : using PostConditionCb = std::function<bool(const std::string&, const GitAuthor&, ConversationCommit&)>;
143 : using OnMembersChanged = std::function<void(const std::set<std::string>&)>;
144 :
145 : /**
146 : * This class gives access to the git repository that represents the conversation
147 : */
148 : class LIBJAMI_TEST_EXPORT ConversationRepository
149 : {
150 : public:
151 : #ifdef LIBJAMI_TEST
152 : static bool DISABLE_RESET; // Some tests inject bad files so resetHard() will break the test
153 :
154 : // If true, clone and fetch operations will be performed directly using the target repo's path,
155 : // avoiding the need for setting up a GitServer and a DHTNet connection.
156 : static bool FETCH_FROM_LOCAL_REPOS;
157 : #endif
158 : /**
159 : * Creates a new repository, with initial files, where the first commit hash is the conversation id
160 : * @param account The related account
161 : * @param mode The wanted mode
162 : * @param otherMember The other uri
163 : * @return the conversation repository object
164 : */
165 : static LIBJAMI_TEST_EXPORT std::unique_ptr<ConversationRepository> createConversation(
166 : const std::shared_ptr<JamiAccount>& account,
167 : ConversationMode mode = ConversationMode::INVITES_ONLY,
168 : const std::string& otherMember = "");
169 :
170 : /**
171 : * Creates a new collaborative document repository (a swarm exactly like a conversation,
172 : * with mode ConversationMode::DOCUMENT), where the first commit hash is the document id.
173 : * @param account The related account
174 : * @param parentConversationId The conversation the document is announced in
175 : * @param mimeType The media type of what the document holds
176 : * @return the repository object
177 : */
178 : static LIBJAMI_TEST_EXPORT std::unique_ptr<ConversationRepository> createDocument(
179 : const std::shared_ptr<JamiAccount>& account,
180 : const std::string& parentConversationId,
181 : const std::string& mimeType);
182 :
183 : /**
184 : * Clones a conversation on a remote device
185 : * @note This will use the socket registered for the conversation with
186 : * Conversation::addGitSocket()
187 : * @param account The account getting the conversation
188 : * @param deviceId Remote device
189 : * @param conversationId Conversation to clone
190 : * @throws InvalidRepositoryError if the cloned repository fails commit validation. This is a
191 : * permanent failure (the remote history is immutable); transient network errors do not
192 : * throw but return an empty repository instead. The sole caller,
193 : * ConversationModule::Impl::handlePendingConversation(), catches this to stop retrying;
194 : * any new caller must handle it as well.
195 : */
196 : static LIBJAMI_TEST_EXPORT std::pair<std::unique_ptr<ConversationRepository>, std::vector<ConversationCommit>>
197 : cloneConversation(const std::shared_ptr<JamiAccount>& account,
198 : const std::string& deviceId,
199 : const std::string& conversationId);
200 :
201 : /**
202 : * Open a conversation repository for an account and an id
203 : * @param account The related account
204 : * @param id The conversation id
205 : */
206 : ConversationRepository(const std::shared_ptr<JamiAccount>& account, const std::string& id);
207 : ~ConversationRepository();
208 :
209 : /**
210 : * Write the certificate in /members and commit the change
211 : * @param uri Member to add
212 : * @return the commit id if successful
213 : */
214 : std::string addMember(const std::string& uri);
215 :
216 : /**
217 : * Fetch a remote repository via the given socket
218 : * @note This will use the socket registered for the conversation with
219 : * Conversation::addGitSocket()
220 : * @note will create a remote identified by the deviceId
221 : * @param remoteDeviceId Remote device id to fetch
222 : * @return if the operation was successful
223 : */
224 : bool fetch(const std::string& remoteDeviceId);
225 :
226 : /**
227 : * Merge the history of the conversation with another peer
228 : * @param uri The peer uri
229 : * @param disconnectFromPeerCb Callback to disconnect from peer when banning
230 : * @return A vector of media maps representing the merged history
231 : */
232 : std::vector<std::map<std::string, std::string>> mergeHistory(
233 : const std::string& uri, std::function<void(const std::string&)>&& disconnectFromPeerCb = {});
234 :
235 : /**
236 : * Retrieve remote head. Can be useful after a fetch operation
237 : * @param remoteDeviceId The remote name
238 : * @param branch Remote branch to check (default: main)
239 : * @return the commit id pointed
240 : */
241 : std::string remoteHead(const std::string& remoteDeviceId, const std::string& branch = "main") const;
242 :
243 : /**
244 : * Return the conversation id
245 : */
246 : const std::string& id() const;
247 :
248 : /**
249 : * Add a new commit to the conversation
250 : * @param msg The commit message of the commit
251 : * @param verifyDevice If we need to validate that certificates are correct (used for testing)
252 : * @return <empty> on failure, else the message id
253 : */
254 : std::string commitMessage(const std::string& msg, bool verifyDevice = true);
255 :
256 : std::vector<std::string> commitMessages(const std::vector<std::string>& msgs);
257 :
258 : /**
259 : * Amend a commit message
260 : * @param id The commit to amend
261 : * @param msg The commit message of the commit
262 : * @return <empty> on failure, else the message id
263 : */
264 : std::string amend(const std::string& id, const std::string& msg);
265 :
266 : /**
267 : * Get commits depending on the options we pass
268 : * @return a list of commits
269 : */
270 : std::vector<ConversationCommit> log(const LogOptions& options = {}) const;
271 : void log(PreConditionCb&& preCondition,
272 : std::function<void(ConversationCommit&&)>&& emplaceCb,
273 : PostConditionCb&& postCondition,
274 : const std::string& from = "",
275 : bool logIfNotFound = true) const;
276 :
277 : /**
278 : * Check if a commit exists in the repository
279 : * @param commitId The commit id to check
280 : * @return true if the commit was found, false if not or if an error occurred
281 : */
282 : bool hasCommit(const std::string& commitId) const;
283 : std::optional<ConversationCommit> getCommit(const std::string& commitId) const;
284 :
285 : /**
286 : * Get parent via topological + date sort in branch main of a commit
287 : * @param commitId id to choice
288 : */
289 : std::optional<std::string> linearizedParent(const std::string& commitId) const;
290 :
291 : /**
292 : * Merge another branch into the main branch
293 : * @param merge_id The reference to merge
294 : * @param force Should be false, skip validateDevice() ; used for test purpose
295 : * @return a pair containing if the merge was successful and the merge commit id
296 : * generated if one (can be a fast forward merge without commit)
297 : */
298 : std::pair<bool, std::string> merge(const std::string& merge_id, bool force = false);
299 :
300 : /**
301 : * Get current diff stats between two commits
302 : * @param oldId Old commit
303 : * @param newId Recent commit (empty value will compare to the empty repository)
304 : * @note "HEAD" is also accepted as parameter for newId
305 : * @return diff stats
306 : */
307 : std::string diffStats(const std::string& newId, const std::string& oldId = "") const;
308 :
309 : /**
310 : * Get changed files from a git diff
311 : * @param diffStats The stats to analyze
312 : * @return get the changed files from a git diff
313 : */
314 : static std::vector<std::string> changedFiles(std::string_view diffStats);
315 :
316 : /**
317 : * Join a repository
318 : * @return commit Id
319 : */
320 : std::string join();
321 :
322 : /**
323 : * Erase self from repository
324 : * @return commit Id
325 : */
326 : std::string leave();
327 :
328 : /**
329 : * Erase repository
330 : */
331 : void erase();
332 :
333 : /**
334 : * Get conversation's mode
335 : * @return the mode
336 : */
337 : ConversationMode mode() const;
338 :
339 : /**
340 : * Document repositories only (mode() == ConversationMode::DOCUMENT).
341 : * Read back the fields of the initial commit: the id of the conversation
342 : * the document was announced in and the media type of what it holds.
343 : */
344 : std::string parentConversationId() const;
345 : std::string documentMimeType() const;
346 :
347 : /**
348 : * Document repositories only. Attachments are content-addressed blobs
349 : * stored under attachments/<oid>: the file name is the git oid of the
350 : * content, so the same bytes added twice converge to the same entry.
351 : */
352 : /**
353 : * Store an attachment and commit it
354 : * @param data The attachment content
355 : * @return the attachment id (blob oid) or empty on failure
356 : */
357 : std::string addAttachment(const std::vector<uint8_t>& data);
358 : /**
359 : * Read an attachment's content at HEAD
360 : */
361 : std::vector<uint8_t> attachment(const std::string& attachmentId) const;
362 : /**
363 : * List attachment ids present at HEAD
364 : */
365 : std::vector<std::string> attachmentIds() const;
366 :
367 : /**
368 : * The voting system is divided in two parts. The voting phase where
369 : * admins can decide an action (such as kicking someone)
370 : * and the resolving phase, when > 50% of the admins voted, we can
371 : * considered the vote as finished
372 : */
373 : /**
374 : * Add a vote to kick a device or a user
375 : * @param uri identified of the user/device
376 : * @param type device, members, admins or invited
377 : * @return the commit id or empty if failed
378 : */
379 : std::string voteKick(const std::string& uri, const std::string& type);
380 : /**
381 : * Add a vote to re-add a user
382 : * @param uri identified of the user
383 : * @param type device, members, admins or invited
384 : * @return the commit id or empty if failed
385 : */
386 : std::string voteUnban(const std::string& uri, const std::string_view type);
387 : /**
388 : * Validate if a vote is finished
389 : * @param uri identified of the user/device
390 : * @param type device, members, admins or invited
391 : * @param voteType "ban" or "unban"
392 : * @return the commit id or empty if failed
393 : */
394 : std::string resolveVote(const std::string& uri, const std::string_view type, const std::string& voteType);
395 :
396 : /**
397 : * Validate a fetch with remote device
398 : * @param remotedevice
399 : * @return the validated commits and if an error occurs
400 : */
401 : std::pair<std::vector<ConversationCommit>, bool> validFetch(const std::string& remoteDevice) const;
402 :
403 : /**
404 : * Validate a clone
405 : * @return the validated commits and false if an error occurs
406 : */
407 : std::pair<std::vector<ConversationCommit>, bool> validClone() const;
408 :
409 : /**
410 : * Verify the signature against the given commit
411 : * @param userDevice the email of the sender (i.e. their device's public key)
412 : * @param commitId the id of the commit
413 : */
414 : bool isValidUserAtCommit(const std::string& userDevice,
415 : const std::string& commitId,
416 : const git_buf& sig,
417 : const git_buf& sig_data) const;
418 :
419 : /**
420 : * Validate that commits are not malformed
421 : * @param commitsToValidate the list of commits
422 : */
423 : bool validCommits(const std::vector<ConversationCommit>& commitsToValidate) const;
424 :
425 : /**
426 : * Delete branch with remote
427 : * @param remoteDevice
428 : */
429 : void removeBranchWith(const std::string& remoteDevice);
430 :
431 : /**
432 : * One to one util, get initial members
433 : * @return initial members
434 : */
435 : std::vector<std::string> getInitialMembers() const;
436 :
437 : /**
438 : * Get conversation's members
439 : * @return members
440 : */
441 : std::vector<ConversationMember> members() const;
442 :
443 : /**
444 : * Get conversation's devices
445 : * @param ignoreExpired If we want to ignore expired devices
446 : * @return members
447 : */
448 : std::map<std::string, std::vector<DeviceId>> devices(bool ignoreExpired = true) const;
449 :
450 : /**
451 : * @param filter If we want to remove one member
452 : * @param filteredRoles If we want to ignore some roles
453 : * @return members' uris
454 : */
455 : std::set<std::string> memberUris(std::string_view filter, const std::set<MemberRole>& filteredRoles) const;
456 :
457 : /**
458 : * To use after a merge with member's events, refresh members knowledge
459 : */
460 : void refreshMembers() const;
461 :
462 : void onMembersChanged(OnMembersChanged&& cb);
463 :
464 : /**
465 : * Because conversations can contains non contacts certificates, this methods
466 : * loads certificates in conversations into the cert store
467 : * @param blocking if we need to wait that certificates are pinned
468 : */
469 : void pinCertificates(bool blocking = false);
470 : /**
471 : * Retrieve the uri from a deviceId
472 : * @note used by swarm manager (peersToSyncWith)
473 : * @param deviceId
474 : * @return corresponding issuer
475 : */
476 : std::string uriFromDevice(const std::string& deviceId) const;
477 :
478 : /**
479 : * Change repository's infos
480 : * @param map New infos (supported keys: title, description, avatar)
481 : * @return the commit id
482 : */
483 : std::string updateInfos(const std::map<std::string, std::string>& map);
484 :
485 : /**
486 : * Retrieve current infos (title, description, avatar, mode)
487 : * @return infos
488 : */
489 : std::map<std::string, std::string> infos() const;
490 : static std::map<std::string, std::string> infosFromVCard(vCard::utils::VCardData&& details);
491 :
492 : /**
493 : * Convert ConversationCommit to MapStringString for the client
494 : */
495 : std::vector<std::map<std::string, std::string>> convCommitsToMap(
496 : const std::vector<ConversationCommit>& commits) const;
497 : std::optional<std::map<std::string, std::string>> convCommitToMap(const ConversationCommit& commit) const;
498 :
499 : /**
500 : * Get current HEAD hash
501 : */
502 : std::string getHead() const;
503 :
504 : private:
505 : ConversationRepository() = delete;
506 : static std::unique_ptr<ConversationRepository> createRepository(const std::shared_ptr<JamiAccount>& account,
507 : ConversationMode mode,
508 : const std::string& otherMember,
509 : const CommitMessage& initialMessage);
510 : class Impl;
511 : std::unique_ptr<Impl> pimpl_;
512 : };
513 :
514 : } // namespace jami
515 14253 : MSGPACK_ADD_ENUM(jami::MemberRole);
516 4111 : MSGPACK_ADD_ENUM(jami::ConversationMode);
|