Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4f0eaa7d9 |
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
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
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
<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 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user