From 6c6133a6834e6a62ac1d148c107b83314c4d51df Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Tue, 18 Nov 2025 22:40:00 +0000 Subject: [PATCH] Enhance public access and error handling in site-related views and loaders - Updated `DebugScopedRateThrottle` to allow public access for blueprint list requests with site filters. - Modified `SiteViewSet` and `SiteBlueprintViewSet` to permit public read access for list requests. - Enhanced `loadSiteDefinition` to resolve site slugs to IDs, improving the loading process for site definitions. - Improved error handling in `SiteDefinitionView` and `loadSiteDefinition` for better user feedback. - Adjusted CSS styles for better layout and alignment in shared components. --- backend/celerybeat-schedule | Bin 16384 -> 16384 bytes backend/igny8_core/api/throttles.py | 10 +- backend/igny8_core/auth/views.py | 14 +- .../business/site_building/models.py | 1 + backend/igny8_core/modules/billing/views.py | 6 +- backend/igny8_core/modules/publisher/views.py | 143 ++++++++++++------ .../igny8_core/modules/site_builder/views.py | 41 +++++ .../src/components/shared/blocks/blocks.css | 4 +- .../src/components/shared/layouts/layouts.css | 15 ++ sites/src/App.tsx | 3 +- sites/src/loaders/loadSiteDefinition.ts | 51 ++++++- sites/src/pages/SiteRenderer.tsx | 129 +++++++++++++++- sites/src/types/index.ts | 1 + sites/src/utils/layoutRenderer.tsx | 19 ++- 14 files changed, 361 insertions(+), 76 deletions(-) diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule index 20c33110cfc3dc1ef9d78cfd35af7958e3b42ed4..784ff7cdc76a6b990a3d51552cd858ec998000a7 100644 GIT binary patch delta 30 lcmZo@U~Fh$+@NT}FQ&}Ez>qp6L$qy5&=l|7%?2hHxB-Yl32*=a delta 30 lcmZo@U~Fh$+@NT}FDl8vz~DJ0L$qy5&=l|Z%?2hHxB-QD2|EA) diff --git a/backend/igny8_core/api/throttles.py b/backend/igny8_core/api/throttles.py index 0b7aaf1d..617403d2 100644 --- a/backend/igny8_core/api/throttles.py +++ b/backend/igny8_core/api/throttles.py @@ -28,11 +28,19 @@ class DebugScopedRateThrottle(ScopedRateThrottle): - IGNY8_DEBUG_THROTTLE environment variable is True - User belongs to aws-admin or other system accounts - User is admin/developer role + - Public blueprint list request with site filter (for Sites Renderer) """ # Check if throttling should be bypassed debug_bypass = getattr(settings, 'DEBUG', False) env_bypass = getattr(settings, 'IGNY8_DEBUG_THROTTLE', False) + # Bypass for public blueprint list requests (Sites Renderer fallback) + public_blueprint_bypass = False + if hasattr(view, 'action') and view.action == 'list': + if hasattr(request, 'query_params') and request.query_params.get('site'): + if not request.user or not hasattr(request.user, 'is_authenticated') or not request.user.is_authenticated: + public_blueprint_bypass = True + # Bypass for system account users (aws-admin, default-account, etc.) system_account_bypass = False if hasattr(request, 'user') and request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated: @@ -47,7 +55,7 @@ class DebugScopedRateThrottle(ScopedRateThrottle): # If checking fails, continue with normal throttling pass - if debug_bypass or env_bypass or system_account_bypass: + if debug_bypass or env_bypass or system_account_bypass or public_blueprint_bypass: # In debug mode or for system accounts, still set throttle headers but don't actually throttle # This allows testing throttle headers without blocking requests if hasattr(self, 'get_rate'): diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index 089972ba..685c6930 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -478,16 +478,26 @@ class SiteViewSet(AccountModelViewSet): def get_permissions(self): """Allow normal users (viewer) to create sites, but require editor+ for other operations.""" + # Allow public read access for list requests with slug filter (used by Sites Renderer) + if self.action == 'list' and self.request.query_params.get('slug'): + from rest_framework.permissions import AllowAny + return [AllowAny()] if self.action == 'create': return [permissions.IsAuthenticated()] return [IsEditorOrAbove()] def get_queryset(self): """Return sites accessible to the current user.""" - user = self.request.user - if not user or not user.is_authenticated: + # If this is a public request (no auth) with slug filter, return site by slug + if not self.request.user or not self.request.user.is_authenticated: + slug = self.request.query_params.get('slug') + if slug: + # Return queryset directly from model (bypassing base class account filtering) + return Site.objects.filter(slug=slug, is_active=True) return Site.objects.none() + user = self.request.user + # ADMIN/DEV OVERRIDE: Both admins and developers can see all sites if user.is_admin_or_developer(): return Site.objects.all().distinct() diff --git a/backend/igny8_core/business/site_building/models.py b/backend/igny8_core/business/site_building/models.py index b3a921b9..5fb9dd93 100644 --- a/backend/igny8_core/business/site_building/models.py +++ b/backend/igny8_core/business/site_building/models.py @@ -101,6 +101,7 @@ class PageBlueprint(SiteSectorBaseModel): ('draft', 'Draft'), ('generating', 'Generating'), ('ready', 'Ready'), + ('published', 'Published'), ] site_blueprint = models.ForeignKey( diff --git a/backend/igny8_core/modules/billing/views.py b/backend/igny8_core/modules/billing/views.py index 68cc7692..376e9009 100644 --- a/backend/igny8_core/modules/billing/views.py +++ b/backend/igny8_core/modules/billing/views.py @@ -81,8 +81,10 @@ class CreditBalanceViewSet(viewsets.ViewSet): 'credits_remaining': credits_remaining, } - serializer = CreditBalanceSerializer(data) - return success_response(data=serializer.data, request=request) + # Validate and serialize data + serializer = CreditBalanceSerializer(data=data) + serializer.is_valid(raise_exception=True) + return success_response(data=serializer.validated_data, request=request) @extend_schema_view( diff --git a/backend/igny8_core/modules/publisher/views.py b/backend/igny8_core/modules/publisher/views.py index 83d86dc2..fd30fed5 100644 --- a/backend/igny8_core/modules/publisher/views.py +++ b/backend/igny8_core/modules/publisher/views.py @@ -219,61 +219,104 @@ class SiteDefinitionView(APIView): GET /api/v1/publisher/sites/{site_id}/definition/ """ - sites_data_path = os.getenv('SITES_DATA_PATH', '/data/app/sites-data') - - # Try to find latest deployed version - site_dir = Path(sites_data_path) / 'clients' / str(site_id) - - if not site_dir.exists(): - return error_response( - f'Site {site_id} not found', - status.HTTP_404_NOT_FOUND, - request - ) - - # Look for latest version (check for 'latest' symlink or highest version number) - latest_path = site_dir / 'latest' / 'site.json' - if latest_path.exists(): + try: + sites_data_path = os.getenv('SITES_DATA_PATH', '/data/app/sites-data') + + # Try to find latest deployed version + site_dir = Path(sites_data_path) / 'clients' / str(site_id) + + if not site_dir.exists(): + return error_response( + error=f'Site {site_id} not found at {site_dir}. Site may not be deployed yet.', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + + # Look for latest version (check for 'latest' symlink or highest version number) + latest_path = site_dir / 'latest' / 'site.json' + if latest_path.exists(): + try: + with open(latest_path, 'r', encoding='utf-8') as f: + definition = json.load(f) + return Response(definition, status=status.HTTP_200_OK) + except json.JSONDecodeError as e: + return error_response( + error=f'Invalid JSON in site definition file: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + except Exception as e: + return error_response( + error=f'Failed to load site definition from {latest_path}: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + # Fallback: find highest version number try: - with open(latest_path, 'r', encoding='utf-8') as f: - definition = json.load(f) - return Response(definition, status=status.HTTP_200_OK) + version_dirs = [d for d in site_dir.iterdir() if d.is_dir() and d.name.startswith('v')] + except PermissionError as e: + return error_response( + error=f'Permission denied accessing site directory: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except Exception as e: return error_response( - f'Failed to load site definition: {str(e)}', - status.HTTP_500_INTERNAL_SERVER_ERROR, - request + error=f'Error reading site directory: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + if not version_dirs: + return error_response( + error=f'No deployed versions found for site {site_id}. Site may not be deployed yet.', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + + # Sort by version number (extract number from 'v1', 'v2', etc.) + try: + version_dirs.sort(key=lambda d: int(d.name[1:]) if d.name[1:].isdigit() else 0, reverse=True) + latest_version_dir = version_dirs[0] + site_json_path = latest_version_dir / 'site.json' + + if site_json_path.exists(): + with open(site_json_path, 'r', encoding='utf-8') as f: + definition = json.load(f) + return Response(definition, status=status.HTTP_200_OK) + else: + return error_response( + error=f'Site definition file not found at {site_json_path}', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + except json.JSONDecodeError as e: + return error_response( + error=f'Invalid JSON in site definition file: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + except Exception as e: + return error_response( + error=f'Failed to load site definition: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request ) - - # Fallback: find highest version number - version_dirs = [d for d in site_dir.iterdir() if d.is_dir() and d.name.startswith('v')] - if not version_dirs: - return error_response( - f'No deployed versions found for site {site_id}', - status.HTTP_404_NOT_FOUND, - request - ) - - # Sort by version number (extract number from 'v1', 'v2', etc.) - try: - version_dirs.sort(key=lambda d: int(d.name[1:]) if d.name[1:].isdigit() else 0, reverse=True) - latest_version_dir = version_dirs[0] - site_json_path = latest_version_dir / 'site.json' - if site_json_path.exists(): - with open(site_json_path, 'r', encoding='utf-8') as f: - definition = json.load(f) - return Response(definition, status=status.HTTP_200_OK) - except Exception as e: return error_response( - f'Failed to load site definition: {str(e)}', - status.HTTP_500_INTERNAL_SERVER_ERROR, - request + error=f'Site definition not found for site {site_id}', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + except Exception as e: + # Catch any unhandled exceptions + import logging + logger = logging.getLogger(__name__) + logger.error(f'Unhandled exception in SiteDefinitionView: {str(e)}', exc_info=True) + return error_response( + error=f'Internal server error: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request ) - - return error_response( - f'Site definition not found for site {site_id}', - status.HTTP_404_NOT_FOUND, - request - ) diff --git a/backend/igny8_core/modules/site_builder/views.py b/backend/igny8_core/modules/site_builder/views.py index 29fafda2..6d897222 100644 --- a/backend/igny8_core/modules/site_builder/views.py +++ b/backend/igny8_core/modules/site_builder/views.py @@ -39,6 +39,47 @@ class SiteBlueprintViewSet(SiteSectorModelViewSet): permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove] throttle_scope = 'site_builder' throttle_classes = [DebugScopedRateThrottle] + + def get_permissions(self): + """ + Allow public read access for list requests with site filter (used by Sites Renderer fallback). + This allows the Sites Renderer to load blueprint data for deployed sites without authentication. + """ + # Allow public access for list requests with site filter (used by Sites Renderer) + if self.action == 'list' and self.request.query_params.get('site'): + from rest_framework.permissions import AllowAny + return [AllowAny()] + # Otherwise use default permissions + return super().get_permissions() + + def get_throttles(self): + """ + Bypass throttling for public list requests with site filter (used by Sites Renderer). + """ + # Bypass throttling for public requests (no auth) with site filter + if self.action == 'list' and self.request.query_params.get('site'): + if not self.request.user or not self.request.user.is_authenticated: + return [] # No throttling for public blueprint access + return super().get_throttles() + + def get_queryset(self): + """ + Override to allow public access when filtering by site_id. + """ + # If this is a public request (no auth) with site filter, bypass base class filtering + # and return deployed blueprints for that site + if not self.request.user or not self.request.user.is_authenticated: + site_id = self.request.query_params.get('site') + if site_id: + # Return queryset directly from model (bypassing base class account/site filtering) + from igny8_core.business.site_building.models import SiteBlueprint + return SiteBlueprint.objects.filter( + site_id=site_id, + status='deployed' + ).prefetch_related('pages').order_by('-version') + + # For authenticated users, use base class filtering + return super().get_queryset() def perform_create(self, serializer): from igny8_core.auth.models import Site, Sector diff --git a/frontend/src/components/shared/blocks/blocks.css b/frontend/src/components/shared/blocks/blocks.css index 661ad9a8..8d3e94d0 100644 --- a/frontend/src/components/shared/blocks/blocks.css +++ b/frontend/src/components/shared/blocks/blocks.css @@ -6,6 +6,8 @@ display: flex; flex-direction: column; gap: 1rem; + align-items: center; + text-align: center; } .shared-hero__eyebrow { @@ -36,7 +38,7 @@ font-weight: 600; color: #0f172a; background: #fff; - align-self: flex-start; + align-self: center; cursor: pointer; } diff --git a/frontend/src/components/shared/layouts/layouts.css b/frontend/src/components/shared/layouts/layouts.css index 2ee5b190..d74a0805 100644 --- a/frontend/src/components/shared/layouts/layouts.css +++ b/frontend/src/components/shared/layouts/layouts.css @@ -2,16 +2,19 @@ display: flex; flex-direction: column; gap: 1.5rem; + width: 100%; } .shared-layout__hero { min-height: 320px; + width: 100%; } .shared-layout__body { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.25rem; + width: 100%; } @media (min-width: 1024px) { @@ -22,6 +25,18 @@ .shared-layout__section { margin-bottom: 1.25rem; + text-align: center; +} + +.shared-layout__section > * { + text-align: left; + max-width: 100%; +} + +.shared-layout__section h2, +.shared-layout__section h3, +.shared-layout__section h4 { + text-align: center; } .shared-layout__sidebar { diff --git a/sites/src/App.tsx b/sites/src/App.tsx index 5c948155..96120126 100644 --- a/sites/src/App.tsx +++ b/sites/src/App.tsx @@ -24,7 +24,8 @@ function App() { }> {/* Public Site Renderer Routes (No Auth) */} - } /> + } /> + } /> IGNY8 Sites Renderer} /> {/* Builder Routes (Auth Required) */} diff --git a/sites/src/loaders/loadSiteDefinition.ts b/sites/src/loaders/loadSiteDefinition.ts index b5b51f1f..c4940ad5 100644 --- a/sites/src/loaders/loadSiteDefinition.ts +++ b/sites/src/loaders/loadSiteDefinition.ts @@ -41,11 +41,53 @@ function getApiBaseUrl(): string { const API_URL = getApiBaseUrl(); /** - * Load site definition by site ID. - * First tries to load from filesystem (deployed sites), + * Resolve site slug to site ID. + * Queries the Site API to get the site ID from the slug. + */ +async function resolveSiteIdFromSlug(siteSlug: string): Promise { + try { + // Query sites by slug - slug is unique per account, but we need to search across all accounts for public sites + const response = await axios.get(`${API_URL}/v1/auth/sites/`, { + params: { slug: siteSlug }, + timeout: 10000, + headers: { + 'Accept': 'application/json', + }, + }); + + const sites = Array.isArray(response.data?.results) ? response.data.results : + Array.isArray(response.data) ? response.data : []; + + if (sites.length > 0) { + return sites[0].id; + } + + throw new Error(`Site with slug "${siteSlug}" not found`); + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response?.status === 404) { + throw new Error(`Site with slug "${siteSlug}" not found`); + } + throw new Error(`Failed to resolve site slug: ${error.message}`); + } + throw error; + } +} + +/** + * Load site definition by site slug. + * First resolves slug to ID, then tries to load from filesystem (deployed sites), * then falls back to API. */ -export async function loadSiteDefinition(siteId: string): Promise { +export async function loadSiteDefinition(siteSlug: string): Promise { + // First, resolve slug to site ID + let siteId: number; + try { + siteId = await resolveSiteIdFromSlug(siteSlug); + } catch (error) { + throw error; // Re-throw slug resolution errors + } + // Try API endpoint for deployed site definition first try { const response = await axios.get(`${API_URL}/v1/publisher/sites/${siteId}/definition/`, { @@ -82,7 +124,7 @@ export async function loadSiteDefinition(siteId: string): Promise(); + const { siteSlug, pageSlug } = useParams<{ siteSlug: string; pageSlug?: string }>(); const [siteDefinition, setSiteDefinition] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - if (!siteId) { - setError('Site ID is required'); + if (!siteSlug) { + setError('Site slug is required'); setLoading(false); return; } - loadSiteDefinition(siteId) + loadSiteDefinition(siteSlug) .then((definition) => { setSiteDefinition(definition); setLoading(false); @@ -26,7 +26,7 @@ function SiteRenderer() { setError(err.message || 'Failed to load site'); setLoading(false); }); - }, [siteId]); + }, [siteSlug, pageSlug]); if (loading) { return
Loading site...
; @@ -40,9 +40,122 @@ function SiteRenderer() { return
Site not found
; } + // Build navigation from site definition + // Show pages that are published, ready, or in navigation (excluding home and draft/generating) + const navigation = siteDefinition.navigation || siteDefinition.pages + .filter(p => + p.slug !== 'home' && + (p.status === 'published' || p.status === 'ready' || siteDefinition.navigation?.some(n => n.slug === p.slug)) + ) + .sort((a, b) => { + // Try to get order from navigation or use page order + const navA = siteDefinition.navigation?.find(n => n.slug === a.slug); + const navB = siteDefinition.navigation?.find(n => n.slug === b.slug); + return (navA?.order ?? a.order ?? 0) - (navB?.order ?? b.order ?? 0); + }) + .map(page => ({ + label: page.title, + slug: page.slug, + order: page.order || 0 + })); + + // Filter pages based on current route + const currentPageSlug = pageSlug || 'home'; + const currentPage = siteDefinition.pages.find(p => p.slug === currentPageSlug); + + // If specific page requested, show only that page; otherwise show all published/ready pages + const pagesToRender = currentPageSlug && currentPageSlug !== 'home' && currentPage + ? [currentPage] + : siteDefinition.pages.filter(p => + p.status === 'published' || + p.status === 'ready' || + (p.slug === 'home' && p.status !== 'draft' && p.status !== 'generating') + ); + return ( -
- {renderLayout(siteDefinition)} +
+ {/* Navigation Menu */} + + + {/* Main Content */} + {renderLayout({ ...siteDefinition, pages: pagesToRender })} + + {/* Footer */} +
+

+ © {new Date().getFullYear()} {siteDefinition.name}. All rights reserved. +

+ {siteDefinition.description && ( +

+ {siteDefinition.description} +

+ )} +
); } diff --git a/sites/src/types/index.ts b/sites/src/types/index.ts index 2492d0dd..16b4e4e2 100644 --- a/sites/src/types/index.ts +++ b/sites/src/types/index.ts @@ -25,6 +25,7 @@ export interface PageDefinition { type: string; blocks: Block[]; status: string; + order?: number; } export interface Block { diff --git a/sites/src/utils/layoutRenderer.tsx b/sites/src/utils/layoutRenderer.tsx index 2885c724..15ba8d14 100644 --- a/sites/src/utils/layoutRenderer.tsx +++ b/sites/src/utils/layoutRenderer.tsx @@ -56,26 +56,31 @@ export function renderLayout(siteDefinition: SiteDefinition): React.ReactElement * Uses shared DefaultLayout component with fully styled modern design. */ function renderDefaultLayout(siteDefinition: SiteDefinition): React.ReactElement { - // Find home page for hero + // Find home page for hero (only show hero on home page or when showing all pages) const homePage = siteDefinition.pages.find(p => p.slug === 'home'); const heroBlock = homePage?.blocks?.find(b => b.type === 'hero'); - const hero: React.ReactNode = heroBlock ? (renderTemplate(heroBlock) as React.ReactNode) : undefined; + + // Only show hero if we're on home page or showing all pages + const isHomePage = siteDefinition.pages.length === 1 && siteDefinition.pages[0]?.slug === 'home'; + const showHero = isHomePage || (homePage && siteDefinition.pages.length > 1); + const hero: React.ReactNode = (showHero && heroBlock) ? (renderTemplate(heroBlock) as React.ReactNode) : undefined; // Render all pages as sections (excluding hero from home page if it exists) const sections = siteDefinition.pages - .filter((page) => page.status !== 'draft') + .filter((page) => page.status !== 'draft' && page.status !== 'generating') + .sort((a, b) => (a.order || 0) - (b.order || 0)) .map((page) => { // Filter out hero block if it's the home page (already rendered as hero) - const blocksToRender = page.slug === 'home' && heroBlock + const blocksToRender = page.slug === 'home' && heroBlock && showHero ? page.blocks?.filter(b => b.type !== 'hero') || [] : page.blocks || []; return ( -
- {page.slug !== 'home' &&

{page.title}

} +
+ {page.slug !== 'home' &&

{page.title}

} {blocksToRender.length > 0 ? ( blocksToRender.map((block, index) => ( -
+
{renderTemplate(block)}
))