chore(init): initialize repository for m0x.it Flask application
- Implement lightweight link-shortener core using Flask and SQLite3 - Configure secure session cookie management via environment variables - Set up isolated database helper modules with strict contextual teardown - Add comprehensive .gitignore to prevent committing runtime environment and local databases - Define production-ready structure including template directories and dependencies
This commit is contained in:
commit
f3d6e100eb
8 changed files with 531 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Python-Umgebung ignorieren
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Lokale Datenbank & Ordner ignorieren
|
||||
data/
|
||||
*.db
|
||||
|
||||
# macOS Systemdateien ignorieren
|
||||
.DS_Store
|
||||
224
app.py
Normal file
224
app.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import os
|
||||
import sqlite3
|
||||
import string
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
|
||||
from flask import (
|
||||
Flask, g, request, redirect, render_template,
|
||||
session, url_for, abort, flash
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Config
|
||||
# --------------------------------------------------------------------------
|
||||
DB_PATH = os.environ.get("DB_PATH", "/data/links.db")
|
||||
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD")
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY")
|
||||
|
||||
if not ADMIN_PASSWORD:
|
||||
raise RuntimeError("ADMIN_PASSWORD env var ist nicht gesetzt")
|
||||
if not SECRET_KEY:
|
||||
raise RuntimeError("SECRET_KEY env var ist nicht gesetzt")
|
||||
|
||||
BASE62 = string.digits + string.ascii_lowercase + string.ascii_uppercase
|
||||
CODE_LENGTH = 6
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = SECRET_KEY
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "true").lower() == "true"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# DB Helpers
|
||||
# --------------------------------------------------------------------------
|
||||
def get_db():
|
||||
if "db" not in g:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
g.db = sqlite3.connect(DB_PATH)
|
||||
g.db.row_factory = sqlite3.Row
|
||||
g.db.execute("PRAGMA foreign_keys = ON")
|
||||
return g.db
|
||||
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_db(exception=None):
|
||||
db = g.pop("db", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS links (
|
||||
code TEXT PRIMARY KEY,
|
||||
target TEXT NOT NULL,
|
||||
is_alias INTEGER NOT NULL DEFAULT 0,
|
||||
clicks INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Code generation
|
||||
# --------------------------------------------------------------------------
|
||||
def generate_code(length=CODE_LENGTH):
|
||||
"""Kryptographisch zufälligen, noch nicht vergebenen base62-Code erzeugen."""
|
||||
db = get_db()
|
||||
for _ in range(20):
|
||||
code = "".join(secrets.choice(BASE62) for _ in range(length))
|
||||
exists = db.execute("SELECT 1 FROM links WHERE code = ?", (code,)).fetchone()
|
||||
if not exists:
|
||||
return code
|
||||
# extrem unwahrscheinlich, aber sicher ist sicher
|
||||
raise RuntimeError("Konnte keinen freien Code generieren, Namespace evtl. zu voll")
|
||||
|
||||
|
||||
def normalize_target(url: str) -> str:
|
||||
url = url.strip()
|
||||
if not url:
|
||||
raise ValueError("Ziel-URL darf nicht leer sein")
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
url = "https://" + url
|
||||
return url
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Auth
|
||||
# --------------------------------------------------------------------------
|
||||
def login_required(view):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not session.get("is_admin"):
|
||||
return redirect(url_for("admin_login", next=request.path))
|
||||
return view(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
@app.route("/admin/login", methods=["GET", "POST"])
|
||||
def admin_login():
|
||||
if request.method == "POST":
|
||||
password = request.form.get("password", "")
|
||||
# secrets.compare_digest schützt gegen Timing-Angriffe
|
||||
if secrets.compare_digest(password, ADMIN_PASSWORD):
|
||||
session.clear()
|
||||
session["is_admin"] = True
|
||||
next_url = request.args.get("next") or url_for("admin_dashboard")
|
||||
return redirect(next_url)
|
||||
flash("Falsches Passwort", "error")
|
||||
return render_template("admin_login.html")
|
||||
|
||||
|
||||
@app.route("/admin/logout")
|
||||
def admin_logout():
|
||||
session.clear()
|
||||
return redirect(url_for("admin_login"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Admin Dashboard
|
||||
# --------------------------------------------------------------------------
|
||||
@app.route("/admin")
|
||||
@login_required
|
||||
def admin_dashboard():
|
||||
db = get_db()
|
||||
links = db.execute(
|
||||
"SELECT * FROM links ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return render_template("admin.html", links=links)
|
||||
|
||||
|
||||
@app.route("/admin/create", methods=["POST"])
|
||||
@login_required
|
||||
def admin_create():
|
||||
db = get_db()
|
||||
target = request.form.get("target", "")
|
||||
custom_code = request.form.get("code", "").strip()
|
||||
|
||||
try:
|
||||
target = normalize_target(target)
|
||||
except ValueError as e:
|
||||
flash(str(e), "error")
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
if custom_code:
|
||||
# Alias-Regeln: nur URL-sichere Zeichen
|
||||
allowed = set(string.ascii_letters + string.digits + "-_")
|
||||
if not set(custom_code) <= allowed:
|
||||
flash("Alias darf nur Buchstaben, Zahlen, - und _ enthalten", "error")
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
existing = db.execute(
|
||||
"SELECT 1 FROM links WHERE code = ?", (custom_code,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
flash(f"Alias '{custom_code}' ist bereits vergeben", "error")
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
code = custom_code
|
||||
is_alias = 1
|
||||
else:
|
||||
code = generate_code()
|
||||
is_alias = 0
|
||||
|
||||
db.execute(
|
||||
"INSERT INTO links (code, target, is_alias, clicks, created_at) VALUES (?, ?, ?, 0, ?)",
|
||||
(code, target, is_alias, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
db.commit()
|
||||
flash(f"Erstellt: /{code} → {target}", "success")
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
@app.route("/admin/delete/<code>", methods=["POST"])
|
||||
@login_required
|
||||
def admin_delete(code):
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM links WHERE code = ?", (code,))
|
||||
db.commit()
|
||||
flash(f"Gelöscht: /{code}", "success")
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Public Redirect
|
||||
# --------------------------------------------------------------------------
|
||||
@app.route("/")
|
||||
def index():
|
||||
return redirect(url_for("admin_login"))
|
||||
|
||||
|
||||
@app.route("/<code>")
|
||||
def do_redirect(code):
|
||||
db = get_db()
|
||||
row = db.execute("SELECT * FROM links WHERE code = ?", (code,)).fetchone()
|
||||
if row is None:
|
||||
abort(404)
|
||||
db.execute("UPDATE links SET clicks = clicks + 1 WHERE code = ?", (code,))
|
||||
db.commit()
|
||||
return redirect(row["target"], code=302)
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return render_template("404.html"), 404
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Health check (nützlich für docker-compose healthcheck)
|
||||
# --------------------------------------------------------------------------
|
||||
@app.route("/healthz")
|
||||
def healthz():
|
||||
return {"status": "ok"}, 200
|
||||
|
||||
|
||||
init_db()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5001)
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Flask==3.1.1
|
||||
5
static/image/favicon.svg
Normal file
5
static/image/favicon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="64" height="64" rx="12" fill="#0d1117"/>
|
||||
<text x="10" y="41" font-family="ui-monospace, SF Mono, Consolas, monospace" font-size="20" font-weight="700" fill="#58a6ff">m0x</text>
|
||||
<rect x="49" y="24" width="6" height="16" fill="#238636"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 358 B |
28
templates/404.html
Normal file
28
templates/404.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>m0x.it // 404</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='image/favicon.svg') }}">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0d1117;
|
||||
color: #8b949e;
|
||||
font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
||||
}
|
||||
h1 { color: #f85149; font-size: 2rem; margin-bottom: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>404</h1>
|
||||
<p>Dieser Link existiert nicht (mehr).</p>
|
||||
</body>
|
||||
</html>
|
||||
145
templates/admin.html
Normal file
145
templates/admin.html
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>m0x.it // Admin</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='image/favicon.svg') }}">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
||||
padding: 2rem;
|
||||
}
|
||||
.wrap { max-width: 780px; margin: 0 auto; }
|
||||
h1 {
|
||||
font-size: 1.2rem;
|
||||
color: #58a6ff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
a.logout { font-size: 0.8rem; color: #8b949e; text-decoration: none; }
|
||||
a.logout:hover { color: #f85149; }
|
||||
|
||||
form.create {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
margin: 1.5rem 0;
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
form.create input {
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
form.create input[name="target"] { flex: 2; min-width: 220px; }
|
||||
form.create input[name="code"] { flex: 1; min-width: 140px; }
|
||||
form.create button {
|
||||
background: #238636;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
form.create button:hover { background: #2ea043; }
|
||||
.hint { font-size: 0.75rem; color: #8b949e; width: 100%; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #21262d; }
|
||||
th { color: #8b949e; font-weight: normal; font-size: 0.8rem; }
|
||||
td.code a { color: #58a6ff; text-decoration: none; }
|
||||
td.code a:hover { text-decoration: underline; }
|
||||
td.target { color: #8b949e; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: #1f2937;
|
||||
color: #8b949e;
|
||||
}
|
||||
.badge.alias { background: #1a2e1a; color: #3fb950; }
|
||||
button.del {
|
||||
background: transparent;
|
||||
border: 1px solid #30363d;
|
||||
color: #f85149;
|
||||
border-radius: 6px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
button.del:hover { background: #3d1a1a; }
|
||||
|
||||
.flash {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.flash.error { background: #3d1a1a; color: #f85149; border: 1px solid #5c2626; }
|
||||
.flash.success { background: #1a2e1a; color: #3fb950; border: 1px solid #2ea04340; }
|
||||
.empty { color: #8b949e; font-size: 0.9rem; padding: 1rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>m0x.it // Links <a class="logout" href="{{ url_for('admin_logout') }}">Logout</a></h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<form class="create" method="POST" action="{{ url_for('admin_create') }}">
|
||||
<input type="text" name="target" placeholder="https://ziel-url.de/pfad" required>
|
||||
<input type="text" name="code" placeholder="Alias (optional, sonst zufällig)">
|
||||
<button type="submit">Erstellen</button>
|
||||
<div class="hint">Alias leer lassen → zufälliger 6-stelliger base62-Code</div>
|
||||
</form>
|
||||
|
||||
{% if links %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Code</th><th>Ziel</th><th>Klicks</th><th>Erstellt</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for link in links %}
|
||||
<tr>
|
||||
<td class="code">
|
||||
<a href="/{{ link.code }}" target="_blank">/{{ link.code }}</a>
|
||||
{% if link.is_alias %}<span class="badge alias">alias</span>{% else %}<span class="badge">auto</span>{% endif %}
|
||||
</td>
|
||||
<td class="target" title="{{ link.target }}">{{ link.target }}</td>
|
||||
<td>{{ link.clicks }}</td>
|
||||
<td>{{ link.created_at[:10] }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('admin_delete', code=link.code) }}" onsubmit="return confirm('Link /{{ link.code }} löschen?');">
|
||||
<button class="del" type="submit">löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty">Noch keine Links vorhanden.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
79
templates/admin_login.html
Normal file
79
templates/admin_login.html
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>m0x.it // Admin Login</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='image/favicon.svg') }}">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
||||
}
|
||||
.card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 10px;
|
||||
padding: 2.5rem;
|
||||
width: 320px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 1.5rem;
|
||||
color: #58a6ff;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding: 0.6rem;
|
||||
background: #238636;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #2ea043; }
|
||||
.flash {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.flash.error { background: #3d1a1a; color: #f85149; border: 1px solid #5c2626; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>m0x.it/admin</h1>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
<form method="POST">
|
||||
<input type="password" name="password" placeholder="Passwort" autofocus required>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
37
templates/index.html
Normal file
37
templates/index.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>m0x.it</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='image/favicon.svg') }}">
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #121214;
|
||||
color: #e4e4e7;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.card {
|
||||
text-align: center;
|
||||
background: #1a1a1e;
|
||||
padding: 40px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.3);
|
||||
}
|
||||
h1 { color: #fff; margin-bottom: 10px; }
|
||||
p { color: #a1a1aa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>m0x.it</h1>
|
||||
<p>Die Webseite ist in Arbeit!</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue