وبلاگ: نامگذاری و متادیتای تصاویر، پرسشوپاسخ مقاله، میزبانی تصاویر چسباندهشده
- 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:
+121
-17
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user