From adadfdc9d71dd62fbeb3dd59b46db7854eceba70 Mon Sep 17 00:00:00 2001 From: T guntenaar Date: Thu, 19 Oct 2023 13:56:48 -0500 Subject: [PATCH] feat: introduced community add-ons (#61) --- src/access_control/members.rs | 8 +- src/community/mod.rs | 65 ++- src/lib.rs | 372 +++++++++++++++++- src/migrations.rs | 108 +++++ src/post/mod.rs | 1 + ...ation__deploy_contract_self_upgrade-5.snap | 3 +- 6 files changed, 543 insertions(+), 14 deletions(-) diff --git a/src/access_control/members.rs b/src/access_control/members.rs index 90b7af1c..5e5b72c4 100644 --- a/src/access_control/members.rs +++ b/src/access_control/members.rs @@ -54,10 +54,10 @@ impl Into for Member { )] #[serde(crate = "near_sdk::serde")] pub struct MemberMetadata { - description: String, - permissions: HashMap>, - children: HashSet, - parents: HashSet, + pub description: String, + pub permissions: HashMap>, + pub children: HashSet, + pub parents: HashSet, } #[derive( diff --git a/src/community/mod.rs b/src/community/mod.rs index 3a74146f..fcb77932 100644 --- a/src/community/mod.rs +++ b/src/community/mod.rs @@ -4,6 +4,8 @@ use near_sdk::{env, require, AccountId}; pub type CommunityHandle = String; +pub type AddOnId = String; + #[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize, Clone)] #[serde(crate = "near_sdk::serde")] pub struct CommunityInputs { @@ -45,7 +47,55 @@ pub struct WikiPage { content_markdown: String, } -#[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize, Clone)] +#[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize, Clone, PartialEq, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct CommunityAddOn { + pub id: String, + pub addon_id: AddOnId, + pub display_name: String, + pub enabled: bool, + pub parameters: String, +} + +#[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize, Clone, PartialEq, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct AddOn { + pub id: AddOnId, + pub title: String, + pub description: String, + pub icon: String, + // The path to the view on the community page + pub view_widget: String, + // The path to the view on the community configuration page + pub configurator_widget: String, +} + +impl AddOn { + pub fn validate(&self) { + if !matches!(self.id.chars().count(), 3..=120) { + panic!("Add-on id must contain 3 to 120 characters"); + } + + if !matches!(self.title.chars().count(), 3..=120) { + panic!("Add-on title must contain 3 to 120 characters"); + } + + if !matches!(self.description.chars().count(), 3..=120) { + panic!("Add-on description must contain 3 to 120 characters"); + } + if !matches!(self.view_widget.chars().count(), 6..=240) { + panic!("Add-on viewer must contain 6 to 240 characters"); + } + if !matches!(self.configurator_widget.chars().count(), 0..=240) { + panic!("Add-on configurator must contain 0 to 240 characters"); + } + if !matches!(self.icon.chars().count(), 6..=60) { + panic!("Add-on icon must contain 6 to 60 characters"); + } + } +} + +#[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize)] #[serde(crate = "near_sdk::serde")] pub struct Community { pub admins: Vec, @@ -67,6 +117,7 @@ pub struct Community { pub wiki1: Option, pub wiki2: Option, pub features: CommunityFeatureFlags, + pub addons: Vec, } #[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize, Clone)] @@ -110,6 +161,18 @@ impl Community { ); } + pub fn add_addon(&mut self, addon_config: CommunityAddOn) { + self.addons.push(addon_config); + } + + pub fn remove_addon(&mut self, addon_to_remove: CommunityAddOn) { + self.addons.retain(|addon| addon != &addon_to_remove); + } + + pub fn set_addons(&mut self, addons: Vec) { + self.addons = addons; + } + pub fn set_default_admin(&mut self) { if self.admins.is_empty() { self.admins = vec![env::predecessor_account_id()]; diff --git a/src/lib.rs b/src/lib.rs index 76045158..ae296fd0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ pub struct Contract { pub authors: UnorderedMap>, pub communities: UnorderedMap, pub featured_communities: Vec, + pub available_addons: UnorderedMap, } #[near_bindgen] @@ -59,6 +60,7 @@ impl Contract { authors: UnorderedMap::new(StorageKey::AuthorToAuthorPosts), communities: UnorderedMap::new(StorageKey::Communities), featured_communities: Vec::new(), + available_addons: UnorderedMap::new(StorageKey::AddOns), }; contract.post_to_children.insert(&ROOT_POST_ID, &Vec::new()); contract @@ -355,6 +357,7 @@ impl Contract { board: true, wiki: true, }, + addons: vec![], }; new_community.validate(); @@ -413,6 +416,87 @@ impl Contract { .collect() } + pub fn get_addon(&self, id: AddOnId) -> Option { + self.available_addons.get(&id) + } + + pub fn get_all_addons(&self) -> Vec { + self.available_addons.iter().map(|(_id, add_on)| add_on).collect() + } + + // Only the contract admin and DevHub moderators + pub fn create_addon(&mut self, addon: AddOn) { + if !self.has_moderator(env::predecessor_account_id()) + && env::predecessor_account_id() != env::current_account_id() + { + panic!("Only the admin and moderators can create new add-ons"); + } + if self.get_addon(addon.id.to_owned()).is_some() { + panic!("Add-on with this id already exists"); + } + addon.validate(); + self.available_addons.insert(&addon.id.clone(), &addon); + } + + // ONLY FOR TESTING + pub fn delete_addon(&mut self, id: AddOnId) { + // Also delete from communities + if !self.has_moderator(env::predecessor_account_id()) + && env::predecessor_account_id() != env::current_account_id() + { + panic!("Only the admin and moderators can delete add-ons"); + } + let addon = self + .get_addon(id.clone()) + .expect(&format!("Add-on with id `{}` does not exist", id)) + .clone(); + + self.available_addons.remove(&addon.id); + } + + pub fn update_addon(&mut self, addon: AddOn) { + if !self.has_moderator(env::predecessor_account_id()) + && env::predecessor_account_id() != env::current_account_id() + { + panic!("Only the admin and moderators can edit add-ons"); + } + self.available_addons.insert(&addon.id.clone(), &addon); + } + + pub fn get_community_addons(&self, handle: CommunityHandle) -> Vec { + let community = self + .get_community(handle.clone()) + .expect(format!("Community not found with handle `{}`", handle).as_str()); + return community.addons; + } + + pub fn set_community_addons(&mut self, handle: CommunityHandle, addons: Vec) { + let mut community = self + .get_community(handle.clone()) + .expect(format!("Community not found with handle `{}`", handle).as_str()); + community.addons = addons; + self.update_community(handle, community); + } + + // To add or update parameters set by the configurator widget + pub fn set_community_addon( + &mut self, + handle: CommunityHandle, + community_addon: CommunityAddOn, + ) { + let mut community = self + .get_community(handle.clone()) + .expect(format!("Community not found with handle `{}`", handle).as_str()); + if let Some(existing_addon) = + community.addons.iter_mut().find(|current| current.id == community_addon.id) + { + *existing_addon = community_addon; + } else { + community.addons.push(community_addon); + } + self.update_community(handle, community); + } + fn get_editable_community(&self, handle: &CommunityHandle) -> Option { if self .get_account_community_permissions(env::predecessor_account_id(), handle.to_owned()) @@ -438,7 +522,7 @@ impl Contract { } else { require!( self.communities.get(&community.handle).is_none(), - "Community handle `{community.handle}` is already taken" + "Community handle is already taken" ); self.communities.remove(&target_community.handle); self.communities.insert(&community.handle, &community); @@ -515,10 +599,7 @@ impl Contract { // Check if every handle corresponds to an existing community for handle in &handles { - require!( - self.communities.get(&handle).is_some(), - "Community '{handle}' does not exist." - ); + require!(self.communities.get(&handle).is_some(), "Community does not exist."); } // Replace the existing featured communities with the new ones @@ -541,19 +622,42 @@ impl Contract { #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { - use std::collections::HashSet; + use std::collections::{HashMap, HashSet}; use std::convert::TryInto; + use crate::access_control::members::{ActionType, Member, MemberMetadata}; + use crate::access_control::rules::Rule; + use crate::community::{AddOn, Community, CommunityAddOn, CommunityInputs}; use crate::post::PostBody; + use near_sdk::store::vec; use near_sdk::test_utils::{get_created_receipts, VMContextBuilder}; - use near_sdk::{testing_env, MockedBlockchain, VMContext}; + use near_sdk::{testing_env, AccountId, MockedBlockchain, VMContext}; use regex::Regex; use super::Contract; fn get_context(is_view: bool) -> VMContext { + get_context_with_signer(is_view, "bob.near".to_string()) + } + + fn get_context_with_signer(is_view: bool, signer: String) -> VMContext { + VMContextBuilder::new() + .signer_account_id(signer.clone().try_into().unwrap()) + .current_account_id(signer.try_into().unwrap()) + .is_view(is_view) + .build() + } + + fn get_context_with_current(is_view: bool, signer: String) -> VMContext { VMContextBuilder::new() - .signer_account_id("bob.near".parse().unwrap()) + .current_account_id(signer.try_into().unwrap()) + .is_view(is_view) + .build() + } + + fn get_context_with_predecessor(is_view: bool, signer: String) -> VMContext { + VMContextBuilder::new() + .predecessor_account_id(signer.try_into().unwrap()) .is_view(is_view) .build() } @@ -606,4 +710,256 @@ mod tests { assert_eq!("{\"data\":{\"bob.near\":{\"index\":{\"notify\":\"[{\\\"key\\\":\\\"petersalomonsen.near\\\",\\\"value\\\":{\\\"type\\\":\\\"devgovgigs/mention\\\",\\\"post\\\":0}},{\\\"key\\\":\\\"psalomo.near.\\\",\\\"value\\\":{\\\"type\\\":\\\"devgovgigs/mention\\\",\\\"post\\\":0}}]\"}}}}", args); } } + + #[test] + pub fn test_create_addon() { + let context = get_context_with_current(false, "bob.near".to_string()); + testing_env!(context); + + let mut contract = Contract::new(); + let input = fake_addon("CommunityAddOnId".to_string()); + contract.create_addon(input.to_owned()); + + let addon = contract.get_addon("CommunityAddOnId".to_owned()); + + assert_eq!(addon, Some(input)) + } + + pub fn fake_addon(id: String) -> AddOn { + let input = AddOn { + id: id.to_owned(), + title: "GitHub AddOn".to_owned(), + description: "Current status of NEARCORE repo".to_owned(), + view_widget: "custom-viewer-widget".to_owned(), + configurator_widget: "github-configurator".to_owned(), + icon: "bi bi-github".to_owned(), + }; + return input; + } + + #[test] + pub fn test_get_all_addons() { + let context = get_context_with_current(false, "bob.near".to_string()); + testing_env!(context); + let mut contract = Contract::new(); + let input = fake_addon("CommunityAddOnId".to_string()); + contract.create_addon(input.to_owned()); + + let addons = contract.get_all_addons(); + + assert_eq!(addons[0], input) + } + + #[test] + pub fn test_get_addon() { + let context = get_context_with_current(false, "bob.near".to_string()); + testing_env!(context); + let mut contract = Contract::new(); + let input = fake_addon("CommunityAddOnId".to_string()); + contract.create_addon(input.to_owned()); + + let addon = contract.get_addon("CommunityAddOnId".to_owned()); + + assert_eq!(addon, Some(input)) + } + + #[test] + pub fn test_update_addon() { + let context = get_context(false); + testing_env!(context); + let mut contract = Contract::new(); + let input = fake_addon("test".to_owned()); + contract.create_addon(input.to_owned()); + + contract.update_addon(AddOn { title: "Telegram AddOn".to_owned(), ..input }); + + let addons = contract.get_all_addons(); + + assert_eq!(addons[0].title, "Telegram AddOn".to_owned()); + } + + #[test] + pub fn test_set_community_addons() { + let context = get_context_with_predecessor(false, "alice.near".to_string()); + testing_env!(context); + let community_handle = "gotham"; + let mut contract = Contract::new(); + let account_id: AccountId = "bob.near".parse().unwrap(); + + contract.add_member( + Member::Account(account_id.to_owned()), + MemberMetadata { ..Default::default() }.into(), + ); + // Add bob.near (signer) as moderator + contract.add_member( + Member::Team("moderators".to_string()), + MemberMetadata { + description: "Moderators can do anything except funding posts.".to_string(), + permissions: HashMap::from([( + Rule::Any(), + HashSet::from([ActionType::EditPost, ActionType::UseLabels]), + )]), + children: HashSet::from([Member::Account(account_id)]), + parents: HashSet::new(), // ..Default::default() + } + .into(), + ); + // Create community + + // Predesscor is made admin of this community automatically + contract.create_community(CommunityInputs { + handle: community_handle.to_string(), + name: "Gotham".to_string(), + tag: "some".to_string(), + description: "This is a test community.".to_string(), + bio_markdown: Some("You can change it on the community configuration page.".to_string()), + logo_url: "https://ipfs.near.social/ipfs/bafkreibysr2mkwhb4j36h2t7mqwhynqdy4vzjfygfkfg65kuspd2bawauu".to_string(), + banner_url: "https://ipfs.near.social/ipfs/bafkreic4xgorjt6ha5z4s5e3hscjqrowe5ahd7hlfc5p4hb6kdfp6prgy4".to_string() + }); + + // Create add-on + contract.create_addon(AddOn { + id: "CommunityAddOnId".to_owned(), + title: "GitHub AddOn".to_owned(), + description: "Current status of NEARCORE repo".to_owned(), + view_widget: "custom-viewer-widget".to_owned(), + configurator_widget: "github-configurator".to_owned(), + icon: "bi bi-github".to_owned(), + }); + let addon = CommunityAddOn { + id: "unique".to_string(), + addon_id: "CommunityAddOnId".to_string(), + display_name: "GitHub".to_string(), + enabled: true, + parameters: "".to_string(), + }; + let addons = vec![addon]; + + // Add add-on to community + contract.set_community_addons(community_handle.to_string(), addons); + + let community = + contract.get_community(community_handle.to_string()).expect("Community not found"); + + let addon = + contract.get_addon(community.addons[0].addon_id.to_owned()).expect("Add-on not found"); + assert_eq!(addon.title, "GitHub AddOn".to_owned()); + } + + #[test] + #[should_panic] + pub fn test_update_community() { + let context = get_context_with_predecessor(false, "alice.near".to_string()); + testing_env!(context); + let community_handle = "gotham"; + let mut contract = Contract::new(); + let account_id: AccountId = "bob.near".parse().unwrap(); + + contract.add_member( + Member::Account(account_id.to_owned()), + MemberMetadata { ..Default::default() }.into(), + ); + // Add bob.near (signer) as moderator + contract.add_member( + Member::Team("moderators".to_string()), + MemberMetadata { + description: "Moderators can do anything except funding posts.".to_string(), + permissions: HashMap::from([( + Rule::Any(), + HashSet::from([ActionType::EditPost, ActionType::UseLabels]), + )]), + children: HashSet::from([Member::Account(account_id)]), + parents: HashSet::new(), // ..Default::default() + } + .into(), + ); + // Create community + let community_inputs = CommunityInputs { + handle: community_handle.to_string(), + name: "Gotham".to_string(), + tag: "some".to_string(), + description: "This is a test community.".to_string(), + bio_markdown: Some("You can change it on the community configuration page.".to_string()), + logo_url: "https://ipfs.near.social/ipfs/bafkreibysr2mkwhb4j36h2t7mqwhynqdy4vzjfygfkfg65kuspd2bawauu".to_string(), + banner_url: "https://ipfs.near.social/ipfs/bafkreic4xgorjt6ha5z4s5e3hscjqrowe5ahd7hlfc5p4hb6kdfp6prgy4".to_string() + }; + // Predesscor is made admin of this community automatically + contract.create_community(community_inputs.to_owned()); + + let different_handle = "diffent_handle".to_string(); + // Create community + let another_community_inputs = CommunityInputs { + handle: different_handle.to_owned(), + name: "Gotham".to_string(), + tag: "some".to_string(), + description: "This is a test community.".to_string(), + bio_markdown: Some("You can change it on the community configuration page.".to_string()), + logo_url: "https://ipfs.near.social/ipfs/bafkreibysr2mkwhb4j36h2t7mqwhynqdy4vzjfygfkfg65kuspd2bawauu".to_string(), + banner_url: "https://ipfs.near.social/ipfs/bafkreic4xgorjt6ha5z4s5e3hscjqrowe5ahd7hlfc5p4hb6kdfp6prgy4".to_string() + }; + // Predesscor is made admin of this community automatically + contract.create_community(another_community_inputs.to_owned()); + + let community = + contract.get_community(community_handle.to_owned()).expect("community not found"); + + contract.update_community(different_handle.to_owned(), community); + } + + #[test] + pub fn test_set_community_addon() { + let context = get_context_with_predecessor(false, "alice.near".to_string()); + testing_env!(context); + let community_handle = "gotham"; + let mut contract = Contract::new(); + let account_id: AccountId = "bob.near".parse().unwrap(); + + contract.add_member( + Member::Account(account_id.to_owned()), + MemberMetadata { ..Default::default() }.into(), + ); + // Add bob.near (signer) as moderator + contract.add_member( + Member::Team("moderators".to_string()), + MemberMetadata { + description: "Moderators can do anything except funding posts.".to_string(), + permissions: HashMap::from([( + Rule::Any(), + HashSet::from([ActionType::EditPost, ActionType::UseLabels]), + )]), + children: HashSet::from([Member::Account(account_id)]), + parents: HashSet::new(), // ..Default::default() + } + .into(), + ); + // Create community + + // Predesscor is made admin of this community automatically + contract.create_community(CommunityInputs { + handle: community_handle.to_string(), + name: "Gotham".to_string(), + tag: "some".to_string(), + description: "This is a test community.".to_string(), + bio_markdown: Some("You can change it on the community configuration page.".to_string()), + logo_url: "https://ipfs.near.social/ipfs/bafkreibysr2mkwhb4j36h2t7mqwhynqdy4vzjfygfkfg65kuspd2bawauu".to_string(), + banner_url: "https://ipfs.near.social/ipfs/bafkreic4xgorjt6ha5z4s5e3hscjqrowe5ahd7hlfc5p4hb6kdfp6prgy4".to_string() + }); + + let mut addon = CommunityAddOn { + id: "unique".to_string(), + addon_id: "CommunityAddOnId".to_string(), + display_name: "GitHub".to_string(), + enabled: true, + parameters: "".to_string(), + }; + contract.set_community_addon(community_handle.to_owned(), addon.clone()); + + addon.display_name = "Telegram".to_string(); + + contract.set_community_addon(community_handle.to_owned(), addon.clone()); + + let retrieved_addons = contract.get_community_addons(community_handle.to_string()); + assert_eq!(retrieved_addons[0].display_name, "Telegram".to_string()); + assert_eq!(addon.id, retrieved_addons[0].id); + } } diff --git a/src/migrations.rs b/src/migrations.rs index 2d7c17da..6ba3351e 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -398,6 +398,109 @@ pub struct ContractV7 { pub featured_communities: Vec, } +// From ContractV7 to ContractV8 +#[near_bindgen] +impl Contract { + fn unsafe_add_community_addons() { + let ContractV7 { + posts, + post_to_parent, + post_to_children, + label_to_posts, + access_control, + authors, + mut communities, + featured_communities, + } = env::state_read().unwrap(); + + let migrated_communities: Vec<(String, CommunityV4)> = communities + .iter() + .map(|(community_handle, community)| { + ( + community_handle, + CommunityV4 { + admins: community.admins, + handle: community.handle, + name: community.name, + tag: community.tag, + description: community.description, + logo_url: community.logo_url, + banner_url: community.banner_url, + bio_markdown: community.bio_markdown, + github_handle: community.github_handle, + telegram_handle: community.telegram_handle, + twitter_handle: community.twitter_handle, + website_url: community.website_url, + github: community.github, + board: None, + wiki1: community.wiki1, + wiki2: community.wiki2, + features: community.features, + addons: Vec::new(), + }, + ) + }) + .collect(); + + communities.clear(); + + let mut communities_new = UnorderedMap::new(StorageKey::Communities); + + for (k, v) in migrated_communities { + communities_new.insert(&k, &v); + } + + env::state_write(&ContractV8 { + posts, + post_to_parent, + post_to_children, + label_to_posts, + access_control, + authors, + communities: communities_new, + featured_communities, + available_addons: UnorderedMap::new(StorageKey::AddOns), + }); + } +} + +#[derive(BorshSerialize, BorshDeserialize)] +pub struct CommunityV4 { + pub admins: Vec, + pub handle: CommunityHandle, + pub name: String, + pub tag: String, + pub description: String, + pub logo_url: String, + pub banner_url: String, + pub bio_markdown: Option, + pub github_handle: Option, + pub telegram_handle: Vec, + pub twitter_handle: Option, + pub website_url: Option, + /// JSON string of github board configuration + pub github: Option, + /// JSON string of kanban board configuration + pub board: Option, + pub wiki1: Option, + pub wiki2: Option, + pub features: CommunityFeatureFlags, + pub addons: Vec, +} + +#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] +pub struct ContractV8 { + pub posts: Vector, + pub post_to_parent: LookupMap, + pub post_to_children: LookupMap>, + pub label_to_posts: UnorderedMap>, + pub access_control: AccessControl, + pub authors: UnorderedMap>, + pub communities: UnorderedMap, + pub featured_communities: Vec, + pub available_addons: UnorderedMap, +} + #[derive(BorshDeserialize, BorshSerialize, Debug)] pub(crate) enum StateVersion { V1, @@ -407,6 +510,7 @@ pub(crate) enum StateVersion { V5, V6, V7, + V8, } const VERSION_KEY: &[u8] = b"VERSION"; @@ -485,6 +589,10 @@ impl Contract { Contract::unsafe_add_board_and_feature_flags(); state_version_write(&StateVersion::V7); } + StateVersion::V7 => { + Contract::unsafe_add_community_addons(); + state_version_write(&StateVersion::V8); + } _ => { return Contract::migration_done(); } diff --git a/src/post/mod.rs b/src/post/mod.rs index e341d10c..e0344589 100644 --- a/src/post/mod.rs +++ b/src/post/mod.rs @@ -53,6 +53,7 @@ pub enum StorageKey { AuthorToAuthorPosts, AuthorPosts(CryptoHash), Communities, + AddOns, } #[derive(BorshSerialize, BorshDeserialize, Serialize, Deserialize, Clone)] diff --git a/tests/snapshots/migration__deploy_contract_self_upgrade-5.snap b/tests/snapshots/migration__deploy_contract_self_upgrade-5.snap index 34bcc3d2..9021be59 100644 --- a/tests/snapshots/migration__deploy_contract_self_upgrade-5.snap +++ b/tests/snapshots/migration__deploy_contract_self_upgrade-5.snap @@ -26,5 +26,6 @@ expression: get_community "github": true, "board": true, "wiki": true - } + }, + "addons": [] }