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:
92
README.md
92
README.md
@@ -1,51 +1,101 @@
|
||||
# Flask UUID Pages Admin (SQLite)
|
||||
# ForMe — мини CMS на Flask (SQLite)
|
||||
|
||||
Минималистичное Flask‑приложение с SQLite:
|
||||
- Админка доступна только пользователю `ruslan` с паролем `utOgbZ09ruslan` (по умолчанию).
|
||||
- В админке можно вставлять HTML и публиковать страницы.
|
||||
- Для каждой страницы генерируется UUID‑ссылка `/p/<uuid>`.
|
||||
- Неавторизованный пользователь не видит админку и главную страницу (404). Страницы по UUID доступны всем, у кого есть ссылка.
|
||||
Небольшое приложение для публикации HTML‑страниц по UUID.
|
||||
|
||||
## Запуск локально
|
||||
Возможности
|
||||
- Авторизация администратора (логин/пароль из переменных окружения).
|
||||
- Публикация HTML со свободной разметкой; генерация ссылки `/p/<uuid>`.
|
||||
- Редактирование и удаление опубликованных страниц в админке.
|
||||
- На опубликованных страницах у авторизованных отображается плавающая кнопка “Админка”.
|
||||
- Водяной знак “Made by Ruslan” в левом верхнем углу опубликованных страниц.
|
||||
|
||||
1. Создайте и активируйте виртуальное окружение (опционально):
|
||||
Важно: функционал экспорта в PDF/Word удалён — код и зависимости очищены.
|
||||
|
||||
Windows PowerShell:
|
||||
## Локальный запуск
|
||||
|
||||
1) Создать и активировать виртуальное окружение (Windows PowerShell):
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.venv\\Scripts\\Activate.ps1
|
||||
.venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
2. Установите зависимости:
|
||||
2) Установить зависимости:
|
||||
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. (Опционально) Переопределите настройки через переменные окружения:
|
||||
3) (Опционально) Настроить переменные окружения:
|
||||
|
||||
```powershell
|
||||
$env:SECRET_KEY = "your-strong-secret-key"
|
||||
$env:SECRET_KEY = "your-strong-secret"
|
||||
$env:ADMIN_USERNAME = "ruslan"
|
||||
$env:ADMIN_PASSWORD = "utOgbZ09ruslan"
|
||||
# путь для БД по умолчанию: ./app.db
|
||||
```
|
||||
|
||||
4. Запустите сервер разработки:
|
||||
4) Запустить приложение:
|
||||
|
||||
```powershell
|
||||
python app.py
|
||||
```
|
||||
|
||||
5) Открыть http://localhost:5000
|
||||
|
||||
## Маршруты
|
||||
|
||||
- `/login` — форма входа в админку.
|
||||
- `/admin` — админ‑панель (только для авторизованных, иначе 404).
|
||||
- `/p/<uuid>` — просмотр опубликованной страницы по UUID.
|
||||
- `/` — редирект в админку для авторизованных, иначе 404.
|
||||
- `/login` — вход
|
||||
- `/admin` — панель публикации и список страниц (только для авторизованных)
|
||||
- `/p/<uuid>` — просмотр опубликованной страницы
|
||||
- `/` — редиректит на `/login` или `/admin`
|
||||
|
||||
## Замечания по безопасности
|
||||
## Запуск в Docker
|
||||
|
||||
- HTML хранится и отдаётся как есть. Предполагается, что контент вводит доверенный админ.
|
||||
- В продакшне обязательно задайте `SECRET_KEY` и используйте HTTPS; настройте защищённые cookies.
|
||||
В проекте есть `Dockerfile` (gunicorn) и `docker-compose.yml` с томом для БД.
|
||||
|
||||
Вариант 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
|
||||
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
|
||||
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run development server
|
||||
import os
|
||||
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>
|
||||
<meta charset="utf-8">
|
||||
<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.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Pacifico&display=swap" rel="stylesheet">
|
||||
<title>{{ title or 'Админка' }}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0e13;
|
||||
--bg-soft: #11161f;
|
||||
--card: #121825;
|
||||
--text: #e6edf3;
|
||||
--muted: #97a3b6;
|
||||
--accent: #4f8cff;
|
||||
--accent-2: #7a5cff;
|
||||
--danger: #ff5c7a;
|
||||
--success: #2ecc71;
|
||||
--ring: rgba(79,140,255,.45);
|
||||
--bg: #0b0e13; --bg-soft: #11161f; --card: #121825; --text: #e6edf3; --muted: #97a3b6;
|
||||
--accent: #4f8cff; --accent-2: #7a5cff; --ring: rgba(79,140,255,.45);
|
||||
}
|
||||
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||
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);
|
||||
}
|
||||
|
||||
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||
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; }
|
||||
|
||||
header {
|
||||
position: sticky; top: 0; z-index: 1000;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 18px; margin: 0 0 18px 0;
|
||||
background: linear-gradient(180deg, rgba(18,24,37,.85), rgba(18,24,37,.65));
|
||||
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; }
|
||||
header { position: sticky; top:0; z-index:1000; display:flex; align-items:center; justify-content:space-between;
|
||||
padding:14px 18px; margin:0 0 18px 0; background: linear-gradient(180deg, rgba(18,24,37,.85), rgba(18,24,37,.65));
|
||||
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); }
|
||||
nav a { margin-left: 10px; }
|
||||
.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: .92rem; }
|
||||
.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;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
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 { display:inline-block; padding:10px 14px; border:none; cursor:pointer; text-decoration:none; color:#fff; font-weight:600; letter-spacing:.2px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2)); border-radius:10px; box-shadow:0 10px 25px rgba(79,140,255,.35); }
|
||||
.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[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; }
|
||||
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; }
|
||||
|
||||
.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.error { background: rgba(255,92,122,.12); color: #fecaca; }
|
||||
|
||||
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); }
|
||||
thead th { background: rgba(255,255,255,.04); font-weight:600; }
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div><strong>{{ title or 'Админка' }}</strong></div>
|
||||
<div><strong>{{ title or 'ForMe' }}</strong></div>
|
||||
<nav>
|
||||
{% if logged_in %}
|
||||
<a class="btn secondary" href="{{ url_for('admin') }}">Админка</a>
|
||||
<form style="display:inline" method="post" action="{{ url_for('logout') }}">
|
||||
<form style="display:inline" method="post" action="{{ url_for('auth.logout') }}">
|
||||
<button class="btn" type="submit">Выйти</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -127,3 +67,4 @@
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user