Fix designer file writing and CSP for CDN assets

- Designer now uses state.bucket_name (like app_generator) instead of DB lookup
- Fixed local file path to match app_server fallback: {site_path}/{bot}.gbai/{bot}.gbapp/{app}/{file}
- Fixed S3 path to match app_server: {bot}.gbapp/{app}/{file} in bucket {bot}.gbai
- Added S3 bucket creation retry logic (like app_generator)
- Updated CSP to allow unpkg.com, cdnjs.cloudflare.com, cdn.jsdelivr.net for scripts/styles
- Added fonts.googleapis.com and fonts.gstatic.com for web fonts
- Updated APP_GENERATOR_PROMPT to use HTMX CDN instead of non-existent /js/vendor path
- Added designer prompt guidelines for relative asset paths
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-02 17:48:51 -03:00
parent 021080d763
commit 7bad8d50f7
5 changed files with 115 additions and 34 deletions

View file

@ -490,8 +490,10 @@ Every HTML page MUST include proper SEO meta tags:
<meta property="og:type" content="website">
<link rel="icon" href="/assets/icons/gb-logo.svg" type="image/svg+xml">
<title>{Page Title} - {App Name}</title>
<!-- IMPORTANT: Use relative paths for app assets -->
<link rel="stylesheet" href="styles.css">
<script src="/js/vendor/htmx.min.js"></script>
<!-- HTMX from CDN - allowed by CSP -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="designer.js" defer></script>
</head>
```

View file

@ -296,11 +296,6 @@ impl AppGenerator {
// Insert "Analyzing Request" at the beginning of sections
new_manifest.sections.insert(0, analyzing_section);
// Add Deployment section at the end
let deploy_section = ManifestSection::new("Deployment", SectionType::Deployment)
.with_steps(1);
new_manifest.add_section(deploy_section);
// Recalculate all global step offsets after insertion
new_manifest.recalculate_global_steps();
new_manifest.completed_steps = 1; // Analyzing is done
@ -538,6 +533,17 @@ impl AppGenerator {
child.current_step = child.item_groups.iter()
.filter(|g| g.status == crate::auto_task::ItemStatus::Completed)
.count() as u32;
// Check if all item_groups in child are completed, then mark child as completed
let all_groups_completed = child.item_groups.iter()
.all(|g| g.status == crate::auto_task::ItemStatus::Completed);
if all_groups_completed && !child.item_groups.is_empty() {
child.status = SectionStatus::Completed;
child.completed_at = Some(Utc::now());
if let Some(started) = child.started_at {
child.duration_seconds = Some((Utc::now() - started).num_seconds() as u64);
}
}
break;
}
}
@ -545,6 +551,17 @@ impl AppGenerator {
section.current_step = section.children.iter()
.map(|c| c.current_step)
.sum();
// Check if all children in section are completed, then mark section as completed
let all_children_completed = section.children.iter()
.all(|c| c.status == SectionStatus::Completed);
if all_children_completed && !section.children.is_empty() {
section.status = SectionStatus::Completed;
section.completed_at = Some(Utc::now());
if let Some(started) = section.started_at {
section.duration_seconds = Some((Utc::now() - started).num_seconds() as u64);
}
}
break;
}
}
@ -597,11 +614,6 @@ impl AppGenerator {
.with_steps(1);
manifest.add_section(tools_section);
// Section 5: Deployment
let deploy_section = ManifestSection::new("Deployment", SectionType::Deployment)
.with_steps(1);
manifest.add_section(deploy_section);
manifest.status = ManifestStatus::Running;
manifest.add_terminal_line(&format!("Analyzing: {}", intent), TerminalLineType::Info);
manifest.add_terminal_line("Sending request to AI...", TerminalLineType::Progress);

View file

@ -1143,6 +1143,9 @@ Guidelines:
- API endpoints follow pattern: /api/db/{{table_name}}
- Forms should use hx-post for submissions
- Lists should use hx-get with pagination
- IMPORTANT: Use RELATIVE paths for app assets (styles.css, app.js, NOT /static/styles.css)
- For HTMX, use CDN: <script src="https://unpkg.com/htmx.org@1.9.10"></script>
- CSS link should be: <link rel="stylesheet" href="styles.css">
Respond with valid JSON only."#,
request.app_name,
@ -1277,36 +1280,35 @@ async fn apply_file_change(
app_name: &str,
file_name: &str,
content: &str,
session: &crate::shared::models::UserSession,
_session: &crate::shared::models::UserSession,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use crate::shared::models::schema::bots::dsl::*;
let mut conn = state.conn.get()?;
let bot_name_val: String = bots
.filter(id.eq(session.bot_id))
.select(name)
.first(&mut conn)?;
// Use bucket_name from state (like app_generator) - e.g., "default.gbai"
let bucket_name = state.bucket_name.clone();
let sanitized_name = bucket_name.trim_end_matches(".gbai").to_string();
// Always write to local disk first (primary storage, like import templates)
// Match app_server filesystem fallback path: {site_path}/{bot}.gbai/{bot}.gbapp/{app_name}/{file}
let site_path = state
.config
.as_ref()
.map(|c| c.site_path.clone())
.unwrap_or_else(|| "./botserver-stack/sites".to_string());
let local_path = format!("{site_path}/{app_name}/{file_name}");
let local_path = format!("{site_path}/{}.gbai/{}.gbapp/{app_name}/{file_name}", sanitized_name, sanitized_name);
if let Some(parent) = std::path::Path::new(&local_path).parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&local_path, content)?;
log::info!("Designer updated local file: {local_path}");
// Also sync to S3/MinIO if available (with bucket creation retry like app_generator)
if let Some(ref s3_client) = state.drive {
use aws_sdk_s3::primitives::ByteStream;
let bucket_name = format!("{}.gbai", bot_name_val.to_lowercase());
// Use same path pattern as app_generator: bucket.gbapp/app_name/file
let sanitized_bucket = bucket_name.trim_end_matches(".gbai");
let file_path = format!("{}.gbapp/{app_name}/{file_name}", sanitized_bucket);
// Use same path pattern as app_server/app_generator: {sanitized_name}.gbapp/{app_name}/{file}
let file_path = format!("{}.gbapp/{}/{}", sanitized_name, app_name, file_name);
log::info!("Designer syncing to S3: bucket={}, key={}", bucket_name, file_path);
match s3_client
.put_object()
@ -1321,7 +1323,47 @@ async fn apply_file_change(
log::info!("Designer synced to S3: s3://{bucket_name}/{file_path}");
}
Err(e) => {
log::warn!("Designer failed to sync to S3 (local write succeeded): {e}");
// Check if bucket doesn't exist and try to create it (like app_generator)
let err_str = format!("{:?}", e);
if err_str.contains("NoSuchBucket") || err_str.contains("NotFound") {
log::warn!("Bucket {} not found, attempting to create...", bucket_name);
// Try to create the bucket
match s3_client.create_bucket().bucket(&bucket_name).send().await {
Ok(_) => {
log::info!("Created bucket: {}", bucket_name);
}
Err(create_err) => {
let create_err_str = format!("{:?}", create_err);
// Ignore if bucket already exists (race condition)
if !create_err_str.contains("BucketAlreadyExists")
&& !create_err_str.contains("BucketAlreadyOwnedByYou") {
log::warn!("Failed to create bucket {}: {}", bucket_name, create_err);
}
}
}
// Retry the write after bucket creation
match s3_client
.put_object()
.bucket(&bucket_name)
.key(&file_path)
.body(ByteStream::from(content.as_bytes().to_vec()))
.content_type(get_content_type(file_name))
.send()
.await
{
Ok(_) => {
log::info!("Designer synced to S3 after bucket creation: s3://{bucket_name}/{file_path}");
}
Err(retry_err) => {
log::warn!("Designer S3 retry failed (local write succeeded): {retry_err}");
}
}
} else {
// S3 sync is optional - local write already succeeded
log::warn!("Designer S3 sync failed (local write succeeded): {e}");
}
}
}
}

View file

@ -24,10 +24,10 @@ impl Default for SecurityHeadersConfig {
Self {
content_security_policy: Some(
"default-src 'self'; \
script-src 'self' 'unsafe-inline' 'unsafe-eval'; \
style-src 'self' 'unsafe-inline'; \
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; \
style-src 'self' 'unsafe-inline' https://unpkg.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.googleapis.com; \
img-src 'self' data: https:; \
font-src 'self' data:; \
font-src 'self' data: https://fonts.gstatic.com; \
connect-src 'self' wss: https:; \
frame-ancestors 'self'; \
base-uri 'self'; \

View file

@ -655,12 +655,31 @@ fn extract_app_url_from_results(step_results: &Option<serde_json::Value>, title:
// Helper functions to get real manifest stats
fn get_manifest_processed_count(state: &Arc<AppState>, task_id: &str) -> String {
// First check in-memory manifest
if let Ok(manifests) = state.task_manifests.read() {
if let Some(manifest) = manifests.get(task_id) {
return manifest.processing_stats.data_points_processed.to_string();
let count = manifest.processing_stats.data_points_processed;
if count > 0 {
return count.to_string();
}
// Fallback: count completed items from manifest sections
let completed_items: u64 = manifest.sections.iter()
.map(|s| {
let section_items = s.items.iter().filter(|i| i.status == crate::auto_task::ItemStatus::Completed).count() as u64;
let section_groups = s.item_groups.iter().filter(|g| g.status == crate::auto_task::ItemStatus::Completed).count() as u64;
let child_items: u64 = s.children.iter().map(|c| {
c.items.iter().filter(|i| i.status == crate::auto_task::ItemStatus::Completed).count() as u64 +
c.item_groups.iter().filter(|g| g.status == crate::auto_task::ItemStatus::Completed).count() as u64
}).sum();
section_items + section_groups + child_items
})
.sum();
if completed_items > 0 {
return completed_items.to_string();
}
}
}
"0".to_string()
"-".to_string()
}
fn get_manifest_speed(state: &Arc<AppState>, task_id: &str) -> String {
@ -670,14 +689,22 @@ fn get_manifest_speed(state: &Arc<AppState>, task_id: &str) -> String {
if speed > 0.0 {
return format!("{:.1}/min", speed);
}
// For completed tasks, show "-" instead of "calculating..."
if manifest.status == crate::auto_task::ManifestStatus::Completed {
return "-".to_string();
}
}
}
"calculating...".to_string()
"-".to_string()
}
fn get_manifest_eta(state: &Arc<AppState>, task_id: &str) -> String {
if let Ok(manifests) = state.task_manifests.read() {
if let Some(manifest) = manifests.get(task_id) {
// Check if completed first
if manifest.status == crate::auto_task::ManifestStatus::Completed {
return "Done".to_string();
}
let eta_secs = manifest.processing_stats.estimated_remaining_seconds;
if eta_secs > 0 {
if eta_secs >= 60 {
@ -685,12 +712,10 @@ fn get_manifest_eta(state: &Arc<AppState>, task_id: &str) -> String {
} else {
return format!("~{} sec", eta_secs);
}
} else if manifest.status == crate::auto_task::ManifestStatus::Completed {
return "Done".to_string();
}
}
}
"calculating...".to_string()
"-".to_string()
}
fn build_taskmd_html(state: &Arc<AppState>, task_id: &str, title: &str, runtime: &str, db_manifest: Option<&serde_json::Value>) -> (String, String) {