From a2824b2132fa3041d2e8beb2f7e98651b3d5ec30 Mon Sep 17 00:00:00 2001 From: Your Name <119736744+aborayan2022@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:26:32 +0200 Subject: [PATCH] feat: Implement CSRF protection and PostgreSQL support - Added CSRF protection mechanism in the backend with utility functions for token management. - Introduced a new CSRF route to fetch the active CSRF token for SPA bootstrap flows. - Updated the auth routes to validate CSRF tokens on sensitive operations. - Configured PostgreSQL as a database option in the environment settings and Docker Compose. - Created a new SQLite configuration file for local development. - Enhanced the API client to automatically attach CSRF tokens to requests. - Updated various frontend components to utilize the new site origin utility for SEO purposes. - Modified Nginx configuration to improve redirection and SEO headers. - Added tests for CSRF token handling in the authentication routes. --- .devdbrc | 6 + .env.example | 3 + backend/app/__init__.py | 26 +- backend/app/routes/auth.py | 8 + backend/app/utils/csrf.py | 77 +++++ backend/tests/conftest.py | 32 ++ backend/tests/test_auth.py | 17 ++ docker-compose.prod.yml | 26 ++ fixed-ssl.md | 273 ------------------ frontend/index.html | 1 + frontend/src/components/seo/SEOHead.tsx | 4 +- .../src/components/seo/ToolLandingPage.tsx | 4 +- frontend/src/pages/AboutPage.tsx | 5 +- frontend/src/pages/BlogPage.tsx | 5 +- frontend/src/pages/BlogPostPage.tsx | 9 +- frontend/src/pages/ContactPage.tsx | 16 +- frontend/src/pages/DevelopersPage.tsx | 6 +- frontend/src/pages/HomePage.tsx | 9 +- frontend/src/pages/PricingPage.tsx | 14 +- frontend/src/pages/PrivacyPage.tsx | 5 +- frontend/src/pages/TermsPage.tsx | 5 +- frontend/src/services/api.ts | 80 ++++- frontend/src/utils/seo.ts | 14 + nginx/nginx.prod.conf | 6 +- 24 files changed, 332 insertions(+), 319 deletions(-) create mode 100644 .devdbrc create mode 100644 backend/app/utils/csrf.py delete mode 100644 fixed-ssl.md diff --git a/.devdbrc b/.devdbrc new file mode 100644 index 0000000..1365d3e --- /dev/null +++ b/.devdbrc @@ -0,0 +1,6 @@ +[ + { + "type": "sqlite", + "path": "c:\\xampp\\htdocs\\SaaS-PDF\\backend\\data\\saas_pdf.db" + } +] \ No newline at end of file diff --git a/.env.example b/.env.example index 6e812b7..dc84f41 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,9 @@ SENTRY_ENVIRONMENT=production # PostgreSQL (production) — leave empty to use SQLite DATABASE_URL= +POSTGRES_DB=dociva +POSTGRES_USER=dociva +POSTGRES_PASSWORD=replace-with-strong-postgres-password # Frontend VITE_SITE_DOMAIN=https://dociva.io diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 0192858..e4d682d 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,7 +1,7 @@ """Flask Application Factory.""" import os -from flask import Flask +from flask import Flask, jsonify from config import config from app.extensions import cors, limiter, talisman, init_celery @@ -11,6 +11,7 @@ from app.services.ai_cost_service import init_ai_cost_db from app.services.site_assistant_service import init_site_assistant_db from app.services.contact_service import init_contact_db from app.services.stripe_service import init_stripe_db +from app.utils.csrf import CSRFError, apply_csrf_cookie, should_enforce_csrf, validate_csrf_request def _init_sentry(app): @@ -48,9 +49,10 @@ def create_app(config_name=None): # Create upload/output/database directories os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) os.makedirs(app.config["OUTPUT_FOLDER"], exist_ok=True) - db_dir = os.path.dirname(app.config["DATABASE_PATH"]) - if db_dir: - os.makedirs(db_dir, exist_ok=True) + if not app.config.get("DATABASE_URL"): + db_dir = os.path.dirname(app.config["DATABASE_PATH"]) + if db_dir: + os.makedirs(db_dir, exist_ok=True) # Initialize extensions cors.init_app( @@ -97,6 +99,22 @@ def create_app(config_name=None): force_https=config_name == "production", ) + @app.before_request + def enforce_csrf(): + if not should_enforce_csrf(): + return None + + try: + validate_csrf_request() + except CSRFError as exc: + return jsonify({"error": exc.message}), exc.status_code + + return None + + @app.after_request + def sync_csrf_cookie(response): + return apply_csrf_cookie(response) + # Initialize Celery init_celery(app) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 4f5eb0d..34cfd0b 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -19,6 +19,7 @@ from app.utils.auth import ( login_user_session, logout_user_session, ) +from app.utils.csrf import get_or_create_csrf_token auth_bp = Blueprint("auth", __name__) @@ -105,6 +106,13 @@ def me_route(): return jsonify({"authenticated": True, "user": user}), 200 +@auth_bp.route("/csrf", methods=["GET"]) +@limiter.limit("240/hour") +def csrf_route(): + """Return the active CSRF token for SPA bootstrap flows.""" + return jsonify({"csrf_token": get_or_create_csrf_token()}), 200 + + @auth_bp.route("/forgot-password", methods=["POST"]) @limiter.limit("5/hour") def forgot_password_route(): diff --git a/backend/app/utils/csrf.py b/backend/app/utils/csrf.py new file mode 100644 index 0000000..9652f4f --- /dev/null +++ b/backend/app/utils/csrf.py @@ -0,0 +1,77 @@ +"""Lightweight CSRF protection for browser-originated session requests.""" +import secrets + +from flask import current_app, request, session + +CSRF_SESSION_KEY = "csrf_token" +CSRF_COOKIE_NAME = "csrf_token" +CSRF_HEADER_NAME = "X-CSRF-Token" +_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} +_EXEMPT_PATHS = { + "/api/stripe/webhook", +} + + +class CSRFError(Exception): + """Raised when CSRF validation fails.""" + + def __init__(self, message: str = "Invalid CSRF token.", status_code: int = 403): + super().__init__(message) + self.message = message + self.status_code = status_code + + +def get_or_create_csrf_token() -> str: + """Return the current CSRF token, creating one when missing.""" + token = session.get(CSRF_SESSION_KEY) + if not isinstance(token, str) or not token: + token = secrets.token_urlsafe(32) + session[CSRF_SESSION_KEY] = token + return token + + +def should_enforce_csrf() -> bool: + """Return whether the current request should pass CSRF validation.""" + if request.method.upper() in _SAFE_METHODS: + return False + + if not request.path.startswith("/api/"): + return False + + if request.path in _EXEMPT_PATHS: + return False + + if request.headers.get("X-API-Key", "").strip(): + return False + + return True + + +def validate_csrf_request(): + """Validate the current request against the active browser CSRF token.""" + session_token = session.get(CSRF_SESSION_KEY) + cookie_token = request.cookies.get(CSRF_COOKIE_NAME, "") + header_token = request.headers.get(CSRF_HEADER_NAME, "").strip() + + if not isinstance(session_token, str) or not session_token: + raise CSRFError("CSRF session token is missing.") + + if not cookie_token or cookie_token != session_token: + raise CSRFError("CSRF cookie token is missing or invalid.") + + if not header_token or header_token != session_token: + raise CSRFError("CSRF header token is missing or invalid.") + + +def apply_csrf_cookie(response): + """Persist the active CSRF token into a readable cookie for the SPA.""" + token = get_or_create_csrf_token() + response.set_cookie( + CSRF_COOKIE_NAME, + token, + secure=bool(current_app.config.get("SESSION_COOKIE_SECURE", False)), + httponly=False, + samesite=current_app.config.get("SESSION_COOKIE_SAMESITE", "Lax"), + path="/", + ) + return response diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a36711b..642ec68 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -11,6 +11,37 @@ from app.services.ai_cost_service import init_ai_cost_db from app.services.site_assistant_service import init_site_assistant_db from app.services.contact_service import init_contact_db from app.services.stripe_service import init_stripe_db +from flask.testing import FlaskClient +from werkzeug.datastructures import Headers + + +class CSRFTestClient(FlaskClient): + """Flask test client that auto-injects the SPA CSRF header for browser requests.""" + + def open(self, *args, **kwargs): + path = args[0] if args and isinstance(args[0], str) else kwargs.get("path", "") + method = str(kwargs.get("method", "GET")).upper() + headers = Headers(kwargs.pop("headers", {})) + + should_add_csrf = ( + method in {"POST", "PUT", "PATCH", "DELETE"} + and path != "/api/stripe/webhook" + and "X-API-Key" not in headers + and "X-CSRF-Token" not in headers + ) + + if should_add_csrf: + token_cookie = self.get_cookie("csrf_token") + if token_cookie is None: + FlaskClient.open(self, "/api/auth/csrf", method="GET") + token_cookie = self.get_cookie("csrf_token") + if token_cookie is not None: + headers["X-CSRF-Token"] = token_cookie.value + + if headers: + kwargs["headers"] = headers + + return super().open(*args, **kwargs) @pytest.fixture @@ -26,6 +57,7 @@ def app(): os.environ['OUTPUT_FOLDER'] = output_folder app = create_app('testing') + app.test_client_class = CSRFTestClient app.config.update({ 'TESTING': True, 'UPLOAD_FOLDER': upload_folder, diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index d67d7b3..df5ba86 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -2,6 +2,13 @@ class TestAuthRoutes: + def test_csrf_bootstrap_returns_token(self, client): + response = client.get('/api/auth/csrf') + + assert response.status_code == 200 + assert isinstance(response.get_json()['csrf_token'], str) + assert response.get_json()['csrf_token'] + def test_register_success(self, client): response = client.post( '/api/auth/register', @@ -77,3 +84,13 @@ class TestAuthRoutes: assert response.status_code == 200 assert response.get_json() == {'authenticated': False, 'user': None} + + def test_register_rejects_invalid_csrf_token(self, client): + response = client.post( + '/api/auth/register', + json={'email': 'csrf@example.com', 'password': 'secretpass123'}, + headers={'X-CSRF-Token': 'invalid-token'}, + ) + + assert response.status_code == 403 + assert 'csrf' in response.get_json()['error'].lower() diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c1262a2..d1c0070 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,4 +1,20 @@ services: + # --- PostgreSQL (launch-ready cutover target) --- + postgres: + image: postgres:16-alpine + environment: + - POSTGRES_DB=${POSTGRES_DB:-dociva} + - POSTGRES_USER=${POSTGRES_USER:-dociva} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-change-me} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dociva} -d ${POSTGRES_DB:-dociva}"] + interval: 10s + timeout: 5s + retries: 5 + restart: always + # --- Redis --- redis: image: redis:7-alpine @@ -23,11 +39,14 @@ services: - REDIS_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/1 + - DATABASE_URL=${DATABASE_URL:-} volumes: - upload_data:/tmp/uploads - output_data:/tmp/outputs - db_data:/app/data depends_on: + postgres: + condition: service_healthy redis: condition: service_healthy restart: always @@ -49,11 +68,14 @@ services: - REDIS_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/1 + - DATABASE_URL=${DATABASE_URL:-} volumes: - upload_data:/tmp/uploads - output_data:/tmp/outputs - db_data:/app/data depends_on: + postgres: + condition: service_healthy redis: condition: service_healthy restart: always @@ -73,9 +95,12 @@ services: - REDIS_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/1 + - DATABASE_URL=${DATABASE_URL:-} volumes: - db_data:/app/data depends_on: + postgres: + condition: service_healthy redis: condition: service_healthy restart: always @@ -120,6 +145,7 @@ services: - frontend_build:/app/dist volumes: + postgres_data: redis_data: upload_data: output_data: diff --git a/fixed-ssl.md b/fixed-ssl.md deleted file mode 100644 index d5a5e96..0000000 --- a/fixed-ssl.md +++ /dev/null @@ -1,273 +0,0 @@ -سؤالك ممتاز—وده بالضبط الفرق بين **حل “يشتغل وخلاص”** وحل **Production حقيقي**. - -خليني أشرح لك الأول **ليه الطريقة الأولى (certbot --nginx) مش مناسبة** عندك، وبعدين أديك **الحل الصحيح الجاهز (Nginx داخل Docker + SSL + Auto renew)** copy/paste. - ---- - -# لماذا `certbot --nginx` مش مناسب عندك؟ - -أنت عندك: - -```text -Nginx شغال داخل Docker (saas-pdf-nginx-1) -``` - -لكن الأمر: - -```bash -certbot --nginx -``` - -بيفترض إن: - -```text -Nginx مثبت على السيرفر (host) -``` - -📌 النتيجة: - -* Certbot حاول يتحكم في Nginx الخارجي -* لكن البورت 80 مستخدم بالفعل بواسطة Docker -* حصل الخطأ: `Address already in use` - ---- - -# هل الحل اللي عملناه “مؤقت”؟ - -لا 👇 - -## عندنا 3 مستويات حلول: - -### 🥉 حل بدائي - -تشغل Certbot خارج Docker → كل مرة توقف Nginx -❌ غير عملي - ---- - -### 🥈 حل متوسط - -Certbot standalone + manual config -✔ شغال -❌ لكن renewal مش أوتوماتيك بسهولة - ---- - -### 🥇 الحل الاحترافي (اللي هنطبقه الآن) - -```text -Nginx + Certbot داخل Docker -``` - -✔ Auto renew -✔ scalable -✔ نفس طريقة الشركات الكبيرة - ---- - -# المشاكل لو ما عملتش الحل الصحيح - -لو كملت بالحل القديم: - -* ❌ SSL هينتهي كل 90 يوم -* ❌ الموقع هيقع فجأة -* ❌ Google هيقلل ترتيبك -* ❌ AdSense ممكن يرفضك -* ❌ صعب التوسعة لاحقًا - ---- - -# الآن الحل الاحترافي (جاهز للتطبيق) - -هنعمل 3 حاجات: - -```text -1. nginx config احترافي -2. docker-compose فيه SSL -3. auto renew -``` - ---- - -# 1️⃣ تعديل nginx config - -ادخل: - -```bash -cd ~/SaaS-PDF/nginx -nano default.conf -``` - -واستبدل كله بهذا: - -```nginx -server { - listen 80; - server_name dociva.io www.dociva.io; - - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - - location / { - return 301 https://$host$request_uri; - } -} - -server { - listen 443 ssl; - server_name dociva.io www.dociva.io; - - ssl_certificate /etc/letsencrypt/live/dociva.io/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/dociva.io/privkey.pem; - - location / { - proxy_pass http://frontend:5173; - } -} -``` - ---- - -# 2️⃣ تعديل docker-compose.yml - -افتح: - -```bash -nano docker-compose.yml -``` - -وابحث عن nginx وعدله: - -```yaml -nginx: - image: nginx:alpine - ports: - - "80:80" - - "443:443" - volumes: - - ./nginx:/etc/nginx/conf.d - - ./certbot/www:/var/www/certbot - - ./certbot/conf:/etc/letsencrypt - depends_on: - - frontend -``` - ---- - -# 3️⃣ إضافة certbot container - -في نفس الملف أضف: - -```yaml -certbot: - image: certbot/certbot - volumes: - - ./certbot/www:/var/www/certbot - - ./certbot/conf:/etc/letsencrypt -``` - ---- - -# 4️⃣ تشغيل المشروع - -```bash -docker compose down -docker compose up -d -``` - ---- - -# 5️⃣ إصدار الشهادة (مرة واحدة) - -```bash -docker compose run --rm certbot certonly \ ---webroot \ ---webroot-path=/var/www/certbot \ --d dociva.io -d www.dociva.io \ ---email your@email.com \ ---agree-tos --no-eff-email -``` - ---- - -# 6️⃣ إعادة تشغيل Nginx - -```bash -docker compose restart nginx -``` - ---- - -# 7️⃣ اختبار - -```text -https://dociva.io -``` - -🔥 المفروض يشتغل - ---- - -# 8️⃣ auto renew (مهم جداً) - -أضف cron job: - -```bash -crontab -e -``` - -وأضف: - -```bash -0 3 * * * docker compose run --rm certbot renew && docker compose restart nginx -``` - ---- - -# النتيجة النهائية - -✔ HTTPS شغال -✔ Auto renew -✔ Production ready -✔ مناسب لـ SEO + AdSense - ---- - -# خلاصة بسيطة - -| الطريقة | هل مناسبة؟ | -| ---------------- | ---------- | -| certbot --nginx | ❌ لا | -| standalone | ⚠️ مؤقت | -| Docker SSL setup | ✅ الأفضل | - ---- - -# رأيي كمستشار - -أنت حالياً وصلت لمستوى: - -```text -Junior → Mid-level DevOps 🚀 -``` - -ولو كملت بالشكل ده مشروعك فعلاً ممكن يتحول لمنتج حقيقي. - ---- - -# الخطوة التالية بعد SSL - -بعد ما نخلص: - -1. Google Search Console -2. Sitemap -3. SEO pages -4. Analytics - ---- - -لو حابب، الخطوة الجاية أعملك: - -👉 **إعداد SEO كامل يجلب أول 10,000 زيارة** -بطريقة عملية مش كلام نظري. diff --git a/frontend/index.html b/frontend/index.html index 834fab1..2afc173 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,7 @@ + diff --git a/frontend/src/components/seo/SEOHead.tsx b/frontend/src/components/seo/SEOHead.tsx index c57a6c3..489a62c 100644 --- a/frontend/src/components/seo/SEOHead.tsx +++ b/frontend/src/components/seo/SEOHead.tsx @@ -1,6 +1,6 @@ import { Helmet } from 'react-helmet-async'; import { useTranslation } from 'react-i18next'; -import { buildLanguageAlternates, buildSocialImageUrl, getOgLocale } from '@/utils/seo'; +import { buildLanguageAlternates, buildSocialImageUrl, getOgLocale, getSiteOrigin } from '@/utils/seo'; const SITE_NAME = 'Dociva'; @@ -26,7 +26,7 @@ interface SEOHeadProps { */ export default function SEOHead({ title, description, path, type = 'website', jsonLd }: SEOHeadProps) { const { i18n } = useTranslation(); - const origin = typeof window !== 'undefined' ? window.location.origin : ''; + const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const canonicalUrl = `${origin}${path}`; const socialImageUrl = buildSocialImageUrl(origin); const fullTitle = `${title} — ${SITE_NAME}`; diff --git a/frontend/src/components/seo/ToolLandingPage.tsx b/frontend/src/components/seo/ToolLandingPage.tsx index 0a27d61..97bf612 100644 --- a/frontend/src/components/seo/ToolLandingPage.tsx +++ b/frontend/src/components/seo/ToolLandingPage.tsx @@ -2,7 +2,7 @@ import { Helmet } from 'react-helmet-async'; import { useTranslation } from 'react-i18next'; import { CheckCircle } from 'lucide-react'; import { getToolSEO } from '@/config/seoData'; -import { buildLanguageAlternates, buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale } from '@/utils/seo'; +import { buildLanguageAlternates, buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale, getSiteOrigin } from '@/utils/seo'; import FAQSection from './FAQSection'; import RelatedTools from './RelatedTools'; import ToolRating from '@/components/shared/ToolRating'; @@ -37,7 +37,7 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps const toolTitle = t(`tools.${seo.i18nKey}.title`); const toolDesc = t(`tools.${seo.i18nKey}.description`); - const origin = typeof window !== 'undefined' ? window.location.origin : ''; + const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const path = `/tools/${slug}`; const canonicalUrl = `${origin}${path}`; const socialImageUrl = buildSocialImageUrl(origin); diff --git a/frontend/src/pages/AboutPage.tsx b/frontend/src/pages/AboutPage.tsx index e9a8f07..d34594f 100644 --- a/frontend/src/pages/AboutPage.tsx +++ b/frontend/src/pages/AboutPage.tsx @@ -1,12 +1,13 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import SEOHead from '@/components/seo/SEOHead'; -import { generateWebPage } from '@/utils/seo'; +import { generateWebPage, getSiteOrigin } from '@/utils/seo'; import { Target, Cpu, Shield, Lock, Wrench } from 'lucide-react'; import { FILE_RETENTION_MINUTES } from '@/config/toolLimits'; export default function AboutPage() { const { t } = useTranslation(); + const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const toolCategories = t('pages.about.toolCategories', { returnObjects: true }) as string[]; return ( @@ -18,7 +19,7 @@ export default function AboutPage() { jsonLd={generateWebPage({ name: t('pages.about.title'), description: t('pages.about.metaDescription'), - url: `${window.location.origin}/about`, + url: `${siteOrigin}/about`, })} /> diff --git a/frontend/src/pages/BlogPage.tsx b/frontend/src/pages/BlogPage.tsx index 102e511..a098367 100644 --- a/frontend/src/pages/BlogPage.tsx +++ b/frontend/src/pages/BlogPage.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom'; import SEOHead from '@/components/seo/SEOHead'; -import { generateWebPage } from '@/utils/seo'; +import { generateWebPage, getSiteOrigin } from '@/utils/seo'; import { BookOpen, Calendar, ArrowRight, Search, X } from 'lucide-react'; import { BLOG_ARTICLES, @@ -13,6 +13,7 @@ import { export default function BlogPage() { const { t, i18n } = useTranslation(); + const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const [searchParams, setSearchParams] = useSearchParams(); const query = searchParams.get('q') || ''; const deferredQuery = useDeferredValue(query.trim().toLowerCase()); @@ -46,7 +47,7 @@ export default function BlogPage() { jsonLd={generateWebPage({ name: t('pages.blog.metaTitle'), description: t('pages.blog.metaDescription'), - url: `${window.location.origin}/blog`, + url: `${siteOrigin}/blog`, })} /> diff --git a/frontend/src/pages/BlogPostPage.tsx b/frontend/src/pages/BlogPostPage.tsx index fa6f8c6..1616add 100644 --- a/frontend/src/pages/BlogPostPage.tsx +++ b/frontend/src/pages/BlogPostPage.tsx @@ -9,7 +9,7 @@ import { getLocalizedBlogArticle, normalizeBlogLocale, } from '@/content/blogArticles'; -import { generateBlogPosting, generateBreadcrumbs, generateWebPage } from '@/utils/seo'; +import { generateBlogPosting, generateBreadcrumbs, generateWebPage, getSiteOrigin } from '@/utils/seo'; import NotFoundPage from './NotFoundPage'; export default function BlogPostPage() { @@ -17,6 +17,7 @@ export default function BlogPostPage() { const { t, i18n } = useTranslation(); const locale = normalizeBlogLocale(i18n.language); const article = slug ? getBlogArticleBySlug(slug) : undefined; + const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); if (!article) { return ; @@ -24,11 +25,11 @@ export default function BlogPostPage() { const localizedArticle = getLocalizedBlogArticle(article, locale); const path = `/blog/${localizedArticle.slug}`; - const url = `${window.location.origin}${path}`; + const url = `${siteOrigin}${path}`; const breadcrumbs = generateBreadcrumbs([ - { name: t('common.home'), url: window.location.origin }, - { name: t('common.blog'), url: `${window.location.origin}/blog` }, + { name: t('common.home'), url: siteOrigin }, + { name: t('common.blog'), url: `${siteOrigin}/blog` }, { name: localizedArticle.title, url }, ]); diff --git a/frontend/src/pages/ContactPage.tsx b/frontend/src/pages/ContactPage.tsx index c82e7a6..de8fbb7 100644 --- a/frontend/src/pages/ContactPage.tsx +++ b/frontend/src/pages/ContactPage.tsx @@ -3,16 +3,18 @@ import { useTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet-async'; import { Mail, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; import SEOHead from '@/components/seo/SEOHead'; -import { generateWebPage } from '@/utils/seo'; -import axios from 'axios'; +import { generateWebPage, getSiteOrigin } from '@/utils/seo'; +import { getApiClient } from '@/services/api'; const CONTACT_EMAIL = 'support@dociva.io'; const API_BASE = import.meta.env.VITE_API_URL || ''; +const api = getApiClient(); type Category = 'general' | 'bug' | 'feature'; export default function ContactPage() { const { t } = useTranslation(); + const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const [category, setCategory] = useState('general'); const [submitted, setSubmitted] = useState(false); const [loading, setLoading] = useState(false); @@ -29,7 +31,7 @@ export default function ContactPage() { const data = new FormData(form); try { - await axios.post(`${API_BASE}/api/contact/submit`, { + await api.post(`${API_BASE}/contact/submit`, { name: data.get('name'), email: data.get('email'), category, @@ -38,10 +40,10 @@ export default function ContactPage() { }); setSubmitted(true); } catch (err: unknown) { - if (axios.isAxiosError(err) && err.response?.data?.error) { - setError(err.response.data.error); + if (err instanceof Error) { + setError(err.message); } else { - setError(t('pages.contact.errorMessage', 'Failed to send message. Please try again.')); + setError(err.response.data.error); } } finally { setLoading(false); @@ -79,7 +81,7 @@ export default function ContactPage() { jsonLd={generateWebPage({ name: t('pages.contact.title'), description: t('pages.contact.metaDescription'), - url: `${window.location.origin}/contact`, + url: `${siteOrigin}/contact`, })} /> diff --git a/frontend/src/pages/DevelopersPage.tsx b/frontend/src/pages/DevelopersPage.tsx index f6ed8c8..5299e20 100644 --- a/frontend/src/pages/DevelopersPage.tsx +++ b/frontend/src/pages/DevelopersPage.tsx @@ -1,6 +1,6 @@ import SEOHead from '@/components/seo/SEOHead'; import SocialProofStrip from '@/components/shared/SocialProofStrip'; -import { generateWebPage } from '@/utils/seo'; +import { generateWebPage, getSiteOrigin } from '@/utils/seo'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Code2, KeyRound, Rocket, Workflow } from 'lucide-react'; @@ -24,7 +24,7 @@ const ENDPOINT_GROUPS = [ export default function DevelopersPage() { const { t } = useTranslation(); - const origin = typeof window !== 'undefined' ? window.location.origin : 'https://dociva.io'; + const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const curlUpload = `curl -X POST ${origin}/api/v1/convert/pdf-to-word \\ -H "X-API-Key: spdf_your_api_key" \\ -F "file=@./sample.pdf"`; @@ -40,7 +40,7 @@ export default function DevelopersPage() { jsonLd={generateWebPage({ name: t('pages.developers.title'), description: t('pages.developers.metaDescription'), - url: `${window.location.origin}/developers`, + url: `${origin}/developers`, })} /> diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index ee5afd4..1351cb1 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -2,7 +2,7 @@ import { useDeferredValue } from 'react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; import SEOHead from '@/components/seo/SEOHead'; -import { generateOrganization } from '@/utils/seo'; +import { generateOrganization, getSiteOrigin } from '@/utils/seo'; import { FileText, FileOutput, @@ -86,6 +86,7 @@ const otherTools: ToolInfo[] = [ export default function HomePage() { const { t } = useTranslation(); + const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const [searchParams, setSearchParams] = useSearchParams(); const query = searchParams.get('q') || ''; const deferredQuery = useDeferredValue(query.trim().toLowerCase()); @@ -123,15 +124,15 @@ export default function HomePage() { '@context': 'https://schema.org', '@type': 'WebSite', name: t('common.appName'), - url: window.location.origin, + url: siteOrigin, description: t('home.heroSub'), potentialAction: { '@type': 'SearchAction', - target: `${window.location.origin}/?q={search_term_string}`, + target: `${siteOrigin}/?q={search_term_string}`, 'query-input': 'required name=search_term_string', }, }, - generateOrganization(window.location.origin), + generateOrganization(siteOrigin), ]} /> diff --git a/frontend/src/pages/PricingPage.tsx b/frontend/src/pages/PricingPage.tsx index eb602fc..eeb3465 100644 --- a/frontend/src/pages/PricingPage.tsx +++ b/frontend/src/pages/PricingPage.tsx @@ -2,13 +2,14 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import SEOHead from '@/components/seo/SEOHead'; -import { generateWebPage } from '@/utils/seo'; +import { generateWebPage, getSiteOrigin } from '@/utils/seo'; import { Check, X, Zap, Crown, Loader2 } from 'lucide-react'; -import axios from 'axios'; import { useAuthStore } from '@/stores/authStore'; import SocialProofStrip from '@/components/shared/SocialProofStrip'; +import { getApiClient } from '@/services/api'; const API_BASE = import.meta.env.VITE_API_URL || ''; +const api = getApiClient(); interface PlanFeature { key: string; @@ -31,6 +32,7 @@ const FEATURES: PlanFeature[] = [ export default function PricingPage() { const { t } = useTranslation(); + const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const user = useAuthStore((s) => s.user); const [loading, setLoading] = useState(false); @@ -41,11 +43,7 @@ export default function PricingPage() { } setLoading(true); try { - const { data } = await axios.post( - `${API_BASE}/api/stripe/create-checkout-session`, - { billing }, - { withCredentials: true }, - ); + const { data } = await api.post(`${API_BASE}/stripe/create-checkout-session`, { billing }); if (data.url) window.location.href = data.url; } catch { // Stripe not configured yet — show message @@ -70,7 +68,7 @@ export default function PricingPage() { jsonLd={generateWebPage({ name: t('pages.pricing.title', 'Pricing'), description: t('pages.pricing.metaDescription', 'Compare Free and Pro plans for Dociva.'), - url: `${window.location.origin}/pricing`, + url: `${siteOrigin}/pricing`, })} /> diff --git a/frontend/src/pages/PrivacyPage.tsx b/frontend/src/pages/PrivacyPage.tsx index 058c3b4..a54bd90 100644 --- a/frontend/src/pages/PrivacyPage.tsx +++ b/frontend/src/pages/PrivacyPage.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; import SEOHead from '@/components/seo/SEOHead'; -import { generateWebPage } from '@/utils/seo'; +import { generateWebPage, getSiteOrigin } from '@/utils/seo'; import { FILE_RETENTION_MINUTES } from '@/config/toolLimits'; const LAST_UPDATED = '2026-03-06'; @@ -8,6 +8,7 @@ const CONTACT_EMAIL = 'support@dociva.io'; export default function PrivacyPage() { const { t } = useTranslation(); + const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const fileItems = t('pages.privacy.fileHandlingItems', { minutes: FILE_RETENTION_MINUTES, returnObjects: true }) as string[]; const thirdPartyItems = t('pages.privacy.thirdPartyItems', { returnObjects: true }) as string[]; @@ -20,7 +21,7 @@ export default function PrivacyPage() { jsonLd={generateWebPage({ name: t('pages.privacy.title'), description: t('pages.privacy.metaDescription'), - url: `${window.location.origin}/privacy`, + url: `${siteOrigin}/privacy`, })} /> diff --git a/frontend/src/pages/TermsPage.tsx b/frontend/src/pages/TermsPage.tsx index 784ac44..e3caf4f 100644 --- a/frontend/src/pages/TermsPage.tsx +++ b/frontend/src/pages/TermsPage.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; import SEOHead from '@/components/seo/SEOHead'; -import { generateWebPage } from '@/utils/seo'; +import { generateWebPage, getSiteOrigin } from '@/utils/seo'; import { FILE_RETENTION_MINUTES } from '@/config/toolLimits'; const LAST_UPDATED = '2026-03-06'; @@ -8,6 +8,7 @@ const CONTACT_EMAIL = 'support@dociva.io'; export default function TermsPage() { const { t } = useTranslation(); + const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const useItems = t('pages.terms.useItems', { returnObjects: true }) as string[]; const fileItems = t('pages.terms.fileItems', { minutes: FILE_RETENTION_MINUTES, returnObjects: true }) as string[]; @@ -20,7 +21,7 @@ export default function TermsPage() { jsonLd={generateWebPage({ name: t('pages.terms.title'), description: t('pages.terms.metaDescription'), - url: `${window.location.origin}/terms`, + url: `${siteOrigin}/terms`, })} /> diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b9624c4..97ba407 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,4 +1,60 @@ -import axios from 'axios'; +import axios, { type InternalAxiosRequestConfig } from 'axios'; + +const CSRF_COOKIE_NAME = 'csrf_token'; +const CSRF_HEADER_NAME = 'X-CSRF-Token'; + + +function getCookieValue(name: string): string { + if (typeof document === 'undefined') { + return ''; + } + + const encodedName = `${encodeURIComponent(name)}=`; + const cookie = document.cookie + .split('; ') + .find((item) => item.startsWith(encodedName)); + + return cookie ? decodeURIComponent(cookie.slice(encodedName.length)) : ''; +} + + +function shouldAttachCsrfToken(config: InternalAxiosRequestConfig): boolean { + const method = String(config.method || 'get').toUpperCase(); + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { + return false; + } + + const headers = config.headers ?? {}; + if ('X-API-Key' in headers || 'x-api-key' in headers) { + return false; + } + + return !String(config.url || '').includes('/auth/csrf'); +} + + +function setRequestHeader(config: InternalAxiosRequestConfig, key: string, value: string) { + if (!config.headers) { + config.headers = {}; + } + + if (typeof (config.headers as { set?: (header: string, headerValue: string) => void }).set === 'function') { + (config.headers as { set: (header: string, headerValue: string) => void }).set(key, value); + return; + } + + (config.headers as Record)[key] = value; +} + + +const csrfBootstrapClient = axios.create({ + baseURL: '/api', + timeout: 15000, + withCredentials: true, + headers: { + Accept: 'application/json', + }, +}); const api = axios.create({ baseURL: '/api', @@ -11,7 +67,23 @@ const api = axios.create({ // Request interceptor for logging api.interceptors.request.use( - (config) => config, + async (config) => { + if (!shouldAttachCsrfToken(config)) { + return config; + } + + let csrfToken = getCookieValue(CSRF_COOKIE_NAME); + if (!csrfToken) { + await csrfBootstrapClient.get('/auth/csrf'); + csrfToken = getCookieValue(CSRF_COOKIE_NAME); + } + + if (csrfToken) { + setRequestHeader(config, CSRF_HEADER_NAME, csrfToken); + } + + return config; + }, (error) => Promise.reject(error) ); @@ -318,6 +390,10 @@ export async function getTaskStatus(taskId: string): Promise { return response.data; } +export function getApiClient() { + return api; +} + /** * Send one message to the site assistant. */ diff --git a/frontend/src/utils/seo.ts b/frontend/src/utils/seo.ts index f9d28d6..f0c82e3 100644 --- a/frontend/src/utils/seo.ts +++ b/frontend/src/utils/seo.ts @@ -18,6 +18,7 @@ export interface LanguageAlternate { } const DEFAULT_SOCIAL_IMAGE_PATH = '/social-preview.svg'; +const DEFAULT_SITE_ORIGIN = 'https://dociva.io'; const LANGUAGE_CONFIG: Record<'en' | 'ar' | 'fr', { hrefLang: string; ogLocale: string }> = { en: { hrefLang: 'en', ogLocale: 'en_US' }, @@ -44,6 +45,19 @@ export function buildLanguageAlternates(origin: string, path: string): LanguageA })); } +export function getSiteOrigin(currentOrigin = ''): string { + const configuredOrigin = String(import.meta.env.VITE_SITE_DOMAIN || '').trim().replace(/\/$/, ''); + if (configuredOrigin) { + return configuredOrigin; + } + + if (currentOrigin) { + return currentOrigin.replace(/\/$/, ''); + } + + return DEFAULT_SITE_ORIGIN; +} + export function buildSocialImageUrl(origin: string): string { return `${origin}${DEFAULT_SOCIAL_IMAGE_PATH}`; } diff --git a/nginx/nginx.prod.conf b/nginx/nginx.prod.conf index a29b2ac..95519d1 100644 --- a/nginx/nginx.prod.conf +++ b/nginx/nginx.prod.conf @@ -8,7 +8,7 @@ server { client_max_body_size 100M; # Redirect HTTP to HTTPS - return 301 https://dociva.io$request_uri; + return 308 https://dociva.io$request_uri; } # Redirect www to non-www @@ -21,7 +21,7 @@ server { ssl_certificate_key /etc/nginx/ssl/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - return 301 https://dociva.io$request_uri; + return 308 https://dociva.io$request_uri; } server { @@ -43,6 +43,7 @@ server { add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header X-Canonical-Host "dociva.io" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://pagead2.googlesyndication.com https://www.googletagmanager.com https://www.google-analytics.com https://plausible.io; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://pagead2.googlesyndication.com https://www.google-analytics.com; connect-src 'self' https://www.google-analytics.com https://pagead2.googlesyndication.com https://plausible.io; frame-src https://googleads.g.doubleclick.net https://tpc.googlesyndication.com; frame-ancestors 'self'" always; # SEO files — no cache, always fresh @@ -71,6 +72,7 @@ server { # Frontend static files location / { root /usr/share/nginx/html; + add_header Link "; rel=canonical" always; try_files $uri $uri/ /index.html; # Cache static assets