Compare commits
6 Commits
mirror
...
5f99a0460c
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f99a0460c | |||
| d857400988 | |||
| 6731496f7a | |||
| 8eafab3f03 | |||
| b1202bc9af | |||
| d4baad799e |
@@ -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,228 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os, tempfile
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
import pillow_heif
|
||||||
|
from PIL import Image
|
||||||
|
pillow_heif.register_heif_opener()
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB
|
||||||
|
|
||||||
|
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'
|
||||||
|
PREFIX = 'images/'
|
||||||
|
ALLOWED = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'mp4', 'mov', 'heic', 'heif'}
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
PAGE = """<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Upload to S3</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{background:#0d1117;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,monospace;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}
|
||||||
|
.card{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:32px;width:100%;max-width:520px}
|
||||||
|
h1{font-size:16px;font-weight:600;margin-bottom:4px}
|
||||||
|
.sub{font-size:12px;color:#8b949e;margin-bottom:24px}
|
||||||
|
.drop{border:2px dashed #30363d;border-radius:8px;padding:48px 24px;text-align:center;cursor:pointer;transition:all .2s}
|
||||||
|
.drop.over{border-color:#58a6ff;background:rgba(88,166,255,.05)}
|
||||||
|
.drop input{display:none}
|
||||||
|
.drop-icon{font-size:36px;margin-bottom:12px}
|
||||||
|
.drop-text{font-size:13px;color:#8b949e}
|
||||||
|
.drop-text b{color:#58a6ff;cursor:pointer}
|
||||||
|
.progress{margin-top:16px;display:none}
|
||||||
|
.bar-wrap{background:#21262d;border-radius:4px;height:6px;margin-top:8px}
|
||||||
|
.bar{background:#58a6ff;height:6px;border-radius:4px;width:0;transition:width .15s}
|
||||||
|
.status{font-size:12px;color:#8b949e;margin-top:6px}
|
||||||
|
.result{margin-top:20px;display:none}
|
||||||
|
.url-box{background:#0d1117;border:1px solid #3fb950;border-radius:6px;padding:12px 14px;font-size:12px;font-family:monospace;word-break:break-all;color:#3fb950;cursor:pointer}
|
||||||
|
.url-box:hover{background:rgba(63,185,80,.05)}
|
||||||
|
.copy-hint{font-size:11px;color:#8b949e;margin-top:6px}
|
||||||
|
.preview{margin-top:12px;max-width:100%;max-height:200px;border-radius:6px;display:none}
|
||||||
|
.err{color:#f85149;font-size:12px;margin-top:12px;display:none;padding:8px 12px;background:rgba(248,81,73,.08);border-radius:6px}
|
||||||
|
.hist{margin-top:24px;border-top:1px solid #21262d;padding-top:16px;display:none}
|
||||||
|
.hist h2{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#8b949e;margin-bottom:10px}
|
||||||
|
.hi{font-size:11px;font-family:monospace;color:#8b949e;padding:5px 0;border-bottom:1px solid #21262d;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.hi:hover{color:#58a6ff}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Upload → S3</h1>
|
||||||
|
<p class="sub">sleeptrip-dev · s3.regru.cloud/images/</p>
|
||||||
|
<div class="drop" id="drop">
|
||||||
|
<input type="file" id="fi" accept="image/*,video/*" multiple>
|
||||||
|
<div class="drop-icon">⇧</div>
|
||||||
|
<div class="drop-text">Перетащи или <b id="btn">выбери файл</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress" id="prog">
|
||||||
|
<div class="bar-wrap"><div class="bar" id="bar"></div></div>
|
||||||
|
<div class="status" id="stat">Загружаем...</div>
|
||||||
|
</div>
|
||||||
|
<div class="err" id="err"></div>
|
||||||
|
<div class="result" id="res">
|
||||||
|
<div class="url-box" id="url" onclick="copy(this)"></div>
|
||||||
|
<div class="copy-hint" id="hint">Нажми чтобы скопировать</div>
|
||||||
|
<img class="preview" id="prev">
|
||||||
|
</div>
|
||||||
|
<div class="hist" id="hist">
|
||||||
|
<h2>История</h2>
|
||||||
|
<div id="hl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var urls = [];
|
||||||
|
var drop = document.getElementById('drop');
|
||||||
|
var fi = document.getElementById('fi');
|
||||||
|
document.getElementById('btn').onclick = function(){ fi.click(); };
|
||||||
|
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'); for(var i=0;i<e.dataTransfer.files.length;i++) doUpload(e.dataTransfer.files[i]); });
|
||||||
|
fi.addEventListener('change', function(){ for(var i=0;i<fi.files.length;i++) doUpload(fi.files[i]); });
|
||||||
|
|
||||||
|
function doUpload(file) {
|
||||||
|
var prog = document.getElementById('prog');
|
||||||
|
var res = document.getElementById('res');
|
||||||
|
var err = document.getElementById('err');
|
||||||
|
prog.style.display = 'block';
|
||||||
|
res.style.display = 'none';
|
||||||
|
err.style.display = 'none';
|
||||||
|
document.getElementById('bar').style.width = '0';
|
||||||
|
document.getElementById('stat').textContent = file.name + ' ...';
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.upload.onprogress = function(e){ if(e.lengthComputable) document.getElementById('bar').style.width = (e.loaded/e.total*100)+'%'; };
|
||||||
|
xhr.onload = function(){
|
||||||
|
prog.style.display = 'none';
|
||||||
|
if(xhr.status === 200){
|
||||||
|
var r = JSON.parse(xhr.responseText);
|
||||||
|
showRes(r.url, file.type.indexOf('image')===0);
|
||||||
|
} else {
|
||||||
|
var msg = 'Ошибка '+xhr.status;
|
||||||
|
try{ msg = JSON.parse(xhr.responseText).error; }catch(e){}
|
||||||
|
showErr(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = function(){ prog.style.display='none'; showErr('Сетевая ошибка'); };
|
||||||
|
xhr.open('POST', '/upload/api');
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
xhr.send(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRes(url, isImg) {
|
||||||
|
var el = document.getElementById('url');
|
||||||
|
el.textContent = url;
|
||||||
|
document.getElementById('res').style.display = 'block';
|
||||||
|
document.getElementById('hint').textContent = 'Нажми чтобы скопировать';
|
||||||
|
var p = document.getElementById('prev');
|
||||||
|
if(isImg){ p.src=url; p.style.display='block'; } else { p.style.display='none'; }
|
||||||
|
urls.unshift(url);
|
||||||
|
updateHist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErr(msg){ var e=document.getElementById('err'); e.textContent=msg; e.style.display='block'; }
|
||||||
|
|
||||||
|
function copy(el){
|
||||||
|
navigator.clipboard.writeText(el.textContent).then(function(){
|
||||||
|
document.getElementById('hint').textContent='Скопировано!';
|
||||||
|
setTimeout(function(){ document.getElementById('hint').textContent='Нажми чтобы скопировать'; }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHist(){
|
||||||
|
document.getElementById('hist').style.display='block';
|
||||||
|
var html = '';
|
||||||
|
for(var i=0;i<urls.length;i++){
|
||||||
|
html += '<div class="hi" data-url="'+urls[i]+'">'+urls[i]+'</div>';
|
||||||
|
}
|
||||||
|
document.getElementById('hl').innerHTML = html;
|
||||||
|
var items = document.getElementById('hl').querySelectorAll('.hi');
|
||||||
|
for(var j=0;j<items.length;j++){
|
||||||
|
items[j].onclick = (function(u){ return function(){ navigator.clipboard.writeText(u); this.textContent='Скопировано!'; }; })(items[j].getAttribute('data-url'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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']
|
||||||
|
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
|
||||||
|
|
||||||
|
original_name = os.path.splitext(f.filename)[0]
|
||||||
|
|
||||||
|
# HEIC/HEIF → конвертируем в JPG
|
||||||
|
is_heic = ext in ('heic', 'heif')
|
||||||
|
out_ext = 'jpg' if is_heic else ext
|
||||||
|
s3_key = '{}{}.{}'.format(PREFIX, original_name, out_ext)
|
||||||
|
|
||||||
|
tmp_path = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.{}'.format(ext)) as tmp:
|
||||||
|
f.save(tmp.name)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
if is_heic:
|
||||||
|
img = Image.open(tmp_path)
|
||||||
|
converted = tmp_path.replace('.{}'.format(ext), '.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(ext, 'application/octet-stream'),
|
||||||
|
'CacheControl': 'max-age=31536000'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
url = 'https://s3.regru.cloud/{}/{}'.format(BUCKET, s3_key)
|
||||||
|
# hint: HEIC was converted to JPG
|
||||||
|
return jsonify({'url': url})
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
backend:
|
||||||
|
name: gitea
|
||||||
|
repo: ptpadmin/ptp
|
||||||
|
branch: mirror
|
||||||
|
api_root: https://git.ptp.saguaro-cactus.ru/api/v1
|
||||||
|
server: https://git.ptp.saguaro-cactus.ru
|
||||||
|
auth_type: pkce
|
||||||
|
app_id: 6be1691d-ea91-448e-acfa-016f57c976d6
|
||||||
|
|
||||||
|
media_folder: static/images/uploads
|
||||||
|
public_folder: /images/uploads
|
||||||
|
locale: ru
|
||||||
|
|
||||||
|
collections:
|
||||||
|
|
||||||
|
- name: post
|
||||||
|
label: 🧳 Поездки
|
||||||
|
label_singular: Поездка
|
||||||
|
folder: content/post
|
||||||
|
create: true
|
||||||
|
format: toml-frontmatter
|
||||||
|
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:
|
||||||
|
- { label: Заголовок, name: title, widget: string }
|
||||||
|
- { label: Slug (URL), name: slug, widget: string, hint: 'Латиница без пробелов' }
|
||||||
|
- label: Дата
|
||||||
|
name: date
|
||||||
|
widget: datetime
|
||||||
|
format: 'YYYY-MM-DDTHH:mm:ss'
|
||||||
|
date_format: 'DD.MM.YYYY'
|
||||||
|
time_format: 'HH:mm'
|
||||||
|
default: ''
|
||||||
|
- { label: Контент, name: body, widget: markdown }
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset=utf-8 />
|
||||||
|
<meta name=viewport content=width=device-width, initial-scale=1.0 />
|
||||||
|
<meta name=robots content=noindex />
|
||||||
|
<title>PTP Admin</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@sveltia/cms/dist/sveltia-cms.js"></script>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user