Compare commits
5 Commits
b1202bc9af
..
cms
| Author | SHA1 | Date | |
|---|---|---|---|
| 943a111af5 | |||
| 5f99a0460c | |||
| d857400988 | |||
| 6731496f7a | |||
| 8eafab3f03 |
@@ -0,0 +1,6 @@
|
|||||||
|
+++
|
||||||
|
title = '{{ replace .Name "-" " " | title }}'
|
||||||
|
slug = '{{ .Name }}'
|
||||||
|
date = "{{ .Date | dateFormat "2006-01-02T00:00:00" }}"
|
||||||
|
description = ''
|
||||||
|
+++
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
+++
|
||||||
|
title = '{{ replace .Name "-" " " | title }}'
|
||||||
|
slug = '{{ .Name }}'
|
||||||
|
image = "https://s3.regru.cloud/sleeptrip-dev/images/{{ .Name }}_1.jpg"
|
||||||
|
date = "{{ .Date | dateFormat "2006-01-02T00:00:00" }}"
|
||||||
|
description = ''
|
||||||
|
disqus_identifier = 0
|
||||||
|
+++
|
||||||
|
Текст поездки.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
{{< rawhtml >}}
|
||||||
|
{{< back-to-top >}}
|
||||||
|
{{< /rawhtml >}}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# PTP Infrastructure Scripts
|
||||||
|
|
||||||
|
## deploy-ptp.sh
|
||||||
|
Deploy script: git pull mirror → hugo build → /var/www/html
|
||||||
|
Triggered by Gitea webhook on push to mirror/main.
|
||||||
|
Service: ptp-webhook (port 9000)
|
||||||
|
|
||||||
|
## webhook-server.py
|
||||||
|
HTTP server на порту 9000.
|
||||||
|
Принимает POST /deploy от Gitea (HMAC-SHA256 подпись).
|
||||||
|
Secret: ptp-deploy-2026
|
||||||
|
|
||||||
|
## uploader.py
|
||||||
|
Flask-приложение на порту 9001.
|
||||||
|
Drag-and-drop загрузка фото → S3 reg.ru (sleeptrip-dev/images/).
|
||||||
|
Защищено Basic Auth через nginx.
|
||||||
|
Поддерживает: jpg, png, gif, webp, heic, mp4, mov.
|
||||||
|
HEIC автоматически конвертируется в JPG.
|
||||||
|
|
||||||
|
## Systemd services
|
||||||
|
- ptp-webhook.service → webhook-server.py
|
||||||
|
- ptp-uploader.service → uploader.py
|
||||||
|
|
||||||
|
## Nginx snippets
|
||||||
|
- /etc/nginx/snippets/ptp-webhook.conf
|
||||||
|
- /etc/nginx/snippets/ptp-uploader.conf
|
||||||
|
|
||||||
|
## Gitea webhook
|
||||||
|
Repo: ptpadmin/ptp, hook id: 1
|
||||||
|
URL: http://127.0.0.1:9000/deploy
|
||||||
|
Branch filter: mirror
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
LOG=/var/log/ptp-deploy.log
|
||||||
|
echo "[2026-04-29 00:05:28] Deploy started" >> $LOG
|
||||||
|
cd /var/www/hugo-source
|
||||||
|
git checkout mirror >> $LOG 2>&1
|
||||||
|
git pull gitea mirror >> $LOG 2>&1
|
||||||
|
hugo -d /var/www/html --baseURL 'https://ptp.saguaro-cactus.ru' >> $LOG 2>&1
|
||||||
|
echo "[2026-04-29 00:05:28] Deploy done" >> $LOG
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
#!/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 += '\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)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import http.server, subprocess, hashlib, hmac, json, os, sys
|
||||||
|
|
||||||
|
SECRET = os.environ.get('WEBHOOK_SECRET', 'ptp-deploy-2026')
|
||||||
|
PORT = 9000
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != '/deploy':
|
||||||
|
self.send_response(404); self.end_headers(); return
|
||||||
|
|
||||||
|
length = int(self.headers.get('Content-Length', 0))
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
|
||||||
|
# Verify signature (Gitea sends X-Gitea-Signature)
|
||||||
|
sig = self.headers.get('X-Gitea-Signature', '')
|
||||||
|
expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
if not hmac.compare_digest(sig, expected):
|
||||||
|
self.send_response(403); self.end_headers()
|
||||||
|
self.wfile.write(b'Bad signature'); return
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
ref = data.get('ref', '')
|
||||||
|
if ref not in ('refs/heads/mirror', 'refs/heads/main'):
|
||||||
|
self.send_response(200); self.end_headers()
|
||||||
|
self.wfile.write(b'Skipped (not mirror/main)'); return
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
subprocess.Popen(['/usr/local/bin/deploy-ptp.sh'])
|
||||||
|
self.send_response(200); self.end_headers()
|
||||||
|
self.wfile.write(b'Deploy triggered')
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
pass # silent
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
httpd = http.server.HTTPServer(('127.0.0.1', PORT), Handler)
|
||||||
|
print(f'Webhook server on port {PORT}')
|
||||||
|
httpd.serve_forever()
|
||||||
+69
-13
@@ -1,28 +1,84 @@
|
|||||||
backend:
|
backend:
|
||||||
name: test-repo
|
name: gitea
|
||||||
|
repo: ptpadmin/ptp
|
||||||
# Когда переключим на GitHub:
|
branch: mirror
|
||||||
# backend:
|
api_root: https://git.ptp.saguaro-cactus.ru/api/v1
|
||||||
# name: github
|
server: https://git.ptp.saguaro-cactus.ru
|
||||||
# repo: reddragon333/ptp
|
auth_type: pkce
|
||||||
# branch: mirror ← пишет сюда, не в main/прод
|
app_id: 6be1691d-ea91-448e-acfa-016f57c976d6
|
||||||
|
|
||||||
media_folder: static/images/uploads
|
media_folder: static/images/uploads
|
||||||
public_folder: /images/uploads
|
public_folder: /images/uploads
|
||||||
locale: ru
|
locale: ru
|
||||||
|
|
||||||
collections:
|
collections:
|
||||||
|
|
||||||
- name: post
|
- name: post
|
||||||
label: Посты / Поездки
|
label: 🧳 Поездки
|
||||||
label_singular: Пост
|
label_singular: Поездка
|
||||||
folder: content/post
|
folder: content/post
|
||||||
create: true
|
create: true
|
||||||
format: toml-frontmatter
|
format: toml-frontmatter
|
||||||
extension: md
|
extension: md
|
||||||
|
summary: '{{title}} — {{date}}'
|
||||||
|
fields:
|
||||||
|
- label: Заголовок
|
||||||
|
name: title
|
||||||
|
widget: string
|
||||||
|
hint: 'Например: Коломна или Дюкинский заказник'
|
||||||
|
|
||||||
|
- label: Slug (URL)
|
||||||
|
name: slug
|
||||||
|
widget: string
|
||||||
|
hint: 'Латиница без пробелов. Пример: Kolomna или Dukyn2504'
|
||||||
|
|
||||||
|
- label: Дата
|
||||||
|
name: date
|
||||||
|
widget: datetime
|
||||||
|
format: 'YYYY-MM-DDTHH:mm:ss'
|
||||||
|
date_format: 'DD.MM.YYYY'
|
||||||
|
time_format: 'HH:mm'
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
- label: Описание (анонс)
|
||||||
|
name: description
|
||||||
|
widget: string
|
||||||
|
hint: 'Короткая фраза. Пример: скалы, рассвет, однодневная поездка'
|
||||||
|
|
||||||
|
- label: Обложка (S3 URL)
|
||||||
|
name: image
|
||||||
|
widget: string
|
||||||
|
required: false
|
||||||
|
hint: 'https://s3.regru.cloud/sleeptrip-dev/images/ИМЯ_1.jpg'
|
||||||
|
pattern: ['^https?://', 'Должен быть полный URL']
|
||||||
|
|
||||||
|
- label: Disqus ID
|
||||||
|
name: disqus_identifier
|
||||||
|
widget: number
|
||||||
|
value_type: int
|
||||||
|
hint: 'Следующий свободный: 134. Каждый пост — уникальный номер.'
|
||||||
|
min: 1
|
||||||
|
|
||||||
|
- label: Контент
|
||||||
|
name: body
|
||||||
|
widget: markdown
|
||||||
|
|
||||||
|
- name: notes
|
||||||
|
label: 📝 Заметки
|
||||||
|
label_singular: Заметка
|
||||||
|
folder: content/notes
|
||||||
|
create: true
|
||||||
|
format: toml-frontmatter
|
||||||
|
extension: md
|
||||||
|
summary: '{{title}} — {{date}}'
|
||||||
fields:
|
fields:
|
||||||
- { label: Заголовок, name: title, widget: string }
|
- { label: Заголовок, name: title, widget: string }
|
||||||
- { label: Slug (URL), name: slug, widget: string }
|
- { label: Slug (URL), name: slug, widget: string, hint: 'Латиница без пробелов' }
|
||||||
- { label: Дата, name: date, widget: datetime, format: 'YYYY-MM-DDTHH:mm:ss', default: '' }
|
- label: Дата
|
||||||
- { label: Описание (анонс), name: description, widget: string }
|
name: date
|
||||||
- { label: Обложка (URL из S3), name: image, widget: string, required: false }
|
widget: datetime
|
||||||
|
format: 'YYYY-MM-DDTHH:mm:ss'
|
||||||
|
date_format: 'DD.MM.YYYY'
|
||||||
|
time_format: 'HH:mm'
|
||||||
|
default: ''
|
||||||
- { label: Контент, name: body, widget: markdown }
|
- { label: Контент, name: body, widget: markdown }
|
||||||
|
|||||||
Reference in New Issue
Block a user