From 1d29e5085264beb7ef1d98dbc50df86df39b835f Mon Sep 17 00:00:00 2001 From: MMD Date: Sat, 26 Sep 2026 22:46:56 +0330 Subject: [PATCH] =?UTF-8?q?=D9=88=D8=A8=D9=84=D8=A7=DA=AF:=20=D9=86=D8=A7?= =?UTF-8?q?=D9=85=E2=80=8C=DA=AF=D8=B0=D8=A7=D8=B1=DB=8C=20=D9=88=20=D9=85?= =?UTF-8?q?=D8=AA=D8=A7=D8=AF=DB=8C=D8=AA=D8=A7=DB=8C=20=D8=AA=D8=B5=D8=A7?= =?UTF-8?q?=D9=88=DB=8C=D8=B1=D8=8C=20=D9=BE=D8=B1=D8=B3=D8=B4=E2=80=8C?= =?UTF-8?q?=D9=88=D9=BE=D8=A7=D8=B3=D8=AE=20=D9=85=D9=82=D8=A7=D9=84=D9=87?= =?UTF-8?q?=D8=8C=20=D9=85=DB=8C=D8=B2=D8=A8=D8=A7=D9=86=DB=8C=20=D8=AA?= =?UTF-8?q?=D8=B5=D8=A7=D9=88=DB=8C=D8=B1=20=DA=86=D8=B3=D8=A8=D8=A7=D9=86?= =?UTF-8?q?=D8=AF=D9=87=E2=80=8C=D8=B4=D8=AF=D9=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Alt/Title تصویر شاخص اکنون ذخیره می‌شود (ستون‌های جدید image_alt/image_title — قبلاً بی‌صدا دور ریخته می‌شد) - Alt و Title تصاویر داخل متن پس از درج هم قابل ویرایش‌اند (کنار کپشن) - نام فایل ذخیره‌سازی خوانا: نام دلخواه هنگام درج یا نام خود فایل (به‌جای UUID خالی) - پنل پرسش‌وپاسخ پایان مقاله (ستون‌های faqs/faqs_en) با ظاهر آکاردئونی سایت و اسکیمای FAQPage - چسباندن از Outline و هر منبع خارجی: تصاویر خودکار دانلود و در Object Storage سایت میزبانی می‌شوند (endpoint جدید import-image با محافظ SSRF) + دکمه میزبانی مجدد روی تصاویر خارجی موجود --- backend/app/api/admin.py | 138 +++++++++++++++--- backend/app/migrations.py | 11 ++ backend/app/models.py | 7 + backend/app/schemas.py | 13 ++ frontend/src/admin/ArticleWritePage.tsx | 85 ++++++++++- frontend/src/admin/adminClient.ts | 7 +- frontend/src/components/ArticleFaq.tsx | 65 +++++++++ frontend/src/components/ContactSection.tsx | 2 +- .../src/components/admin/ArticleEditor.tsx | 135 ++++++++++++++++- frontend/src/components/admin/ImageField.tsx | 26 +++- .../src/components/admin/ImageNodeView.tsx | 72 ++++++++- frontend/src/data/site.ts | 6 +- frontend/src/i18n/localize.ts | 1 + frontend/src/pages/ArticleDetail.tsx | 50 +++++-- frontend/src/seo.ts | 7 +- 15 files changed, 580 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/ArticleFaq.tsx diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 9e84a87..245fe85 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -1,13 +1,19 @@ """انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند.""" import csv +import ipaddress import io +import re +import socket +import urllib.request import uuid from datetime import datetime, timezone +from urllib.parse import urlparse import boto3 -from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status from PIL import Image, ImageOps +from pydantic import BaseModel from sqlalchemy import func from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -294,9 +300,41 @@ def _optimize_image(data: bytes, content_type: str) -> tuple[bytes, str, int, in return data, content_type, orig_w, orig_h +def _readable_key(name: str, ext: str) -> str: + """کلید ذخیره‌سازی خوانا از نام دلخواه/نام فایل — «۱۴۰۳/elahieh-tower-a1b2c3.jpg». + + نام‌گذاری‌ها ایمن می‌شوند: نویسه‌های ناامن و جداکننده مسیر حذف، فاصله‌ها خط تیره، + طول محدود؛ خالی بودن نام → شناسه تصادفی مثل قبل. پسوند ۶ نویسه‌ای تضمین یکتایی است. + """ + slug = re.sub(r"[^\w؀-ۿ\- ]+", "", name, flags=re.UNICODE).strip() + slug = re.sub(r"[\s_]+", "-", slug).strip("-")[:80] + unique = uuid.uuid4().hex[:6] + base = f"{slug}-{unique}" if slug else uuid.uuid4().hex + return f"{datetime.now(timezone.utc):%Y/%m}/{base}.{ext}" + + +def _store_image(data: bytes, content_type: str, name: str) -> str: + """ذخیره تصویر در Object Storage با کلید خوانا — URL نسبی برمی‌گردد.""" + ext = ALLOWED_IMAGE_TYPES[content_type] + key = _readable_key(name, ext) + s3 = boto3.client( + "s3", + endpoint_url=settings.s3_endpoint, + aws_access_key_id=settings.s3_access_key, + aws_secret_access_key=settings.s3_secret_key, + region_name="us-east-1", + ) + try: + s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type) + except Exception: + raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود") + return f"{settings.media_public_base}/{key}" + + @router.post("/upload") async def upload_media( file: UploadFile = File(...), + name: str = Form(""), _admin: models.AdminUser = Depends(get_current_admin), ): content_type = (file.content_type or "").lower() @@ -325,24 +363,90 @@ async def upload_media( # تصویر خراب/ناشناخته — همان داده اصلی ذخیره می‌شود pass - now = datetime.now(timezone.utc) - key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}" - - s3 = boto3.client( - "s3", - endpoint_url=settings.s3_endpoint, - aws_access_key_id=settings.s3_access_key, - aws_secret_access_key=settings.s3_secret_key, - region_name="us-east-1", - ) - try: - s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type) - except Exception: - raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود") + # نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند + display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0] + url = _store_image(data, content_type, display_name) return { - "url": f"{settings.media_public_base}/{key}", - "key": key, + "url": url, + "key": url.removeprefix(f"{settings.media_public_base}/"), + "size": len(data), + "content_type": content_type, + "width": width, + "height": height, + } + + +# ---------- درون‌ریزی تصویر از نشانی خارجی (چسباندن از Outline و مانند آن) ---------- + + +class ImportImageIn(BaseModel): + url: str + + +def _assert_public_http(url: str) -> None: + """فقط http/https به مقاصد عمومی — جلوگیری از SSRF به شبکه داخلی.""" + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.hostname: + raise HTTPException(status_code=400, detail="نشانی تصویر معتبر نیست") + try: + infos = socket.getaddrinfo(parsed.hostname, None) + except socket.gaierror: + raise HTTPException(status_code=400, detail="میزبان تصویر پیدا نشد") + for info in infos: + ip = ipaddress.ip_address(info[4][0]) + if not ip.is_global: + raise HTTPException(status_code=400, detail="نشانی تصویر به میزبان داخلی اشاره می‌کند") + + +@router.post("/import-image") +async def import_image( + payload: ImportImageIn, + _admin: models.AdminUser = Depends(get_current_admin), +): + """دانلود تصویر از نشانی خارجی، بهینه‌سازی و ذخیره در Object Storage خودِ سایت — + تصاویر چسبانده‌شده از Outline/مستندات خارجی به میزبانی سایت منتقل می‌شوند.""" + _assert_public_http(payload.url) + + max_bytes = settings.media_max_upload_mb * 1024 * 1024 + request = urllib.request.Request( + payload.url, headers={"User-Agent": "ArameshSite/1.0 (+image import)"} + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + content_type = (response.headers.get("Content-Type") or "").split(";")[0].strip().lower() + if content_type not in ALLOWED_IMAGE_TYPES: + raise HTTPException( + status_code=415, + detail="فرمت تصویر پشتیبانی نمی‌شود (مجاز: jpg، png، webp، gif، avif)", + ) + data = response.read(max_bytes + 1) + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=502, detail="دانلود تصویر از نشانی ناموفق بود") + if len(data) == 0: + raise HTTPException(status_code=400, detail="تصویر خالی است") + if len(data) > max_bytes: + raise HTTPException( + status_code=413, + detail=f"حجم تصویر بیش از {settings.media_max_upload_mb} مگابایت است", + ) + + width = height = None + if content_type in REENCODE_TYPES: + try: + data, content_type, width, height = _optimize_image(data, content_type) + except Exception: + pass + + # نام خوانا از مسیر نشانی — بدون پارامترهای کوئری + url_name = urlparse(payload.url).path.rsplit("/", 1)[-1].rsplit(".", 1)[0] + media_url = _store_image(data, content_type, url_name) + + return { + "url": media_url, + "key": media_url.removeprefix(f"{settings.media_public_base}/"), "size": len(data), "content_type": content_type, "width": width, diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 60ca893..0f299d6 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -145,6 +145,17 @@ def run_ddl_migrations() -> None: conn.execute( text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''") ) + # متادیتای تصویر شاخص مقاله + پرسش‌وپاسخ‌های پایان مقاله (سئو) + conn.execute( + text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''") + ) + conn.execute( + text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_title VARCHAR(256) NOT NULL DEFAULT ''") + ) + conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs JSON")) + conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs_en JSON")) + # سطرهای موجود ستون تازه را NULL می‌گیرند — آرایه خالی یعنی «بدون پرسش‌وپاسخ» + conn.execute(text("UPDATE articles SET faqs = '[]'::json WHERE faqs IS NULL")) # ستون‌های انگلیسی — رشته‌ها با پیش‌فرض خالی، JSON بدون پیش‌فرض (NULL = بدون ترجمه) for table, column, col_type in EN_COLUMNS: if col_type == "JSON": diff --git a/backend/app/models.py b/backend/app/models.py index c8b9d31..2140107 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +from typing import Optional from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column @@ -95,12 +96,18 @@ class Article(Base): category: Mapped[str] = mapped_column(String(64), default="") category_en: Mapped[str] = mapped_column(String(64), default="") image: Mapped[str] = mapped_column(Text, default="") + # متن جایگزین و عنوان تصویر شاخص — سئو و دسترس‌پذیری + image_alt: Mapped[str] = mapped_column(String(256), default="") + image_title: Mapped[str] = mapped_column(String(256), default="") read: Mapped[str] = mapped_column(String(32), default="") read_en: Mapped[str] = mapped_column(String(32), default="") excerpt: Mapped[str] = mapped_column(Text, default="") excerpt_en: Mapped[str] = mapped_column(Text, default="") body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف) body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی + # پرسش و پاسخ‌های پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage) + faqs: Mapped[list] = mapped_column(JSON, default=list) + faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی # draft | published — پیش‌نویس فقط برای ادمین دیده می‌شود status: Mapped[str] = mapped_column(String(16), default="published") # کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b830dcb..948a118 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -77,12 +77,17 @@ class ArticleOut(ORMModel): category: str category_en: str = "" image: str + image_alt: str = "" + image_title: str = "" read: str read_en: str = "" excerpt: str excerpt_en: str = "" body: dict body_en: Optional[dict] = None + # پرسش‌وپاسخ‌های پایان مقاله — [{question, answer}]؛ سطرهای قدیمی ممکن است NULL باشند + faqs: Optional[list[dict]] = None + faqs_en: Optional[list[dict]] = None status: str seo_keyphrase: str @@ -484,12 +489,16 @@ class ArticleIn(BaseModel): category: str = "" category_en: str = "" image: str = "" + image_alt: str = "" + image_title: str = "" read: str = "" read_en: str = "" excerpt: str = "" excerpt_en: str = "" body: dict = Field(default_factory=lambda: dict(EMPTY_DOC)) body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC)) + faqs: list[dict] = [] + faqs_en: list[dict] = [] status: Literal["draft", "published"] = "draft" seo_keyphrase: str = Field(default="", max_length=128) _validate_body = field_validator("body")(validate_doc_body) @@ -786,12 +795,16 @@ class ArticlePatch(BaseModel): category: Optional[str] = None category_en: Optional[str] = None image: Optional[str] = None + image_alt: Optional[str] = None + image_title: Optional[str] = None read: Optional[str] = None read_en: Optional[str] = None excerpt: Optional[str] = None excerpt_en: Optional[str] = None body: Optional[dict] = None body_en: Optional[dict] = None + faqs: Optional[list[dict]] = None + faqs_en: Optional[list[dict]] = None status: Optional[Literal["draft", "published"]] = None seo_keyphrase: Optional[str] = None _validate_body = field_validator("body")(validate_doc_body) diff --git a/frontend/src/admin/ArticleWritePage.tsx b/frontend/src/admin/ArticleWritePage.tsx index 8916d09..65cd498 100644 --- a/frontend/src/admin/ArticleWritePage.tsx +++ b/frontend/src/admin/ArticleWritePage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Controller, useController, useForm } from 'react-hook-form' import { Link, Navigate, useNavigate, useParams } from 'react-router-dom' -import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react' +import { ArrowRightIcon, CheckIcon, EyeIcon, PlusIcon, Trash2Icon, XIcon } from 'lucide-react' import Swal from 'sweetalert2' import { useSiteData } from '../api' import { useAdmin } from './AdminContext' @@ -54,6 +54,75 @@ function normalizeTags(value: unknown): string[] { return [] } +/** یک ردیف پرسش‌وپاسخ مقاله */ +type ArticleFaq = { question: string; answer: string } + +/** + * پنل پرسش‌وپاسخ‌های پایان مقاله — مثل بخش متا دیسکریپشن زیر ویرایشگر. + * در زبان فارسی ستون faqs و در زبان انگلیسی faqs_en را ویرایش می‌کند؛ + * نسخه انگلیسیِ خالی در سایت به فارسی برمی‌گردد. ذخیره‌سازی خودکار است. + */ +function FaqPanel({ + faqs, + onChange, +}: { + faqs: ArticleFaq[] + onChange: (next: ArticleFaq[]) => void +}) { + const update = (index: number, patch: Partial) => { + const next = faqs.map((f, i) => (i === index ? { ...f, ...patch } : f)) + onChange(next) + } + const remove = (index: number) => onChange(faqs.filter((_, i) => i !== index)) + const add = () => onChange([...faqs, { question: '', answer: '' }]) + + return ( +
+ {faqs.map((faq, i) => ( +
+
+ پرسش {i + 1} + +
+ update(i, { question: e.target.value })} + placeholder="پرسش مخاطب…" + aria-label={`متن پرسش ${i + 1}`} + className="w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none" + /> +