Release v2.0: URL upload, BBCode sharing, QR codes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+41
-7
@@ -21,7 +21,7 @@ from app.folder_utils import can_edit_folder
|
||||
from app.models import Folder, Photo
|
||||
from app.settings_service import get_settings
|
||||
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
|
||||
|
||||
bp = Blueprint("main", __name__)
|
||||
@@ -64,12 +64,24 @@ def upload():
|
||||
if not can_edit_folder(folder):
|
||||
abort(403)
|
||||
|
||||
result = process_uploads(
|
||||
request.files,
|
||||
current_user,
|
||||
folder,
|
||||
current_app.config["ALLOWED_EXTENSIONS"],
|
||||
)
|
||||
image_urls = request.form.get("image_urls", "").strip()
|
||||
max_upload_mb = current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024)
|
||||
|
||||
if image_urls:
|
||||
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"]:
|
||||
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>")
|
||||
def uploaded_file(filename):
|
||||
photo = Photo.query.filter_by(filename=filename).first()
|
||||
|
||||
@@ -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('"', """)
|
||||
return f'<img src="{url}" alt="{name}">'
|
||||
+160
-1
@@ -420,7 +420,9 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
@@ -1160,3 +1162,160 @@ body {
|
||||
.admin-table--groups td {
|
||||
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
@@ -1,10 +1,31 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initUploadForm();
|
||||
initCopyButtons();
|
||||
initShareModal();
|
||||
});
|
||||
|
||||
function initUploadForm() {
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
const photoInput = document.getElementById("photoInput");
|
||||
const preview = document.getElementById("preview");
|
||||
const previewImg = document.getElementById("previewImg");
|
||||
const previewName = document.getElementById("previewName");
|
||||
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;
|
||||
|
||||
@@ -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) {
|
||||
const dt = new DataTransfer();
|
||||
const limit = Math.min(fileList.length, maxFiles);
|
||||
@@ -68,26 +108,81 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
};
|
||||
reader.readAsDataURL(first);
|
||||
}
|
||||
}
|
||||
|
||||
function initCopyButtons() {
|
||||
document.querySelectorAll(".copy-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const url = btn.dataset.url;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showToast("Ссылка скопирована!");
|
||||
} catch {
|
||||
const input = document.createElement("input");
|
||||
input.value = url;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(input);
|
||||
showToast("Ссылка скопирована!");
|
||||
const targetId = btn.dataset.target;
|
||||
const url = targetId
|
||||
? document.getElementById(targetId)?.value
|
||||
: btn.dataset.url;
|
||||
|
||||
if (!url) return;
|
||||
|
||||
const copied = await copyText(url);
|
||||
if (copied) {
|
||||
const label = btn.textContent.trim();
|
||||
showToast(label === "BBCode" ? "BBCode скопирован!" : "Скопировано!");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
const existing = document.querySelector(".toast");
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% include "partials/share_modal.html" %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{% if photos %}
|
||||
<div class="gallery">
|
||||
{% 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 }}">
|
||||
<div class="photo-card__image-wrap">
|
||||
<img
|
||||
@@ -10,8 +13,22 @@
|
||||
loading="lazy"
|
||||
>
|
||||
<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>
|
||||
<a href="{{ photo.url }}" target="_blank" class="btn btn--ghost btn--sm">Открыть</a>
|
||||
</div>
|
||||
|
||||
@@ -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="Закрыть">×</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>
|
||||
@@ -1,22 +1,47 @@
|
||||
<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 %}
|
||||
<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="dropzone__icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M12 16V4m0 0L8 8m4-4l4 4"/>
|
||||
<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>
|
||||
<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 class="upload-tabs">
|
||||
<button type="button" class="upload-tabs__btn upload-tabs__btn--active" data-tab="files">Файлы</button>
|
||||
<button type="button" class="upload-tabs__btn" data-tab="urls">Ссылки</button>
|
||||
</div>
|
||||
|
||||
<div class="upload-panel upload-panel--active" data-panel="files">
|
||||
<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="dropzone__icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M12 16V4m0 0L8 8m4-4l4 4"/>
|
||||
<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>
|
||||
<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 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>
|
||||
<button type="submit" class="btn btn--primary" id="submitBtn" disabled>
|
||||
<span>Загрузить</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -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