Release v2.0: URL upload, BBCode sharing, QR codes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from app import db
|
||||
@@ -10,6 +16,18 @@ from app.quota_utils import check_photo_count_limit, check_upload_quota
|
||||
from app.settings_service import get_settings
|
||||
from app.storage_service import save_photo_file
|
||||
|
||||
URL_SPLIT_RE = re.compile(r"[\r\n,;\s]+")
|
||||
|
||||
MIME_TO_EXT = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/bmp": "bmp",
|
||||
"image/x-png": "png",
|
||||
}
|
||||
|
||||
|
||||
def allowed_file(filename, allowed_extensions):
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in allowed_extensions
|
||||
@@ -24,6 +42,184 @@ def collect_upload_files(request_files):
|
||||
return [f for f in files if f and f.filename]
|
||||
|
||||
|
||||
def parse_image_urls(raw_text):
|
||||
if not raw_text:
|
||||
return []
|
||||
urls = []
|
||||
for part in URL_SPLIT_RE.split(raw_text.strip()):
|
||||
url = part.strip()
|
||||
if url and url not in urls:
|
||||
urls.append(url)
|
||||
return urls
|
||||
|
||||
|
||||
def is_safe_image_url(url):
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
if not parsed.hostname:
|
||||
return False
|
||||
|
||||
host = parsed.hostname.lower()
|
||||
if host in ("localhost", "0.0.0.0") or host.endswith(".local"):
|
||||
return False
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
return False
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def guess_extension(url, content_type):
|
||||
ext = MIME_TO_EXT.get((content_type or "").split(";")[0].strip().lower())
|
||||
if ext:
|
||||
return ext
|
||||
|
||||
path = urlparse(url).path
|
||||
if "." in path:
|
||||
candidate = path.rsplit(".", 1)[1].lower()
|
||||
if candidate in {"png", "jpg", "jpeg", "gif", "webp", "bmp"}:
|
||||
return "jpg" if candidate == "jpeg" else candidate
|
||||
return None
|
||||
|
||||
|
||||
def download_image(url, max_bytes, timeout=30):
|
||||
response = requests.get(
|
||||
url,
|
||||
stream=True,
|
||||
timeout=timeout,
|
||||
headers={"User-Agent": "PhotoHost/2.0"},
|
||||
allow_redirects=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
final_url = response.url
|
||||
if not is_safe_image_url(final_url):
|
||||
raise ValueError("Недопустимый URL после редиректа")
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
ext = guess_extension(final_url, content_type)
|
||||
if not ext:
|
||||
raise ValueError("URL не содержит изображение")
|
||||
|
||||
chunks = []
|
||||
total = 0
|
||||
for chunk in response.iter_content(chunk_size=65536):
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise ValueError("Файл превышает лимит размера")
|
||||
chunks.append(chunk)
|
||||
|
||||
if total == 0:
|
||||
raise ValueError("Пустой файл")
|
||||
|
||||
data = b"".join(chunks)
|
||||
filename = os.path.basename(urlparse(final_url).path) or f"image.{ext}"
|
||||
return data, ext, content_type, filename
|
||||
|
||||
|
||||
def save_downloaded_image(data, ext, content_type, original_name, user, folder):
|
||||
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
||||
safe_original = secure_filename(original_name) or f"photo.{ext}"
|
||||
if not safe_original.lower().endswith(f".{ext}"):
|
||||
safe_original = f"{safe_original.rsplit('.', 1)[0]}.{ext}"
|
||||
|
||||
stream = BytesIO(data)
|
||||
file_storage = FileStorage(
|
||||
stream=stream,
|
||||
filename=safe_original,
|
||||
content_type=content_type or f"image/{ext}",
|
||||
)
|
||||
|
||||
_path, file_size, storage_backend, sync_errors = save_photo_file(file_storage, stored_name)
|
||||
photo = Photo(
|
||||
filename=stored_name,
|
||||
original_name=safe_original,
|
||||
file_size=file_size,
|
||||
mime_type=content_type or f"image/{ext}",
|
||||
user_id=user.id,
|
||||
folder_id=folder.id if folder else None,
|
||||
storage_backend=storage_backend,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.session.add(photo)
|
||||
return photo, sync_errors
|
||||
|
||||
|
||||
def process_url_uploads(raw_urls, user, folder, allowed_extensions, max_upload_mb):
|
||||
settings = get_settings()
|
||||
max_bulk = settings.max_bulk_upload or 100
|
||||
urls = parse_image_urls(raw_urls)
|
||||
|
||||
if not urls:
|
||||
return {"uploaded": 0, "errors": ["Ссылки не указаны"], "photos": []}
|
||||
|
||||
if len(urls) > max_bulk:
|
||||
return {
|
||||
"uploaded": 0,
|
||||
"errors": [f"Максимум {max_bulk} ссылок за раз"],
|
||||
"photos": [],
|
||||
}
|
||||
|
||||
ok, photo_limit_msg = check_photo_count_limit(user, len(urls))
|
||||
if not ok:
|
||||
return {"uploaded": 0, "errors": [photo_limit_msg], "photos": []}
|
||||
|
||||
max_bytes = max_upload_mb * 1024 * 1024
|
||||
errors = []
|
||||
uploaded_photos = []
|
||||
pending_sizes = []
|
||||
|
||||
for url in urls:
|
||||
if not is_safe_image_url(url):
|
||||
errors.append(f"{url}: недопустимый URL")
|
||||
continue
|
||||
try:
|
||||
data, ext, content_type, filename = download_image(url, max_bytes)
|
||||
if ext not in allowed_extensions:
|
||||
errors.append(f"{url}: недопустимый формат")
|
||||
continue
|
||||
pending_sizes.append((url, data, ext, content_type, filename, len(data)))
|
||||
except requests.RequestException:
|
||||
errors.append(f"{url}: не удалось скачать")
|
||||
except ValueError as exc:
|
||||
errors.append(f"{url}: {exc}")
|
||||
|
||||
if not pending_sizes:
|
||||
return {"uploaded": 0, "errors": errors, "photos": []}
|
||||
|
||||
total_size = sum(item[5] for item in pending_sizes)
|
||||
ok, quota_msg = check_upload_quota(user, total_size)
|
||||
if not ok:
|
||||
return {"uploaded": 0, "errors": [quota_msg], "photos": []}
|
||||
|
||||
for url, data, ext, content_type, filename, _size in pending_sizes:
|
||||
try:
|
||||
photo, sync_errors = save_downloaded_image(
|
||||
data, ext, content_type, filename, user, folder
|
||||
)
|
||||
for sync_err in sync_errors:
|
||||
errors.append(f"{filename}: {sync_err}")
|
||||
uploaded_photos.append(photo)
|
||||
except Exception as exc:
|
||||
errors.append(f"{url}: {exc}")
|
||||
|
||||
if uploaded_photos:
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
"uploaded": len(uploaded_photos),
|
||||
"errors": errors,
|
||||
"photos": uploaded_photos,
|
||||
}
|
||||
|
||||
|
||||
def process_uploads(request_files, user, folder, allowed_extensions):
|
||||
settings = get_settings()
|
||||
max_bulk = settings.max_bulk_upload or 100
|
||||
|
||||
Reference in New Issue
Block a user