diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5246082..847d0cd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,9 +1,9 @@ name: CI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: rununittest: name: Unit tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6e7223..cf935d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,11 @@ name: Release on: push: - branches: [ main ] + branches: [main] workflow_dispatch: inputs: environment: - description: 'Account to deploy' + description: "Account to deploy" type: environment required: true @@ -21,24 +21,20 @@ jobs: environment: ${{ inputs.environment || 'devhub.near' }} steps: - - name: Checkout repository - uses: actions/checkout@v4 - - uses: Swatinem/rust-cache@v1 - - name: Install cargo-near - run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/cargo-near/releases/latest/download/cargo-near-installer.sh | sh - - name: Build community factory contract - run: cd community-factory && cargo near build - - name: Build devhub contract - run: cargo near build - - name: Install near CLI - run: | - curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/near-cli-rs/releases/download/v0.3.1/near-cli-rs-v0.3.1-installer.sh | sh - - name: Deploy contract - run: | - output=$(near contract call-function as-transaction "$NEAR_DEVHUB_ACCOUNT_ID" unsafe_self_upgrade file-args ./target/near/devhub.wasm prepaid-gas '200 TeraGas' attached-deposit '0 NEAR' sign-as "$NEAR_DEVHUB_ACCOUNT_ID" network-config "$NEAR_NETWORK_CONNECTION" sign-with-plaintext-private-key --signer-public-key "$NEAR_DEVHUB_ACCOUNT_PUBLIC_KEY" --signer-private-key "$NEAR_DEVHUB_ACCOUNT_PRIVATE_KEY" send) - while [[ ! "$output" == *"Migration done."* ]]; do - echo "$output" - sleep 5 - output=$(near contract call-function as-transaction "$NEAR_DEVHUB_ACCOUNT_ID" unsafe_migrate json-args '{}' prepaid-gas '100 TeraGas' attached-deposit '0 NEAR' sign-as "$NEAR_DEVHUB_ACCOUNT_ID" network-config "$NEAR_NETWORK_CONNECTION" sign-with-plaintext-private-key --signer-public-key "$NEAR_DEVHUB_ACCOUNT_PUBLIC_KEY" --signer-private-key "$NEAR_DEVHUB_ACCOUNT_PRIVATE_KEY" send) - done - echo "$output" + - name: Checkout repository + uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v1 + - name: Install cargo-near + run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/cargo-near/releases/latest/download/cargo-near-installer.sh | sh + - name: Build community factory contract + run: cd community-factory && cargo near build + - name: Build devhub contract + run: cargo near build + - name: Install near CLI + run: | + curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/near-cli-rs/releases/download/v0.3.1/near-cli-rs-v0.3.1-installer.sh | sh + - name: Deploy contract + run: | + output=$(near contract call-function as-transaction "$NEAR_DEVHUB_ACCOUNT_ID" unsafe_self_upgrade file-args ./target/near/devhub.wasm prepaid-gas '200 TeraGas' attached-deposit '0 NEAR' sign-as "$NEAR_DEVHUB_ACCOUNT_ID" network-config "$NEAR_NETWORK_CONNECTION" sign-with-plaintext-private-key --signer-public-key "$NEAR_DEVHUB_ACCOUNT_PUBLIC_KEY" --signer-private-key "$NEAR_DEVHUB_ACCOUNT_PRIVATE_KEY" send) + while [[ ! "$output" == *"Migration done."* ]]; do + echo "$output" diff --git a/.gitignore b/.gitignore index 3db7b0d..b5e0cfc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ neardev/ target/ res/*.wasm -.DS_Store \ No newline at end of file +.DS_Store +.vscode \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ee6759d..e90b0b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -630,6 +630,7 @@ dependencies = [ "regex", "serde_json", "tokio", + "urlencoding", ] [[package]] @@ -3603,6 +3604,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8-width" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 9f708d3..1f5400f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ near-contract-standards.workspace = true serde_json = { version = "1.0", features = ["preserve_order"] } devhub_common.workspace = true html-escape = "0.2.13" +urlencoding = "2.1.3" [dev-dependencies] near-sdk = { workspace = true, features = ["unit-testing"] } diff --git a/src/lib.rs b/src/lib.rs index 820cdfd..62ec3f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,9 @@ pub mod access_control; +pub mod common; pub mod community; pub mod debug; pub mod migrations; mod notify; -pub mod common; pub mod proposal; pub mod rfp; pub mod stats; @@ -16,7 +16,7 @@ use crate::access_control::AccessControl; use community::*; use common::*; -use proposal::timeline::{TimelineStatusV1, TimelineStatus, VersionedTimelineStatus}; +use proposal::timeline::{TimelineStatus, TimelineStatusV1, VersionedTimelineStatus}; use proposal::*; use rfp::{ RFPId, RFPSnapshot, TimelineStatus as RFPTimelineStatus, VersionedRFP, VersionedRFPBody, RFP, @@ -26,13 +26,13 @@ use devhub_common::{social_db_contract, SetReturnType}; use near_sdk::borsh::BorshDeserialize; use near_sdk::collections::{LookupMap, UnorderedMap, Vector}; -use near_sdk::store::Lazy; use near_sdk::serde::{Deserialize, Serialize}; use near_sdk::serde_json::{json, Number, Value}; +use near_sdk::store::Lazy; use near_sdk::{env, near, require, AccountId, NearSchema, PanicOnDefault, Promise}; use web4::types::{Web4Request, Web4Response}; -use std::collections::{HashSet, HashMap}; +use std::collections::{HashMap, HashSet}; use std::convert::TryInto; type PostId = u64; @@ -90,12 +90,9 @@ impl Contract { contract } - pub fn get_proposals(&self, ids: Option< Vec >) -> Vec { + pub fn get_proposals(&self, ids: Option>) -> Vec { if let Some(ids) = ids { - ids - .into_iter() - .filter_map(|id| self.proposals.get(id.into())) - .collect() + ids.into_iter().filter_map(|id| self.proposals.get(id.into())).collect() } else { self.proposals.to_vec() } @@ -140,7 +137,10 @@ impl Contract { "Terms and conditions version cannot be from the future" ); } else { - require!(env::current_account_id() != "devhub.near", "Accepted terms and conditions version is required"); + require!( + env::current_account_id() != "devhub.near", + "Accepted terms and conditions version is required" + ); } let proposal_body = body.clone().latest_version(); @@ -301,7 +301,7 @@ impl Contract { res.sort(); res } - + pub fn get_all_authors(&self) -> Vec { let mut res: Vec<_> = self.authors.keys().collect(); res.sort(); @@ -453,7 +453,11 @@ impl Contract { } #[payable] - pub fn edit_proposal_timeline(&mut self, id: ProposalId, timeline: TimelineStatusV1) -> ProposalId { + pub fn edit_proposal_timeline( + &mut self, + id: ProposalId, + timeline: TimelineStatusV1, + ) -> ProposalId { let proposal: Proposal = self .proposals .get(id.into()) @@ -484,7 +488,11 @@ impl Contract { } #[payable] - pub fn edit_proposal_linked_rfp(&mut self, id: ProposalId, rfp_id: Option) -> ProposalId { + pub fn edit_proposal_linked_rfp( + &mut self, + id: ProposalId, + rfp_id: Option, + ) -> ProposalId { let proposal: Proposal = self .proposals .get(id.into()) @@ -507,12 +515,20 @@ impl Contract { } #[payable] - pub fn cancel_rfp(&mut self, id: RFPId, proposals_to_cancel: Vec, proposals_to_unlink: Vec) -> RFPId { + pub fn cancel_rfp( + &mut self, + id: RFPId, + proposals_to_cancel: Vec, + proposals_to_unlink: Vec, + ) -> RFPId { for proposal_id in proposals_to_cancel { let proposal: Proposal = self.get_proposal(proposal_id).into(); let proposal_timeline = proposal.snapshot.body.latest_version().timeline; let review_status = proposal_timeline.latest_version().get_review_status().clone(); - self.edit_proposal_versioned_timeline(proposal_id, TimelineStatus::Cancelled(review_status).into()); + self.edit_proposal_versioned_timeline( + proposal_id, + TimelineStatus::Cancelled(review_status).into(), + ); } for proposal_id in proposals_to_unlink { @@ -536,7 +552,8 @@ impl Contract { } pub fn get_global_labels(&self) -> Vec { - let mut result: Vec = self.global_labels_info + let mut result: Vec = self + .global_labels_info .iter() .map(|(label, label_info)| LabelInfoExtended { value: label.clone(), @@ -549,9 +566,7 @@ impl Contract { } pub fn get_rfp_linked_proposals(&self, rfp_id: RFPId) -> Vec { - self.get_linked_proposals_in_rfp(rfp_id) - .into_iter() - .collect() + self.get_linked_proposals_in_rfp(rfp_id).into_iter().collect() } #[payable] @@ -884,10 +899,13 @@ impl Contract { pub fn web4_get(&self, request: Web4Request) -> Web4Response { web4::handler::web4_get(self, request) } - + pub fn set_social_db_profile_description(&self, description: String) -> Promise { let editor = env::predecessor_account_id(); - require!(editor == env::current_account_id() || self.has_moderator(editor), "Permission denied"); + require!( + editor == env::current_account_id() || self.has_moderator(editor), + "Permission denied" + ); social_db_contract() .with_static_gas(env::prepaid_gas().saturating_div(3)) .with_attached_deposit(env::attached_deposit()) diff --git a/src/web4/handler.rs b/src/web4/handler.rs index 03f5fdf..e6126c5 100644 --- a/src/web4/handler.rs +++ b/src/web4/handler.rs @@ -6,6 +6,8 @@ use crate::{ web4::types::{Web4Request, Web4Response}, Contract, Proposal, }; +use std::borrow::Cow; +use urlencoding::decode; pub const WEB4_RESOURCE_ACCOUNT: &str = "devhub.near"; @@ -109,11 +111,11 @@ pub fn web4_get(contract: &Contract, request: Web4Request) -> Web4Response { "https://i.near.social/magic/large/https://near.social/magic/img/account/{}", ¤t_account_id ); - let redirect_path; + let mut redirect_path; let initial_props_json; - match (page, path_parts.get(2)) { - ("community", Some(handle)) => { + match (page, path_parts.get(2), path_parts.get(3)) { + ("community", Some(handle), _) => { if let Some(community) = contract.get_community(handle.to_string()) { title = format!(" - Community - {}", community.name); description = community.description; @@ -125,7 +127,7 @@ pub fn web4_get(contract: &Contract, request: Web4Request) -> Web4Response { format!("{}/widget/app?page={}&handle={}", ¤t_account_id, page, handle); initial_props_json = json!({"page": page, "handle": handle}); } - ("proposal", Some(id)) => { + ("proposal", Some(id), _) => { if let Ok(id) = id.parse::() { if let Some(versioned_proposal) = contract.proposals.get(id.into()) { let proposal_body = @@ -141,7 +143,7 @@ pub fn web4_get(contract: &Contract, request: Web4Request) -> Web4Response { redirect_path = format!("{}/widget/app?page={}&id={}", ¤t_account_id, page, id); initial_props_json = json!({"page": page, "id": id}); } - ("rfp", Some(id)) => { + ("rfp", Some(id), _) => { if let Ok(id) = id.parse::() { if let Some(versioned_rfp) = contract.rfps.get(id.into()) { let rfp_body = RFP::from(versioned_rfp).snapshot.body.latest_version(); @@ -156,12 +158,38 @@ pub fn web4_get(contract: &Contract, request: Web4Request) -> Web4Response { redirect_path = format!("{}/widget/app?page={}&id={}", ¤t_account_id, page, id); initial_props_json = json!({"page": page, "id": id}); } + // Handle blog route with community and blog title + ("blog", Some(community), Some(last)) => { + let (blog_title_encoded, _) = last.split_once('?').unwrap_or((last, "")); + let community = decode(community).unwrap_or(Cow::Borrowed(community)); + let blog_title = + decode(blog_title_encoded).unwrap_or(Cow::Borrowed(blog_title_encoded)); + + redirect_path = format!( + "{}/widget/app?page=blogv2&community={}&id={}", + ¤t_account_id, community, blog_title + ); + initial_props_json = json!({ + "page": "blogv2", + "community": community, + "id": blog_title + }); + title = format!(" - Blog - {} - {}", community, blog_title); + description = + format!("Read the latest blog from the {} community: {}", community, blog_title); + } _ => { - redirect_path = format!("{}/widget/app", ¤t_account_id); + redirect_path = format!("{}/widget/app?", ¤t_account_id); initial_props_json = json!({"page": page}); } } + let query_parameters: Option<&str> = request.path.split('?').nth(1); + + if let Some(parameters) = query_parameters { + redirect_path = format!("{}&{}", redirect_path, parameters); + } + let app_name = html_escape::encode_text(&app_name).to_string(); let title = html_escape::encode_text(&title).to_string(); let description = html_escape::encode_text(&description).to_string(); @@ -255,12 +283,12 @@ mod tests { .to_string(); let body_base64 = BASE64_STANDARD.encode(body_string); - return serde_json::json!({ + serde_json::json!({ String::from(PRELOAD_URL): { "contentType": "application/json", "body": body_base64 } - }); + }) } fn view_test_env() -> VMContext { @@ -286,7 +314,7 @@ mod tests { ); match response_before_preload { Web4Response::PreloadUrls { preload_urls } => { - assert_eq!(PRELOAD_URL, preload_urls.get(0).unwrap()) + assert_eq!(PRELOAD_URL, preload_urls.first().unwrap()) } _ => { panic!("Should return Web4Response::PreloadUrls"); @@ -443,6 +471,36 @@ mod tests { } } + #[test] + pub fn test_blog_page_response() { + view_test_env(); + let contract = Contract::new(); + + let response = web4_get( + &contract, + serde_json::from_value(serde_json::json!({ + "path": "/blog/dev-dao/blog-title?c=1&s=i", + "preloads": create_preload_result(String::from("near/dev/hub"), String::from("The decentralized home base for NEAR builders")), + })) + .unwrap(), + ); + match response { + Web4Response::Body { content_type, body } => { + assert_eq!("text/html; charset=UTF-8", content_type); + + let body_string = String::from_utf8(BASE64_STANDARD.decode(body).unwrap()).unwrap(); + assert!(body_string.contains(" - Blog - dev-dao - blog-title")); + assert!(body_string.contains("")); + assert!(body_string.contains( + "devhub.near/widget/app?page=blogv2&community=dev-dao&id=blog-title&c=1&s=i" + )); + } + _ => { + panic!("Should return Web4Response::Body"); + } + } + } + #[test] pub fn test_web4_unknown_path() { view_test_env(); @@ -761,6 +819,114 @@ mod tests { } } + #[test] + fn test_blog_page_with_special_characters() { + view_test_env(); + let contract = Contract::new(); + + let test_cases = vec![ + ( + "/blog/dev-dao/My%20First%20Blog", + " - Blog - dev-dao - My First Blog", + "Read the latest blog from the dev-dao community: My First Blog", + "not-only-devhub.near/widget/app?page=blogv2&community=dev-dao&id=My First Blog", + json!({ + "page": "blogv2", + "community": "dev-dao", + "id": "My First Blog" + }), + ), + ( + "/blog/dev-dao/My%20Blog%3F", + " - Blog - dev-dao - My Blog?", + "Read the latest blog from the dev-dao community: My Blog?", + "not-only-devhub.near/widget/app?page=blogv2&community=dev-dao&id=My Blog?", + json!({ + "page": "blogv2", + "community": "dev-dao", + "id": "My Blog?" + }), + ), + ( + "/blog/dev-dao/My%20Blog%20%26%20More", + " - Blog - dev-dao - My Blog & More", + "Read the latest blog from the dev-dao community: My Blog & More", + "not-only-devhub.near/widget/app?page=blogv2&community=dev-dao&id=My Blog & More", + json!({ + "page": "blogv2", + "community": "dev-dao", + "id": "My Blog & More" + }), + ), + ( + "/blog/dev-dao/My%2FBlog%2FTitle", + " - Blog - dev-dao - My/Blog/Title", + "Read the latest blog from the dev-dao community: My/Blog/Title", + "not-only-devhub.near/widget/app?page=blogv2&community=dev-dao&id=My/Blog/Title", + json!({ + "page": "blogv2", + "community": "dev-dao", + "id": "My/Blog/Title" + }), + ), + ]; + + for (path, expected_title, expected_description, expected_redirect_path, expected_props) in + test_cases + { + let response = web4_get( + &contract, + serde_json::from_value(serde_json::json!({ + "path": path, + "preloads": create_preload_result( + String::from("near/dev/hub"), + String::from("The decentralized home base for NEAR builders") + ), + })) + .unwrap(), + ); + + match response { + Web4Response::Body { content_type, body } => { + assert_eq!("text/html; charset=UTF-8", content_type); + + let body_string = + String::from_utf8(BASE64_STANDARD.decode(body).unwrap()).unwrap(); + assert!( + body_string.contains(&format!("{}", expected_title)), + "Expected title '{}' not found in body:\n{}", + expected_title, + body_string + ); + assert!( + body_string.contains(&format!( + "", + expected_description + )), + "Expected description '{}' not found in body:\n{}", + expected_description, + body_string + ); + assert!( + body_string.contains(&expected_redirect_path), + "Expected redirect path '{}' not found in body:\n{}", + expected_redirect_path, + body_string + ); + assert!( + body_string.contains(&expected_props.to_string()), + "Expected props '{}' not found in body:\n{}", + expected_props, + body_string + ); + } + _ => { + panic!("Should return Web4Response::Body"); + } + } + } + } + #[test] pub fn test_rfp_path() { let signer = "bob.near".to_string(); diff --git a/tests/migration/mod.rs b/tests/migration/mod.rs index 8c9b786..50144b2 100644 --- a/tests/migration/mod.rs +++ b/tests/migration/mod.rs @@ -76,7 +76,7 @@ async fn test_deploy_contract_self_upgrade() -> anyhow::Result<()> { assert!(_edit_proposal_timeline_review.is_success()); - // // Call self upgrade with current branch code + // Call self upgrade with current branch code let mut contract_upgrade_result = contract .call("unsafe_self_upgrade") .args(crate::test_env::DEVHUB_CONTRACT_WASM.clone())