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 "</head>" in lower: - i = lower.rfind("</head>") - html = html[:i] + f"<title>{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 "" in lower: - idx = lower.rfind("") - html = html[:idx] + toolbar + html[idx:] - else: - html = html + toolbar - # Ensure watermark exists on published pages (top-left) - wm = ( - "" - "" - "" - "
" - "Made by Ruslan" - "
" - ) - # 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'
' - f'PDF' - f'Word' - '
' - ) - 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"{title}" if title else "" - return ( - "" + head_title + - "" + - 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/.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/.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) + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..bb58522 --- /dev/null +++ b/app/__init__.py @@ -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 + diff --git a/app/admin.py b/app/admin.py new file mode 100644 index 0000000..c854479 --- /dev/null +++ b/app/admin.py @@ -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/", 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/", 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")) + diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..0c8f705 --- /dev/null +++ b/app/auth.py @@ -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) + diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..7788e31 --- /dev/null +++ b/app/db.py @@ -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() + diff --git a/app/pages.py b/app/pages.py new file mode 100644 index 0000000..a039498 --- /dev/null +++ b/app/pages.py @@ -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/") +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 "" in lower: + i = lower.rfind("") + html = html[:i] + f"{page_title}" + html[i:] + elif "{page_title}" + html[j:] + else: + html = f"{page_title}" + html + + # Admin button only for logged-in users + if is_logged_in(): + admin_url = url_for("admin.index") + toolbar = ( + '' + ) + lower = html.lower() + if "" in lower: + idx = lower.rfind("") + html = html[:idx] + toolbar + html[idx:] + else: + html = html + toolbar + + # Watermark for everyone (top-left) + wm = ( + "" + "" + "" + "
" + "Made by Ruslan" + "
" + ) + lower_all = html.lower() + if "" in lower_all: + i2 = lower_all.rfind("") + html = html[:i2] + wm + html[i2:] + else: + html = html + wm + + return Response(html, mimetype="text/html; charset=utf-8") + diff --git a/templates/base.html b/templates/base.html index f7d8d95..e489cad 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,110 +3,50 @@ + {{ title or 'ForMe' }} - {{ title or 'Админка' }}
-
{{ title or 'Админка' }}
+
{{ title or 'ForMe' }}
+