Release v2.0: URL upload, BBCode sharing, QR codes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-07 02:36:59 +03:00
parent 49abcc20b4
commit 1924032071
11 changed files with 646 additions and 42 deletions
+28 -1
View File
@@ -323,6 +323,32 @@ docker compose up -d --build
--- ---
## Релиз v2.0
**Загрузка по прямым ссылкам**
- В форме загрузки вкладка «Ссылки»
- Вставьте одну или несколько прямых URL на изображения (HTTP/HTTPS)
- Поддерживаются те же форматы и лимиты, что и при обычной загрузке
**Поделиться: BBCode, HTML, QR**
- На каждой фотографии кнопки **Ссылка**, **BBCode** и **QR**
- BBCode для форумов: `[img]https://...[/img]`
- HTML для сайтов: `<img src="...">`
- QR-код открывается в модальном окне с быстрым копированием всех форматов
**Обновление до v2.0 на сервере:**
```bash
cd ~/fotohost
git fetch --tags
git checkout v2.0
docker compose up -d --build
```
---
## Релиз v1.4 ## Релиз v1.4
**Лимиты групп пользователей** **Лимиты групп пользователей**
@@ -493,7 +519,8 @@ python wsgi.py
| GET | `/cabinet/` | Личный кабинет | | GET | `/cabinet/` | Личный кабинет |
| GET | `/admin/` | Админ-панель | | GET | `/admin/` | Админ-панель |
| GET | `/admin/banners` | Управление рекламными баннерами | | GET | `/admin/banners` | Управление рекламными баннерами |
| POST | `/upload` | Загрузка фото (auth) | | GET | `/photo/<id>/qr` | QR-код для прямой ссылки на фото |
| POST | `/upload` | Загрузка фото или по URL (auth) |
| GET | `/uploads/<filename>` | Прямая ссылка на файл | | GET | `/uploads/<filename>` | Прямая ссылка на файл |
| GET | `/api/photos` | JSON-список всех фото | | GET | `/api/photos` | JSON-список всех фото |
| POST | `/delete/<id>` | Удаление фото | | POST | `/delete/<id>` | Удаление фото |
+41 -7
View File
@@ -21,7 +21,7 @@ from app.folder_utils import can_edit_folder
from app.models import Folder, Photo from app.models import Folder, Photo
from app.settings_service import get_settings from app.settings_service import get_settings
from app.storage_service import delete_photo_file, get_photo_stream from app.storage_service import delete_photo_file, get_photo_stream
from app.upload_service import process_uploads from app.upload_service import process_uploads, process_url_uploads
from sqlalchemy import text from sqlalchemy import text
bp = Blueprint("main", __name__) bp = Blueprint("main", __name__)
@@ -64,12 +64,24 @@ def upload():
if not can_edit_folder(folder): if not can_edit_folder(folder):
abort(403) abort(403)
result = process_uploads( image_urls = request.form.get("image_urls", "").strip()
request.files, max_upload_mb = current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024)
current_user,
folder, if image_urls:
current_app.config["ALLOWED_EXTENSIONS"], result = process_url_uploads(
) image_urls,
current_user,
folder,
current_app.config["ALLOWED_EXTENSIONS"],
max_upload_mb,
)
else:
result = process_uploads(
request.files,
current_user,
folder,
current_app.config["ALLOWED_EXTENSIONS"],
)
if result["uploaded"] == 0 and result["errors"]: if result["uploaded"] == 0 and result["errors"]:
flash(result["errors"][0], "error") flash(result["errors"][0], "error")
@@ -115,6 +127,28 @@ def api_photos():
) )
@bp.route("/photo/<int:photo_id>/qr")
def photo_qr(photo_id):
import io
import qrcode
from app.share_utils import photo_absolute_url
photo = Photo.query.get_or_404(photo_id)
target = photo_absolute_url(photo, request.url_root)
qr = qrcode.QRCode(box_size=8, border=2)
qr.add_data(target)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return send_file(buf, mimetype="image/png", download_name=f"photo-{photo.id}-qr.png")
@bp.route("/uploads/<path:filename>") @bp.route("/uploads/<path:filename>")
def uploaded_file(filename): def uploaded_file(filename):
photo = Photo.query.filter_by(filename=filename).first() photo = Photo.query.filter_by(filename=filename).first()
+12
View File
@@ -0,0 +1,12 @@
def photo_absolute_url(photo, base_url):
return f"{base_url.rstrip('/')}{photo.url}"
def photo_bbcode(photo, base_url):
return f"[img]{photo_absolute_url(photo, base_url)}[/img]"
def photo_html(photo, base_url):
url = photo_absolute_url(photo, base_url)
name = photo.original_name.replace('"', "&quot;")
return f'<img src="{url}" alt="{name}">'
+160 -1
View File
@@ -420,7 +420,9 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; flex-wrap: wrap;
gap: 6px;
padding: 12px;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
@@ -1160,3 +1162,160 @@ body {
.admin-table--groups td { .admin-table--groups td {
vertical-align: top; vertical-align: top;
} }
.upload-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.upload-tabs__btn {
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
border-radius: 999px;
padding: 8px 16px;
cursor: pointer;
font: inherit;
}
.upload-tabs__btn--active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.upload-panel {
display: none;
}
.upload-panel--active {
display: block;
}
.url-upload {
margin-bottom: 16px;
}
.url-upload__label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.url-upload__input {
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: inherit;
font: inherit;
resize: vertical;
}
.url-upload__hint {
margin-top: 8px;
font-size: 0.85rem;
color: var(--text-muted);
}
.share-modal[hidden] {
display: none;
}
.share-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.share-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.65);
}
.share-modal__dialog {
position: relative;
width: min(100%, 520px);
max-height: 90vh;
overflow-y: auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
.share-modal__close {
position: absolute;
top: 12px;
right: 12px;
border: none;
background: transparent;
color: var(--text-muted);
font-size: 1.5rem;
cursor: pointer;
}
.share-modal__title {
margin-bottom: 8px;
}
.share-modal__name {
margin-bottom: 16px;
color: var(--text-muted);
font-size: 0.9rem;
word-break: break-all;
}
.share-modal__qr-wrap {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.share-modal__qr {
width: 180px;
height: 180px;
border-radius: var(--radius-sm);
background: #fff;
padding: 8px;
}
.share-field {
margin-bottom: 14px;
}
.share-field label {
display: block;
margin-bottom: 6px;
font-size: 0.85rem;
color: var(--text-muted);
}
.share-field__row {
display: flex;
gap: 8px;
}
.share-field__row input {
flex: 1;
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.03);
color: inherit;
font-size: 0.85rem;
}
body.modal-open {
overflow: hidden;
}
+108 -13
View File
@@ -1,10 +1,31 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
initUploadForm();
initCopyButtons();
initShareModal();
});
function initUploadForm() {
const dropzone = document.getElementById("dropzone"); const dropzone = document.getElementById("dropzone");
const photoInput = document.getElementById("photoInput"); const photoInput = document.getElementById("photoInput");
const preview = document.getElementById("preview"); const preview = document.getElementById("preview");
const previewImg = document.getElementById("previewImg"); const previewImg = document.getElementById("previewImg");
const previewName = document.getElementById("previewName"); const previewName = document.getElementById("previewName");
const submitBtn = document.getElementById("submitBtn"); const submitBtn = document.getElementById("submitBtn");
const uploadForm = document.getElementById("uploadForm");
const tabButtons = document.querySelectorAll(".upload-tabs__btn");
const panels = document.querySelectorAll(".upload-panel");
if (!uploadForm) return;
tabButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const tab = btn.dataset.tab;
tabButtons.forEach((item) => item.classList.toggle("upload-tabs__btn--active", item === btn));
panels.forEach((panel) => {
panel.classList.toggle("upload-panel--active", panel.dataset.panel === tab);
});
});
});
if (!dropzone || !photoInput) return; if (!dropzone || !photoInput) return;
@@ -40,6 +61,25 @@ document.addEventListener("DOMContentLoaded", () => {
} }
}); });
uploadForm.addEventListener("submit", (e) => {
const activePanel = document.querySelector(".upload-panel--active");
if (!activePanel) return;
if (activePanel.dataset.panel === "urls") {
const urls = document.getElementById("imageUrls");
if (!urls || !urls.value.trim()) {
e.preventDefault();
showToast("Укажите хотя бы одну ссылку");
}
return;
}
if (activePanel.dataset.panel === "files" && photoInput.files.length === 0) {
e.preventDefault();
showToast("Выберите файлы для загрузки");
}
});
function assignFiles(fileList) { function assignFiles(fileList) {
const dt = new DataTransfer(); const dt = new DataTransfer();
const limit = Math.min(fileList.length, maxFiles); const limit = Math.min(fileList.length, maxFiles);
@@ -68,26 +108,81 @@ document.addEventListener("DOMContentLoaded", () => {
}; };
reader.readAsDataURL(first); reader.readAsDataURL(first);
} }
}
function initCopyButtons() {
document.querySelectorAll(".copy-btn").forEach((btn) => { document.querySelectorAll(".copy-btn").forEach((btn) => {
btn.addEventListener("click", async (e) => { btn.addEventListener("click", async (e) => {
e.stopPropagation(); e.stopPropagation();
const url = btn.dataset.url; const targetId = btn.dataset.target;
try { const url = targetId
await navigator.clipboard.writeText(url); ? document.getElementById(targetId)?.value
showToast("Ссылка скопирована!"); : btn.dataset.url;
} catch {
const input = document.createElement("input"); if (!url) return;
input.value = url;
document.body.appendChild(input); const copied = await copyText(url);
input.select(); if (copied) {
document.execCommand("copy"); const label = btn.textContent.trim();
document.body.removeChild(input); showToast(label === "BBCode" ? "BBCode скопирован!" : "Скопировано!");
showToast("Ссылка скопирована!");
} }
}); });
}); });
}); }
function initShareModal() {
const modal = document.getElementById("shareModal");
if (!modal) return;
const urlInput = document.getElementById("shareModalUrl");
const bbcodeInput = document.getElementById("shareModalBbcode");
const htmlInput = document.getElementById("shareModalHtml");
const qrImg = document.getElementById("shareModalQr");
const nameEl = document.getElementById("shareModalName");
document.querySelectorAll(".share-qr-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
urlInput.value = btn.dataset.url || "";
bbcodeInput.value = btn.dataset.bbcode || "";
htmlInput.value = btn.dataset.html || "";
qrImg.src = btn.dataset.qr || "";
nameEl.textContent = btn.dataset.name || "";
modal.hidden = false;
document.body.classList.add("modal-open");
});
});
modal.querySelectorAll("[data-close-share]").forEach((el) => {
el.addEventListener("click", closeShareModal);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !modal.hidden) {
closeShareModal();
}
});
function closeShareModal() {
modal.hidden = true;
document.body.classList.remove("modal-open");
}
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const input = document.createElement("input");
input.value = text;
document.body.appendChild(input);
input.select();
const ok = document.execCommand("copy");
document.body.removeChild(input);
return ok;
}
}
function showToast(message) { function showToast(message) {
const existing = document.querySelector(".toast"); const existing = document.querySelector(".toast");
+1
View File
@@ -54,6 +54,7 @@
</footer> </footer>
<script src="{{ url_for('static', filename='js/main.js') }}"></script> <script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% include "partials/share_modal.html" %}
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+19 -2
View File
@@ -1,6 +1,9 @@
{% if photos %} {% if photos %}
<div class="gallery"> <div class="gallery">
{% for photo in photos %} {% for photo in photos %}
{% set share_url = request.url_root.rstrip('/') ~ photo.url %}
{% set share_bbcode = '[img]' ~ share_url ~ '[/img]' %}
{% set share_html = '<img src="' ~ share_url ~ '" alt="' ~ photo.original_name ~ '">' %}
<article class="photo-card" data-id="{{ photo.id }}"> <article class="photo-card" data-id="{{ photo.id }}">
<div class="photo-card__image-wrap"> <div class="photo-card__image-wrap">
<img <img
@@ -10,8 +13,22 @@
loading="lazy" loading="lazy"
> >
<div class="photo-card__overlay"> <div class="photo-card__overlay">
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-url="{{ request.url_root.rstrip('/') }}{{ photo.url }}"> <button type="button" class="btn btn--ghost btn--sm copy-btn" data-url="{{ share_url }}">
Копировать ссылку Ссылка
</button>
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-url="{{ share_bbcode }}">
BBCode
</button>
<button
type="button"
class="btn btn--ghost btn--sm share-qr-btn"
data-url="{{ share_url }}"
data-bbcode="{{ share_bbcode }}"
data-html="{{ share_html }}"
data-qr="{{ url_for('main.photo_qr', photo_id=photo.id) }}"
data-name="{{ photo.original_name }}"
>
QR
</button> </button>
<a href="{{ photo.url }}" target="_blank" class="btn btn--ghost btn--sm">Открыть</a> <a href="{{ photo.url }}" target="_blank" class="btn btn--ghost btn--sm">Открыть</a>
</div> </div>
+36
View File
@@ -0,0 +1,36 @@
<div class="share-modal" id="shareModal" hidden>
<div class="share-modal__backdrop" data-close-share></div>
<div class="share-modal__dialog" role="dialog" aria-labelledby="shareModalTitle">
<button type="button" class="share-modal__close" data-close-share aria-label="Закрыть">&times;</button>
<h3 class="share-modal__title" id="shareModalTitle">Поделиться</h3>
<p class="share-modal__name" id="shareModalName"></p>
<div class="share-modal__qr-wrap">
<img id="shareModalQr" class="share-modal__qr" alt="QR-код">
</div>
<div class="share-field">
<label for="shareModalUrl">Прямая ссылка</label>
<div class="share-field__row">
<input id="shareModalUrl" type="text" readonly>
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-target="shareModalUrl">Копировать</button>
</div>
</div>
<div class="share-field">
<label for="shareModalBbcode">BBCode для форумов</label>
<div class="share-field__row">
<input id="shareModalBbcode" type="text" readonly>
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-target="shareModalBbcode">Копировать</button>
</div>
</div>
<div class="share-field">
<label for="shareModalHtml">HTML для сайтов</label>
<div class="share-field__row">
<input id="shareModalHtml" type="text" readonly>
<button type="button" class="btn btn--ghost btn--sm copy-btn" data-target="shareModalHtml">Копировать</button>
</div>
</div>
</div>
</div>
+43 -18
View File
@@ -1,22 +1,47 @@
<form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm"> <form action="{{ url_for('main.upload') }}" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm">
{% if folder_id %}<input type="hidden" name="folder_id" value="{{ folder_id }}">{% endif %} {% if folder_id %}<input type="hidden" name="folder_id" value="{{ folder_id }}">{% endif %}
<div class="dropzone" id="dropzone">
<input type="file" name="photos" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" multiple data-max="{{ max_bulk_upload|default(100) }}" hidden> <div class="upload-tabs">
<div class="dropzone__icon"> <button type="button" class="upload-tabs__btn upload-tabs__btn--active" data-tab="files">Файлы</button>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <button type="button" class="upload-tabs__btn" data-tab="urls">Ссылки</button>
<path d="M12 16V4m0 0L8 8m4-4l4 4"/> </div>
<path d="M20 16.5v1a2.5 2.5 0 01-2.5 2.5h-11A2.5 2.5 0 014 17.5v-1"/>
</svg> <div class="upload-panel upload-panel--active" data-panel="files">
</div> <div class="dropzone" id="dropzone">
<p class="dropzone__title">Перетащите фото сюда</p> <input type="file" name="photos" id="photoInput" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp" multiple data-max="{{ max_bulk_upload|default(100) }}" hidden>
<p class="dropzone__hint">или выберите до {{ max_bulk_upload|default(100) }} файлов</p> <div class="dropzone__icon">
<p class="dropzone__formats">PNG · JPG · GIF · WEBP · BMP</p> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<div class="dropzone__preview" id="preview" hidden> <path d="M12 16V4m0 0L8 8m4-4l4 4"/>
<img id="previewImg" alt="Предпросмотр"> <path d="M20 16.5v1a2.5 2.5 0 01-2.5 2.5h-11A2.5 2.5 0 014 17.5v-1"/>
<span id="previewName"></span> </svg>
</div> </div>
<p class="dropzone__title">Перетащите фото сюда</p>
<p class="dropzone__hint">или выберите до {{ max_bulk_upload|default(100) }} файлов</p>
<p class="dropzone__formats">PNG · JPG · GIF · WEBP · BMP</p>
<div class="dropzone__preview" id="preview" hidden>
<img id="previewImg" alt="Предпросмотр">
<span id="previewName"></span>
</div>
</div>
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>
<span>Загрузить файлы</span>
</button>
</div>
<div class="upload-panel" data-panel="urls">
<div class="url-upload">
<label for="imageUrls" class="url-upload__label">Прямые ссылки на изображения</label>
<textarea
id="imageUrls"
name="image_urls"
class="url-upload__input"
rows="5"
placeholder="https://example.com/photo.jpg&#10;https://example.com/image.png"
></textarea>
<p class="url-upload__hint">По одной ссылке в строке. Поддерживаются HTTP и HTTPS.</p>
</div>
<button type="submit" class="btn btn--primary" id="submitUrlBtn">
<span>Загрузить по ссылкам</span>
</button>
</div> </div>
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>
<span>Загрузить</span>
</button>
</form> </form>
+196
View File
@@ -1,7 +1,13 @@
import ipaddress
import os import os
import re
import uuid import uuid
from datetime import datetime, timezone 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 werkzeug.utils import secure_filename
from app import db 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.settings_service import get_settings
from app.storage_service import save_photo_file 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): def allowed_file(filename, allowed_extensions):
return "." in filename and filename.rsplit(".", 1)[1].lower() in 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] 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): def process_uploads(request_files, user, folder, allowed_extensions):
settings = get_settings() settings = get_settings()
max_bulk = settings.max_bulk_upload or 100 max_bulk = settings.max_bulk_upload or 100
+2
View File
@@ -8,3 +8,5 @@ python-dotenv==1.0.1
Werkzeug==3.1.3 Werkzeug==3.1.3
boto3==1.35.99 boto3==1.35.99
paramiko==3.5.1 paramiko==3.5.1
requests==2.32.3
qrcode[pil]==8.0