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