From f4e1bc8a9543e91eac54ff8bd5fcfe01f090b00a Mon Sep 17 00:00:00 2001
From: RGalyaviev
Date: Thu, 4 Sep 2025 10:48:04 +0300
Subject: [PATCH] Docs: rewrite README with clear Docker and Docker Compose
instructions; refactor app into package (auth, admin, pages, db); keep single
entrypoint app.py
---
README.md | 118 +++++++++++-----
app.py | 338 +-------------------------------------------
app/__init__.py | 34 +++++
app/admin.py | 73 ++++++++++
app/auth.py | 41 ++++++
app/db.py | 50 +++++++
app/pages.py | 76 ++++++++++
templates/base.html | 109 ++++----------
8 files changed, 386 insertions(+), 453 deletions(-)
create mode 100644 app/__init__.py
create mode 100644 app/admin.py
create mode 100644 app/auth.py
create mode 100644 app/db.py
create mode 100644 app/pages.py
diff --git a/README.md b/README.md
index cf38998..7dc70a6 100644
--- a/README.md
+++ b/README.md
@@ -1,51 +1,101 @@
-# Flask UUID Pages Admin (SQLite)
+# ForMe — мини CMS на Flask (SQLite)
-Минималистичное Flask‑приложение с SQLite:
-- Админка доступна только пользователю `ruslan` с паролем `utOgbZ09ruslan` (по умолчанию).
-- В админке можно вставлять HTML и публиковать страницы.
-- Для каждой страницы генерируется UUID‑ссылка `/p/`.
-- Неавторизованный пользователь не видит админку и главную страницу (404). Страницы по UUID доступны всем, у кого есть ссылка.
+Небольшое приложение для публикации HTML‑страниц по UUID.
-## Запуск локально
+Возможности
+- Авторизация администратора (логин/пароль из переменных окружения).
+- Публикация HTML со свободной разметкой; генерация ссылки `/p/`.
+- Редактирование и удаление опубликованных страниц в админке.
+- На опубликованных страницах у авторизованных отображается плавающая кнопка “Админка”.
+- Водяной знак “Made by Ruslan” в левом верхнем углу опубликованных страниц.
-1. Создайте и активируйте виртуальное окружение (опционально):
+Важно: функционал экспорта в PDF/Word удалён — код и зависимости очищены.
- Windows PowerShell:
+## Локальный запуск
- ```powershell
- python -m venv .venv
- .venv\\Scripts\\Activate.ps1
- ```
+1) Создать и активировать виртуальное окружение (Windows PowerShell):
-2. Установите зависимости:
+```powershell
+python -m venv .venv
+.venv\Scripts\Activate.ps1
+```
- ```powershell
- pip install -r requirements.txt
- ```
+2) Установить зависимости:
-3. (Опционально) Переопределите настройки через переменные окружения:
+```powershell
+pip install -r requirements.txt
+```
- ```powershell
- $env:SECRET_KEY = "your-strong-secret-key"
- $env:ADMIN_USERNAME = "ruslan"
- $env:ADMIN_PASSWORD = "utOgbZ09ruslan"
- ```
+3) (Опционально) Настроить переменные окружения:
-4. Запустите сервер разработки:
+```powershell
+$env:SECRET_KEY = "your-strong-secret"
+$env:ADMIN_USERNAME = "ruslan"
+$env:ADMIN_PASSWORD = "utOgbZ09ruslan"
+# путь для БД по умолчанию: ./app.db
+```
- ```powershell
- python app.py
- ```
+4) Запустить приложение:
+
+```powershell
+python app.py
+```
+
+5) Открыть http://localhost:5000
## Маршруты
-- `/login` — форма входа в админку.
-- `/admin` — админ‑панель (только для авторизованных, иначе 404).
-- `/p/` — просмотр опубликованной страницы по UUID.
-- `/` — редирект в админку для авторизованных, иначе 404.
+- `/login` — вход
+- `/admin` — панель публикации и список страниц (только для авторизованных)
+- `/p/` — просмотр опубликованной страницы
+- `/` — редиректит на `/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/
+templates/
+ base.html, login.html, admin.html, edit.html
+```
+
+## Заметки по продакшену
+
+- Обязательно задайте `SECRET_KEY` через переменные окружения.
+- Смените дефолтные `ADMIN_USERNAME/ADMIN_PASSWORD`.
+- Вынесите БД в том/персистентный диск.
diff --git a/app.py b/app.py
index e8bbc02..a21bc24 100644
--- a/app.py
+++ b/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/", 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/", 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/")
- 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 tag for better page naming
- try:
- page_title = row["title"]
- except Exception:
- page_title = ""
- if page_title:
- lower = html.lower()
- if "" in lower:
- i = lower.rfind("")
- html = html[:i] + f"{page_title}" + html[i:]
- elif "{page_title}" + html[j:]
- else:
- html = f"{page_title}" + html
- if is_logged_in():
- admin_url = url_for("admin")
- toolbar = (
- ''
- )
- lower = html.lower()
- if "
" +
- html + "" in lower:
- idx = lower.rfind("")
- html = html[:idx] + toolbar + html[idx:]
- else:
- html = html + toolbar
- # Ensure watermark exists on published pages (top-left)
- wm = (
- ""
- ""
- ""
- "
"
- )
- # 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'
'
- )
- overlays = wm + exports
- lower_all = html.lower()
- if "" in lower_all:
- i2 = lower_all.rfind("")
- 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"", "", html, flags=re.I | re.S)
- html = re.sub(r"\sstyle=(\"|\')(.*?)\1", "", html, flags=re.I | re.S)
- html = re.sub(r"", "", html, flags=re.I | re.S)
- return html
-
- def _wrap_html_for_export(title: str, html: str) -> str:
- head_title = f"