diff --git a/PRODUCTION_DEPLOYMENT.md b/PRODUCTION_DEPLOYMENT.md new file mode 100644 index 0000000..2f8ab13 --- /dev/null +++ b/PRODUCTION_DEPLOYMENT.md @@ -0,0 +1,98 @@ +# Production Deployment Guide + +This guide explains how to deploy PicPeak in production behind a reverse proxy like Traefik. + +## Environment Configuration + +### Frontend Configuration + +For production deployment behind a reverse proxy (Traefik, Nginx, etc.), the frontend should use relative URLs to automatically inherit the protocol (HTTPS) and domain. + +1. Copy the production environment template: + ```bash + cp frontend/.env.production.example frontend/.env.production + ``` + +2. Set the API URL to use relative path: + ```env + # frontend/.env.production + VITE_API_URL=/api + ``` + + This ensures all API calls will use the same domain and protocol as the frontend. + +### Backend Configuration + +Ensure your backend `.env` file has the correct URLs: +```env +# backend/.env +FRONTEND_URL=https://yourdomain.com +ADMIN_URL=https://yourdomain.com +``` + +## Docker Compose Production + +When using Docker Compose in production: + +1. Build with production environment: + ```bash + docker-compose -f docker-compose.prod.yml build --build-arg NODE_ENV=production + ``` + +2. The frontend nginx configuration already includes proper proxy settings for: + - `/api` → Backend API + - `/photos` → Protected photo access + - `/thumbnails` → Thumbnail images + - `/uploads` → Public uploads (logos, favicons) + +## Traefik Configuration + +Example Traefik labels for docker-compose: + +```yaml +services: + frontend: + labels: + - "traefik.enable=true" + - "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)" + - "traefik.http.routers.picpeak.entrypoints=websecure" + - "traefik.http.routers.picpeak.tls.certresolver=letsencrypt" + - "traefik.http.services.picpeak.loadbalancer.server.port=80" +``` + +## Important Notes + +1. **No Hardcoded URLs**: The application uses environment variables with relative URL fallbacks, making it production-ready. + +2. **HTTPS Only**: When `VITE_API_URL=/api`, all requests will use the same protocol as the page (HTTPS in production). + +3. **CORS Configuration**: The backend CORS is configured to accept requests from the URLs specified in `FRONTEND_URL` and `ADMIN_URL`. + +4. **Static Assets**: All static assets (photos, thumbnails, uploads) are served through the nginx proxy, inheriting authentication headers. + +## Verification + +After deployment, verify: + +1. Check browser console for any localhost URLs (there should be none) +2. Verify all API calls use HTTPS +3. Check that images load correctly with authentication +4. Test favicon and logo display + +## Troubleshooting + +If you see console errors about localhost: + +1. Ensure `VITE_API_URL=/api` in frontend environment +2. Clear browser cache +3. Rebuild frontend with production environment: + ```bash + cd frontend + npm run build + ``` + +If images don't load: + +1. Check that nginx proxy locations are configured +2. Verify authentication tokens are being sent +3. Check backend logs for authentication errors \ No newline at end of file diff --git a/frontend/.env.production.example b/frontend/.env.production.example new file mode 100644 index 0000000..11ca0a6 --- /dev/null +++ b/frontend/.env.production.example @@ -0,0 +1,14 @@ +# Production Environment Configuration +# When running behind a reverse proxy like Traefik, use relative URLs + +# Backend API URL +# For production behind reverse proxy, use relative URL: +VITE_API_URL=/api + +# For development or if frontend/backend are on different domains: +# VITE_API_URL=https://api.yourdomain.com + +# Umami Analytics Configuration (optional) +# VITE_UMAMI_URL=https://analytics.yourdomain.com +# VITE_UMAMI_WEBSITE_ID=your-website-id-from-umami +# VITE_UMAMI_SHARE_URL=https://analytics.yourdomain.com/share/your-share-id/photo-sharing \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 9833ab3..fd24354 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -79,6 +79,20 @@ server { proxy_cache_valid 404 1m; } + # Uploads serving proxy (logos, favicons, watermarks) + location /uploads { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Cache uploads + proxy_cache_valid 200 302 7d; + proxy_cache_valid 404 1m; + } + # SPA fallback location / { try_files $uri $uri/ /index.html; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f778c39..e09bb41 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { GlobalThemeProvider } from './components/GlobalThemeProvider'; +import { getApiBaseUrl } from './utils/url'; // Create a client const queryClient = new QueryClient({ @@ -48,7 +49,7 @@ function App() { if (umamiUrl && umamiWebsiteId) { try { // Fetch public settings to check if analytics is enabled - const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`); + const response = await fetch(`${getApiBaseUrl()}/public/settings`); const settings = await response.json(); // Only initialize if analytics is enabled in settings diff --git a/frontend/src/components/MaintenanceMode.tsx b/frontend/src/components/MaintenanceMode.tsx index 7160163..0019824 100644 --- a/frontend/src/components/MaintenanceMode.tsx +++ b/frontend/src/components/MaintenanceMode.tsx @@ -3,6 +3,7 @@ import { AlertTriangle } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { api } from '../config/api'; +import { buildResourceUrl } from '../utils/url'; interface BrandingSettings { branding_company_name?: string; @@ -50,7 +51,7 @@ export const MaintenanceMode: React.FC = () => { src={settings?.branding_logo_url ? (settings.branding_logo_url.startsWith('http') ? settings.branding_logo_url - : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_logo_url}`) + : buildResourceUrl(settings.branding_logo_url)) : '/picpeak-logo-transparent.png' } alt={settings?.branding_company_name || 'PicPeak'} diff --git a/frontend/src/components/admin/ThemeCustomizer.tsx b/frontend/src/components/admin/ThemeCustomizer.tsx index f699def..3966ecf 100644 --- a/frontend/src/components/admin/ThemeCustomizer.tsx +++ b/frontend/src/components/admin/ThemeCustomizer.tsx @@ -4,6 +4,7 @@ import { Button, Card, Input } from '../common'; import { GALLERY_THEME_PRESETS, type ThemeConfig } from '../../contexts/ThemeContext'; import { settingsService } from '../../services/settings.service'; import { toast } from 'react-toastify'; +import { buildResourceUrl } from '../../utils/url'; interface ThemeCustomizerProps { value: ThemeConfig; @@ -269,7 +270,7 @@ export const ThemeCustomizer: React.FC = ({
{localTheme.logoUrl && ( Custom logo diff --git a/frontend/src/components/common/AuthenticatedImage.tsx b/frontend/src/components/common/AuthenticatedImage.tsx index f6e7328..372da01 100644 --- a/frontend/src/components/common/AuthenticatedImage.tsx +++ b/frontend/src/components/common/AuthenticatedImage.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { getAuthToken } from '../../config/api'; +import { buildResourceUrl } from '../../utils/url'; interface AuthenticatedImageProps extends React.ImgHTMLAttributes { src: string; @@ -60,9 +61,8 @@ export const AuthenticatedImage: React.FC = ({ // Use the src as-is since it should already be the correct endpoint let imageUrl = src; - // Prepend API URL for absolute paths - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001'; - const fullImageUrl = imageUrl.startsWith('/') ? `${apiUrl}${imageUrl}` : imageUrl; + // Build full URL for the image + const fullImageUrl = imageUrl.startsWith('/') ? buildResourceUrl(imageUrl) : imageUrl; console.log('Fetching authenticated image:', fullImageUrl); const response = await fetch(fullImageUrl, { diff --git a/frontend/src/components/common/DynamicFavicon.tsx b/frontend/src/components/common/DynamicFavicon.tsx index 628f8dc..40d9068 100644 --- a/frontend/src/components/common/DynamicFavicon.tsx +++ b/frontend/src/components/common/DynamicFavicon.tsx @@ -1,12 +1,13 @@ import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { getApiBaseUrl, buildResourceUrl } from '../../utils/url'; export const DynamicFavicon: React.FC = () => { const { data: settings } = useQuery({ queryKey: ['public-settings'], queryFn: async () => { try { - const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`); + const response = await fetch(`${getApiBaseUrl()}/public/settings`); if (response.ok) { return response.json(); } @@ -30,7 +31,7 @@ export const DynamicFavicon: React.FC = () => { link.type = 'image/png'; link.href = settings.branding_favicon_url.startsWith('http') ? settings.branding_favicon_url - : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_favicon_url}`; + : buildResourceUrl(settings.branding_favicon_url); document.head.appendChild(link); } diff --git a/frontend/src/components/common/ReCaptcha.tsx b/frontend/src/components/common/ReCaptcha.tsx index d765a71..f65ea48 100644 --- a/frontend/src/components/common/ReCaptcha.tsx +++ b/frontend/src/components/common/ReCaptcha.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import ReCAPTCHA from 'react-google-recaptcha'; import { useQuery } from '@tanstack/react-query'; +import { getApiBaseUrl } from '../../utils/url'; interface ReCaptchaProps { onChange: (token: string | null) => void; @@ -20,7 +21,7 @@ export const ReCaptcha: React.FC = ({ const { data: settings } = useQuery({ queryKey: ['public-settings'], queryFn: async () => { - const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`); + const response = await fetch(`${getApiBaseUrl()}/public/settings`); return response.json(); }, staleTime: 5 * 60 * 1000, // Cache for 5 minutes diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx index a41f9b0..8cefc1a 100644 --- a/frontend/src/components/gallery/GalleryLayout.tsx +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -7,6 +7,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button } from '../common'; import { DynamicFavicon } from '../common/DynamicFavicon'; import { useTheme } from '../../contexts/ThemeContext'; +import { buildResourceUrl } from '../../utils/url'; interface GalleryLayoutProps { event: { @@ -122,7 +123,7 @@ export const GalleryLayout: React.FC = ({
{brandingSettings?.company_name = ({
{brandingSettings?.company_name = ({
Event logo void) // Create axios instance export const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001', + baseURL: import.meta.env.VITE_API_URL || '/api', headers: { 'Content-Type': 'application/json', }, diff --git a/frontend/src/contexts/MaintenanceContext.tsx b/frontend/src/contexts/MaintenanceContext.tsx index 3ac9aff..cba3bab 100644 --- a/frontend/src/contexts/MaintenanceContext.tsx +++ b/frontend/src/contexts/MaintenanceContext.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { useQuery } from '@tanstack/react-query'; import { setMaintenanceModeCallback } from '../config/api'; +import { getApiBaseUrl } from '../utils/url'; interface MaintenanceContextType { isMaintenanceMode: boolean; @@ -29,7 +30,7 @@ export const MaintenanceProvider: React.FC = ({ childr queryKey: ['public-settings-maintenance'], queryFn: async () => { try { - const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`); + const response = await fetch(`${getApiBaseUrl()}/public/settings`); if (response.status === 503) { setIsMaintenanceMode(true); return null; diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 80471e0..b39dfc3 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -13,6 +13,7 @@ import { GalleryView } from '../components/gallery'; import { analyticsService } from '../services/analytics.service'; import { api } from '../config/api'; import { GALLERY_THEME_PRESETS } from '../types/theme.types'; +import { buildResourceUrl } from '../utils/url'; export const GalleryPage: React.FC = () => { const { slug, token } = useParams<{ slug: string; token?: string }>(); @@ -164,7 +165,7 @@ export const GalleryPage: React.FC = () => { {settingsData?.branding_logo_url && (
{settingsData.branding_company_name @@ -220,7 +221,7 @@ export const GalleryPage: React.FC = () => { {settingsData?.branding_logo_url && (
{settingsData.branding_company_name @@ -282,7 +283,7 @@ export const GalleryPage: React.FC = () => {
{settingsData?.branding_company_name { const { t } = useTranslation(); @@ -279,7 +280,7 @@ export const BrandingPage: React.FC = () => { {brandingSettings.favicon_url && (
Current favicon @@ -344,7 +345,7 @@ export const BrandingPage: React.FC = () => { {brandingSettings.watermark_logo_url && (
Current watermark diff --git a/frontend/src/utils/url.ts b/frontend/src/utils/url.ts new file mode 100644 index 0000000..52af87a --- /dev/null +++ b/frontend/src/utils/url.ts @@ -0,0 +1,46 @@ +/** + * Utility functions for URL handling in production environments + */ + +/** + * Get the base API URL, preferring relative URLs for production + * @returns The API base URL + */ +export const getApiBaseUrl = (): string => { + // If VITE_API_URL is explicitly set, use it + if (import.meta.env.VITE_API_URL && import.meta.env.VITE_API_URL !== '/api') { + return import.meta.env.VITE_API_URL; + } + + // In production, use relative URL + return '/api'; +}; + +/** + * Build a full URL for resources (images, files, etc.) + * In production, this will use the current origin + * @param path - The resource path + * @returns The full URL + */ +export const buildResourceUrl = (path: string): string => { + // Remove leading slash if present + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + + // If we have an explicit API URL that's not relative, use it + const apiUrl = import.meta.env.VITE_API_URL; + if (apiUrl && apiUrl !== '/api' && apiUrl.startsWith('http')) { + const baseUrl = apiUrl.replace(/\/api\/?$/, ''); // Remove /api suffix if present + return `${baseUrl}/${cleanPath}`; + } + + // In production (relative API), use current origin + return `${window.location.origin}/${cleanPath}`; +}; + +/** + * Check if we're in production mode (using relative URLs) + * @returns True if in production mode + */ +export const isProductionMode = (): boolean => { + return !import.meta.env.VITE_API_URL || import.meta.env.VITE_API_URL === '/api'; +}; \ No newline at end of file