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