341 lines
13 KiB
Python
341 lines
13 KiB
Python
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
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Run development server
|
|
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=True)
|