diff --git a/.github/workflows/deploy-to-production-on-merge-to-main.yaml b/.github/workflows/deploy-to-production-on-merge-to-main.yaml index 977df301..58969f64 100644 --- a/.github/workflows/deploy-to-production-on-merge-to-main.yaml +++ b/.github/workflows/deploy-to-production-on-merge-to-main.yaml @@ -23,6 +23,11 @@ jobs: name: build-musl - run: chmod +x target/x86_64-unknown-linux-musl/release/hot-or-not-web-leptos-ssr - uses: superfly/flyctl-actions/setup-flyctl@master + - name: Set cloudflare token + run: flyctl secrets set CF_TOKEN=$CF_TOKEN --app "hot-or-not-web-leptos-ssr" --stage + env: + CF_TOKEN: ${{ secrets.CLOUDFLARE_STREAM_IMAGES_ANALYTICS_READ_WRITE_SECRET }} + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - name: Deploy a docker container to Fly.io run: flyctl deploy --remote-only env: diff --git a/Cargo.lock b/Cargo.lock index 6cc8edc3..29e2a037 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -250,6 +250,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "binread" version = "2.2.0" @@ -1151,6 +1160,88 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "gloo" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15282ece24eaf4bd338d73ef580c6714c8615155c4190c781290ee3fa0fd372" +dependencies = [ + "gloo-console", + "gloo-dialogs", + "gloo-events", + "gloo-file", + "gloo-history", + "gloo-net 0.5.0", + "gloo-render", + "gloo-storage", + "gloo-timers", + "gloo-utils 0.2.0", + "gloo-worker", +] + +[[package]] +name = "gloo-console" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4748e10122b01435750ff530095b1217cf6546173459448b83913ebe7815df" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f" +dependencies = [ + "futures-channel", + "gloo-events", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" +dependencies = [ + "getrandom", + "gloo-events", + "gloo-utils 0.2.0", + "serde", + "serde-wasm-bindgen 0.6.3", + "serde_urlencoded", + "thiserror", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-net" version = "0.2.6" @@ -1171,6 +1262,52 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-net" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-render" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56008b6744713a8e8d98ac3dcb7d06543d5662358c9c805b4ce2167ad4649833" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -1209,6 +1346,37 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-worker" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "085f262d7604911c8150162529cefab3782e91adb20202e8658f7275d2aefe5d" +dependencies = [ + "bincode", + "futures", + "gloo-utils 0.2.0", + "gloo-worker-macros", + "js-sys", + "pinned", + "serde", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956caa58d4857bc9941749d55e4bd3000032d8212762586fa5705632967140e7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.46", +] + [[package]] name = "group" version = "0.12.1" @@ -1312,6 +1480,7 @@ dependencies = [ name = "hot-or-not-web-leptos-ssr" version = "0.1.0" dependencies = [ + "async-trait", "axum", "candid", "candid_parser", @@ -1320,10 +1489,12 @@ dependencies = [ "console_log", "convert_case", "futures", + "gloo", "hex", "http", "ic-agent", "icondata", + "k256", "leptos", "leptos-use", "leptos_axum", @@ -1331,6 +1502,7 @@ dependencies = [ "leptos_meta", "leptos_router", "log", + "once_cell", "reqwest", "serde", "serde-wasm-bindgen 0.6.3", @@ -1343,6 +1515,7 @@ dependencies = [ "tower-http", "tracing", "wasm-bindgen", + "web-time", ] [[package]] @@ -1841,9 +2014,9 @@ dependencies = [ [[package]] name = "k256" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f01b677d82ef7a676aa37e099defd83a28e15687112cafdd112d60236b6115b" +checksum = "956ff9b67e26e1a6a866cb758f12c6f8746208489e3e4a4b5580802f2f0a587b" dependencies = [ "cfg-if", "ecdsa", @@ -2123,7 +2296,7 @@ checksum = "c1a2ff8b8e8ae8b17efd8be2a407f7f83ed57c5243f70f2d03e6635f9ff61848" dependencies = [ "cached 0.45.1", "cfg-if", - "gloo-net", + "gloo-net 0.2.6", "itertools 0.11.0", "js-sys", "lazy_static", @@ -2610,6 +2783,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pinned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a829027bd95e54cfe13e3e258a1ae7b645960553fb82b75ff852c29688ee595b" +dependencies = [ + "futures", + "rustversion", + "thiserror", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -2665,6 +2849,16 @@ dependencies = [ "syn 2.0.46", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -3305,7 +3499,7 @@ checksum = "cfed18dfcc8d9004579c40482c3419c07f60ffb9c5b250542edca99f508b0ce9" dependencies = [ "ciborium", "const_format", - "gloo-net", + "gloo-net 0.2.6", "inventory", "js-sys", "lazy_static", @@ -3778,6 +3972,23 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -4113,6 +4324,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee269d72cc29bf77a2c4bc689cc750fb39f5cbd493d2205bbb3f5c7779cf7b0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.25.3" @@ -4282,6 +4503,15 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +[[package]] +name = "winnow" +version = "0.5.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cf47b659b318dccbd69cc4797a39ae128f533dce7902a1096044d1967b9c16" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" diff --git a/Cargo.toml b/Cargo.toml index f5102dd4..91347fb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,11 +30,16 @@ ic-agent = { version = "0.31.0", default-features = false, features = ["pem", "r serde-wasm-bindgen = { version = "0.6.3" } futures = "0.3.30" leptos-use = "0.9.0" -reqwest = { version = "0.11.23", default-features = false } +reqwest = { version = "0.11.23", default-features = false, features = ["json"] } serde_bytes = "0.11.14" hex = "0.4.3" leptos_icons = "0.2.1" icondata = "0.3.0" +gloo = { version = "0.11.0", features = ["futures", "net", "net"] } +once_cell = "1.19.0" +async-trait = "0.1.77" +web-time = "1.0.0" +k256 = { version = "0.13.3", default-features = false, features = ["std"] } [build-dependencies] candid_parser = "0.1.1" @@ -57,6 +62,9 @@ ssr = [ "leptos-use/ssr", "reqwest/rustls-tls", ] +cloudflare = [] +release-bin = ["ssr", "cloudflare"] +release-lib = ["hydrate", "cloudflare"] # Defines a size-optimized profile for the WASM bundle in release mode [profile.wasm-release] diff --git a/did/individual_user_template.did b/did/individual_user_template.did index 33cbd9fe..90b947f0 100644 --- a/did/individual_user_template.did +++ b/did/individual_user_template.did @@ -89,6 +89,7 @@ type HotOrNotOutcomePayoutEvent = variant { }; type IndividualUserTemplateInitArgs = record { known_principal_ids : opt vec record { KnownPrincipalType; principal }; + version : text; url_to_send_canister_metrics_to : opt text; profile_owner : opt principal; upgrade_version_number : opt nat64; @@ -102,6 +103,7 @@ type KnownPrincipalType = variant { CanisterIdDataBackup; CanisterIdPostCache; CanisterIdSNSController; + CanisterIdSnsGovernance; UserIdGlobalSuperAdmin; }; type MintEvent = variant { @@ -129,6 +131,7 @@ type PlacedBetDetail = record { }; type Post = record { id : nat64; + is_nsfw : bool; status : PostStatus; share_count : nat64; hashtags : vec text; @@ -143,6 +146,7 @@ type Post = record { }; type PostDetailsForFrontend = record { id : nat64; + is_nsfw : bool; status : PostStatus; home_feed_ranking_score : nat64; hashtags : vec text; @@ -151,6 +155,7 @@ type PostDetailsForFrontend = record { description : text; total_view_count : nat64; created_by_display_name : opt text; + created_at : SystemTime; created_by_unique_user_name : opt text; video_uid : text; created_by_user_principal_id : principal; @@ -159,6 +164,7 @@ type PostDetailsForFrontend = record { created_by_profile_photo_url : opt text; }; type PostDetailsFromFrontend = record { + is_nsfw : bool; hashtags : vec text; description : text; video_uid : text; @@ -267,7 +273,7 @@ type UserProfileUpdateDetailsFromFrontend = record { profile_picture_url : opt text; display_name : opt text; }; -service : (IndividualUserTemplateInitArgs) -> { +service : { add_post_v2 : (PostDetailsFromFrontend) -> (Result); backup_data_to_backup_canister : (principal, principal) -> (); bet_on_currently_viewing_post : (PlaceBetArg) -> (Result_1); @@ -293,12 +299,15 @@ service : (IndividualUserTemplateInitArgs) -> { get_profile_details : () -> (UserProfileDetailsForFrontend) query; get_rewarded_for_referral : (principal, principal) -> (); get_rewarded_for_signing_up : () -> (); + get_stable_memory_size : () -> (nat32) query; get_user_caniser_cycle_balance : () -> (nat) query; get_user_utility_token_transaction_history_with_pagination : ( nat64, nat64, ) -> (Result_5) query; get_utility_token_balance : () -> (nat64) query; + get_version : () -> (text) query; + get_version_number : () -> (nat64) query; get_well_known_principal_value : (KnownPrincipalType) -> ( opt principal, ) query; diff --git a/did/post_cache.did b/did/post_cache.did index f168a441..db5f346b 100644 --- a/did/post_cache.did +++ b/did/post_cache.did @@ -7,6 +7,7 @@ type KnownPrincipalType = variant { CanisterIdDataBackup; CanisterIdPostCache; CanisterIdSNSController; + CanisterIdSnsGovernance; UserIdGlobalSuperAdmin; }; type PostCacheInitArgs = record { @@ -23,7 +24,7 @@ type TopPostsFetchError = variant { InvalidBoundsPassed; ExceededMaxNumberOfItemsAllowedInOneRequest; }; -service : (PostCacheInitArgs) -> { +service : { get_top_posts_aggregated_from_canisters_on_this_network_for_home_feed : ( nat64, nat64, diff --git a/did/user_index.did b/did/user_index.did index fe0b58d0..8afd9e09 100644 --- a/did/user_index.did +++ b/did/user_index.did @@ -23,6 +23,7 @@ type KnownPrincipalType = variant { CanisterIdDataBackup; CanisterIdPostCache; CanisterIdSNSController; + CanisterIdSnsGovernance; UserIdGlobalSuperAdmin; }; type RejectionCode = variant { @@ -38,7 +39,9 @@ type Result = variant { Ok : record { CanisterStatusResponse }; Err : record { RejectionCode; text }; }; -type Result_1 = variant { Ok; Err : SetUniqueUsernameError }; +type Result_1 = variant { Ok : text; Err : text }; +type Result_2 = variant { Ok; Err : text }; +type Result_3 = variant { Ok; Err : SetUniqueUsernameError }; type SetUniqueUsernameError = variant { UsernameAlreadyTaken; SendingCanisterDoesNotMatchUserCanisterId; @@ -50,6 +53,7 @@ type SystemTime = record { }; type UpgradeStatus = record { version_number : nat64; + version : text; last_run_on : SystemTime; failed_canister_ids : vec record { principal; principal; text }; successful_upgrade_count : nat32; @@ -62,12 +66,18 @@ type UserAccessRole = variant { }; type UserIndexInitArgs = record { known_principal_ids : opt vec record { KnownPrincipalType; principal }; + version : text; access_control_map : opt vec record { principal; vec UserAccessRole }; }; -service : (UserIndexInitArgs) -> { +service : { + are_signups_enabled : () -> (bool) query; backup_all_individual_user_canisters : () -> (); + get_current_list_of_all_well_known_principal_values : () -> ( + vec record { KnownPrincipalType; principal }, + ) query; get_index_details_is_user_name_taken : (text) -> (bool) query; get_index_details_last_upgrade_status : () -> (UpgradeStatus) query; + get_list_of_available_canisters : () -> (vec principal) query; get_requester_principals_canister_id_create_if_not_exists_and_optionally_allow_referrer : ( opt principal, ) -> (principal); @@ -86,16 +96,20 @@ service : (UserIndexInitArgs) -> { principal, text, ) -> (); + reset_user_individual_canisters : (vec principal) -> (Result_1); set_permission_to_upgrade_individual_canisters : (bool) -> (text); start_upgrades_for_individual_canisters : () -> (text); + toggle_signups_enabled : () -> (Result_2); update_index_with_unique_user_name_corresponding_to_user_principal_id : ( text, principal, - ) -> (Result_1); + ) -> (Result_3); upgrade_specific_individual_user_canister_with_latest_wasm : ( principal, principal, opt CanisterInstallMode, - bool, ) -> (text); + validate_reset_user_individual_canisters : (vec principal) -> ( + Result_1, + ) query; } \ No newline at end of file diff --git a/fly.toml b/fly.toml index 0c6e13c7..843117d4 100644 --- a/fly.toml +++ b/fly.toml @@ -15,3 +15,6 @@ auto_stop_machines = true auto_start_machines = true min_machines_running = 0 processes = ["app"] + +[env] +CF_ACCOUNT_ID="a209c523d2d9646cc56227dbe6ce3ede" \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index b0121978..4d742356 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,13 @@ use crate::{ error_template::{AppError, ErrorTemplate}, - page::{err::ServerErrorPage, post_view::PostView, profile::ProfileView, root::RootPage}, - state::canisters::Canisters, + page::{ + err::ServerErrorPage, post_view::PostView, profile::ProfileView, root::RootPage, + upload::UploadPostPage, + }, + state::{ + auth::AuthClient, + canisters::{do_canister_auth, Canisters}, + }, }; use leptos::*; use leptos_meta::*; @@ -12,6 +18,10 @@ pub fn App() -> impl IntoView { // Provides context that manages stylesheets, titles, meta tags, etc. provide_meta_context(); provide_context(Canisters::default()); + provide_context(Resource::local( + || (), + |_| do_canister_auth(AuthClient::default()), + )); view! { @@ -30,6 +40,7 @@ pub fn App() -> impl IntoView { + diff --git a/src/canister/generated.rs b/src/canister/generated.rs new file mode 100644 index 00000000..5614f263 --- /dev/null +++ b/src/canister/generated.rs @@ -0,0 +1 @@ +include!(concat!(env!("OUT_DIR"), "/did/mod.rs")); diff --git a/src/canister/mod.rs b/src/canister/mod.rs index a5bbca56..69cfb6ae 100644 --- a/src/canister/mod.rs +++ b/src/canister/mod.rs @@ -1,8 +1,6 @@ //! Auto generated bindings for canisters #[allow(clippy::all)] -mod generated { - include!(concat!(env!("OUT_DIR"), "/did/mod.rs")); -} +mod generated; pub mod utils; pub use generated::*; diff --git a/src/component/mod.rs b/src/component/mod.rs index bac27d7b..fca1591e 100644 --- a/src/component/mod.rs +++ b/src/component/mod.rs @@ -1,2 +1,4 @@ pub mod bullet_loader; +pub mod modal; pub mod spinner; +pub mod toggle; diff --git a/src/component/modal.rs b/src/component/modal.rs new file mode 100644 index 00000000..d7fc4667 --- /dev/null +++ b/src/component/modal.rs @@ -0,0 +1,33 @@ +use cfg_if::cfg_if; +use leptos::*; +use leptos_icons::*; + +#[component] +pub fn Modal(#[prop(into)] show: RwSignal, children: Children) -> impl IntoView { + view! { + (& ev); if target.class_list() + .contains("modal-bg") { show.set(false); } } else { _ = ev } + } + } + + class="cursor-pointer modal-bg w-screen h-screen absolute left-0 top-0 bg-black/60 z-50 justify-center items-center" + style:display=move || if show() { "flex" } else { "none" } + > + + + + + + + {children()} + + + } +} diff --git a/src/component/toggle.rs b/src/component/toggle.rs new file mode 100644 index 00000000..584d9bbe --- /dev/null +++ b/src/component/toggle.rs @@ -0,0 +1,21 @@ +use leptos::{html::Input, *}; + +#[component] +pub fn Toggle( + #[prop(into)] lab: String, + #[prop(optional)] node_ref: Option>, +) -> impl IntoView { + view! { + + {move || { + if let Some(_ref) = node_ref { + view! { } + } else { + view! { } + } + }} + + {lab} + + } +} diff --git a/src/consts.rs b/src/consts.rs index aca9e19f..720741c2 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -1,2 +1,10 @@ +use once_cell::sync::Lazy; +use reqwest::Url; + pub const CF_STREAM_BASE: &str = "https://customer-2p3jflss4r4hmpnz.cloudflarestream.com"; pub const FALLBACK_PROPIC_BASE: &str = "https://api.dicebear.com/7.x/big-smile/svg"; +pub const CF_WATERMARK_UID: &str = "28c721e45583a215d7b2ec1ae16e2679"; +pub static CF_BASE_URL: Lazy = + Lazy::new(|| Url::parse("https://api.cloudflare.com/client/v4/").unwrap()); +pub static AUTH_URL: Lazy = + Lazy::new(|| Url::parse("https://hot-or-not-auth.fly.dev/").unwrap()); diff --git a/src/main.rs b/src/main.rs index 18baf23a..6f6e5fe3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,8 @@ mod handlers { raw_query, move || { provide_context(app_state.canisters.clone()); + #[cfg(feature = "cloudflare")] + provide_context(app_state.cloudflare.clone()); }, request, ) @@ -38,6 +40,8 @@ mod handlers { app_state.routes.clone(), move || { provide_context(app_state.canisters.clone()); + #[cfg(feature = "cloudflare")] + provide_context(app_state.cloudflare.clone()); }, App, ); @@ -45,6 +49,16 @@ mod handlers { } } +#[cfg(feature = "ssr")] +#[cfg(feature = "cloudflare")] +fn init_cf() -> hot_or_not_web_leptos_ssr::state::cf::CfApi { + use hot_or_not_web_leptos_ssr::state::cf::{CfApi, CfCredentials}; + let Some(creds) = CfCredentials::from_env("CF_TOKEN", "CF_ACCOUNT_ID") else { + panic!("Cloudlflare credentials are required: CF_TOKEN, CF_ACCOUNT_ID"); + }; + CfApi::::new(creds) +} + #[cfg(feature = "ssr")] #[tokio::main] async fn main() { @@ -73,6 +87,8 @@ async fn main() { leptos_options, canisters: Canisters::default(), routes: routes.clone(), + #[cfg(feature = "cloudflare")] + cloudflare: init_cf(), }; // build our application with a route diff --git a/src/page/mod.rs b/src/page/mod.rs index de9018e6..e020e9c0 100644 --- a/src/page/mod.rs +++ b/src/page/mod.rs @@ -2,3 +2,4 @@ pub mod err; pub mod post_view; pub mod profile; pub mod root; +pub mod upload; diff --git a/src/page/post_view/mod.rs b/src/page/post_view/mod.rs index 639b847b..70e6e7ee 100644 --- a/src/page/post_view/mod.rs +++ b/src/page/post_view/mod.rs @@ -11,7 +11,7 @@ use leptos_router::*; use crate::{ component::spinner::FullScreenSpinner, - state::canisters::Canisters, + state::canisters::unauth_canisters, try_or_redirect, utils::route::{failure_redirect, go_to_root}, }; @@ -123,7 +123,7 @@ pub fn PostViewWithUpdates(initial_post: Option) -> impl IntoView { let current_idx = create_rw_signal(0); let fetch_video_uids = Resource::once(move || async move { - let canisters = expect_context::(); + let canisters = unauth_canisters(); let cursor = fetch_cursor.get_untracked(); let fetch_stream = VideoFetchStream::new(&canisters, cursor); let chunks = try_or_redirect!(fetch_stream.fetch_post_uids_chunked(8).await); diff --git a/src/page/post_view/video_iter.rs b/src/page/post_view/video_iter.rs index 8547153c..ff3a9210 100644 --- a/src/page/post_view/video_iter.rs +++ b/src/page/post_view/video_iter.rs @@ -10,7 +10,7 @@ use crate::{ use super::{error::PostViewError, FetchCursor}; pub async fn get_post_uid( - canisters: &Canisters, + canisters: &Canisters, user_canister: Principal, post_id: u64, ) -> Result, PostViewError> { @@ -51,12 +51,12 @@ pub struct PostDetails { } pub struct VideoFetchStream<'a> { - canisters: &'a Canisters, + canisters: &'a Canisters, cursor: FetchCursor, } impl<'a> VideoFetchStream<'a> { - pub fn new(canisters: &'a Canisters, cursor: FetchCursor) -> Self { + pub fn new(canisters: &'a Canisters, cursor: FetchCursor) -> Self { Self { canisters, cursor } } diff --git a/src/page/post_view/video_loader.rs b/src/page/post_view/video_loader.rs index d1e27873..3b9b8150 100644 --- a/src/page/post_view/video_loader.rs +++ b/src/page/post_view/video_loader.rs @@ -72,7 +72,15 @@ pub fn HlsVideo(video_ref: NodeRef, allow_show: RwSignal) -> impl I }) }); - view! { } + view! { + + } } #[component] diff --git a/src/page/profile/ic.rs b/src/page/profile/ic.rs index 38601f0d..7654ca26 100644 --- a/src/page/profile/ic.rs +++ b/src/page/profile/ic.rs @@ -12,7 +12,7 @@ use crate::{ }, component::bullet_loader::BulletLoader, consts::FALLBACK_PROPIC_BASE, - state::canisters::Canisters, + state::canisters::unauth_canisters, }; #[derive(Serialize, Deserialize, Clone)] @@ -173,7 +173,7 @@ where I: 'static, C: Fn(R) -> Option> + 'static, { - let canisters = expect_context::(); + let canisters = unauth_canisters(); futures::stream::try_unfold( (getter, conv, canisters, 0usize, false), move |(getter, conv, canisters, mut cursor, mut ended)| async move { diff --git a/src/page/profile/mod.rs b/src/page/profile/mod.rs index 52d5af02..6a88877e 100644 --- a/src/page/profile/mod.rs +++ b/src/page/profile/mod.rs @@ -7,7 +7,7 @@ use leptos::*; use leptos_icons::*; use leptos_router::*; -use crate::{component::spinner::FullScreenSpinner, state::canisters::Canisters}; +use crate::{component::spinner::FullScreenSpinner, state::canisters::unauth_canisters}; use ic::ProfileDetails; use posts::ProfilePosts; @@ -117,7 +117,7 @@ pub fn ProfileView() -> impl IntoView { }; let user_details = create_resource(principal_or_username, |principal_or_username| async move { - let canisters = expect_context::(); + let canisters = unauth_canisters(); let user_index = canisters.user_index(); let user_canister = match principal_or_username? { Ok(p) => user_index diff --git a/src/page/profile/speculation.rs b/src/page/profile/speculation.rs index 8014b3b3..193d5f73 100644 --- a/src/page/profile/speculation.rs +++ b/src/page/profile/speculation.rs @@ -5,7 +5,7 @@ use leptos_icons::*; use super::ic::{ speculations_stream, BetDetails, BetOutcome, PostDetails, ProfileDetails, ProfileStream, }; -use crate::{canister::utils::bg_url, state::canisters::Canisters}; +use crate::{canister::utils::bg_url, state::canisters::unauth_canisters}; #[component] pub fn ExternalUser(user: Option) -> impl IntoView { @@ -104,7 +104,7 @@ pub fn Speculation(details: BetDetails) -> impl IntoView { let profile_details = create_resource( move || details.canister_id, move |canister_id| async move { - let canister = expect_context::(); + let canister = unauth_canisters(); let user = canister.individual_user(canister_id); let profile_details = user.get_profile_details().await.ok()?; Some(ProfileDetails::from(profile_details)) @@ -113,7 +113,7 @@ pub fn Speculation(details: BetDetails) -> impl IntoView { let post_details = create_resource( move || (details.canister_id, details.post_id), move |(canister_id, post_id)| async move { - let canister = expect_context::(); + let canister = unauth_canisters(); let user = canister.individual_user(canister_id); let post_details = user.get_individual_post_details_by_id(post_id).await.ok()?; Some(PostDetails::from(&post_details)) diff --git a/src/page/root.rs b/src/page/root.rs index dc0f08c5..91f951d3 100644 --- a/src/page/root.rs +++ b/src/page/root.rs @@ -4,11 +4,11 @@ use leptos::*; use leptos_router::*; #[cfg(feature = "ssr")] -use crate::{canister::post_cache, state::canisters::Canisters}; +use crate::{canister::post_cache, state::canisters::unauth_canisters}; #[server] async fn get_top_post_id() -> Result, ServerFnError> { - let canisters = expect_context::(); + let canisters = unauth_canisters(); let post_cache = canisters.post_cache(); let top_items = match post_cache diff --git a/src/page/upload/cf_upload.rs b/src/page/upload/cf_upload.rs new file mode 100644 index 00000000..b2a2dfaf --- /dev/null +++ b/src/page/upload/cf_upload.rs @@ -0,0 +1,163 @@ +use candid::Principal; +use cfg_if::cfg_if; +use leptos::*; +use serde::{Deserialize, Serialize}; + +cfg_if! { + if #[cfg(feature = "cloudflare")] { + pub use cf_impl::upload_video_stream; + #[cfg(feature = "ssr")] + use cf_impl::server_func::*; + } else { + pub use mock_impl::upload_video_stream; + #[cfg(feature = "ssr")] + use mock_impl::server_func::*; + } +} + +#[derive(Serialize, Deserialize)] +pub struct UploadInfo { + pub uid: String, + pub upload_url: String, +} + +#[server(GetUploadInfo)] +pub async fn get_upload_info( + creator: Principal, + hashtags: Vec, + description: String, + file_name: String, +) -> Result { + // TODO(SECURITY): authenticate creator + + if description.len() < 10 { + return Err(ServerFnError::Args( + "Description must be at least 10 characters".into(), + )); + } + if hashtags.len() > 8 { + return Err(ServerFnError::Args("Too many hashtags".into())); + } + + get_upload_info_impl(creator, hashtags, description, file_name).await +} + +#[server(GetVideoStatus)] +pub async fn get_video_status(uid: String) -> Result { + get_video_status_impl(uid).await +} + +#[cfg(feature = "cloudflare")] +mod cf_impl { + use cfg_if::cfg_if; + + use super::UploadInfo; + + #[cfg(feature = "ssr")] + pub mod server_func { + use candid::Principal; + use leptos::{expect_context, ServerFnError}; + + use super::UploadInfo; + use crate::{ + consts::CF_WATERMARK_UID, + state::cf::{ + direct_upload::DirectUpload, enable_mp4::EnableMp4, video_details::VideoDetails, + CfApi, CfReqAuth, + }, + }; + use std::time::Duration; + + pub async fn get_upload_info_impl( + creator: Principal, + hashtags: Vec, + description: String, + file_name: String, + ) -> Result { + let cf_api: CfApi = expect_context(); + let res = DirectUpload::default() + .creator(creator.to_text()) + .add_meta("hashtags", hashtags.join(",")) + .add_meta("description", description) + .add_meta("fileName", file_name) + .add_meta("uploadType", "challenge") + .watermark(CF_WATERMARK_UID) + .max_duration(Duration::from_secs(60)) + .send(&cf_api) + .await?; + + Ok(UploadInfo { + uid: res.uid, + upload_url: res.upload_url, + }) + } + + pub async fn get_video_status_impl(uid: String) -> Result { + let cf_api: CfApi = expect_context(); + let res = VideoDetails::new(uid.clone()).send(&cf_api).await?; + let state = res.status.state.as_str(); + if state == "ready" { + EnableMp4::new(uid).send(&cf_api).await?; + } + + Ok(res.status.state) + } + } + + pub async fn upload_video_stream( + _upload_res: &UploadInfo, + _file: &gloo::file::File, + ) -> Result<(), gloo::net::Error> { + cfg_if! {if #[cfg(feature = "hydrate")] { + use gloo::net::http::Request; + use leptos::web_sys::FormData; + let form = FormData::new().unwrap(); + form.append_with_blob("file", _file.as_ref()).unwrap(); + let req = Request::post(&_upload_res.upload_url) + .body(form) + .unwrap(); + req.send().await?; + }} + Ok(()) + } +} + +#[cfg(not(feature = "cloudflare"))] +mod mock_impl { + use super::UploadInfo; + + #[cfg(feature = "ssr")] + pub mod server_func { + use candid::Principal; + use leptos::ServerFnError; + use std::time::Duration; + + use super::UploadInfo; + + pub async fn get_upload_info_impl( + _creator: Principal, + _hashtags: Vec, + _description: String, + _file_name: String, + ) -> Result { + Ok(UploadInfo { + uid: "mock".into(), + upload_url: "http://mock.com".into(), + }) + } + + pub async fn get_video_status_impl(_uid: String) -> Result { + tokio::time::sleep(Duration::from_secs(2)).await; + Ok("ready".into()) + } + } + + pub async fn upload_video_stream( + _upload_res: &UploadInfo, + _file: &gloo::file::File, + ) -> Result<(), gloo::net::Error> { + use gloo::timers::future::TimeoutFuture; + TimeoutFuture::new(1000).await; + Ok(()) + } +} diff --git a/src/page/upload/mod.rs b/src/page/upload/mod.rs new file mode 100644 index 00000000..b010de25 --- /dev/null +++ b/src/page/upload/mod.rs @@ -0,0 +1,157 @@ +mod cf_upload; +mod validators; +mod video_upload; + +use crate::component::toggle::Toggle; + +use leptos::{ + html::{Input, Textarea}, + *, +}; + +use validators::{description_validator, hashtags_validator}; +use video_upload::{FileWithUrl, PreVideoUpload, VideoUploader}; + +#[derive(Clone)] +struct UploadParams { + file_blob: FileWithUrl, + hashtags: Vec, + description: String, + enable_hot_or_not: bool, + is_nsfw: bool, +} + +#[component] +fn PreUploadView(trigger_upload: WriteSignal>) -> impl IntoView { + let description_err = create_rw_signal(String::new()); + let desc_err_memo = create_memo(move |_| description_err()); + let hashtags = create_rw_signal(Vec::new()); + let hashtags_err = create_rw_signal(String::new()); + let hashtags_err_memo = create_memo(move |_| hashtags_err()); + let file_blob = create_rw_signal(None::); + let invalid_form = create_memo(move |_| { + with!(|desc_err_memo, hashtags_err_memo, file_blob| { + !desc_err_memo.is_empty() || !hashtags_err_memo.is_empty() || file_blob.is_none() + }) + }); + let desc = create_node_ref::(); + let hashtag_inp = create_node_ref::(); + let enable_hot_or_not = create_node_ref::(); + let is_nsfw = create_node_ref::(); + let on_submit = move || { + let description = desc.get_untracked().unwrap().value(); + let hashtags = hashtags.get_untracked(); + let Some(file_blob) = file_blob.get_untracked() else { + return; + }; + trigger_upload.set(Some(UploadParams { + file_blob, + hashtags, + description, + enable_hot_or_not: enable_hot_or_not + .get_untracked() + .map(|v| v.checked()) + .unwrap_or_default(), + is_nsfw: is_nsfw + .get_untracked() + .map(|v| v.checked()) + .unwrap_or_default(), + })); + }; + + let hashtag_on_input = move |hts| match hashtags_validator(hts) { + Ok(hts) => { + hashtags.set(hts); + hashtags_err.set(String::new()); + } + Err(e) => hashtags_err.set(e), + }; + + create_effect(move |_| { + let Some(hashtag_inp) = hashtag_inp() else { + return; + }; + + let val = hashtag_inp.value(); + if !val.is_empty() { + hashtag_on_input(val); + } + }); + + view! { + + + + + {desc_err_memo()} + + + + + Add Hashtags } + } + > + + {hashtags_err_memo()} + + + + + + + + + Upload Video + + + } +} + +#[component] +pub fn UploadPostPage() -> impl IntoView { + let trigger_upload = create_rw_signal(None::); + + view! { + + Upload + + } + } + > + + + + + + } +} diff --git a/src/page/upload/validators.rs b/src/page/upload/validators.rs new file mode 100644 index 00000000..395c4da0 --- /dev/null +++ b/src/page/upload/validators.rs @@ -0,0 +1,33 @@ +pub fn description_validator(desc: String) -> Result<(), String> { + if desc.is_empty() { + return Err("Description is required".into()); + } else if desc.len() < 10 { + return Err("Description must be at least 10 characters".into()); + } + + Ok(()) +} + +pub fn hashtags_validator(hashtags: String) -> Result, String> { + if hashtags.is_empty() { + return Err("Hashtags are required".into()); + } + + let hashtags: Vec<_> = hashtags + .split(',') + .filter_map(|s| { + let ht = s.trim().replace('#', ""); + if ht.is_empty() { + None + } else { + Some(ht) + } + }) + .collect(); + + if hashtags.len() > 8 { + return Err("Only a maximum of 8 hashtags are allowed".into()); + } + + Ok(hashtags) +} diff --git a/src/page/upload/video_upload.rs b/src/page/upload/video_upload.rs new file mode 100644 index 00000000..3f82b665 --- /dev/null +++ b/src/page/upload/video_upload.rs @@ -0,0 +1,279 @@ +use super::{ + cf_upload::{get_upload_info, get_video_status, upload_video_stream}, + UploadParams, +}; +use crate::{ + canister::individual_user_template::{PostDetailsFromFrontend, Result_}, + component::modal::Modal, + state::canisters::{authenticated_canisters, Canisters}, + try_or_redirect, try_or_redirect_opt, + utils::route::{failure_redirect, go_to_root}, +}; +use candid::Principal; +use cfg_if::cfg_if; +use futures::StreamExt; +use gloo::{file::ObjectUrl, timers::future::IntervalStream}; +use leptos::{ + ev::{change, durationchange}, + html::{Input, Video}, + *, +}; +use leptos_icons::*; +use leptos_use::use_event_listener; +use web_time::SystemTime; + +#[component] +pub fn DropBox() -> impl IntoView { + view! { + + + + Click to upload + or drag and drop + + Video File (Max 60s) + + } +} + +#[derive(Clone)] +pub struct FileWithUrl { + file: gloo::file::File, + url: ObjectUrl, +} + +impl FileWithUrl { + #[cfg(feature = "hydrate")] + fn new(file: gloo::file::File) -> Self { + let url = ObjectUrl::from(file.clone()); + Self { file, url } + } +} + +#[component] +pub fn PreVideoUpload(file_blob: WriteSignal>) -> impl IntoView { + let file_ref = create_node_ref::(); + let file = create_rw_signal(None::); + let video_ref = create_node_ref::(); + let modal_show = create_rw_signal(false); + + _ = use_event_listener(file_ref, change, move |_ev| { + cfg_if! { if #[cfg(feature = "hydrate")] { + use wasm_bindgen::JsCast; + use web_sys::HtmlInputElement; + _ev.target().and_then(|target| { + let input: &HtmlInputElement = target.dyn_ref()?; + let inp_file = input.files()?.get(0)?; + file.set(Some(FileWithUrl::new(inp_file.into()))); + Some(()) + }); + }} + }); + + _ = use_event_listener(video_ref, durationchange, move |_| { + let duration = video_ref + .get_untracked() + .map(|v| v.duration()) + .unwrap_or_default(); + let Some(vid_file) = file.get_untracked() else { + return; + }; + if duration <= 60.0 || duration.is_nan() { + modal_show.set(false); + file_blob.set(Some(vid_file)); + return; + } + + batch(|| { + modal_show.set(true); + file.set(None); + file_blob.set(None); + }); + if let Some(f) = file_ref.get_untracked() { + f.set_value(""); + } + }); + + view! { + + + + + + + + + + + + + Please ensure that the video is shorter than 60 seconds + + + } +} + +#[component] +pub fn ProgressItem( + #[prop(into)] initial_text: String, + #[prop(into)] done_text: String, + #[prop(into)] loading: Signal, +) -> impl IntoView { + view! { + + {done_text.clone()} + } + } + > + + + {initial_text.clone()} + + } +} + +#[component] +pub fn VideoUploader(params: UploadParams) -> impl IntoView { + let file_blob = params.file_blob; + let hashtags = params.hashtags; + let description = params.description; + + let uploading = create_rw_signal(true); + let processing = create_rw_signal(true); + let publishing = create_rw_signal(true); + let video_url = file_blob.url; + let file_blob = file_blob.file.clone(); + + let up_hashtags = hashtags.clone(); + let up_desc = description.clone(); + let upload_action = create_action(move |_: &()| { + let hashtags = up_hashtags.clone(); + let description = up_desc.clone(); + let file_blob = file_blob.clone(); + async move { + let time_ms = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_millis(); + let upload_info = try_or_redirect_opt!( + get_upload_info( + Principal::anonymous(), + hashtags, + description, + time_ms.to_string() + ) + .await + ); + try_or_redirect_opt!(upload_video_stream(&upload_info, &file_blob).await); + uploading.set(false); + + let mut check_status = IntervalStream::new(4000); + while (check_status.next().await).is_some() { + let uid = upload_info.uid.clone(); + let status = try_or_redirect_opt!(get_video_status(uid).await); + if status == "ready" { + break; + } + } + processing.set(false); + + Some(upload_info.uid) + } + }); + upload_action.dispatch(()); + + let canisters = authenticated_canisters(); + let upload_uid = upload_action.value(); + let publish_action = create_action(move |(canisters, uid): &(Canisters, String)| { + let canisters = canisters.clone(); + let hashtags = hashtags.clone(); + let description = description.clone(); + let uid = uid.clone(); + async move { + let user = canisters.authenticated_user(); + let res = user + .add_post_v_2(PostDetailsFromFrontend { + hashtags, + description, + video_uid: uid, + creator_consent_for_inclusion_in_hot_or_not: params.enable_hot_or_not, + is_nsfw: params.is_nsfw, + }) + .await; + let res = try_or_redirect!(res); + match res { + Result_::Ok(_) => (), + Result_::Err(e) => { + failure_redirect(e); + return; + } + } + publishing.set(false); + } + }); + + view! { + + + + + + + + + + + + + + {move || { + let uid = upload_uid().flatten()?; + let canisters = try_or_redirect_opt!(canisters.get() ?); + publish_action.dispatch((canisters, uid)); + Some(()) + }} + + + + + Continue Browsing + + + } +} diff --git a/src/state/auth.rs b/src/state/auth.rs new file mode 100644 index 00000000..34e984e5 --- /dev/null +++ b/src/state/auth.rs @@ -0,0 +1,122 @@ +use std::num::ParseIntError; + +use candid::Principal; +use ic_agent::identity::{DelegatedIdentity, Secp256k1Identity}; +use k256::SecretKey; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::consts::AUTH_URL; + +#[derive(Debug, Serialize)] +struct PrincipalId { + _arr: String, + #[serde(rename = "_isPrincipal")] + _is_principal: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DelegationIdentity { + pub _inner: Vec>, + pub _delegation: DelegationChain, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DelegationChain { + pub delegations: Vec, + #[serde(rename = "publicKey")] + pub public_key: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SignedDelegation { + pub delegation: Delegation, + pub signature: Vec, +} + +impl TryFrom for ic_agent::identity::SignedDelegation { + type Error = AuthError; + + fn try_from(value: SignedDelegation) -> Result { + Ok(ic_agent::identity::SignedDelegation { + delegation: ic_agent::identity::Delegation { + pubkey: value.delegation.pubkey, + expiration: u64::from_str_radix(&value.delegation.expiration, 16)?, + targets: value.delegation.targets.and_then(|v| { + v.into_iter() + .map(|s| Principal::from_text(s).ok()) + .collect::>() + }), + }, + signature: value.signature, + }) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Delegation { + pub pubkey: Vec, + pub expiration: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub targets: Option>, +} + +impl TryFrom for DelegatedIdentity { + type Error = AuthError; + + fn try_from(value: DelegationIdentity) -> Result { + let sec_key = SecretKey::from_slice(&value._inner[1])?; + let del_key = Secp256k1Identity::from_private_key(sec_key); + Ok(DelegatedIdentity::new( + value._delegation.public_key, + Box::new(del_key), + value + ._delegation + .delegations + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + )) + } +} + +#[derive(Deserialize)] +struct SessionResponse { + #[allow(dead_code)] + user_identity: String, + delegation_identity: DelegationIdentity, +} + +#[derive(Error, Debug, Clone)] +pub enum AuthError { + #[error("Invalid Secret Key")] + InvalidSecretKey(#[from] k256::elliptic_curve::Error), + #[error("Invalid expiry")] + InvalidExpiry(#[from] ParseIntError), + #[error("reqwest error: {0}")] + Reqwest(String), +} + +impl From for AuthError { + fn from(e: reqwest::Error) -> Self { + AuthError::Reqwest(e.to_string()) + } +} + +#[derive(Default, Clone)] +pub struct AuthClient { + client: reqwest::Client, +} + +impl AuthClient { + pub async fn generate_session(&self) -> Result { + let resp: SessionResponse = self + .client + .post(AUTH_URL.join("api/generate_session").unwrap()) + .send() + .await? + .json() + .await?; + resp.delegation_identity.try_into() + } +} diff --git a/src/state/canisters.rs b/src/state/canisters.rs index d2ca4762..ee86d2cb 100644 --- a/src/state/canisters.rs +++ b/src/state/canisters.rs @@ -1,29 +1,85 @@ +use std::sync::Arc; + use candid::Principal; +use ic_agent::{identity::DelegatedIdentity, Identity}; +use leptos::{expect_context, Resource}; -use crate::canister::{ - individual_user_template::IndividualUserTemplate, - post_cache::{self, PostCache}, - user_index::{self, UserIndex}, - AGENT_URL, +use crate::{ + canister::{ + individual_user_template::IndividualUserTemplate, + post_cache::{self, PostCache}, + user_index::{self, UserIndex}, + AGENT_URL, + }, + state::auth::AuthClient, }; -#[derive(Debug, Clone)] -pub struct Canisters { +use super::auth::AuthError; + +#[derive(Clone)] +pub struct Canisters { agent: ic_agent::Agent, + id: Option>, + user_canister: Principal, + expiry: u64, } -impl Default for Canisters { +impl Default for Canisters { fn default() -> Self { Self { agent: ic_agent::Agent::builder() .with_url(AGENT_URL) .build() .unwrap(), + id: None, + user_canister: Principal::anonymous(), + expiry: 0, } } } -impl Canisters { +impl Canisters { + pub fn authenticated(id: DelegatedIdentity) -> Canisters { + let expiry = id + .delegation_chain() + .iter() + .fold(u64::MAX, |prev_expiry, del| { + del.delegation.expiration.min(prev_expiry) + }); + let id = Arc::new(id); + + Canisters { + agent: ic_agent::Agent::builder() + .with_url(AGENT_URL) + .with_arc_identity(id.clone()) + .build() + .unwrap(), + id: Some(id), + user_canister: Principal::anonymous(), + expiry, + } + } + + pub fn expiry_ns(&self) -> u64 { + self.expiry + } + + pub fn identity(&self) -> &DelegatedIdentity { + self.id + .as_ref() + .expect("Authenticated canisters must have an identity") + } + + pub fn user_canister(&self) -> Principal { + self.user_canister + } + + pub fn authenticated_user(&self) -> IndividualUserTemplate<'_> { + IndividualUserTemplate(self.user_canister, &self.agent) + } +} + +impl Canisters { pub fn post_cache(&self) -> PostCache<'_> { PostCache(post_cache::CANISTER_ID, &self.agent) } @@ -36,3 +92,30 @@ impl Canisters { UserIndex(user_index::CANISTER_ID, &self.agent) } } + +pub fn unauth_canisters() -> Canisters { + expect_context() +} + +pub type AuthCanistersResource = Resource<(), Result, AuthError>>; + +pub async fn do_canister_auth(client: AuthClient) -> Result, AuthError> { + let auth = client.generate_session().await?; + let mut canisters = Canisters::::authenticated(auth); + let idx = canisters.user_index(); + // TOOD: referrer + // TODO: error handling + let user_canister = idx + .get_requester_principals_canister_id_create_if_not_exists_and_optionally_allow_referrer( + None, + ) + .await + .unwrap(); + canisters.user_canister = user_canister; + Ok(canisters) +} + +pub fn authenticated_canisters() -> AuthCanistersResource { + // TODO: handle identity expiry + expect_context() +} diff --git a/src/state/cf/direct_upload.rs b/src/state/cf/direct_upload.rs new file mode 100644 index 00000000..7e0e73c6 --- /dev/null +++ b/src/state/cf/direct_upload.rs @@ -0,0 +1,61 @@ +use std::{collections::HashMap, time::Duration}; + +use super::{CfReqAuth, CfReqMeta}; +use http::Method; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +struct Watermark { + uid: String, +} + +#[derive(Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct DirectUpload { + creator: Option, + max_duration_seconds: Option, + meta: HashMap, + watermark: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct DirectUploadRes { + pub uid: String, + #[serde(rename = "uploadURL")] + pub upload_url: String, +} + +impl DirectUpload { + pub fn creator(mut self, creator: impl Into) -> Self { + self.creator = Some(creator.into()); + self + } + + pub fn max_duration(mut self, max_duration: Duration) -> Self { + self.max_duration_seconds = Some(max_duration.as_secs()); + self + } + + pub fn add_meta(mut self, key: impl Into, value: impl Into) -> Self { + self.meta.insert(key.into(), value.into()); + self + } + + pub fn watermark(mut self, uid: impl Into) -> Self { + self.watermark = Some(Watermark { uid: uid.into() }); + self + } +} + +impl CfReqMeta for DirectUpload { + const METHOD: Method = Method::POST; + type JsonResponse = DirectUploadRes; +} + +impl CfReqAuth for DirectUpload { + type Url = String; + + fn path(&self, account_id: &str) -> String { + format!("accounts/{account_id}/stream/direct_upload") + } +} diff --git a/src/state/cf/enable_mp4.rs b/src/state/cf/enable_mp4.rs new file mode 100644 index 00000000..5f35a135 --- /dev/null +++ b/src/state/cf/enable_mp4.rs @@ -0,0 +1,31 @@ +use http::Method; +use serde::Serialize; + +use super::{CfReqAuth, CfReqMeta}; + +#[derive(Serialize)] +pub struct EnableMp4 { + #[serde(skip)] + identifier: String, +} + +impl EnableMp4 { + pub fn new(identifier: impl Into) -> Self { + Self { + identifier: identifier.into(), + } + } +} + +impl CfReqMeta for EnableMp4 { + const METHOD: Method = Method::PUT; + type JsonResponse = (); +} + +impl CfReqAuth for EnableMp4 { + type Url = String; + + fn path(&self, account_id: &str) -> String { + format!("accounts/{account_id}/stream/{}/downloads", self.identifier) + } +} diff --git a/src/state/cf/error.rs b/src/state/cf/error.rs new file mode 100644 index 00000000..7e44d4f9 --- /dev/null +++ b/src/state/cf/error.rs @@ -0,0 +1,28 @@ +use super::CfApiErr; +use leptos::ServerFnErrorErr; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + #[error("errors from cloudflare: {0:?}")] + Cloudflare(Vec), +} + +impl From for ServerFnErrorErr { + fn from(e: Error) -> Self { + ServerFnErrorErr::ServerError(match e { + Error::Reqwest(e) => { + log::warn!("reqwest error while calling cf: {e}"); + "Failed to send to cloudflare".into() + } + Error::Cloudflare(_) => { + log::warn!("{e}"); + "Cloudflare returned an error".into() + } + }) + } +} + +pub type Result = std::result::Result; diff --git a/src/state/cf/mod.rs b/src/state/cf/mod.rs new file mode 100644 index 00000000..f24a96e7 --- /dev/null +++ b/src/state/cf/mod.rs @@ -0,0 +1,151 @@ +use std::{env, sync::Arc}; + +use futures::Future; +use http::Method; +use reqwest::{IntoUrl, RequestBuilder}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +use crate::consts::CF_BASE_URL; + +pub mod direct_upload; +pub mod enable_mp4; +mod error; +pub mod video_details; +pub use error::*; + +#[derive(Debug)] +pub struct CfCredentials { + token: String, + account_id: String, +} + +impl CfCredentials { + pub fn from_env(token_env: &str, account_id_env: &str) -> Option { + Some(Self { + token: env::var(token_env).ok()?, + account_id: env::var(account_id_env).ok()?, + }) + } +} + +#[derive(Clone, Debug)] +pub struct CfApi { + client: reqwest::Client, + credentials: Option>, +} + +impl CfApi { + pub fn new(creds: CfCredentials) -> Self { + Self { + client: Default::default(), + credentials: Some(Arc::new(creds)), + } + } + + async fn send_auth(&self, req: Req) -> Result { + let reqb = self.req_builder( + Req::METHOD, + CF_BASE_URL + .join( + req.path(&self.credentials.as_ref().unwrap().account_id) + .as_ref(), + ) + .unwrap(), + ); + self.send_inner(req, reqb).await + } +} + +impl Default for CfApi { + fn default() -> Self { + Self::new() + } +} + +impl CfApi { + pub fn new() -> Self { + Self { + client: Default::default(), + credentials: None, + } + } +} + +impl CfApi { + fn req_builder(&self, method: Method, url: impl IntoUrl) -> RequestBuilder { + let reqb = self.client.request(method, url); + if let Some(creds) = self.credentials.as_ref() { + reqb.bearer_auth(&creds.token) + } else { + reqb + } + } + + async fn send_inner( + &self, + req: Req, + reqb: RequestBuilder, + ) -> Result { + let reqb = if Req::METHOD == Method::GET { + reqb.query(&req) + } else { + reqb.json(&req) + }; + let resp = reqb.send().await?; + let status = resp.status(); + if status.is_success() { + let res: CfSuccessRes = resp.json().await?; + return Ok(res.result); + } + + let err: CfErrRes = resp.json().await?; + Err(Error::Cloudflare(err.errors)) + } + + async fn send(&self, req: Req) -> Result { + let reqb = self.req_builder(Req::METHOD, CF_BASE_URL.join(Req::PATH).unwrap()); + self.send_inner(req, reqb).await + } +} + +pub trait CfReqMeta: Serialize + Sized + Send { + const METHOD: Method; + type JsonResponse: DeserializeOwned; +} + +pub trait CfReq: CfReqMeta { + const PATH: &'static str; + + fn send( + self, + api: &CfApi, + ) -> impl Future> { + api.send(self) + } +} + +pub trait CfReqAuth: CfReqMeta { + type Url: AsRef; + + fn path(&self, account_id: &str) -> Self::Url; + + fn send(self, api: &CfApi) -> impl Future> { + api.send_auth(self) + } +} + +#[derive(Deserialize, Debug)] +pub struct CfApiErr { + pub code: u16, + pub message: String, +} + +#[derive(Deserialize)] +struct CfSuccessRes { + result: T, +} + +#[derive(Deserialize)] +struct CfErrRes { + errors: Vec, +} diff --git a/src/state/cf/video_details.rs b/src/state/cf/video_details.rs new file mode 100644 index 00000000..b5001b0e --- /dev/null +++ b/src/state/cf/video_details.rs @@ -0,0 +1,45 @@ +use http::Method; +use serde::{Deserialize, Serialize}; + +use super::{CfReqAuth, CfReqMeta}; + +#[derive(Serialize)] +pub struct VideoDetails { + #[serde(skip)] + identifier: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VideoStatus { + pub error_reason_code: Option, + pub error_reason_text: Option, + pub pct_complete: usize, + pub state: String, +} + +#[derive(Serialize, Deserialize)] +pub struct VideoDetailsRes { + pub status: VideoStatus, +} + +impl VideoDetails { + pub fn new(identifier: impl Into) -> Self { + Self { + identifier: identifier.into(), + } + } +} + +impl CfReqMeta for VideoDetails { + const METHOD: Method = Method::GET; + type JsonResponse = VideoDetailsRes; +} + +impl CfReqAuth for VideoDetails { + type Url = String; + + fn path(&self, account_id: &str) -> String { + format!("accounts/{account_id}/stream/{}", self.identifier) + } +} diff --git a/src/state/mod.rs b/src/state/mod.rs index 46634027..38bdafbd 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -1,4 +1,7 @@ +pub mod auth; pub mod canisters; +#[cfg(feature = "cloudflare")] +pub mod cf; #[cfg(feature = "ssr")] pub mod server { @@ -7,10 +10,12 @@ pub mod server { use leptos::LeptosOptions; use leptos_router::RouteListing; - #[derive(FromRef, Debug, Clone)] + #[derive(FromRef, Clone)] pub struct AppState { pub leptos_options: LeptosOptions, - pub canisters: Canisters, + pub canisters: Canisters, + #[cfg(feature = "cloudflare")] + pub cloudflare: super::cf::CfApi, pub routes: Vec, } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index b9bb57fe..afd63ad6 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1 +1,9 @@ +use web_time::{Duration, SystemTime}; + pub mod route; + +pub fn current_epoch() -> Duration { + web_time::SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() +} diff --git a/src/utils/route.rs b/src/utils/route.rs index eb13a31a..3abf09b9 100644 --- a/src/utils/route.rs +++ b/src/utils/route.rs @@ -1,4 +1,4 @@ -use std::error::Error; +use std::fmt::Display; use leptos_router::use_navigate; @@ -16,7 +16,21 @@ macro_rules! try_or_redirect { }; } -pub fn failure_redirect(err: E) { +#[macro_export] +macro_rules! try_or_redirect_opt { + ($e:expr) => { + match $e { + Ok(v) => v, + Err(e) => { + use $crate::utils::route::failure_redirect; + failure_redirect(e); + return None; + } + } + }; +} + +pub fn failure_redirect(err: E) { let nav = use_navigate(); nav(&format!("/error?err={err}"), Default::default()); }
+ Click to upload + or drag and drop +
Video File (Max 60s)