From e402156ba449ca53a8f0f79dde61c730231fcdda Mon Sep 17 00:00:00 2001 From: Yashb404 Date: Wed, 11 Feb 2026 02:22:05 +0900 Subject: [PATCH 01/53] middle --- client/src/pages/embed.rs | 15 +- client/src/pages/view.rs | 289 ++++++++++-------- .../20260210000000_secure_embed_schema.up.sql | 21 ++ server/src/models.rs | 6 + 4 files changed, 211 insertions(+), 120 deletions(-) create mode 100644 server/migrations/20260210000000_secure_embed_schema.up.sql diff --git a/client/src/pages/embed.rs b/client/src/pages/embed.rs index aae747f..709a61e 100644 --- a/client/src/pages/embed.rs +++ b/client/src/pages/embed.rs @@ -12,6 +12,7 @@ enum ProjectState { NotFound, LimitReached, Ready(serde_json::Value), + Unauthorized, } #[component] @@ -32,7 +33,9 @@ pub fn EmbedPage() -> impl IntoView { 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 { @@ -75,6 +78,16 @@ pub fn EmbedPage() -> impl IntoView { let cid = data["container_id"].as_str().unwrap_or_default().to_string(); view! { }.into_view() }, + Some(ProjectState::Unauthorized) => { + view! { +
+

"403: Unauthorized Embed Location"

+

+ "This domain is not on the publisher's Guest List." +

+
+ }.into_view() + }, Some(ProjectState::LimitReached) => { // Inside embed, we remove the "Start" overlay (already gone via if logic) and show Limit view! { }.into_view() diff --git a/client/src/pages/view.rs b/client/src/pages/view.rs index 2c1292c..6ed0a5f 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -14,6 +14,15 @@ use crate::components::modal::EmbedModal; use crate::types::User; use serde::{Serialize, Deserialize}; +#[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(); options.insert(Options::ENABLE_STRIKETHROUGH); @@ -24,7 +33,6 @@ pub fn render_markdown(text: &str) -> String { html_output } -// Simple resize divider setup fn setup_resize_divider() { if let Some(divider) = web_sys::window() .and_then(|w| w.document()) @@ -43,10 +51,7 @@ fn setup_resize_divider() { 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 !*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()) @@ -59,13 +64,13 @@ fn setup_resize_divider() { 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()) { - 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::().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::().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::().ok()) { + p2.style().set_property("flex", "0 1 auto").ok(); + p2.style().set_property("width", &format!("{}%", 100.0 - percentage)).ok(); } } } @@ -92,34 +97,28 @@ fn setup_resize_divider() { } } -#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] -enum ProjectState { - Loading, - NotFound, - LimitReached, - Ready(serde_json::Value), -} - #[component] pub fn ViewPage() -> 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 (user, set_user) = create_signal(None::); 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())) + let (whitelist, set_whitelist) = create_signal(Vec::::new()); + let (new_url, set_new_url) = create_signal(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; + .send().await; - if let Ok(resp) = auth_req { + if let Ok(resp) = req { if resp.ok() { if let Ok(u) = resp.json::().await { set_user.set(Some(u)); @@ -128,22 +127,29 @@ pub fn ViewPage() -> impl IntoView { } }); + // 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); + 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::().await { - ProjectState::Ready(json) - } else { - ProjectState::NotFound - } + resp.json::().await + .map(ProjectState::Ready) + .unwrap_or(ProjectState::NotFound) } else { ProjectState::NotFound } @@ -153,7 +159,21 @@ pub fn ViewPage() -> impl IntoView { } ); - // Ownership Logic + // Whitelist Resource for Owners + let whitelist_resource = create_resource( + move || (project_resource.get(), slug()), + move |(state, s)| async move { + if let Some(ProjectState::Ready(_)) = state { + 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::>().await { + set_whitelist.set(list); + } + } + } + } + ); + let is_owner = move || { let current_user = user.get(); if let Some(ProjectState::Ready(p)) = project_resource.get() { @@ -167,6 +187,22 @@ pub fn ViewPage() -> impl IntoView { } }; + 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 { + let _ = builder.send().await; + set_new_url.set(String::new()); + whitelist_resource.refetch(); + } + } + }); + view! { <> impl IntoView {
- {move || { - if is_owner() { - match user.get() { - Some(u) => view! { -
-
- - {u.login.clone()} -
+ {move || if is_owner() { + user.get().map(|u| view! { +
+
+ + {u.login} +
+ - - "Logout" -
- }.into_view(), - None => view! { <> }.into_view() - } - } else { - view! { <> }.into_view() - } - }} + set_iframe_code.set(format!( + "", + public_url + )); + set_smart_link.set(smart_url); + set_embed_modal_open.set(true); + } + }> + "Share / Embed" + + "Logout" +
+ }) + } else { None }}
- // 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); create_effect(move |_| { - if !mounted.0.get() { - 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(); - } + if let Some(window) = web_sys::window() { + let callback = wasm_bindgen::closure::Closure::once(move || { + setup_resize_divider(); + }); + window.request_animation_frame(callback.as_ref().unchecked_ref()).ok(); + callback.forget(); } }); view! { -
-
-
-
-
-
-
-
-
-
- "Live Demo" +
+
+
+
-
- +
+
+
+
+
+
+ "Live Demo" +
+
+ +
+ + {move || if is_owner() { + view! { +
+

"Guest List (Authorized URLs)"

+
+ + +
+
+ + {url} + "×" + + } + }/> +
+
+ }.into_view() + } else { + view! { +
+

"This project is shared with a select list of authorized websites."

+

"Contact the owner for access or more information."

+
+ }.into_view() + }}
}.into_view() }, + Some(ProjectState::Unauthorized) => view! { +
+

"403: Access Denied"

+

+ "This terminal is restricted to authorized websites. Contact the owner to whitelist this domain." +

+
+ }.into_view(), Some(ProjectState::LimitReached) => view! { }.into_view(), Some(ProjectState::NotFound) => view! { -
"Project not found."
+
"Project not found."
}.into_view(), - Some(ProjectState::Loading) | None => view! { + _ => view! {
-

"Loading Environment..."

+

"PREPARING ENVIRONMENT..."

}.into_view() }} 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/models.rs b/server/src/models.rs index fa71627..becfddc 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, } #[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, From f7cb3e67fdc953720fdc099af2dd1e7f5ed70d9f Mon Sep 17 00:00:00 2001 From: Yashb404 Date: Wed, 11 Feb 2026 02:30:22 +0900 Subject: [PATCH 02/53] Rewrite project.rs --- server/src/handlers/project.rs | 227 ++++++++++++++++++++++++++++++--- 1 file changed, 210 insertions(+), 17 deletions(-) diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index 13c5bc0..84aca48 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,7 +15,7 @@ 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; #[derive(Deserialize)] @@ -28,6 +28,10 @@ pub fn routes() -> Router { .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/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 @@ -209,24 +213,82 @@ pub async fn publish_handler( pub async fn get_project( Path((username, slug)): Path<(String, String)>, State(state): State, - session: Session, // Added session to check ownership + session: Session, // Session is used to check ownership for secure embeds + Query(params): Query>, + headers: HeaderMap, ) -> Result, (StatusCode, String)> { - // Case-insensitive lookup (Fixes 404s due to capitalization) - let row_result = sqlx::query_as::<_, (String, String, String, i64)>( - "SELECT 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) + let row_result = sqlx::query_as::<_, (i64, String, String, String, i64, Option)>( + "SELECT id, image_tag, markdown, shell, owner_id, embed_key \ + 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 (image_tag, markdown, shell, owner_id) = match row_result { + let (project_id, image_tag, markdown, shell, owner_id, embed_key) = match row_result { Some(r) => r, None => return Err((StatusCode::NOT_FOUND, "Project not found".to_string())), }; - // 2. Increment View Count asynchronously + // 2. Determine current user & ownership (owners bypass embed security) + let current_user: Option = session.get("user").await.ok().flatten(); + let is_owner = current_user.as_ref().map(|u| u.id) == Some(owner_id); + + // 3. 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 = if let Some(referer_url) = 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(&referer_url) + .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 { + 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(), + )); + } + } + } + + // 4. Increment View Count asynchronously (only after passing security) let db_clone = state.db.clone(); let slug_clone = slug.clone(); let username_clone = username.clone(); @@ -238,6 +300,7 @@ pub async fn get_project( .await; }); + // 5. Publisher concurrency limit (protect compute) { let sessions = state.lock_sessions(); let active_viewers = sessions.values() @@ -249,18 +312,14 @@ 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 = session.get("user").await.ok().flatten(); - let is_owner = current_user.map(|u| u.id) == Some(owner_id); - + // 6. 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 + VIP embed_key for the Share / Embed modal 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)" @@ -275,6 +334,10 @@ pub async fn get_project( if let Some((token,)) = token_record { response_json["embed_token"] = serde_json::Value::String(token); } + + if let Some(key) = embed_key { + response_json["embed_key"] = serde_json::Value::String(key); + } } // Spin up container @@ -365,6 +428,136 @@ 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, + session: Session, + Path(slug): Path, +) -> Result>, (StatusCode, String)> { + let user: Option = 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()))?; + + // 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, + session: Session, + Path(slug): Path, + Json(payload): Json, +) -> Result { + let user: Option = 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()))?; + + 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())), + }; + + // Unique(project_id, allowed_url) is enforced by the DB; ignore conflicts + let result = sqlx::query( + "INSERT INTO project_whitelists (project_id, allowed_url) \ + VALUES ($1, $2) \ + ON CONFLICT (project_id, allowed_url) DO NOTHING", + ) + .bind(project_id) + .bind(trimmed_url) + .execute(&state.db) + .await; + + if let Err(e) = result { + return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e))); + } + + Ok(StatusCode::CREATED) +} + +/// Remove a URL from a project's whitelist (Guest List). +pub async fn remove_from_whitelist( + State(state): State, + session: Session, + Path(slug): Path, + Json(payload): Json, +) -> Result { + let user: Option = 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()))?; + + let trimmed_url = payload.allowed_url.trim(); + if trimmed_url.is_empty() { + return Err((StatusCode::BAD_REQUEST, "allowed_url is required".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(trimmed_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) +} + pub async fn delete_project( State(state): State, session: Session, From f97303998c8ec679c046efab216bd464a8bdc10b Mon Sep 17 00:00:00 2001 From: Yashb404 Date: Wed, 11 Feb 2026 00:00:04 +0600 Subject: [PATCH 03/53] Enhance EmbedModal and ViewPage components to support VIP links and whitelisting functionality. Update project handler to ensure VIP keys are generated for project owners and include embed_key in project queries. --- client/src/components/modal.rs | 119 ++++++++++++++++++++++++++++++++- client/src/pages/view.rs | 68 ++++++++----------- server/src/handlers/project.rs | 39 +++++++++-- 3 files changed, 177 insertions(+), 49 deletions(-) diff --git a/client/src/components/modal.rs b/client/src/components/modal.rs index d46df84..c1d9d1b 100644 --- a/client/src/components/modal.rs +++ b/client/src/components/modal.rs @@ -42,13 +42,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 || { @@ -56,10 +63,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! { @@ -110,11 +122,55 @@ 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() + }} +
+
+ + +
+

+ "Share privately. This bypasses the Guest List and should never be embedded publicly." +

+
+ + // --- SECTION 3: SMART LINK ---
{move || if copied_link.get() { view! { "✓ Copied!" }.into_view() @@ -154,6 +210,65 @@ pub fn EmbedModal(

+ // --- SECTION 4: Guest List / Whitelist --- +
+
+ + + "Only these pages can auto-launch your terminal." + +
+
+ + +
+
+ + {url.clone()} + + + } + } + /> +
+
+
- - {move || if is_owner() { - view! { -
-

"Guest List (Authorized URLs)"

-
- - -
-
- - {url} - "×" - - } - }/> -
-
- }.into_view() - } else { - view! { -
-

"This project is shared with a select list of authorized websites."

-

"Contact the owner for access or more information."

-
- }.into_view() - }}
}.into_view() }, diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index 84aca48..83092c9 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -48,7 +48,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) @@ -75,7 +76,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) @@ -229,7 +231,7 @@ pub async fn get_project( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Read Error: {}", e)))?; - let (project_id, image_tag, markdown, shell, owner_id, embed_key) = match row_result { + let (project_id, image_tag, markdown, shell, owner_id, mut embed_key) = match row_result { Some(r) => r, None => return Err((StatusCode::NOT_FOUND, "Project not found".to_string())), }; @@ -238,7 +240,30 @@ pub async fn get_project( let current_user: Option = session.get("user").await.ok().flatten(); let is_owner = current_user.as_ref().map(|u| u.id) == Some(owner_id); - // 3. Dual-layer security for embeds (VIP key + Guest List) + // 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()) { @@ -288,7 +313,7 @@ pub async fn get_project( } } - // 4. Increment View Count asynchronously (only after passing security) + // 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(); @@ -300,7 +325,7 @@ pub async fn get_project( .await; }); - // 5. Publisher concurrency limit (protect compute) + // 6. Publisher concurrency limit (protect compute) { let sessions = state.lock_sessions(); let active_viewers = sessions.values() @@ -312,7 +337,7 @@ pub async fn get_project( } } - // 6. Construct JSON response + // 7. Construct JSON response let mut response_json = serde_json::json!({ "markdown": markdown, "owner_id": owner_id, From c77743379b93a7efecef832610e5ba959bf7be49 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:34:10 +0000 Subject: [PATCH 04/53] Initial plan From 9dca27924b8d41306fc64fc331655e312e50d6e8 Mon Sep 17 00:00:00 2001 From: Yashb404 <139128977+Yashb404@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:04:45 +0530 Subject: [PATCH 05/53] Update client/src/components/modal.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- client/src/components/modal.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/modal.rs b/client/src/components/modal.rs index c1d9d1b..7e9d6d9 100644 --- a/client/src/components/modal.rs +++ b/client/src/components/modal.rs @@ -214,7 +214,7 @@ pub fn EmbedModal(
"Only these pages can auto-launch your terminal." From 5d486170dff46ccca6ce8a629b5434c5d9a57acc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:35:25 +0000 Subject: [PATCH 06/53] Initial plan From 373a5cb43aa8c3105a546cf453a2fd1c07b8cbab Mon Sep 17 00:00:00 2001 From: Yashb404 <139128977+Yashb404@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:05:39 +0530 Subject: [PATCH 07/53] Update client/src/pages/embed.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- client/src/pages/embed.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/pages/embed.rs b/client/src/pages/embed.rs index 709a61e..f83d2a4 100644 --- a/client/src/pages/embed.rs +++ b/client/src/pages/embed.rs @@ -33,7 +33,7 @@ pub fn EmbedPage() -> impl IntoView { match req { Ok(resp) => { - if resp.status() == 403{ + if resp.status() == 403 { ProjectState::Unauthorized } else if resp.status() == 429 { ProjectState::LimitReached From 15b0a71c81e6c9054785b47b938e8de9d9129f11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:36:32 +0000 Subject: [PATCH 08/53] Initial plan From 35b43656dd6b4ed521c4e636a13ec2eec7fdb278 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:37:33 +0000 Subject: [PATCH 09/53] Initial plan From de4acb35ca395f4ca1584c1b6d79a23e21cb7c24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:37:53 +0000 Subject: [PATCH 10/53] Initial plan From 7d337dfeeef8e03522c6b979663d918a4d8a32ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:38:49 +0000 Subject: [PATCH 11/53] Initial plan From 3c3c96ddd8c995b427c3f31aa6a2b51721014738 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:38:52 +0000 Subject: [PATCH 12/53] Implement URL normalization for Referer header validation to prevent security bypasses Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- server/src/handlers/project.rs | 75 +++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index 83092c9..daf5fe4 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -17,12 +17,33 @@ use futures::StreamExt; use crate::state::{AppState, SessionContext}; use crate::models::{User, ProjectSummary, PublishRequest, WhitelistRequest}; use std::collections::HashMap; +use url::Url; #[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. +/// Returns None if the URL cannot be parsed. +fn normalize_url(url_str: &str) -> Option { + Url::parse(url_str).ok().map(|url| { + let scheme = url.scheme(); + let host = url.host_str().unwrap_or(""); + let path = url.path(); + + // Normalize trailing slashes for consistency + let normalized_path = if path == "/" || path.is_empty() { + "/" + } else { + path.trim_end_matches('/') + }; + + format!("{}://{}{}", scheme, host, normalized_path) + }) +} + pub fn routes() -> Router { Router::new() .route("/api/my-projects", get(list_user_projects)) @@ -280,24 +301,32 @@ pub async fn get_project( // Optional boolean, safely unwrapped later let whitelist_allowed: Option = if let Some(referer_url) = 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(&referer_url) - .fetch_optional(&state.db) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Whitelist DB Error: {}", e), + // 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", ) - })?; - - // TRUE row exists => allowed; otherwise false - Some(exists_row.is_some()) + .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 + Some(false) + } } else { None }; @@ -525,6 +554,10 @@ pub async fn add_to_whitelist( 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".to_string()))?; + // Unique(project_id, allowed_url) is enforced by the DB; ignore conflicts let result = sqlx::query( "INSERT INTO project_whitelists (project_id, allowed_url) \ @@ -532,7 +565,7 @@ pub async fn add_to_whitelist( ON CONFLICT (project_id, allowed_url) DO NOTHING", ) .bind(project_id) - .bind(trimmed_url) + .bind(&normalized_url) .execute(&state.db) .await; @@ -561,6 +594,10 @@ pub async fn remove_from_whitelist( 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".to_string()))?; + let delete_result = sqlx::query( "DELETE FROM project_whitelists pw \ USING projects p \ @@ -571,7 +608,7 @@ pub async fn remove_from_whitelist( ) .bind(user.id) .bind(&slug) - .bind(trimmed_url) + .bind(&normalized_url) .execute(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; From dbf9c7af8b269299002a1330dbd3beb0f6e6b00a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:39:16 +0000 Subject: [PATCH 13/53] Initial plan From 5f6574fd8a08dd63041480cf258b07f712d69843 Mon Sep 17 00:00:00 2001 From: Yashb404 <139128977+Yashb404@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:09:24 +0530 Subject: [PATCH 14/53] Update client/src/components/modal.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- client/src/components/modal.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/components/modal.rs b/client/src/components/modal.rs index 7e9d6d9..67b3019 100644 --- a/client/src/components/modal.rs +++ b/client/src/components/modal.rs @@ -162,7 +162,8 @@ pub fn EmbedModal(

- "Share privately. This bypasses the Guest List and should never be embedded publicly." + "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."

From d870e3b6e423d772bf418ea1d569aeae85406a27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:39:57 +0000 Subject: [PATCH 15/53] Improve error handling for whitelist add/remove operations Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/view.rs | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/client/src/pages/view.rs b/client/src/pages/view.rs index ec51a95..6a9fd1c 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -196,8 +196,22 @@ pub fn ViewPage() -> impl IntoView { .json(&serde_json::json!({ "allowed_url": url })); if let Ok(builder) = req { - let _ = builder.send().await; - whitelist_resource.refetch(); + match builder.send().await { + Ok(resp) => { + if resp.ok() { + whitelist_resource.refetch(); + } else { + web_sys::console::log_1(&JsValue::from_str(&format!( + "Failed to add URL to whitelist: HTTP {}", resp.status() + ))); + } + } + Err(e) => { + web_sys::console::log_1(&JsValue::from_str(&format!( + "Failed to add URL to whitelist: {:?}", e + ))); + } + } } } }); @@ -211,8 +225,22 @@ pub fn ViewPage() -> impl IntoView { .json(&serde_json::json!({ "allowed_url": url })); if let Ok(builder) = req { - let _ = builder.send().await; - whitelist_resource.refetch(); + match builder.send().await { + Ok(resp) => { + if resp.ok() { + whitelist_resource.refetch(); + } else { + web_sys::console::log_1(&JsValue::from_str(&format!( + "Failed to remove URL from whitelist: HTTP {}", resp.status() + ))); + } + } + Err(e) => { + web_sys::console::log_1(&JsValue::from_str(&format!( + "Failed to remove URL from whitelist: {:?}", e + ))); + } + } } } }); From 1f36148a91767a727f53bc8f44e14d22e371ce7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:40:25 +0000 Subject: [PATCH 16/53] Enhance URL normalization security: restrict to http/https, require valid hosts Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- server/src/handlers/project.rs | 49 +++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index daf5fe4..69533bf 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -25,23 +25,40 @@ pub struct SearchQuery { } /// Normalizes a URL by extracting scheme + host + path (without query params or fragments). -/// This prevents bypass attempts using query parameters or fragments. -/// Returns None if the URL cannot be parsed. +/// +/// 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 { - Url::parse(url_str).ok().map(|url| { - let scheme = url.scheme(); - let host = url.host_str().unwrap_or(""); - let path = url.path(); - - // Normalize trailing slashes for consistency - let normalized_path = if path == "/" || path.is_empty() { - "/" - } else { - path.trim_end_matches('/') - }; - - format!("{}://{}{}", scheme, host, normalized_path) - }) + 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)) } pub fn routes() -> Router { From 9c3bd6ef8ddf222cf1be77cd788eae8f3cc68c03 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:40:55 +0000 Subject: [PATCH 17/53] Fix resize divider memory leak by adding mounted signal guard Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/view.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/client/src/pages/view.rs b/client/src/pages/view.rs index ec51a95..10632e7 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -279,13 +279,18 @@ pub fn ViewPage() -> impl IntoView { let md_raw = data["markdown"].as_str().unwrap_or_default().to_string(); let html_output = render_markdown(&md_raw); + let mounted = create_signal(false); + create_effect(move |_| { - if let Some(window) = web_sys::window() { - let callback = wasm_bindgen::closure::Closure::once(move || { - setup_resize_divider(); - }); - window.request_animation_frame(callback.as_ref().unchecked_ref()).ok(); - callback.forget(); + if !mounted.0.get() { + 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(); + } } }); From c361986b2529112d4008b406ce39993d7ec44e1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:41:30 +0000 Subject: [PATCH 18/53] Fix race condition by setting mounted flag before scheduling callback Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/pages/view.rs b/client/src/pages/view.rs index 10632e7..bfca2c1 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -283,10 +283,10 @@ pub fn ViewPage() -> impl IntoView { create_effect(move |_| { if !mounted.0.get() { + mounted.1.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(); From 5f08671e90efeea76bc42dba64d973fcc5ca2fee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:42:10 +0000 Subject: [PATCH 19/53] Improve code readability by destructuring mounted signal Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/view.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/pages/view.rs b/client/src/pages/view.rs index bfca2c1..1034260 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -279,11 +279,11 @@ pub fn ViewPage() -> impl IntoView { 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() { - mounted.1.set(true); + 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(); From 127525cb451d06616e959b1857f81cb4d1305fe4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:42:19 +0000 Subject: [PATCH 20/53] Generate embed_key at project creation time in publish_handler - Added embed_key generation in INSERT statement using encode(gen_random_bytes(24), 'base64') - Ensures VIP links are immediately available after project publishing - Lazy generation in get_project handler still works as fallback for migrated projects Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- server/src/handlers/project.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index 83092c9..d31d761 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -183,9 +183,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 ") From beca6bd4f4f5eba80618dec2f0de2df9e79e63a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:42:25 +0000 Subject: [PATCH 21/53] Use console::error_1 instead of console::log_1 for error messages Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/view.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/pages/view.rs b/client/src/pages/view.rs index 6a9fd1c..48fa57e 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -201,13 +201,13 @@ pub fn ViewPage() -> impl IntoView { if resp.ok() { whitelist_resource.refetch(); } else { - web_sys::console::log_1(&JsValue::from_str(&format!( + web_sys::console::error_1(&JsValue::from_str(&format!( "Failed to add URL to whitelist: HTTP {}", resp.status() ))); } } Err(e) => { - web_sys::console::log_1(&JsValue::from_str(&format!( + web_sys::console::error_1(&JsValue::from_str(&format!( "Failed to add URL to whitelist: {:?}", e ))); } @@ -230,13 +230,13 @@ pub fn ViewPage() -> impl IntoView { if resp.ok() { whitelist_resource.refetch(); } else { - web_sys::console::log_1(&JsValue::from_str(&format!( + web_sys::console::error_1(&JsValue::from_str(&format!( "Failed to remove URL from whitelist: HTTP {}", resp.status() ))); } } Err(e) => { - web_sys::console::log_1(&JsValue::from_str(&format!( + web_sys::console::error_1(&JsValue::from_str(&format!( "Failed to remove URL from whitelist: {:?}", e ))); } From ad406be9b18e65f81f5e01889b2e19fc1cd7531b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:44:25 +0000 Subject: [PATCH 22/53] Add logging and improve error messages for URL validation Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- server/src/handlers/project.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index 69533bf..7e38965 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -341,7 +341,8 @@ pub async fn get_project( // TRUE row exists => allowed; otherwise false Some(exists_row.is_some()) } else { - // If URL parsing fails, deny access + // 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 { @@ -572,8 +573,10 @@ pub async fn add_to_whitelist( }; // 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".to_string()))?; + 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(), + ))?; // Unique(project_id, allowed_url) is enforced by the DB; ignore conflicts let result = sqlx::query( @@ -612,8 +615,10 @@ pub async fn remove_from_whitelist( } // Normalize the URL to match how it was stored - let normalized_url = normalize_url(trimmed_url) - .ok_or((StatusCode::BAD_REQUEST, "Invalid URL format".to_string()))?; + 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 \ From f32a538136bb69b10991bafd50931848b48ce712 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:50:40 +0000 Subject: [PATCH 23/53] Add VIP key query parameter support to embed pages Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/embed.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/src/pages/embed.rs b/client/src/pages/embed.rs index f83d2a4..b958ff2 100644 --- a/client/src/pages/embed.rs +++ b/client/src/pages/embed.rs @@ -18,6 +18,7 @@ enum ProjectState { #[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); @@ -25,10 +26,15 @@ pub fn EmbedPage() -> impl IntoView { // Resource now returns ProjectState 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); + 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 { From 350dd58a250003ee403d4bc0f257a0c543d5012d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:51:38 +0000 Subject: [PATCH 24/53] Show helpful loading message when VIP link is unavailable Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/components/modal.rs | 82 +++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/client/src/components/modal.rs b/client/src/components/modal.rs index 7e9d6d9..ccd5f57 100644 --- a/client/src/components/modal.rs +++ b/client/src/components/modal.rs @@ -134,36 +134,58 @@ pub fn EmbedModal( view! { "Placeholder" }.into_view() }}
-
- - -
-

- "Share privately. This bypasses the Guest List and should never be embedded publicly." -

+ {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..." +

+

+ "Refresh this page to see your private VIP link once it's ready." +

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

+ "Share privately. This bypasses the Guest List and should never be embedded publicly." +

+ + }.into_view() + } + }}
// --- SECTION 3: SMART LINK --- From 3e820dbc049fcf7c220c2d221b0e79d6b5c79b6e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:53:36 +0000 Subject: [PATCH 25/53] Run cargo fmt to fix formatting Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/api.rs | 2 +- client/src/app.rs | 12 +- client/src/components/limit.rs | 10 +- client/src/components/modal.rs | 10 +- client/src/components/protected.rs | 45 ++++---- client/src/components/terminal.rs | 48 ++++---- client/src/lib.rs | 24 ++-- client/src/pages/admin.rs | 70 ++++++++---- client/src/pages/analytics.rs | 125 +++++++++++---------- client/src/pages/blogs.rs | 43 +++---- client/src/pages/create.rs | 170 ++++++++++++++++------------ client/src/pages/dashboard.rs | 142 +++++++++++------------ client/src/pages/docs.rs | 63 ++++++----- client/src/pages/embed.rs | 38 ++++--- client/src/pages/home.rs | 91 +++++++-------- client/src/pages/policy.rs | 53 ++++----- client/src/pages/view.rs | 173 +++++++++++++++++------------ client/src/types.rs | 4 +- 18 files changed, 622 insertions(+), 501 deletions(-) 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."

- + } -} \ No newline at end of file +} diff --git a/client/src/components/modal.rs b/client/src/components/modal.rs index ccd5f57..d0718da 100644 --- a/client/src/components/modal.rs +++ b/client/src/components/modal.rs @@ -52,7 +52,7 @@ pub fn EmbedModal( 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::(); @@ -81,7 +81,7 @@ pub fn EmbedModal( - + // --- SECTION 1: IFRAME ---
@@ -155,7 +155,7 @@ pub fn EmbedModal(
impl IntoView { let (user, set_user) = create_signal(None::); let (checked, set_checked) = create_signal(false); - create_resource(|| (), move |_| async move { - // FIX: Use dynamic API URL - let url = format!("{}/api/me", api_base()); - let auth_req = Request::get(&url) - .credentials(RequestCredentials::Include) - .send() - .await; + create_resource( + || (), + move |_| async move { + // FIX: Use dynamic API URL + 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)); + match auth_req { + Ok(resp) => { + if resp.ok() { + if let Ok(u) = resp.json::().await { + set_user.set(Some(u)); + } } } + Err(_) => {} } - Err(_) => {} - } - set_checked.set(true); - }); + set_checked.set(true); + }, + ); let children_view = children(); @@ -49,13 +52,13 @@ pub fn ProtectedRoute(children: Children) -> impl IntoView { }.into_view() } diff --git a/client/src/components/terminal.rs b/client/src/components/terminal.rs index 3cf3ae2..935dd94 100644 --- a/client/src/components/terminal.rs +++ b/client/src/components/terminal.rs @@ -1,10 +1,10 @@ +use crate::api::ws_base; use leptos::*; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; -use web_sys::{WebSocket, MessageEvent, ErrorEvent}; -use crate::api::ws_base; +use web_sys::{ErrorEvent, MessageEvent, WebSocket}; -// BINDING 1: FitAddon +// BINDING 1: FitAddon #[wasm_bindgen] extern "C" { #[wasm_bindgen(js_name = FitAddon)] @@ -15,7 +15,7 @@ extern "C" { fn fit(this: &XtermFitAddon); } -// BINDING 2: Terminal +// BINDING 2: Terminal #[wasm_bindgen] extern "C" { type Terminal; @@ -39,12 +39,12 @@ pub fn TerminalView(container_id: String) -> impl IntoView { create_effect(move |_| { if let Some(div) = terminal_div_ref.get() { let term = Terminal::new(); - + let fit_addon = XtermFitAddon::new(); term.load_addon(&fit_addon); term.open(&div); - - fit_addon.fit(); + + fit_addon.fit(); let fit_addon_clone = fit_addon.clone().unchecked_into::(); let on_resize = Closure::::new(move || { fit_addon_clone.fit(); @@ -55,49 +55,53 @@ pub fn TerminalView(container_id: String) -> impl IntoView { window().set_onresize(None); }); term.write(&format!("Connecting to session {}...\r\n", id_for_effect)); - + let term_clone: Terminal = term.clone().unchecked_into(); let ws_url = format!("{}/ws/{}", ws_base(), id_for_effect); - + // FIX: Removed unwrap() on WebSocket::new match WebSocket::new(&ws_url) { Ok(ws) => { let ws_cleanup = ws.clone(); - + on_cleanup(move || { let _ = ws_cleanup.close(); }); - let onmessage = Closure::::new(move |e: MessageEvent| { - if let Ok(txt) = e.data().dyn_into::() { - term_clone.write(&String::from(txt)); - } - }); + let onmessage = + Closure::::new(move |e: MessageEvent| { + if let Ok(txt) = e.data().dyn_into::() { + term_clone.write(&String::from(txt)); + } + }); ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); onmessage.forget(); let ws_clone = ws.clone(); - let on_data_callback = Closure::::new(move |data: String| { - let _ = ws_clone.send_with_str(&data); - }); + let on_data_callback = + Closure::::new(move |data: String| { + let _ = ws_clone.send_with_str(&data); + }); term.on_data(&on_data_callback); on_data_callback.forget(); let term_err = term.clone().unchecked_into::(); let onerror = Closure::::new(move |_| { - term_err.write("\r\n\x1b[31m[!] Connection Error.\x1b[0m\r\n"); + term_err.write("\r\n\x1b[31m[!] Connection Error.\x1b[0m\r\n"); }); ws.set_onerror(Some(onerror.as_ref().unchecked_ref())); onerror.forget(); let term_close = term.clone().unchecked_into::(); let onclose = Closure::::new(move || { - term_close.write("\r\n\x1b[33m[!] Connection Closed.\x1b[0m\r\n"); + term_close.write("\r\n\x1b[33m[!] Connection Closed.\x1b[0m\r\n"); }); ws.set_onclose(Some(onclose.as_ref().unchecked_ref())); onclose.forget(); - }, + } Err(_) => { - term.write("\r\n\x1b[31m[!] Failed to initialize WebSocket connection.\x1b[0m\r\n"); + term.write( + "\r\n\x1b[31m[!] Failed to initialize WebSocket connection.\x1b[0m\r\n", + ); } } } diff --git a/client/src/lib.rs b/client/src/lib.rs index bf33e06..4600c25 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -1,26 +1,26 @@ use leptos::*; -pub mod types; pub mod api; pub mod app; +pub mod types; pub mod components { - pub mod terminal; - pub mod protected; pub mod limit; - pub mod navbar; pub mod modal; + pub mod navbar; + pub mod protected; + pub mod terminal; } pub mod pages { - pub mod home; - pub mod dashboard; + pub mod admin; + pub mod analytics; + pub mod blogs; pub mod create; - pub mod view; - pub mod embed; + pub mod dashboard; pub mod docs; - pub mod blogs; - pub mod analytics; - pub mod admin; + pub mod embed; + pub mod home; pub mod policy; + pub mod view; } pub use app::App; @@ -29,4 +29,4 @@ pub use app::App; pub fn main() { console_error_panic_hook::set_once(); leptos::mount_to_body(|| view! { }) -} \ No newline at end of file +} diff --git a/client/src/pages/admin.rs b/client/src/pages/admin.rs index cf3e8b2..761bbc2 100644 --- a/client/src/pages/admin.rs +++ b/client/src/pages/admin.rs @@ -1,11 +1,11 @@ -use leptos::*; -use leptos_router::use_navigate; -use gloo_net::http::Request; -use web_sys::RequestCredentials; -use serde::{Deserialize, Serialize}; use crate::api::api_base; use crate::components::navbar::Navbar; use crate::types::ProjectSummary; +use gloo_net::http::Request; +use leptos::*; +use leptos_router::use_navigate; +use serde::{Deserialize, Serialize}; +use web_sys::RequestCredentials; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ContainerInfo { @@ -35,11 +35,14 @@ pub fn AdminPage() -> impl IntoView { // Actions are Copy, so we can use 'refresh_action' everywhere without cloning let refresh_action = create_action(move |_: &()| { // FIX: Clone navigate here so the async block owns this copy - let navigate = navigate.clone(); - + let navigate = navigate.clone(); + async move { let url = format!("{}/api/admin/stats", api_base()); - let req = Request::get(&url).credentials(RequestCredentials::Include).send().await; + let req = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await; match req { Ok(resp) => { @@ -54,12 +57,16 @@ pub fn AdminPage() -> impl IntoView { set_stats.set(Some(data)); } } - }, + } Err(_) => {} } - + let p_url = format!("{}/api/admin/projects", api_base()); - if let Ok(resp) = Request::get(&p_url).credentials(RequestCredentials::Include).send().await { + if let Ok(resp) = Request::get(&p_url) + .credentials(RequestCredentials::Include) + .send() + .await + { if let Ok(data) = resp.json::>().await { set_projects.set(data); } @@ -76,9 +83,17 @@ pub fn AdminPage() -> impl IntoView { let kill_container = create_action(move |id: &String| { let id = id.clone(); async move { - if !window().confirm_with_message(&format!("Kill container {}?", id)).unwrap_or(false) { return; } + if !window() + .confirm_with_message(&format!("Kill container {}?", id)) + .unwrap_or(false) + { + return; + } let url = format!("{}/api/admin/container/{}", api_base(), id); - let _ = Request::delete(&url).credentials(RequestCredentials::Include).send().await; + let _ = Request::delete(&url) + .credentials(RequestCredentials::Include) + .send() + .await; refresh_action.dispatch(()); // Trigger refresh } }); @@ -87,9 +102,20 @@ pub fn AdminPage() -> impl IntoView { let delete_project = create_action(move |slug: &String| { let slug = slug.clone(); async move { - if !window().confirm_with_message(&format!("DELETE project '{}'?\nThis deletes the DB entry AND Docker image.", slug)).unwrap_or(false) { return; } + if !window() + .confirm_with_message(&format!( + "DELETE project '{}'?\nThis deletes the DB entry AND Docker image.", + slug + )) + .unwrap_or(false) + { + return; + } let url = format!("{}/api/admin/project/{}", api_base(), slug); - let _ = Request::delete(&url).credentials(RequestCredentials::Include).send().await; + let _ = Request::delete(&url) + .credentials(RequestCredentials::Include) + .send() + .await; refresh_action.dispatch(()); // Trigger refresh } }); @@ -107,11 +133,11 @@ pub fn AdminPage() -> impl IntoView {
- "ADMIN MODE" -
} -} \ No newline at end of file +} diff --git a/client/src/pages/blogs.rs b/client/src/pages/blogs.rs index 9e05a82..2dcedab 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 813c9c6..e1db08a 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; // Simple resize divider setup fn setup_resize_divider() { @@ -20,21 +20,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()) @@ -44,36 +44,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(); } @@ -83,11 +104,8 @@ fn setup_resize_divider() { #[component] pub fn CreatePage() -> impl IntoView { 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 (markdown, set_markdown) = create_signal(r#"# Welcome to Your TryCLI Environment @@ -138,47 +156,52 @@ 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); + } + } else { + let status = spawn_resp.status(); + let text = spawn_resp.text().await.unwrap_or_default(); + set_container_id.set(format!("ERROR {}: {}", 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_container_id.set(format!("NETWORK_FAIL: {}", 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 @@ -219,14 +242,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()); @@ -266,20 +290,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 f83d2a4..b25201f 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 { @@ -24,13 +24,15 @@ pub fn EmbedPage() -> impl IntoView { // Resource now returns ProjectState let project_data = create_resource( - move || (started.get(), username(), slug()), + move || (started.get(), username(), slug()), |(is_started, u, s)| async move { - if !is_started { return ProjectState::Loading; } - + if !is_started { + return ProjectState::Loading; + } + let url = format!("{}/api/project/{}/{}", api_base(), u, s); let req = Request::get(&url).send().await; - + match req { Ok(resp) => { if resp.status() == 403 { @@ -46,21 +48,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() @@ -107,4 +109,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 fee7da9..7141748 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,45 @@ 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> <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 +120,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 +129,7 @@ pub fn LandingPage() -> impl IntoView { </svg> "Login" </a> - + <button class="hamburger-menu" class:open=move || menu_open.get() @@ -160,12 +163,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 +184,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 +199,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 +209,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 +226,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 +316,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 +332,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 +347,7 @@ pub fn LandingPage() -> impl IntoView { </div> </section> - // FOOTER + // FOOTER <footer class="landing-footer"> <div class="footer-container"> <div class="footer-top"> @@ -369,4 +372,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 af1e1d8..39a3168 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 ec51a95..08e69e6 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -1,18 +1,18 @@ -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::navbar::Navbar; +use crate::components::terminal::TerminalView; use crate::types::User; -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 { @@ -29,7 +29,7 @@ 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 } @@ -40,18 +40,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 !*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()) @@ -61,36 +63,55 @@ 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(p1) = panes.get(0).and_then(|e| e.dyn_into::<web_sys::HtmlElement>().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(); + p1.style() + .set_property("width", &format!("{}%", percentage)) + .ok(); } - if let Some(p2) = panes.get(1).and_then(|e| e.dyn_into::<web_sys::HtmlElement>().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(); + 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(); } @@ -103,43 +124,54 @@ pub fn ViewPage() -> impl IntoView { 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); let (iframe_code, set_iframe_code) = create_signal(String::new()); let (smart_link, set_smart_link) = create_signal(String::new()); 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; + 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) = 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(), auth_resource.get()), + move || (username(), slug(), auth_resource.get()), move |(u, s, _)| async move { - let key = query_params.get_untracked().get("key").cloned().unwrap_or_default(); + 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; - + let req = Request::get(&url) + .credentials(RequestCredentials::Include) + .send() + .await; + match req { Ok(resp) => { if resp.status() == 403 { @@ -147,16 +179,17 @@ pub fn ViewPage() -> impl IntoView { } else if resp.status() == 429 { ProjectState::LimitReached } else if resp.ok() { - resp.json::<serde_json::Value>().await + 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 @@ -165,23 +198,27 @@ pub fn ViewPage() -> impl IntoView { move |(state, s)| async move { if let Some(ProjectState::Ready(_)) = state { let url = format!("{}/api/project/{}/whitelist", api_base(), s); - if let Ok(resp) = Request::get(&url).credentials(RequestCredentials::Include).send().await { + 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); } } } - } + }, ); 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 } @@ -219,18 +256,18 @@ pub fn ViewPage() -> impl IntoView { view! { <> - <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() 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)) + on_close=Callback::new(move |_| set_embed_modal_open.set(false)) /> - + <Navbar> <div class="controls"> {move || if is_owner() { @@ -245,7 +282,7 @@ pub fn ViewPage() -> impl IntoView { if let Some(ProjectState::Ready(data)) = project_resource.get() { let token = data.get("embed_token").and_then(|v| v.as_str()).unwrap_or_default(); let key = data.get("embed_key").and_then(|v| v.as_str()).unwrap_or_default(); - + // Public embed uses whitelist + domain check only let public_url = format!("{}/embed/{}/{}", origin, username(), slug()); let smart_url = format!("{}/e/{}", api_base(), token); @@ -278,7 +315,7 @@ pub fn ViewPage() -> impl IntoView { 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); - + create_effect(move |_| { if let Some(window) = web_sys::window() { let callback = wasm_bindgen::closure::Closure::once(move || { @@ -288,7 +325,7 @@ pub fn ViewPage() -> impl IntoView { callback.forget(); } }); - + view! { <div style="display: flex; flex-direction: column; height: calc(100vh - 60px);"> <div class="workspace" style="flex: 1;"> @@ -320,16 +357,16 @@ pub fn ViewPage() -> impl IntoView { </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: 100px;">"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(), - _ => 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);">"PREPARING ENVIRONMENT..."</p> - </div> + </div> }.into_view() }} </> } -} \ No newline at end of file +} diff --git a/client/src/types.rs b/client/src/types.rs index 903d5c9..8c51301 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, @@ -54,4 +54,4 @@ pub struct User { pub id: i64, pub login: String, pub avatar_url: String, -} \ No newline at end of file +} From 23f1ad64fbca95e18046a97d6527d59b31c013c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:57:22 +0000 Subject: [PATCH 26/53] Add rate limiting and max entries limit for whitelist endpoints Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- server/Cargo.toml | 2 ++ server/src/config.rs | 2 ++ server/src/handlers/project.rs | 46 ++++++++++++++++++++++++++++++++++ server/src/state.rs | 17 +++++++++++++ 4 files changed, 67 insertions(+) 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/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 83092c9..dd6df7b 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -18,6 +18,9 @@ use crate::state::{AppState, SessionContext}; use crate::models::{User, ProjectSummary, PublishRequest, WhitelistRequest}; use std::collections::HashMap; +// Maximum number of whitelist entries allowed per project +const MAX_WHITELIST_ENTRIES: i64 = 100; + #[derive(Deserialize)] pub struct SearchQuery { q: String, @@ -465,6 +468,15 @@ pub async fn get_whitelist( .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_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)", @@ -506,6 +518,15 @@ pub async fn add_to_whitelist( .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_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())); @@ -525,6 +546,22 @@ pub async fn add_to_whitelist( None => return Err((StatusCode::NOT_FOUND, "Project not found".to_string())), }; + // Check current whitelist entry count to prevent storage exhaustion + let count_row: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM project_whitelists WHERE project_id = $1", + ) + .bind(project_id) + .fetch_one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; + + if count_row.0 >= MAX_WHITELIST_ENTRIES { + return Err(( + StatusCode::FORBIDDEN, + format!("Maximum whitelist entries ({}) reached for this project", MAX_WHITELIST_ENTRIES) + )); + } + // Unique(project_id, allowed_url) is enforced by the DB; ignore conflicts let result = sqlx::query( "INSERT INTO project_whitelists (project_id, allowed_url) \ @@ -556,6 +593,15 @@ pub async fn remove_from_whitelist( .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_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())); diff --git a/server/src/state.rs b/server/src/state.rs index 7dcac10..474b10a 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -2,6 +2,9 @@ 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; // New Struct to track container details AND ownership #[derive(Clone, Debug)] @@ -25,6 +28,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 +42,16 @@ impl AppState { } } } + + /// Get or create a rate limiter for a user (20 requests per minute for whitelist operations) + pub fn get_whitelist_rate_limiter(&self, user_id: i64) -> Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>> { + self.whitelist_rate_limiters + .entry(user_id) + .or_insert_with(|| { + // 20 requests per 60 seconds + let quota = Quota::per_minute(NonZeroU32::new(20).unwrap()); + Arc::new(RateLimiter::direct(quota)) + }) + .clone() + } } \ No newline at end of file From a36cd10a99b11bf45d72517df4f270aaf8b8b2f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:57:27 +0000 Subject: [PATCH 27/53] Initial plan From 26b847f6a97a2d5ea5a4fbd965a84186b8c5e915 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:57:35 +0000 Subject: [PATCH 28/53] Initial plan From 7ba4492580ded35bb774522a10e0aed87ca1b95e Mon Sep 17 00:00:00 2001 From: Yashb404 <139128977+Yashb404@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:27:48 +0530 Subject: [PATCH 29/53] Update server/migrations/20260210000000_secure_embed_schema.up.sql Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- server/migrations/20260210000000_secure_embed_schema.up.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/migrations/20260210000000_secure_embed_schema.up.sql b/server/migrations/20260210000000_secure_embed_schema.up.sql index 6a2506a..4d51c31 100644 --- a/server/migrations/20260210000000_secure_embed_schema.up.sql +++ b/server/migrations/20260210000000_secure_embed_schema.up.sql @@ -12,7 +12,7 @@ WHERE embed_key IS NULL; 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, + allowed_url TEXT NOT NULL CHECK (length(allowed_url) <= 2048), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(project_id, allowed_url) ); From dc3c3bcf1f9301c23910601ec3813308a18b1ddc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:58:36 +0000 Subject: [PATCH 30/53] Initial plan From f190fee1df638d6ea9ce78d98e15bd8e7d8474e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:58:47 +0000 Subject: [PATCH 31/53] Initial plan From be66fb43b49c1d4c027be6cd30ba58e282d1e2e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 04:59:07 +0000 Subject: [PATCH 32/53] Address code review feedback: fix race condition and improve code quality Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- server/src/handlers/project.rs | 59 ++++++++++++++++++++-------------- server/src/state.rs | 8 ++--- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index dd6df7b..26600b5 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -21,6 +21,9 @@ use std::collections::HashMap; // 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, @@ -469,7 +472,7 @@ pub async fn get_whitelist( let user = user.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; // Apply rate limiting: 20 requests per minute per user - let rate_limiter = state.get_whitelist_rate_limiter(user.id); + let rate_limiter = state.get_or_create_whitelist_rate_limiter(user.id); if rate_limiter.check().is_err() { return Err(( StatusCode::TOO_MANY_REQUESTS, @@ -519,7 +522,7 @@ pub async fn add_to_whitelist( let user = user.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; // Apply rate limiting: 20 requests per minute per user - let rate_limiter = state.get_whitelist_rate_limiter(user.id); + let rate_limiter = state.get_or_create_whitelist_rate_limiter(user.id); if rate_limiter.check().is_err() { return Err(( StatusCode::TOO_MANY_REQUESTS, @@ -546,35 +549,43 @@ pub async fn add_to_whitelist( None => return Err((StatusCode::NOT_FOUND, "Project not found".to_string())), }; - // Check current whitelist entry count to prevent storage exhaustion - let count_row: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM project_whitelists WHERE project_id = $1", - ) - .bind(project_id) - .fetch_one(&state.db) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; - - if count_row.0 >= MAX_WHITELIST_ENTRIES { - return Err(( - StatusCode::FORBIDDEN, - format!("Maximum whitelist entries ({}) reached for this project", MAX_WHITELIST_ENTRIES) - )); - } - - // Unique(project_id, allowed_url) is enforced by the DB; ignore conflicts + // Use a single query to check count and insert atomically to prevent race conditions + // This INSERT will only succeed if the count is below the limit let result = sqlx::query( "INSERT INTO project_whitelists (project_id, allowed_url) \ - VALUES ($1, $2) \ + SELECT $1, $2 \ + WHERE (SELECT COUNT(*) FROM project_whitelists WHERE project_id = $1) < $3 \ ON CONFLICT (project_id, allowed_url) DO NOTHING", ) .bind(project_id) .bind(trimmed_url) + .bind(MAX_WHITELIST_ENTRIES) .execute(&state.db) - .await; + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; - if let Err(e) = result { - return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e))); + // Check if the insert was successful or if limit was reached + if result.rows_affected() == 0 { + // Check if it's because of the limit or because it already exists + 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(trimmed_url) + .fetch_one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; + + if exists.0 { + // Entry already exists, this is acceptable + return Ok(StatusCode::CREATED); + } else { + // Must have hit the limit + return Err(( + StatusCode::FORBIDDEN, + format!("Maximum whitelist entries ({}) reached for this project", MAX_WHITELIST_ENTRIES) + )); + } } Ok(StatusCode::CREATED) @@ -594,7 +605,7 @@ pub async fn remove_from_whitelist( let user = user.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?; // Apply rate limiting: 20 requests per minute per user - let rate_limiter = state.get_whitelist_rate_limiter(user.id); + let rate_limiter = state.get_or_create_whitelist_rate_limiter(user.id); if rate_limiter.check().is_err() { return Err(( StatusCode::TOO_MANY_REQUESTS, diff --git a/server/src/state.rs b/server/src/state.rs index 474b10a..4969a87 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -5,6 +5,7 @@ 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)] @@ -43,13 +44,12 @@ impl AppState { } } - /// Get or create a rate limiter for a user (20 requests per minute for whitelist operations) - pub fn get_whitelist_rate_limiter(&self, user_id: i64) -> Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>> { + /// 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(|| { - // 20 requests per 60 seconds - let quota = Quota::per_minute(NonZeroU32::new(20).unwrap()); + let quota = Quota::per_minute(NonZeroU32::new(WHITELIST_RATE_LIMIT_PER_MINUTE).unwrap()); Arc::new(RateLimiter::direct(quota)) }) .clone() From fdb09a32237da7b69d520b7e2b80732b05ce9700 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:00:08 +0000 Subject: [PATCH 33/53] Fix race condition using PostgreSQL advisory locks in transaction Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- server/src/handlers/project.rs | 82 +++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index 26600b5..e8272ee 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -549,45 +549,67 @@ pub async fn add_to_whitelist( None => return Err((StatusCode::NOT_FOUND, "Project not found".to_string())), }; - // Use a single query to check count and insert atomically to prevent race conditions - // This INSERT will only succeed if the count is below the limit - let result = sqlx::query( - "INSERT INTO project_whitelists (project_id, allowed_url) \ - SELECT $1, $2 \ - WHERE (SELECT COUNT(*) FROM project_whitelists WHERE project_id = $1) < $3 \ - ON CONFLICT (project_id, allowed_url) DO NOTHING", + // 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 + 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(trimmed_url) - .bind(MAX_WHITELIST_ENTRIES) - .execute(&state.db) + .fetch_one(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; - // Check if the insert was successful or if limit was reached - if result.rows_affected() == 0 { - // Check if it's because of the limit or because it already exists - 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(trimmed_url) - .fetch_one(&state.db) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; + if exists.0 { + // Entry already exists, return success + tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Commit Error: {}", e)))?; + return Ok(StatusCode::CREATED); + } - if exists.0 { - // Entry already exists, this is acceptable - return Ok(StatusCode::CREATED); - } else { - // Must have hit the limit - return Err(( - StatusCode::FORBIDDEN, - format!("Maximum whitelist entries ({}) reached for this project", MAX_WHITELIST_ENTRIES) - )); - } + // 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 { + // Rollback transaction and return error + tx.rollback().await.ok(); + return Err(( + StatusCode::FORBIDDEN, + format!("Maximum whitelist entries ({}) reached for this project", MAX_WHITELIST_ENTRIES) + )); } + // Insert the new entry + sqlx::query( + "INSERT INTO project_whitelists (project_id, allowed_url) VALUES ($1, $2)", + ) + .bind(project_id) + .bind(trimmed_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) } From d8023cd36c4407ba92ede2f7102b3e7e0d45a95c Mon Sep 17 00:00:00 2001 From: Yashb404 <139128977+Yashb404@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:30:42 +0530 Subject: [PATCH 34/53] Update server/src/handlers/project.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- server/src/handlers/project.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index df7fd97..53499a3 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -254,7 +254,7 @@ pub async fn publish_handler( pub async fn get_project( Path((username, slug)): Path<(String, String)>, State(state): State<AppState>, - session: Session, // Session is used to check ownership for secure embeds + 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)> { From 8298271e9d34c9e0478cd3c8a60fb9070561aaba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:01:04 +0000 Subject: [PATCH 35/53] Address final code review comments Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- server/src/handlers/project.rs | 7 +++---- server/src/state.rs | 5 ++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index e8272ee..3c7ccb7 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -572,9 +572,9 @@ pub async fn add_to_whitelist( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; if exists.0 { - // Entry already exists, return success + // 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::CREATED); + return Ok(StatusCode::OK); // 200 OK - idempotent operation, entry already exists } // Check current count @@ -587,8 +587,7 @@ pub async fn add_to_whitelist( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?; if count_row.0 >= MAX_WHITELIST_ENTRIES { - // Rollback transaction and return error - tx.rollback().await.ok(); + // Transaction will automatically rollback when dropped return Err(( StatusCode::FORBIDDEN, format!("Maximum whitelist entries ({}) reached for this project", MAX_WHITELIST_ENTRIES) diff --git a/server/src/state.rs b/server/src/state.rs index 4969a87..e93230a 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -49,7 +49,10 @@ impl AppState { self.whitelist_rate_limiters .entry(user_id) .or_insert_with(|| { - let quota = Quota::per_minute(NonZeroU32::new(WHITELIST_RATE_LIMIT_PER_MINUTE).unwrap()); + 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() From 08f4e6ae2928541f66e582824d801d6d5e5003d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:02:24 +0000 Subject: [PATCH 36/53] Fix whitelist fetch to only execute for project owners Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/view.rs | 23 ++++++++++++++++------- client/src/types.rs | 2 +- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/client/src/pages/view.rs b/client/src/pages/view.rs index 872237d..38ed1d6 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -161,13 +161,22 @@ pub fn ViewPage() -> impl IntoView { // Whitelist Resource for Owners let whitelist_resource = create_resource( - move || (project_resource.get(), slug()), - move |(state, s)| async move { - if let Some(ProjectState::Ready(_)) = state { - 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); + 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); + } } } } diff --git a/client/src/types.rs b/client/src/types.rs index 903d5c9..c5b74dd 100644 --- a/client/src/types.rs +++ b/client/src/types.rs @@ -49,7 +49,7 @@ 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, From f4f10ac997333c26e8a337cd48b97ae06de32bb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:03:14 +0000 Subject: [PATCH 37/53] Implement CSRF protection for whitelist management endpoints Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- server/src/handlers/project.rs | 87 ++++++++++++++++++++++++++++++++++ server/src/router.rs | 2 +- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index df7fd97..51ac629 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -61,6 +61,85 @@ fn normalize_url(url_str: &str) -> Option<String> { 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)) @@ -544,10 +623,14 @@ pub async fn get_whitelist( /// 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 @@ -600,10 +683,14 @@ pub async fn add_to_whitelist( /// 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 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 From e745d42fae2e902d61ba306458022ac72a757571 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:07:44 +0000 Subject: [PATCH 38/53] Implement separate endpoint for embed_key retrieval to prevent exposure Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/view.rs | 25 ++++++++--- server/src/handlers/project.rs | 78 +++++++++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/client/src/pages/view.rs b/client/src/pages/view.rs index 872237d..61d6609 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -272,23 +272,34 @@ pub fn ViewPage() -> impl IntoView { 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(); - let key = data.get("embed_key").and_then(|v| v.as_str()).unwrap_or_default(); // Public embed uses whitelist + domain check only let public_url = format!("{}/embed/{}/{}", origin, username(), slug()); let smart_url = format!("{}/e/{}", api_base(), token); - let vip = if key.is_empty() { - String::new() - } else { - format!("{}/{}/{}?key={}", origin, username(), slug(), key) - }; 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); - set_vip_link.set(vip); + + // Fetch embed_key from dedicated endpoint to avoid exposure in main response + let slug_clone = slug(); + spawn_local(async move { + let url = format!("{}/api/project/{}/embed-key", api_base(), slug_clone); + if let Ok(resp) = Request::get(&url).credentials(RequestCredentials::Include).send().await { + if let Ok(data) = resp.json::<serde_json::Value>().await { + 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, username(), slug_clone, key) + }; + set_vip_link.set(vip); + } + } + }); + set_embed_modal_open.set(true); } }> diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index df7fd97..acfe42a 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -66,6 +66,7 @@ pub fn routes() -> Router<AppState> { .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), @@ -392,7 +393,9 @@ pub async fn get_project( // We will insert container_id below }); - // If owner, fetch and attach the secret token + VIP embed_key for the Share / Embed modal + // 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)" @@ -407,10 +410,6 @@ pub async fn get_project( if let Some((token,)) = token_record { response_json["embed_token"] = serde_json::Value::String(token); } - - if let Some(key) = embed_key { - response_json["embed_key"] = serde_json::Value::String(key); - } } // Spin up container @@ -643,6 +642,75 @@ pub async fn remove_from_whitelist( 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 + if user.id != owner_id { + return Err((StatusCode::FORBIDDEN, "Only project owners can access the embed key".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), + ) + })?; + + if let Some((k,)) = new_key_row { + embed_key = Some(k); + } + } + + // 5. Return the embed_key + let response = serde_json::json!({ + "embed_key": embed_key.unwrap_or_default() + }); + + Ok(Json(response)) +} + pub async fn delete_project( State(state): State<AppState>, session: Session, From b7c8984c898d7b783fb4ddbf930fa32f0c07c312 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:08:10 +0000 Subject: [PATCH 39/53] Add security documentation for embed key protection Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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. From e63ff19a5bf571dd976bd13cca43ac5097ec4e35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:09:20 +0000 Subject: [PATCH 40/53] Fix closure scope and improve error handling based on code review Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/view.rs | 3 ++- server/src/handlers/project.rs | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/client/src/pages/view.rs b/client/src/pages/view.rs index 61d6609..9e9d588 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -285,6 +285,7 @@ pub fn ViewPage() -> impl IntoView { // Fetch embed_key from dedicated endpoint to avoid exposure in main response let slug_clone = slug(); + let username_clone = username(); spawn_local(async move { let url = format!("{}/api/project/{}/embed-key", api_base(), slug_clone); if let Ok(resp) = Request::get(&url).credentials(RequestCredentials::Include).send().await { @@ -293,7 +294,7 @@ pub fn ViewPage() -> impl IntoView { let vip = if key.is_empty() { String::new() } else { - format!("{}/{}/{}?key={}", origin, username(), slug_clone, key) + format!("{}/{}/{}?key={}", origin, username_clone, slug_clone, key) }; set_vip_link.set(vip); } diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index acfe42a..0a08db6 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -703,9 +703,10 @@ pub async fn get_embed_key( } } - // 5. Return the embed_key + // 5. Return the embed_key (should always be present after step 4) + let key = embed_key.expect("embed_key should have been generated in step 4"); let response = serde_json::json!({ - "embed_key": embed_key.unwrap_or_default() + "embed_key": key }); Ok(Json(response)) From f3799d8aa651e3186f595196b2629062521c94d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:10:35 +0000 Subject: [PATCH 41/53] Add error logging and improve error handling for embed_key fetch Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/view.rs | 32 +++++++++++++++++++++++--------- server/src/handlers/project.rs | 10 ++++++---- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/client/src/pages/view.rs b/client/src/pages/view.rs index 9e9d588..b59d562 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -288,15 +288,29 @@ pub fn ViewPage() -> impl IntoView { let username_clone = username(); spawn_local(async move { let url = format!("{}/api/project/{}/embed-key", api_base(), slug_clone); - if let Ok(resp) = Request::get(&url).credentials(RequestCredentials::Include).send().await { - if let Ok(data) = resp.json::<serde_json::Value>().await { - 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, username_clone, slug_clone, key) - }; - set_vip_link.set(vip); + 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, 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: {:?}", e + ))); } } }); diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index 0a08db6..5d9af9d 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -698,13 +698,15 @@ pub async fn get_embed_key( ) })?; - if let Some((k,)) = new_key_row { - embed_key = Some(k); - } + embed_key = new_key_row.map(|(k,)| k); } // 5. Return the embed_key (should always be present after step 4) - let key = embed_key.expect("embed_key should have been generated in 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 }); From d89d045a62dddd693c07811eabed4167def94714 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:12:09 +0000 Subject: [PATCH 42/53] Reorder query fields to add 'id' at the end for maintainability Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- server/src/handlers/project.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index df7fd97..8306236 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -260,8 +260,9 @@ pub async fn get_project( ) -> Result<Json<serde_json::Value>, (StatusCode, String)> { // 1. Load project + security metadata (case-insensitive for username/slug) - let row_result = sqlx::query_as::<_, (i64, String, String, String, i64, Option<String>)>( - "SELECT id, image_tag, markdown, shell, owner_id, embed_key \ + // 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)" ) @@ -270,7 +271,7 @@ pub async fn get_project( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Read Error: {}", e)))?; - let (project_id, image_tag, markdown, shell, owner_id, mut embed_key) = 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())), }; From d48433114cb2b2248f99755d3fa230bbda244115 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:14:30 +0000 Subject: [PATCH 43/53] Fix information disclosure by returning NOT_FOUND for non-owners Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- server/src/handlers/project.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/handlers/project.rs b/server/src/handlers/project.rs index 5d9af9d..c49aa26 100644 --- a/server/src/handlers/project.rs +++ b/server/src/handlers/project.rs @@ -675,9 +675,9 @@ pub async fn get_embed_key( None => return Err((StatusCode::NOT_FOUND, "Project not found".to_string())), }; - // 3. Verify the user is the owner + // 3. Verify the user is the owner (return NOT_FOUND to avoid leaking project existence) if user.id != owner_id { - return Err((StatusCode::FORBIDDEN, "Only project owners can access the embed key".to_string())); + return Err((StatusCode::NOT_FOUND, "Project not found".to_string())); } // 4. Generate embed_key if it doesn't exist (lazy generation) From 3036331734e78aab24bd4c6733a78c94700edeca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 05:15:24 +0000 Subject: [PATCH 44/53] Fix closure capture of origin variable and improve error messages Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/view.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/src/pages/view.rs b/client/src/pages/view.rs index b59d562..a59897f 100644 --- a/client/src/pages/view.rs +++ b/client/src/pages/view.rs @@ -284,6 +284,7 @@ pub fn ViewPage() -> impl IntoView { set_smart_link.set(smart_url); // 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 { @@ -296,20 +297,20 @@ pub fn ViewPage() -> impl IntoView { let vip = if key.is_empty() { String::new() } else { - format!("{}/{}/{}?key={}", origin, username_clone, slug_clone, key) + 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 + "Failed to parse embed_key response: {}", e ))); } } } Err(e) => { web_sys::console::error_1(&JsValue::from_str(&format!( - "Failed to fetch embed_key: {:?}", e + "Failed to fetch embed_key from server: {}", e ))); } } From f14900333c93472b656ca1dec919c0cda4d7196c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 06:42:55 +0000 Subject: [PATCH 45/53] Fix error handling in dashboard and create pages - dashboard.rs: Handle JSON parse failure explicitly to prevent stuck loading state - create.rs: Separate spawn error state from container_id to avoid invalid WebSocket connections Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/create.rs | 25 ++++++++++++--- client/src/pages/dashboard.rs | 58 +++++++++++++++++++---------------- 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/client/src/pages/create.rs b/client/src/pages/create.rs index e1db08a..a712008 100644 --- a/client/src/pages/create.rs +++ b/client/src/pages/create.rs @@ -108,6 +108,7 @@ pub fn CreatePage() -> impl IntoView { 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::<String>); 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. @@ -182,15 +183,18 @@ After publishing, you can easily distribute your interactive terminal: if spawn_resp.ok() { if let Ok(id) = spawn_resp.json::<String>().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_container_id.set(format!("ERROR {}: {}", status, text)); + set_spawn_error.set(Some(format!("Spawn failed ({}): {}", status, text))); } } Err(e) => { - set_container_id.set(format!("NETWORK_FAIL: {}", e)); + set_spawn_error.set(Some(format!("Network error: {}", e))); } } } @@ -361,9 +365,20 @@ After publishing, you can easily distribute your interactive terminal: <span class="terminal-title">"bash — interactive"</span> </div> <div class="terminal-body"> - {move || match container_id.get().as_str() { - "" => view! { <div style="padding: 20px; color: #666;">"Initializing Environment..."</div> }.into_view(), - id => view! { <TerminalView container_id=id.to_string() /> }.into_view() + {move || { + if let Some(error) = spawn_error.get() { + view! { + <div style="padding: 20px; color: #ef4444;"> + <div style="margin-bottom: 10px; font-weight: bold;">"⚠️ Environment Spawn Error"</div> + <div style="color: #666;">{error}</div> + </div> + }.into_view() + } else { + match container_id.get().as_str() { + "" => view! { <div style="padding: 20px; color: #666;">"Initializing Environment..."</div> }.into_view(), + id => view! { <TerminalView container_id=id.to_string() /> }.into_view() + } + } }} </div> </div> diff --git a/client/src/pages/dashboard.rs b/client/src/pages/dashboard.rs index 6cfadf0..c489432 100644 --- a/client/src/pages/dashboard.rs +++ b/client/src/pages/dashboard.rs @@ -29,40 +29,46 @@ pub fn DashboardPage() -> impl IntoView { match auth_req { Ok(resp) => { if resp.ok() { - if let Ok(u) = resp.json::<User>().await { - // user authenticated - set_user.set(Some(u.clone())); + match resp.json::<User>().await { + Ok(u) => { + // user authenticated + set_user.set(Some(u.clone())); - let proj_url = format!("{}/api/my-projects", api_base()); - let projects_req = Request::get(&proj_url) - .credentials(RequestCredentials::Include) - .send() - .await; + let proj_url = format!("{}/api/my-projects", api_base()); + let projects_req = Request::get(&proj_url) + .credentials(RequestCredentials::Include) + .send() + .await; - match projects_req { - Ok(p_resp) => { - if p_resp.ok() { - if let Ok(projs) = - p_resp.json::<Vec<ProjectSummary>>().await - { - set_projects.set(projs); - set_error.set(None); + match projects_req { + Ok(p_resp) => { + if p_resp.ok() { + if let Ok(projs) = + p_resp.json::<Vec<ProjectSummary>>().await + { + set_projects.set(projs); + set_error.set(None); + } else { + set_error.set(Some( + "Failed to parse project list".to_string(), + )); + } } else { - set_error.set(Some( - "Failed to parse project list".to_string(), - )); + set_error + .set(Some("Failed to fetch deployments".to_string())); } - } else { + } + Err(_) => { set_error - .set(Some("Failed to fetch deployments".to_string())); + .set(Some("Network error connecting to API".to_string())); } } - Err(_) => { - set_error - .set(Some("Network error connecting to API".to_string())); - } + set_loading.set(false); + } + Err(_) => { + set_loading.set(false); + set_error.set(Some("Failed to parse user data".to_string())); } - set_loading.set(false); } } else { set_loading.set(false); From 705a0bfd0e2b4b8c1f4ee009b0fc93c1bb678897 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 06:43:10 +0000 Subject: [PATCH 46/53] Run cargo fmt Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/create.rs | 9 +++++++-- client/src/pages/dashboard.rs | 10 ++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/client/src/pages/create.rs b/client/src/pages/create.rs index a712008..fc0a05d 100644 --- a/client/src/pages/create.rs +++ b/client/src/pages/create.rs @@ -185,12 +185,17 @@ After publishing, you can easily distribute your interactive terminal: set_container_id.set(id); set_spawn_error.set(None); } else { - set_spawn_error.set(Some("Failed to parse container ID".to_string())); + 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))); + set_spawn_error.set(Some(format!( + "Spawn failed ({}): {}", + status, text + ))); } } Err(e) => { diff --git a/client/src/pages/dashboard.rs b/client/src/pages/dashboard.rs index c489432..53082f8 100644 --- a/client/src/pages/dashboard.rs +++ b/client/src/pages/dashboard.rs @@ -54,13 +54,15 @@ pub fn DashboardPage() -> impl IntoView { )); } } else { - set_error - .set(Some("Failed to fetch deployments".to_string())); + set_error.set(Some( + "Failed to fetch deployments".to_string(), + )); } } Err(_) => { - set_error - .set(Some("Network error connecting to API".to_string())); + set_error.set(Some( + "Network error connecting to API".to_string(), + )); } } set_loading.set(false); From 33a77cb66a7ab8d6ee4d9b0dea2b46512ae3225f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 06:44:12 +0000 Subject: [PATCH 47/53] Use CSS variables for consistent styling in error messages Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/pages/create.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/pages/create.rs b/client/src/pages/create.rs index fc0a05d..b6a6e69 100644 --- a/client/src/pages/create.rs +++ b/client/src/pages/create.rs @@ -373,14 +373,14 @@ After publishing, you can easily distribute your interactive terminal: {move || { if let Some(error) = spawn_error.get() { view! { - <div style="padding: 20px; color: #ef4444;"> - <div style="margin-bottom: 10px; font-weight: bold;">"⚠️ Environment Spawn Error"</div> - <div style="color: #666;">{error}</div> + <div style="padding: 20px; color: var(--text-main);"> + <div style="margin-bottom: 10px; font-weight: bold; color: #ef4444;">"⚠️ Environment Spawn Error"</div> + <div style="color: var(--text-muted);">{error}</div> </div> }.into_view() } else { match container_id.get().as_str() { - "" => view! { <div style="padding: 20px; color: #666;">"Initializing Environment..."</div> }.into_view(), + "" => view! { <div style="padding: 20px; color: var(--text-muted);">"Initializing Environment..."</div> }.into_view(), id => view! { <TerminalView container_id=id.to_string() /> }.into_view() } } From 5e104d4ed2f1647a2720c43841eec3e3fac03fcc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 07:17:09 +0000 Subject: [PATCH 48/53] Initial plan From b477d42c57fac79e7d4addbfac20ca6768e7eeaa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 07:25:21 +0000 Subject: [PATCH 49/53] Restore VIP link loading state that was lost during merges Co-authored-by: Yashb404 <139128977+Yashb404@users.noreply.github.com> --- client/src/components/modal.rs | 84 +++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/client/src/components/modal.rs b/client/src/components/modal.rs index 1a3efc4..a042b95 100644 --- a/client/src/components/modal.rs +++ b/client/src/components/modal.rs @@ -135,37 +135,59 @@ pub fn EmbedModal( }} </div> - <div class="input-hero-wrapper" style="display: flex; gap: 0;"> - <input - type="text" - class="input-slug" - style="flex: 1; font-family: var(--font-mono); font-size: 0.85rem; border-top-right-radius: 0; border-bottom-right-radius: 0; padding: 10px;" - readonly - node_ref=vip_ref - prop:value=move || vip_link.get() - /> - <button - class="btn-secondary" - style="border-top-left-radius: 0; border-bottom-left-radius: 0; border-left: none; width: 50px; display: flex; align-items: center; justify-content: center;" - aria-label="Copy VIP link" - on:click=move |_| { - let text = vip_link_for_click.get(); - let _ = window().navigator().clipboard().write_text(&text); - if let Some(el) = vip_ref.get() { el.select(); } - set_copied_vip.set(true); - set_timeout(move || set_copied_vip.set(false), std::time::Duration::from_millis(2000)); - } - > - <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> - <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> - <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> - </svg> - </button> - </div> - <p style="font-size: 0.8rem; color: var(--text-muted); margin-top: 8px;"> - <strong style="color: #dc2626;">"Security warning:"</strong> - " 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." - </p> + {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! { + <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center;"> + <p style="color: var(--text-muted); font-size: 0.85rem; margin: 0;"> + "🔒 VIP link is being generated..." + </p> + <p style="color: var(--text-muted); font-size: 0.75rem; margin-top: 8px;"> + "This may take a moment. The link will appear here once ready." + </p> + </div> + }.into_view() + } else { + view! { + <> + <div class="input-hero-wrapper" style="display: flex; gap: 0;"> + <input + type="text" + class="input-slug" + style="flex: 1; font-family: var(--font-mono); font-size: 0.85rem; border-top-right-radius: 0; border-bottom-right-radius: 0; padding: 10px;" + readonly + node_ref=vip_ref + prop:value=move || vip_link_for_input.get() + /> + <button + class="btn-secondary" + style="border-top-left-radius: 0; border-bottom-left-radius: 0; border-left: none; width: 50px; display: flex; align-items: center; justify-content: center;" + aria-label="Copy VIP link" + on:click=move |_| { + let text = vip_link_for_button.get(); + let _ = window().navigator().clipboard().write_text(&text); + if let Some(el) = vip_ref.get() { el.select(); } + set_copied_vip.set(true); + set_timeout(move || set_copied_vip.set(false), std::time::Duration::from_millis(2000)); + } + > + <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> + <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> + </svg> + </button> + </div> + <p style="font-size: 0.8rem; color: var(--text-muted); margin-top: 8px;"> + <strong style="color: #dc2626;">"Security warning:"</strong> + " 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." + </p> + </> + }.into_view() + } + }} </div> // --- SECTION 3: SMART LINK --- From f91245bec10f21317043e5720fecca5dad59f2ba Mon Sep 17 00:00:00 2001 From: Yashb404 <139128977+Yashb404@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:47:08 +0530 Subject: [PATCH 50/53] Update modal.rs --- client/src/components/modal.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/modal.rs b/client/src/components/modal.rs index a042b95..d309584 100644 --- a/client/src/components/modal.rs +++ b/client/src/components/modal.rs @@ -143,7 +143,7 @@ pub fn EmbedModal( view! { <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center;"> <p style="color: var(--text-muted); font-size: 0.85rem; margin: 0;"> - "🔒 VIP link is being generated..." + "VIP link is being generated..." </p> <p style="color: var(--text-muted); font-size: 0.75rem; margin-top: 8px;"> "This may take a moment. The link will appear here once ready." From bb873bb3ebf33925eda5d26eb57a3cc977430ec2 Mon Sep 17 00:00:00 2001 From: Yashb404 <yashbhardwaj7890@gmail.com> Date: Thu, 12 Feb 2026 16:10:32 +0900 Subject: [PATCH 51/53] remove analytics event struct --- server/src/models.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/server/src/models.rs b/server/src/models.rs index becfddc..ef4bc27 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -54,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 { From 2847c5c2feadedf1cfcf9cfee6eb31b15c3ef35f Mon Sep 17 00:00:00 2001 From: Yashb404 <yashbhardwaj7890@gmail.com> Date: Thu, 12 Feb 2026 16:32:03 +0900 Subject: [PATCH 52/53] remove unnecessary mut --- client/src/hooks/landscape_lock.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/hooks/landscape_lock.rs b/client/src/hooks/landscape_lock.rs index c4c5ae2..d6c7cb7 100644 --- a/client/src/hooks/landscape_lock.rs +++ b/client/src/hooks/landscape_lock.rs @@ -31,7 +31,7 @@ pub fn use_landscape_lock() -> ReadSignal<bool> { let width = window.inner_width().ok().and_then(|w| w.as_f64()).unwrap_or(0.0); let portrait = height > width; - if let Ok(mut setter) = set_portrait_clone1.try_borrow_mut() { + if let Ok(setter) = set_portrait_clone1.try_borrow_mut() { setter.set(portrait); } } @@ -46,7 +46,7 @@ pub fn use_landscape_lock() -> ReadSignal<bool> { let width = window.inner_width().ok().and_then(|w| w.as_f64()).unwrap_or(0.0); let portrait = height > width; - if let Ok(mut setter) = set_portrait_clone2.try_borrow_mut() { + if let Ok(setter) = set_portrait_clone2.try_borrow_mut() { setter.set(portrait); } } From d74315a72553f8b5eee3cfee666bab5131d6f8f1 Mon Sep 17 00:00:00 2001 From: Yashb404 <yashbhardwaj7890@gmail.com> Date: Thu, 12 Feb 2026 20:33:56 +0900 Subject: [PATCH 53/53] reset allowed url to prevent issues in prod --- server/migrations/20260210000000_secure_embed_schema.up.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/migrations/20260210000000_secure_embed_schema.up.sql b/server/migrations/20260210000000_secure_embed_schema.up.sql index 4d51c31..6a2506a 100644 --- a/server/migrations/20260210000000_secure_embed_schema.up.sql +++ b/server/migrations/20260210000000_secure_embed_schema.up.sql @@ -12,7 +12,7 @@ WHERE embed_key IS NULL; 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 CHECK (length(allowed_url) <= 2048), + allowed_url TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(project_id, allowed_url) );