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.
This commit is contained in:
6
.devdbrc
Normal file
6
.devdbrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "sqlite",
|
||||||
|
"path": "c:\\xampp\\htdocs\\SaaS-PDF\\backend\\data\\saas_pdf.db"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -56,6 +56,9 @@ SENTRY_ENVIRONMENT=production
|
|||||||
|
|
||||||
# PostgreSQL (production) — leave empty to use SQLite
|
# PostgreSQL (production) — leave empty to use SQLite
|
||||||
DATABASE_URL=
|
DATABASE_URL=
|
||||||
|
POSTGRES_DB=dociva
|
||||||
|
POSTGRES_USER=dociva
|
||||||
|
POSTGRES_PASSWORD=replace-with-strong-postgres-password
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
VITE_SITE_DOMAIN=https://dociva.io
|
VITE_SITE_DOMAIN=https://dociva.io
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Flask Application Factory."""
|
"""Flask Application Factory."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask, jsonify
|
||||||
|
|
||||||
from config import config
|
from config import config
|
||||||
from app.extensions import cors, limiter, talisman, init_celery
|
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.site_assistant_service import init_site_assistant_db
|
||||||
from app.services.contact_service import init_contact_db
|
from app.services.contact_service import init_contact_db
|
||||||
from app.services.stripe_service import init_stripe_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):
|
def _init_sentry(app):
|
||||||
@@ -48,6 +49,7 @@ def create_app(config_name=None):
|
|||||||
# Create upload/output/database directories
|
# Create upload/output/database directories
|
||||||
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
|
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
|
||||||
os.makedirs(app.config["OUTPUT_FOLDER"], exist_ok=True)
|
os.makedirs(app.config["OUTPUT_FOLDER"], exist_ok=True)
|
||||||
|
if not app.config.get("DATABASE_URL"):
|
||||||
db_dir = os.path.dirname(app.config["DATABASE_PATH"])
|
db_dir = os.path.dirname(app.config["DATABASE_PATH"])
|
||||||
if db_dir:
|
if db_dir:
|
||||||
os.makedirs(db_dir, exist_ok=True)
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
@@ -97,6 +99,22 @@ def create_app(config_name=None):
|
|||||||
force_https=config_name == "production",
|
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
|
# Initialize Celery
|
||||||
init_celery(app)
|
init_celery(app)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.utils.auth import (
|
|||||||
login_user_session,
|
login_user_session,
|
||||||
logout_user_session,
|
logout_user_session,
|
||||||
)
|
)
|
||||||
|
from app.utils.csrf import get_or_create_csrf_token
|
||||||
|
|
||||||
auth_bp = Blueprint("auth", __name__)
|
auth_bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
@@ -105,6 +106,13 @@ def me_route():
|
|||||||
return jsonify({"authenticated": True, "user": user}), 200
|
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"])
|
@auth_bp.route("/forgot-password", methods=["POST"])
|
||||||
@limiter.limit("5/hour")
|
@limiter.limit("5/hour")
|
||||||
def forgot_password_route():
|
def forgot_password_route():
|
||||||
|
|||||||
77
backend/app/utils/csrf.py
Normal file
77
backend/app/utils/csrf.py
Normal file
@@ -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
|
||||||
@@ -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.site_assistant_service import init_site_assistant_db
|
||||||
from app.services.contact_service import init_contact_db
|
from app.services.contact_service import init_contact_db
|
||||||
from app.services.stripe_service import init_stripe_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
|
@pytest.fixture
|
||||||
@@ -26,6 +57,7 @@ def app():
|
|||||||
os.environ['OUTPUT_FOLDER'] = output_folder
|
os.environ['OUTPUT_FOLDER'] = output_folder
|
||||||
|
|
||||||
app = create_app('testing')
|
app = create_app('testing')
|
||||||
|
app.test_client_class = CSRFTestClient
|
||||||
app.config.update({
|
app.config.update({
|
||||||
'TESTING': True,
|
'TESTING': True,
|
||||||
'UPLOAD_FOLDER': upload_folder,
|
'UPLOAD_FOLDER': upload_folder,
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
|
|
||||||
class TestAuthRoutes:
|
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):
|
def test_register_success(self, client):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
'/api/auth/register',
|
'/api/auth/register',
|
||||||
@@ -77,3 +84,13 @@ class TestAuthRoutes:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.get_json() == {'authenticated': False, 'user': None}
|
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()
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
services:
|
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 ---
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
@@ -23,11 +39,14 @@ services:
|
|||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/1
|
- CELERY_RESULT_BACKEND=redis://redis:6379/1
|
||||||
|
- DATABASE_URL=${DATABASE_URL:-}
|
||||||
volumes:
|
volumes:
|
||||||
- upload_data:/tmp/uploads
|
- upload_data:/tmp/uploads
|
||||||
- output_data:/tmp/outputs
|
- output_data:/tmp/outputs
|
||||||
- db_data:/app/data
|
- db_data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: always
|
restart: always
|
||||||
@@ -49,11 +68,14 @@ services:
|
|||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/1
|
- CELERY_RESULT_BACKEND=redis://redis:6379/1
|
||||||
|
- DATABASE_URL=${DATABASE_URL:-}
|
||||||
volumes:
|
volumes:
|
||||||
- upload_data:/tmp/uploads
|
- upload_data:/tmp/uploads
|
||||||
- output_data:/tmp/outputs
|
- output_data:/tmp/outputs
|
||||||
- db_data:/app/data
|
- db_data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: always
|
restart: always
|
||||||
@@ -73,9 +95,12 @@ services:
|
|||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/1
|
- CELERY_RESULT_BACKEND=redis://redis:6379/1
|
||||||
|
- DATABASE_URL=${DATABASE_URL:-}
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/app/data
|
- db_data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: always
|
restart: always
|
||||||
@@ -120,6 +145,7 @@ services:
|
|||||||
- frontend_build:/app/dist
|
- frontend_build:/app/dist
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
upload_data:
|
upload_data:
|
||||||
output_data:
|
output_data:
|
||||||
|
|||||||
273
fixed-ssl.md
273
fixed-ssl.md
@@ -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 زيارة**
|
|
||||||
بطريقة عملية مش كلام نظري.
|
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Free online tools for PDF, image, video, and text processing. Merge, split, compress, convert, watermark, protect & more — instantly." />
|
<meta name="description" content="Free online tools for PDF, image, video, and text processing. Merge, split, compress, convert, watermark, protect & more — instantly." />
|
||||||
|
<meta name="google-site-verification" content="tx9YptvPfrvb115PeFBWpYpRhw_4CYHQXzpLKNXXV20" />
|
||||||
<meta name="keywords" content="PDF tools, merge PDF, split PDF, compress PDF, PDF to Word, image converter, free online tools, Arabic PDF tools" />
|
<meta name="keywords" content="PDF tools, merge PDF, split PDF, compress PDF, PDF to Word, image converter, free online tools, Arabic PDF tools" />
|
||||||
<meta name="author" content="Dociva" />
|
<meta name="author" content="Dociva" />
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="index, follow" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { buildLanguageAlternates, buildSocialImageUrl, getOgLocale } from '@/utils/seo';
|
import { buildLanguageAlternates, buildSocialImageUrl, getOgLocale, getSiteOrigin } from '@/utils/seo';
|
||||||
|
|
||||||
const SITE_NAME = 'Dociva';
|
const SITE_NAME = 'Dociva';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ interface SEOHeadProps {
|
|||||||
*/
|
*/
|
||||||
export default function SEOHead({ title, description, path, type = 'website', jsonLd }: SEOHeadProps) {
|
export default function SEOHead({ title, description, path, type = 'website', jsonLd }: SEOHeadProps) {
|
||||||
const { i18n } = useTranslation();
|
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 canonicalUrl = `${origin}${path}`;
|
||||||
const socialImageUrl = buildSocialImageUrl(origin);
|
const socialImageUrl = buildSocialImageUrl(origin);
|
||||||
const fullTitle = `${title} — ${SITE_NAME}`;
|
const fullTitle = `${title} — ${SITE_NAME}`;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Helmet } from 'react-helmet-async';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CheckCircle } from 'lucide-react';
|
import { CheckCircle } from 'lucide-react';
|
||||||
import { getToolSEO } from '@/config/seoData';
|
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 FAQSection from './FAQSection';
|
||||||
import RelatedTools from './RelatedTools';
|
import RelatedTools from './RelatedTools';
|
||||||
import ToolRating from '@/components/shared/ToolRating';
|
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 toolTitle = t(`tools.${seo.i18nKey}.title`);
|
||||||
const toolDesc = t(`tools.${seo.i18nKey}.description`);
|
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 path = `/tools/${slug}`;
|
||||||
const canonicalUrl = `${origin}${path}`;
|
const canonicalUrl = `${origin}${path}`;
|
||||||
const socialImageUrl = buildSocialImageUrl(origin);
|
const socialImageUrl = buildSocialImageUrl(origin);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import SEOHead from '@/components/seo/SEOHead';
|
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 { Target, Cpu, Shield, Lock, Wrench } from 'lucide-react';
|
||||||
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
|
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||||
const toolCategories = t('pages.about.toolCategories', { returnObjects: true }) as string[];
|
const toolCategories = t('pages.about.toolCategories', { returnObjects: true }) as string[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -18,7 +19,7 @@ export default function AboutPage() {
|
|||||||
jsonLd={generateWebPage({
|
jsonLd={generateWebPage({
|
||||||
name: t('pages.about.title'),
|
name: t('pages.about.title'),
|
||||||
description: t('pages.about.metaDescription'),
|
description: t('pages.about.metaDescription'),
|
||||||
url: `${window.location.origin}/about`,
|
url: `${siteOrigin}/about`,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import SEOHead from '@/components/seo/SEOHead';
|
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 { BookOpen, Calendar, ArrowRight, Search, X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
BLOG_ARTICLES,
|
BLOG_ARTICLES,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
|
|
||||||
export default function BlogPage() {
|
export default function BlogPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const query = searchParams.get('q') || '';
|
const query = searchParams.get('q') || '';
|
||||||
const deferredQuery = useDeferredValue(query.trim().toLowerCase());
|
const deferredQuery = useDeferredValue(query.trim().toLowerCase());
|
||||||
@@ -46,7 +47,7 @@ export default function BlogPage() {
|
|||||||
jsonLd={generateWebPage({
|
jsonLd={generateWebPage({
|
||||||
name: t('pages.blog.metaTitle'),
|
name: t('pages.blog.metaTitle'),
|
||||||
description: t('pages.blog.metaDescription'),
|
description: t('pages.blog.metaDescription'),
|
||||||
url: `${window.location.origin}/blog`,
|
url: `${siteOrigin}/blog`,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
getLocalizedBlogArticle,
|
getLocalizedBlogArticle,
|
||||||
normalizeBlogLocale,
|
normalizeBlogLocale,
|
||||||
} from '@/content/blogArticles';
|
} from '@/content/blogArticles';
|
||||||
import { generateBlogPosting, generateBreadcrumbs, generateWebPage } from '@/utils/seo';
|
import { generateBlogPosting, generateBreadcrumbs, generateWebPage, getSiteOrigin } from '@/utils/seo';
|
||||||
import NotFoundPage from './NotFoundPage';
|
import NotFoundPage from './NotFoundPage';
|
||||||
|
|
||||||
export default function BlogPostPage() {
|
export default function BlogPostPage() {
|
||||||
@@ -17,6 +17,7 @@ export default function BlogPostPage() {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const locale = normalizeBlogLocale(i18n.language);
|
const locale = normalizeBlogLocale(i18n.language);
|
||||||
const article = slug ? getBlogArticleBySlug(slug) : undefined;
|
const article = slug ? getBlogArticleBySlug(slug) : undefined;
|
||||||
|
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||||
|
|
||||||
if (!article) {
|
if (!article) {
|
||||||
return <NotFoundPage />;
|
return <NotFoundPage />;
|
||||||
@@ -24,11 +25,11 @@ export default function BlogPostPage() {
|
|||||||
|
|
||||||
const localizedArticle = getLocalizedBlogArticle(article, locale);
|
const localizedArticle = getLocalizedBlogArticle(article, locale);
|
||||||
const path = `/blog/${localizedArticle.slug}`;
|
const path = `/blog/${localizedArticle.slug}`;
|
||||||
const url = `${window.location.origin}${path}`;
|
const url = `${siteOrigin}${path}`;
|
||||||
|
|
||||||
const breadcrumbs = generateBreadcrumbs([
|
const breadcrumbs = generateBreadcrumbs([
|
||||||
{ name: t('common.home'), url: window.location.origin },
|
{ name: t('common.home'), url: siteOrigin },
|
||||||
{ name: t('common.blog'), url: `${window.location.origin}/blog` },
|
{ name: t('common.blog'), url: `${siteOrigin}/blog` },
|
||||||
{ name: localizedArticle.title, url },
|
{ name: localizedArticle.title, url },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { Mail, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
import { Mail, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
import SEOHead from '@/components/seo/SEOHead';
|
import SEOHead from '@/components/seo/SEOHead';
|
||||||
import { generateWebPage } from '@/utils/seo';
|
import { generateWebPage, getSiteOrigin } from '@/utils/seo';
|
||||||
import axios from 'axios';
|
import { getApiClient } from '@/services/api';
|
||||||
|
|
||||||
const CONTACT_EMAIL = 'support@dociva.io';
|
const CONTACT_EMAIL = 'support@dociva.io';
|
||||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||||
|
const api = getApiClient();
|
||||||
|
|
||||||
type Category = 'general' | 'bug' | 'feature';
|
type Category = 'general' | 'bug' | 'feature';
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||||
const [category, setCategory] = useState<Category>('general');
|
const [category, setCategory] = useState<Category>('general');
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -29,7 +31,7 @@ export default function ContactPage() {
|
|||||||
const data = new FormData(form);
|
const data = new FormData(form);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(`${API_BASE}/api/contact/submit`, {
|
await api.post(`${API_BASE}/contact/submit`, {
|
||||||
name: data.get('name'),
|
name: data.get('name'),
|
||||||
email: data.get('email'),
|
email: data.get('email'),
|
||||||
category,
|
category,
|
||||||
@@ -38,10 +40,10 @@ export default function ContactPage() {
|
|||||||
});
|
});
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (axios.isAxiosError(err) && err.response?.data?.error) {
|
if (err instanceof Error) {
|
||||||
setError(err.response.data.error);
|
setError(err.message);
|
||||||
} else {
|
} else {
|
||||||
setError(t('pages.contact.errorMessage', 'Failed to send message. Please try again.'));
|
setError(err.response.data.error);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -79,7 +81,7 @@ export default function ContactPage() {
|
|||||||
jsonLd={generateWebPage({
|
jsonLd={generateWebPage({
|
||||||
name: t('pages.contact.title'),
|
name: t('pages.contact.title'),
|
||||||
description: t('pages.contact.metaDescription'),
|
description: t('pages.contact.metaDescription'),
|
||||||
url: `${window.location.origin}/contact`,
|
url: `${siteOrigin}/contact`,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import SEOHead from '@/components/seo/SEOHead';
|
import SEOHead from '@/components/seo/SEOHead';
|
||||||
import SocialProofStrip from '@/components/shared/SocialProofStrip';
|
import SocialProofStrip from '@/components/shared/SocialProofStrip';
|
||||||
import { generateWebPage } from '@/utils/seo';
|
import { generateWebPage, getSiteOrigin } from '@/utils/seo';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Code2, KeyRound, Rocket, Workflow } from 'lucide-react';
|
import { Code2, KeyRound, Rocket, Workflow } from 'lucide-react';
|
||||||
@@ -24,7 +24,7 @@ const ENDPOINT_GROUPS = [
|
|||||||
|
|
||||||
export default function DevelopersPage() {
|
export default function DevelopersPage() {
|
||||||
const { t } = useTranslation();
|
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 \\
|
const curlUpload = `curl -X POST ${origin}/api/v1/convert/pdf-to-word \\
|
||||||
-H "X-API-Key: spdf_your_api_key" \\
|
-H "X-API-Key: spdf_your_api_key" \\
|
||||||
-F "file=@./sample.pdf"`;
|
-F "file=@./sample.pdf"`;
|
||||||
@@ -40,7 +40,7 @@ export default function DevelopersPage() {
|
|||||||
jsonLd={generateWebPage({
|
jsonLd={generateWebPage({
|
||||||
name: t('pages.developers.title'),
|
name: t('pages.developers.title'),
|
||||||
description: t('pages.developers.metaDescription'),
|
description: t('pages.developers.metaDescription'),
|
||||||
url: `${window.location.origin}/developers`,
|
url: `${origin}/developers`,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useDeferredValue } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import SEOHead from '@/components/seo/SEOHead';
|
import SEOHead from '@/components/seo/SEOHead';
|
||||||
import { generateOrganization } from '@/utils/seo';
|
import { generateOrganization, getSiteOrigin } from '@/utils/seo';
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
FileOutput,
|
FileOutput,
|
||||||
@@ -86,6 +86,7 @@ const otherTools: ToolInfo[] = [
|
|||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const query = searchParams.get('q') || '';
|
const query = searchParams.get('q') || '';
|
||||||
const deferredQuery = useDeferredValue(query.trim().toLowerCase());
|
const deferredQuery = useDeferredValue(query.trim().toLowerCase());
|
||||||
@@ -123,15 +124,15 @@ export default function HomePage() {
|
|||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'WebSite',
|
'@type': 'WebSite',
|
||||||
name: t('common.appName'),
|
name: t('common.appName'),
|
||||||
url: window.location.origin,
|
url: siteOrigin,
|
||||||
description: t('home.heroSub'),
|
description: t('home.heroSub'),
|
||||||
potentialAction: {
|
potentialAction: {
|
||||||
'@type': 'SearchAction',
|
'@type': 'SearchAction',
|
||||||
target: `${window.location.origin}/?q={search_term_string}`,
|
target: `${siteOrigin}/?q={search_term_string}`,
|
||||||
'query-input': 'required name=search_term_string',
|
'query-input': 'required name=search_term_string',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
generateOrganization(window.location.origin),
|
generateOrganization(siteOrigin),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import SEOHead from '@/components/seo/SEOHead';
|
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 { Check, X, Zap, Crown, Loader2 } from 'lucide-react';
|
||||||
import axios from 'axios';
|
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import SocialProofStrip from '@/components/shared/SocialProofStrip';
|
import SocialProofStrip from '@/components/shared/SocialProofStrip';
|
||||||
|
import { getApiClient } from '@/services/api';
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||||
|
const api = getApiClient();
|
||||||
|
|
||||||
interface PlanFeature {
|
interface PlanFeature {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -31,6 +32,7 @@ const FEATURES: PlanFeature[] = [
|
|||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -41,11 +43,7 @@ export default function PricingPage() {
|
|||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.post(
|
const { data } = await api.post(`${API_BASE}/stripe/create-checkout-session`, { billing });
|
||||||
`${API_BASE}/api/stripe/create-checkout-session`,
|
|
||||||
{ billing },
|
|
||||||
{ withCredentials: true },
|
|
||||||
);
|
|
||||||
if (data.url) window.location.href = data.url;
|
if (data.url) window.location.href = data.url;
|
||||||
} catch {
|
} catch {
|
||||||
// Stripe not configured yet — show message
|
// Stripe not configured yet — show message
|
||||||
@@ -70,7 +68,7 @@ export default function PricingPage() {
|
|||||||
jsonLd={generateWebPage({
|
jsonLd={generateWebPage({
|
||||||
name: t('pages.pricing.title', 'Pricing'),
|
name: t('pages.pricing.title', 'Pricing'),
|
||||||
description: t('pages.pricing.metaDescription', 'Compare Free and Pro plans for Dociva.'),
|
description: t('pages.pricing.metaDescription', 'Compare Free and Pro plans for Dociva.'),
|
||||||
url: `${window.location.origin}/pricing`,
|
url: `${siteOrigin}/pricing`,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import SEOHead from '@/components/seo/SEOHead';
|
import SEOHead from '@/components/seo/SEOHead';
|
||||||
import { generateWebPage } from '@/utils/seo';
|
import { generateWebPage, getSiteOrigin } from '@/utils/seo';
|
||||||
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
|
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
|
||||||
|
|
||||||
const LAST_UPDATED = '2026-03-06';
|
const LAST_UPDATED = '2026-03-06';
|
||||||
@@ -8,6 +8,7 @@ const CONTACT_EMAIL = 'support@dociva.io';
|
|||||||
|
|
||||||
export default function PrivacyPage() {
|
export default function PrivacyPage() {
|
||||||
const { t } = useTranslation();
|
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 fileItems = t('pages.privacy.fileHandlingItems', { minutes: FILE_RETENTION_MINUTES, returnObjects: true }) as string[];
|
||||||
const thirdPartyItems = t('pages.privacy.thirdPartyItems', { returnObjects: true }) as string[];
|
const thirdPartyItems = t('pages.privacy.thirdPartyItems', { returnObjects: true }) as string[];
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ export default function PrivacyPage() {
|
|||||||
jsonLd={generateWebPage({
|
jsonLd={generateWebPage({
|
||||||
name: t('pages.privacy.title'),
|
name: t('pages.privacy.title'),
|
||||||
description: t('pages.privacy.metaDescription'),
|
description: t('pages.privacy.metaDescription'),
|
||||||
url: `${window.location.origin}/privacy`,
|
url: `${siteOrigin}/privacy`,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import SEOHead from '@/components/seo/SEOHead';
|
import SEOHead from '@/components/seo/SEOHead';
|
||||||
import { generateWebPage } from '@/utils/seo';
|
import { generateWebPage, getSiteOrigin } from '@/utils/seo';
|
||||||
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
|
import { FILE_RETENTION_MINUTES } from '@/config/toolLimits';
|
||||||
|
|
||||||
const LAST_UPDATED = '2026-03-06';
|
const LAST_UPDATED = '2026-03-06';
|
||||||
@@ -8,6 +8,7 @@ const CONTACT_EMAIL = 'support@dociva.io';
|
|||||||
|
|
||||||
export default function TermsPage() {
|
export default function TermsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||||
const useItems = t('pages.terms.useItems', { returnObjects: true }) as string[];
|
const useItems = t('pages.terms.useItems', { returnObjects: true }) as string[];
|
||||||
const fileItems = t('pages.terms.fileItems', { minutes: FILE_RETENTION_MINUTES, 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({
|
jsonLd={generateWebPage({
|
||||||
name: t('pages.terms.title'),
|
name: t('pages.terms.title'),
|
||||||
description: t('pages.terms.metaDescription'),
|
description: t('pages.terms.metaDescription'),
|
||||||
url: `${window.location.origin}/terms`,
|
url: `${siteOrigin}/terms`,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, string>)[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const csrfBootstrapClient = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 15000,
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
@@ -11,7 +67,23 @@ const api = axios.create({
|
|||||||
|
|
||||||
// Request interceptor for logging
|
// Request interceptor for logging
|
||||||
api.interceptors.request.use(
|
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)
|
(error) => Promise.reject(error)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -318,6 +390,10 @@ export async function getTaskStatus(taskId: string): Promise<TaskStatus> {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getApiClient() {
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send one message to the site assistant.
|
* Send one message to the site assistant.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface LanguageAlternate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SOCIAL_IMAGE_PATH = '/social-preview.svg';
|
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 }> = {
|
const LANGUAGE_CONFIG: Record<'en' | 'ar' | 'fr', { hrefLang: string; ogLocale: string }> = {
|
||||||
en: { hrefLang: 'en', ogLocale: 'en_US' },
|
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 {
|
export function buildSocialImageUrl(origin: string): string {
|
||||||
return `${origin}${DEFAULT_SOCIAL_IMAGE_PATH}`;
|
return `${origin}${DEFAULT_SOCIAL_IMAGE_PATH}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ server {
|
|||||||
client_max_body_size 100M;
|
client_max_body_size 100M;
|
||||||
|
|
||||||
# Redirect HTTP to HTTPS
|
# Redirect HTTP to HTTPS
|
||||||
return 301 https://dociva.io$request_uri;
|
return 308 https://dociva.io$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Redirect www to non-www
|
# Redirect www to non-www
|
||||||
@@ -21,7 +21,7 @@ server {
|
|||||||
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
return 301 https://dociva.io$request_uri;
|
return 308 https://dociva.io$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
@@ -43,6 +43,7 @@ server {
|
|||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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;
|
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
|
# SEO files — no cache, always fresh
|
||||||
@@ -71,6 +72,7 @@ server {
|
|||||||
# Frontend static files
|
# Frontend static files
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
add_header Link "<https://dociva.io$uri>; rel=canonical" always;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
||||||
# Cache static assets
|
# Cache static assets
|
||||||
|
|||||||
Reference in New Issue
Block a user