diff --git a/.gitignore b/.gitignore index 215776a56c..a2e8d54929 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ ftd/t/js/**.manual.html ftd/t/js/**.script.html +# nix symlink to the build output +result + # Rust stuff target **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index 3551093146..47b2eb8566 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,7 +109,7 @@ dependencies = [ "futures-core", "futures-util", "mio 0.8.8", - "socket2 0.5.4", + "socket2 0.5.5", "tokio", "tracing", ] @@ -170,7 +170,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.5.4", + "socket2 0.5.5", "time", "url", ] @@ -844,10 +844,11 @@ dependencies = [ [[package]] name = "comrak" -version = "0.18.0" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "482aa5695bca086022be453c700a40c02893f1ba7098a2c88351de55341ae894" +checksum = "82c995deda3bfdebd07d0e2af79e9da13e4b1be652b21a746f3f5b24bf0a49ef" dependencies = [ + "derive_builder", "entities", "memchr", "once_cell", @@ -927,9 +928,9 @@ checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" [[package]] name = "cpufeatures" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1" +checksum = "3fbc60abd742b35f2492f808e1abbb83d45f72db402e14c55057edc9c7b1e9e4" dependencies = [ "libc", ] @@ -1019,11 +1020,11 @@ dependencies = [ [[package]] name = "crossterm" -version = "0.26.1" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.4.1", "crossterm_winapi", "libc", "mio 0.8.8", @@ -1257,6 +1258,37 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "0.99.17" @@ -1425,6 +1457,12 @@ dependencies = [ "syn 2.0.38", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dtoa" version = "1.0.9" @@ -1591,13 +1629,17 @@ dependencies = [ [[package]] name = "fastn" -version = "0.3.76" +version = "0.3.79" dependencies = [ "clap", "colored", + "dotenvy", "fastn-cloud", "fastn-core", "fastn-observer", + "futures", + "reqwest", + "serde", "thiserror", "tokio", "tracing", @@ -1650,7 +1692,7 @@ dependencies = [ "ignore", "indoc 2.0.4", "intl-memoizer", - "itertools 0.10.5", + "itertools 0.11.0", "magic-crypt", "mime_guess", "native-tls", @@ -1702,7 +1744,7 @@ version = "0.1.0" dependencies = [ "fastn-grammar", "indoc 2.0.4", - "itertools 0.10.5", + "itertools 0.11.0", "prettify-js", "pretty", "pretty_assertions", @@ -1713,6 +1755,7 @@ dependencies = [ [[package]] name = "fastn-observer" version = "0.1.0" +source = "git+https://github.com/fastn-stack/fastn-observer?rev=47b29ea#47b29eacf78f2ec96a4932631282db9f262ce5a4" dependencies = [ "ansi_term", "smallvec", @@ -1896,8 +1939,8 @@ name = "ftd" version = "0.3.0" dependencies = [ "colored", - "comrak 0.18.0", - "crossterm 0.26.1", + "comrak 0.19.0", + "crossterm 0.27.0", "css-color-parser", "diffy", "dioxus-html", @@ -1910,7 +1953,7 @@ dependencies = [ "include_dir", "indexmap 2.0.2", "indoc 2.0.4", - "itertools 0.10.5", + "itertools 0.11.0", "once_cell", "pretty_assertions", "rand", @@ -2234,7 +2277,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.4.9", + "socket2 0.4.10", "tokio", "tower-service", "tracing", @@ -3739,9 +3782,9 @@ dependencies = [ [[package]] name = "rquickjs" -version = "0.1.7" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc022cc82b5de6f38b2f4ddb8ed9c49cdbd7ce112e650b181598e102157257de" +checksum = "6db7788c2818f4546daabe9ae2d1ee2f4db61ab1998d4b483494c4193cc38dab" dependencies = [ "rquickjs-core", "rquickjs-macro", @@ -3749,9 +3792,9 @@ dependencies = [ [[package]] name = "rquickjs-core" -version = "0.1.7" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fa1ecc1c84b31da87e5b26ce2b5218d36ffeb5c322141c78b79fa86a6ee3b9" +checksum = "b12cf8646fe0af5bcff2822ccd162990f0679a1f9287c7257f4f4193a9d31ea9" dependencies = [ "relative-path", "rquickjs-sys", @@ -3759,9 +3802,9 @@ dependencies = [ [[package]] name = "rquickjs-macro" -version = "0.1.7" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a59ea6b93ccb811b02fefef0eec225d7fed0366b929ebf849afb22013c2953a0" +checksum = "80564583a91b0ae6b2d6b9b3d0f8ffd69a4b17202cc63a12df78dfa8983885fc" dependencies = [ "darling 0.14.4", "fnv", @@ -3777,9 +3820,9 @@ dependencies = [ [[package]] name = "rquickjs-sys" -version = "0.1.7" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24311952af42d8252e399cf48e7d470cb413b1a11a1a5b7fab648cd2edec76c5" +checksum = "b747058afd4d988d056e4972ec8516a5a86fdfc103c1c1485bfee8966a0743ae" dependencies = [ "cc", ] @@ -4214,9 +4257,9 @@ checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" [[package]] name = "socket2" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" dependencies = [ "libc", "winapi", @@ -4224,9 +4267,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" dependencies = [ "libc", "windows-sys", @@ -4500,7 +4543,7 @@ dependencies = [ "parking_lot 0.12.1", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.4", + "socket2 0.5.5", "tokio-macros", "windows-sys", ] @@ -4546,7 +4589,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand", - "socket2 0.5.4", + "socket2 0.5.5", "tokio", "tokio-util", "whoami", diff --git a/Cargo.toml b/Cargo.toml index 9cb0ec96fd..47b87cb96b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,6 @@ members = [ "fastn-issues", "fastn-js", "fastn-grammar", - "fastn-observer", # "fastn-wasm", # "fastn-runtime", ] @@ -71,7 +70,7 @@ bytemuck = { version = "1", features = [ "derive" ] } camino = "1" clap = "4" colored = "2.0.4" -crossterm = "0.26" +crossterm = "0.27" css-color-parser = "0.1" diffy = "0.3" dioxus-html = { git = "https://github.com/DioxusLabs/dioxus", rev = "fb52673433cc57a70c86185ffa7da5fa3a2394da" } @@ -84,7 +83,6 @@ fastn-core = { path = "fastn-core" } fastn-issues = { path = "fastn-issues" } fastn-package = { path = "fastn-package" } fastn-runtime = { path = "fastn-runtime" } -fastn-observer = { path = "fastn-observer" } fastn-wasm = { path = "fastn-wasm" } fastn-grammar = { path = "fastn-grammar" } fluent = "0.16" @@ -97,7 +95,7 @@ ignore = "0.4" include_dir = "0.7" indoc = "2" intl-memoizer = "0.5" -itertools = "0.10" +itertools = "0.11" log = "0.4" magic-crypt = { version = "3", default-features = false } mime_guess = "2" @@ -112,7 +110,7 @@ regex = "1" reqwest = { version = "0.11", features = ["json"] } rink = { git = "https://github.com/DioxusLabs/dioxus", rev = "fb52673433cc57a70c86185ffa7da5fa3a2394da" } ron = "0.8" -rquickjs = { version = "0.1", features = ["macro"] } +rquickjs = { version = "0.3", features = ["macro"] } quick-js = "0.4.1" rustc-hash = "1" rusty-hook = "^0.11.2" @@ -132,13 +130,17 @@ ansi_term = "0.12" walkdir = "2" smallvec = "1" wasm-bindgen = "0.2" -wasmtime = "10" -wgpu = "0.16" +wasmtime = "13" +wgpu = "0.17" winit = "0.28" zip = "0.6" prettify-js = "0.1.0" indexmap = { version = "2", features = ["serde"] } +[workspace.dependencies.fastn-observer] +git = "https://github.com/fastn-stack/fastn-observer" +rev = "47b29ea" + [workspace.dependencies.rusqlite] version = "0.29.0" features = [ @@ -200,7 +202,7 @@ features = [ [workspace.dependencies.comrak] # We use comrak for markup processing. -version = "0.18" +version = "0.19" # By default comrak ships with support for syntax highlighting using syntext for "fenced # code blocks". We have disabled that by not using default features. We did that because # we already have a way to show code in ftd, ftd.code. Further, comark requires syntect 4.6 diff --git a/design/github.md b/design/github.md new file mode 100644 index 0000000000..ce8e553ae3 --- /dev/null +++ b/design/github.md @@ -0,0 +1,26 @@ +# How Does Github Login Work? + +The auth related stuff is in `fastn_core::auth` module. + +## Login + +To login we send user to `/-/auth/login?provider=github&next=`. + +The `next` can be used to send the user to arbitrary URL after successful signing. + +We use `oauth2` crate for authentication with github. + +## Callback URL + +The callback URL is + +## CSRF Token + +Are we CSRF safe? We are generating a CSRF token using `oauth2::CsrfToken::new_random` in +`fastn_core::auth::github::login()`, but we are not checking it in `fastn_core::auth::github::callback()`. I think +we aught to, else we may be susceptible to CSRF. Not sure how someone can use CSRF in this context, but given +the library supports should too. + +How would we verify? Easiest thing would be to store it in a cookie. This is what Django does, stores CSRF token in +cookie, and verifies that tokens match on POST request etc. + diff --git a/fastn-core/src/auth/discord.rs b/fastn-core/src/auth/discord.rs index dc5f10653c..a49c4c1ebf 100644 --- a/fastn-core/src/auth/discord.rs +++ b/fastn-core/src/auth/discord.rs @@ -293,11 +293,8 @@ pub mod apis { username: String, id: String, } - let user_obj: UserDetails = fastn_core::auth::utils::get_api( - "https://discord.com/api/users/@me", - format!("{} {}", "Bearer", token).as_str(), - ) - .await?; + let user_obj: UserDetails = + fastn_core::auth::utils::get_api("https://discord.com/api/users/@me", token).await?; Ok((user_obj.username, user_obj.id)) } @@ -353,7 +350,7 @@ pub mod apis { } let user_server_list: Vec = fastn_core::auth::utils::get_api( format!("{}?limit=100", "https://discord.com/api/users/@me/guilds").as_str(), - format!("{} {}", "Bearer", token).as_str(), + token, ) .await?; Ok(user_server_list.into_iter().map(|x| x.name).collect()) diff --git a/fastn-core/src/auth/github.rs b/fastn-core/src/auth/github.rs deleted file mode 100644 index 1d7646323b..0000000000 --- a/fastn-core/src/auth/github.rs +++ /dev/null @@ -1,638 +0,0 @@ -// TODO: This has be set while creating the GitHub OAuth Application -pub const CALLBACK_URL: &str = "/auth/github/callback/"; -#[derive(Debug, serde::Deserialize, serde::Serialize)] -pub struct UserDetail { - pub token: String, - pub user_name: String, -} -// route: /auth/login/ -pub async fn login(req: actix_web::HttpRequest) -> fastn_core::Result { - // GitHub will be redirect to this url after login process completed - - let mut next_url = "/".to_string(); - if let Ok(queries) = - actix_web::web::Query::>::from_query( - req.query_string(), - ) - { - if queries.get("next").is_some() { - next_url = queries.get("next").unwrap().to_string(); - } - } - - let redirect_url: String = format!( - "{}://{}{}?next={}", - req.connection_info().scheme(), - req.connection_info().host(), - CALLBACK_URL, - next_url, - ); - - // Set up the config for the Github OAuth2 process. - // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest - let client = utils::github_client().set_redirect_uri(oauth2::RedirectUrl::new(redirect_url)?); - // Note: public_repos user:email all these things are github resources - // So we have to tell client who is getting logged in what are we going to access - let (mut authorize_url, _token) = client - .authorize_url(oauth2::CsrfToken::new_random) - .add_scope(oauth2::Scope::new("public_repo".to_string())) - .add_scope(oauth2::Scope::new("user:email".to_string())) - .add_scope(oauth2::Scope::new("read:org".to_string())) - .url(); - - // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest:~:text=an%20appropriate%20display.-,prompt,-OPTIONAL.%20Space%20delimited - authorize_url - .query_pairs_mut() - .append_pair("prompt", "consent"); - - // let mut pairs: Vec<(&str, &str)> = vec![("response_type", self.response_type.as_ref())]; - - // send redirect to /auth/github/callback/ - Ok(actix_web::HttpResponse::Found() - .append_header((actix_web::http::header::LOCATION, authorize_url.to_string())) - .finish()) -} - -// route: /auth/github/callback/ -// In this API we are accessing -// the token and setting it to cookies -pub async fn callback(req: actix_web::HttpRequest) -> fastn_core::Result { - #[derive(serde::Deserialize)] - pub struct QueryParams { - pub code: String, - pub state: String, - pub next: String, - } - let query = actix_web::web::Query::::from_query(req.query_string())?.0; - let auth_url = format!( - "{}://{}{}", - req.connection_info().scheme(), - req.connection_info().host(), - CALLBACK_URL - ); - let client = utils::github_client().set_redirect_uri(oauth2::RedirectUrl::new(auth_url)?); - match client - .exchange_code(oauth2::AuthorizationCode::new(query.code)) - .request_async(oauth2::reqwest::async_http_client) - .await - { - Ok(access_token) => { - let token = oauth2::TokenResponse::access_token(&access_token).secret(); - let user_name = apis::user_details(token).await?; - let user_detail_obj: UserDetail = UserDetail { - token: token.to_owned(), - user_name, - }; - let user_detail_str = serde_json::to_string(&user_detail_obj)?; - return Ok(actix_web::HttpResponse::Found() - .cookie( - actix_web::cookie::Cookie::build( - fastn_core::auth::AuthProviders::GitHub.as_str(), - fastn_core::auth::utils::encrypt_str(&user_detail_str).await, - ) - .domain(fastn_core::auth::utils::domain( - req.connection_info().host(), - )) - .path("/") - .permanent() - // TODO: AbrarK is running on http, - // will remove it later - // .secure(true) - .finish(), - ) - .append_header((actix_web::http::header::LOCATION, query.next)) - .finish()); - } - Err(err) => Ok(actix_web::HttpResponse::InternalServerError().body(err.to_string())), - } -} - -// it returns identities which matches to given input -pub async fn matched_identities( - ud: UserDetail, - identities: &[fastn_core::user_group::UserIdentity], -) -> fastn_core::Result> { - let github_identities = identities - .iter() - .filter(|identity| identity.key.starts_with("github")) - .collect::>(); - - if github_identities.is_empty() { - return Ok(vec![]); - } - - let mut matched_identities = vec![]; - // matched_starred_repositories - matched_identities.extend(matched_starred_repos(&ud, github_identities.as_slice()).await?); - // matched: github-watches - matched_identities.extend(matched_watched_repos(&ud, github_identities.as_slice()).await?); - // matched: github-follows - matched_identities.extend(matched_followed_org(&ud, github_identities.as_slice()).await?); - // matched: github-contributor - matched_identities.extend(matched_contributed_repos(&ud, github_identities.as_slice()).await?); - // matched: github-collaborator - matched_identities.extend(matched_collaborated_repos(&ud, github_identities.as_slice()).await?); - // matched: github-team - matched_identities.extend(matched_org_teams(&ud, github_identities.as_slice()).await?); - // matched: github-sponsor - matched_identities.extend(matched_sponsored_org(&ud, github_identities.as_slice()).await?); - - Ok(matched_identities) -} - -pub async fn matched_starred_repos( - ud: &UserDetail, - identities: &[&fastn_core::user_group::UserIdentity], -) -> fastn_core::Result> { - use itertools::Itertools; - - let starred_repos = identities - .iter() - .filter_map(|i| { - if i.key.eq("github-starred") { - Some(i.value.as_str()) - } else { - None - } - }) - .collect_vec(); - - if starred_repos.is_empty() { - return Ok(vec![]); - } - let user_starred_repos = apis::starred_repo(ud.token.as_str()).await?; - // filter the user starred repos with input - Ok(user_starred_repos - .into_iter() - .filter(|user_repo| starred_repos.contains(&user_repo.as_str())) - .map(|repo| fastn_core::user_group::UserIdentity { - key: "github-starred".to_string(), - value: repo, - }) - .collect()) -} - -pub async fn matched_watched_repos( - ud: &UserDetail, - identities: &[&fastn_core::user_group::UserIdentity], -) -> fastn_core::Result> { - use itertools::Itertools; - let watched_repos = identities - .iter() - .filter_map(|i| { - if i.key.eq("github-watches") { - Some(i.value.as_str()) - } else { - None - } - }) - .collect_vec(); - if watched_repos.is_empty() { - return Ok(vec![]); - } - let user_watched_repos = apis::watched_repo(ud.token.as_str()).await?; - // filter the user watched repos with input - Ok(user_watched_repos - .into_iter() - .filter(|user_repo| watched_repos.contains(&user_repo.as_str())) - .map(|repo| fastn_core::user_group::UserIdentity { - key: "github-watches".to_string(), - value: repo, - }) - .collect()) -} - -pub async fn matched_followed_org( - ud: &UserDetail, - identities: &[&fastn_core::user_group::UserIdentity], -) -> fastn_core::Result> { - use itertools::Itertools; - let followed_orgs = identities - .iter() - .filter_map(|i| { - if i.key.eq("github-follows") { - Some(i.value.as_str()) - } else { - None - } - }) - .collect_vec(); - if followed_orgs.is_empty() { - return Ok(vec![]); - } - let user_followed_orgs = apis::followed_org(ud.token.as_str()).await?; - // filter the user followed orgs with input - Ok(user_followed_orgs - .into_iter() - .filter(|user_org| followed_orgs.contains(&user_org.as_str())) - .map(|org| fastn_core::user_group::UserIdentity { - key: "github-follows".to_string(), - value: org, - }) - .collect()) -} - -pub async fn matched_contributed_repos( - ud: &UserDetail, - identities: &[&fastn_core::user_group::UserIdentity], -) -> fastn_core::Result> { - use itertools::Itertools; - let mut matched_repo_contributors_list: Vec = vec![]; - let contributed_repos = identities - .iter() - .filter_map(|i| { - if i.key.eq("github-contributor") { - Some(i.value.as_str()) - } else { - None - } - }) - .collect_vec(); - - if contributed_repos.is_empty() { - return Ok(vec![]); - } - for repo in &contributed_repos { - let repo_contributors = apis::repo_contributors(ud.token.as_str(), repo).await?; - - if repo_contributors.contains(&ud.user_name) { - matched_repo_contributors_list.push(String::from(repo.to_owned())); - } - } - // filter the user contributed repos with input - Ok(matched_repo_contributors_list - .into_iter() - .filter(|user_repo| contributed_repos.contains(&user_repo.as_str())) - .map(|repo| fastn_core::user_group::UserIdentity { - key: "github-contributor".to_string(), - value: repo, - }) - .collect()) -} - -pub async fn matched_collaborated_repos( - ud: &UserDetail, - identities: &[&fastn_core::user_group::UserIdentity], -) -> fastn_core::Result> { - use itertools::Itertools; - let mut matched_repo_collaborator_list: Vec = vec![]; - let collaborated_repos = identities - .iter() - .filter_map(|i| { - if i.key.eq("github-collaborator") { - Some(i.value.as_str()) - } else { - None - } - }) - .collect_vec(); - - if collaborated_repos.is_empty() { - return Ok(vec![]); - } - for repo in &collaborated_repos { - let repo_collaborator = apis::repo_collaborators(ud.token.as_str(), repo).await?; - - if repo_collaborator.contains(&ud.user_name) { - matched_repo_collaborator_list.push(String::from(repo.to_owned())); - } - } - // filter the user collaborated repos with input - Ok(matched_repo_collaborator_list - .into_iter() - .filter(|user_repo| collaborated_repos.contains(&user_repo.as_str())) - .map(|repo| fastn_core::user_group::UserIdentity { - key: "github-collaborator".to_string(), - value: repo, - }) - .collect()) -} - -pub async fn matched_org_teams( - ud: &UserDetail, - identities: &[&fastn_core::user_group::UserIdentity], -) -> fastn_core::Result> { - use itertools::Itertools; - let mut matched_org_teams: Vec = vec![]; - let org_teams = identities - .iter() - .filter_map(|i| { - if i.key.eq("github-team") { - Some(i.value.as_str()) - } else { - None - } - }) - .collect_vec(); - - if org_teams.is_empty() { - return Ok(vec![]); - } - - for org_team in org_teams.iter() { - if let Some((org_name, team_name)) = org_team.split_once('/') { - let team_members: Vec = - apis::team_members(ud.token.as_str(), org_name, team_name).await?; - if team_members.contains(&ud.user_name) { - matched_org_teams.push(org_team.to_string()); - } - } - // TODO: - // Return Error if org-name/team-name does not come - } - // filter the user joined teams with input - Ok(matched_org_teams - .into_iter() - .map(|org_team| fastn_core::user_group::UserIdentity { - key: "github-team".to_string(), - value: org_team, - }) - .collect()) -} -pub async fn matched_sponsored_org( - ud: &UserDetail, - identities: &[&fastn_core::user_group::UserIdentity], -) -> fastn_core::Result> { - use itertools::Itertools; - let mut sponsored_users_list: Vec = vec![]; - - let sponsors_list = identities - .iter() - .filter_map(|i| { - if i.key.eq("github-sponsor") { - Some(i.value.as_str()) - } else { - None - } - }) - .collect_vec(); - if sponsors_list.is_empty() { - return Ok(vec![]); - } - for sponsor in sponsors_list.iter() { - if apis::is_user_sponsored(ud.token.as_str(), ud.user_name.as_str(), sponsor.to_owned()) - .await? - { - sponsored_users_list.push(sponsor.to_string()); - } - } - // return the sponsor list - Ok(sponsored_users_list - .into_iter() - .map(|sponsor| fastn_core::user_group::UserIdentity { - key: "github-sponsor".to_string(), - value: sponsor, - }) - .collect()) -} -pub mod apis { - #[derive(Debug, serde::Deserialize)] - pub struct GraphQLResp { - pub data: Data, - } - #[derive(Debug, serde::Deserialize)] - pub struct Data { - pub user: User, - } - #[derive(Debug, serde::Deserialize)] - pub struct User { - #[serde(rename = "isSponsoredBy")] - pub is_sponsored_by: bool, - } - // TODO: API to starred a repo on behalf of the user - // API Docs: https://docs.github.com/en/rest/activity/starring#list-repositories-starred-by-the-authenticated-user - - pub async fn starred_repo(token: &str) -> fastn_core::Result> { - // API Docs: https://docs.github.com/en/rest/activity/starring#list-repositories-starred-by-the-authenticated-user - // TODO: Handle paginated response - - #[derive(Debug, serde::Deserialize)] - struct UserRepos { - full_name: String, - } - let starred_repo: Vec = fastn_core::auth::utils::get_api( - format!("{}?per_page=100", "https://api.github.com/user/starred").as_str(), - format!("{} {}", "Bearer", token).as_str(), - ) - .await?; - Ok(starred_repo.into_iter().map(|x| x.full_name).collect()) - } - - pub async fn followed_org(token: &str) -> fastn_core::Result> { - // API Docs: https://docs.github.com/en/rest/users/followers#list-followers-of-the-authenticated-user - // TODO: Handle paginated response - #[derive(Debug, serde::Deserialize)] - struct FollowedOrg { - login: String, - } - let watched_repo: Vec = fastn_core::auth::utils::get_api( - format!("{}?per_page=100", "https://api.github.com/user/following").as_str(), - format!("{} {}", "Bearer", token).as_str(), - ) - .await?; - Ok(watched_repo.into_iter().map(|x| x.login).collect()) - } - - pub async fn team_members( - token: &str, - org_title: &str, - team_slug: &str, - ) -> fastn_core::Result> { - // API Docs: https://docs.github.com/en/rest/teams/members#list-team-members - // TODO: Handle paginated response - #[derive(Debug, serde::Deserialize)] - struct TeamMembers { - login: String, - } - - let user_orgs: Vec = fastn_core::auth::utils::get_api( - format!( - "{}{}{}{}/members?per_page=100", - "https://api.github.com/orgs/", org_title, "/teams/", team_slug - ) - .as_str(), - format!("{} {}", "Bearer", token).as_str(), - ) - .await?; - Ok(user_orgs.into_iter().map(|x| x.login).collect()) - } - - pub async fn watched_repo(token: &str) -> fastn_core::Result> { - // API Docs: https://docs.github.com/en/rest/activity/watching#list-repositories-watched-by-the-authenticated-user - // TODO: Handle paginated response - #[derive(Debug, serde::Deserialize)] - struct UserRepos { - full_name: String, - } - let watched_repo: Vec = fastn_core::auth::utils::get_api( - format!( - "{}?per_page=100", - "https://api.github.com/user/subscriptions" - ) - .as_str(), - format!("{} {}", "Bearer", token).as_str(), - ) - .await?; - Ok(watched_repo.into_iter().map(|x| x.full_name).collect()) - } - pub async fn repo_contributors( - token: &str, - repo_name: &str, - ) -> fastn_core::Result> { - // API Docs: https://docs.github.com/en/rest/activity/starring#list-repositories-starred-by-the-authenticated-user - // TODO: Handle paginated response - #[derive(Debug, serde::Deserialize)] - struct RepoContributor { - login: String, - } - let repo_contributor: Vec = fastn_core::auth::utils::get_api( - format!( - "{}{}/contributors?per_page=100", - "https://api.github.com/repos/", repo_name - ) - .as_str(), - format!("{} {}", "Bearer", token).as_str(), - ) - .await?; - Ok(repo_contributor.into_iter().map(|x| x.login).collect()) - } - pub async fn repo_collaborators( - token: &str, - repo_name: &str, - ) -> fastn_core::Result> { - // API Docs: https://docs.github.com/en/rest/collaborators/collaborators#list-repository-collaborators - // TODO: Handle paginated response - #[derive(Debug, serde::Deserialize)] - struct RepoCollaborator { - login: String, - } - let repo_collaborators_list: Vec = fastn_core::auth::utils::get_api( - format!( - "{}{}/collaborators?per_page=100", - "https://api.github.com/repos/", repo_name - ) - .as_str(), - format!("{} {}", "Bearer", token).as_str(), - ) - .await?; - Ok(repo_collaborators_list - .into_iter() - .map(|x| x.login) - .collect()) - } - pub async fn is_user_sponsored( - token: &str, - user_name: &str, - sponsored_by: &str, - ) -> fastn_core::Result { - // API Docs: https://docs.github.com/en/graphql/reference/queries#user - // TODO: Handle paginated response - - let query = format!( - "{}{}{}{}{}{}{}{}{}", - "query { user(login:", - r#"""#, - user_name, - r#"""#, - "){isSponsoredBy(accountLogin:", - r#"""#, - sponsored_by, - r#"""#, - ")}}" - ); - let sponsor_obj = - graphql_sponsor_api("https://api.github.com/graphql", query.as_str(), token).await?; - if sponsor_obj.data.user.is_sponsored_by { - Ok(true) - } else { - Ok(false) - } - } - // TODO: It can be stored in the request cookies - pub async fn user_details(token: &str) -> fastn_core::Result { - // API Docs: https://docs.github.com/en/rest/users/users#get-the-authenticated-user - // TODO: Handle paginated response - #[derive(Debug, serde::Deserialize)] - struct UserDetails { - login: String, - } - let user_obj: UserDetails = fastn_core::auth::utils::get_api( - "https://api.github.com/user", - format!("{} {}", "Bearer", token).as_str(), - ) - .await?; - - Ok(String::from(&user_obj.login)) - } - - pub async fn graphql_sponsor_api( - url: &str, - query_str: &str, - token: &str, - ) -> fastn_core::Result { - let mut map: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); - map.insert("query", query_str); - - let response = reqwest::Client::new() - .post(url) - .json(&map) - .header( - reqwest::header::AUTHORIZATION, - format!("{} {}", "Bearer", token), - ) - .header(reqwest::header::ACCEPT, "application/json") - .header( - reqwest::header::USER_AGENT, - reqwest::header::HeaderValue::from_static("fastn"), - ) - .send() - .await?; - if !response.status().eq(&reqwest::StatusCode::OK) { - return Err(fastn_core::Error::APIResponseError(format!( - "GitHub API ERROR: {}", - url - ))); - } - let return_obj = response.json::().await?; - - Ok(return_obj) - } -} - -pub mod utils { - - // Lazy means a value which initialize at the first time access - // we have to access it before using it and make sure to use it while starting a server - // TODO: they should be configured with auth feature flag - // if feature flag auth is enabled Make sure that before accessing in the API these variable - // are set - static GITHUB_CLIENT_ID: once_cell::sync::Lazy = { - once_cell::sync::Lazy::new(|| { - oauth2::ClientId::new(match std::env::var("GITHUB_CLIENT_ID") { - Ok(val) => val, - Err(e) => format!("{}{}", "GITHUB_CLIENT_ID not set in env ", e), - }) - }) - }; - - static GITHUB_CLIENT_SECRET: once_cell::sync::Lazy = { - once_cell::sync::Lazy::new(|| { - oauth2::ClientSecret::new(match std::env::var("GITHUB_CLIENT_SECRET") { - Ok(val) => val, - Err(e) => format!("{}{}", "GITHUB_CLIENT_SECRET not set in env ", e), - }) - }) - }; - - pub fn github_client() -> oauth2::basic::BasicClient { - oauth2::basic::BasicClient::new( - GITHUB_CLIENT_ID.to_owned(), - Some(GITHUB_CLIENT_SECRET.to_owned()), - oauth2::AuthUrl::new("https://github.com/login/oauth/authorize".to_string()).unwrap(), - Some( - oauth2::TokenUrl::new("https://github.com/login/oauth/access_token".to_string()) - .expect("Invalid token endpoint URL"), - ), - ) - } -} diff --git a/fastn-core/src/auth/github/apis.rs b/fastn-core/src/auth/github/apis.rs new file mode 100644 index 0000000000..cbd3da6ef7 --- /dev/null +++ b/fastn-core/src/auth/github/apis.rs @@ -0,0 +1,146 @@ +#[derive(Debug, serde::Deserialize)] +pub struct GraphQLResp { + pub data: Data, +} + +#[derive(Debug, serde::Deserialize)] +pub struct Data { + pub user: User, +} + +#[derive(Debug, serde::Deserialize)] +pub struct User { + #[serde(rename = "isSponsoredBy")] + pub is_sponsored_by: bool, +} + +// TODO: API to starred a repo on behalf of the user +// API Docs: https://docs.github.com/en/rest/activity/starring#list-repositories-starred-by-the-authenticated-user +pub async fn starred_repo(token: &str) -> fastn_core::Result> { + // API Docs: https://docs.github.com/en/rest/activity/starring#list-repositories-starred-by-the-authenticated-user + // TODO: Handle paginated response + + #[derive(Debug, serde::Deserialize)] + struct UserRepos { + full_name: String, + } + let starred_repo: Vec = + fastn_core::http::get_api("https://api.github.com/user/starred?per_page=100", token) + .await?; + Ok(starred_repo.into_iter().map(|x| x.full_name).collect()) +} + +pub async fn followed_org(token: &str) -> fastn_core::Result> { + // API Docs: https://docs.github.com/en/rest/users/followers#list-followers-of-the-authenticated-user + // TODO: Handle paginated response + #[derive(Debug, serde::Deserialize)] + struct FollowedOrg { + login: String, + } + let watched_repo: Vec = + fastn_core::http::get_api("https://api.github.com/user/following?per_page=100", token) + .await?; + Ok(watched_repo.into_iter().map(|x| x.login).collect()) +} + +pub async fn team_members( + token: &str, + org_title: &str, + team_slug: &str, +) -> fastn_core::Result> { + // API Docs: https://docs.github.com/en/rest/teams/members#list-team-members + // TODO: Handle paginated response + #[derive(Debug, serde::Deserialize)] + struct TeamMembers { + login: String, + } + + let user_orgs: Vec = fastn_core::http::get_api( + format!("https://api.github.com/orgs/{org_title}/teams/{team_slug}/members?per_page=100",), + token, + ) + .await?; + Ok(user_orgs.into_iter().map(|x| x.login).collect()) +} + +pub async fn watched_repo(token: &str) -> fastn_core::Result> { + // API Docs: https://docs.github.com/en/rest/activity/watching#list-repositories-watched-by-the-authenticated-user + // TODO: Handle paginated response + #[derive(Debug, serde::Deserialize)] + struct UserRepos { + full_name: String, + } + let watched_repo: Vec = fastn_core::http::get_api( + "https://api.github.com/user/subscriptions?per_page=100", + token, + ) + .await?; + Ok(watched_repo.into_iter().map(|x| x.full_name).collect()) +} + +pub async fn repo_contributors(token: &str, repo_name: &str) -> fastn_core::Result> { + // API Docs: https://docs.github.com/en/rest/activity/starring#list-repositories-starred-by-the-authenticated-user + // TODO: Handle paginated response + #[derive(Debug, serde::Deserialize)] + struct RepoContributor { + login: String, + } + let repo_contributor: Vec = fastn_core::http::get_api( + format!("https://api.github.com/repos/{repo_name}/contributors?per_page=100",), + token, + ) + .await?; + Ok(repo_contributor.into_iter().map(|x| x.login).collect()) +} + +pub async fn repo_collaborators(token: &str, repo_name: &str) -> fastn_core::Result> { + // API Docs: https://docs.github.com/en/rest/collaborators/collaborators#list-repository-collaborators + // TODO: Handle paginated response + #[derive(Debug, serde::Deserialize)] + struct RepoCollaborator { + login: String, + } + let repo_collaborators_list: Vec = fastn_core::http::get_api( + format!("https://api.github.com/repos/{repo_name}/collaborators?per_page=100"), + token, + ) + .await?; + Ok(repo_collaborators_list + .into_iter() + .map(|x| x.login) + .collect()) +} + +pub async fn is_user_sponsored( + token: &str, + username: &str, + sponsored_by: &str, +) -> fastn_core::Result { + let query = format!( + r#"query {{ + user(login: "{username}") + {{ isSponsoredBy(accountLogin: "{sponsored_by}" )}} + }}"# + ); + let sponsor_obj: GraphQLResp = fastn_core::http::github_graphql(query.as_str(), token).await?; + if sponsor_obj.data.user.is_sponsored_by { + Ok(true) + } else { + Ok(false) + } +} + +// TODO: It can be stored in the request cookies +pub async fn username(access_token: &str) -> fastn_core::Result { + // API Docs: https://docs.github.com/en/rest/users/users#get-the-authenticated-user + // TODO: Handle paginated response + #[derive(Debug, serde::Deserialize)] + struct UserDetails { + login: String, + } + + let user_obj: UserDetails = + fastn_core::http::get_api("https://api.github.com/user", access_token).await?; + + Ok(String::from(&user_obj.login)) +} diff --git a/fastn-core/src/auth/github/mod.rs b/fastn-core/src/auth/github/mod.rs new file mode 100644 index 0000000000..98eb61cf36 --- /dev/null +++ b/fastn-core/src/auth/github/mod.rs @@ -0,0 +1,379 @@ +mod apis; +mod utils; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct UserDetail { + pub access_token: String, + pub username: String, +} + +pub async fn login( + req: &fastn_core::http::Request, + next: String, +) -> fastn_core::Result { + let redirect_url: String = format!( + "{}://{}/-/auth/github/?next={}", + req.connection_info.scheme(), + req.connection_info.host(), + next, // TODO: we should url escape this + ); + + // Note: public_repos user:email all these things are github resources + // So we have to tell oauth_client who is getting logged in what are we going to access + let (mut authorize_url, _token) = fastn_core::auth::github::utils::github_client() + .set_redirect_uri(oauth2::RedirectUrl::new(redirect_url)?) + .authorize_url(oauth2::CsrfToken::new_random) + .add_scope(oauth2::Scope::new("public_repo".to_string())) + .add_scope(oauth2::Scope::new("user:email".to_string())) + .add_scope(oauth2::Scope::new("read:org".to_string())) + .url(); + + // https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest:~:text=an%20appropriate%20display.-,prompt,-OPTIONAL.%20Space%20delimited + authorize_url + .query_pairs_mut() + .append_pair("prompt", "consent"); + + Ok(fastn_core::http::redirect(authorize_url.to_string())) +} + +// route: /-/auth/github/done/ +// In this API we are accessing +// the token and setting it to cookies +pub async fn callback( + req: &fastn_core::http::Request, + next: String, +) -> fastn_core::Result { + let code = req.q("code", "".to_string())?; + // TODO: CSRF check + + let access_token = match fastn_core::auth::github::utils::github_client() + .exchange_code(oauth2::AuthorizationCode::new(code)) + .request_async(oauth2::reqwest::async_http_client) + .await + { + Ok(access_token) => oauth2::TokenResponse::access_token(&access_token) + .secret() + .to_string(), + Err(e) => return Ok(fastn_core::server_error!("{}", e.to_string())), + }; + + let ud = UserDetail { + username: fastn_core::auth::github::apis::username(access_token.as_str()).await?, + access_token, + }; + + let user_detail_str = serde_json::to_string(&ud)?; + return Ok(actix_web::HttpResponse::Found() + .cookie( + actix_web::cookie::Cookie::build( + fastn_core::auth::AuthProviders::GitHub.as_str(), + fastn_core::auth::utils::encrypt_str(&user_detail_str).await, + ) + .domain(fastn_core::auth::utils::domain(req.connection_info.host())) + .path("/") + .permanent() + // TODO: AbrarK is running on http, + // will remove it later + // .secure(true) + .finish(), + ) + .append_header((actix_web::http::header::LOCATION, next)) + .finish()); +} + +// it returns identities which matches to given input +pub async fn matched_identities( + ud: UserDetail, + identities: &[fastn_core::user_group::UserIdentity], +) -> fastn_core::Result> { + let github_identities = identities + .iter() + .filter(|identity| identity.key.starts_with("github")) + .collect::>(); + + if github_identities.is_empty() { + return Ok(vec![]); + } + + let mut matched_identities = vec![]; + // matched_starred_repositories + matched_identities.extend(matched_starred_repos(&ud, github_identities.as_slice()).await?); + // matched: github-watches + matched_identities.extend(matched_watched_repos(&ud, github_identities.as_slice()).await?); + // matched: github-follows + matched_identities.extend(matched_followed_org(&ud, github_identities.as_slice()).await?); + // matched: github-contributor + matched_identities.extend(matched_contributed_repos(&ud, github_identities.as_slice()).await?); + // matched: github-collaborator + matched_identities.extend(matched_collaborated_repos(&ud, github_identities.as_slice()).await?); + // matched: github-team + matched_identities.extend(matched_org_teams(&ud, github_identities.as_slice()).await?); + // matched: github-sponsor + matched_identities.extend(matched_sponsored_org(&ud, github_identities.as_slice()).await?); + + Ok(matched_identities) +} + +pub async fn matched_starred_repos( + ud: &UserDetail, + identities: &[&fastn_core::user_group::UserIdentity], +) -> fastn_core::Result> { + use itertools::Itertools; + + let starred_repos = identities + .iter() + .filter_map(|i| { + if i.key.eq("github-starred") { + Some(i.value.as_str()) + } else { + None + } + }) + .collect_vec(); + + if starred_repos.is_empty() { + return Ok(vec![]); + } + let user_starred_repos = + fastn_core::auth::github::apis::starred_repo(ud.access_token.as_str()).await?; + // filter the user starred repos with input + Ok(user_starred_repos + .into_iter() + .filter(|user_repo| starred_repos.contains(&user_repo.as_str())) + .map(|repo| fastn_core::user_group::UserIdentity { + key: "github-starred".to_string(), + value: repo, + }) + .collect()) +} + +pub async fn matched_watched_repos( + ud: &UserDetail, + identities: &[&fastn_core::user_group::UserIdentity], +) -> fastn_core::Result> { + use itertools::Itertools; + let watched_repos = identities + .iter() + .filter_map(|i| { + if i.key.eq("github-watches") { + Some(i.value.as_str()) + } else { + None + } + }) + .collect_vec(); + if watched_repos.is_empty() { + return Ok(vec![]); + } + let user_watched_repos = + fastn_core::auth::github::apis::watched_repo(ud.access_token.as_str()).await?; + // filter the user watched repos with input + Ok(user_watched_repos + .into_iter() + .filter(|user_repo| watched_repos.contains(&user_repo.as_str())) + .map(|repo| fastn_core::user_group::UserIdentity { + key: "github-watches".to_string(), + value: repo, + }) + .collect()) +} + +pub async fn matched_followed_org( + ud: &UserDetail, + identities: &[&fastn_core::user_group::UserIdentity], +) -> fastn_core::Result> { + use itertools::Itertools; + let followed_orgs = identities + .iter() + .filter_map(|i| { + if i.key.eq("github-follows") { + Some(i.value.as_str()) + } else { + None + } + }) + .collect_vec(); + if followed_orgs.is_empty() { + return Ok(vec![]); + } + let user_followed_orgs = + fastn_core::auth::github::apis::followed_org(ud.access_token.as_str()).await?; + // filter the user followed orgs with input + Ok(user_followed_orgs + .into_iter() + .filter(|user_org| followed_orgs.contains(&user_org.as_str())) + .map(|org| fastn_core::user_group::UserIdentity { + key: "github-follows".to_string(), + value: org, + }) + .collect()) +} + +pub async fn matched_contributed_repos( + ud: &UserDetail, + identities: &[&fastn_core::user_group::UserIdentity], +) -> fastn_core::Result> { + use itertools::Itertools; + let mut matched_repo_contributors_list: Vec = vec![]; + let contributed_repos = identities + .iter() + .filter_map(|i| { + if i.key.eq("github-contributor") { + Some(i.value.as_str()) + } else { + None + } + }) + .collect_vec(); + + if contributed_repos.is_empty() { + return Ok(vec![]); + } + for repo in &contributed_repos { + let repo_contributors = + fastn_core::auth::github::apis::repo_contributors(ud.access_token.as_str(), repo) + .await?; + + if repo_contributors.contains(&ud.username) { + matched_repo_contributors_list.push(String::from(repo.to_owned())); + } + } + // filter the user contributed repos with input + Ok(matched_repo_contributors_list + .into_iter() + .filter(|user_repo| contributed_repos.contains(&user_repo.as_str())) + .map(|repo| fastn_core::user_group::UserIdentity { + key: "github-contributor".to_string(), + value: repo, + }) + .collect()) +} + +pub async fn matched_collaborated_repos( + ud: &UserDetail, + identities: &[&fastn_core::user_group::UserIdentity], +) -> fastn_core::Result> { + use itertools::Itertools; + let mut matched_repo_collaborator_list: Vec = vec![]; + let collaborated_repos = identities + .iter() + .filter_map(|i| { + if i.key.eq("github-collaborator") { + Some(i.value.as_str()) + } else { + None + } + }) + .collect_vec(); + + if collaborated_repos.is_empty() { + return Ok(vec![]); + } + for repo in &collaborated_repos { + let repo_collaborator = + fastn_core::auth::github::apis::repo_collaborators(ud.access_token.as_str(), repo) + .await?; + + if repo_collaborator.contains(&ud.username) { + matched_repo_collaborator_list.push(String::from(repo.to_owned())); + } + } + // filter the user collaborated repos with input + Ok(matched_repo_collaborator_list + .into_iter() + .filter(|user_repo| collaborated_repos.contains(&user_repo.as_str())) + .map(|repo| fastn_core::user_group::UserIdentity { + key: "github-collaborator".to_string(), + value: repo, + }) + .collect()) +} + +pub async fn matched_org_teams( + ud: &UserDetail, + identities: &[&fastn_core::user_group::UserIdentity], +) -> fastn_core::Result> { + use itertools::Itertools; + let mut matched_org_teams: Vec = vec![]; + let org_teams = identities + .iter() + .filter_map(|i| { + if i.key.eq("github-team") { + Some(i.value.as_str()) + } else { + None + } + }) + .collect_vec(); + + if org_teams.is_empty() { + return Ok(vec![]); + } + + for org_team in org_teams.iter() { + if let Some((org_name, team_name)) = org_team.split_once('/') { + let team_members: Vec = fastn_core::auth::github::apis::team_members( + ud.access_token.as_str(), + org_name, + team_name, + ) + .await?; + if team_members.contains(&ud.username) { + matched_org_teams.push(org_team.to_string()); + } + } + // TODO: + // Return Error if org-name/team-name does not come + } + // filter the user joined teams with input + Ok(matched_org_teams + .into_iter() + .map(|org_team| fastn_core::user_group::UserIdentity { + key: "github-team".to_string(), + value: org_team, + }) + .collect()) +} + +pub async fn matched_sponsored_org( + ud: &UserDetail, + identities: &[&fastn_core::user_group::UserIdentity], +) -> fastn_core::Result> { + use itertools::Itertools; + let mut sponsored_users_list: Vec = vec![]; + + let sponsors_list = identities + .iter() + .filter_map(|i| { + if i.key.eq("github-sponsor") { + Some(i.value.as_str()) + } else { + None + } + }) + .collect_vec(); + + if sponsors_list.is_empty() { + return Ok(vec![]); + } + + for sponsor in sponsors_list.iter() { + if fastn_core::auth::github::apis::is_user_sponsored( + ud.access_token.as_str(), + ud.username.as_str(), + sponsor.to_owned(), + ) + .await? + { + sponsored_users_list.push(sponsor.to_string()); + } + } + // return the sponsor list + Ok(sponsored_users_list + .into_iter() + .map(|sponsor| fastn_core::user_group::UserIdentity { + key: "github-sponsor".to_string(), + value: sponsor, + }) + .collect()) +} diff --git a/fastn-core/src/auth/github/utils.rs b/fastn-core/src/auth/github/utils.rs new file mode 100644 index 0000000000..7cbc2b3bc0 --- /dev/null +++ b/fastn-core/src/auth/github/utils.rs @@ -0,0 +1,34 @@ +// Lazy means a value which initialize at the first time access +// we have to access it before using it and make sure to use it while starting a server +// TODO: they should be configured with auth feature flag +// if feature flag auth is enabled Make sure that before accessing in the API these variable +// are set +static GITHUB_CLIENT_ID: once_cell::sync::Lazy = { + once_cell::sync::Lazy::new(|| { + oauth2::ClientId::new(match std::env::var("FASTN_GITHUB_CLIENT_ID") { + Ok(val) => val, + Err(e) => format!("{}{}", "FASTN_GITHUB_CLIENT_ID not set in env ", e), + }) + }) +}; + +static GITHUB_CLIENT_SECRET: once_cell::sync::Lazy = { + once_cell::sync::Lazy::new(|| { + oauth2::ClientSecret::new(match std::env::var("FASTN_GITHUB_CLIENT_SECRET") { + Ok(val) => val, + Err(e) => format!("{}{}", "FASTN_GITHUB_CLIENT_SECRET not set in env ", e), + }) + }) +}; + +pub fn github_client() -> oauth2::basic::BasicClient { + oauth2::basic::BasicClient::new( + GITHUB_CLIENT_ID.to_owned(), + Some(GITHUB_CLIENT_SECRET.to_owned()), + oauth2::AuthUrl::new("https://github.com/login/oauth/authorize".to_string()).unwrap(), + Some( + oauth2::TokenUrl::new("https://github.com/login/oauth/access_token".to_string()) + .expect("Invalid token endpoint URL"), + ), + ) +} diff --git a/fastn-core/src/auth/mod.rs b/fastn-core/src/auth/mod.rs index 7c4410077c..cb6c0cbd95 100644 --- a/fastn-core/src/auth/mod.rs +++ b/fastn-core/src/auth/mod.rs @@ -26,7 +26,7 @@ impl AuthProviders { } pub fn secret_key() -> String { - match std::env::var("SECRET_KEY") { + match std::env::var("FASTN_SECRET_KEY") { Ok(secret) => secret, Err(_e) => { println!("WARN: SECRET_KEY not set"); @@ -58,8 +58,8 @@ pub async fn get_user_data_from_cookies( let github_ud: github::UserDetail = serde_json::from_str(ud_decrypted.as_str())?; match requested_field { - "username" | "user_name" | "user-name" => Ok(Some(github_ud.user_name)), - "token" => Ok(Some(github_ud.token)), + "username" | "user_name" | "user-name" => Ok(Some(github_ud.username)), + "token" => Ok(Some(github_ud.access_token)), _ => Err(fastn_core::Error::GenericError(format!( "invalid field {} requested for platform {}", requested_field, platform diff --git a/fastn-core/src/auth/routes.rs b/fastn-core/src/auth/routes.rs index a42aeead38..b0f550f4c2 100644 --- a/fastn-core/src/auth/routes.rs +++ b/fastn-core/src/auth/routes.rs @@ -1,95 +1,45 @@ -// route: /auth/login/ +// route: /-/auth/login/ pub async fn login( - req: actix_web::HttpRequest, - edition: Option, - external_js: Vec, - inline_js: Vec, - external_css: Vec, - inline_css: Vec, -) -> fastn_core::Result { - if fastn_core::auth::utils::is_login(&req) { - return Ok(actix_web::HttpResponse::Found() - .append_header((actix_web::http::header::LOCATION, "/".to_string())) - .finish()); + req: &fastn_core::http::Request, + next: String, +) -> fastn_core::Result { + if fastn_core::auth::utils::is_authenticated(req) { + return Ok(fastn_core::http::redirect(next)); } - #[derive(serde::Deserialize)] - pub struct QueryParams { - pub platform: String, - } - let query = match actix_web::web::Query::::from_query(req.query_string()) { - Ok(q) => q, - Err(err) => { - dbg!(err); - return Ok(actix_web::HttpResponse::BadRequest() - .body("Please select the platform, by which you want to login")); - } - }; - match query.platform.as_str() { - "github" => fastn_core::auth::github::login(req).await, - _ => { - let mut req = fastn_core::http::Request::from_actix(req, actix_web::web::Bytes::new()); - req.path = "/sorry/".to_string(); - fastn_core::commands::serve::serve( - req, - edition, - external_js, - inline_js, - external_css, - inline_css, - ) - .await - } // _ => unreachable!(), + let provider = req.q("provider", "github".to_string())?; + + match provider.as_str() { + "github" => fastn_core::auth::github::login(req, next).await, + _ => Ok(fastn_core::not_found!("unknown provider: {}", provider)), } } -// route: /auth/logout/ -pub fn logout(req: actix_web::HttpRequest) -> fastn_core::Result { +// route: /-/auth/logout/ +pub fn logout(next: String) -> fastn_core::Result { // TODO: Refactor, Not happy with this code, too much of repetition of similar code - // It is logging out from all the platforms - - // Ideally it should capture the platform in the request and then logged out - // only from that platform Ok(actix_web::HttpResponse::Found() .cookie( actix_web::cookie::Cookie::build(fastn_core::auth::AuthProviders::GitHub.as_str(), "") - .domain(fastn_core::auth::utils::domain( - req.connection_info().host(), - )) - .path("/") .expires(actix_web::cookie::time::OffsetDateTime::now_utc()) .finish(), ) - .append_header((actix_web::http::header::LOCATION, "/".to_string())) + .append_header((actix_web::http::header::LOCATION, next)) .finish()) } -// handle: if request.url starts with /auth/ +// handle: if request.url starts with /-/auth/ #[tracing::instrument(skip_all)] pub async fn handle_auth( - req: actix_web::HttpRequest, - edition: Option, - external_js: Vec, - inline_js: Vec, - external_css: Vec, - inline_css: Vec, + req: fastn_core::http::Request, ) -> fastn_core::Result { + let next = req.q("next", "/".to_string())?; + match req.path() { - "/auth/login/" => { - login( - req, - edition, - external_js, - inline_js, - external_css, - inline_css, - ) - .await - } - fastn_core::auth::github::CALLBACK_URL => fastn_core::auth::github::callback(req).await, - "/auth/logout/" => logout(req), - _ => Ok(actix_web::HttpResponse::new( - actix_web::http::StatusCode::NOT_FOUND, - )), + "/-/auth/login/" => login(&req, next).await, + // TODO: This has be set while creating the GitHub OAuth Application + "/-/auth/github/" => fastn_core::auth::github::callback(&req, next).await, + "/-/auth/logout/" => logout(next), + _ => Ok(fastn_core::not_found!("route not found: {}", req.path())), } } diff --git a/fastn-core/src/auth/twitter.rs b/fastn-core/src/auth/twitter.rs index a74ff0087f..9552a9b12d 100644 --- a/fastn-core/src/auth/twitter.rs +++ b/fastn-core/src/auth/twitter.rs @@ -437,11 +437,8 @@ pub mod apis { username: String, id: String, } - let user_obj: DataObj = fastn_core::auth::utils::get_api( - "https://api.twitter.com/2/users/me", - format!("{} {}", "Bearer", token).as_str(), - ) - .await?; + let user_obj: DataObj = + fastn_core::auth::utils::get_api("https://api.twitter.com/2/users/me", token).await?; Ok((user_obj.data.username, user_obj.data.id)) } @@ -463,7 +460,7 @@ pub mod apis { "https://api.twitter.com/2/users/by/username/", username ) .as_str(), - format!("{} {}", "Bearer", token).as_str(), + token, ) .await?; Ok(user_obj.data.id) @@ -478,7 +475,7 @@ pub mod apis { "https://api.twitter.com/2/tweets/", tweet_id, "/liking_users" ) .as_str(), - format!("{} {}", "Bearer", token).as_str(), + token, ) .await?; Ok(liking_member_list.data.into_iter().map(|x| x.id).collect()) @@ -496,7 +493,7 @@ pub mod apis { "https://api.twitter.com/2/tweets/", tweet_id, "/retweeted_by" ) .as_str(), - format!("{} {}", "Bearer", token).as_str(), + token, ) .await?; Ok(retweeted_member_list @@ -515,7 +512,7 @@ pub mod apis { "https://api.twitter.com/2/users/", member_id, "/followers" ) .as_str(), - format!("{} {}", "Bearer", token).as_str(), + token, ) .await?; Ok(member_follower_list @@ -537,7 +534,7 @@ pub mod apis { "https://api.twitter.com/2/users/", member_id, "/following" ) .as_str(), - format!("{} {}", "Bearer", token).as_str(), + token, ) .await?; Ok(member_following_list @@ -559,7 +556,7 @@ pub mod apis { "https://api.twitter.com/2/spaces/", space_id, "/buyers" ) .as_str(), - format!("{} {}", "Bearer", token).as_str(), + token, ) .await?; Ok(space_ticket_buyers_list diff --git a/fastn-core/src/auth/utils.rs b/fastn-core/src/auth/utils.rs index 92018fd99f..da456fa179 100644 --- a/fastn-core/src/auth/utils.rs +++ b/fastn-core/src/auth/utils.rs @@ -8,32 +8,6 @@ pub fn domain(host: &str) -> String { } } -pub async fn get_api( - url: &str, - token: &str, -) -> fastn_core::Result { - let response = reqwest::Client::new() - .get(url) - .header(reqwest::header::AUTHORIZATION, token) - .header(reqwest::header::ACCEPT, "application/json") - .header( - reqwest::header::USER_AGENT, - reqwest::header::HeaderValue::from_static("fastn"), - ) - .send() - .await?; - - if !response.status().eq(&reqwest::StatusCode::OK) { - return Err(fastn_core::Error::APIResponseError(format!( - "fastn-API-ERROR: {}, Error: {}", - url, - response.text().await? - ))); - } - - Ok(response.json().await?) -} - pub async fn encrypt_str(user_detail_str: &String) -> String { use magic_crypt::MagicCryptTrait; let secret_key = fastn_core::auth::secret_key(); @@ -51,7 +25,7 @@ pub async fn decrypt_str(encrypted_str: &String) -> Result bool { +pub fn is_authenticated(req: &fastn_core::http::Request) -> bool { let mut found_cookie = false; for auth_provider in fastn_core::auth::AuthProviders::AUTH_ITER.iter() { dbg!(&auth_provider); diff --git a/fastn-core/src/commands/create_package.rs b/fastn-core/src/commands/create_package.rs index 12339d28e8..cf87c65ce4 100644 --- a/fastn-core/src/commands/create_package.rs +++ b/fastn-core/src/commands/create_package.rs @@ -1,7 +1,7 @@ async fn template_contents( project_name: &str, download_base_url: Option<&str>, -) -> (String, String) { +) -> (String, String, String) { let ftd = format!( r#"-- import: fastn @@ -14,8 +14,12 @@ async fn template_contents( .unwrap_or_default() ); let index = "-- ftd.text: Hello world".to_string(); + let gitignore = r#".build/ +.env + "# + .to_string(); - (ftd, index) + (ftd, index, gitignore) } pub async fn create_package( @@ -53,12 +57,11 @@ pub async fn create_package( // Create all directories if not present tokio::fs::create_dir_all(final_dir.as_str()).await?; - let tmp_contents = template_contents(name, download_base_url).await; - let tmp_fastn = tmp_contents.0; - let tmp_index = tmp_contents.1; + let (tmp_fastn, tmp_index, tmp_gitignore) = template_contents(name, download_base_url).await; fastn_core::utils::update(&final_dir.join("FASTN.ftd"), tmp_fastn.as_bytes()).await?; fastn_core::utils::update(&final_dir.join("index.ftd"), tmp_index.as_bytes()).await?; + fastn_core::utils::update(&final_dir.join(".gitignore"), tmp_gitignore.as_bytes()).await?; // Note: Not required for now // let sync_message = "Initial sync".to_string(); diff --git a/fastn-core/src/commands/serve.rs b/fastn-core/src/commands/serve.rs index 2ec1ea98dc..0bbf40fc35 100644 --- a/fastn-core/src/commands/serve.rs +++ b/fastn-core/src/commands/serve.rs @@ -631,6 +631,7 @@ async fn route( app_data: actix_web::web::Data, ) -> fastn_core::Result { tracing::info!(method = req.method().as_str(), uri = req.path()); + tracing::info!(tutor_mode = fastn_core::tutor::is_tutor()); let package_name = &app_data.package_name; @@ -638,17 +639,6 @@ async fn route( return Ok(default_response); } - if req.path().starts_with("/auth/") { - return fastn_core::auth::routes::handle_auth( - req, - app_data.edition.clone(), - app_data.external_js.clone(), - app_data.inline_js.clone(), - app_data.external_css.clone(), - app_data.inline_css.clone(), - ) - .await; - } let req = fastn_core::http::Request::from_actix(req, body); match (req.method().to_lowercase().as_str(), req.path()) { ("post", "/-/sync/") if cfg!(feature = "remote") => sync(req).await, @@ -656,6 +646,7 @@ async fn route( ("get", "/-/clone/") if cfg!(feature = "remote") => clone(req).await, ("get", t) if t.starts_with("/-/view-src/") => view_source(req).await, ("get", t) if t.starts_with("/-/edit-src/") => edit_source(req).await, + ("get", t) if t.starts_with("/-/auth/") => fastn_core::auth::routes::handle_auth(req).await, ("post", "/-/edit/") => edit(req).await, ("post", "/-/revert/") => revert(req).await, ("get", "/-/editor-sync/") => editor_sync(req).await, @@ -665,6 +656,8 @@ async fn route( ("get", "/-/poll/") => fastn_core::watcher::poll().await, ("get", "/favicon.ico") => favicon().await, ("get", "/test/") => test().await, + ("get", "/-/pwd/") => fastn_core::tutor::pwd().await, + ("get", "/-/shutdown/") => fastn_core::tutor::shutdown().await, (_, _) => { serve( req, @@ -679,6 +672,7 @@ async fn route( } } +//noinspection HttpUrlsUsage #[allow(clippy::too_many_arguments)] pub async fn listen( bind_address: &str, @@ -752,7 +746,11 @@ You can try without providing port, it will automatically pick unused port."#, .route("/{path:.*}", actix_web::web::route().to(route)) }; - println!("### Server Started ###"); + if fastn_core::tutor::is_tutor() { + println!("### Server Started in TUTOR MODE ###"); + } else { + println!("### Server Started ###"); + } println!( "Go to: http://{}:{}", bind_address, diff --git a/fastn-core/src/http.rs b/fastn-core/src/http.rs index 145d66bb34..6dbb9131c7 100644 --- a/fastn-core/src/http.rs +++ b/fastn-core/src/http.rs @@ -15,7 +15,7 @@ macro_rules! unauthorised { #[macro_export] macro_rules! not_found { ($($t:tt)*) => {{ - fastn_core::http::not_found_(format!($($t)*)) + fastn_core::http::not_found_(format!($($t)*) + "\n") }}; } @@ -86,6 +86,7 @@ pub struct Request { ip: Option, scheme: String, host: String, + pub connection_info: actix_web::dev::ConnectionInfo, // path_params: Vec<(String, )> } @@ -107,6 +108,7 @@ impl Request { uri: req.uri().to_string(), path: req.path().to_string(), query_string: req.query_string().to_string(), + connection_info: req.connection_info().clone(), headers, query: { actix_web::web::Query::>::from_query( @@ -229,6 +231,18 @@ impl Request { &self.query } + pub fn q(&self, key: &str, default: T) -> fastn_core::Result + where + T: serde::de::DeserializeOwned, + { + let value = match self.query.get(key) { + Some(v) => v, + None => return Ok(default), + }; + + Ok(serde_json::from_value(value.clone())?) + } + pub fn get_ip(&self) -> Option { self.ip.clone() } @@ -329,11 +343,17 @@ where F: FnOnce(String) -> T + Copy, T: futures::Future> + Send + 'static, { - if url[1..].contains("://") || url.starts_with("//") { - f(url).await + let mut url = if url[1..].contains("://") || url.starts_with("//") { + url } else { - f(format!("https://{}", url)).await + format!("https://{}", url) + }; + + if let Ok(package_proxy) = std::env::var("FASTN_PACKAGE_PROXY") { + url = format!("{}/proxy/?url={}", package_proxy, url); } + + f(url).await } #[tracing::instrument] @@ -567,3 +587,65 @@ pub(crate) fn get_available_port( } None } + +// TODO: move to fastn_core::http +pub async fn get_api( + url: impl AsRef, + bearer_token: &str, +) -> fastn_core::Result { + let response = reqwest::Client::new() + .get(url.as_ref()) + .header( + reqwest::header::AUTHORIZATION, + format!("{} {}", "Bearer", bearer_token), + ) + .header(reqwest::header::ACCEPT, "application/json") + .header( + reqwest::header::USER_AGENT, + reqwest::header::HeaderValue::from_static("fastn"), + ) + .send() + .await?; + + if !response.status().eq(&reqwest::StatusCode::OK) { + return Err(fastn_core::Error::APIResponseError(format!( + "fastn-API-ERROR: {}, Error: {}", + url.as_ref(), + response.text().await? + ))); + } + + Ok(response.json().await?) +} + +pub async fn github_graphql( + query: &str, + token: &str, +) -> fastn_core::Result { + let mut map: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); + map.insert("query", query); + + let response = reqwest::Client::new() + .post("https://api.github.com/graphql") + .json(&map) + .header( + reqwest::header::AUTHORIZATION, + format!("{} {}", "Bearer", token), + ) + .header(reqwest::header::ACCEPT, "application/json") + .header( + reqwest::header::USER_AGENT, + reqwest::header::HeaderValue::from_static("fastn"), + ) + .send() + .await?; + if !response.status().eq(&reqwest::StatusCode::OK) { + return Err(fastn_core::Error::APIResponseError(format!( + "GitHub API ERROR: {}", + response.status() + ))); + } + let return_obj = response.json::().await?; + + Ok(return_obj) +} diff --git a/fastn-core/src/lib.rs b/fastn-core/src/lib.rs index e51b2de802..696d5f9baf 100644 --- a/fastn-core/src/lib.rs +++ b/fastn-core/src/lib.rs @@ -14,6 +14,7 @@ mod file; mod font; mod history; mod package; +pub(crate) mod tutor; pub(crate) mod watcher; #[macro_use] mod http; diff --git a/fastn-core/src/library2022/mod.rs b/fastn-core/src/library2022/mod.rs index f008c11cb6..a19410a2b6 100644 --- a/fastn-core/src/library2022/mod.rs +++ b/fastn-core/src/library2022/mod.rs @@ -235,6 +235,7 @@ impl Library2022 { processor::figma_tokens::process_figma_tokens_old(value, kind, doc, &self.config) } "http" => processor::http::process(value, kind, doc, &self.config).await, + "tutor" => fastn_core::tutor::process(value, kind, doc).await, "toc" => processor::toc::process(value, kind, doc, &self.config), "get-data" => processor::get_data::process(value, kind, doc, &self.config), "sitemap" => processor::sitemap::process(value, kind, doc, &self.config), diff --git a/fastn-core/src/library2022/processor/user_details.rs b/fastn-core/src/library2022/processor/user_details.rs index 3a97330076..4860cb91d5 100644 --- a/fastn-core/src/library2022/processor/user_details.rs +++ b/fastn-core/src/library2022/processor/user_details.rs @@ -10,6 +10,7 @@ pub fn process( for auth_provider in fastn_core::auth::AuthProviders::AUTH_ITER.iter() { if req.cookie(auth_provider.as_str()).is_some() { found_cookie = true; + break; } } found_cookie diff --git a/fastn-core/src/tutor.rs b/fastn-core/src/tutor.rs new file mode 100644 index 0000000000..65c3229a01 --- /dev/null +++ b/fastn-core/src/tutor.rs @@ -0,0 +1,237 @@ +pub async fn pwd() -> fastn_core::Result { + if !is_tutor() { + return Ok(fastn_core::not_found!("this only works in tutor mode")); + } + + fastn_core::http::api_ok(std::env::current_dir()?.to_string_lossy()) +} + +pub async fn shutdown() -> fastn_core::Result { + if !is_tutor() { + return Ok(fastn_core::not_found!("this only works in tutor mode")); + } + + println!("/-/shutdown/ called, shutting down"); + std::process::exit(0); +} + +pub async fn process( + value: ftd::ast::VariableValue, + kind: ftd::interpreter::Kind, + doc: &ftd::interpreter::TDoc<'_>, +) -> ftd::interpreter::Result { + if !fastn_core::tutor::is_tutor() { + return Err(ftd::interpreter::Error::OtherError( + "tutor process only works in tutor mode".to_string(), + )); + } + + let state = + match tokio::fs::read(dirs::home_dir().unwrap().join(".fastn").join("tutor.json")).await { + Ok(v) => serde_json::from_slice(&v)?, + Err(e) => match e.kind() { + std::io::ErrorKind::NotFound => TutorStateFS::default(), + _ => return Err(e.into()), + }, + } + .to_state(std::env::current_dir()?)?; + + doc.from_json(&state, &kind, &value) +} + +#[derive(Debug, Default, serde::Deserialize)] +struct TutorStateFS { + done: Vec, + current: String, +} + +#[derive(Debug, serde::Serialize, PartialEq)] +struct TutorState { + workshops: Vec, +} + +impl TutorStateFS { + fn to_state>( + self: TutorStateFS, + path: T, + ) -> ftd::interpreter::Result { + use itertools::Itertools; + + let mut workshops = vec![]; + static RE: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| regex::Regex::new(r"^[a-zA-Z]-[a-zA-Z]+.*$").unwrap()); + + for entry in std::fs::read_dir(path)?.sorted_by(sort_path) { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + if !RE.is_match(&path.file_name().unwrap().to_string_lossy()) { + continue; + } + + workshops.push(Workshop::load(&path, &self)?); + } + + Ok(TutorState { workshops }) + } +} + +fn sort_path( + a: &std::io::Result, + b: &std::io::Result, +) -> std::cmp::Ordering { + a.as_ref().unwrap().path().cmp(&b.as_ref().unwrap().path()) +} + +#[derive(Debug, serde::Serialize, PartialEq)] +struct Workshop { + title: String, + url: String, + done: bool, + current: bool, + tutorials: Vec, +} + +impl Workshop { + fn load(path: &std::path::Path, state: &TutorStateFS) -> ftd::interpreter::Result { + use itertools::Itertools; + + let mut tutorials = vec![]; + let id = path.file_name().unwrap().to_string_lossy(); + + static RE: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| regex::Regex::new(r"^[0-9][0-9]-[a-zA-Z]+.*$").unwrap()); + + for entry in std::fs::read_dir(path)?.sorted_by(sort_path) { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + if !RE.is_match(&path.file_name().unwrap().to_string_lossy()) { + continue; + } + + tutorials.push(Tutorial::load(&id, &path, state)?); + } + + Ok(Workshop { + title: title_from_readme(path)?, + url: format!("/{id}/"), + done: !tutorials.iter().any(|t| !t.done), + current: tutorials.iter().any(|t| t.current), + tutorials, + }) + } +} + +fn title_from_readme(folder: &std::path::Path) -> ftd::interpreter::Result { + let content = std::fs::read_to_string(folder.join("README.md"))?; + let (title, _about) = match content.split_once("\n\n") { + Some(v) => v, + None => { + return Err(ftd::interpreter::Error::OtherError( + "invalid README.md".into(), + )) + } + }; + Ok(title.replacen("# ", "", 1)) +} + +#[derive(Debug, serde::Serialize, PartialEq)] +struct Tutorial { + id: String, + url: String, + title: String, + done: bool, + current: bool, +} + +impl Tutorial { + fn load( + parent: &str, + path: &std::path::Path, + state: &TutorStateFS, + ) -> ftd::interpreter::Result { + let id = format!("{parent}/{}", path.file_name().unwrap().to_string_lossy()); + + Ok(Tutorial { + title: title_from_readme(path)?, + done: state.done.contains(&id), + current: state.current == id, + url: format!("/{id}/"), + id, + }) + } +} + +pub fn is_tutor() -> bool { + // https://github.com/orgs/fastn-stack/discussions/1414 + // with either of these are passed we allow APIs like /-/shutdown/, `/-/start/` etc + std::env::args().any(|e| e == "tutor" || e == "--tutor") +} + +#[cfg(test)] +mod test { + use pretty_assertions::assert_eq; + + #[test] + fn test() { + let mut ts = super::TutorState { + workshops: vec![ + super::Workshop { + title: "Build Websites Using `fastn`".to_string(), + url: "/a-website/".to_string(), + done: false, + current: false, + tutorials: vec![super::Tutorial { + id: "a-website/01-hello-world".to_string(), + url: "/a-website/01-hello-world/".to_string(), + title: "Install and start using `fastn`".to_string(), + done: false, + current: false, + }], + }, + super::Workshop { + title: "Build User Interfaces Using `fastn`".to_string(), + url: "/b-ui/".to_string(), + done: false, + current: false, + tutorials: vec![super::Tutorial { + id: "b-ui/01-hello-world".to_string(), + url: "/b-ui/01-hello-world/".to_string(), + title: "Install and start using `fastn`".to_string(), + done: false, + current: false, + }], + }, + ], + }; + + assert_eq!( + super::TutorStateFS::default() + .to_state("tutor-tests/one") + .unwrap(), + ts, + ); + + ts.workshops[0].tutorials[0].done = true; + ts.workshops[0].done = true; + ts.workshops[1].current = true; + ts.workshops[1].tutorials[0].current = true; + + assert_eq!( + super::TutorStateFS { + done: vec!["a-website/01-hello-world".to_string()], + current: "b-ui/01-hello-world".to_string(), + } + .to_state("tutor-tests/one") + .unwrap(), + ts, + ); + } +} diff --git a/fastn-core/tests/01-help/cmd.p1 b/fastn-core/tests/01-help/cmd.p1 index a1885e638b..48dd61f27a 100644 --- a/fastn-core/tests/01-help/cmd.p1 +++ b/fastn-core/tests/01-help/cmd.p1 @@ -17,6 +17,7 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -v Sets the level of verbosity - -h, --help Print help - -V, --version Print version + -c, --check-for-updates Check for updates + -v Sets the level of verbosity + -h, --help Print help + -V, --version Print version diff --git a/fastn-core/tests/02-hello/output/index.html b/fastn-core/tests/02-hello/output/index.html index bcaeebf4b2..9cd0c98c4c 100644 --- a/fastn-core/tests/02-hello/output/index.html +++ b/fastn-core/tests/02-hello/output/index.html @@ -563,7 +563,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/fastn-core/tests/03-nested-document/output/index.html b/fastn-core/tests/03-nested-document/output/index.html index 2174026e55..b4650db6ba 100644 --- a/fastn-core/tests/03-nested-document/output/index.html +++ b/fastn-core/tests/03-nested-document/output/index.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/fastn-core/tests/03-nested-document/output/nested/document/index.html b/fastn-core/tests/03-nested-document/output/nested/document/index.html index f619c9cc04..2970767a43 100644 --- a/fastn-core/tests/03-nested-document/output/nested/document/index.html +++ b/fastn-core/tests/03-nested-document/output/nested/document/index.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/fastn-core/tests/03-nested-document/output/nested/index.html b/fastn-core/tests/03-nested-document/output/nested/index.html index 356cb73d11..c334813562 100644 --- a/fastn-core/tests/03-nested-document/output/nested/index.html +++ b/fastn-core/tests/03-nested-document/output/nested/index.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/fastn-core/tests/04-import-code-block/output/index.html b/fastn-core/tests/04-import-code-block/output/index.html index 2bd5cadce6..0f1c406eac 100644 --- a/fastn-core/tests/04-import-code-block/output/index.html +++ b/fastn-core/tests/04-import-code-block/output/index.html @@ -563,7 +563,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/fastn-core/tests/04-import-code-block/output/lib/index.html b/fastn-core/tests/04-import-code-block/output/lib/index.html index 54cc11b02d..38185efcca 100644 --- a/fastn-core/tests/04-import-code-block/output/lib/index.html +++ b/fastn-core/tests/04-import-code-block/output/lib/index.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/fastn-core/tests/05-hello-font/output/index.html b/fastn-core/tests/05-hello-font/output/index.html index 713f52899f..a41ce499c1 100644 --- a/fastn-core/tests/05-hello-font/output/index.html +++ b/fastn-core/tests/05-hello-font/output/index.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/fastn-core/tests/08-static-assets/output/index.html b/fastn-core/tests/08-static-assets/output/index.html index 2174026e55..b4650db6ba 100644 --- a/fastn-core/tests/08-static-assets/output/index.html +++ b/fastn-core/tests/08-static-assets/output/index.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/fastn-core/tests/09-markdown-pages/output/index.html b/fastn-core/tests/09-markdown-pages/output/index.html index 2174026e55..b4650db6ba 100644 --- a/fastn-core/tests/09-markdown-pages/output/index.html +++ b/fastn-core/tests/09-markdown-pages/output/index.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/fastn-core/tests/11-readme-with-index/output/index.html b/fastn-core/tests/11-readme-with-index/output/index.html index 30aa65dc4f..12bd740dec 100644 --- a/fastn-core/tests/11-readme-with-index/output/index.html +++ b/fastn-core/tests/11-readme-with-index/output/index.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/fastn-core/tests/15-fpm-dependency-alias/output/index.html b/fastn-core/tests/15-fpm-dependency-alias/output/index.html index 82db1fc7f3..508b42baf7 100644 --- a/fastn-core/tests/15-fpm-dependency-alias/output/index.html +++ b/fastn-core/tests/15-fpm-dependency-alias/output/index.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/fastn-core/tests/16-include-processor/output/index.html b/fastn-core/tests/16-include-processor/output/index.html index e1540936ba..3907fbf693 100644 --- a/fastn-core/tests/16-include-processor/output/index.html +++ b/fastn-core/tests/16-include-processor/output/index.html @@ -563,7 +563,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/fastn-core/tutor-tests/one/a-website/01-hello-world/README.md b/fastn-core/tutor-tests/one/a-website/01-hello-world/README.md new file mode 100644 index 0000000000..11d9765222 --- /dev/null +++ b/fastn-core/tutor-tests/one/a-website/01-hello-world/README.md @@ -0,0 +1,3 @@ +# Install and start using `fastn` + +In this exercise we will install fastn and create a basic hello world program. \ No newline at end of file diff --git a/fastn-core/tutor-tests/one/a-website/README.md b/fastn-core/tutor-tests/one/a-website/README.md new file mode 100644 index 0000000000..cfd5187ce7 --- /dev/null +++ b/fastn-core/tutor-tests/one/a-website/README.md @@ -0,0 +1,3 @@ +# Build Websites Using `fastn` + +This workshop teaches you how to build websites using `fastn`. \ No newline at end of file diff --git a/fastn-core/tutor-tests/one/b-ui/01-hello-world/README.md b/fastn-core/tutor-tests/one/b-ui/01-hello-world/README.md new file mode 100644 index 0000000000..11d9765222 --- /dev/null +++ b/fastn-core/tutor-tests/one/b-ui/01-hello-world/README.md @@ -0,0 +1,3 @@ +# Install and start using `fastn` + +In this exercise we will install fastn and create a basic hello world program. \ No newline at end of file diff --git a/fastn-core/tutor-tests/one/b-ui/README.md b/fastn-core/tutor-tests/one/b-ui/README.md new file mode 100644 index 0000000000..594b45e2af --- /dev/null +++ b/fastn-core/tutor-tests/one/b-ui/README.md @@ -0,0 +1,3 @@ +# Build User Interfaces Using `fastn` + +This workshop teaches you how to build user interfaces using `fastn`. \ No newline at end of file diff --git a/fastn-js/js/dom.js b/fastn-js/js/dom.js index e237cc64e3..d1f78713f9 100644 --- a/fastn-js/js/dom.js +++ b/fastn-js/js/dom.js @@ -1,5 +1,7 @@ let fastn_dom = {}; +fastn_dom.styleClasses = ""; + fastn_dom.codeData = { availableThemes: {}, addedCssFile: [] @@ -99,6 +101,14 @@ fastn_dom.propertyMap = { "-webkit-box-orient": "wbo", "-webkit-line-clamp": "wlc", "backdrop-filter": "bdf", + "mask-image": "mi", + "-webkit-mask-image": "wmi", + "mask-size": "ms", + "-webkit-mask-size": "wms", + "mask-repeat": "mre", + "-webkit-mask-repeat": "wmre", + "mask-position": "mp", + "-webkit-mask-position": "wmp", }; // dynamic-class-css.md @@ -280,6 +290,7 @@ fastn_dom.PropertyKind = { TextShadow: 117, Selectable: 118, BackdropFilter: 119, + Mask: 120, }; @@ -370,7 +381,6 @@ fastn_dom.Spacing = { Fixed: (value) => { return [4, value]; } } - fastn_dom.BorderStyle = { Solid: "solid", Dashed: "dashed", @@ -656,7 +666,46 @@ fastn_dom.Length = { } } +fastn_dom.Mask = { + Image: (value) => { + return [1, value]; + }, + Multi: (value) => { + return [2, value] + }, +} + +fastn_dom.MaskSize = { + Auto: "auto", + Cover: "cover", + Contain: "contain", + Fixed: (value) => { return value } +} + +fastn_dom.MaskRepeat = { + Repeat: "repeat", + RepeatX: "repeat-x", + RepeatY: "repeat-y", + NoRepeat: "no-repeat", + Space: "space", + Round: "round", +} +fastn_dom.MaskPosition = { + Left: "left", + Right: "right", + Center: "center", + LeftTop: "left top", + LeftCenter: "left center", + LeftBottom: "left bottom", + CenterTop: "center top", + CenterCenter: "center center", + CenterBottom: "center bottom", + RightTop: "right top", + RightCenter: "right center", + RightBottom: "right bottom", + Length: (value) => { return value; }, +} fastn_dom.Event = { Click: 0, @@ -757,7 +806,7 @@ class Node2 { return this.#parent; } removeAllFaviconLinks() { - if (hydrating) { + if (hydrating || rerender) { const links = document.head.querySelectorAll('link[rel="shortcut icon"]'); links.forEach( link => { link.parentNode.removeChild(link); @@ -766,7 +815,7 @@ class Node2 { } setFavicon(url) { - if (hydrating) { + if (hydrating || rerender) { if (url instanceof fastn.recordInstanceClass) url = url.get('src'); while (true) { if (url instanceof fastn.mutableClass) url = url.get(); @@ -853,7 +902,7 @@ class Node2 { } } updateMetaTitle(value) { - if (!ssr && hydrating) { + if (!ssr && (hydrating || rerender)) { if (!fastn_utils.isNull(value)) window.document.title = value; } } @@ -862,7 +911,7 @@ class Node2 { this.removeMetaTagByName(name); return; } - if (!ssr && hydrating) { + if (!ssr && (hydrating || rerender)) { const metaTag = window.document.createElement('meta'); metaTag.setAttribute('name', name); metaTag.setAttribute('content', value); @@ -874,7 +923,7 @@ class Node2 { this.removeMetaTagByProperty(property); return; } - if (!ssr && hydrating) { + if (!ssr && (hydrating || rerender)) { const metaTag = window.document.createElement('meta'); metaTag.setAttribute('property', property); metaTag.setAttribute('content', value); @@ -882,7 +931,7 @@ class Node2 { } } removeMetaTagByName(name) { - if (!ssr && hydrating) { + if (!ssr && (hydrating || rerender)) { const metaTags = document.getElementsByTagName('meta'); for (let i = 0; i < metaTags.length; i++) { const metaTag = metaTags[i]; @@ -894,7 +943,7 @@ class Node2 { } } removeMetaTagByProperty(property) { - if (!ssr && hydrating) { + if (!ssr && (hydrating || rerender)) { const metaTags = document.getElementsByTagName('meta'); for (let i = 0; i < metaTags.length; i++) { const metaTag = metaTags[i]; @@ -909,7 +958,7 @@ class Node2 { attachCss(property, value, createClass, className) { let propertyShort = fastn_dom.propertyMap[property] || property; propertyShort = `__${propertyShort}`; - let cls = `${propertyShort}-${JSON.stringify(fastn_dom.class_count)}`; + let cls = `${propertyShort}-${fastn_dom.class_count}`; if (!!className) { cls = className; } else { @@ -938,8 +987,7 @@ class Node2 { if (!!className) { if (!fastn_dom.classes[cssClass]) { fastn_dom.classes[cssClass] = fastn_dom.classes[cssClass] || obj; - let styles = document.getElementById('styles'); - styles.innerHTML = `${styles.innerHTML}${getClassAsString(cssClass, obj)}\n`; + fastn_utils.createStyle(cssClass, obj); } return cls; } @@ -953,8 +1001,7 @@ class Node2 { if (createClass) { if (!fastn_dom.classes[cssClass]) { fastn_dom.classes[cssClass] = fastn_dom.classes[cssClass] || obj; - let styles = document.getElementById('styles'); - styles.innerHTML = `${styles.innerHTML}${getClassAsString(cssClass, obj)}\n`; + fastn_utils.createStyle(cssClass, obj); } this.#node.style.removeProperty(property); this.#node.classList.add(cls); @@ -1056,16 +1103,11 @@ class Node2 { this.attachCss("text-shadow", darkShadowCss, true, `body.dark .${lightClass}`); } } - attachLinearGradientCss(value) { - if (fastn_utils.isNull(value)) { - this.attachCss("background-image", value); - return; - } + getLinearGradientString(value) { var lightGradientString = ""; var darkGradientString = ""; let colorsList = value.get("colors").get().getList(); - let direction = fastn_utils.getStaticValue(value.get("direction")); colorsList.map(function (element) { // LinearGradient RecordInstance let lg_color = element.item; @@ -1102,6 +1144,18 @@ class Node2 { lightGradientString = lightGradientString.trim().slice(0, -1); darkGradientString = darkGradientString.trim().slice(0, -1); + return [lightGradientString, darkGradientString]; + } + attachLinearGradientCss(value) { + if (fastn_utils.isNull(value)) { + this.attachCss("background-image", value); + return; + } + + let direction = fastn_utils.getStaticValue(value.get("direction")); + + const [lightGradientString, darkGradientString] = this.getLinearGradientString(value); + if (lightGradientString === darkGradientString) { this.attachCss("background-image", `linear-gradient(${direction}, ${lightGradientString})`, false); } else { @@ -1161,8 +1215,114 @@ class Node2 { this.attachCss("background-image", `url(${darkValue})`, true, `body.dark .${lightClass}`); } } + attachMaskImageCss(value, vendorPrefix) { + const propertyWithPrefix = vendorPrefix ? `${vendorPrefix}-mask-image` : "mask-image"; + + if (fastn_utils.isNull(value)) { + this.attachCss(propertyWithPrefix, value); + return; + } + + let src = fastn_utils.getStaticValue(value.get("src")); + let linearGradient = fastn_utils.getStaticValue(value.get("linear_gradient")); + let color = fastn_utils.getStaticValue(value.get("color")); + + const maskLightImageValues = []; + const maskDarkImageValues = []; + + if(!fastn_utils.isNull(src)) { + let lightValue = fastn_utils.getStaticValue(src.get("light")); + let darkValue = fastn_utils.getStaticValue(src.get("dark")); + + const lightUrl = `url(${lightValue})`; + const darkUrl = `url(${darkValue})`; + + if(!fastn_utils.isNull(linearGradient)) { + const lightImageValues = [lightUrl]; + const darkImageValues = [darkUrl]; + + if(!fastn_utils.isNull(color)) { + const lightColor = fastn_utils.getStaticValue(color.get("light")); + const darkColor = fastn_utils.getStaticValue(color.get("dark")); + + lightImageValues.push(lightColor); + darkImageValues.push(darkColor); + } + maskLightImageValues.push(`image(${lightImageValues.join(", ")})`); + maskDarkImageValues.push(`image(${darkImageValues.join(", ")})`); + } else { + maskLightImageValues.push(lightUrl); + maskDarkImageValues.push(darkUrl); + } + } + + if(!fastn_utils.isNull(linearGradient)) { + let direction = fastn_utils.getStaticValue(linearGradient.get("direction")); + + const [lightGradientString, darkGradientString] = this.getLinearGradientString(linearGradient); + + maskLightImageValues.push(`linear-gradient(${direction}, ${lightGradientString})`); + maskDarkImageValues.push(`linear-gradient(${direction}, ${darkGradientString})`); + } + + const maskLightImageString = maskLightImageValues.join(", "); + const maskDarkImageString = maskDarkImageValues.join(", "); + + if(maskLightImageString === maskDarkImageString) { + this.attachCss(propertyWithPrefix, maskLightImageString, true); + } else { + let lightClass = this.attachCss(propertyWithPrefix, maskLightImageString, true); + this.attachCss(propertyWithPrefix, maskDarkImageString, true, `body.dark .${lightClass}`); + } + } + attachMaskSizeCss(value, vendorPrefix) { + const propertyNameWithPrefix = vendorPrefix ? `${vendorPrefix}-mask-size` : "mask-size"; + if(fastn_utils.isNull(value)) { + this.attachCss(propertyNameWithPrefix, value); + } + const [size, ...two_values] = ["size", "size_x", "size_y"] + .map(size => fastn_utils.getStaticValue(value.get(size))); + + if(!fastn_utils.isNull(size)) { + this.attachCss(propertyNameWithPrefix, size, true); + } else { + const [size_x, size_y] = two_values.map(value => value || "auto"); + this.attachCss(propertyNameWithPrefix, `${size_x} ${size_y}`, true); + } + } + attachMaskMultiCss(value, vendorPrefix) { + if (fastn_utils.isNull(value)) { + this.attachCss("mask-repeat", value); + this.attachCss("mask-position", value); + this.attachCss("mask-size", value); + this.attachCss("mask-image", value); + return; + } + + const maskImage = fastn_utils.getStaticValue(value.get("image")); + this.attachMaskImageCss(maskImage); + this.attachMaskImageCss(maskImage, vendorPrefix); + this.attachMaskSizeCss(value); + this.attachMaskSizeCss(value, vendorPrefix); + const maskRepeatValue = fastn_utils.getStaticValue(value.get("repeat")); + if(fastn_utils.isNull(maskRepeatValue)) { + this.attachCss("mask-repeat", maskRepeatValue, true); + this.attachCss("-webkit-mask-repeat", maskRepeatValue, true); + } else { + this.attachCss("mask-repeat", maskRepeatValue, true); + this.attachCss("-webkit-mask-repeat", maskRepeatValue, true); + } + const maskPositionValue = fastn_utils.getStaticValue(value.get("position")); + if(fastn_utils.isNull(maskPositionValue)) { + this.attachCss("mask-position", maskPositionValue, true); + this.attachCss("-webkit-mask-position", maskPositionValue, true); + } else { + this.attachCss("mask-position", maskPositionValue, true); + this.attachCss("-webkit-mask-position", maskPositionValue, true); + } + } attachExternalCss(css) { - if (hydrating) { + if (hydrating || rerender) { let css_tag = document.createElement('link'); css_tag.rel = 'stylesheet'; css_tag.type = 'text/css'; @@ -1176,7 +1336,7 @@ class Node2 { } } attachExternalJs(js) { - if (hydrating) { + if (hydrating || rerender) { let js_tag = document.createElement('script'); js_tag.src = js; @@ -1497,8 +1657,8 @@ class Node2 { } else if (kind === fastn_dom.PropertyKind.TextShadow) { this.attachTextShadow(staticValue); } else if (kind === fastn_dom.PropertyKind.BackdropFilter) { - if (fastn_utils.isNull(value)) { - this.attachCss("backdrop-filter", value); + if (fastn_utils.isNull(staticValue)) { + this.attachCss("backdrop-filter", staticValue); return; } @@ -1529,10 +1689,27 @@ class Node2 { this.attachCss("backdrop-filter", `saturate(${fastn_utils.getStaticValue(staticValue[1])})`); break; case 9: - console.log("Here"); this.attachBackdropMultiFilter(staticValue[1]); break; } + } else if (kind === fastn_dom.PropertyKind.Mask) { + if (fastn_utils.isNull(staticValue)) { + this.attachCss("mask-image", staticValue); + return; + } + + const [backgroundType, value] = staticValue; + + switch (backgroundType) { + case fastn_dom.Mask.Image()[0]: + this.attachMaskImageCss(value); + this.attachMaskImageCss(value, "-webkit"); + break; + case fastn_dom.Mask.Multi()[0]: + this.attachMaskMultiCss(value); + this.attachMaskMultiCss(value, "-webkit"); + break; + } } else if (kind === fastn_dom.PropertyKind.Classes) { fastn_utils.removeNonFastnClasses(this); if (!fastn_utils.isNull(staticValue)) { diff --git a/fastn-js/js/postInit.js b/fastn-js/js/postInit.js index f09e309c28..06d0b88933 100644 --- a/fastn-js/js/postInit.js +++ b/fastn-js/js/postInit.js @@ -2,6 +2,34 @@ ftd.clickOutsideEvents = []; ftd.globalKeyEvents = []; ftd.globalKeySeqEvents = []; + +ftd.get_device = function () { + const MOBILE_CLASS = "mobile"; + // not at all sure about this function logic. + let width = window.innerWidth; + // In the future, we may want to have more than one break points, and + // then we may also want the theme builders to decide where the + // breakpoints should go. we should be able to fetch fpm variables + // here, or maybe simply pass the width, user agent etc. to fpm and + // let people put the checks on width user agent etc., but it would + // be good if we can standardize few breakpoints. or maybe we should + // do both, some standard breakpoints and pass the raw data. + // we would then rename this function to detect_device() which will + // return one of "desktop", "mobile". and also maybe have another + // function detect_orientation(), "landscape" and "portrait" etc., + // and instead of setting `ftd#mobile: boolean` we set `ftd#device` + // and `ftd#view-port-orientation` etc. + let mobile_breakpoint = fastn_utils.getStaticValue(ftd.breakpoint_width.get("mobile")); + if (width <= mobile_breakpoint) { + document.body.classList.add(MOBILE_CLASS); + return fastn_dom.DeviceData.Mobile; + } + if (document.body.classList.contains(MOBILE_CLASS)) { + document.body.classList.remove(MOBILE_CLASS); + } + return fastn_dom.DeviceData.Desktop; +} + ftd.post_init = function () { const DARK_MODE_COOKIE = "fastn-dark-mode"; const COOKIE_SYSTEM_LIGHT = "system-light"; @@ -9,8 +37,7 @@ ftd.post_init = function () { const COOKIE_DARK_MODE = "dark"; const COOKIE_LIGHT_MODE = "light"; const DARK_MODE_CLASS = "dark"; - const MOBILE_CLASS = "mobile"; - let last_device = "desktop"; + let last_device = ftd.device.get(); window.onresize = function () { initialise_device() @@ -71,7 +98,7 @@ ftd.post_init = function () { }) } function initialise_device() { - let current = get_device(); + let current = ftd.get_device(); if (current === last_device) { return; } @@ -80,32 +107,6 @@ ftd.post_init = function () { last_device = current; } - function get_device() { - // not at all sure about this function logic. - let width = window.innerWidth; - // In the future, we may want to have more than one break points, and - // then we may also want the theme builders to decide where the - // breakpoints should go. we should be able to fetch fpm variables - // here, or maybe simply pass the width, user agent etc. to fpm and - // let people put the checks on width user agent etc., but it would - // be good if we can standardize few breakpoints. or maybe we should - // do both, some standard breakpoints and pass the raw data. - // we would then rename this function to detect_device() which will - // return one of "desktop", "mobile". and also maybe have another - // function detect_orientation(), "landscape" and "portrait" etc., - // and instead of setting `ftd#mobile: boolean` we set `ftd#device` - // and `ftd#view-port-orientation` etc. - let mobile_breakpoint = fastn_utils.getStaticValue(ftd.breakpoint_width.get("mobile")); - if (width <= mobile_breakpoint) { - document.body.classList.add(MOBILE_CLASS); - return fastn_dom.DeviceData.Mobile; - } - if (document.body.classList.contains(MOBILE_CLASS)) { - document.body.classList.remove(MOBILE_CLASS); - } - return fastn_dom.DeviceData.Desktop; - } - /* ftd.dark-mode behaviour: @@ -216,8 +217,8 @@ ftd.post_init = function () { function start_watching_dark_mode_system_preference() { window.matchMedia('(prefers-color-scheme: dark)').addEventListener("change", update_dark_mode); } - initialise_dark_mode(); initialise_device(); + initialise_dark_mode(); initialise_click_outside_events(); initialise_global_key_events(); fastn_utils.resetFullHeight(); diff --git a/fastn-js/js/utils.js b/fastn-js/js/utils.js index 36e1912d39..b32e64ee65 100644 --- a/fastn-js/js/utils.js +++ b/fastn-js/js/utils.js @@ -45,6 +45,20 @@ let fastn_utils = { } return [node, css, attributes]; }, + createStyle(cssClass, obj) { + if (rerender) { + fastn_dom.styleClasses = `${fastn_dom.styleClasses}${getClassAsString(cssClass, obj)}\n`; + } else { + let styles = document.getElementById('styles'); + let newClasses = getClassAsString(cssClass, obj); + let textNode = document.createTextNode(newClasses); + if (styles.styleSheet) { + styles.styleSheet.cssText = newClasses; + } else { + styles.appendChild(textNode); + } + } + }, getStaticValue(obj) { if (obj instanceof fastn.mutableClass) { return this.getStaticValue(obj.get()); diff --git a/fastn-js/js/virtual.js b/fastn-js/js/virtual.js index a033fe87ad..167cd35ea4 100644 --- a/fastn-js/js/virtual.js +++ b/fastn-js/js/virtual.js @@ -3,6 +3,7 @@ let fastn_virtual = {} let id_counter = 0; let hydrating = false; let ssr = false; +let rerender = false; class ClassList { #classes = []; @@ -149,6 +150,27 @@ fastn_virtual.document = new Document2(); fastn_virtual.hydrate = function(main) { + let current_device = ftd.get_device(); + let found_device = ftd.device.get(); + if (current_device !== found_device) { + rerender = true + ftd.device = fastn.mutable(current_device); + let styles = document.getElementById("styles"); + styles.innerText = ""; + var children = document.body.children; + // Loop through the direct children and remove those with tagName 'div' + for (var i = children.length - 1; i >= 0; i--) { + var child = children[i]; + if (child.tagName === 'DIV') { + document.body.removeChild(child); + } + } + + main(document.body); + rerender = false; + styles.innerHTML = fastn_dom.styleClasses; + return; + } hydrating = true; let body = fastn_virtual.document.createElement("body"); main(body); diff --git a/fastn-js/src/property.rs b/fastn-js/src/property.rs index 174d0c52e1..4018fb2613 100644 --- a/fastn-js/src/property.rs +++ b/fastn-js/src/property.rs @@ -427,6 +427,7 @@ pub enum PropertyKind { Favicon, Selectable, BackdropFilter, + Mask, } impl PropertyKind { @@ -566,6 +567,7 @@ impl PropertyKind { PropertyKind::Favicon => "fastn_dom.PropertyKind.Favicon", PropertyKind::Selectable => "fastn_dom.PropertyKind.Selectable", PropertyKind::BackdropFilter => "fastn_dom.PropertyKind.BackdropFilter", + PropertyKind::Mask => "fastn_dom.PropertyKind.Mask", } } } diff --git a/fastn-js/src/to_js.rs b/fastn-js/src/to_js.rs index 470c771971..e31e3935ef 100644 --- a/fastn-js/src/to_js.rs +++ b/fastn-js/src/to_js.rs @@ -1034,6 +1034,17 @@ impl ExpressionGenerator { }; if node.operator().get_variable_identifier_read().is_some() && !no_getter { + let chain_dot_operator_count = value.matches('.').count(); + // When there are chained dot operator value + // like person.name, person.meta.address + if chain_dot_operator_count > 1 { + return format!( + "fastn_utils.getter({})", + get_chained_getter_string(value.as_str()) + ); + } + + // When there is no chained dot operator value format!("fastn_utils.getter({})", value) } else { value @@ -1108,6 +1119,24 @@ impl ExpressionGenerator { } } +pub fn get_chained_getter_string(value: &str) -> String { + let chain_dot_operator_count = value.matches('.').count(); + if chain_dot_operator_count > 1 { + if let Some((variable, key)) = value.rsplit_once('.') { + // Ignore values which are already resolved with get() + if key.contains("get") { + return value.to_string(); + } + return format!( + "fastn_utils.getterByKey({}, \"{}\")", + get_chained_getter_string(variable), + key.replace('-', "_") // record fields are stored in snake case + ); + } + } + value.to_string() +} + #[cfg(test)] #[track_caller] pub fn e(f: fastn_js::Ast, s: &str) { diff --git a/fastn-observer/Cargo.toml b/fastn-observer/Cargo.toml deleted file mode 100644 index 392c9fa3c9..0000000000 --- a/fastn-observer/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "fastn-observer" -version = "0.1.0" -edition = "2021" - -[dependencies] -tracing.workspace = true -tracing-subscriber.workspace = true -tokio.workspace = true -smallvec.workspace = true -ansi_term.workspace = true \ No newline at end of file diff --git a/fastn-observer/src/duration_display.rs b/fastn-observer/src/duration_display.rs deleted file mode 100644 index b5335507bd..0000000000 --- a/fastn-observer/src/duration_display.rs +++ /dev/null @@ -1,21 +0,0 @@ -// borrowed from https://github.com/QnnOkabayashi/tracing-forest/ (license: MIT) - -pub struct DurationDisplay(pub(crate) f64); - -// Taken from chrono -impl std::fmt::Display for DurationDisplay { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - let mut t = self.0; - for unit in ["ns", "µs", "ms", "s"] { - if t < 10.0 { - return write!(f, "{:.2}{}", t, unit); - } else if t < 100.0 { - return write!(f, "{:.1}{}", t, unit); - } else if t < 1000.0 { - return write!(f, "{:.0}{}", t, unit); - } - t /= 1000.0; - } - write!(f, "{:.0}s", t * 1000.0) - } -} diff --git a/fastn-observer/src/field.rs b/fastn-observer/src/field.rs deleted file mode 100644 index d6764aa937..0000000000 --- a/fastn-observer/src/field.rs +++ /dev/null @@ -1,26 +0,0 @@ -// borrowed from https://github.com/QnnOkabayashi/tracing-forest/ (license: MIT) - -pub type FieldSet = smallvec::SmallVec<[Field; 3]>; - -/// A key-value pair recorded from trace data. -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub struct Field { - key: &'static str, - value: String, -} - -impl Field { - pub(crate) fn new(key: &'static str, value: String) -> Self { - Field { key, value } - } - - /// Returns the field's key. - pub fn key(&self) -> &'static str { - self.key - } - - /// Returns the field's value. - pub fn value(&self) -> &str { - &self.value - } -} diff --git a/fastn-observer/src/formatter.rs b/fastn-observer/src/formatter.rs deleted file mode 100644 index d0b5ed9052..0000000000 --- a/fastn-observer/src/formatter.rs +++ /dev/null @@ -1,207 +0,0 @@ -// borrowed from https://github.com/QnnOkabayashi/tracing-forest/ (license: MIT) - -pub fn write_immediate( - _event: &fastn_observer::Event, - _current: Option<&tracing_subscriber::registry::SpanRef>, -) -> std::io::Result<()> -where - S: for<'a> tracing_subscriber::registry::LookupSpan<'a>, -{ - // dbg!(event); - Ok(()) -} - -type IndentVec = smallvec::SmallVec<[Indent; 32]>; - -use ansi_term::Color; - -/// Format logs for pretty printing. -/// -/// # Examples -/// -/// An arbitrarily complex example: -/// ```log -/// INFO try_from_entry_ro [ 324µs | 8.47% / 100.00% ] -/// INFO ┝━ server::internal_search [ 296µs | 19.02% / 91.53% ] -/// INFO │ ┝━ i [filter.info]: Some filter info... -/// INFO │ ┝━ server::search [ 226µs | 10.11% / 70.01% ] -/// INFO │ │ ┝━ be::search [ 181µs | 6.94% / 55.85% ] -/// INFO │ │ │ ┕━ be::search -> filter2idl [ 158µs | 19.65% / 48.91% ] -/// INFO │ │ │ ┝━ be::idl_arc_sqlite::get_idl [ 20.4µs | 6.30% ] -/// INFO │ │ │ │ ┕━ i [filter.info]: Some filter info... -/// INFO │ │ │ ┕━ be::idl_arc_sqlite::get_idl [ 74.3µs | 22.96% ] -/// ERROR │ │ │ ┝━ 🚨 [admin.error]: On no, an admin error occurred :( -/// DEBUG │ │ │ ┝━ 🐛 [debug]: An untagged debug log -/// INFO │ │ │ ┕━ i [admin.info]: there's been a big mistake | alive: false | status: "very sad" -/// INFO │ │ ┕━ be::idl_arc_sqlite::get_identry [ 13.1µs | 4.04% ] -/// ERROR │ │ ┝━ 🔐 [security.critical]: A security critical log -/// INFO │ │ ┕━ 🔓 [security.access]: A security access log -/// INFO │ ┕━ server::search [ 8.08µs | 2.50% ] -/// WARN │ ┕━ 🚧 [filter.warn]: Some filter warning -/// TRACE ┕━ 📍 [trace]: Finished! -/// ``` -#[derive(Debug)] -pub struct Pretty; - -impl Pretty { - pub fn fmt(&self, tree: &fastn_observer::Tree) -> Result { - let mut writer = String::with_capacity(256); - - Pretty::format_tree(tree, None, &mut IndentVec::new(), &mut writer)?; - - Ok(writer) - } -} - -impl Pretty { - fn format_tree( - tree: &fastn_observer::Tree, - duration_root: Option, - indent: &mut IndentVec, - writer: &mut String, - ) -> std::fmt::Result { - match tree { - fastn_observer::Tree::Event(event) => { - Pretty::format_shared(&event.shared, writer)?; - Pretty::format_indent(indent, writer)?; - Pretty::format_event(event, writer) - } - fastn_observer::Tree::Span(span) => { - Pretty::format_shared(&span.shared, writer)?; - Pretty::format_indent(indent, writer)?; - Pretty::format_span(span, duration_root, indent, writer) - } - } - } - - fn format_shared(shared: &fastn_observer::Shared, writer: &mut String) -> std::fmt::Result { - use std::fmt::Write; - - write!(writer, "{:<8} ", ColorLevel(shared.level)) - } - - fn format_indent(indent: &[Indent], writer: &mut String) -> std::fmt::Result { - use std::fmt::Write; - - for indent in indent { - writer.write_str(indent.repr())?; - } - Ok(()) - } - - fn format_event(event: &fastn_observer::Event, writer: &mut String) -> std::fmt::Result { - use std::fmt::Write; - - // write!(writer, "{} [{}]: ", tag.icon(), tag)?; - - if let Some(ref message) = event.message { - writer.write_str(message)?; - } - - for field in event.shared.fields.iter() { - write!( - writer, - " | {} {}: {}", - fastn_observer::DurationDisplay(event.shared.on.as_nanos() as f64), - field.key(), - field.value() - )?; - } - - writeln!(writer) - } - - fn format_span( - span: &fastn_observer::Span, - duration_root: Option, - indent: &mut IndentVec, - writer: &mut String, - ) -> std::fmt::Result { - use std::fmt::Write; - - let total_duration = span.duration.as_nanos() as f64; - let root_duration = duration_root.unwrap_or(total_duration); - - write!( - writer, - "{} {} [ {} ] ", - fastn_observer::DurationDisplay(span.shared.on.as_nanos() as f64), - span.name, - fastn_observer::DurationDisplay(total_duration) - )?; - - for (n, field) in span.shared.fields.iter().enumerate() { - write!( - writer, - "{} {}: {}", - if n == 0 { "" } else { " |" }, - field.key(), - field.value() - )?; - } - writeln!(writer)?; - - if let Some((last, remaining)) = span.nodes.split_last() { - match indent.last_mut() { - Some(edge @ Indent::Turn) => *edge = Indent::Null, - Some(edge @ Indent::Fork) => *edge = Indent::Line, - _ => {} - } - - indent.push(Indent::Fork); - - for tree in remaining { - if let Some(edge) = indent.last_mut() { - *edge = Indent::Fork; - } - Pretty::format_tree(tree, Some(root_duration), indent, writer)?; - } - - if let Some(edge) = indent.last_mut() { - *edge = Indent::Turn; - } - Pretty::format_tree(last, Some(root_duration), indent, writer)?; - - indent.pop(); - } - - Ok(()) - } -} - -enum Indent { - Null, - Line, - Fork, - Turn, -} - -impl Indent { - fn repr(&self) -> &'static str { - match self { - Self::Null => " ", - Self::Line => "│ ", - Self::Fork => "┝━ ", - Self::Turn => "┕━ ", - } - } -} - -// From tracing-tree -struct ColorLevel(tracing::Level); - -impl std::fmt::Display for ColorLevel { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let color = match self.0 { - tracing::Level::TRACE => Color::Purple, - tracing::Level::DEBUG => Color::Blue, - tracing::Level::INFO => Color::Green, - tracing::Level::WARN => Color::RGB(252, 234, 160), // orange - tracing::Level::ERROR => Color::Red, - }; - let style = color.bold(); - write!(f, "{}", style.prefix())?; - f.pad(self.0.as_str())?; - write!(f, "{}", style.suffix()) - } -} diff --git a/fastn-observer/src/layer.rs b/fastn-observer/src/layer.rs deleted file mode 100644 index a2a80ba001..0000000000 --- a/fastn-observer/src/layer.rs +++ /dev/null @@ -1,130 +0,0 @@ -// borrowed from https://github.com/QnnOkabayashi/tracing-forest/ (license: MIT) - -pub const SPAN_NOT_IN_CONTEXT: &str = "Span not in context, this is a bug"; -pub const OPENED_SPAN_NOT_IN_EXTENSIONS: &str = - "Span extension doesn't contain `OpenedSpan`, this is a bug"; -pub const WRITING_URGENT_ERROR: &str = "writing_urgent failed, this is a bug"; - -#[derive(Default)] -pub struct Layer {} - -impl tracing_subscriber::Layer for Layer -where - S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a> + std::fmt::Debug, -{ - fn on_new_span( - &self, - attrs: &tracing::span::Attributes, - id: &tracing::Id, - ctx: tracing_subscriber::layer::Context, - ) { - let span = ctx.span(id).expect(SPAN_NOT_IN_CONTEXT); - let opened = fastn_observer::OpenedSpan::new( - attrs, - span.parent().and_then(|v| { - v.extensions() - .get::() - .map(|v| v.start) - }), - ); - - let mut extensions = span.extensions_mut(); - extensions.insert(opened); - } - - fn on_event(&self, event: &tracing::Event, ctx: tracing_subscriber::layer::Context) { - struct Visitor { - message: Option, - fields: fastn_observer::FieldSet, - immediate: bool, - } - - impl tracing::field::Visit for Visitor { - fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { - match field.name() { - "immediate" => self.immediate |= value, - _ => self.record_debug(field, &value), - } - } - - fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { - let value = format!("{:?}", value); - match field.name() { - "message" if self.message.is_none() => self.message = Some(value), - key => self.fields.push(fastn_observer::Field::new(key, value)), - } - } - } - - let mut visitor = Visitor { - message: None, - fields: fastn_observer::FieldSet::default(), - immediate: false, - }; - - event.record(&mut visitor); - let current_span = ctx.event_span(event); - - let shared = fastn_observer::Shared { - level: *event.metadata().level(), - fields: visitor.fields, - on: current_span - .as_ref() - .and_then(|v| { - v.extensions() - .get::() - .map(|v| v.start) - }) - .unwrap_or_else(std::time::Instant::now) - .elapsed(), - }; - - let tree_event = fastn_observer::Event { - shared, - message: visitor.message, - }; - - if visitor.immediate { - fastn_observer::write_immediate(&tree_event, current_span.as_ref()) - .expect(WRITING_URGENT_ERROR); - } - - match current_span.as_ref() { - Some(parent) => parent - .extensions_mut() - .get_mut::() - .expect(OPENED_SPAN_NOT_IN_EXTENSIONS) - .record_event(tree_event), - None => fastn_observer::write_immediate(&tree_event, current_span.as_ref()) - .expect(WRITING_URGENT_ERROR), - } - } - - fn on_close(&self, id: tracing::Id, ctx: tracing_subscriber::layer::Context) { - let span_ref = ctx.span(&id).expect(SPAN_NOT_IN_CONTEXT); - - let span = span_ref - .extensions_mut() - .remove::() - .expect(OPENED_SPAN_NOT_IN_EXTENSIONS) - .close(); - - match span_ref.parent() { - Some(parent) => parent - .extensions_mut() - .get_mut::() - .expect(OPENED_SPAN_NOT_IN_EXTENSIONS) - .record_span(span), - None => { - if fastn_observer::is_traced() { - println!( - "{}", - fastn_observer::formatter::Pretty {} - .fmt(&fastn_observer::Tree::Span(span)) - .unwrap() - ); - } - } - } - } -} diff --git a/fastn-observer/src/lib.rs b/fastn-observer/src/lib.rs deleted file mode 100644 index 310f7009e4..0000000000 --- a/fastn-observer/src/lib.rs +++ /dev/null @@ -1,33 +0,0 @@ -extern crate self as fastn_observer; - -mod duration_display; -mod field; -mod formatter; -mod layer; -mod opened_span; -mod tree; - -pub(crate) use duration_display::DurationDisplay; -pub use field::{Field, FieldSet}; -pub use formatter::write_immediate; -pub use layer::Layer; -pub use opened_span::OpenedSpan; -pub use tree::{Event, Shared, Span, Tree}; - -pub fn observe() { - use tracing_subscriber::layer::SubscriberExt; - - // let level = std::env::var("TRACING") - // .unwrap_or_else(|_| "info".to_string()) - // .parse::() - // .unwrap_or(tracing_forest::util::LevelFilter::INFO); - - let s = tracing_subscriber::registry() - //.with(level) - .with(Layer::default()); - tracing::subscriber::set_global_default(s).unwrap(); -} - -pub fn is_traced() -> bool { - std::env::var("TRACING").is_ok() || std::env::args().any(|e| e == "--trace") -} diff --git a/fastn-observer/src/main.rs b/fastn-observer/src/main.rs deleted file mode 100644 index 17d2769c9d..0000000000 --- a/fastn-observer/src/main.rs +++ /dev/null @@ -1,31 +0,0 @@ -fn main() { - fastn_observer::observe(); - - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .unwrap() - .block_on(outer_main(2, 3)) -} - -#[tracing::instrument] -async fn outer_main(time1: u64, time2: u64) { - tracing::info!(time1, time2); - test(time1, time2).await; - tracing::info!("we are done"); -} - -#[tracing::instrument] -async fn foo(time: u64) { - tracing::info!(time); - tokio::time::sleep(std::time::Duration::from_secs(time)).await; - tracing::info!(tag = "we are done"); -} - -#[tracing::instrument] -async fn test(time1: u64, time2: u64) { - tracing::info!(hello = 10); - tokio::time::sleep(std::time::Duration::from_secs(time1)).await; - foo(time2).await; - tracing::info!(awesome = "we are done"); -} diff --git a/fastn-observer/src/opened_span.rs b/fastn-observer/src/opened_span.rs deleted file mode 100644 index 9f091882d0..0000000000 --- a/fastn-observer/src/opened_span.rs +++ /dev/null @@ -1,47 +0,0 @@ -// borrowed from https://github.com/QnnOkabayashi/tracing-forest/ (license: MIT) - -pub struct OpenedSpan { - span: fastn_observer::Span, - pub start: std::time::Instant, -} - -impl OpenedSpan { - pub fn new( - attrs: &tracing::span::Attributes, - parent_start: Option, - ) -> Self { - let start = std::time::Instant::now(); - let mut fields = fastn_observer::FieldSet::default(); - - attrs.record( - &mut |field: &tracing::field::Field, value: &dyn std::fmt::Debug| { - let value = format!("{:?}", value); - fields.push(fastn_observer::Field::new(field.name(), value)); - }, - ); - - let shared = fastn_observer::Shared { - level: *attrs.metadata().level(), - fields, - on: parent_start.unwrap_or(start).elapsed(), - }; - - OpenedSpan { - span: fastn_observer::Span::new(shared, attrs.metadata().name()), - start, - } - } - - pub fn close(mut self) -> fastn_observer::Span { - self.span.duration = self.start.elapsed(); - self.span - } - - pub fn record_event(&mut self, event: fastn_observer::Event) { - self.span.nodes.push(fastn_observer::Tree::Event(event)); - } - - pub fn record_span(&mut self, span: fastn_observer::Span) { - self.span.nodes.push(fastn_observer::Tree::Span(span)); - } -} diff --git a/fastn-observer/src/tree.rs b/fastn-observer/src/tree.rs deleted file mode 100644 index 19b2bec5eb..0000000000 --- a/fastn-observer/src/tree.rs +++ /dev/null @@ -1,40 +0,0 @@ -// borrowed from https://github.com/QnnOkabayashi/tracing-forest/ (license: MIT) - -#[derive(Debug)] -pub enum Tree { - Event(Event), - Span(Span), -} - -#[derive(Debug)] -pub struct Event { - pub(crate) shared: Shared, - pub(crate) message: Option, -} - -#[derive(Debug)] -pub struct Span { - pub(crate) shared: Shared, - pub(crate) name: &'static str, - pub(crate) duration: std::time::Duration, - pub(crate) nodes: Vec, -} - -#[derive(Debug)] -pub struct Shared { - pub(crate) level: tracing::Level, - pub(crate) fields: fastn_observer::FieldSet, - /// when did this event occur, with respect to immediate parent start - pub(crate) on: std::time::Duration, -} - -impl Span { - pub(crate) fn new(shared: Shared, name: &'static str) -> Self { - Span { - shared, - name, - duration: std::time::Duration::ZERO, - nodes: Vec::new(), - } - } -} diff --git a/fastn/Cargo.toml b/fastn/Cargo.toml index 2b535608ab..227487f9f4 100644 --- a/fastn/Cargo.toml +++ b/fastn/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fastn" -version = "0.3.76" +version = "0.3.79" authors.workspace = true edition.workspace = true license.workspace = true @@ -13,7 +13,11 @@ colored.workspace = true fastn-cloud.workspace = true fastn-observer.workspace = true fastn-core.workspace = true +futures.workspace = true +reqwest.workspace = true +serde.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +dotenvy = "0.15.7" diff --git a/fastn/src/main.rs b/fastn/src/main.rs index 46a1525186..ffc73481d8 100644 --- a/fastn/src/main.rs +++ b/fastn/src/main.rs @@ -1,4 +1,5 @@ mod commands; + pub fn main() { fastn_observer::observe(); @@ -26,11 +27,27 @@ pub enum Error { async fn async_main() -> Result<(), Error> { let matches = app(version()).get_matches(); + + set_env_vars(); + if cloud_commands(&matches).await? { return Ok(()); } - fastn_core_commands(&matches).await?; - Ok(()) + + futures::try_join!( + fastn_core_commands(&matches), + check_for_update_cmd(&matches) + )?; + + match std::env::var("FASTN_CHECK_FOR_UPDATES") { + Ok(val) => { + if val != "false" && !matches.get_flag("check-for-updates") { + check_for_update(false).await?; + } + Ok(()) + } + Err(_) => Ok(()), + } } async fn cloud_commands(matches: &clap::ArgMatches) -> Result { @@ -70,6 +87,22 @@ async fn fastn_core_commands(matches: &clap::ArgMatches) -> fastn_core::Result<( let mut config = fastn_core::Config::read(None, true, None).await?; let package_name = config.package.name.clone(); + if let Some(_tutor) = matches.subcommand_matches("tutor") { + println!("starting TUTOR mode"); + return fastn_core::listen( + "127.0.0.1", + Some(2000), + None, + Some("2023".to_string()), + vec![], + vec![], + vec![], + vec![], + package_name, + ) + .await; + } + if let Some(serve) = matches.subcommand_matches("serve") { let port = serve.value_of_("port").map(|p| match p.parse::() { Ok(v) => v, @@ -241,12 +274,55 @@ async fn fastn_core_commands(matches: &clap::ArgMatches) -> fastn_core::Result<( return fastn_core::post_build_check(&config).await; } + if matches.get_flag("check-for-updates") { + return check_for_update(true).await; + } + unreachable!("No subcommand matched"); } +async fn check_for_update_cmd(matches: &clap::ArgMatches) -> fastn_core::Result<()> { + if matches.get_flag("check-for-updates") { + check_for_update(false).await?; + } + + Ok(()) +} + +async fn check_for_update(report: bool) -> fastn_core::Result<()> { + #[derive(serde::Deserialize, Debug)] + struct GithubRelease { + tag_name: String, + } + + let url = "https://api.github.com/repos/fastn-stack/fastn/releases/latest"; + let release: GithubRelease = reqwest::Client::new() + .get(url) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header(reqwest::header::USER_AGENT, "fastn") + .send() + .await? + .json() + .await?; + + let current_version = version(); + + if release.tag_name != current_version { + println!( + "You are using fastn {}, and latest release is {}, visit https://fastn.com/install/ to learn how to upgrade.", + current_version, release.tag_name + ); + } else if report { + println!("You are using the latest release of fastn."); + } + + Ok(()) +} + fn app(version: &'static str) -> clap::Command { - clap::Command::new("fastn: FTD Package Manager") + clap::Command::new("fastn: Full-stack Web Development Made Easy") .version(version) + .arg(clap::arg!(-c --"check-for-updates" "Check for updates")) .arg_required_else_help(true) .arg(clap::arg!(verbose: -v "Sets the level of verbosity")) .arg(clap::arg!(--test "Runs the command in test mode").hide(true)) @@ -405,6 +481,9 @@ fn app(version: &'static str) -> clap::Command { .arg(clap::arg!(--target "The target file to mark as up to date")) .hide(true) // hidden since the feature is not being released yet. ) + .subcommand( + clap::Command::new("tutor").about("Start fastn tutor").hide(true) + ) .subcommand( clap::Command::new("start-tracking") .about("Add a tracking relation between two files") @@ -435,6 +514,7 @@ mod sub_command { .action(clap::ArgAction::Append)) .arg(clap::arg!(--"css" "CSS text added in ftd files") .action(clap::ArgAction::Append)) + .arg(clap::arg!(--"tutor" "Start the server in tutor mode").hide(true)) .arg(clap::arg!(--"download-base-url" "If running without files locally, download needed files from here")); if cfg!(feature = "remote") { serve @@ -466,3 +546,48 @@ pub fn version() -> &'static str { } } } + +fn set_env_vars() { + let checked_in = { + if let Ok(status) = std::process::Command::new("git") + .arg("ls-files") + .arg("--error-unmatch") + .arg(".env") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + { + status.success() // .env is checked in + } else { + false + } + }; + + let ignore = { + if let Ok(val) = std::env::var("FASTN_DANGER_ACCEPT_CHECKED_IN_ENV") { + val != "false" + } else { + false + } + }; + + if checked_in && !ignore { + eprintln!( + "ERROR: the .env file is checked in to version control! This is a security risk. +Remove it from your version control system or run fastn again with +FASTN_DANGER_ACCEPT_CHECKED_IN_ENV set" + ); + std::process::exit(1); + } else { + if checked_in && ignore { + println!( + "WARN: your .env file has been detected in the version control system! This poses a +significant security risk in case the source code becomes public." + ); + } + + if dotenvy::dotenv().is_ok() { + println!("INFO: loaded environment variables from .env file."); + } + } +} diff --git a/flake.lock b/flake.lock index 57de9e3c0f..49f5c88d53 100644 --- a/flake.lock +++ b/flake.lock @@ -18,6 +18,24 @@ "type": "github" } }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1681202837, + "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "cfacdce06f30d2b68473a46042957675eebb3401", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "naersk": { "inputs": { "nixpkgs": "nixpkgs" @@ -50,29 +68,29 @@ "type": "indirect" } }, - "nixpkgs-mozilla": { - "flake": false, + "nixpkgs_2": { "locked": { - "lastModified": 1695805681, - "narHash": "sha256-1ElPLD8eFfnuIk0G52HGGpRtQZ4QPCjChRlEOfkZ5ro=", - "owner": "mozilla", - "repo": "nixpkgs-mozilla", - "rev": "6eabade97bc28d707a8b9d82ad13ef143836736e", + "lastModified": 1697660476, + "narHash": "sha256-w5/4HKG6/TPOFUki4rYUbME8zC6+suT6hSunyFD4BlI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "964a525d67348323be4fa100345d37b361ebd36e", "type": "github" }, "original": { - "owner": "mozilla", - "repo": "nixpkgs-mozilla", + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", "type": "github" } }, - "nixpkgs_2": { + "nixpkgs_3": { "locked": { - "lastModified": 1697660476, - "narHash": "sha256-w5/4HKG6/TPOFUki4rYUbME8zC6+suT6hSunyFD4BlI=", + "lastModified": 1681358109, + "narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "964a525d67348323be4fa100345d37b361ebd36e", + "rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9", "type": "github" }, "original": { @@ -87,7 +105,26 @@ "flake-utils": "flake-utils", "naersk": "naersk", "nixpkgs": "nixpkgs_2", - "nixpkgs-mozilla": "nixpkgs-mozilla" + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs_3" + }, + "locked": { + "lastModified": 1697854201, + "narHash": "sha256-H+0Wb20PQx/8N7X/OfwwAVPeN9TbfjcyG0sXbdgsh50=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "6e8e3332433847cd56186b1f6fc8c47603cf5b46", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" } }, "systems": { @@ -104,6 +141,21 @@ "repo": "default", "type": "github" } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index be326f2781..2889d81bf9 100644 --- a/flake.nix +++ b/flake.nix @@ -4,55 +4,62 @@ naersk.url = "github:nix-community/naersk"; - nixpkgs-mozilla = { - url = "github:mozilla/nixpkgs-mozilla"; - flake = false; - }; + rust-overlay.url = "github:oxalica/rust-overlay"; nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - }; - outputs = { self, flake-utils, nixpkgs, nixpkgs-mozilla, naersk }: + outputs = { self, flake-utils, nixpkgs, rust-overlay, naersk }: flake-utils.lib.eachDefaultSystem (system: let pkgs = (import nixpkgs) { inherit system; overlays = [ - (import nixpkgs-mozilla) + (import rust-overlay) ]; }; - toolchain = (pkgs.rustChannelOf { - rustToolchain = ./rust-toolchain; - sha256 = "sha256-rLP8+fTxnPHoR96ZJiCa/5Ans1OojI7MLsmSqR2ip8o="; - }).rust; + toolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain; naersk' = pkgs.callPackage naersk { cargo = toolchain; rustc = toolchain; }; - fastnp = naersk'.buildPackage { + cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); + + fastn = naersk'.buildPackage { name = "fastn"; - version = "0.3.0"; - src = ./.; + version = cargoToml.workspace.package.version; + src = pkgs.lib.cleanSource ./.; + + nativeBuildInputs = with pkgs; [ + pkg-config + openssl.dev + ] ++ lib.optionals stdenv.isDarwin [ xcbuild ]; - nativeBuildInputs = with pkgs; [ pkg-config openssl.dev ]; + buildInputs = with pkgs; lib.optionals stdenv.isDarwin [ + darwin.apple_sdk.frameworks.SystemConfiguration + ]; }; in rec { # For `nix build` & `nix run`: - defaultPackage = fastnp; + defaultPackage = fastn; packages = { - fastn = fastnp; + inherit fastn; }; # nix develop devShell = pkgs.mkShell { + name = "fastn-shell"; nativeBuildInputs = [ toolchain pkgs.pkg-config pkgs.openssl.dev ]; + + shellHook = '' + export PATH="$PATH:$HOME/.cargo/bin" + ''; }; formatter = pkgs.nixpkgs-fmt; diff --git a/ftd/src/executor/test.rs b/ftd/src/executor/test.rs index e87208f31c..461bd33e40 100644 --- a/ftd/src/executor/test.rs +++ b/ftd/src/executor/test.rs @@ -77,7 +77,7 @@ fn p(s: &str, t: &str, fix: bool, file_location: &std::path::PathBuf) { let doc = interpret_helper("foo", s).unwrap_or_else(|e| panic!("{:?}", e)); let mut executor = ftd::executor::ExecuteDoc::from_interpreter(doc).unwrap_or_else(|e| panic!("{:?}", e)); - for thing in ftd::interpreter::default::default_bag().keys() { + for thing in ftd::interpreter::default::get_default_bag().keys() { executor.bag.remove(thing); } let expected_json = serde_json::to_string_pretty(&executor).unwrap(); diff --git a/ftd/src/interpreter/constants.rs b/ftd/src/interpreter/constants.rs index 2411cec670..a441c3db57 100644 --- a/ftd/src/interpreter/constants.rs +++ b/ftd/src/interpreter/constants.rs @@ -330,3 +330,42 @@ pub const FTD_BACKDROP_FILTER_OPACITY: &str = "ftd#backdrop-filter.opacity"; pub const FTD_BACKDROP_FILTER_SEPIA: &str = "ftd#backdrop-filter.sepia"; pub const FTD_BACKDROP_FILTER_SATURATE: &str = "ftd#backdrop-filter.saturate"; pub const FTD_BACKDROP_FILTER_MULTI: &str = "ftd#backdrop-filter.multi"; + +pub const FTD_MASK_IMAGE_DATA: &str = "ftd#mask-image"; +pub const FTD_MASK_IMAGE_DATA_SRC: &str = "ftd#mask-image.src"; +pub const FTD_MASK_IMAGE_DATA_LINEAR_GRADIENT: &str = "ftd#mask-image.linear-gradient"; + +pub const FTD_MASK: &str = "ftd#mask"; +pub const FTD_MASK_IMAGE: &str = "ftd#mask.image"; +pub const FTD_MASK_MULTI: &str = "ftd#mask.multi"; + +pub const FTD_MASK_MULTI_DATA: &str = "ftd#mask-multi"; + +pub const FTD_MASK_SIZE: &str = "ftd#mask-size"; +pub const FTD_MASK_SIZE_FIXED: &str = "ftd#mask-size.fixed"; +pub const FTD_MASK_SIZE_COVER: &str = "ftd#mask-size.cover"; +pub const FTD_MASK_SIZE_CONTAIN: &str = "ftd#mask-size.contain"; +pub const FTD_MASK_SIZE_AUTO: &str = "ftd#mask-size.auto"; + +pub const FTD_MASK_REPEAT: &str = "ftd#mask-repeat"; +pub const FTD_MASK_REPEAT_BOTH_REPEAT: &str = "ftd#mask-repeat.repeat"; +pub const FTD_MASK_REPEAT_X_REPEAT: &str = "ftd#mask-repeat.repeat-x"; +pub const FTD_MASK_REPEAT_Y_REPEAT: &str = "ftd#mask-repeat.repeat-y"; +pub const FTD_MASK_REPEAT_NO_REPEAT: &str = "ftd#mask-repeat.no-repeat"; +pub const FTD_MASK_REPEAT_SPACE: &str = "ftd#mask-repeat.space"; +pub const FTD_MASK_REPEAT_ROUND: &str = "ftd#mask-repeat.round"; + +pub const FTD_MASK_POSITION: &str = "ftd#mask-position"; +pub const FTD_MASK_POSITION_LEFT: &str = "ftd#mask-position.left"; +pub const FTD_MASK_POSITION_CENTER: &str = "ftd#mask-position.center"; +pub const FTD_MASK_POSITION_RIGHT: &str = "ftd#mask-position.right"; +pub const FTD_MASK_POSITION_LEFT_TOP: &str = "ftd#mask-position.left-top"; +pub const FTD_MASK_POSITION_LEFT_CENTER: &str = "ftd#mask-position.left-center"; +pub const FTD_MASK_POSITION_LEFT_BOTTOM: &str = "ftd#mask-position.left-bottom"; +pub const FTD_MASK_POSITION_CENTER_TOP: &str = "ftd#mask-position.center-top"; +pub const FTD_MASK_POSITION_CENTER_CENTER: &str = "ftd#mask-position.center-center"; +pub const FTD_MASK_POSITION_CENTER_BOTTOM: &str = "ftd#mask-position.center-bottom"; +pub const FTD_MASK_POSITION_RIGHT_TOP: &str = "ftd#mask-position.right-top"; +pub const FTD_MASK_POSITION_RIGHT_CENTER: &str = "ftd#mask-position.right-center"; +pub const FTD_MASK_POSITION_RIGHT_BOTTOM: &str = "ftd#mask-position.right-bottom"; +pub const FTD_MASK_POSITION_LENGTH: &str = "ftd#mask-position.length"; diff --git a/ftd/src/interpreter/main.rs b/ftd/src/interpreter/main.rs index ae34cca2c1..c3664a3d1e 100644 --- a/ftd/src/interpreter/main.rs +++ b/ftd/src/interpreter/main.rs @@ -78,7 +78,7 @@ impl InterpreterState { fn new(id: String) -> InterpreterState { InterpreterState { id, - bag: ftd::interpreter::default::default_bag(), + bag: ftd::interpreter::default::get_default_bag().clone(), ..Default::default() } } diff --git a/ftd/src/interpreter/mod.rs b/ftd/src/interpreter/mod.rs index dbbd38124f..c012d4746e 100644 --- a/ftd/src/interpreter/mod.rs +++ b/ftd/src/interpreter/mod.rs @@ -50,6 +50,9 @@ pub enum Error { #[error("P1Error: {}", _0)] P1Error(#[from] ftd::p1::Error), + #[error("IOError: {}", _0)] + IOError(#[from] std::io::Error), + #[error("OldP1Error: {}", _0)] OldP1Error(#[from] ftd::ftd2021::p1::Error), diff --git a/ftd/src/interpreter/test.rs b/ftd/src/interpreter/test.rs index 1f0de8e030..85ba03bf35 100644 --- a/ftd/src/interpreter/test.rs +++ b/ftd/src/interpreter/test.rs @@ -73,7 +73,7 @@ pub fn interpret_helper( #[track_caller] fn p(s: &str, t: &str, fix: bool, file_location: &std::path::PathBuf) { let mut i = interpret_helper("foo", s).unwrap_or_else(|e| panic!("{:?}", e)); - for thing in ftd::interpreter::default::default_bag().keys() { + for thing in ftd::interpreter::default::get_default_bag().keys() { i.data.remove(thing); } let expected_json = serde_json::to_string_pretty(&i).unwrap(); diff --git a/ftd/src/interpreter/things/default.rs b/ftd/src/interpreter/things/default.rs index df11ec6065..8aaeb654d3 100644 --- a/ftd/src/interpreter/things/default.rs +++ b/ftd/src/interpreter/things/default.rs @@ -1299,7 +1299,6 @@ pub fn default_bag() -> indexmap::IndexMap { line_number: 0, }), ), - // TODO: Instead of default value, make the integer optional ( ftd::interpreter::FTD_BACKDROP_MULTI.to_string(), ftd::interpreter::Thing::Record(ftd::interpreter::Record { @@ -7598,7 +7597,7 @@ pub fn default_bag() -> indexmap::IndexMap { value: ftd::interpreter::Value::Record { name: ftd::interpreter::FTD_COLOR.to_string(), fields: std::iter::IntoIterator::into_iter([( - "light".to_string(), + "light".to_string().to_string(), ftd::interpreter::PropertyValue::Value { value: ftd::interpreter::Value::String { @@ -7880,7 +7879,7 @@ pub fn default_bag() -> indexmap::IndexMap { line_number: 0, }, ), ( - "dark".to_string(), + "dark".to_string().to_string(), ftd::interpreter::PropertyValue::Value { value: ftd::interpreter::Value::String { text: "#65b693".to_string() @@ -9693,9 +9692,9 @@ pub fn default_bag() -> indexmap::IndexMap { value: ftd::interpreter::PropertyValue::Value { value: ftd::interpreter::Value::OrType { name: ftd::interpreter::FTD_DEVICE_DATA.to_string(), - variant: ftd::interpreter::FTD_DEVICE_DATA_DESKTOP.to_string(), - full_variant: ftd::interpreter::FTD_DEVICE_DATA_DESKTOP.to_string(), - value: Box::new(ftd::interpreter::Value::new_string("desktop") + variant: ftd::interpreter::FTD_DEVICE_DATA_MOBILE.to_string(), + full_variant: ftd::interpreter::FTD_DEVICE_DATA_MOBILE.to_string(), + value: Box::new(ftd::interpreter::Value::new_string("mobile") .into_property_value(false, 0)) }, is_mutable: true, @@ -9705,11 +9704,414 @@ pub fn default_bag() -> indexmap::IndexMap { line_number: 0, is_static: false }) - ) + ), + ( + ftd::interpreter::FTD_MASK_IMAGE_DATA.to_string(), + ftd::interpreter::Thing::Record(ftd::interpreter::Record { + name: ftd::interpreter::FTD_MASK_IMAGE_DATA.to_string(), + fields: std::iter::IntoIterator::into_iter([ + ftd::interpreter::Field { + name: "src".to_string(), + kind: ftd::interpreter::Kind::record(ftd::interpreter::FTD_IMAGE_SRC) + .into_kind_data().caption().into_optional(), + mutable: false, + value: None, + access_modifier: Default::default(), + line_number: 0, + }, + ftd::interpreter::Field { + name: "linear-gradient".to_string(), + kind: ftd::interpreter::Kind::record(ftd::interpreter::FTD_LINEAR_GRADIENT) + .into_kind_data() + .into_optional(), + mutable: false, + value: None, + access_modifier: Default::default(), + line_number: 0, + }, + ftd::interpreter::Field { + name: "color".to_string(), + kind: ftd::interpreter::Kind::record(ftd::interpreter::FTD_COLOR) + .into_kind_data() + .into_optional(), + mutable: false, + value: None, + access_modifier: Default::default(), + line_number: 0, + }, + ]).collect(), + line_number: 0, + }), + ), + ( + ftd::interpreter::FTD_MASK_SIZE.to_string(), + ftd::interpreter::Thing::OrType(ftd::interpreter::OrType { + name: ftd::interpreter::FTD_MASK_SIZE.to_string(), + variants: vec![ + ftd::interpreter::OrTypeVariant::Regular(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_SIZE_FIXED, + ftd::interpreter::Kind::or_type(ftd::interpreter::FTD_LENGTH) + .into_kind_data(), + false, + None, + 0, + )), + ftd::interpreter::OrTypeVariant::new_constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_SIZE_AUTO, + ftd::interpreter::Kind::string().into_kind_data(), + false, + Some( + ftd::interpreter::Value::new_string( + ftd::interpreter::FTD_MASK_SIZE_AUTO, + ) + .into_property_value(false, 0), + ), + 0, + )), + ftd::interpreter::OrTypeVariant::new_constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_SIZE_COVER, + ftd::interpreter::Kind::string().into_kind_data(), + false, + Some( + ftd::interpreter::Value::new_string( + ftd::interpreter::FTD_MASK_SIZE_CONTAIN, + ) + .into_property_value(false, 0), + ), + 0, + )), + ], + line_number: 0, + }), + ), + + ( + ftd::interpreter::FTD_MASK_REPEAT.to_string(), + ftd::interpreter::Thing::OrType(ftd::interpreter::OrType { + name: ftd::interpreter::FTD_MASK_REPEAT.to_string(), + variants: vec![ + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_REPEAT_BOTH_REPEAT, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("repeat") + .into_property_value(false, 0),), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_REPEAT_X_REPEAT, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("repeat-x") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_REPEAT_Y_REPEAT, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("repeat-y") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_REPEAT_NO_REPEAT, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("no-repeat") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_REPEAT_SPACE, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("space") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_REPEAT_ROUND, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("round") + .into_property_value(false, 0)), + 0, + )), + ], + line_number: 0, + }), + ), + ( + ftd::interpreter::FTD_MASK_POSITION.to_string(), + ftd::interpreter::Thing::OrType(ftd::interpreter::OrType { + name: ftd::interpreter::FTD_MASK_POSITION.to_string(), + variants: vec![ + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_POSITION_LEFT, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("left") + .into_property_value(false, 0),), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_POSITION_CENTER, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("center") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_POSITION_RIGHT, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("right") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_POSITION_LEFT_TOP, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("left-top") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_POSITION_LEFT_CENTER, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("left-center") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_POSITION_LEFT_BOTTOM, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("left-bottom") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_POSITION_CENTER_TOP, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("center-top") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_POSITION_CENTER_CENTER, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("center-center") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_POSITION_CENTER_BOTTOM, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("center-bottom") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_POSITION_RIGHT_TOP, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("right-top") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_POSITION_RIGHT_CENTER, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("right-center") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::Constant(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_POSITION_RIGHT_BOTTOM, + ftd::interpreter::Kind::string() + .into_kind_data() + .caption(), + false, + Some(ftd::interpreter::Value::new_string("right-bottom") + .into_property_value(false, 0)), + 0, + )), + ftd::interpreter::OrTypeVariant::AnonymousRecord(ftd::interpreter::Record { + name: ftd::interpreter::FTD_MASK_POSITION_LENGTH.to_string(), + fields: std::iter::IntoIterator::into_iter([ + ftd::interpreter::Field { + name: "x".to_string(), + kind: ftd::interpreter::Kind::or_type(ftd::interpreter::FTD_LENGTH) + .into_kind_data(), + mutable: false, + value: None, + access_modifier: Default::default(), + line_number: 0, + }, + ftd::interpreter::Field { + name: "y".to_string(), + kind: ftd::interpreter::Kind::or_type(ftd::interpreter::FTD_LENGTH) + .into_kind_data(), + mutable: false, + value: None, + access_modifier: Default::default(), + line_number: 0, + }, + ]).collect(), + line_number: 0, + }), + ], + line_number: 0, + }), + ), + ( + ftd::interpreter::FTD_MASK_MULTI_DATA.to_string(), + ftd::interpreter::Thing::Record(ftd::interpreter::Record { + name: ftd::interpreter::FTD_MASK_MULTI_DATA.to_string(), + fields: std::iter::IntoIterator::into_iter([ + ftd::interpreter::Field { + name: "image".to_string(), + kind: ftd::interpreter::Kind::record(ftd::interpreter::FTD_MASK_IMAGE_DATA) + .into_kind_data(), + mutable: false, + value: None, + access_modifier: Default::default(), + line_number: 0, + }, + ftd::interpreter::Field { + name: "size".to_string(), + kind: ftd::interpreter::Kind::or_type(ftd::interpreter::FTD_MASK_SIZE) + .into_kind_data() + .into_optional(), + mutable: false, + value: None, + access_modifier: Default::default(), + line_number: 0, + }, + ftd::interpreter::Field { + name: "size-x".to_string(), + kind: ftd::interpreter::Kind::or_type(ftd::interpreter::FTD_MASK_SIZE) + .into_kind_data() + .into_optional(), + mutable: false, + value: None, + access_modifier: Default::default(), + line_number: 0, + }, + ftd::interpreter::Field { + name: "size-y".to_string(), + kind: ftd::interpreter::Kind::or_type(ftd::interpreter::FTD_MASK_SIZE) + .into_kind_data() + .into_optional(), + mutable: false, + value: None, + access_modifier: Default::default(), + line_number: 0, + }, + ftd::interpreter::Field { + name: "repeat".to_string(), + kind: ftd::interpreter::Kind::or_type(ftd::interpreter::FTD_MASK_REPEAT) + .into_kind_data() + .into_optional(), + mutable: false, + value: None, + access_modifier: Default::default(), + line_number: 0, + }, + ftd::interpreter::Field { + name: "position".to_string(), + kind: ftd::interpreter::Kind::or_type(ftd::interpreter::FTD_MASK_POSITION) + .into_kind_data() + .into_optional(), + mutable: false, + value: None, + access_modifier: Default::default(), + line_number: 0, + }, + ]).collect(), + line_number: 0, + }), + ), + ( + ftd::interpreter::FTD_MASK.to_string(), + ftd::interpreter::Thing::OrType(ftd::interpreter::OrType { + name: ftd::interpreter::FTD_MASK.to_string(), + variants: vec![ + ftd::interpreter::OrTypeVariant::Regular(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_IMAGE, + ftd::interpreter::Kind::record(ftd::interpreter::FTD_MASK_IMAGE_DATA) + .into_kind_data(), + false, + None, + 0, + )), + ftd::interpreter::OrTypeVariant::Regular(ftd::interpreter::Field::new( + ftd::interpreter::FTD_MASK_MULTI, + ftd::interpreter::Kind::record(ftd::interpreter::FTD_MASK_MULTI_DATA) + .into_kind_data(), + false, + None, + 0, + )), + ], + line_number: 0, + }), + ), ]) .collect() } +pub static DEFAULT_BAG: once_cell::sync::OnceCell< + indexmap::IndexMap, +> = once_cell::sync::OnceCell::new(); + +pub fn get_default_bag() -> &'static indexmap::IndexMap { + DEFAULT_BAG.get_or_init(ftd::interpreter::things::default::default_bag) +} + pub fn image_function() -> ftd::interpreter::ComponentDefinition { ftd::interpreter::ComponentDefinition { name: "ftd#image".to_string(), @@ -10900,6 +11302,12 @@ fn common_arguments() -> Vec { .into_optional() .into_kind_data(), ), + ftd::interpreter::Argument::default( + "mask", + ftd::interpreter::Kind::or_type(ftd::interpreter::FTD_MASK) + .into_optional() + .into_kind_data(), + ), ] } diff --git a/ftd/src/js/element.rs b/ftd/src/js/element.rs index 8d83125c60..fbd3b0d535 100644 --- a/ftd/src/js/element.rs +++ b/ftd/src/js/element.rs @@ -147,7 +147,7 @@ pub struct CheckBox { impl CheckBox { pub fn from(component: &ftd::interpreter::Component) -> CheckBox { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#checkbox") .unwrap() .clone() @@ -233,7 +233,7 @@ pub struct TextInput { impl TextInput { pub fn from(component: &ftd::interpreter::Component) -> TextInput { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#text-input") .unwrap() .clone() @@ -377,7 +377,7 @@ pub struct Iframe { impl Iframe { pub fn from(component: &ftd::interpreter::Component) -> Iframe { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#iframe") .unwrap() .clone() @@ -495,7 +495,7 @@ pub struct Code { impl Code { pub fn from(component: &ftd::interpreter::Component, _doc: &ftd::interpreter::TDoc) -> Code { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#code") .unwrap() .clone() @@ -619,7 +619,7 @@ pub struct Image { impl Image { pub fn from(component: &ftd::interpreter::Component) -> Image { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#image") .unwrap() .clone() @@ -718,7 +718,7 @@ pub struct Video { impl Video { pub fn from(component: &ftd::interpreter::Component) -> Video { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#video") .unwrap() .clone() @@ -1124,7 +1124,7 @@ impl InheritedProperties { impl Text { pub fn from(component: &ftd::interpreter::Component) -> Text { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#text") .unwrap() .clone() @@ -1190,7 +1190,7 @@ impl Text { impl Integer { pub fn from(component: &ftd::interpreter::Component) -> Integer { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#integer") .unwrap() .clone() @@ -1255,7 +1255,7 @@ impl Integer { impl Decimal { pub fn from(component: &ftd::interpreter::Component) -> Decimal { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#decimal") .unwrap() .clone() @@ -1320,7 +1320,7 @@ impl Decimal { impl Boolean { pub fn from(component: &ftd::interpreter::Component) -> Boolean { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#boolean") .unwrap() .clone() @@ -1385,7 +1385,7 @@ impl Boolean { impl Document { pub fn from(component: &ftd::interpreter::Component) -> Document { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#document") .unwrap() .clone() @@ -1636,7 +1636,7 @@ impl DocumentMeta { impl Column { pub fn from(component: &ftd::interpreter::Component) -> Column { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#column") .unwrap() .clone() @@ -1702,7 +1702,7 @@ impl Column { impl Row { pub fn from(component: &ftd::interpreter::Component) -> Row { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#row") .unwrap() .clone() @@ -1768,7 +1768,7 @@ impl Row { impl ContainerElement { pub fn from(component: &ftd::interpreter::Component) -> ContainerElement { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#container") .unwrap() .clone() @@ -1836,7 +1836,7 @@ pub struct Device { impl Device { pub fn from(component: &ftd::interpreter::Component, device: &str) -> Device { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get(device) .unwrap() .clone() @@ -2021,7 +2021,7 @@ pub struct Rive { impl Rive { pub fn from(component: &ftd::interpreter::Component) -> Rive { - let component_definition = ftd::interpreter::default::default_bag() + let component_definition = ftd::interpreter::default::get_default_bag() .get("ftd#rive") .unwrap() .clone() @@ -2225,6 +2225,7 @@ pub struct Common { pub js: Option, pub events: Vec, pub selectable: Option, + pub mask: Option, } impl Common { @@ -2439,8 +2440,9 @@ impl Common { min_width: ftd::js::value::get_optional_js_value("min-width", properties, arguments), whitespace: ftd::js::value::get_optional_js_value("white-space", properties, arguments), shadow: ftd::js::value::get_optional_js_value("shadow", properties, arguments), - events: events.to_vec(), selectable: ftd::js::value::get_optional_js_value("selectable", properties, arguments), + mask: ftd::js::value::get_optional_js_value("mask", properties, arguments), + events: events.to_vec(), } } @@ -3037,6 +3039,11 @@ impl Common { ), )); } + if let Some(ref mask) = self.mask { + component_statements.push(fastn_js::ComponentStatement::SetProperty( + mask.to_set_property(fastn_js::PropertyKind::Mask, doc, element_name, rdata), + )); + } component_statements } } diff --git a/ftd/src/js/mod.rs b/ftd/src/js/mod.rs index 2fcde906df..0414e3570b 100644 --- a/ftd/src/js/mod.rs +++ b/ftd/src/js/mod.rs @@ -23,14 +23,14 @@ pub fn all_js_without_test(package_name: &str) -> String { /// This returns asts of things present in `ftd` module or `default_bag` pub fn default_bag_into_js_ast() -> Vec { let mut ftd_asts = vec![]; - let bag = ftd::interpreter::default::default_bag(); + let bag = ftd::interpreter::default::get_default_bag(); let doc = ftd::interpreter::TDoc { name: "", aliases: &ftd::interpreter::default::default_aliases(), - bag: ftd::interpreter::BagOrState::Bag(&bag), + bag: ftd::interpreter::BagOrState::Bag(bag), }; let mut export_asts = vec![]; - for thing in ftd::interpreter::default::default_bag().values() { + for thing in ftd::interpreter::default::get_default_bag().values() { if let ftd::interpreter::Thing::Variable(v) = thing { ftd_asts.push(v.to_ast(&doc, None, &mut false)); } else if let ftd::interpreter::Thing::Function(f) = thing { @@ -98,7 +98,7 @@ pub fn document_into_js_ast(document: ftd::interpreter::Document) -> JSAstData { &doc, &mut has_rive_components, )]; - let default_thing_name = ftd::interpreter::default::default_bag() + let default_thing_name = ftd::interpreter::default::get_default_bag() .into_iter() .map(|v| v.0) .collect_vec(); @@ -106,7 +106,7 @@ pub fn document_into_js_ast(document: ftd::interpreter::Document) -> JSAstData { let mut export_asts = vec![]; for (key, thing) in document.data.iter() { - if default_thing_name.contains(key) { + if default_thing_name.contains(&key) { continue; } if let ftd::interpreter::Thing::Component(c) = thing { diff --git a/ftd/src/js/value.rs b/ftd/src/js/value.rs index 656e07395c..92e82a794b 100644 --- a/ftd/src/js/value.rs +++ b/ftd/src/js/value.rs @@ -537,6 +537,25 @@ fn ftd_to_js_variant(name: &str, variant: &str) -> (String, bool) { let js_variant = backdrop_filter_variants(variant); (format!("fastn_dom.BackdropFilter.{}", js_variant), true) } + "ftd#mask" => { + let js_variant = mask_variants(variant); + (format!("fastn_dom.Mask.{}", js_variant), true) + } + "ftd#mask-size" => { + let js_variant = mask_size_variants(variant); + (format!("fastn_dom.MaskSize.{}", js_variant.0), js_variant.1) + } + "ftd#mask-repeat" => { + let js_variant = mask_repeat_variants(variant); + (format!("fastn_dom.MaskRepeat.{}", js_variant), false) + } + "ftd#mask-position" => { + let js_variant = mask_position_variants(variant); + ( + format!("fastn_dom.MaskPosition.{}", js_variant.0), + js_variant.1, + ) + } t => todo!("{} {}", t, variant), } } @@ -900,3 +919,52 @@ fn backdrop_filter_variants(name: &str) -> &'static str { t => unimplemented!("invalid backdrop filter variant {}", t), } } + +fn mask_variants(name: &str) -> &'static str { + match name { + "image" => "Image", + "multi" => "Multi", + t => todo!("invalid mask variant {}", t), + } +} + +fn mask_size_variants(name: &str) -> (&'static str, bool) { + match name { + "auto" => ("Auto", false), + "cover" => ("Cover", false), + "contain" => ("Contain", false), + "fixed" => ("Fixed", true), + t => todo!("invalid mask variant {}", t), + } +} + +fn mask_repeat_variants(name: &str) -> &'static str { + match name { + "repeat" => "Repeat", + "repeat-x" => "RepeatX", + "repeat-y" => "RepeatY", + "no-repeat" => "NoRepeat", + "space" => "Space", + "round" => "Round", + t => todo!("invalid mask repeat variant {}", t), + } +} + +fn mask_position_variants(name: &str) -> (&'static str, bool) { + match name { + "left" => ("Left", false), + "right" => ("Right", false), + "center" => ("Center", false), + "left-top" => ("LeftTop", false), + "left-center" => ("LeftCenter", false), + "left-bottom" => ("LeftBottom", false), + "center-top" => ("CenterTop", false), + "center-center" => ("CenterCenter", false), + "center-bottom" => ("CenterBottom", false), + "right-top" => ("RightTop", false), + "right-center" => ("RightCenter", false), + "right-bottom" => ("RightBottom", false), + "length" => ("Length", true), + t => todo!("invalid mask position variant {}", t), + } +} diff --git a/ftd/src/node/test.rs b/ftd/src/node/test.rs index 9d657f9ae3..559327f8b5 100644 --- a/ftd/src/node/test.rs +++ b/ftd/src/node/test.rs @@ -76,7 +76,7 @@ fn p(s: &str, t: &str, fix: bool, file_location: &std::path::PathBuf) { let executor = ftd::executor::ExecuteDoc::from_interpreter(doc).unwrap_or_else(|e| panic!("{:?}", e)); let mut node = ftd::node::NodeData::from_rt(executor); - for thing in ftd::interpreter::default::default_bag().keys() { + for thing in ftd::interpreter::default::get_default_bag().keys() { node.bag.remove(thing); } let expected_json = serde_json::to_string_pretty(&node).unwrap(); diff --git a/ftd/t/html/1-component.html b/ftd/t/html/1-component.html index aa9039f5e0..7cfe6aa66b 100644 --- a/ftd/t/html/1-component.html +++ b/ftd/t/html/1-component.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/10-conditional-properties.html b/ftd/t/html/10-conditional-properties.html index db871ddc21..045f04eb73 100644 --- a/ftd/t/html/10-conditional-properties.html +++ b/ftd/t/html/10-conditional-properties.html @@ -565,7 +565,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/100-linear-gradient.html b/ftd/t/html/100-linear-gradient.html index a4e613093d..1449a0ee6d 100644 --- a/ftd/t/html/100-linear-gradient.html +++ b/ftd/t/html/100-linear-gradient.html @@ -742,7 +742,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/100-re-export.html b/ftd/t/html/100-re-export.html index 2ad12cfa4f..f723c35bc9 100644 --- a/ftd/t/html/100-re-export.html +++ b/ftd/t/html/100-re-export.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/101-re-re-export.html b/ftd/t/html/101-re-re-export.html index 342b6798f1..6a52adb50e 100644 --- a/ftd/t/html/101-re-re-export.html +++ b/ftd/t/html/101-re-re-export.html @@ -564,7 +564,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/102-access-modifiers.html b/ftd/t/html/102-access-modifiers.html index c715153d2b..f403fc21e6 100644 --- a/ftd/t/html/102-access-modifiers.html +++ b/ftd/t/html/102-access-modifiers.html @@ -564,7 +564,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/103-block-header-record.html b/ftd/t/html/103-block-header-record.html index 8e981052ca..1a013655f9 100644 --- a/ftd/t/html/103-block-header-record.html +++ b/ftd/t/html/103-block-header-record.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/104-block-header-record-extended.html b/ftd/t/html/104-block-header-record-extended.html index af3119f3e6..8b6ea2b578 100644 --- a/ftd/t/html/104-block-header-record-extended.html +++ b/ftd/t/html/104-block-header-record-extended.html @@ -603,7 +603,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/105-document-breakpoint.html b/ftd/t/html/105-document-breakpoint.html index 7b554059b7..e77d000780 100644 --- a/ftd/t/html/105-document-breakpoint.html +++ b/ftd/t/html/105-document-breakpoint.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/106-comments.html b/ftd/t/html/106-comments.html index f09884a4bf..b4287ffe4d 100644 --- a/ftd/t/html/106-comments.html +++ b/ftd/t/html/106-comments.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/107-old-fastn-code-syntax.html b/ftd/t/html/107-old-fastn-code-syntax.html index cd4011deab..ac88e99c5e 100644 --- a/ftd/t/html/107-old-fastn-code-syntax.html +++ b/ftd/t/html/107-old-fastn-code-syntax.html @@ -563,7 +563,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/108-linear-gradient-conditional.html b/ftd/t/html/108-linear-gradient-conditional.html index 641fd60310..d21f2b468c 100644 --- a/ftd/t/html/108-linear-gradient-conditional.html +++ b/ftd/t/html/108-linear-gradient-conditional.html @@ -607,7 +607,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/109-image-fit.html b/ftd/t/html/109-image-fit.html index 53b7e8338e..aa3cf8c4b5 100644 --- a/ftd/t/html/109-image-fit.html +++ b/ftd/t/html/109-image-fit.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/11-external-children.html b/ftd/t/html/11-external-children.html index fc5714cc9f..f290e82f17 100644 --- a/ftd/t/html/11-external-children.html +++ b/ftd/t/html/11-external-children.html @@ -567,7 +567,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/110-fallback-fonts.html b/ftd/t/html/110-fallback-fonts.html index d2732f0a36..dcc62167a1 100644 --- a/ftd/t/html/110-fallback-fonts.html +++ b/ftd/t/html/110-fallback-fonts.html @@ -591,7 +591,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/12-conditional-component.html b/ftd/t/html/12-conditional-component.html index b567f52bcd..80ac8a9b3b 100644 --- a/ftd/t/html/12-conditional-component.html +++ b/ftd/t/html/12-conditional-component.html @@ -564,7 +564,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/13-image.html b/ftd/t/html/13-image.html index 87ea015e73..6a177d7075 100644 --- a/ftd/t/html/13-image.html +++ b/ftd/t/html/13-image.html @@ -572,7 +572,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/14-processor.html b/ftd/t/html/14-processor.html index 9562db7b75..dca3a47640 100644 --- a/ftd/t/html/14-processor.html +++ b/ftd/t/html/14-processor.html @@ -563,7 +563,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/15-foreign-variable.html b/ftd/t/html/15-foreign-variable.html index 1d1a345375..f5c3abca76 100644 --- a/ftd/t/html/15-foreign-variable.html +++ b/ftd/t/html/15-foreign-variable.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/16-or-type.html b/ftd/t/html/16-or-type.html index 3183071481..034592a97e 100644 --- a/ftd/t/html/16-or-type.html +++ b/ftd/t/html/16-or-type.html @@ -565,7 +565,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/17-record.html b/ftd/t/html/17-record.html index 2230d5db33..a6d6fe3e58 100644 --- a/ftd/t/html/17-record.html +++ b/ftd/t/html/17-record.html @@ -569,7 +569,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/18-styles.html b/ftd/t/html/18-styles.html index 716a096792..8ec0fd1986 100644 --- a/ftd/t/html/18-styles.html +++ b/ftd/t/html/18-styles.html @@ -564,7 +564,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/19-complex-styles.html b/ftd/t/html/19-complex-styles.html index bbbefaebe3..6df4070c88 100644 --- a/ftd/t/html/19-complex-styles.html +++ b/ftd/t/html/19-complex-styles.html @@ -566,7 +566,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/2-component.html b/ftd/t/html/2-component.html index 08447ecf62..7fe2732bab 100644 --- a/ftd/t/html/2-component.html +++ b/ftd/t/html/2-component.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/20-link.html b/ftd/t/html/20-link.html index e873dba7d4..f8e3e6d9cd 100644 --- a/ftd/t/html/20-link.html +++ b/ftd/t/html/20-link.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/21-color.html b/ftd/t/html/21-color.html index d2fa1e8044..c512d4f05e 100644 --- a/ftd/t/html/21-color.html +++ b/ftd/t/html/21-color.html @@ -570,7 +570,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/22-test.html b/ftd/t/html/22-test.html index ff040f93dc..9718f871d1 100644 --- a/ftd/t/html/22-test.html +++ b/ftd/t/html/22-test.html @@ -563,7 +563,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/23-alignment.html b/ftd/t/html/23-alignment.html index 07c3c27b34..7986433836 100644 --- a/ftd/t/html/23-alignment.html +++ b/ftd/t/html/23-alignment.html @@ -563,7 +563,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/23-doc-site.html b/ftd/t/html/23-doc-site.html index 0627caeee3..27615a90d3 100644 --- a/ftd/t/html/23-doc-site.html +++ b/ftd/t/html/23-doc-site.html @@ -684,7 +684,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/24-margin.html b/ftd/t/html/24-margin.html index d548f602cf..c6bc998a0c 100644 --- a/ftd/t/html/24-margin.html +++ b/ftd/t/html/24-margin.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/25-expander.html b/ftd/t/html/25-expander.html index 285dcf5eb4..c76244abd3 100644 --- a/ftd/t/html/25-expander.html +++ b/ftd/t/html/25-expander.html @@ -571,7 +571,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/25-overflow.html b/ftd/t/html/25-overflow.html index 33a0859b91..5b66c683c6 100644 --- a/ftd/t/html/25-overflow.html +++ b/ftd/t/html/25-overflow.html @@ -563,7 +563,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/26-border.html b/ftd/t/html/26-border.html index ce1459e3a2..917650cc7c 100644 --- a/ftd/t/html/26-border.html +++ b/ftd/t/html/26-border.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/27-optional.html b/ftd/t/html/27-optional.html index f0478b9a19..03e9995687 100644 --- a/ftd/t/html/27-optional.html +++ b/ftd/t/html/27-optional.html @@ -562,7 +562,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/28-complex.html b/ftd/t/html/28-complex.html index a48996cf4b..d1e24a28b1 100644 --- a/ftd/t/html/28-complex.html +++ b/ftd/t/html/28-complex.html @@ -602,7 +602,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/29-slides.html b/ftd/t/html/29-slides.html index 3522721d7d..eabed25275 100644 --- a/ftd/t/html/29-slides.html +++ b/ftd/t/html/29-slides.html @@ -571,7 +571,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/3-component.html b/ftd/t/html/3-component.html index 725cac2777..afb8ea5b8a 100644 --- a/ftd/t/html/3-component.html +++ b/ftd/t/html/3-component.html @@ -563,7 +563,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/30-slides.html b/ftd/t/html/30-slides.html index 051743fab2..03c05c37e2 100644 --- a/ftd/t/html/30-slides.html +++ b/ftd/t/html/30-slides.html @@ -589,7 +589,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/31-message.html b/ftd/t/html/31-message.html index 8ad05520c1..a76c000a65 100644 --- a/ftd/t/html/31-message.html +++ b/ftd/t/html/31-message.html @@ -592,7 +592,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/32-test.html b/ftd/t/html/32-test.html index 4086e7d9fa..0feef2cf39 100644 --- a/ftd/t/html/32-test.html +++ b/ftd/t/html/32-test.html @@ -563,7 +563,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/33-component-using-css.html b/ftd/t/html/33-component-using-css.html index 3ddc214cd8..4e3e8086b1 100644 --- a/ftd/t/html/33-component-using-css.html +++ b/ftd/t/html/33-component-using-css.html @@ -564,7 +564,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/33-using-css.html b/ftd/t/html/33-using-css.html index 83c5424198..1d097613a8 100644 --- a/ftd/t/html/33-using-css.html +++ b/ftd/t/html/33-using-css.html @@ -563,7 +563,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", diff --git a/ftd/t/html/34-device.html b/ftd/t/html/34-device.html index 255dd4bfdf..a261a8c90f 100644 --- a/ftd/t/html/34-device.html +++ b/ftd/t/html/34-device.html @@ -570,7 +570,7 @@ } } }, -"ftd#device": "desktop", +"ftd#device": "mobile", "ftd#empty": "", "ftd#follow-system-dark-mode": true, "ftd#font-code": "sans-serif", @@ -792,7 +792,7 @@ -
Hello World
+
Hello World
+ + + + + + +
+ + diff --git a/ftd/t/js/69-chained-dot-value-in-functions.ftd b/ftd/t/js/69-chained-dot-value-in-functions.ftd new file mode 100644 index 0000000000..786ffb81d9 --- /dev/null +++ b/ftd/t/js/69-chained-dot-value-in-functions.ftd @@ -0,0 +1,55 @@ +-- record Person: +caption name: +integer age: +Metadata meta: + +-- record Metadata: +string address: +string phone-number: + +-- string list places: Bangalore, Mumbai, Chennai, Kolkata + +-- Person list people: + +-- Person: Sam Ather +age: 30 + +-- Person.meta: +address: Sam Ather City at Some Other House +phone-number: +987-654321 + +-- Person: $r + +-- end: people + +-- Metadata meta: +address: Sam City in Some House +phone-number: +1234-56789 + +-- Person r: Sam Wan +age: 23 +meta: $meta + +-- ftd.text: $some-details(person = $r, places = $places, date = 27th October) + +-- ftd.text: $more-details(p = $r) + +-- ftd.text: $first-person-details(people = $people) + +-- string more-details(p): +Person p: + +"Person " + p.name + " lives at " + p.meta.address + ". His contact number is " + p.meta.phone-number + +-- string some-details(person, places): +Person person: +string list places: +string date: + +"This person named " + person.name + " has first visited " + places.0 + " on " + date + +-- string first-person-details(people): +Person list people: + +"First Person is " + people.0.name + " lives at " + people.0.meta.address + ". His contact number is " + people.0.meta.phone-number + diff --git a/ftd/t/js/69-chained-dot-value-in-functions.html b/ftd/t/js/69-chained-dot-value-in-functions.html new file mode 100644 index 0000000000..bd5811e428 --- /dev/null +++ b/ftd/t/js/69-chained-dot-value-in-functions.html @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + +
This person named Sam Wan has first visited Bangalore on 27th October
Person Sam Wan lives at Sam City in Some House. His contact number is +1234-56789
First Person is Sam Ather lives at Sam Ather City at Some Other House. His contact number is +987-654321
+ +