693 lines
30 KiB
Python
693 lines
30 KiB
Python
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
|
|
|
import csv
|
|
import ipaddress
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import urllib.request
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from urllib.parse import urlparse
|
|
|
|
import boto3
|
|
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
|
|
|
|
from .. import models, schemas
|
|
from ..auth import create_access_token, get_current_admin, get_user_from_token, hash_password, oauth2_scheme, verify_password
|
|
from ..config import settings
|
|
from ..database import get_db
|
|
from ..rate_limit import clear as clear_rate_limit
|
|
from ..rate_limit import ensure_allowed, record_failure
|
|
from ..schemas import (
|
|
ArticleIn,
|
|
ArticleOut,
|
|
ArticlePatch,
|
|
BeforeAfterIn,
|
|
BeforeAfterOut,
|
|
BeforeAfterPatch,
|
|
BrandIn,
|
|
BrandOut,
|
|
BrandPatch,
|
|
ChangePasswordIn,
|
|
ContactSectionIn,
|
|
ContactSectionOut,
|
|
ContactSectionPatch,
|
|
ContactMessageOut,
|
|
FaqIn,
|
|
FaqOut,
|
|
FaqPatch,
|
|
FooterIn,
|
|
FooterOut,
|
|
FooterPatch,
|
|
GalleryImageIn,
|
|
GalleryImageOut,
|
|
GalleryImagePatch,
|
|
HeroIn,
|
|
HeroOut,
|
|
HeroPatch,
|
|
InvestmentModelIn,
|
|
InvestmentModelOut,
|
|
InvestmentModelPatch,
|
|
LoginIn,
|
|
MessageReadIn,
|
|
NavItemIn,
|
|
NavItemOut,
|
|
NavItemPatch,
|
|
NewsletterSectionIn,
|
|
NewsletterOut,
|
|
NewsletterSectionPatch,
|
|
OfficeIn,
|
|
OfficeOut,
|
|
OfficePatch,
|
|
PageHeroIn,
|
|
PageHeroOut,
|
|
PageHeroPatch,
|
|
ProcessStepIn,
|
|
ProcessStepOut,
|
|
ProcessStepPatch,
|
|
ProjectIn,
|
|
ProjectOut,
|
|
ProjectPatch,
|
|
ReorderIn,
|
|
SeoMetaIn,
|
|
SeoMetaOut,
|
|
SeoMetaPatch,
|
|
SectionHeadingIn,
|
|
SectionHeadingOut,
|
|
SectionHeadingPatch,
|
|
ServiceIn,
|
|
ServiceOut,
|
|
ServicePatch,
|
|
StatIn,
|
|
StatOut,
|
|
StatPatch,
|
|
StatusLabelIn,
|
|
StatusLabelOut,
|
|
StatusLabelPatch,
|
|
StoryIn,
|
|
StoryOut,
|
|
StoryPatch,
|
|
SubscriberOut,
|
|
TeamMemberIn,
|
|
TeamMemberOut,
|
|
TeamMemberPatch,
|
|
TestimonialIn,
|
|
TestimonialOut,
|
|
TestimonialPatch,
|
|
TimelineItemIn,
|
|
TimelineItemOut,
|
|
TimelineItemPatch,
|
|
TokenOut,
|
|
WhyUsItemIn,
|
|
WhyUsItemOut,
|
|
WhyUsItemPatch,
|
|
)
|
|
|
|
# روتر عمومی برای ورود — بدون نیاز به توکن
|
|
auth_router = APIRouter()
|
|
|
|
# روتر محافظتشده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند
|
|
async def admin_access_guard(
|
|
request: Request,
|
|
token: str = Depends(oauth2_scheme),
|
|
db: Session = Depends(get_db),
|
|
) -> models.AdminUser:
|
|
"""نقشگارد — 'admin' همهکاره؛ 'creator' فقط مقالهنویسی: ساخت پیشنویس،
|
|
خواندن/ویرایش پیشنویس خودش، و آپلود تصویر. بقیهٔ مسیرهای مدیریتی ۴۰۳."""
|
|
user = get_user_from_token(token, db)
|
|
if user.role == "admin":
|
|
return user
|
|
if user.role != "creator":
|
|
raise HTTPException(status_code=403, detail="نقش ناشناخته")
|
|
|
|
path = request.url.path.rstrip("/")
|
|
method = request.method
|
|
|
|
# آپلود تصویر مقالهها — مجاز
|
|
if path in ("/api/admin/upload", "/api/admin/import-image") and method == "POST":
|
|
return user
|
|
# فهرست و ساخت مقاله — مجاز (ساخت همیشه پیشنویس است و نویسندهاش ثبت میشود)
|
|
if path == "/api/admin/articles" and method in ("GET", "POST"):
|
|
return user
|
|
# خواندن/ویرایش پیشنویس خودِ نویسنده — مالکیت در هندلر هم دوباره چک میشود
|
|
m = re.fullmatch(r"/api/admin/articles/(\d+)", path)
|
|
if m and method in ("GET", "PUT", "PATCH"):
|
|
article = db.get(models.Article, int(m.group(1)))
|
|
if article is not None and article.author_id == user.id:
|
|
return user
|
|
# ۴۰۴ نه ۴۰۳ — مقالههای بقیه برای نویسنده اصلاً وجود ندارند
|
|
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
|
raise HTTPException(status_code=403, detail="دسترسی نویسنده محدود است")
|
|
|
|
|
|
router = APIRouter(
|
|
dependencies=[
|
|
Depends(get_current_admin),
|
|
Depends(admin_access_guard),
|
|
]
|
|
)
|
|
|
|
|
|
|
|
@auth_router.post("/auth/login", response_model=TokenOut)
|
|
def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
|
|
ensure_allowed(request)
|
|
user = db.query(models.AdminUser).filter(models.AdminUser.username == payload.username).first()
|
|
if user is None or not verify_password(payload.password, user.password_hash):
|
|
record_failure(request)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="نام کاربری یا رمز عبور اشتباه است",
|
|
)
|
|
clear_rate_limit(request)
|
|
return TokenOut(access_token=create_access_token(user.username, user.role), role=user.role)
|
|
|
|
|
|
@auth_router.post("/auth/change-password")
|
|
def change_password(
|
|
payload: ChangePasswordIn,
|
|
admin: models.AdminUser = Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""تغییر رمز ادمین واردشده — رمز فعلی باید درست باشد."""
|
|
if not verify_password(payload.current_password, admin.password_hash):
|
|
raise HTTPException(status_code=400, detail="رمز عبور فعلی اشتباه است")
|
|
admin.password_hash = hash_password(payload.new_password)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
def register_crud(
|
|
path: str, model, out_schema, create_schema, patch_schema=None,
|
|
creator_scoped: bool = False,
|
|
) -> None:
|
|
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
|
|
حذف و ترتیببندی) روی روتر مدیریت.
|
|
|
|
creator_scoped=True (فقط مقالهها): نقش creator فقط رکوردهای خودش را میبیند،
|
|
ساختش همیشه پیشنویس است و ویرایشش هرگز منتشر نمیشود."""
|
|
|
|
@router.get(f"/{path}", response_model=list[out_schema])
|
|
def list_items(db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
|
query = db.query(model)
|
|
if creator_scoped and admin.role == "creator":
|
|
query = query.filter(model.author_id == admin.id)
|
|
if hasattr(model, "position"):
|
|
query = query.order_by(model.position, model.id)
|
|
else:
|
|
query = query.order_by(model.id)
|
|
return query.all()
|
|
|
|
@router.post(f"/{path}", response_model=out_schema, status_code=201)
|
|
def create_item(payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
|
item = model(**payload.model_dump())
|
|
if creator_scoped:
|
|
# نویسنده ثبت میشود؛ ساخت توسط creator همیشه پیشنویس است
|
|
item.author_id = admin.id
|
|
if admin.role == "creator":
|
|
item.status = "draft"
|
|
if hasattr(model, "position"):
|
|
# آیتم جدید در انتهای لیست قرار میگیرد
|
|
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
|
|
db.add(item)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
|
|
db.refresh(item)
|
|
return item
|
|
|
|
@router.get(f"/{path}/{{item_id}}", response_model=out_schema)
|
|
def get_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
|
item = db.get(model, item_id)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
|
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
|
|
# مقالهٔ بقیه برای creator وجود ندارد
|
|
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
|
return item
|
|
|
|
@router.put(f"/{path}/{{item_id}}", response_model=out_schema)
|
|
def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
|
item = db.get(model, item_id)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
|
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
|
|
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
|
for key, value in payload.model_dump().items():
|
|
setattr(item, key, value)
|
|
if creator_scoped and admin.role == "creator":
|
|
item.status = "draft" # نویسنده حق انتشار ندارد
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
|
|
db.refresh(item)
|
|
return item
|
|
|
|
if patch_schema is not None:
|
|
|
|
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
|
|
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
|
"""ویرایش جزئی — فقط فیلدهای ارسالشده بهروزرسانی میشوند (برای ویرایش زنده)."""
|
|
item = db.get(model, item_id)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
|
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
|
|
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
|
|
for key, value in payload.model_dump(exclude_unset=True).items():
|
|
setattr(item, key, value)
|
|
if creator_scoped and admin.role == "creator":
|
|
item.status = "draft" # نویسنده حق انتشار ندارد
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
|
|
db.refresh(item)
|
|
return item
|
|
|
|
@router.post(f"/{path}/reorder", response_model=list[out_schema])
|
|
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
|
if creator_scoped and admin.role == "creator":
|
|
raise HTTPException(status_code=403, detail="ترتیب مقالهها فقط توسط مدیر تغییر میکند")
|
|
"""ذخیره ترتیب جدید آیتمها بر اساس لیست idهای ارسالی."""
|
|
if not hasattr(model, "position"):
|
|
raise HTTPException(status_code=400, detail="این منبع از ترتیببندی پشتیبانی نمیکند")
|
|
items = db.query(model).filter(model.id.in_(payload.ids)).all()
|
|
if len(items) != len(payload.ids):
|
|
raise HTTPException(status_code=404, detail="برخی رکوردها یافت نشدند")
|
|
by_id = {item.id: item for item in items}
|
|
for pos, item_id in enumerate(payload.ids):
|
|
by_id[item_id].position = pos
|
|
db.commit()
|
|
return db.query(model).order_by(model.position, model.id).all()
|
|
|
|
@router.delete(f"/{path}/{{item_id}}", status_code=204)
|
|
def delete_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
|
|
if creator_scoped and admin.role == "creator":
|
|
raise HTTPException(status_code=403, detail="حذف مقاله فقط توسط مدیر انجام میشود")
|
|
item = db.get(model, item_id)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
|
|
db.delete(item)
|
|
db.commit()
|
|
|
|
|
|
# CRUD محتوا
|
|
register_crud("brand", models.Brand, BrandOut, BrandIn, BrandPatch)
|
|
register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch)
|
|
register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch)
|
|
register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch)
|
|
register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch)
|
|
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
|
|
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
|
|
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
|
|
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch, creator_scoped=True)
|
|
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
|
|
register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
|
|
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
|
|
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn, TimelineItemPatch)
|
|
register_crud("stats", models.Stat, StatOut, StatIn, StatPatch)
|
|
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn, WhyUsItemPatch)
|
|
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn, BeforeAfterPatch)
|
|
register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn, InvestmentModelPatch)
|
|
register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn, ProcessStepPatch)
|
|
register_crud("offices", models.Office, OfficeOut, OfficeIn, OfficePatch)
|
|
register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn, GalleryImagePatch)
|
|
register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn, StatusLabelPatch)
|
|
register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch)
|
|
register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch)
|
|
register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
|
|
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
|
|
|
|
|
|
# آپلود رسانه به Object Storage (MinIO) — هر ورودی قابلرمزگشایی به WebP نرمال میشود:
|
|
# تصویر ثابت → WebP تکفریمی | gif/apng/webp متحرک → WebP متحرک | ویدیو → WebP متحرک با سقف.
|
|
# URL نسبی برمیگردد تا بکاند ذخیرهسازی قابل تعویض باشد.
|
|
MAX_DIMENSION = 2000 # سقف ضلع تصویر ثابت — ابعاد بزرگ اصلیترین عامل کندی سایت است
|
|
MAX_ANIM_DIMENSION = 800 # انیمیشن: سقف ضلع فریم
|
|
MAX_ANIM_FRAMES = 150 # انیمیشن: سقف فریم
|
|
VIDEO_MAX_SECONDS = 8 # ویدیو: سقف مدت ورودی
|
|
VIDEO_FPS = 12 # ویدیو: نرخ فریم خروجی
|
|
VIDEO_WEBP_LIMIT = 5 * 1024 * 1024 # انیمیشن ویدیویی حجیمتر از این → فقط فریم پوستر
|
|
WEBP_QUALITY = 82
|
|
WEBP_CONTENT_TYPE = "image/webp"
|
|
|
|
SUPPORTED_MEDIA_DETAIL = "فرمت پشتیبانی نمیشود — فقط تصویر یا ویدیویی که بتوان به WebP تبدیل کرد مجاز است"
|
|
|
|
|
|
def _to_webp_mode(img: Image.Image) -> Image.Image:
|
|
"""ذخیرهٔ WebP در Pillow فقط RGB/RGBA/L را میپذیرد — مدهای دیگر با حفظ شفافیت تبدیل میشوند."""
|
|
if img.mode in ("RGB", "RGBA", "L"):
|
|
return img
|
|
has_alpha = img.mode in ("LA", "PA") or (img.mode == "P" and "transparency" in img.info)
|
|
return img.convert("RGBA" if has_alpha else "RGB")
|
|
|
|
|
|
def _webp_still(img: Image.Image) -> tuple[bytes, int, int]:
|
|
"""تکفریم → WebP؛ چرخش EXIF اعمال، متادیتا حذف و سقف ضلع ۲۰۰۰ پیکسل رعایت میشود."""
|
|
img = ImageOps.exif_transpose(img)
|
|
if max(img.size) > MAX_DIMENSION:
|
|
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
|
|
img = _to_webp_mode(img)
|
|
buf = io.BytesIO()
|
|
img.save(buf, "WEBP", quality=WEBP_QUALITY, method=4)
|
|
return buf.getvalue(), *img.size
|
|
|
|
|
|
def _webp_animated(img: Image.Image) -> tuple[bytes, int, int] | None:
|
|
"""چندفریم (gif/apng/webp متحرک) → WebP متحرک؛ None یعنی ورودی تکفریمی است.
|
|
فریمهای بیش از سقف رها میشوند تا حافظه و حجم خروجی مهار شود."""
|
|
total = getattr(img, "n_frames", 1)
|
|
if total <= 1:
|
|
return None
|
|
count = min(total, MAX_ANIM_FRAMES)
|
|
frames: list[Image.Image] = []
|
|
durations: list[int] = []
|
|
for i in range(count):
|
|
img.seek(i)
|
|
durations.append(int(img.info.get("duration") or 100))
|
|
frame = img.convert("RGBA")
|
|
if max(frame.size) > MAX_ANIM_DIMENSION:
|
|
frame.thumbnail((MAX_ANIM_DIMENSION, MAX_ANIM_DIMENSION), Image.LANCZOS)
|
|
frames.append(frame)
|
|
buf = io.BytesIO()
|
|
frames[0].save(
|
|
buf, "WEBP", save_all=True, append_images=frames[1:],
|
|
duration=durations, loop=0, quality=WEBP_QUALITY, method=4,
|
|
)
|
|
return buf.getvalue(), *frames[0].size
|
|
|
|
|
|
def _ffprobe_video(path: str) -> tuple[dict | None, dict]:
|
|
"""استریم ویدیوی اول + مشخصات فرمت با ffprobe — استریم None یعنی ویدیویی در فایل نیست."""
|
|
ffprobe = shutil.which("ffprobe")
|
|
if ffprobe is None:
|
|
return None, {}
|
|
try:
|
|
proc = subprocess.run(
|
|
[ffprobe, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", path],
|
|
capture_output=True, timeout=20,
|
|
)
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
return None, {}
|
|
if proc.returncode != 0:
|
|
return None, {}
|
|
try:
|
|
info = json.loads(proc.stdout or "{}")
|
|
except json.JSONDecodeError:
|
|
return None, {}
|
|
video = next((s for s in info.get("streams") or [] if s.get("codec_type") == "video"), None)
|
|
return video, info.get("format") or {}
|
|
|
|
|
|
def _run_ffmpeg(args: list[str]) -> None:
|
|
try:
|
|
proc = subprocess.run(args, capture_output=True, timeout=60)
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise HTTPException(status_code=415, detail="تبدیل رسانه بیش از حد طول کشید") from exc
|
|
except OSError as exc:
|
|
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL) from exc
|
|
if proc.returncode != 0:
|
|
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
|
|
|
|
|
def _ffmpeg_webp(data: bytes) -> tuple[bytes, int | None, int | None]:
|
|
"""مسیر FFmpeg — ورودیهایی که Pillow نمیخواند: ویدیو و فرمتهای تصویری خاص.
|
|
ویدیو تا ۸ ثانیه با ۱۲fps به WebP متحرک میرسد؛ خروجی حجیم → فقط فریم پوستر."""
|
|
if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
|
|
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
src = os.path.join(tmp, "src")
|
|
dst = os.path.join(tmp, "out.webp")
|
|
with open(src, "wb") as fh:
|
|
fh.write(data)
|
|
video, fmt = _ffprobe_video(src)
|
|
if video is None:
|
|
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
|
|
ff = shutil.which("ffmpeg") or "ffmpeg"
|
|
animated = float(fmt.get("duration") or 0) >= 0.5
|
|
video_vf = f"fps={VIDEO_FPS},scale='min({MAX_ANIM_DIMENSION},iw)':-2:flags=lanczos"
|
|
still_vf = f"scale='min({MAX_DIMENSION},iw)':-2:flags=lanczos"
|
|
|
|
def run_convert(extra: list[str], vf: str) -> None:
|
|
_run_ffmpeg([ff, "-y", "-v", "error", "-i", src, *extra, "-vf", vf,
|
|
"-c:v", "libwebp", "-q:v", str(WEBP_QUALITY), "-map_metadata", "-1", dst])
|
|
|
|
# ویدیو → متحرک با سقف فریمنرخ/ضلع/مدت؛ تصویر خاص → تکفریم
|
|
if animated:
|
|
run_convert(["-t", str(VIDEO_MAX_SECONDS)], video_vf)
|
|
else:
|
|
run_convert(["-frames:v", "1"], still_vf)
|
|
with open(dst, "rb") as fh:
|
|
out = fh.read()
|
|
# انیمیشن ویدیویی ناگذیر حجیم → فقط فریم پوستر
|
|
if animated and len(out) > VIDEO_WEBP_LIMIT:
|
|
run_convert(["-frames:v", "1"], still_vf)
|
|
with open(dst, "rb") as fh:
|
|
out = fh.read()
|
|
dims = _ffprobe_video(dst)[0] or {}
|
|
width = int(dims["width"]) if dims.get("width") else None
|
|
height = int(dims["height"]) if dims.get("height") else None
|
|
return out, width, height
|
|
|
|
|
|
def _normalize_media(data: bytes) -> tuple[bytes, int | None, int | None]:
|
|
"""هر ورودی قابلرمزگشایی → WebP. اول Pillow (دقیقترین برای EXIF/شفافیت/انیمیشن)؛
|
|
اگر رمزگشایی نشد، FFmpeg (ویدیو و فرمتهای خاص). هیچکدام نشد → ۴۱۵."""
|
|
try:
|
|
img = Image.open(io.BytesIO(data))
|
|
animated = _webp_animated(img)
|
|
if animated is not None:
|
|
return animated
|
|
return _webp_still(img)
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
pass
|
|
return _ffmpeg_webp(data)
|
|
|
|
|
|
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, name: str) -> str:
|
|
"""ذخیرهٔ WebP در Object Storage با کلید خوانا — URL نسبی برمیگردد."""
|
|
key = _readable_key(name, "webp")
|
|
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=WEBP_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),
|
|
):
|
|
max_bytes = settings.media_max_upload_mb * 1024 * 1024
|
|
data = await file.read()
|
|
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} مگابایت است",
|
|
)
|
|
|
|
# هر ورودی قابلرمزگشایی (تصویر رایج/خاص، متحرک، ویدیو) → WebP نرمال
|
|
data, width, height = _normalize_media(data)
|
|
|
|
# نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
|
|
display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0]
|
|
url = _store_image(data, display_name)
|
|
|
|
return {
|
|
"url": url,
|
|
"key": url.removeprefix(f"{settings.media_public_base}/"),
|
|
"size": len(data),
|
|
"content_type": WEBP_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:
|
|
data = response.read(max_bytes + 1)
|
|
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} مگابایت است",
|
|
)
|
|
|
|
# هدر Content-Type دروغ میگوید — خود بدنه رمزگشایی و به WebP نرمال میشود
|
|
data, width, height = _normalize_media(data)
|
|
|
|
# نام خوانا از مسیر نشانی — بدون پارامترهای کوئری
|
|
url_name = urlparse(payload.url).path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
|
media_url = _store_image(data, url_name)
|
|
|
|
return {
|
|
"url": media_url,
|
|
"key": media_url.removeprefix(f"{settings.media_public_base}/"),
|
|
"size": len(data),
|
|
"content_type": WEBP_CONTENT_TYPE,
|
|
"width": width,
|
|
"height": height,
|
|
}
|
|
|
|
|
|
# ---------- خروجی CSV (برای تیم فروش) ----------
|
|
|
|
def _csv_response(filename: str, header: list[str], rows: list[list]) -> Response:
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf)
|
|
writer.writerow(header)
|
|
writer.writerows(rows)
|
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
|
return Response(
|
|
# BOM ابتدای فایل — تا Excel متن فارسی را درست باز کند
|
|
content="\ufeff" + buf.getvalue(),
|
|
media_type="text/csv; charset=utf-8",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}-{stamp}.csv"'},
|
|
)
|
|
|
|
|
|
@router.get("/messages/export.csv")
|
|
def export_messages(db: Session = Depends(get_db)):
|
|
rows = [
|
|
[m.id, m.name, m.phone, m.note, "yes" if m.is_read else "no", m.created_at.isoformat()]
|
|
for m in db.query(models.ContactMessage).order_by(models.ContactMessage.created_at.desc()).all()
|
|
]
|
|
return _csv_response(
|
|
"contact-messages", ["id", "name", "phone", "note", "is_read", "created_at"], rows
|
|
)
|
|
|
|
|
|
@router.get("/subscribers/export.csv")
|
|
def export_subscribers(db: Session = Depends(get_db)):
|
|
rows = [
|
|
[s.id, s.email, "yes" if s.is_active else "no", s.created_at.isoformat()]
|
|
for s in db.query(models.Subscriber).order_by(models.Subscriber.created_at.desc()).all()
|
|
]
|
|
return _csv_response(
|
|
"newsletter-subscribers", ["id", "email", "is_active", "created_at"], rows
|
|
)
|
|
|
|
|
|
# پیامهای تماس
|
|
@router.get("/messages", response_model=list[ContactMessageOut])
|
|
def list_messages(unread_only: bool = False, db: Session = Depends(get_db)):
|
|
query = db.query(models.ContactMessage)
|
|
if unread_only:
|
|
query = query.filter(models.ContactMessage.is_read.is_(False))
|
|
return query.order_by(models.ContactMessage.created_at.desc()).all()
|
|
|
|
|
|
@router.patch("/messages/{message_id}", response_model=ContactMessageOut)
|
|
def update_message(message_id: int, payload: MessageReadIn, db: Session = Depends(get_db)):
|
|
message = db.get(models.ContactMessage, message_id)
|
|
if message is None:
|
|
raise HTTPException(status_code=404, detail="پیام یافت نشد")
|
|
message.is_read = payload.is_read
|
|
db.commit()
|
|
db.refresh(message)
|
|
return message
|
|
|
|
|
|
@router.delete("/messages/{message_id}", status_code=204)
|
|
def delete_message(message_id: int, db: Session = Depends(get_db)):
|
|
message = db.get(models.ContactMessage, message_id)
|
|
if message is None:
|
|
raise HTTPException(status_code=404, detail="پیام یافت نشد")
|
|
db.delete(message)
|
|
db.commit()
|
|
|
|
|
|
# مشترکین خبرنامه
|
|
@router.get("/subscribers", response_model=list[SubscriberOut])
|
|
def list_subscribers(db: Session = Depends(get_db)):
|
|
return db.query(models.Subscriber).order_by(models.Subscriber.created_at.desc()).all()
|
|
|
|
|
|
@router.delete("/subscribers/{subscriber_id}", status_code=204)
|
|
def delete_subscriber(subscriber_id: int, db: Session = Depends(get_db)):
|
|
subscriber = db.get(models.Subscriber, subscriber_id)
|
|
if subscriber is None:
|
|
raise HTTPException(status_code=404, detail="مشترک یافت نشد")
|
|
db.delete(subscriber)
|
|
db.commit()
|