From 3f24a7ea3ee6afddd415cdad328aac37b73c8170 Mon Sep 17 00:00:00 2001 From: Your Name <119736744+aborayan2022@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:19:23 +0200 Subject: [PATCH] feat: Enhance task access control and session management - Implemented API and web task access assertions in the task status polling endpoint. - Added functions to remember and check task access in user sessions. - Updated task status tests to validate access control based on session data. - Enhanced download route tests to ensure proper access checks. - Improved SEO metadata handling with dynamic social preview images. - Updated sitemap generation to include blog posts and new tools. - Added a social preview SVG for better sharing on social media platforms. --- .env.example | 2 +- .gitignore | 3 - backend/app/routes/download.py | 18 ++ backend/app/routes/tasks.py | 19 +- backend/app/services/policy_service.py | 15 ++ backend/app/utils/auth.py | 23 +++ backend/tests/test_download.py | 23 ++- backend/tests/test_tasks_route.py | 22 ++- frontend/index.html | 4 + frontend/public/sitemap.xml | 178 ++++++++++++++---- frontend/public/social-preview.svg | 35 ++++ frontend/src/components/seo/SEOHead.tsx | 9 +- .../src/components/seo/ToolLandingPage.tsx | 9 +- .../components/shared/SocialProofStrip.tsx | 50 +++-- frontend/src/pages/DevelopersPage.tsx | 17 +- frontend/src/utils/seo.ts | 6 + scripts/generate_sitemap.py | 26 ++- 17 files changed, 384 insertions(+), 75 deletions(-) create mode 100644 frontend/public/social-preview.svg diff --git a/.env.example b/.env.example index 344a03d..6e812b7 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ CELERY_BROKER_URL=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/1 # OpenRouter AI -OPENROUTER_API_KEY=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 +OPENROUTER_API_KEY=sk-or-v1-your-openrouter-api-key OPENROUTER_MODEL=nvidia/nemotron-3-super-120b-a12b:free OPENROUTER_BASE_URL=https://openrouter.ai/api/v1/chat/completions diff --git a/.gitignore b/.gitignore index b0451f1..3505e5b 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,3 @@ htmlcov/ .coverage coverage/ -# Celery -celerybeat-schedule -backend/celerybeat-schedule \ No newline at end of file diff --git a/backend/app/routes/download.py b/backend/app/routes/download.py index c1e47c7..cdc7452 100644 --- a/backend/app/routes/download.py +++ b/backend/app/routes/download.py @@ -3,6 +3,14 @@ import os from flask import Blueprint, send_file, abort, request, current_app +from app.services.policy_service import ( + PolicyError, + assert_api_task_access, + assert_web_task_access, + resolve_api_actor, + resolve_web_actor, +) + download_bp = Blueprint("download", __name__) @@ -20,6 +28,16 @@ def download_file(task_id: str, filename: str): if ".." in filename or "/" in filename or "\\" in filename: abort(400, "Invalid filename.") + try: + if request.headers.get("X-API-Key", "").strip(): + actor = resolve_api_actor() + assert_api_task_access(actor, task_id) + else: + actor = resolve_web_actor() + assert_web_task_access(actor, task_id) + except PolicyError as exc: + abort(exc.status_code, exc.message) + output_dir = current_app.config["OUTPUT_FOLDER"] file_path = os.path.join(output_dir, task_id, filename) diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py index bd776e7..b2bddc0 100644 --- a/backend/app/routes/tasks.py +++ b/backend/app/routes/tasks.py @@ -1,9 +1,16 @@ """Task status polling endpoint.""" -from flask import Blueprint, jsonify +from flask import Blueprint, jsonify, request from celery.result import AsyncResult from app.extensions import celery from app.middleware.rate_limiter import limiter +from app.services.policy_service import ( + PolicyError, + assert_api_task_access, + assert_web_task_access, + resolve_api_actor, + resolve_web_actor, +) tasks_bp = Blueprint("tasks", __name__) @@ -17,6 +24,16 @@ def get_task_status(task_id: str): Returns: JSON with task state and result (if completed) """ + try: + if request.headers.get("X-API-Key", "").strip(): + actor = resolve_api_actor() + assert_api_task_access(actor, task_id) + else: + actor = resolve_web_actor() + assert_web_task_access(actor, task_id) + except PolicyError as exc: + return jsonify({"error": exc.message}), exc.status_code + result = AsyncResult(task_id, app=celery) response = { diff --git a/backend/app/services/policy_service.py b/backend/app/services/policy_service.py index 09e2833..6de8406 100644 --- a/backend/app/services/policy_service.py +++ b/backend/app/services/policy_service.py @@ -13,6 +13,7 @@ from app.services.account_service import ( record_usage_event, ) from app.utils.auth import get_current_user_id, logout_user_session +from app.utils.auth import has_session_task_access, remember_task_access from app.utils.file_validator import validate_file FREE_PLAN = "free" @@ -202,6 +203,9 @@ def assert_quota_available(actor: ActorContext): def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str): """Record one accepted usage event after task dispatch succeeds.""" + if actor.source == "web": + remember_task_access(celery_task_id) + record_usage_event( user_id=actor.user_id, source=actor.source, @@ -225,3 +229,14 @@ def assert_api_task_access(actor: ActorContext, task_id: str): """Ensure one API actor can poll one task id.""" if actor.user_id is None or not has_task_access(actor.user_id, "api", task_id): raise PolicyError("Task not found.", 404) + + +def assert_web_task_access(actor: ActorContext, task_id: str): + """Ensure one web browser session can access one task id.""" + if actor.user_id is not None and has_task_access(actor.user_id, "web", task_id): + return + + if has_session_task_access(task_id): + return + + raise PolicyError("Task not found.", 404) diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py index d116ef9..d70f385 100644 --- a/backend/app/utils/auth.py +++ b/backend/app/utils/auth.py @@ -1,6 +1,9 @@ """Session helpers for authenticated routes.""" from flask import session +TASK_ACCESS_SESSION_KEY = "task_access_ids" +MAX_TRACKED_TASK_IDS = 200 + def get_current_user_id() -> int | None: """Return the authenticated user id from session storage.""" @@ -8,11 +11,31 @@ def get_current_user_id() -> int | None: return user_id if isinstance(user_id, int) else None +def remember_task_access(task_id: str): + """Persist one web task id in the active browser session.""" + tracked = session.get(TASK_ACCESS_SESSION_KEY, []) + if not isinstance(tracked, list): + tracked = [] + + normalized = [value for value in tracked if isinstance(value, str) and value != task_id] + normalized.append(task_id) + session[TASK_ACCESS_SESSION_KEY] = normalized[-MAX_TRACKED_TASK_IDS:] + + +def has_session_task_access(task_id: str) -> bool: + """Return whether the active browser session owns one web task id.""" + tracked = session.get(TASK_ACCESS_SESSION_KEY, []) + return isinstance(tracked, list) and task_id in tracked + + def login_user_session(user_id: int): """Persist the authenticated user in the Flask session.""" + tracked_task_ids = session.get(TASK_ACCESS_SESSION_KEY, []) session.clear() session.permanent = True session["user_id"] = user_id + if isinstance(tracked_task_ids, list) and tracked_task_ids: + session[TASK_ACCESS_SESSION_KEY] = tracked_task_ids[-MAX_TRACKED_TASK_IDS:] def logout_user_session(): diff --git a/backend/tests/test_download.py b/backend/tests/test_download.py index df7cabe..c2ce3b1 100644 --- a/backend/tests/test_download.py +++ b/backend/tests/test_download.py @@ -1,6 +1,8 @@ """Tests for file download route.""" import os +from app.utils.auth import TASK_ACCESS_SESSION_KEY + class TestDownload: def test_download_nonexistent_file(self, client): @@ -31,6 +33,9 @@ class TestDownload: with open(file_path, 'wb') as f: f.write(b'%PDF-1.4 test content') + with client.session_transaction() as session: + session[TASK_ACCESS_SESSION_KEY] = [task_id] + response = client.get(f'/api/download/{task_id}/{filename}') assert response.status_code == 200 assert response.data == b'%PDF-1.4 test content' @@ -45,5 +50,21 @@ class TestDownload: with open(os.path.join(output_dir, filename), 'wb') as f: f.write(b'%PDF-1.4') + with client.session_transaction() as session: + session[TASK_ACCESS_SESSION_KEY] = [task_id] + response = client.get(f'/api/download/{task_id}/{filename}?name=my-document.pdf') - assert response.status_code == 200 \ No newline at end of file + assert response.status_code == 200 + + def test_download_requires_task_access(self, client, app): + """Should not serve an existing file without session or API ownership.""" + task_id = 'protected-download-id' + filename = 'output.pdf' + + output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id) + os.makedirs(output_dir, exist_ok=True) + with open(os.path.join(output_dir, filename), 'wb') as f: + f.write(b'%PDF-1.4 protected') + + response = client.get(f'/api/download/{task_id}/{filename}') + assert response.status_code == 404 \ No newline at end of file diff --git a/backend/tests/test_tasks_route.py b/backend/tests/test_tasks_route.py index 18298eb..9c6e5f6 100644 --- a/backend/tests/test_tasks_route.py +++ b/backend/tests/test_tasks_route.py @@ -1,6 +1,8 @@ """Tests for task status polling route.""" from unittest.mock import patch, MagicMock +from app.utils.auth import TASK_ACCESS_SESSION_KEY + class TestTaskStatus: def test_pending_task(self, client, monkeypatch): @@ -9,6 +11,9 @@ class TestTaskStatus: mock_result.state = 'PENDING' mock_result.info = None + with client.session_transaction() as session: + session[TASK_ACCESS_SESSION_KEY] = ['test-task-id'] + with patch('app.routes.tasks.AsyncResult', return_value=mock_result): response = client.get('/api/tasks/test-task-id/status') @@ -24,6 +29,9 @@ class TestTaskStatus: mock_result.state = 'PROCESSING' mock_result.info = {'step': 'Converting page 3 of 10...'} + with client.session_transaction() as session: + session[TASK_ACCESS_SESSION_KEY] = ['processing-id'] + with patch('app.routes.tasks.AsyncResult', return_value=mock_result): response = client.get('/api/tasks/processing-id/status') @@ -42,6 +50,9 @@ class TestTaskStatus: 'filename': 'output.pdf', } + with client.session_transaction() as session: + session[TASK_ACCESS_SESSION_KEY] = ['success-id'] + with patch('app.routes.tasks.AsyncResult', return_value=mock_result): response = client.get('/api/tasks/success-id/status') @@ -57,10 +68,19 @@ class TestTaskStatus: mock_result.state = 'FAILURE' mock_result.info = Exception('Conversion failed due to corrupt PDF.') + with client.session_transaction() as session: + session[TASK_ACCESS_SESSION_KEY] = ['failed-id'] + with patch('app.routes.tasks.AsyncResult', return_value=mock_result): response = client.get('/api/tasks/failed-id/status') assert response.status_code == 200 data = response.get_json() assert data['state'] == 'FAILURE' - assert 'error' in data \ No newline at end of file + assert 'error' in data + + def test_unknown_task_without_access_returns_404(self, client): + """Should not expose task state without session or API ownership.""" + response = client.get('/api/tasks/unknown-task/status') + + assert response.status_code == 404 \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index bebf00f..834fab1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -12,12 +12,16 @@ + + + + diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml index 8aac8fd..c03f37d 100644 --- a/frontend/public/sitemap.xml +++ b/frontend/public/sitemap.xml @@ -1,212 +1,286 @@ - + https://dociva.io/ - 2026-03-14 + 2026-03-17 daily 1.0 https://dociva.io/about - 2026-03-14 + 2026-03-17 monthly 0.4 https://dociva.io/contact - 2026-03-14 + 2026-03-17 monthly 0.4 https://dociva.io/privacy - 2026-03-14 + 2026-03-17 yearly 0.3 https://dociva.io/terms - 2026-03-14 + 2026-03-17 yearly 0.3 https://dociva.io/pricing - 2026-03-14 + 2026-03-17 monthly 0.7 https://dociva.io/blog - 2026-03-14 + 2026-03-17 weekly 0.6 + + + https://dociva.io/blog/how-to-compress-pdf-online + 2026-03-17 + monthly + 0.6 + + + https://dociva.io/blog/convert-images-without-losing-quality + 2026-03-17 + monthly + 0.6 + + + https://dociva.io/blog/ocr-extract-text-from-images + 2026-03-17 + monthly + 0.6 + + + https://dociva.io/blog/merge-split-pdf-files + 2026-03-17 + monthly + 0.6 + + + https://dociva.io/blog/ai-chat-with-pdf-documents + 2026-03-17 + monthly + 0.6 + + https://dociva.io/tools/pdf-to-word - 2026-03-14 + 2026-03-17 weekly 0.9 https://dociva.io/tools/word-to-pdf - 2026-03-14 + 2026-03-17 weekly 0.9 https://dociva.io/tools/compress-pdf - 2026-03-14 + 2026-03-17 weekly 0.9 https://dociva.io/tools/merge-pdf - 2026-03-14 + 2026-03-17 weekly 0.9 https://dociva.io/tools/split-pdf - 2026-03-14 + 2026-03-17 weekly 0.8 https://dociva.io/tools/rotate-pdf - 2026-03-14 + 2026-03-17 weekly 0.7 https://dociva.io/tools/pdf-to-images - 2026-03-14 + 2026-03-17 weekly 0.8 https://dociva.io/tools/images-to-pdf - 2026-03-14 + 2026-03-17 weekly 0.8 https://dociva.io/tools/watermark-pdf - 2026-03-14 + 2026-03-17 weekly 0.7 https://dociva.io/tools/remove-watermark-pdf - 2026-03-14 + 2026-03-17 weekly 0.7 https://dociva.io/tools/protect-pdf - 2026-03-14 + 2026-03-17 weekly 0.8 https://dociva.io/tools/unlock-pdf - 2026-03-14 + 2026-03-17 weekly 0.8 https://dociva.io/tools/page-numbers - 2026-03-14 + 2026-03-17 weekly 0.7 https://dociva.io/tools/reorder-pdf - 2026-03-14 + 2026-03-17 weekly 0.7 https://dociva.io/tools/extract-pages - 2026-03-14 + 2026-03-17 weekly 0.7 https://dociva.io/tools/pdf-editor - 2026-03-14 + 2026-03-17 weekly 0.8 https://dociva.io/tools/pdf-flowchart - 2026-03-14 + 2026-03-17 weekly 0.7 https://dociva.io/tools/pdf-to-excel - 2026-03-14 + 2026-03-17 weekly 0.8 + + https://dociva.io/tools/sign-pdf + 2026-03-17 + weekly + 0.8 + + + https://dociva.io/tools/crop-pdf + 2026-03-17 + weekly + 0.7 + + + https://dociva.io/tools/flatten-pdf + 2026-03-17 + weekly + 0.7 + + + https://dociva.io/tools/repair-pdf + 2026-03-17 + weekly + 0.7 + + + https://dociva.io/tools/pdf-metadata + 2026-03-17 + weekly + 0.6 + https://dociva.io/tools/image-converter - 2026-03-14 + 2026-03-17 weekly 0.8 https://dociva.io/tools/image-resize - 2026-03-14 + 2026-03-17 weekly 0.8 https://dociva.io/tools/compress-image - 2026-03-14 + 2026-03-17 weekly 0.8 https://dociva.io/tools/remove-background - 2026-03-14 + 2026-03-17 weekly 0.8 + + https://dociva.io/tools/image-crop + 2026-03-17 + weekly + 0.7 + + + https://dociva.io/tools/image-rotate-flip + 2026-03-17 + weekly + 0.7 + https://dociva.io/tools/ocr - 2026-03-14 + 2026-03-17 weekly 0.8 https://dociva.io/tools/chat-pdf - 2026-03-14 + 2026-03-17 weekly 0.8 https://dociva.io/tools/summarize-pdf - 2026-03-14 + 2026-03-17 weekly 0.8 https://dociva.io/tools/translate-pdf - 2026-03-14 + 2026-03-17 weekly 0.8 https://dociva.io/tools/extract-tables - 2026-03-14 + 2026-03-17 weekly 0.8 @@ -214,32 +288,56 @@ https://dociva.io/tools/html-to-pdf - 2026-03-14 + 2026-03-17 weekly 0.7 https://dociva.io/tools/qr-code - 2026-03-14 + 2026-03-17 weekly 0.7 https://dociva.io/tools/video-to-gif - 2026-03-14 + 2026-03-17 weekly 0.7 https://dociva.io/tools/word-counter - 2026-03-14 + 2026-03-17 weekly 0.6 https://dociva.io/tools/text-cleaner - 2026-03-14 + 2026-03-17 weekly 0.6 + + https://dociva.io/tools/pdf-to-pptx + 2026-03-17 + weekly + 0.8 + + + https://dociva.io/tools/excel-to-pdf + 2026-03-17 + weekly + 0.8 + + + https://dociva.io/tools/pptx-to-pdf + 2026-03-17 + weekly + 0.8 + + + https://dociva.io/tools/barcode-generator + 2026-03-17 + weekly + 0.7 + \ No newline at end of file diff --git a/frontend/public/social-preview.svg b/frontend/public/social-preview.svg new file mode 100644 index 0000000..42fcece --- /dev/null +++ b/frontend/public/social-preview.svg @@ -0,0 +1,35 @@ + + + + + + + + + + DOCIVA + Online PDF, Image, and AI Tools + Convert, compress, edit, OCR, and automate document workflows from one workspace. + + + dociva.io + + + + No signup required + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/seo/SEOHead.tsx b/frontend/src/components/seo/SEOHead.tsx index 485da5c..c57a6c3 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, getOgLocale } from '@/utils/seo'; +import { buildLanguageAlternates, buildSocialImageUrl, getOgLocale } from '@/utils/seo'; const SITE_NAME = 'Dociva'; @@ -28,6 +28,7 @@ export default function SEOHead({ title, description, path, type = 'website', js const { i18n } = useTranslation(); const origin = typeof window !== 'undefined' ? window.location.origin : ''; const canonicalUrl = `${origin}${path}`; + const socialImageUrl = buildSocialImageUrl(origin); const fullTitle = `${title} — ${SITE_NAME}`; const languageAlternates = buildLanguageAlternates(origin, path); const currentOgLocale = getOgLocale(i18n.language); @@ -55,6 +56,8 @@ export default function SEOHead({ title, description, path, type = 'website', js + + {languageAlternates .filter((alternate) => alternate.ogLocale !== currentOgLocale) @@ -63,9 +66,11 @@ export default function SEOHead({ title, description, path, type = 'website', js ))} {/* Twitter */} - + + + {/* JSON-LD Structured Data */} {schemas.map((schema, i) => ( diff --git a/frontend/src/components/seo/ToolLandingPage.tsx b/frontend/src/components/seo/ToolLandingPage.tsx index cb3ac4b..0a27d61 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, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale } from '@/utils/seo'; +import { buildLanguageAlternates, buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale } from '@/utils/seo'; import FAQSection from './FAQSection'; import RelatedTools from './RelatedTools'; import ToolRating from '@/components/shared/ToolRating'; @@ -40,6 +40,7 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps const origin = typeof window !== 'undefined' ? window.location.origin : ''; const path = `/tools/${slug}`; const canonicalUrl = `${origin}${path}`; + const socialImageUrl = buildSocialImageUrl(origin); const languageAlternates = buildLanguageAlternates(origin, path); const currentOgLocale = getOgLocale(i18n.language); @@ -82,6 +83,8 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps + + {languageAlternates .filter((alternate) => alternate.ogLocale !== currentOgLocale) @@ -90,9 +93,11 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps ))} {/* Twitter */} - + + + {/* Structured Data */} diff --git a/frontend/src/components/shared/SocialProofStrip.tsx b/frontend/src/components/shared/SocialProofStrip.tsx index 2f74915..32ab80d 100644 --- a/frontend/src/components/shared/SocialProofStrip.tsx +++ b/frontend/src/components/shared/SocialProofStrip.tsx @@ -37,17 +37,28 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro return null; } + const hasReliableUsageStats = stats.total_files_processed >= 25; + const hasReliableRating = stats.rating_count >= 3; + const topTools = stats.top_tools.slice(0, 3).map((tool) => { const seo = getToolSEO(tool.tool); return seo ? t(`tools.${seo.i18nKey}.title`) : tool.tool; }); const cards = [ - { label: t('socialProof.processedFiles'), value: stats.total_files_processed.toLocaleString() }, - { label: t('socialProof.successRate'), value: `${stats.success_rate}%` }, - { label: t('socialProof.last24h'), value: stats.files_last_24h.toLocaleString() }, - { label: t('socialProof.averageRating'), value: `${stats.average_rating.toFixed(1)} / 5` }, - ]; + hasReliableUsageStats + ? { label: t('socialProof.processedFiles'), value: stats.total_files_processed.toLocaleString() } + : null, + hasReliableUsageStats + ? { label: t('socialProof.successRate'), value: `${stats.success_rate}%` } + : null, + hasReliableUsageStats + ? { label: t('socialProof.last24h'), value: stats.files_last_24h.toLocaleString() } + : null, + hasReliableRating + ? { label: t('socialProof.averageRating'), value: `${stats.average_rating.toFixed(1)} / 5` } + : null, + ].filter((card): card is { label: string; value: string } => Boolean(card)); return (
@@ -73,20 +84,31 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro )} -
- {cards.map((card) => ( -
-

{card.label}

-

{card.value}

-
- ))} -
+ {cards.length > 0 ? ( +
+ {cards.map((card) => ( +
+

{card.label}

+

{card.value}

+
+ ))} +
+ ) : ( +
+ {t( + 'socialProof.pendingSummary', + 'Public activity metrics appear here after we collect enough completed jobs and verified ratings.' + )} +
+ )}

- {t('socialProof.basedOnRatings', { count: stats.rating_count })} + {hasReliableRating + ? t('socialProof.basedOnRatings', { count: stats.rating_count }) + : t('socialProof.pendingRatings', 'Ratings summary will unlock after enough verified feedback.')}

{t('socialProof.viewDevelopers')} diff --git a/frontend/src/pages/DevelopersPage.tsx b/frontend/src/pages/DevelopersPage.tsx index 6f9be8e..f6ed8c8 100644 --- a/frontend/src/pages/DevelopersPage.tsx +++ b/frontend/src/pages/DevelopersPage.tsx @@ -22,15 +22,14 @@ const ENDPOINT_GROUPS = [ }, ]; -const CURL_UPLOAD = `curl -X POST https://your-domain.example/api/v1/convert/pdf-to-word \\ - -H "X-API-Key: spdf_your_api_key" \\ - -F "file=@./sample.pdf"`; - -const CURL_POLL = `curl https://your-domain.example/api/v1/tasks//status \\ - -H "X-API-Key: spdf_your_api_key"`; - export default function DevelopersPage() { const { t } = useTranslation(); + const origin = typeof window !== 'undefined' ? window.location.origin : 'https://dociva.io'; + const curlUpload = `curl -X POST ${origin}/api/v1/convert/pdf-to-word \\ + -H "X-API-Key: spdf_your_api_key" \\ + -F "file=@./sample.pdf"`; + const curlPoll = `curl ${origin}/api/tasks//status \\ + -H "X-API-Key: spdf_your_api_key"`; return ( <> @@ -91,12 +90,12 @@ export default function DevelopersPage() {

{t('pages.developers.authExampleTitle')}

{t('pages.developers.authExampleSubtitle')}

-
{CURL_UPLOAD}
+
{curlUpload}

{t('pages.developers.pollExampleTitle')}

{t('pages.developers.pollExampleSubtitle')}

-
{CURL_POLL}
+
{curlPoll}
diff --git a/frontend/src/utils/seo.ts b/frontend/src/utils/seo.ts index b6b4044..f9d28d6 100644 --- a/frontend/src/utils/seo.ts +++ b/frontend/src/utils/seo.ts @@ -17,6 +17,8 @@ export interface LanguageAlternate { ogLocale: string; } +const DEFAULT_SOCIAL_IMAGE_PATH = '/social-preview.svg'; + const LANGUAGE_CONFIG: Record<'en' | 'ar' | 'fr', { hrefLang: string; ogLocale: string }> = { en: { hrefLang: 'en', ogLocale: 'en_US' }, ar: { hrefLang: 'ar', ogLocale: 'ar_SA' }, @@ -42,6 +44,10 @@ export function buildLanguageAlternates(origin: string, path: string): LanguageA })); } +export function buildSocialImageUrl(origin: string): string { + return `${origin}${DEFAULT_SOCIAL_IMAGE_PATH}`; +} + /** * Generate WebApplication JSON-LD structured data for a tool page. */ diff --git a/scripts/generate_sitemap.py b/scripts/generate_sitemap.py index 4f6c2ba..bbcbdde 100644 --- a/scripts/generate_sitemap.py +++ b/scripts/generate_sitemap.py @@ -11,7 +11,9 @@ Usage: import argparse import os +import re from datetime import datetime +from pathlib import Path # ─── Route definitions with priority and changefreq ────────────────────────── @@ -95,9 +97,21 @@ TOOL_GROUPS = [ ] +def get_blog_slugs() -> list[str]: + repo_root = Path(__file__).resolve().parents[1] + blog_articles_path = repo_root / 'frontend' / 'src' / 'content' / 'blogArticles.ts' + + if not blog_articles_path.exists(): + return [] + + content = blog_articles_path.read_text(encoding='utf-8') + return list(dict.fromkeys(re.findall(r"slug:\s*'([^']+)'", content))) + + def generate_sitemap(domain: str) -> str: today = datetime.now().strftime('%Y-%m-%d') urls = [] + blog_slugs = get_blog_slugs() # Static pages for page in PAGES: @@ -108,6 +122,16 @@ def generate_sitemap(domain: str) -> str: {page["priority"]} ''') + if blog_slugs: + urls.append('\n ') + for slug in blog_slugs: + urls.append(f''' + {domain}/blog/{slug} + {today} + monthly + 0.6 + ''') + # Tool pages by category for label, routes in TOOL_GROUPS: urls.append(f'\n ') @@ -143,7 +167,7 @@ def main(): with open(args.output, 'w', encoding='utf-8') as f: f.write(sitemap) - total = len(PAGES) + sum(len(routes) for _, routes in TOOL_GROUPS) + total = len(PAGES) + len(get_blog_slugs()) + sum(len(routes) for _, routes in TOOL_GROUPS) print(f"Sitemap generated: {args.output}") print(f"Total URLs: {total}")