وبلاگ: نام‌گذاری و متادیتای تصاویر، پرسش‌وپاسخ مقاله، میزبانی تصاویر چسبانده‌شده

- Alt/Title تصویر شاخص اکنون ذخیره می‌شود (ستون‌های جدید image_alt/image_title — قبلاً بی‌صدا دور ریخته می‌شد)
- Alt و Title تصاویر داخل متن پس از درج هم قابل ویرایش‌اند (کنار کپشن)
- نام فایل ذخیره‌سازی خوانا: نام دلخواه هنگام درج یا نام خود فایل (به‌جای UUID خالی)
- پنل پرسش‌وپاسخ پایان مقاله (ستون‌های faqs/faqs_en) با ظاهر آکاردئونی سایت و اسکیمای FAQPage
- چسباندن از Outline و هر منبع خارجی: تصاویر خودکار دانلود و در Object Storage سایت میزبانی می‌شوند (endpoint جدید import-image با محافظ SSRF) + دکمه میزبانی مجدد روی تصاویر خارجی موجود
This commit is contained in:
MMD
2026-09-26 22:46:56 +03:30
parent 851e3fc2dc
commit 1d29e50852
15 changed files with 580 additions and 45 deletions
+121 -17
View File
@@ -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,