diff --git a/infra/README.md b/infra/README.md
new file mode 100644
index 0000000..fd643ff
--- /dev/null
+++ b/infra/README.md
@@ -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
diff --git a/infra/deploy-ptp.sh b/infra/deploy-ptp.sh
new file mode 100644
index 0000000..f4562b7
--- /dev/null
+++ b/infra/deploy-ptp.sh
@@ -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
diff --git a/infra/uploader.py b/infra/uploader.py
new file mode 100644
index 0000000..d127081
--- /dev/null
+++ b/infra/uploader.py
@@ -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 = """
+
+
+
+
+Upload to S3
+
+
+
+
+
Upload → S3
+
sleeptrip-dev · s3.regru.cloud/images/
+
+
+
⇧
+
Перетащи или выбери файл
+
+
+
+
+
+
Нажми чтобы скопировать
+
![]()
+
+
+
+
+
+"""
+
+
+@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)
diff --git a/infra/webhook-server.py b/infra/webhook-server.py
new file mode 100644
index 0000000..6fc4e42
--- /dev/null
+++ b/infra/webhook-server.py
@@ -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()