Files
ptp/infra/uploader.py
T

430 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
import os, tempfile, re
from flask import Flask, request, jsonify
import boto3
from botocore.client import Config
import pillow_heif
from PIL import Image
pillow_heif.register_heif_opener()
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024
S3_CONFIG = dict(
endpoint_url='https://s3.regru.cloud',
aws_access_key_id='TXBN3MRO1YCC2Y593URD',
aws_secret_access_key='GIMA9lJetvXgMtl8GCO4dNSpJDGHvKgGBVfiA9zV',
region_name='ru-1',
config=Config(signature_version='s3v4')
)
BUCKET = 'sleeptrip-dev'
MIME = {
'jpg':'image/jpeg','jpeg':'image/jpeg','png':'image/png',
'gif':'image/gif','webp':'image/webp','mp4':'video/mp4','mov':'video/quicktime',
'heic':'image/jpeg','heif':'image/jpeg'
}
ALLOWED = set(MIME.keys())
PAGE = r"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PTP Post Wizard</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0d1117;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:0 0 60px}
.top{background:#161b22;border-bottom:1px solid #30363d;padding:16px 24px;display:flex;align-items:center;gap:12px}
.top h1{font-size:15px;font-weight:600}
.top .sub{font-size:12px;color:#8b949e}
.wrap{max-width:860px;margin:0 auto;padding:24px}
.section{margin-bottom:24px}
.section-title{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#8b949e;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #21262d}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
label{display:block;font-size:11px;color:#8b949e;margin-bottom:4px}
input,textarea{width:100%;background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:8px 10px;color:#e6edf3;font-size:13px;font-family:inherit;outline:none;transition:border-color .15s}
input:focus,textarea:focus{border-color:#58a6ff}
.hint{font-size:11px;color:#8b949e;margin-top:4px}
.slug-preview{font-family:monospace;font-size:12px;color:#58a6ff;margin-top:4px}
.drop{border:2px dashed #30363d;border-radius:10px;padding:40px 24px;text-align:center;cursor:pointer;transition:all .2s;position:relative}
.drop.over,.drop.has-files{border-color:#58a6ff;background:rgba(88,166,255,.04)}
.drop-icon{font-size:32px;margin-bottom:10px;color:#8b949e}
.drop-text{font-size:13px;color:#8b949e}
.drop-text b{color:#58a6ff;cursor:pointer}
.drop input{display:none}
.photos{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-top:16px}
.photo-card{background:#161b22;border:1px solid #30363d;border-radius:8px;overflow:hidden;position:relative}
.photo-card img{width:100%;height:110px;object-fit:cover;display:block}
.photo-card .ph-name{font-size:10px;font-family:monospace;padding:6px 8px;color:#58a6ff;word-break:break-all;line-height:1.4}
.photo-card .ph-num{position:absolute;top:6px;left:6px;background:rgba(0,0,0,.7);color:#fff;font-size:11px;font-weight:700;padding:2px 7px;border-radius:4px}
.photo-card .ph-del{position:absolute;top:6px;right:6px;background:rgba(248,81,73,.8);color:#fff;border:none;border-radius:4px;padding:2px 7px;font-size:11px;cursor:pointer}
.photo-card.uploading{opacity:.6}
.photo-card.uploading::after{content:'';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:32px;color:#58a6ff}
.output{background:#161b22;border:1px solid #30363d;border-radius:10px;overflow:hidden}
.out-tabs{display:flex;border-bottom:1px solid #30363d}
.out-tab{padding:10px 18px;font-size:12px;color:#8b949e;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s}
.out-tab.active{color:#58a6ff;border-bottom-color:#58a6ff}
.out-panel{display:none;padding:16px}
.out-panel.active{display:block}
.out-panel pre{background:#0d1117;border-radius:6px;padding:14px;font-size:12px;font-family:monospace;overflow-x:auto;white-space:pre-wrap;word-break:break-all;color:#e6edf3;max-height:400px;overflow-y:auto}
.copy-btn{background:#21262d;border:1px solid #30363d;border-radius:6px;padding:6px 14px;font-size:12px;color:#8b949e;cursor:pointer;float:right;margin-bottom:8px;transition:all .15s}
.copy-btn:hover{color:#e6edf3;border-color:#8b949e}
.copy-btn.ok{color:#3fb950;border-color:#3fb950}
.err{color:#f85149;font-size:12px;padding:8px 12px;background:rgba(248,81,73,.08);border-radius:6px;margin-top:8px;display:none}
.prog-bar{height:3px;background:#21262d;border-radius:2px;margin-top:4px;display:none}
.prog-bar-fill{height:3px;background:#58a6ff;border-radius:2px;width:0;transition:width .1s}
.notice{background:rgba(88,166,255,.08);border:1px solid rgba(88,166,255,.2);border-radius:6px;padding:10px 14px;font-size:12px;color:#8b949e;margin-bottom:16px}
</style>
</head>
<body>
<div class="top">
<div>
<h1>PTP Post Wizard</h1>
<div class="sub">Загрузка фото → готовый пост для Sveltia CMS</div>
</div>
</div>
<div class="wrap">
<!-- STEP 1: INFO -->
<div class="section">
<div class="section-title">1. Информация о посте</div>
<div class="grid2" style="margin-bottom:12px">
<div>
<label>Заголовок</label>
<input type="text" id="title" placeholder="Дюкинский заказник" oninput="onTitleInput()">
<div class="hint">Название места — как на сайте</div>
</div>
<div>
<label>Slug (URL страницы и база имён фото)</label>
<input type="text" id="slug" placeholder="Dukyn2504" oninput="onSlugInput()">
<div class="slug-preview" id="slug-preview"></div>
</div>
</div>
<div class="grid3">
<div>
<label>Дата поездки</label>
<input type="date" id="date" oninput="updateOutput()">
<div class="hint" id="date-fmt"></div>
</div>
<div>
<label>Описание (анонс)</label>
<input type="text" id="desc" placeholder="скалы, лес, отличная погода" oninput="updateOutput()">
</div>
<div>
<label>Disqus ID</label>
<input type="number" id="disqus" placeholder="134" min="1" oninput="updateOutput()">
<div class="hint">Следующий свободный: 134</div>
</div>
</div>
</div>
<!-- STEP 2: UPLOAD -->
<div class="section">
<div class="section-title">2. Фотографии</div>
<div class="notice">Фото автоматически называются <b id="naming-hint">slug-YYYYMMDD-N.jpg</b> и загружаются в S3. Первое фото = обложка.</div>
<div class="drop" id="drop">
<input type="file" id="fi" accept="image/*,video/*" multiple>
<div class="drop-icon">⬆</div>
<div class="drop-text">Перетащи фото сюда или <b onclick="document.getElementById('fi').click()">выбери файлы</b></div>
</div>
<div class="prog-bar" id="prog"><div class="prog-bar-fill" id="prog-fill"></div></div>
<div class="err" id="err"></div>
<div class="photos" id="photos"></div>
</div>
<!-- STEP 3: OUTPUT -->
<div class="section" id="output-section" style="display:none">
<div class="section-title">3. Готовый контент — копируй в Sveltia</div>
<div class="output">
<div class="out-tabs">
<div class="out-tab active" onclick="switchTab(0)">Поля формы</div>
<div class="out-tab" onclick="switchTab(1)">Markdown контент</div>
<div class="out-tab" onclick="switchTab(2)">Все URL фото</div>
</div>
<div class="out-panel active" id="tab0">
<button class="copy-btn" onclick="copyPanel('fields-out','this')">Копировать</button>
<pre id="fields-out"></pre>
</div>
<div class="out-panel" id="tab1">
<button class="copy-btn" onclick="copyPanel('md-out','this')">Копировать</button>
<pre id="md-out"></pre>
</div>
<div class="out-panel" id="tab2">
<button class="copy-btn" onclick="copyPanel('urls-out','this')">Копировать</button>
<pre id="urls-out"></pre>
</div>
</div>
</div>
</div>
<script>
// ---- State ----
var photos = []; // {url, filename, ext}
var counter = 0;
var uploading = 0;
// ---- Transliteration ----
var RU = {'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'yo','ж':'zh','з':'z','и':'i','й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r','с':'s','т':'t','у':'u','ф':'f','х':'kh','ц':'ts','ч':'ch','ш':'sh','щ':'sch','ъ':'','ы':'y','ь':'','э':'e','ю':'yu','я':'ya'};
function translit(s){
return s.toLowerCase().split('').map(function(c){return RU[c]||c;}).join('').replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'');
}
function onTitleInput(){
var slug = document.getElementById('slug');
if(!slug._manual){
var t = translit(document.getElementById('title').value);
slug.value = t;
}
onSlugInput();
}
function onSlugInput(){
var slug = document.getElementById('slug');
slug._manual = slug.value.length > 0;
updateNamingHint();
updateOutput();
}
function getSlug(){ return document.getElementById('slug').value.trim() || 'slug'; }
function getDateStr(){
var d = document.getElementById('date').value;
return d ? d.replace(/-/g,'') : 'YYYYMMDD';
}
function getDateISO(){
var d = document.getElementById('date').value;
return d ? d + 'T00:00:00' : '';
}
function updateNamingHint(){
var s = getSlug(); var d = getDateStr();
document.getElementById('naming-hint').textContent = s+'-'+d+'-N.jpg';
var p = document.getElementById('slug-preview');
p.textContent = s ? 'URL: /post/'+s+'/' : '';
}
// init date = today
(function(){
var d = new Date();
var iso = d.toISOString().slice(0,10);
document.getElementById('date').value = iso;
updateNamingHint();
})();
// ---- Drag & Drop ----
var drop = document.getElementById('drop');
var fi = document.getElementById('fi');
drop.addEventListener('dragover',function(e){e.preventDefault();drop.classList.add('over');});
drop.addEventListener('dragleave',function(){drop.classList.remove('over');});
drop.addEventListener('drop',function(e){
e.preventDefault(); drop.classList.remove('over');
var files = e.dataTransfer.files;
for(var i=0;i<files.length;i++) uploadFile(files[i]);
});
fi.addEventListener('change',function(){
for(var i=0;i<fi.files.length;i++) uploadFile(fi.files[i]);
fi.value='';
});
function uploadFile(file){
var slug = getSlug(); var dateStr = getDateStr();
counter++;
var n = counter;
var ext = file.name.split('.').pop().toLowerCase();
var outExt = (ext==='heic'||ext==='heif') ? 'jpg' : ext;
var filename = slug+'-'+dateStr+'-'+n+'.'+outExt;
// Placeholder card
var card = document.createElement('div');
card.className='photo-card uploading';
card.id='card-'+n;
card.innerHTML='<div style="height:110px;background:#21262d"></div><div class="ph-name">'+filename+'</div><div class="ph-num">#'+n+'</div>';
document.getElementById('photos').appendChild(card);
drop.classList.add('has-files');
var prog = document.getElementById('prog');
prog.style.display='block';
uploading++;
var fd = new FormData();
fd.append('file', file);
fd.append('filename', filename);
var xhr = new XMLHttpRequest();
xhr.upload.onprogress = function(e){
if(e.lengthComputable) document.getElementById('prog-fill').style.width=(e.loaded/e.total*100)+'%';
};
xhr.onload = function(){
uploading--;
if(uploading===0){prog.style.display='none'; document.getElementById('prog-fill').style.width='0';}
if(xhr.status===200){
var r = JSON.parse(xhr.responseText);
photos.push({url:r.url, filename:filename, ext:outExt, n:n});
photos.sort(function(a,b){return a.n-b.n;});
// Update card
var c = document.getElementById('card-'+n);
c.className='photo-card';
c.innerHTML='<img src="'+r.url+'" onerror="this.style.display=\'none\'">'
+'<div class="ph-num">#'+n+'</div>'
+'<button class="ph-del" onclick="deletePhoto('+n+')">✕</button>'
+'<div class="ph-name">'+filename+'</div>';
updateOutput();
} else {
var msg='Ошибка'; try{msg=JSON.parse(xhr.responseText).error;}catch(e){}
var c=document.getElementById('card-'+n); if(c) c.remove();
showErr(msg); counter--;
}
};
xhr.onerror=function(){uploading--;showErr('Сетевая ошибка');};
xhr.open('POST','/upload/api');
xhr.withCredentials=true;
xhr.send(fd);
}
function deletePhoto(n){
photos = photos.filter(function(p){return p.n!==n;});
var c=document.getElementById('card-'+n); if(c) c.remove();
if(photos.length===0) document.getElementById('output-section').style.display='none';
updateOutput();
}
function showErr(msg){var e=document.getElementById('err');e.textContent=msg;e.style.display='block';setTimeout(function(){e.style.display='none';},5000);}
// ---- Output generation ----
function updateOutput(){
updateNamingHint();
if(photos.length===0) return;
document.getElementById('output-section').style.display='block';
var title = document.getElementById('title').value.trim() || '';
var slug = getSlug();
var date = getDateISO();
var desc = document.getElementById('desc').value.trim() || '';
var disqus = document.getElementById('disqus').value.trim() || '';
var cover = photos.length>0 ? photos[0].url : '';
// Tab 0: fields
var fields = 'Заголовок: '+title+'\n'
+'Slug: '+slug+'\n'
+'Дата: '+(document.getElementById('date').value||'')+'\n'
+'Описание: '+desc+'\n'
+'Обложка: '+cover+'\n'
+'Disqus ID: '+disqus;
document.getElementById('fields-out').textContent = fields;
// Tab 1: markdown content
var md = 'Текст поездки — напиши здесь.\n\n';
photos.forEach(function(p){
md += '!['+slug+']('+p.url+')\n\n';
});
md += '{{< youtube id="YOUTUBE_ID" >}}\n\n';
md += '---\n\n';
md += '📍 Локация\n';
md += '{{< rawhtml >}}\n';
md += '<div class="yandex-map-container">\n';
md += '<!-- Вставь embed-код карты из Яндекс Конструктора -->\n';
md += '</div>\n';
md += '{{< /rawhtml >}}\n\n';
md += '{{< rawhtml >}}\n{{< back-to-top >}}\n{{< /rawhtml >}}';
document.getElementById('md-out').textContent = md;
// Tab 2: URLs
var urls = photos.map(function(p,i){return '#'+(i+1)+': '+p.url;}).join('\n');
document.getElementById('urls-out').textContent = urls;
}
// ---- Tabs ----
function switchTab(i){
var tabs=document.querySelectorAll('.out-tab');
var panels=document.querySelectorAll('.out-panel');
tabs.forEach(function(t,j){t.className='out-tab'+(j===i?' active':'');});
panels.forEach(function(p,j){p.className='out-panel'+(j===i?' active':'');});
}
// ---- Copy ----
function copyPanel(id, btn){
var text = document.getElementById(id).textContent;
navigator.clipboard.writeText(text).then(function(){
var b = document.querySelector('[onclick*="'+id+'"]');
var orig = b.textContent; b.textContent='✓ Скопировано'; b.className='copy-btn ok';
setTimeout(function(){b.textContent=orig;b.className='copy-btn';},2000);
});
}
</script>
</body>
</html>
"""
@app.route('/upload/')
def index():
return PAGE
@app.route('/upload/api', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({'error': 'Нет файла'}), 400
f = request.files['file']
custom_filename = request.form.get('filename', '').strip()
if not f.filename:
return jsonify({'error': 'Пустое имя файла'}), 400
ext = f.filename.rsplit('.', 1)[-1].lower() if '.' in f.filename else ''
if ext not in ALLOWED:
return jsonify({'error': 'Формат .{} не поддерживается'.format(ext)}), 400
# Use custom filename from wizard if provided
if custom_filename:
out_name = custom_filename
out_ext = out_name.rsplit('.', 1)[-1].lower()
else:
original = os.path.splitext(f.filename)[0]
out_ext = 'jpg' if ext in ('heic', 'heif') else ext
out_name = '{}.{}'.format(original, out_ext)
s3_key = 'images/{}'.format(out_name)
tmp_path = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix='.{}'.format(ext)) as tmp:
f.save(tmp.name)
tmp_path = tmp.name
# HEIC → JPG conversion
if ext in ('heic', 'heif'):
img = Image.open(tmp_path)
converted = tmp_path.rsplit('.', 1)[0] + '.jpg'
img.convert('RGB').save(converted, 'JPEG', quality=92)
os.unlink(tmp_path)
tmp_path = converted
import urllib3
urllib3.disable_warnings()
s3 = boto3.client('s3', verify=False, **S3_CONFIG)
s3.upload_file(
tmp_path, BUCKET, s3_key,
ExtraArgs={
'ContentType': MIME.get(out_ext, 'application/octet-stream'),
'CacheControl': 'max-age=31536000'
}
)
url = 'https://s3.regru.cloud/{}/{}'.format(BUCKET, s3_key)
return jsonify({'url': url, 'filename': out_name})
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
if __name__ == '__main__':
app.run(host='127.0.0.1', port=9001, debug=False)