diff --git a/README.md b/README.md index 04106b3..c08494a 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,29 @@ trunk serve --open * Copy the URL (e.g., `http://localhost:8080/project/my-cool-tool`). * Send it to users. They will get a fresh clone of the environment you set up! +## Security Architecture + +### Embed Authorization + +TryCli Studio implements a dual-layer security model for embedded projects: + +1. **VIP Pass (embed_key):** A private key that grants unrestricted access to embedded projects. Only project owners have access to this key. +2. **Guest List (whitelist):** A list of authorized URLs that can embed the project publicly. + +#### Embed Key Protection + +To prevent accidental exposure of the `embed_key` through browser dev tools or network inspection: + +* The `embed_key` is **not** included in the main project response (`/api/project/:username/:slug`) +* Instead, a dedicated authenticated endpoint (`/api/project/:slug/embed-key`) is used to retrieve the key +* This endpoint requires authentication and verifies project ownership +* The key is only fetched when the user explicitly clicks the "Share / Embed" button + +This separation ensures that: +* Screen sharing during project viewing won't expose the key +* Browser extensions or network logs won't capture the key during normal browsing +* The key is only retrieved when intentionally needed for sharing purposes + ## Troubleshooting * **"Container ID not found":** Ensure you wait for the terminal to initialize before clicking Publish. diff --git a/client/src/api.rs b/client/src/api.rs index 4ca6589..5c23c84 100644 --- a/client/src/api.rs +++ b/client/src/api.rs @@ -1,4 +1,4 @@ -// CONFIGURATION HELPERS +// CONFIGURATION HELPERS pub fn api_base() -> &'static str { option_env!("API_URL").unwrap_or("http://localhost:3000") } diff --git a/client/src/app.rs b/client/src/app.rs index 776be65..b0cf2d2 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -1,7 +1,11 @@ +use crate::components::protected::ProtectedRoute; +use crate::pages::{ + admin::AdminPage, analytics::AnalyticsPage, blogs::BlogsPage, create::CreatePage, + dashboard::DashboardPage, docs::DocsPage, embed::EmbedPage, home::LandingPage, + policy::PolicyPage, view::ViewPage, +}; use leptos::*; use leptos_router::*; -use crate::components::protected::ProtectedRoute; -use crate::pages::{home::LandingPage, dashboard::DashboardPage, create::CreatePage, view::ViewPage, embed::EmbedPage, docs::DocsPage, blogs::BlogsPage, analytics::AnalyticsPage, admin::AdminPage, policy::PolicyPage}; #[component] pub fn App() -> impl IntoView { @@ -23,8 +27,8 @@ pub fn App() -> impl IntoView { } /> - - + + diff --git a/client/src/components/limit.rs b/client/src/components/limit.rs index 335c914..33462eb 100644 --- a/client/src/components/limit.rs +++ b/client/src/components/limit.rs @@ -13,18 +13,18 @@ pub fn LimitReached() -> impl IntoView {
"Please try again later or contact the owner."

- +

"Are you the owner?"

- "Request More Compute" - @@ -34,4 +34,4 @@ pub fn LimitReached() -> impl IntoView {
} -} \ No newline at end of file +} diff --git a/client/src/components/modal.rs b/client/src/components/modal.rs index f6207e8..1509cef 100644 --- a/client/src/components/modal.rs +++ b/client/src/components/modal.rs @@ -69,13 +69,20 @@ pub fn EmbedModal( title: MaybeSignal, iframe_code: MaybeSignal, smart_link: MaybeSignal, + vip_link: MaybeSignal, + whitelist: MaybeSignal>, + on_add_url: Callback, + on_remove_url: Callback, on_close: Callback<()>, ) -> impl IntoView { let (copied_iframe, set_copied_iframe) = create_signal(false); let (copied_link, set_copied_link) = create_signal(false); - + let (copied_vip, set_copied_vip) = create_signal(false); + let (new_url, set_new_url) = create_signal(String::new()); + let iframe_ref = create_node_ref::(); let link_ref = create_node_ref::(); + let vip_ref = create_node_ref::(); view! { {move || { @@ -83,10 +90,15 @@ pub fn EmbedModal( let title = title.clone(); let iframe_code = iframe_code.clone(); let smart_link = smart_link.clone(); + let vip_link = vip_link.clone(); + let whitelist = whitelist.clone(); let on_close = on_close.clone(); + let on_add_url = on_add_url.clone(); + let on_remove_url = on_remove_url.clone(); let iframe_code_for_click = iframe_code.clone(); let smart_link_for_click = smart_link.clone(); + let vip_link_for_click = vip_link.clone(); if show.get() { view! { @@ -96,7 +108,7 @@ pub fn EmbedModal( - + // --- SECTION 1: IFRAME ---
@@ -137,11 +149,79 @@ pub fn EmbedModal(
- // --- SECTION 2: SMART LINK --- + // --- SECTION 2: PRIVATE VIP LINK --- +
+
+ + {move || if copied_vip.get() { + view! { "✓ Copied!" }.into_view() + } else { + view! { "Placeholder" }.into_view() + }} +
+ + {move || { + let link = vip_link.get(); + let vip_link_for_input = vip_link.clone(); + let vip_link_for_button = vip_link_for_click.clone(); + if link.is_empty() { + view! { +
+

+ "VIP link is being generated..." +

+

+ "This may take a moment. The link will appear here once ready." +

+
+ }.into_view() + } else { + view! { + <> +
+ + +
+

+ "Security warning:" + " This VIP link bypasses the Guest List and must only be shared privately. Do NOT embed it on public websites, iframes, or forums; anyone with this link can access your terminal." +

+ + }.into_view() + } + }} +
+ + // --- SECTION 3: SMART LINK ---
{move || if copied_link.get() { view! { "✓ Copied!" }.into_view() @@ -152,7 +232,7 @@ pub fn EmbedModal(
+ // --- SECTION 4: Guest List / Whitelist --- +
+
+ + + "Only these pages can auto-launch your terminal." + +
+
+ + +
+
+ + {url.clone()} + + + } + } + /> +
+
+
} -} \ No newline at end of file +} diff --git a/client/src/pages/blogs.rs b/client/src/pages/blogs.rs index f9ce293..f5ad061 100644 --- a/client/src/pages/blogs.rs +++ b/client/src/pages/blogs.rs @@ -1,10 +1,10 @@ +use crate::api::api_base; +use crate::components::navbar::Navbar; +use crate::types::User; +use gloo_net::http::Request; use leptos::*; use leptos_router::A; -use gloo_net::http::Request; use web_sys::RequestCredentials; -use crate::components::navbar::Navbar; -use crate::api::api_base; -use crate::types::User; #[component] pub fn BlogsPage() -> impl IntoView { @@ -12,21 +12,24 @@ pub fn BlogsPage() -> impl IntoView { let (user, set_user) = create_signal(None::); let (auth_checked, set_auth_checked) = create_signal(false); - create_resource(|| (), move |_| async move { - let url = format!("{}/api/me", api_base()); - if let Ok(resp) = Request::get(&url) - .credentials(RequestCredentials::Include) - .send() - .await - { - if resp.ok() { - if let Ok(u) = resp.json::().await { - set_user.set(Some(u)); + create_resource( + || (), + move |_| async move { + let url = format!("{}/api/me", api_base()); + if let Ok(resp) = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await + { + if resp.ok() { + if let Ok(u) = resp.json::().await { + set_user.set(Some(u)); + } } } - } - set_auth_checked.set(true); - }); + set_auth_checked.set(true); + }, + ); view! {
@@ -37,8 +40,8 @@ pub fn BlogsPage() -> impl IntoView { if auth_checked.get() { if let Some(u) = user.get() { view! { - User Avatar }.into_view() } else { @@ -92,4 +95,4 @@ pub fn BlogsPage() -> impl IntoView {
} -} \ No newline at end of file +} diff --git a/client/src/pages/create.rs b/client/src/pages/create.rs index 71ca9b5..d298d7b 100644 --- a/client/src/pages/create.rs +++ b/client/src/pages/create.rs @@ -1,16 +1,16 @@ +use crate::api::api_base; +use crate::components::modal::Modal; +use crate::components::navbar::Navbar; +use crate::components::terminal::TerminalView; +use crate::types::User; +use gloo_net::http::Request; use leptos::*; use leptos_router::*; -use gloo_net::http::Request; -use web_sys::RequestCredentials; -use wasm_bindgen::JsValue; -use wasm_bindgen::prelude::*; -use std::rc::Rc; use std::cell::RefCell; -use crate::api::api_base; -use crate::types::User; -use crate::components::terminal::TerminalView; -use crate::components::navbar::Navbar; -use crate::components::modal::Modal; +use std::rc::Rc; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsValue; +use web_sys::RequestCredentials; use crate::hooks::use_landscape_lock; // Simple resize divider setup @@ -21,21 +21,21 @@ fn setup_resize_divider() { .and_then(|e| e.dyn_into::().ok()) { let is_dragging = Rc::new(RefCell::new(false)); - + let on_mousedown = { let is_dragging = is_dragging.clone(); wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::MouseEvent| { *is_dragging.borrow_mut() = true; }) as Box) }; - + let on_mousemove = { let is_dragging = is_dragging.clone(); wasm_bindgen::closure::Closure::wrap(Box::new(move |e: web_sys::MouseEvent| { if !*is_dragging.borrow() { return; } - + if let Some(workspace) = web_sys::window() .and_then(|w| w.document()) .and_then(|d| d.query_selector(".workspace").ok().flatten()) @@ -45,36 +45,57 @@ fn setup_resize_divider() { let workspace_left = workspace.offset_left() as f64; let relative_x = e.client_x() as f64 - workspace_left; let percentage = (relative_x / workspace_width * 100.0).max(20.0).min(80.0); - + if let Ok(panes) = workspace.query_selector_all(".pane") { if panes.length() >= 2 { - if let Some(first_pane) = panes.get(0).and_then(|e| e.dyn_into::().ok()) { + if let Some(first_pane) = panes + .get(0) + .and_then(|e| e.dyn_into::().ok()) + { first_pane.style().set_property("flex", "0 1 auto").ok(); - first_pane.style().set_property("width", &format!("{}%", percentage)).ok(); + first_pane + .style() + .set_property("width", &format!("{}%", percentage)) + .ok(); } - if let Some(second_pane) = panes.get(1).and_then(|e| e.dyn_into::().ok()) { + if let Some(second_pane) = panes + .get(1) + .and_then(|e| e.dyn_into::().ok()) + { second_pane.style().set_property("flex", "0 1 auto").ok(); - second_pane.style().set_property("width", &format!("{}%", 100.0 - percentage)).ok(); + second_pane + .style() + .set_property("width", &format!("{}%", 100.0 - percentage)) + .ok(); } } } } }) as Box) }; - + let on_mouseup = { let is_dragging = is_dragging.clone(); wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::MouseEvent| { *is_dragging.borrow_mut() = false; }) as Box) }; - - divider.add_event_listener_with_callback("mousedown", on_mousedown.as_ref().unchecked_ref()).ok(); + + divider + .add_event_listener_with_callback("mousedown", on_mousedown.as_ref().unchecked_ref()) + .ok(); on_mousedown.forget(); - + if let Some(document) = web_sys::window().and_then(|w| w.document()) { - document.add_event_listener_with_callback("mousemove", on_mousemove.as_ref().unchecked_ref()).ok(); - document.add_event_listener_with_callback("mouseup", on_mouseup.as_ref().unchecked_ref()).ok(); + document + .add_event_listener_with_callback( + "mousemove", + on_mousemove.as_ref().unchecked_ref(), + ) + .ok(); + document + .add_event_listener_with_callback("mouseup", on_mouseup.as_ref().unchecked_ref()) + .ok(); on_mousemove.forget(); on_mouseup.forget(); } @@ -85,13 +106,11 @@ fn setup_resize_divider() { pub fn CreatePage() -> impl IntoView { let is_portrait = use_landscape_lock(); let query_params = use_query_map(); - let pre_filled_name = move || { - query_params.with(|params| { - params.get("name").cloned().unwrap_or_default() - }) - }; + let pre_filled_name = + move || query_params.with(|params| params.get("name").cloned().unwrap_or_default()); let (container_id, set_container_id) = create_signal("".to_string()); + let (spawn_error, set_spawn_error) = create_signal(None::); let (markdown, set_markdown) = create_signal(r#"# Welcome to Your TryCLI Environment This interactive workspace is your project's staging area. On the left is your live terminal, and here on the right is your editable documentation panel. @@ -140,47 +159,60 @@ After publishing, you can easily distribute your interactive terminal: let (modal_body, set_modal_body) = create_signal(String::new()); let (modal_success, set_modal_success) = create_signal(false); - create_resource(|| (), move |_| async move { - let url = format!("{}/api/me", api_base()); - let auth_req = Request::get(&url) - .credentials(RequestCredentials::Include) - .send() - .await; - - match auth_req { - Ok(resp) => { - if resp.ok() { - if let Ok(u) = resp.json::().await { - set_user.set(Some(u)); - - let spawn_url = format!("{}/api/spawn", api_base()); - let spawn_req = Request::post(&spawn_url) - .credentials(RequestCredentials::Include) - .send() - .await; - - match spawn_req { - Ok(spawn_resp) => { - if spawn_resp.ok() { - if let Ok(id) = spawn_resp.json::().await { - set_container_id.set(id); + create_resource( + || (), + move |_| async move { + let url = format!("{}/api/me", api_base()); + let auth_req = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await; + + match auth_req { + Ok(resp) => { + if resp.ok() { + if let Ok(u) = resp.json::().await { + set_user.set(Some(u)); + + let spawn_url = format!("{}/api/spawn", api_base()); + let spawn_req = Request::post(&spawn_url) + .credentials(RequestCredentials::Include) + .send() + .await; + + match spawn_req { + Ok(spawn_resp) => { + if spawn_resp.ok() { + if let Ok(id) = spawn_resp.json::().await { + set_container_id.set(id); + set_spawn_error.set(None); + } else { + set_spawn_error.set(Some( + "Failed to parse container ID".to_string(), + )); + } + } else { + let status = spawn_resp.status(); + let text = spawn_resp.text().await.unwrap_or_default(); + set_spawn_error.set(Some(format!( + "Spawn failed ({}): {}", + status, text + ))); } - } else { - let status = spawn_resp.status(); - let text = spawn_resp.text().await.unwrap_or_default(); - set_container_id.set(format!("ERROR {}: {}", status, text)); } - } - Err(e) => { - set_container_id.set(format!("NETWORK_FAIL: {}", e)); + Err(e) => { + set_spawn_error.set(Some(format!("Network error: {}", e))); + } } } } } + Err(e) => { + web_sys::console::log_1(&JsValue::from_str(&format!("Auth Error: {}", e))) + } } - Err(e) => web_sys::console::log_1(&JsValue::from_str(&format!("Auth Error: {}", e))), - } - }); + }, + ); let navigate_modal = use_navigate(); let on_publish = Rc::new(move |_: ev::MouseEvent| { // Prevent concurrent publish requests @@ -221,14 +253,15 @@ After publishing, you can easily distribute your interactive terminal: if resp.ok() { publish_success = true; set_modal_title.set("Published".to_string()); - set_modal_body.set("Your project has been published successfully.".to_string()); + set_modal_body + .set("Your project has been published successfully.".to_string()); } else { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); set_modal_title.set("Publish failed".to_string()); set_modal_body.set(format!("{}: {}", status, text)); } - }, + } Err(_) => { set_modal_title.set("Publish failed".to_string()); set_modal_body.set("Network error while publishing.".to_string()); @@ -275,20 +308,20 @@ After publishing, you can easily distribute your interactive terminal: match user.get() { Some(u) => view! {
- {u.login.clone()}
- "Logout" "Project Slug:" - {err} })} -
} -} \ No newline at end of file +} diff --git a/client/src/pages/embed.rs b/client/src/pages/embed.rs index aae747f..6fb90b4 100644 --- a/client/src/pages/embed.rs +++ b/client/src/pages/embed.rs @@ -1,10 +1,10 @@ -use leptos::*; -use leptos_router::*; -use gloo_net::http::Request; use crate::api::api_base; -use crate::components::terminal::TerminalView; use crate::components::limit::LimitReached; -use serde::{Serialize, Deserialize}; +use crate::components::terminal::TerminalView; +use gloo_net::http::Request; +use leptos::*; +use leptos_router::*; +use serde::{Deserialize, Serialize}; #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] enum ProjectState { @@ -12,27 +12,38 @@ enum ProjectState { NotFound, LimitReached, Ready(serde_json::Value), + Unauthorized, } #[component] pub fn EmbedPage() -> impl IntoView { let params = use_params_map(); + let query_params = use_query_map(); let username = move || params.get().get("username").cloned().unwrap_or_default(); let slug = move || params.get().get("slug").cloned().unwrap_or_default(); let (started, set_started) = create_signal(false); // Resource now returns ProjectState - let project_data = create_resource( +let project_data = create_resource( move || (started.get(), username(), slug()), - |(is_started, u, s)| async move { + move |(is_started, u, s)| async move { if !is_started { return ProjectState::Loading; } - let url = format!("{}/api/project/{}/{}", api_base(), u, s); + // Fix: Include the 'key' parameter if present (for VIP/Secret embeds) + let key = query_params.get_untracked().get("key").cloned().unwrap_or_default(); + let url = if key.is_empty() { + format!("{}/api/project/{}/{}", api_base(), u, s) + } else { + format!("{}/api/project/{}/{}?key={}", api_base(), u, s, key) + }; + let req = Request::get(&url).send().await; - + match req { Ok(resp) => { - if resp.status() == 429 { + if resp.status() == 403 { + ProjectState::Unauthorized + } else if resp.status() == 429 { ProjectState::LimitReached } else if resp.ok() { if let Ok(json) = resp.json::().await { @@ -43,21 +54,21 @@ pub fn EmbedPage() -> impl IntoView { } else { ProjectState::NotFound } - }, - Err(_) => ProjectState::NotFound + } + Err(_) => ProjectState::NotFound, } - } + }, ); view! {
{move || if !started.get() { view! { -

"TryCli Studio Demo"

-
}.into_view() } else { view! {}.into_view() @@ -94,4 +115,4 @@ pub fn EmbedPage() -> impl IntoView { }}
} -} \ No newline at end of file +} diff --git a/client/src/pages/home.rs b/client/src/pages/home.rs index 92c0b4a..ce14c14 100644 --- a/client/src/pages/home.rs +++ b/client/src/pages/home.rs @@ -1,32 +1,35 @@ +use crate::api::api_base; +use crate::components::navbar::Navbar; +use crate::types::User; +use gloo_net::http::Request; use leptos::*; +use leptos_meta::{Link, Meta, Script, Title}; use leptos_router::A; -use leptos_meta::{Title, Meta, Link, Script}; -use gloo_net::http::Request; use web_sys::RequestCredentials; -use crate::components::navbar::Navbar; -use crate::api::api_base; -use crate::types::User; #[component] pub fn LandingPage() -> impl IntoView { let (user, set_user) = create_signal(None::); let (auth_checked, set_auth_checked) = create_signal(false); - create_resource(|| (), move |_| async move { - let url = format!("{}/api/me", api_base()); - if let Ok(resp) = Request::get(&url) - .credentials(RequestCredentials::Include) - .send() - .await - { - if resp.ok() { - if let Ok(u) = resp.json::().await { - set_user.set(Some(u)); + create_resource( + || (), + move |_| async move { + let url = format!("{}/api/me", api_base()); + if let Ok(resp) = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await + { + if resp.ok() { + if let Ok(u) = resp.json::().await { + set_user.set(Some(u)); + } } } - } - set_auth_checked.set(true); - }); + set_auth_checked.set(true); + }, + ); let auth_github_url = move || format!("{}/auth/github", api_base()); @@ -50,45 +53,46 @@ pub fn LandingPage() -> impl IntoView { // 4. SEO METADATA <Meta name="description" content="Turn your static documentation into interactive live demos. Zero-config Docker sandboxes for onboarding users to your CLI tools instantly." /> - + <Link rel="canonical" href="https://trycli.com" /> <Script type_="application/ld+json"> {schema_json} </Script> - + <Meta property="og:type" content="website" /> <Meta property="og:title" content="TryCLI - The Standard for Interactive Documentation" /> <Meta property="og:description" content="Instantly spin up isolated Docker containers and share your CLI projects with a single link." /> <Meta property="og:url" content="https://trycli.com" /> - + <Meta name="twitter:card" content="summary_large_image" /> <Meta name="twitter:title" content="TryCLI Studio" /> <Meta name="twitter:description" content="Host, share, and embed fully interactive CLI demos directly in the browser." /> - + // MAIN CONTENT <div class="landing-container"> <Navbar is_logged_in=user.get().is_some()> + <div class="nav-actions"> {move || { let (menu_open, set_menu_open) = create_signal(false); - + if auth_checked.get() { if let Some(u) = user.get() { // LOGGED IN: Show Profile + Dashboard Button + Hamburger Menu view! { <div style="display: flex; align-items: center; gap: 20px; width: 100%;"> <div style="display: flex; align-items: center; gap: 12px;"> - <img src=u.avatar_url - style="width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--border);" + <img src=u.avatar_url + style="width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--border);" alt="User Avatar" /> <span style="color: var(--text-main); font-weight: 500; font-size: 0.95rem;"> {u.login} </span> </div> <A href="/dashboard" class="btn-secondary btn-action btn-dashboard">"Dashboard"</A> - + // Hamburger Menu Button <button class="hamburger-menu" @@ -117,7 +121,7 @@ pub fn LandingPage() -> impl IntoView { // LOGGED OUT let url = auth_github_url(); let (menu_open, set_menu_open) = create_signal(false); - + view! { <div style="display: flex; align-items: center; gap: 20px; width: 100%;"> <a href=url class="btn-secondary btn-action btn-login" rel="external" style="display: flex; align-items: center; gap: 8px;"> @@ -126,7 +130,7 @@ pub fn LandingPage() -> impl IntoView { </svg> "Login" </a> - + <button class="hamburger-menu" class:open=move || menu_open.get() @@ -160,12 +164,12 @@ pub fn LandingPage() -> impl IntoView { <main class="hero-main"> <div class="hero-content"> <div class="badge">"Now supporting Alpine, Debian & Fish Shell"</div> - + <h1 class="hero-title"> "Interactive CLI Demos"<br /> <span class="text-gradient">"for the Modern Web"</span> </h1> - + <p class="hero-subtitle"> "The modern way to showcase CLI tools. Spin up instant, sandboxed Linux environments directly in your browser. No downloads, no configuration, just code." </p> @@ -181,13 +185,13 @@ pub fn LandingPage() -> impl IntoView { </A> }.into_view() } else { - view! { + view! { <a href=url class="btn-primary btn-hero" rel="external" style="display: flex; align-items: center; gap: 10px;"> <svg height="24" width="24" viewBox="0 0 16 16" fill="currentColor" style="color: black;"> <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path> </svg> "Start Building Free" - </a> + </a> }.into_view() } }} @@ -196,7 +200,7 @@ pub fn LandingPage() -> impl IntoView { </A> </div> - // TERMINAL PREVIEW + // TERMINAL PREVIEW <div class="terminal-preview" role="log" aria-label="Terminal Preview Demo"> <div class="terminal-header-preview" aria-hidden="true"> <div class="dot red"></div> @@ -206,14 +210,14 @@ pub fn LandingPage() -> impl IntoView { </div> <div class="terminal-body-preview"> <div class="line"> - <span class="prompt">"➜"</span> + <span class="prompt">"➜"</span> <span class="cmd">" curl -fsSL https://trycli.com/install.sh | sh"</span> </div> <div class="line output"><span>"→ Initializing environment (Ubuntu 22.04)..."</span></div> <div class="line output"><span>"→ Installing dependencies..."</span></div> <div class="line output"><span class="success">"✔ Environment Ready! Session ID: 9f8a-2b1c"</span></div> <div class="line"> - <span class="prompt">"➜"</span> + <span class="prompt">"➜"</span> <span class="cmd">" trycli publish --public"</span> </div> <div class="line output"><span>"Snapshotting container state... Done (1.2s)"</span></div> @@ -223,18 +227,18 @@ pub fn LandingPage() -> impl IntoView { </div> </main> - // FEATURES + // FEATURES <section class="section-features" style="background: rgba(255,255,255,0.01);"> <div class="container-narrow"> <h2 class="section-title">"Frictionless Onboarding"</h2> - + <p class="section-subtitle" style="text-align: left; margin-bottom: 3rem;"> "The biggest drop-off in developer adoption happens before the first command is ever run. " "TryCLI bridges the gap between reading about a tool and actually experiencing it." </p> <div style="display: flex; flex-wrap: wrap; gap: 40px; align-items: center;"> - + // Left Column: Text explanation <div style="flex: 1 1 400px; text-align: left;"> <h3 style="font-size: 1.5rem; margin-bottom: 1rem; color: #fff; font-weight: 700;">"Stop Losing Users at 'npm install'"</h3> @@ -313,7 +317,7 @@ pub fn LandingPage() -> impl IntoView { </section> - // FINAL CTA + // FINAL CTA <section class="section-usage" style="border-bottom: none;"> <div class="container-narrow"> <div class="final-cta"> @@ -329,13 +333,13 @@ pub fn LandingPage() -> impl IntoView { if auth_checked.get() && user.get().is_some() { view! { <A href="/new" class="btn-secondary btn-lg">"Create New Project"</A> }.into_view() } else { - view! { + view! { <a href=url class="btn-primary btn-hero btn-lg" rel="external" style="display: flex; align-items: center; gap: 10px;"> <svg height="24" width="24" viewBox="0 0 16 16" fill="currentColor" style="color: black;"> <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path> </svg> "Sign Up with GitHub" - </a> + </a> }.into_view() } }} @@ -344,7 +348,7 @@ pub fn LandingPage() -> impl IntoView { </div> </section> - // FOOTER + // FOOTER <footer class="landing-footer"> <div class="footer-container"> <div class="footer-top"> @@ -369,4 +373,4 @@ pub fn LandingPage() -> impl IntoView { </div> </> } -} \ No newline at end of file +} diff --git a/client/src/pages/policy.rs b/client/src/pages/policy.rs index c7d1624..a92bb25 100644 --- a/client/src/pages/policy.rs +++ b/client/src/pages/policy.rs @@ -1,10 +1,10 @@ +use crate::api::api_base; +use crate::components::navbar::Navbar; +use crate::types::User; +use gloo_net::http::Request; use leptos::*; use leptos_router::A; -use gloo_net::http::Request; use web_sys::RequestCredentials; -use crate::components::navbar::Navbar; -use crate::api::api_base; -use crate::types::User; #[component] pub fn PolicyPage() -> impl IntoView { @@ -13,21 +13,24 @@ pub fn PolicyPage() -> impl IntoView { let (user, set_user) = create_signal(None::<User>); let (auth_checked, set_auth_checked) = create_signal(false); - create_resource(|| (), move |_| async move { - let url = format!("{}/api/me", api_base()); - if let Ok(resp) = Request::get(&url) - .credentials(RequestCredentials::Include) - .send() - .await - { - if resp.ok() { - if let Ok(u) = resp.json::<User>().await { - set_user.set(Some(u)); + create_resource( + || (), + move |_| async move { + let url = format!("{}/api/me", api_base()); + if let Ok(resp) = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await + { + if resp.ok() { + if let Ok(u) = resp.json::<User>().await { + set_user.set(Some(u)); + } } } - } - set_auth_checked.set(true); - }); + set_auth_checked.set(true); + }, + ); view! { <div class="docs-container"> @@ -37,8 +40,8 @@ pub fn PolicyPage() -> impl IntoView { if auth_checked.get() { if let Some(u) = user.get() { view! { - <img src=u.avatar_url - style="width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--border);" + <img src=u.avatar_url + style="width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--border);" alt="User Avatar" /> }.into_view() } else { @@ -120,7 +123,7 @@ pub fn PolicyPage() -> impl IntoView { <main class="docs-content"> <h1>"TryCLI Terms of Service"</h1> <p class="text-sm text-gray-500">"Last Updated: February 9, 2026"</p> - + <section id="introduction"> <h2>"1. Introduction and Acceptance of Terms"</h2> <p>"These Terms of Service (\"Terms\") constitute a binding legal agreement between you (\"Publisher,\" \"User,\" or \"You\") and TryCLI (\"Platform,\" \"We,\" or \"Us\"). By accessing, registering for, or using the TryCLI platform, including our browser-based sandbox environment, live-syncing Markdown editor, and related developer tools (collectively, the \"Services\"), you acknowledge that you have read, understood, and agree to be bound by these Terms."</p> @@ -129,7 +132,7 @@ pub fn PolicyPage() -> impl IntoView { <section id="nature-of-service"> <h2>"2. Nature of the Service: Passive Conduit"</h2> - + <h3>"2.1 Platform Status"</h3> <p>"You acknowledge and agree that TryCLI operates solely as a technological intermediary and hosting platform. We provide the infrastructure (containerized environments) for the execution of code and the display of documentation. We do not create, select, or modify the content, code, or applications (\"User Content\") uploaded or executed by Publishers."</p> @@ -160,7 +163,7 @@ pub fn PolicyPage() -> impl IntoView { <h2>"4. Proprietary Rights and License"</h2> <h3>"4.1 Your Content"</h3> <p>"You retain all ownership rights to the User Content you create, upload, or execute on TryCLI."</p> - + <h3>"4.2 License to Host"</h3> <p>"By submitting User Content to the Service, you grant TryCLI a worldwide, non-exclusive, royalty-free license to use, reproduce, modify, adapt, publish, and display such content solely for the purpose of providing the Services (e.g., running your code in a container, displaying your Markdown tutorial)."</p> </section> @@ -178,10 +181,10 @@ pub fn PolicyPage() -> impl IntoView { <section id="disclaimer"> <h2>"6. Disclaimer of Warranties"</h2> <p><strong>"THE SERVICES ARE PROVIDED ON AN \"AS IS\" AND \"AS AVAILABLE\" BASIS, WITHOUT ANY WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED."</strong></p> - + <h3>"6.1 No Warranty of Functionality"</h3> <p>"TryCLI does not warrant that the Services will be uninterrupted, secure, or error-free, or that any defects will be corrected. We do not guarantee that code which runs in our sandbox environment will function correctly in other environments or on local machines (\"Works on My Machine\" disclaimer)."</p> - + <h3>"6.2 Data Persistence"</h3> <p>"TryCLI is a sandbox environment designed for development and testing. We do not guarantee the permanent persistence of data, container states, or file systems. You are responsible for backing up your own data."</p> </section> @@ -204,4 +207,4 @@ pub fn PolicyPage() -> impl IntoView { </div> </div> } -} \ No newline at end of file +} diff --git a/client/src/pages/view.rs b/client/src/pages/view.rs index ada1163..04c3444 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -1,20 +1,29 @@ -use leptos::*; -use leptos_router::*; -use gloo_net::http::Request; -use web_sys::RequestCredentials; -use wasm_bindgen::prelude::*; -use std::rc::Rc; -use std::cell::RefCell; -use pulldown_cmark::{Parser, Options, html}; use crate::api::api_base; -use crate::components::terminal::TerminalView; use crate::components::limit::LimitReached; -use crate::components::navbar::Navbar; use crate::components::modal::EmbedModal; use crate::components::modal::Modal; +use crate::components::navbar::Navbar; +use crate::components::terminal::TerminalView; use crate::types::User; use crate::hooks::use_landscape_lock; -use serde::{Serialize, Deserialize}; +use gloo_net::http::Request; +use leptos::*; +use leptos_router::*; +use pulldown_cmark::{html, Options, Parser}; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::rc::Rc; +use wasm_bindgen::prelude::*; +use web_sys::RequestCredentials; + +#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] +enum ProjectState { + Loading, + NotFound, + LimitReached, + Unauthorized, // Security block state + Ready(serde_json::Value), +} pub fn render_markdown(text: &str) -> String { let mut options = Options::empty(); @@ -22,11 +31,10 @@ pub fn render_markdown(text: &str) -> String { options.insert(Options::ENABLE_TABLES); let parser = Parser::new_ext(text, options); let mut html_output = String::new(); - html::push_html(&mut html_output, parser); + html::push_html(&mut html_output, parser); html_output } -// Simple resize divider setup fn setup_resize_divider() { if let Some(divider) = web_sys::window() .and_then(|w| w.document()) @@ -34,21 +42,20 @@ fn setup_resize_divider() { .and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok()) { let is_dragging = Rc::new(RefCell::new(false)); - + let on_mousedown = { let is_dragging = is_dragging.clone(); wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::MouseEvent| { *is_dragging.borrow_mut() = true; }) as Box<dyn Fn(web_sys::MouseEvent)>) }; - + let on_mousemove = { let is_dragging = is_dragging.clone(); wasm_bindgen::closure::Closure::wrap(Box::new(move |e: web_sys::MouseEvent| { if !*is_dragging.borrow() { return; } - if let Some(workspace) = web_sys::window() .and_then(|w| w.document()) .and_then(|d| d.query_selector(".workspace").ok().flatten()) @@ -58,118 +65,231 @@ fn setup_resize_divider() { let workspace_left = workspace.offset_left() as f64; let relative_x = e.client_x() as f64 - workspace_left; let percentage = (relative_x / workspace_width * 100.0).max(20.0).min(80.0); - + if let Ok(panes) = workspace.query_selector_all(".pane") { if panes.length() >= 2 { - if let Some(first_pane) = panes.get(0).and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok()) { - first_pane.style().set_property("flex", "0 1 auto").ok(); - first_pane.style().set_property("width", &format!("{}%", percentage)).ok(); + if let Some(p1) = panes + .get(0) + .and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok()) + { + p1.style().set_property("flex", "0 1 auto").ok(); + p1.style() + .set_property("width", &format!("{}%", percentage)) + .ok(); } - if let Some(second_pane) = panes.get(1).and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok()) { - second_pane.style().set_property("flex", "0 1 auto").ok(); - second_pane.style().set_property("width", &format!("{}%", 100.0 - percentage)).ok(); + if let Some(p2) = panes + .get(1) + .and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok()) + { + p2.style().set_property("flex", "0 1 auto").ok(); + p2.style() + .set_property("width", &format!("{}%", 100.0 - percentage)) + .ok(); } } } } }) as Box<dyn Fn(web_sys::MouseEvent)>) }; - + let on_mouseup = { let is_dragging = is_dragging.clone(); wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::MouseEvent| { *is_dragging.borrow_mut() = false; }) as Box<dyn Fn(web_sys::MouseEvent)>) }; - - divider.add_event_listener_with_callback("mousedown", on_mousedown.as_ref().unchecked_ref()).ok(); + + divider + .add_event_listener_with_callback("mousedown", on_mousedown.as_ref().unchecked_ref()) + .ok(); on_mousedown.forget(); - + if let Some(document) = web_sys::window().and_then(|w| w.document()) { - document.add_event_listener_with_callback("mousemove", on_mousemove.as_ref().unchecked_ref()).ok(); - document.add_event_listener_with_callback("mouseup", on_mouseup.as_ref().unchecked_ref()).ok(); + document + .add_event_listener_with_callback( + "mousemove", + on_mousemove.as_ref().unchecked_ref(), + ) + .ok(); + document + .add_event_listener_with_callback("mouseup", on_mouseup.as_ref().unchecked_ref()) + .ok(); on_mousemove.forget(); on_mouseup.forget(); } } } -#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] -enum ProjectState { - Loading, - NotFound, - LimitReached, - Ready(serde_json::Value), -} - #[component] pub fn ViewPage() -> impl IntoView { let is_portrait = use_landscape_lock(); let params = use_params_map(); + let query_params = use_query_map(); let username = move || params.get().get("username").cloned().unwrap_or_default(); let slug = move || params.get().get("slug").cloned().unwrap_or_default(); + let (user, set_user) = create_signal(None::<User>); let (embed_modal_open, set_embed_modal_open) = create_signal(false); - - // Create two separate signals for the two fields let (iframe_code, set_iframe_code) = create_signal(String::new()); let (smart_link, set_smart_link) = create_signal(String::new()); - - // Auth Check - create_resource(|| (), move |_| async move { - let auth_req = Request::get(&format!("{}/api/me", api_base())) - .credentials(RequestCredentials::Include) - .send() - .await; + let (vip_link, set_vip_link) = create_signal(String::new()); + + let (whitelist, set_whitelist) = create_signal(Vec::<String>::new()); + + // Auth Resource + let auth_resource = create_resource( + || (), + move |_| async move { + let req = Request::get(&format!("{}/api/me", api_base())) + .credentials(RequestCredentials::Include) + .send() + .await; - if let Ok(resp) = auth_req { - if resp.ok() { - if let Ok(u) = resp.json::<User>().await { - set_user.set(Some(u)); - } + if let Ok(resp) = req { + if resp.ok() { + if let Ok(u) = resp.json::<User>().await { + set_user.set(Some(u)); + } + } } - } - }); + }, + ); + // Project Data Resource (with VIP key and Referer security) let project_resource = create_resource( - move || (username(), slug(), user.with(|u| u.as_ref().map(|x| x.id))), - |(u, s, _)| async move { - let url = format!("{}/api/project/{}/{}", api_base(), u, s); - let req = Request::get(&url).credentials(RequestCredentials::Include).send().await; - + move || (username(), slug(), auth_resource.get()), + move |(u, s, _)| async move { + let key = query_params + .get_untracked() + .get("key") + .cloned() + .unwrap_or_default(); + let url = if key.is_empty() { + format!("{}/api/project/{}/{}", api_base(), u, s) + } else { + format!("{}/api/project/{}/{}?key={}", api_base(), u, s, key) + }; + + let req = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await; + match req { Ok(resp) => { - if resp.status() == 429 { + if resp.status() == 403 { + ProjectState::Unauthorized + } else if resp.status() == 429 { ProjectState::LimitReached } else if resp.ok() { - if let Ok(json) = resp.json::<serde_json::Value>().await { - ProjectState::Ready(json) - } else { - ProjectState::NotFound - } + resp.json::<serde_json::Value>() + .await + .map(ProjectState::Ready) + .unwrap_or(ProjectState::NotFound) } else { ProjectState::NotFound } - }, - Err(_) => ProjectState::NotFound + } + Err(_) => ProjectState::NotFound, } - } + }, + ); + + // Whitelist Resource for Owners + let whitelist_resource = create_resource( + move || (project_resource.get(), user.get(), slug()), + move |(state, current_user, s)| async move { + if let Some(ProjectState::Ready(p)) = state { + // Only fetch whitelist if the current user is the owner + let project_owner_id = p.get("owner_id").and_then(|id| id.as_i64()); + let is_owner = match current_user { + Some(u) => Some(u.id) == project_owner_id, + None => false + }; + + if is_owner { + let url = format!("{}/api/project/{}/whitelist", api_base(), s); + if let Ok(resp) = Request::get(&url).credentials(RequestCredentials::Include).send().await { + if let Ok(list) = resp.json::<Vec<String>>().await { + set_whitelist.set(list); + } + } + } + } + }, ); - // Ownership Logic let is_owner = move || { let current_user = user.get(); if let Some(ProjectState::Ready(p)) = project_resource.get() { - let project_owner_id = p.get("owner_id").and_then(|id| id.as_i64()); - match current_user { - Some(u) => Some(u.id) == project_owner_id, - None => false - } + let project_owner_id = p.get("owner_id").and_then(|id| id.as_i64()); + match current_user { + Some(u) => Some(u.id) == project_owner_id, + None => false, + } } else { false } }; + let add_whitelist_item = create_action(move |url: &String| { + let url = url.clone(); + let s = slug(); + async move { + let req = Request::post(&format!("{}/api/project/{}/whitelist", api_base(), s)) + .credentials(RequestCredentials::Include) + .json(&serde_json::json!({ "allowed_url": url })); + + if let Ok(builder) = req { + match builder.send().await { + Ok(resp) => { + if resp.ok() { + whitelist_resource.refetch(); + } else { + web_sys::console::error_1(&JsValue::from_str(&format!( + "Failed to add URL to whitelist: HTTP {}", resp.status() + ))); + } + } + Err(e) => { + web_sys::console::error_1(&JsValue::from_str(&format!( + "Failed to add URL to whitelist: {:?}", e + ))); + } + } + } + } + }); + + let remove_whitelist_item = create_action(move |url: &String| { + let url = url.clone(); + let s = slug(); + async move { + let req = Request::delete(&format!("{}/api/project/{}/whitelist", api_base(), s)) + .credentials(RequestCredentials::Include) + .json(&serde_json::json!({ "allowed_url": url })); + + if let Ok(builder) = req { + match builder.send().await { + Ok(resp) => { + if resp.ok() { + whitelist_resource.refetch(); + } else { + web_sys::console::error_1(&JsValue::from_str(&format!( + "Failed to remove URL from whitelist: HTTP {}", resp.status() + ))); + } + } + Err(e) => { + web_sys::console::error_1(&JsValue::from_str(&format!( + "Failed to remove URL from whitelist: {:?}", e + ))); + } + } + } + } + }); + view! { <> <Modal @@ -179,126 +299,147 @@ pub fn ViewPage() -> impl IntoView { button_label="".to_string().into() on_close=Callback::new(|_| {}) /> - <EmbedModal - show=embed_modal_open.into() - title="Share Project".to_string().into() - iframe_code=iframe_code.into() + <EmbedModal + show=embed_modal_open.into() + title="Share Project".to_string().into() + iframe_code=iframe_code.into() smart_link=smart_link.into() - on_close=Callback::new(move |_| set_embed_modal_open.set(false)) + vip_link=vip_link.into() + whitelist=whitelist.into() + on_add_url=Callback::new(move |url: String| add_whitelist_item.dispatch(url)) + on_remove_url=Callback::new(move |url: String| remove_whitelist_item.dispatch(url)) + on_close=Callback::new(move |_| set_embed_modal_open.set(false)) /> - + <Navbar> <div class="controls"> - {move || { - if is_owner() { - match user.get() { - Some(u) => view! { - <div style="display: flex; align-items: center; gap: 16px;"> - <div style="display: flex; align-items: center; gap: 8px;"> - <img src=u.avatar_url style="width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--border);" /> - <span style="color: var(--text-main); font-weight: 500;">{u.login.clone()}</span> - </div> + {move || if is_owner() { + user.get().map(|u| view! { + <div style="display: flex; align-items: center; gap: 16px;"> + <div style="display: flex; align-items: center; gap: 8px;"> + <img src=u.avatar_url style="width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--border);" /> + <span style="color: var(--text-main); font-weight: 500;">{u.login}</span> + </div> + <button class="btn-secondary btn-action btn-success" on:click=move |_| { + let origin = window().location().origin().unwrap_or_else(|_| "http://localhost:8080".to_string()); + if let Some(ProjectState::Ready(data)) = project_resource.get() { + let token = data.get("embed_token").and_then(|v| v.as_str()).unwrap_or_default(); - <button class="btn-secondary btn-action btn-success" on:click=move |_| { - // FIX: Defined origin at top scope of closure - let frontend_origin = window().location().origin().unwrap_or("http://localhost:8080".to_string()); - let backend_origin = api_base(); - - let current_state = project_resource.get(); - - let token = if let Some(ProjectState::Ready(data)) = current_state { - data.get("embed_token") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - } else { - None - }; + // Public embed uses whitelist + domain check only + let public_url = format!("{}/embed/{}/{}", origin, username(), slug()); + let smart_url = format!("{}/e/{}", api_base(), token); - // 1. PUBLIC URL (Always safe, no token) - // Hits Frontend directly. Interactive IF you are logged in. - let public_url = format!("{}/embed/{}/{}", frontend_origin, username(), slug()); - - // 2. SECRET LINK (Only for Medium/Reddit) - // Hits Backend -> Redirects to Frontend with oEmbed magic. - let smart_url = match token { - Some(t) => format!("{}/e/{}", backend_origin, t), - None => public_url.clone() // Fallback (shouldn't happen for owner) - }; - - set_iframe_code.set(format!( - "<iframe src=\"{}\" width=\"100%\" height=\"500px\" frameborder=\"0\" allowtransparency=\"true\" loading=\"lazy\" allow=\"clipboard-read; clipboard-write\"></iframe>", - public_url // <--- ALWAYS PUBLIC URL HERE - )); - - set_smart_link.set(smart_url); - - set_embed_modal_open.set(true); - }> - "Share / Embed" - </button> + set_iframe_code.set(format!( + "<iframe src=\"{}\" width=\"100%\" height=\"500px\" frameborder=\"0\" allowtransparency=\"true\" loading=\"lazy\" allow=\"clipboard-read; clipboard-write\"></iframe>", + public_url + )); + set_smart_link.set(smart_url); - <a href=format!("{}/auth/logout", api_base()) class="btn-secondary btn-action btn-logout" rel="external" style="text-decoration: none; font-size: 0.9rem;">"Logout"</a> - </div> - }.into_view(), - None => view! { <></> }.into_view() - } - } else { - view! { <></> }.into_view() - } - }} + // Fetch embed_key from dedicated endpoint to avoid exposure in main response + let origin_clone = origin.clone(); + let slug_clone = slug(); + let username_clone = username(); + spawn_local(async move { + let url = format!("{}/api/project/{}/embed-key", api_base(), slug_clone); + match Request::get(&url).credentials(RequestCredentials::Include).send().await { + Ok(resp) => { + match resp.json::<serde_json::Value>().await { + Ok(data) => { + let key = data.get("embed_key").and_then(|v| v.as_str()).unwrap_or_default(); + let vip = if key.is_empty() { + String::new() + } else { + format!("{}/{}/{}?key={}", origin_clone, username_clone, slug_clone, key) + }; + set_vip_link.set(vip); + } + Err(e) => { + web_sys::console::error_1(&JsValue::from_str(&format!( + "Failed to parse embed_key response: {}", e + ))); + } + } + } + Err(e) => { + web_sys::console::error_1(&JsValue::from_str(&format!( + "Failed to fetch embed_key from server: {}", e + ))); + } + } + }); + + set_embed_modal_open.set(true); + } + }> + "Share / Embed" + </button> + <a href=format!("{}/auth/logout", api_base()) class="btn-secondary btn-action btn-logout" rel="external" style="text-decoration: none; font-size: 0.9rem;">"Logout"</a> + </div> + }) + } else { None }} </div> </Navbar> - // MAIN CONTENT SWITCH {move || match project_resource.get() { Some(ProjectState::Ready(data)) => { let cid = data["container_id"].as_str().unwrap_or_default().to_string(); let md_raw = data["markdown"].as_str().unwrap_or_default().to_string(); let html_output = render_markdown(&md_raw); - let mounted = create_signal(false); + let (is_mounted, set_mounted) = create_signal(false); + create_effect(move |_| { - if !mounted.0.get() { + if !is_mounted.get() { + set_mounted.set(true); if let Some(window) = web_sys::window() { let callback = wasm_bindgen::closure::Closure::once(move || { setup_resize_divider(); - mounted.1.set(true); }); window.request_animation_frame(callback.as_ref().unchecked_ref()).ok(); callback.forget(); } } }); - + view! { - <div class="workspace"> - <div class="pane" style="width: 50%; background: var(--bg-dark); overflow-y: auto;"> - <div class="markdown-body" inner_html=html_output /> - </div> - <div class="resize-divider"></div> - <div class="pane" style="width: 50%;"> - <div class="terminal-header"> - <div class="dot red"></div> - <div class="dot yellow"></div> - <div class="dot green"></div> - <span class="terminal-title">"Live Demo"</span> + <div style="display: flex; flex-direction: column; height: calc(100vh - 60px);"> + <div class="workspace" style="flex: 1;"> + <div class="pane" style="width: 50%; background: var(--bg-dark); overflow-y: auto;"> + <div class="markdown-body" inner_html=html_output /> </div> - <div class="terminal-body"> - <TerminalView container_id=cid /> + <div class="resize-divider"></div> + <div class="pane" style="width: 50%;"> + <div class="terminal-header"> + <div class="dot red"></div> + <div class="dot yellow"></div> + <div class="dot green"></div> + <span class="terminal-title">"Live Demo"</span> + </div> + <div class="terminal-body"> + <TerminalView container_id=cid /> + </div> </div> </div> </div> }.into_view() }, + Some(ProjectState::Unauthorized) => view! { + <div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:80vh; text-align:center; padding:40px;"> + <h2 style="color: #ef4444; font-size: 2rem;">"403: Access Denied"</h2> + <p style="color: var(--text-muted); margin-top: 1rem; max-width: 400px;"> + "This terminal is restricted to authorized websites. Contact the owner to whitelist this domain." + </p> + </div> + }.into_view(), Some(ProjectState::LimitReached) => view! { <LimitReached /> }.into_view(), - Some(ProjectState::NotFound) => view! { - <div style="color: var(--text-muted); text-align: center; margin-top: 50px;">"Project not found."</div> + Some(ProjectState::NotFound) => view! { + <div style="color: var(--text-muted); text-align: center; margin-top: 100px;">"Project not found."</div> }.into_view(), - Some(ProjectState::Loading) | None => view! { + _ => view! { <div style="padding: 50px; text-align: center;"> <div class="spinner" style="margin: 0 auto;"></div> - <p style="margin-top: 1rem; color: var(--text-muted);">"Loading Environment..."</p> - </div> + <p style="margin-top: 1rem; color: var(--text-muted);">"PREPARING ENVIRONMENT..."</p> + </div> }.into_view() }} </> diff --git a/client/src/types.rs b/client/src/types.rs index 903d5c9..dc8325c 100644 --- a/client/src/types.rs +++ b/client/src/types.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct ProjectSummary { pub slug: String, pub image_tag: String, @@ -49,9 +49,9 @@ pub struct AnalyticsDashboardData { pub active_sessions: Vec<LiveSessionMetric>, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct User { pub id: i64, pub login: String, pub avatar_url: String, -} \ No newline at end of file +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 197c7c0..26ee5d5 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -24,3 +24,5 @@ time = "0.3.46" openssl = { version = "0.10", features = ["vendored"] } openssl-sys = { version = "0.9", features = ["vendored"] } url = "2.5.8" +dashmap = "6.1" +governor = "0.10" diff --git a/server/migrations/20260210000000_secure_embed_schema.up.sql b/server/migrations/20260210000000_secure_embed_schema.up.sql new file mode 100644 index 0000000..6a2506a --- /dev/null +++ b/server/migrations/20260210000000_secure_embed_schema.up.sql @@ -0,0 +1,21 @@ +-- 1. Add the secret VIP Pass key to projects +ALTER TABLE projects +ADD COLUMN IF NOT EXISTS embed_key TEXT UNIQUE; + +-- 2. Backfill existing projects with a unique secure key +-- Using pgcrypto which you already enabled in a previous migration +UPDATE projects +SET embed_key = encode(gen_random_bytes(24), 'base64') +WHERE embed_key IS NULL; + +-- 3. Create the Whitelist (Guest List) table +CREATE TABLE IF NOT EXISTS project_whitelists ( + id SERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + allowed_url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(project_id, allowed_url) +); + +-- Index for high-performance Referer lookups +CREATE INDEX IF NOT EXISTS idx_whitelist_lookup ON project_whitelists(project_id, allowed_url); \ No newline at end of file diff --git a/server/src/config.rs b/server/src/config.rs index d2bcdd3..f23f552 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -3,6 +3,7 @@ use sqlx::postgres::PgPoolOptions; use std::sync::{Arc, Mutex}; use std::collections::HashMap; use crate::state::AppState; +use dashmap::DashMap; pub async fn setup_database_and_docker() -> Result<AppState, Box<dyn std::error::Error>> { // 1. Docker setup @@ -22,6 +23,7 @@ pub async fn setup_database_and_docker() -> Result<AppState, Box<dyn std::error: github_id: std::env::var("GITHUB_CLIENT_ID").expect("Missing GITHUB_CLIENT_ID"), github_secret: std::env::var("GITHUB_CLIENT_SECRET").expect("Missing GITHUB_CLIENT_SECRET"), sessions: Arc::new(Mutex::new(HashMap::new())), + whitelist_rate_limiters: Arc::new(DashMap::new()), }; Ok(state) diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index c3e7576..1c2cd6f 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -1,10 +1,10 @@ use axum::{ - extract::{Path, Query, State}, - routing::{get, post, delete}, - Router, Json, - http::StatusCode, body::Bytes, + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, response::Html, + routing::{delete, get, post}, + Json, Router, }; use bollard::container::{CreateContainerOptions, Config}; use bollard::models::{HostConfig, Mount, MountTypeEnum, MountTmpfsOptions}; @@ -15,19 +15,147 @@ use uuid::Uuid; use serde::Deserialize; use futures::StreamExt; use crate::state::{AppState, SessionContext}; -use crate::models::{User, ProjectSummary, PublishRequest}; +use crate::models::{User, ProjectSummary, PublishRequest, WhitelistRequest}; use std::collections::HashMap; +use url::Url; + +// Maximum number of whitelist entries allowed per project +const MAX_WHITELIST_ENTRIES: i64 = 100; + +// Rate limit for whitelist operations (requests per minute per user) +pub const WHITELIST_RATE_LIMIT_PER_MINUTE: u32 = 20; #[derive(Deserialize)] pub struct SearchQuery { q: String, } +/// Normalizes a URL by extracting scheme + host + path (without query params or fragments). +/// +/// This prevents bypass attempts using query parameters or fragments. For example: +/// - `https://example.com/path?bypass=1` -> `https://example.com/path` +/// - `https://example.com/path#fragment` -> `https://example.com/path` +/// - `https://example.com/path/` -> `https://example.com/path` +/// +/// # Security +/// Only http and https URLs with valid hosts are accepted. URLs without hosts or +/// with other schemes are rejected to prevent security issues. +/// +/// # Returns +/// - `Some(normalized_url)` if the URL is valid and has http/https scheme with a host +/// - `None` if the URL cannot be parsed, lacks a host, or uses a non-http(s) scheme +fn normalize_url(url_str: &str) -> Option<String> { + let url = Url::parse(url_str).ok()?; + + // Only accept http and https schemes for security + let scheme = url.scheme(); + if scheme != "http" && scheme != "https" { + return None; + } + + // Require a valid host for http(s) URLs + let host = url.host_str()?; + let path = url.path(); + + // Normalize trailing slashes for consistency + let normalized_path = if path == "/" || path.is_empty() { + "/" + } else { + path.trim_end_matches('/') + }; + + Some(format!("{}://{}{}", scheme, host, normalized_path)) +} + +/// Validates CSRF protection by checking Origin/Referer headers against expected frontend URL. +/// +/// This function provides defense-in-depth against CSRF attacks by: +/// 1. Checking the Origin header (sent by browsers on cross-origin requests) +/// 2. Falling back to Referer header validation if Origin is not present +/// 3. Requiring the request to come from the configured FRONTEND_URL +/// +/// # Security +/// This works in conjunction with SameSite=Strict cookies to prevent CSRF attacks. +/// An attacker on a different domain cannot forge these headers in a way that would +/// pass validation. +/// +/// # Returns +/// - `Ok(())` if the request passes CSRF validation +/// - `Err((StatusCode, String))` if the request fails validation +fn validate_csrf_protection(headers: &HeaderMap) -> Result<(), (StatusCode, String)> { + let frontend_url = std::env::var("FRONTEND_URL") + .unwrap_or_else(|_| "http://localhost:8080".to_string()); + + // Parse the expected origin from FRONTEND_URL + let expected_origin = Url::parse(&frontend_url) + .ok() + .and_then(|u| { + let scheme = u.scheme(); + let host = u.host_str()?; + let port = u.port(); + if let Some(p) = port { + Some(format!("{}://{}:{}", scheme, host, p)) + } else { + Some(format!("{}://{}", scheme, host)) + } + }) + .ok_or_else(|| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid FRONTEND_URL configuration".to_string(), + ) + })?; + + // Check Origin header first (preferred for CORS requests) + if let Some(origin) = headers.get("origin") { + let origin_str = origin + .to_str() + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid Origin header".to_string()))?; + + if origin_str == expected_origin { + return Ok(()); + } else { + return Err(( + StatusCode::FORBIDDEN, + "CSRF validation failed: Origin mismatch".to_string(), + )); + } + } + + // Fallback to Referer header + if let Some(referer) = headers.get("referer") { + let referer_str = referer + .to_str() + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid Referer header".to_string()))?; + + // Check if referer starts with the expected origin + if referer_str.starts_with(&expected_origin) { + return Ok(()); + } else { + return Err(( + StatusCode::FORBIDDEN, + "CSRF validation failed: Referer mismatch".to_string(), + )); + } + } + + // No Origin or Referer header found + Err(( + StatusCode::FORBIDDEN, + "CSRF validation failed: Missing Origin or Referer header".to_string(), + )) +} + pub fn routes() -> Router<AppState> { Router::new() .route("/api/my-projects", get(list_user_projects)) .route("/api/project/:username/:slug", get(get_project)) .route("/api/project/:slug", delete(delete_project)) + .route("/api/project/:slug/embed-key", get(get_embed_key)) + .route( + "/api/project/:slug/whitelist", + get(get_whitelist).post(add_to_whitelist).delete(remove_from_whitelist), + ) .route("/api/search-projects", get(search_projects)) .route("/api/publish", post(publish_handler)) .route("/e/:token", get(resolve_secret_embed)) // Secret Embed Route @@ -44,7 +172,8 @@ pub async fn list_user_projects( let user = user.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; let projects = sqlx::query_as::<_, ProjectSummary>( - "SELECT slug, image_tag, view_count, owner_username FROM projects WHERE owner_id = $1 ORDER BY slug ASC" + "SELECT slug, image_tag, view_count, owner_username, embed_key \ + FROM projects WHERE owner_id = $1 ORDER BY slug ASC" ) .bind(user.id) .fetch_all(&state.db) @@ -71,7 +200,8 @@ pub async fn search_projects( let query_term = format!("%{}%", search.q); let projects = sqlx::query_as::<_, ProjectSummary>( - "SELECT slug, image_tag, view_count, owner_username FROM projects WHERE owner_id = $1 AND slug ILIKE $2 ORDER BY slug ASC LIMIT 10" + "SELECT slug, image_tag, view_count, owner_username, embed_key \ + FROM projects WHERE owner_id = $1 AND slug ILIKE $2 ORDER BY slug ASC LIMIT 10" ) .bind(user.id) .bind(query_term) @@ -177,9 +307,10 @@ pub async fn publish_handler( // 6. Update Database with new embed_token logic // gen_random_uuid() requires Postgres 13+. If older, ensure pgcrypto extension is enabled. + // Generate embed_key at creation time to ensure VIP links work immediately sqlx::query(" - INSERT INTO projects (slug, image_tag, markdown, owner_id, owner_username, shell, embed_token) - VALUES ($1, $2, $3, $4, $5, $6, gen_random_uuid()::text) + INSERT INTO projects (slug, image_tag, markdown, owner_id, owner_username, shell, embed_token, embed_key) + VALUES ($1, $2, $3, $4, $5, $6, gen_random_uuid()::text, encode(gen_random_bytes(24), 'base64')) ON CONFLICT (owner_username, slug) DO UPDATE SET image_tag = $2, markdown = $3, shell = $6 ") @@ -209,24 +340,127 @@ pub async fn publish_handler( pub async fn get_project( Path((username, slug)): Path<(String, String)>, State(state): State<AppState>, - session: Session, // Added session to check ownership + session: Session, // Session is used to detect owner (bypass checks), lazily create embed_key for owners, and return embed_token/embed_key to them; non-owner secure embeds use VIP key + Referer whitelist + Query(params): Query<HashMap<String, String>>, + headers: HeaderMap, ) -> Result<Json<serde_json::Value>, (StatusCode, String)> { - // Case-insensitive lookup (Fixes 404s due to capitalization) - NOW INCLUDES id - let row_result = sqlx::query_as::<_, (i64, String, String, String, i64)>( - "SELECT id, image_tag, markdown, shell, owner_id FROM projects WHERE LOWER(owner_username) = LOWER($1) AND LOWER(slug) = LOWER($2)" + // 1. Load project + security metadata (case-insensitive for username/slug) + // Query returns: (image_tag, markdown, shell, owner_id, embed_key, id) + let row_result = sqlx::query_as::<_, (String, String, String, i64, Option<String>, i64)>( + "SELECT image_tag, markdown, shell, owner_id, embed_key, id \ + FROM projects \ + WHERE LOWER(owner_username) = LOWER($1) AND LOWER(slug) = LOWER($2)" ) .bind(&username).bind(&slug) .fetch_optional(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Read Error: {}", e)))?; - let (_project_id, image_tag, markdown, shell, owner_id) = match row_result { + let (image_tag, markdown, shell, owner_id, mut embed_key, project_id) = match row_result { Some(r) => r, None => return Err((StatusCode::NOT_FOUND, "Project not found".to_string())), }; - // 2. Check active viewer limits (view counting moved to WebSocket connection) + // 2. Determine current user & ownership (owners bypass embed security) + let current_user: Option<User> = session.get("user").await.ok().flatten(); + let is_owner = current_user.as_ref().map(|u| u.id) == Some(owner_id); + + // 3. Ensure owners always have a VIP key (embed_key); generate one lazily if missing + if is_owner && embed_key.is_none() { + let new_key_row: Option<(String,)> = sqlx::query_as( + "UPDATE projects \ + SET embed_key = encode(gen_random_bytes(24), 'base64') \ + WHERE id = $1 \ + RETURNING embed_key", + ) + .bind(project_id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to generate VIP key: {}", e), + ) + })?; + + if let Some((k,)) = new_key_row { + embed_key = Some(k); + } + } + + // 4. Dual-layer security for embeds (VIP key + Guest List) + if !is_owner { + // VIP Pass: compare URL ?key with stored embed_key using a match on Options + let vip_allowed = match (params.get("key"), embed_key.as_ref()) { + (Some(request_key), Some(stored_key)) if request_key == stored_key => true, + _ => false, + }; + + if !vip_allowed { + // Guest List: validate Referer header against project_whitelists + let referer = headers + .get("Referer") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + // Optional boolean, safely unwrapped later + let whitelist_allowed: Option<bool> = if let Some(referer_url) = referer { + // Normalize the Referer URL to prevent bypasses via query params or fragments + let normalized_referer = normalize_url(&referer_url); + + if let Some(normalized) = normalized_referer { + let exists_row: Option<(bool,)> = sqlx::query_as( + "SELECT TRUE FROM project_whitelists \ + WHERE project_id = $1 AND allowed_url = $2 \ + LIMIT 1", + ) + .bind(project_id) + .bind(&normalized) + .fetch_optional(&state.db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Whitelist DB Error: {}", e), + ) + })?; + + // TRUE row exists => allowed; otherwise false + Some(exists_row.is_some()) + } else { + // If URL parsing fails, deny access and log for security monitoring + tracing::warn!("Referer normalization failed for project {}: {}", project_id, referer_url); + Some(false) + } + } else { + None + }; + + let is_allowed = whitelist_allowed.unwrap_or(false); + + if !is_allowed { + return Err(( + StatusCode::FORBIDDEN, + "This terminal is restricted to authorized websites.".to_string(), + )); + } + } + } + + // 5. Increment View Count asynchronously (only after passing security) + let db_clone = state.db.clone(); + let slug_clone = slug.clone(); + let username_clone = username.clone(); + tokio::spawn(async move { + let _ = sqlx::query("UPDATE projects SET view_count = view_count + 1 WHERE LOWER(owner_username) = LOWER($1) AND LOWER(slug) = LOWER($2)") + .bind(username_clone) + .bind(slug_clone) + .execute(&db_clone) + .await; + }); + + // 6. Publisher concurrency limit (protect compute) { let sessions = state.lock_sessions(); let active_viewers = sessions.values() @@ -238,18 +472,16 @@ pub async fn get_project( } } - // 3. [NEW] Construct Response - // We check if the current user is the owner. If so, we attach the `embed_token`. - let current_user: Option<User> = session.get("user").await.ok().flatten(); - let is_owner = current_user.map(|u| u.id) == Some(owner_id); - + // 7. Construct JSON response let mut response_json = serde_json::json!({ "markdown": markdown, "owner_id": owner_id, // We will insert container_id below }); - // If owner, fetch and attach the secret token + // If owner, fetch and attach the secret token for the Share / Embed modal + // Note: embed_key is now fetched separately via /api/project/:slug/embed-key endpoint + // to prevent accidental exposure in browser dev tools if is_owner { let token_record: Option<(String,)> = sqlx::query_as( "SELECT embed_token FROM projects WHERE owner_id = $1 AND LOWER(slug) = LOWER($2)" @@ -354,6 +586,300 @@ pub async fn get_project( Ok(Json(response_json)) } +/// Get the current whitelist (Guest List) for a project owned by the authenticated user. +pub async fn get_whitelist( + State(state): State<AppState>, + session: Session, + Path(slug): Path<String>, +) -> Result<Json<Vec<String>>, (StatusCode, String)> { + let user: Option<User> = session + .get("user") + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Session Error: {}", e)))?; + let user = user.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; + + // Apply rate limiting: 20 requests per minute per user + let rate_limiter = state.get_or_create_whitelist_rate_limiter(user.id); + if rate_limiter.check().is_err() { + return Err(( + StatusCode::TOO_MANY_REQUESTS, + "Rate limit exceeded. Please try again later.".to_string() + )); + } + + // Resolve project_id for this owner + slug + let project_row: Option<(i64,)> = sqlx::query_as( + "SELECT id FROM projects WHERE owner_id = $1 AND LOWER(slug) = LOWER($2)", + ) + .bind(user.id) + .bind(&slug) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; + + let (project_id,) = match project_row { + Some(row) => row, + None => return Err((StatusCode::NOT_FOUND, "Project not found".to_string())), + }; + + let rows: Vec<(String,)> = sqlx::query_as( + "SELECT allowed_url FROM project_whitelists WHERE project_id = $1 ORDER BY created_at DESC", + ) + .bind(project_id) + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; + + let urls = rows.into_iter().map(|(url,)| url).collect(); + + Ok(Json(urls)) +} + +/// Add a new URL to a project's whitelist (Guest List). +pub async fn add_to_whitelist( + State(state): State<AppState>, + headers: HeaderMap, + session: Session, + Path(slug): Path<String>, + Json(payload): Json<WhitelistRequest>, +) -> Result<StatusCode, (StatusCode, String)> { + // CSRF Protection: Validate Origin/Referer headers + validate_csrf_protection(&headers)?; + + let user: Option<User> = session + .get("user") + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Session Error: {}", e)))?; + let user = user.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; + + // Apply rate limiting: 20 requests per minute per user + let rate_limiter = state.get_or_create_whitelist_rate_limiter(user.id); + if rate_limiter.check().is_err() { + return Err(( + StatusCode::TOO_MANY_REQUESTS, + "Rate limit exceeded. Please try again later.".to_string() + )); + } + + let trimmed_url = payload.allowed_url.trim(); + if trimmed_url.is_empty() { + return Err((StatusCode::BAD_REQUEST, "allowed_url is required".to_string())); + } + + let project_row: Option<(i64,)> = sqlx::query_as( + "SELECT id FROM projects WHERE owner_id = $1 AND LOWER(slug) = LOWER($2)", + ) + .bind(user.id) + .bind(&slug) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; + + let (project_id,) = match project_row { + Some(row) => row, + None => return Err((StatusCode::NOT_FOUND, "Project not found".to_string())), + }; + + // Normalize the URL to prevent bypasses via query params or fragments + let normalized_url = normalize_url(trimmed_url).ok_or(( + StatusCode::BAD_REQUEST, + "Invalid URL format. URL must use http or https scheme and include a valid host.".to_string(), + ))?; + + // Use a database transaction with table-level advisory lock to prevent race conditions + let mut tx = state.db.begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Transaction Error: {}", e)))?; + + // Get an advisory lock for this project's whitelist to prevent concurrent modifications + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(project_id) + .execute(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Lock Error: {}", e)))?; + + // Check if entry already exists (using normalized URL) + let exists: (bool,) = sqlx::query_as( + "SELECT EXISTS(SELECT 1 FROM project_whitelists WHERE project_id = $1 AND allowed_url = $2)", + ) + .bind(project_id) + .bind(&normalized_url) + .fetch_one(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; + + if exists.0 { + // Entry already exists, commit transaction and return success + tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Commit Error: {}", e)))?; + return Ok(StatusCode::OK); // 200 OK - idempotent operation, entry already exists + } + + // Check current count + let count_row: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM project_whitelists WHERE project_id = $1", + ) + .bind(project_id) + .fetch_one(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; + + if count_row.0 >= MAX_WHITELIST_ENTRIES { + // Transaction will automatically rollback when dropped + return Err(( + StatusCode::FORBIDDEN, + format!("Maximum whitelist entries ({}) reached for this project", MAX_WHITELIST_ENTRIES) + )); + } + + // Insert the new entry (normalized) + sqlx::query( + "INSERT INTO project_whitelists (project_id, allowed_url) VALUES ($1, $2)", + ) + .bind(project_id) + .bind(normalized_url) + .execute(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert Error: {}", e)))?; + + // Commit transaction + tx.commit() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Commit Error: {}", e)))?; + + Ok(StatusCode::CREATED) +} + +/// Remove a URL from a project's whitelist (Guest List). +pub async fn remove_from_whitelist( + State(state): State<AppState>, + headers: HeaderMap, + session: Session, + Path(slug): Path<String>, + Json(payload): Json<WhitelistRequest>, +) -> Result<StatusCode, (StatusCode, String)> { + // CSRF Protection: Validate Origin/Referer headers + validate_csrf_protection(&headers)?; + + let user: Option<User> = session + .get("user") + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Session Error: {}", e)))?; + let user = user.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; + + // Apply rate limiting: 20 requests per minute per user + let rate_limiter = state.get_or_create_whitelist_rate_limiter(user.id); + if rate_limiter.check().is_err() { + return Err(( + StatusCode::TOO_MANY_REQUESTS, + "Rate limit exceeded. Please try again later.".to_string() + )); + } + + let trimmed_url = payload.allowed_url.trim(); + if trimmed_url.is_empty() { + return Err((StatusCode::BAD_REQUEST, "allowed_url is required".to_string())); + } + + // Normalize the URL to match how it was stored + let normalized_url = normalize_url(trimmed_url).ok_or(( + StatusCode::BAD_REQUEST, + "Invalid URL format. URL must use http or https scheme and include a valid host.".to_string(), + ))?; + + let delete_result = sqlx::query( + "DELETE FROM project_whitelists pw \ + USING projects p \ + WHERE pw.project_id = p.id \ + AND p.owner_id = $1 \ + AND LOWER(p.slug) = LOWER($2) \ + AND pw.allowed_url = $3", + ) + .bind(user.id) + .bind(&slug) + .bind(&normalized_url) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; + + if delete_result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Whitelist entry not found".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +/// Get the embed_key for a project (owner-only endpoint). +/// +/// This endpoint requires authentication and only returns the embed_key to the project owner. +/// Separating this from the main project response prevents accidental exposure via browser +/// dev tools or network inspection when viewing the project normally. +pub async fn get_embed_key( + State(state): State<AppState>, + session: Session, + Path(slug): Path<String>, +) -> Result<Json<serde_json::Value>, (StatusCode, String)> { + // 1. Verify user is authenticated + let user: Option<User> = session + .get("user") + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Session Error: {}", e)))?; + let user = user.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; + + // 2. Fetch project and verify ownership + let row_result = sqlx::query_as::<_, (i64, i64, Option<String>)>( + "SELECT id, owner_id, embed_key \ + FROM projects \ + WHERE LOWER(slug) = LOWER($1)" + ) + .bind(&slug) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Read Error: {}", e)))?; + + let (project_id, owner_id, mut embed_key) = match row_result { + Some(r) => r, + None => return Err((StatusCode::NOT_FOUND, "Project not found".to_string())), + }; + + // 3. Verify the user is the owner (return NOT_FOUND to avoid leaking project existence) + if user.id != owner_id { + return Err((StatusCode::NOT_FOUND, "Project not found".to_string())); + } + + // 4. Generate embed_key if it doesn't exist (lazy generation) + if embed_key.is_none() { + let new_key_row: Option<(String,)> = sqlx::query_as( + "UPDATE projects \ + SET embed_key = encode(gen_random_bytes(24), 'base64') \ + WHERE id = $1 \ + RETURNING embed_key", + ) + .bind(project_id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to generate embed key: {}", e), + ) + })?; + + embed_key = new_key_row.map(|(k,)| k); + } + + // 5. Return the embed_key (should always be present after step 4) + let key = embed_key.ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to retrieve or generate embed key".to_string(), + ))?; + + let response = serde_json::json!({ + "embed_key": key + }); + + Ok(Json(response)) +} + pub async fn delete_project( State(state): State<AppState>, session: Session, @@ -450,4 +976,5 @@ pub async fn resolve_secret_embed( </html>"#, oembed_url, target_url, target_url); Ok(Html(html)) -} \ No newline at end of file +} + diff --git a/server/src/models.rs b/server/src/models.rs index 2b1b9e5..effc747 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -9,6 +9,7 @@ pub struct ProjectSummary { pub view_count: i64, #[serde(default)] pub owner_username: String, + pub embed_key: Option<String>, } #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] @@ -20,6 +21,11 @@ pub struct AnalyticsProjectSummary { pub error_count: i64, } +#[derive(Deserialize)] +pub struct WhitelistRequest { + pub allowed_url: String, +} + #[derive(Serialize)] pub struct LiveSessionMetric { pub slug: String, @@ -48,15 +54,6 @@ pub enum AnalyticsEventType { Error, } -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -pub struct AnalyticsEvent { - pub id: i64, - pub project_id: i64, - pub event_type: AnalyticsEventType, - pub duration_seconds: Option<i64>, - pub error_type: Option<String>, - pub created_at: time::OffsetDateTime, -} #[derive(Debug, Serialize, Deserialize, Clone)] pub struct User { diff --git a/server/src/router.rs b/server/src/router.rs index 797d6ae..95052b3 100644 --- a/server/src/router.rs +++ b/server/src/router.rs @@ -13,7 +13,7 @@ pub fn create_router(state: AppState) -> Result<Router, Box<dyn std::error::Erro let session_store = MemoryStore::default(); let session_layer = SessionManagerLayer::new(session_store) .with_secure(false) - .with_same_site(tower_sessions::cookie::SameSite::Lax) + .with_same_site(tower_sessions::cookie::SameSite::Strict) .with_expiry(Expiry::OnInactivity(time::Duration::minutes(60))); // 1. DYNAMIC ORIGIN: Reads from env, defaults to localhost for dev diff --git a/server/src/state.rs b/server/src/state.rs index 7dcac10..e93230a 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -2,6 +2,10 @@ use bollard::Docker; use std::sync::{Arc, Mutex, MutexGuard}; use std::collections::HashMap; use std::time::Instant; +use dashmap::DashMap; +use governor::{Quota, RateLimiter, clock::DefaultClock, state::{InMemoryState, NotKeyed}}; +use std::num::NonZeroU32; +use crate::handlers::project::WHITELIST_RATE_LIMIT_PER_MINUTE; // New Struct to track container details AND ownership #[derive(Clone, Debug)] @@ -25,6 +29,8 @@ pub struct AppState { pub github_id: String, pub github_secret: String, pub sessions: SessionMap, + // Rate limiter for whitelist operations: per-user tracking + pub whitelist_rate_limiters: Arc<DashMap<i64, Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>>>, } impl AppState { @@ -37,4 +43,18 @@ impl AppState { } } } + + /// Get or create a rate limiter for a user's whitelist operations + pub fn get_or_create_whitelist_rate_limiter(&self, user_id: i64) -> Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>> { + self.whitelist_rate_limiters + .entry(user_id) + .or_insert_with(|| { + let quota = Quota::per_minute( + NonZeroU32::new(WHITELIST_RATE_LIMIT_PER_MINUTE) + .expect("WHITELIST_RATE_LIMIT_PER_MINUTE must be non-zero") + ); + Arc::new(RateLimiter::direct(quota)) + }) + .clone() + } } \ No newline at end of file