Docs: rewrite README with clear Docker and Docker Compose instructions; refactor app into package (auth, admin, pages, db); keep single entrypoint app.py
This commit is contained in:
118
README.md
118
README.md
@@ -1,51 +1,101 @@
|
|||||||
# Flask UUID Pages Admin (SQLite)
|
# ForMe — мини CMS на Flask (SQLite)
|
||||||
|
|
||||||
Минималистичное Flask‑приложение с SQLite:
|
Небольшое приложение для публикации HTML‑страниц по UUID.
|
||||||
- Админка доступна только пользователю `ruslan` с паролем `utOgbZ09ruslan` (по умолчанию).
|
|
||||||
- В админке можно вставлять HTML и публиковать страницы.
|
|
||||||
- Для каждой страницы генерируется UUID‑ссылка `/p/<uuid>`.
|
|
||||||
- Неавторизованный пользователь не видит админку и главную страницу (404). Страницы по UUID доступны всем, у кого есть ссылка.
|
|
||||||
|
|
||||||
## Запуск локально
|
Возможности
|
||||||
|
- Авторизация администратора (логин/пароль из переменных окружения).
|
||||||
|
- Публикация HTML со свободной разметкой; генерация ссылки `/p/<uuid>`.
|
||||||
|
- Редактирование и удаление опубликованных страниц в админке.
|
||||||
|
- На опубликованных страницах у авторизованных отображается плавающая кнопка “Админка”.
|
||||||
|
- Водяной знак “Made by Ruslan” в левом верхнем углу опубликованных страниц.
|
||||||
|
|
||||||
1. Создайте и активируйте виртуальное окружение (опционально):
|
Важно: функционал экспорта в PDF/Word удалён — код и зависимости очищены.
|
||||||
|
|
||||||
Windows PowerShell:
|
## Локальный запуск
|
||||||
|
|
||||||
```powershell
|
1) Создать и активировать виртуальное окружение (Windows PowerShell):
|
||||||
python -m venv .venv
|
|
||||||
.venv\\Scripts\\Activate.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Установите зависимости:
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\Activate.ps1
|
||||||
|
```
|
||||||
|
|
||||||
```powershell
|
2) Установить зависимости:
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
3. (Опционально) Переопределите настройки через переменные окружения:
|
```powershell
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
```powershell
|
3) (Опционально) Настроить переменные окружения:
|
||||||
$env:SECRET_KEY = "your-strong-secret-key"
|
|
||||||
$env:ADMIN_USERNAME = "ruslan"
|
|
||||||
$env:ADMIN_PASSWORD = "utOgbZ09ruslan"
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Запустите сервер разработки:
|
```powershell
|
||||||
|
$env:SECRET_KEY = "your-strong-secret"
|
||||||
|
$env:ADMIN_USERNAME = "ruslan"
|
||||||
|
$env:ADMIN_PASSWORD = "utOgbZ09ruslan"
|
||||||
|
# путь для БД по умолчанию: ./app.db
|
||||||
|
```
|
||||||
|
|
||||||
```powershell
|
4) Запустить приложение:
|
||||||
python app.py
|
|
||||||
```
|
```powershell
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
5) Открыть http://localhost:5000
|
||||||
|
|
||||||
## Маршруты
|
## Маршруты
|
||||||
|
|
||||||
- `/login` — форма входа в админку.
|
- `/login` — вход
|
||||||
- `/admin` — админ‑панель (только для авторизованных, иначе 404).
|
- `/admin` — панель публикации и список страниц (только для авторизованных)
|
||||||
- `/p/<uuid>` — просмотр опубликованной страницы по UUID.
|
- `/p/<uuid>` — просмотр опубликованной страницы
|
||||||
- `/` — редирект в админку для авторизованных, иначе 404.
|
- `/` — редиректит на `/login` или `/admin`
|
||||||
|
|
||||||
## Замечания по безопасности
|
## Запуск в Docker
|
||||||
|
|
||||||
- HTML хранится и отдаётся как есть. Предполагается, что контент вводит доверенный админ.
|
В проекте есть `Dockerfile` (gunicorn) и `docker-compose.yml` с томом для БД.
|
||||||
- В продакшне обязательно задайте `SECRET_KEY` и используйте HTTPS; настройте защищённые cookies.
|
|
||||||
|
Вариант A. Docker (без compose)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t forme .
|
||||||
|
docker run --name forme-web -p 5000:5000 \
|
||||||
|
-e SECRET_KEY=prod-secret \
|
||||||
|
-e ADMIN_USERNAME=ruslan \
|
||||||
|
-e ADMIN_PASSWORD=utOgbZ09ruslan \
|
||||||
|
-e DATABASE=/data/app.db \
|
||||||
|
-v forme_appdb:/data \
|
||||||
|
-d forme
|
||||||
|
```
|
||||||
|
|
||||||
|
Вариант B. Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
По умолчанию:
|
||||||
|
- Приложение доступно на `http://localhost:5000`
|
||||||
|
- Переменные окружения можно переопределить через `.env`
|
||||||
|
- База хранится в томе `appdb` по пути контейнера `/data/app.db`
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
app.py # точка входа
|
||||||
|
app/
|
||||||
|
__init__.py # create_app + регистрация блюпринтов
|
||||||
|
db.py # инициализация/доступ к SQLite
|
||||||
|
auth.py # логин/логаут и context processor
|
||||||
|
admin.py # админка: создать/редактировать/удалить
|
||||||
|
pages.py # публичные страницы / и /p/<uuid>
|
||||||
|
templates/
|
||||||
|
base.html, login.html, admin.html, edit.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Заметки по продакшену
|
||||||
|
|
||||||
|
- Обязательно задайте `SECRET_KEY` через переменные окружения.
|
||||||
|
- Смените дефолтные `ADMIN_USERNAME/ADMIN_PASSWORD`.
|
||||||
|
- Вынесите БД в том/персистентный диск.
|
||||||
|
|
||||||
|
|||||||
338
app.py
338
app.py
@@ -1,340 +1,8 @@
|
|||||||
import os
|
from app import create_app
|
||||||
import sqlite3
|
|
||||||
import uuid as uuid_lib
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from flask import (
|
|
||||||
Flask,
|
|
||||||
g,
|
|
||||||
render_template,
|
|
||||||
request,
|
|
||||||
redirect,
|
|
||||||
url_for,
|
|
||||||
session,
|
|
||||||
abort,
|
|
||||||
flash,
|
|
||||||
Response,
|
|
||||||
send_file,
|
|
||||||
)
|
|
||||||
from io import BytesIO
|
|
||||||
import re
|
|
||||||
import unicodedata
|
|
||||||
from urllib.parse import quote as urlquote
|
|
||||||
from xhtml2pdf import pisa # type: ignore
|
|
||||||
from docx import Document # type: ignore
|
|
||||||
from htmldocx import HtmlToDocx # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
# Basic config
|
|
||||||
app.config["SECRET_KEY"] = os.environ.get(
|
|
||||||
"SECRET_KEY",
|
|
||||||
# For production, override via env var. This default is for local/dev only.
|
|
||||||
"dev-secret-change-me",
|
|
||||||
)
|
|
||||||
app.config["DATABASE"] = os.path.join(os.path.dirname(__file__), "app.db")
|
|
||||||
|
|
||||||
# Admin credentials (can be overridden via env)
|
|
||||||
app.config["ADMIN_USERNAME"] = os.environ.get("ADMIN_USERNAME", "ruslan")
|
|
||||||
app.config["ADMIN_PASSWORD"] = os.environ.get("ADMIN_PASSWORD", "utOgbZ09ruslan")
|
|
||||||
|
|
||||||
# --------------------
|
|
||||||
# Database helpers
|
|
||||||
# --------------------
|
|
||||||
def get_db():
|
|
||||||
if "db" not in g:
|
|
||||||
g.db = sqlite3.connect(app.config["DATABASE"]) # type: ignore[attr-defined]
|
|
||||||
g.db.row_factory = sqlite3.Row
|
|
||||||
return g.db
|
|
||||||
|
|
||||||
def close_db(e=None):
|
|
||||||
db = g.pop("db", None)
|
|
||||||
if db is not None:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
def init_db():
|
|
||||||
db = get_db()
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS pages (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
|
||||||
title TEXT NOT NULL DEFAULT '',
|
|
||||||
html TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
# Migration: add 'title' column for older DB versions
|
|
||||||
try:
|
|
||||||
cols = {row[1] for row in db.execute("PRAGMA table_info(pages)").fetchall()}
|
|
||||||
if "title" not in cols:
|
|
||||||
db.execute("ALTER TABLE pages ADD COLUMN title TEXT NOT NULL DEFAULT ''")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
@app.before_request
|
|
||||||
def _before_request():
|
|
||||||
# Ensure DB exists
|
|
||||||
init_db()
|
|
||||||
|
|
||||||
@app.teardown_appcontext
|
|
||||||
def _teardown_appcontext(_=None):
|
|
||||||
close_db()
|
|
||||||
|
|
||||||
# --------------------
|
|
||||||
# Auth helpers
|
|
||||||
# --------------------
|
|
||||||
def is_logged_in() -> bool:
|
|
||||||
return bool(session.get("logged_in"))
|
|
||||||
|
|
||||||
def login_required():
|
|
||||||
if not is_logged_in():
|
|
||||||
# For non-auth users: show nothing (404), not even an admin hint
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
@app.context_processor
|
|
||||||
def inject_auth():
|
|
||||||
return {"logged_in": is_logged_in()}
|
|
||||||
|
|
||||||
# --------------------
|
|
||||||
# Routes
|
|
||||||
# --------------------
|
|
||||||
@app.route("/")
|
|
||||||
def index():
|
|
||||||
# Redirect unauthenticated users to login; authenticated users to admin.
|
|
||||||
if not is_logged_in():
|
|
||||||
return redirect(url_for("login"))
|
|
||||||
return redirect(url_for("admin"))
|
|
||||||
|
|
||||||
@app.route("/login", methods=["GET", "POST"])
|
|
||||||
def login():
|
|
||||||
if request.method == "POST":
|
|
||||||
username = request.form.get("username", "")
|
|
||||||
password = request.form.get("password", "")
|
|
||||||
if (
|
|
||||||
username == app.config["ADMIN_USERNAME"]
|
|
||||||
and password == app.config["ADMIN_PASSWORD"]
|
|
||||||
):
|
|
||||||
session["logged_in"] = True
|
|
||||||
flash("Успешный вход в админку.", "success")
|
|
||||||
return redirect(url_for("admin"))
|
|
||||||
flash("Неверные имя пользователя или пароль.", "error")
|
|
||||||
return render_template("login.html")
|
|
||||||
|
|
||||||
@app.route("/logout", methods=["POST"])
|
|
||||||
def logout():
|
|
||||||
session.clear()
|
|
||||||
# After logout, nothing is visible. Return 404-like behavior.
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
@app.route("/admin", methods=["GET", "POST"])
|
|
||||||
def admin():
|
|
||||||
login_required()
|
|
||||||
db = get_db()
|
|
||||||
if request.method == "POST":
|
|
||||||
title = request.form.get("title", "").strip()
|
|
||||||
html = request.form.get("html", "").strip()
|
|
||||||
if not html:
|
|
||||||
flash("HTML не может быть пустым.", "error")
|
|
||||||
else:
|
|
||||||
uid = uuid_lib.uuid4().hex
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO pages (uuid, title, html, created_at) VALUES (?, ?, ?, ?)",
|
|
||||||
(uid, title, html, datetime.utcnow().isoformat(timespec="seconds")),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
flash("Страница опубликована.", "success")
|
|
||||||
return redirect(url_for("admin"))
|
|
||||||
|
|
||||||
pages = db.execute(
|
|
||||||
"SELECT id, uuid, title, created_at FROM pages ORDER BY id DESC"
|
|
||||||
).fetchall()
|
|
||||||
base_url = request.host_url.rstrip("/")
|
|
||||||
return render_template("admin.html", pages=pages, base_url=base_url)
|
|
||||||
|
|
||||||
@app.route("/admin/edit/<int:pid>", methods=["GET", "POST"])
|
|
||||||
def admin_edit(pid: int):
|
|
||||||
login_required()
|
|
||||||
db = get_db()
|
|
||||||
if request.method == "POST":
|
|
||||||
title = request.form.get("title", "").strip()
|
|
||||||
html = request.form.get("html", "").strip()
|
|
||||||
if not html:
|
|
||||||
flash("HTML не может быть пустым.", "error")
|
|
||||||
else:
|
|
||||||
db.execute(
|
|
||||||
"UPDATE pages SET title = ?, html = ? WHERE id = ?",
|
|
||||||
(title, html, pid),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
flash("Страница обновлена.", "success")
|
|
||||||
return redirect(url_for("admin"))
|
|
||||||
|
|
||||||
row = db.execute(
|
|
||||||
"SELECT id, uuid, title, html, created_at FROM pages WHERE id = ?",
|
|
||||||
(pid,),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
abort(404)
|
|
||||||
return render_template("edit.html", page=row)
|
|
||||||
|
|
||||||
@app.route("/admin/delete/<int:pid>", methods=["POST"])
|
|
||||||
def admin_delete(pid: int):
|
|
||||||
login_required()
|
|
||||||
db = get_db()
|
|
||||||
cur = db.execute("DELETE FROM pages WHERE id = ?", (pid,))
|
|
||||||
db.commit()
|
|
||||||
if cur.rowcount:
|
|
||||||
flash("Страница удалена.", "success")
|
|
||||||
else:
|
|
||||||
flash("Страница не найдена.", "error")
|
|
||||||
return redirect(url_for("admin"))
|
|
||||||
|
|
||||||
@app.route("/p/<string:uid>")
|
|
||||||
def view_page(uid: str):
|
|
||||||
db = get_db()
|
|
||||||
row = db.execute("SELECT html, title FROM pages WHERE uuid = ?", (uid,)).fetchone()
|
|
||||||
if row is None:
|
|
||||||
abort(404)
|
|
||||||
# Show a floating admin button for authenticated users, otherwise serve raw HTML
|
|
||||||
html: str = row["html"]
|
|
||||||
# Inject <title> tag for better page naming
|
|
||||||
try:
|
|
||||||
page_title = row["title"]
|
|
||||||
except Exception:
|
|
||||||
page_title = ""
|
|
||||||
if page_title:
|
|
||||||
lower = html.lower()
|
|
||||||
if "</head>" in lower:
|
|
||||||
i = lower.rfind("</head>")
|
|
||||||
html = html[:i] + f"<title>{page_title}</title>" + html[i:]
|
|
||||||
elif "<html" in lower:
|
|
||||||
if "<body" in lower:
|
|
||||||
j = lower.find("<body")
|
|
||||||
html = html[:j] + f"<head><meta charset=\"utf-8\"><title>{page_title}</title></head>" + html[j:]
|
|
||||||
else:
|
|
||||||
html = f"<head><meta charset=\"utf-8\"><title>{page_title}</title></head>" + html
|
|
||||||
if is_logged_in():
|
|
||||||
admin_url = url_for("admin")
|
|
||||||
toolbar = (
|
|
||||||
'<div style="position:fixed;top:16px;right:16px;z-index:2147483647;">'
|
|
||||||
f'<a href="{admin_url}" '
|
|
||||||
'style="background:linear-gradient(135deg,#111,#333);color:#fff;padding:10px 14px;'
|
|
||||||
'border-radius:10px;box-shadow:0 8px 20px rgba(0,0,0,0.25);'
|
|
||||||
'text-decoration:none;font-weight:600;letter-spacing:.2px;'
|
|
||||||
'font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;"'
|
|
||||||
'>Админка</a></div>'
|
|
||||||
)
|
|
||||||
lower = html.lower()
|
|
||||||
if "</body>" in lower:
|
|
||||||
idx = lower.rfind("</body>")
|
|
||||||
html = html[:idx] + toolbar + html[idx:]
|
|
||||||
else:
|
|
||||||
html = html + toolbar
|
|
||||||
# Ensure watermark exists on published pages (top-left)
|
|
||||||
wm = (
|
|
||||||
"<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">"
|
|
||||||
"<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>"
|
|
||||||
"<link href=\"https://fonts.googleapis.com/css2?family=Pacifico&display=swap\" rel=\"stylesheet\">"
|
|
||||||
"<div style=\"position:fixed;top:16px;left:16px;z-index:2147483647;"
|
|
||||||
"font-family:'Pacifico',cursive;letter-spacing:.2px;color:rgba(255,255,255,.92);"
|
|
||||||
"text-shadow:0 2px 8px rgba(0,0,0,.35);pointer-events:none;user-select:none;\">"
|
|
||||||
"Made by Ruslan"
|
|
||||||
"</div>"
|
|
||||||
)
|
|
||||||
# Export buttons (visible to everyone). If admin overlay exists, offset a bit.
|
|
||||||
top_offset = "60px" if is_logged_in() else "16px"
|
|
||||||
pdf_url = url_for("export_pdf", uid=uid)
|
|
||||||
docx_url = url_for("export_docx", uid=uid)
|
|
||||||
exports = (
|
|
||||||
f'<div style="position:fixed;top:{top_offset};right:16px;z-index:2147483647;display:flex;gap:8px;">'
|
|
||||||
f'<a href="{pdf_url}" style="background:#2b2f3b;color:#fff;padding:8px 10px;border-radius:8px;text-decoration:none;font-weight:600;">PDF</a>'
|
|
||||||
f'<a href="{docx_url}" style="background:#2b2f3b;color:#fff;padding:8px 10px;border-radius:8px;text-decoration:none;font-weight:600;">Word</a>'
|
|
||||||
'</div>'
|
|
||||||
)
|
|
||||||
overlays = wm + exports
|
|
||||||
lower_all = html.lower()
|
|
||||||
if "</body>" in lower_all:
|
|
||||||
i2 = lower_all.rfind("</body>")
|
|
||||||
html = html[:i2] + overlays + html[i2:]
|
|
||||||
else:
|
|
||||||
html = html + overlays
|
|
||||||
return Response(html, mimetype="text/html; charset=utf-8")
|
|
||||||
|
|
||||||
def _fetch_page(uid: str):
|
|
||||||
db = get_db()
|
|
||||||
row = db.execute("SELECT title, html FROM pages WHERE uuid = ?", (uid,)).fetchone()
|
|
||||||
if row is None:
|
|
||||||
abort(404)
|
|
||||||
return row
|
|
||||||
|
|
||||||
def _sanitize_html_for_pdf(html: str) -> str:
|
|
||||||
# xhtml2pdf плохо переносит современный CSS; вычищаем стили/скрипты
|
|
||||||
html = re.sub(r"<style[^>]*>.*?</style>", "", html, flags=re.I | re.S)
|
|
||||||
html = re.sub(r"\sstyle=(\"|\')(.*?)\1", "", html, flags=re.I | re.S)
|
|
||||||
html = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.I | re.S)
|
|
||||||
return html
|
|
||||||
|
|
||||||
def _wrap_html_for_export(title: str, html: str) -> str:
|
|
||||||
head_title = f"<title>{title}</title>" if title else ""
|
|
||||||
return (
|
|
||||||
"<!doctype html><html lang='ru'><head><meta charset='utf-8'>" + head_title +
|
|
||||||
"<style>body{font-family:Arial,Helvetica,sans-serif;}</style></head><body>" +
|
|
||||||
html + "</body></html>"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _safe_download_name(title: str, uid: str, ext: str) -> str:
|
|
||||||
base = (title or f"page-{uid[:8]}").strip()
|
|
||||||
ascii_base = unicodedata.normalize("NFKD", base).encode("ascii", "ignore").decode("ascii")
|
|
||||||
ascii_base = re.sub(r"[^A-Za-z0-9_.-]+", "_", ascii_base).strip("_") or f"page-{uid[:8]}"
|
|
||||||
return f"{ascii_base}.{ext}"
|
|
||||||
|
|
||||||
@app.route("/p/<string:uid>.pdf")
|
|
||||||
def export_pdf(uid: str):
|
|
||||||
row = _fetch_page(uid)
|
|
||||||
title = row["title"] or f"page-{uid[:8]}"
|
|
||||||
cleaned = _sanitize_html_for_pdf(row["html"])
|
|
||||||
html_doc = _wrap_html_for_export(title, cleaned)
|
|
||||||
out = BytesIO()
|
|
||||||
pisa.CreatePDF(src=html_doc, dest=out)
|
|
||||||
out.seek(0)
|
|
||||||
filename = _safe_download_name(title, uid, "pdf")
|
|
||||||
# send_file properly sets Content-Disposition with download_name
|
|
||||||
return send_file(out, mimetype="application/pdf", as_attachment=True, download_name=filename)
|
|
||||||
|
|
||||||
@app.route("/p/<string:uid>.docx")
|
|
||||||
def export_docx(uid: str):
|
|
||||||
row = _fetch_page(uid)
|
|
||||||
title = row["title"] or f"page-{uid[:8]}"
|
|
||||||
html_doc = _wrap_html_for_export(title, row["html"])
|
|
||||||
doc = Document()
|
|
||||||
HtmlToDocx().add_html_to_document(html_doc, doc)
|
|
||||||
out = BytesIO()
|
|
||||||
doc.save(out)
|
|
||||||
out.seek(0)
|
|
||||||
filename = _safe_download_name(title, uid, "docx")
|
|
||||||
return send_file(
|
|
||||||
out,
|
|
||||||
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
||||||
as_attachment=True,
|
|
||||||
download_name=filename,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Optional: simple 404 page to keep things minimal
|
|
||||||
@app.errorhandler(404)
|
|
||||||
def not_found(_):
|
|
||||||
return ("", 404)
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Run development server
|
import os
|
||||||
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=True)
|
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=True)
|
||||||
|
|
||||||
|
|||||||
34
app/__init__.py
Normal file
34
app/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import os
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .auth import auth_bp
|
||||||
|
from .admin import admin_bp
|
||||||
|
from .pages import pages_bp
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> Flask:
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Basic config
|
||||||
|
app.config["SECRET_KEY"] = os.environ.get(
|
||||||
|
"SECRET_KEY",
|
||||||
|
"dev-secret-change-me",
|
||||||
|
)
|
||||||
|
app.config["DATABASE"] = os.environ.get(
|
||||||
|
"DATABASE",
|
||||||
|
os.path.join(os.path.dirname(os.path.dirname(__file__)), "app.db"),
|
||||||
|
)
|
||||||
|
app.config["ADMIN_USERNAME"] = os.environ.get("ADMIN_USERNAME", "ruslan")
|
||||||
|
app.config["ADMIN_PASSWORD"] = os.environ.get("ADMIN_PASSWORD", "utOgbZ09ruslan")
|
||||||
|
|
||||||
|
# Init DB hooks
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(pages_bp)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
73
app/admin.py
Normal file
73
app/admin.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import uuid as uuid_lib
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||||
|
|
||||||
|
from .db import get_db
|
||||||
|
from .auth import login_required
|
||||||
|
|
||||||
|
|
||||||
|
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/", methods=["GET", "POST"])
|
||||||
|
def index():
|
||||||
|
login_required()
|
||||||
|
db = get_db()
|
||||||
|
if request.method == "POST":
|
||||||
|
title = request.form.get("title", "").strip()
|
||||||
|
html = request.form.get("html", "").strip()
|
||||||
|
if not html:
|
||||||
|
flash("HTML не может быть пустым.", "error")
|
||||||
|
else:
|
||||||
|
uid = uuid_lib.uuid4().hex
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO pages (uuid, title, html, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(uid, title, html, datetime.utcnow().isoformat(timespec="seconds")),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
flash("Страница опубликована.", "success")
|
||||||
|
return redirect(url_for("admin.index"))
|
||||||
|
|
||||||
|
pages = db.execute(
|
||||||
|
"SELECT id, uuid, title, created_at FROM pages ORDER BY id DESC"
|
||||||
|
).fetchall()
|
||||||
|
from flask import request as _request # local import
|
||||||
|
|
||||||
|
base_url = _request.host_url.rstrip("/")
|
||||||
|
return render_template("admin.html", pages=pages, base_url=base_url)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/edit/<int:pid>", methods=["GET", "POST"])
|
||||||
|
def edit(pid: int):
|
||||||
|
login_required()
|
||||||
|
db = get_db()
|
||||||
|
if request.method == "POST":
|
||||||
|
title = request.form.get("title", "").strip()
|
||||||
|
html = request.form.get("html", "").strip()
|
||||||
|
if not html:
|
||||||
|
flash("HTML не может быть пустым.", "error")
|
||||||
|
else:
|
||||||
|
db.execute("UPDATE pages SET title = ?, html = ? WHERE id = ?", (title, html, pid))
|
||||||
|
db.commit()
|
||||||
|
flash("Страница обновлена.", "success")
|
||||||
|
return redirect(url_for("admin.index"))
|
||||||
|
|
||||||
|
row = db.execute("SELECT id, uuid, title, html, created_at FROM pages WHERE id = ?", (pid,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
from flask import abort as _abort
|
||||||
|
_abort(404)
|
||||||
|
return render_template("edit.html", page=row)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/delete/<int:pid>", methods=["POST"])
|
||||||
|
def delete(pid: int):
|
||||||
|
login_required()
|
||||||
|
db = get_db()
|
||||||
|
cur = db.execute("DELETE FROM pages WHERE id = ?", (pid,))
|
||||||
|
db.commit()
|
||||||
|
if cur.rowcount:
|
||||||
|
flash("Страница удалена.", "success")
|
||||||
|
else:
|
||||||
|
flash("Страница не найдена.", "error")
|
||||||
|
return redirect(url_for("admin.index"))
|
||||||
|
|
||||||
41
app/auth.py
Normal file
41
app/auth.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from flask import Blueprint, current_app, render_template, request, redirect, url_for, session, abort, flash
|
||||||
|
|
||||||
|
|
||||||
|
auth_bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_logged_in() -> bool:
|
||||||
|
return bool(session.get("logged_in"))
|
||||||
|
|
||||||
|
|
||||||
|
def login_required():
|
||||||
|
if not is_logged_in():
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.app_context_processor
|
||||||
|
def inject_auth():
|
||||||
|
return {"logged_in": is_logged_in()}
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "")
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
if (
|
||||||
|
username == current_app.config["ADMIN_USERNAME"]
|
||||||
|
and password == current_app.config["ADMIN_PASSWORD"]
|
||||||
|
):
|
||||||
|
session["logged_in"] = True
|
||||||
|
flash("Вход выполнен", "success")
|
||||||
|
return redirect(url_for("admin.index"))
|
||||||
|
flash("Неверные логин или пароль", "error")
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/logout", methods=["POST"])
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
abort(404)
|
||||||
|
|
||||||
50
app/db.py
Normal file
50
app/db.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import g, current_app
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
if "db" not in g:
|
||||||
|
g.db = sqlite3.connect(current_app.config["DATABASE"]) # type: ignore[attr-defined]
|
||||||
|
g.db.row_factory = sqlite3.Row
|
||||||
|
return g.db
|
||||||
|
|
||||||
|
|
||||||
|
def close_db(_=None):
|
||||||
|
db = g.pop("db", None)
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS pages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
html TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# Migrate: ensure title exists
|
||||||
|
try:
|
||||||
|
cols = {row[1] for row in db.execute("PRAGMA table_info(pages)").fetchall()}
|
||||||
|
if "title" not in cols:
|
||||||
|
db.execute("ALTER TABLE pages ADD COLUMN title TEXT NOT NULL DEFAULT ''")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def init_app(app):
|
||||||
|
@app.before_request
|
||||||
|
def _before_request(): # noqa: D401
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
@app.teardown_appcontext
|
||||||
|
def _teardown(_=None): # noqa: D401
|
||||||
|
close_db()
|
||||||
|
|
||||||
76
app/pages.py
Normal file
76
app/pages.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from flask import Blueprint, redirect, url_for, abort, Response
|
||||||
|
|
||||||
|
from .db import get_db
|
||||||
|
from .auth import is_logged_in
|
||||||
|
|
||||||
|
|
||||||
|
pages_bp = Blueprint("pages", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/")
|
||||||
|
def home():
|
||||||
|
if not is_logged_in():
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
return redirect(url_for("admin.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@pages_bp.route("/p/<string:uid>")
|
||||||
|
def view_page(uid: str):
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute("SELECT html, title FROM pages WHERE uuid = ?", (uid,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
html: str = row["html"]
|
||||||
|
page_title = row["title"] or ""
|
||||||
|
if page_title:
|
||||||
|
lower = html.lower()
|
||||||
|
if "</head>" in lower:
|
||||||
|
i = lower.rfind("</head>")
|
||||||
|
html = html[:i] + f"<title>{page_title}</title>" + html[i:]
|
||||||
|
elif "<html" in lower:
|
||||||
|
if "<body" in lower:
|
||||||
|
j = lower.find("<body")
|
||||||
|
html = html[:j] + f"<head><meta charset=\"utf-8\"><title>{page_title}</title></head>" + html[j:]
|
||||||
|
else:
|
||||||
|
html = f"<head><meta charset=\"utf-8\"><title>{page_title}</title></head>" + html
|
||||||
|
|
||||||
|
# Admin button only for logged-in users
|
||||||
|
if is_logged_in():
|
||||||
|
admin_url = url_for("admin.index")
|
||||||
|
toolbar = (
|
||||||
|
'<div style="position:fixed;top:16px;right:16px;z-index:2147483647;">'
|
||||||
|
f'<a href="{admin_url}" '
|
||||||
|
'style="background:linear-gradient(135deg,#111,#333);color:#fff;padding:10px 14px;'
|
||||||
|
'border-radius:10px;box-shadow:0 8px 20px rgba(0,0,0,0.25);'
|
||||||
|
'text-decoration:none;font-weight:600;letter-spacing:.2px;'
|
||||||
|
'font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;"'
|
||||||
|
'>Админка</a></div>'
|
||||||
|
)
|
||||||
|
lower = html.lower()
|
||||||
|
if "</body>" in lower:
|
||||||
|
idx = lower.rfind("</body>")
|
||||||
|
html = html[:idx] + toolbar + html[idx:]
|
||||||
|
else:
|
||||||
|
html = html + toolbar
|
||||||
|
|
||||||
|
# Watermark for everyone (top-left)
|
||||||
|
wm = (
|
||||||
|
"<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">"
|
||||||
|
"<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>"
|
||||||
|
"<link href=\"https://fonts.googleapis.com/css2?family=Pacifico&display=swap\" rel=\"stylesheet\">"
|
||||||
|
"<div style=\"position:fixed;top:16px;left:16px;z-index:2147483647;"
|
||||||
|
"font-family:'Pacifico',cursive;letter-spacing:.2px;color:rgba(255,255,255,.92);"
|
||||||
|
"text-shadow:0 2px 8px rgba(0,0,0,.35);pointer-events:none;user-select:none;\">"
|
||||||
|
"Made by Ruslan"
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
lower_all = html.lower()
|
||||||
|
if "</body>" in lower_all:
|
||||||
|
i2 = lower_all.rfind("</body>")
|
||||||
|
html = html[:i2] + wm + html[i2:]
|
||||||
|
else:
|
||||||
|
html = html + wm
|
||||||
|
|
||||||
|
return Response(html, mimetype="text/html; charset=utf-8")
|
||||||
|
|
||||||
@@ -3,110 +3,50 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ title or 'ForMe' }}</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Pacifico&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Pacifico&display=swap" rel="stylesheet">
|
||||||
<title>{{ title or 'Админка' }}</title>
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b0e13;
|
--bg: #0b0e13; --bg-soft: #11161f; --card: #121825; --text: #e6edf3; --muted: #97a3b6;
|
||||||
--bg-soft: #11161f;
|
--accent: #4f8cff; --accent-2: #7a5cff; --ring: rgba(79,140,255,.45);
|
||||||
--card: #121825;
|
|
||||||
--text: #e6edf3;
|
|
||||||
--muted: #97a3b6;
|
|
||||||
--accent: #4f8cff;
|
|
||||||
--accent-2: #7a5cff;
|
|
||||||
--danger: #ff5c7a;
|
|
||||||
--success: #2ecc71;
|
|
||||||
--ring: rgba(79,140,255,.45);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
body {
|
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||||
margin: 0;
|
color: var(--text); background: radial-gradient(1200px 400px at 10% -10%, rgba(79,140,255,0.12), transparent 60%),
|
||||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
radial-gradient(1000px 400px at 100% 0%, rgba(122,92,255,0.10), transparent 60%), var(--bg); }
|
||||||
color: var(--text);
|
|
||||||
background:
|
|
||||||
radial-gradient(1200px 400px at 10% -10%, rgba(79,140,255,0.12), transparent 60%),
|
|
||||||
radial-gradient(1000px 400px at 100% 0%, rgba(122,92,255,0.10), transparent 60%),
|
|
||||||
var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container { max-width: 980px; margin: 0 auto; padding: 24px; }
|
.container { max-width: 980px; margin: 0 auto; padding: 24px; }
|
||||||
|
header { position: sticky; top:0; z-index:1000; display:flex; align-items:center; justify-content:space-between;
|
||||||
header {
|
padding:14px 18px; margin:0 0 18px 0; background: linear-gradient(180deg, rgba(18,24,37,.85), rgba(18,24,37,.65));
|
||||||
position: sticky; top: 0; z-index: 1000;
|
backdrop-filter: saturate(1.2) blur(8px); border:1px solid rgba(255,255,255,.06); border-radius:14px; box-shadow:0 10px 30px rgba(0,0,0,.35); }
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
nav a { margin-left: 10px; }
|
||||||
padding: 14px 18px; margin: 0 0 18px 0;
|
.card { background: var(--card); border: 1px solid rgba(255,255,255,.06); border-radius: 16px; padding: 20px; box-shadow: 0 12px 40px rgba(0,0,0,.35); }
|
||||||
background: linear-gradient(180deg, rgba(18,24,37,.85), rgba(18,24,37,.65));
|
.muted { color: var(--muted); font-size: .92rem; }
|
||||||
backdrop-filter: saturate(1.2) blur(8px);
|
|
||||||
border: 1px solid rgba(255,255,255,.06);
|
|
||||||
border-radius: 14px;
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
header nav { }
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid rgba(255,255,255,.06);
|
|
||||||
border-radius: 16px; padding: 20px;
|
|
||||||
box-shadow: 0 12px 40px rgba(0,0,0,.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted { color: var(--muted); font-size: 0.92rem; }
|
|
||||||
.row { margin-bottom: 1rem; }
|
.row { margin-bottom: 1rem; }
|
||||||
|
.btn { display:inline-block; padding:10px 14px; border:none; cursor:pointer; text-decoration:none; color:#fff; font-weight:600; letter-spacing:.2px;
|
||||||
.btn {
|
background: linear-gradient(135deg, var(--accent), var(--accent-2)); border-radius:10px; box-shadow:0 10px 25px rgba(79,140,255,.35); }
|
||||||
display: inline-block; padding: 10px 14px; border: none; cursor: pointer;
|
.btn.secondary { background: linear-gradient(135deg,#2a2f3a,#394356); box-shadow:none; }
|
||||||
text-decoration: none; color: #fff; font-weight: 600; letter-spacing: .2px;
|
input[type=text], input[type=password], textarea { width:100%; color:var(--text); background:var(--bg-soft); border:1px solid rgba(255,255,255,.06);
|
||||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
border-radius:12px; padding:10px 12px; outline:none; }
|
||||||
border-radius: 10px; box-shadow: 0 10px 25px rgba(79,140,255,.35);
|
|
||||||
transition: transform .06s ease, box-shadow .15s ease;
|
|
||||||
}
|
|
||||||
.btn:hover { transform: translateY(-1px); box-shadow: 0 14px 30px rgba(79,140,255,.45); }
|
|
||||||
.btn.secondary { background: linear-gradient(135deg,#2a2f3a,#394356); box-shadow: none; }
|
|
||||||
|
|
||||||
input[type="text"], input[type="password"], textarea {
|
|
||||||
width: 100%; color: var(--text); background: var(--bg-soft);
|
|
||||||
border: 1px solid rgba(255,255,255,.06);
|
|
||||||
border-radius: 12px; padding: 10px 12px; outline: none;
|
|
||||||
box-shadow: inset 0 0 0 1px transparent;
|
|
||||||
}
|
|
||||||
input:focus, textarea:focus { box-shadow: 0 0 0 4px var(--ring); }
|
input:focus, textarea:focus { box-shadow: 0 0 0 4px var(--ring); }
|
||||||
textarea { min-height: 260px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
textarea { min-height:260px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
|
.flash { padding:10px 12px; margin-bottom:1rem; border-radius:10px; border:1px solid rgba(255,255,255,.08); }
|
||||||
.flash { padding: 10px 12px; margin-bottom: 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,.08); }
|
|
||||||
.flash.success { background: rgba(46,204,113,.12); color: #bbf7d0; }
|
.flash.success { background: rgba(46,204,113,.12); color: #bbf7d0; }
|
||||||
.flash.error { background: rgba(255,92,122,.12); color: #fecaca; }
|
.flash.error { background: rgba(255,92,122,.12); color: #fecaca; }
|
||||||
|
table { width:100%; border-collapse: collapse; background: var(--card); border-radius:12px; overflow:hidden; }
|
||||||
table { width: 100%; border-collapse: collapse; background: var(--card); border-radius: 12px; overflow: hidden; }
|
th, td { text-align:left; padding:10px 12px; border-bottom:1px solid rgba(255,255,255,.06); }
|
||||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,.06); }
|
thead th { background: rgba(255,255,255,.04); font-weight:600; }
|
||||||
thead th { background: rgba(255,255,255,.04); font-weight: 600; }
|
|
||||||
tbody tr:hover { background: rgba(255,255,255,.02); }
|
tbody tr:hover { background: rgba(255,255,255,.02); }
|
||||||
|
|
||||||
nav a { margin-left: 10px; }
|
|
||||||
/* Hide global Admin button in header; use page overlay instead */
|
|
||||||
header nav a[href*="/admin"] { display: none !important; }
|
|
||||||
/* Replace garbled logout text with a visible label */
|
|
||||||
header nav form button.btn { position: relative; }
|
|
||||||
header nav form button.btn::after { content: 'Выйти'; }
|
|
||||||
|
|
||||||
/* Hide Admin link in header (we use page overlay on published pages) */
|
|
||||||
header nav a[href*="/admin"] { display: none !important; }
|
|
||||||
/* Force readable label for logout button regardless of encoding */
|
|
||||||
header nav form button.btn { font-size: 0; }
|
|
||||||
header nav form button.btn::after { content: 'Выйти'; font-size: 14px; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<div><strong>{{ title or 'Админка' }}</strong></div>
|
<div><strong>{{ title or 'ForMe' }}</strong></div>
|
||||||
<nav>
|
<nav>
|
||||||
{% if logged_in %}
|
{% if logged_in %}
|
||||||
<a class="btn secondary" href="{{ url_for('admin') }}">Админка</a>
|
<form style="display:inline" method="post" action="{{ url_for('auth.logout') }}">
|
||||||
<form style="display:inline" method="post" action="{{ url_for('logout') }}">
|
|
||||||
<button class="btn" type="submit">Выйти</button>
|
<button class="btn" type="submit">Выйти</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -127,3 +67,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user