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:
Moritz 2026-07-03 23:12:43 +02:00
commit f3d6e100eb
8 changed files with 531 additions and 0 deletions

12
.gitignore vendored Normal file
View 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
View 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
View file

@ -0,0 +1 @@
Flask==3.1.1

5
static/image/favicon.svg Normal file
View 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
View 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
View 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>

View 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
View 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>