diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2ad935 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +*.db +*.log +__pycache__/ +*.pyc +.env diff --git a/README.md b/README.md index e69de29..94c8c65 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,45 @@ +# PunktFri.dk + +Flask landing page for PunktFri — a Danish non-profit registrar initiative for self-hosters. + +## Setup + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python app.py +``` + +Visit `http://localhost:5000` + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `5000` | Port to listen on | +| `DATABASE` | `punktfri.db` | SQLite database path | +| `LOG_FILE` | `signups.log` | Signup log file path | +| `ADMIN_USER` | `admin` | Admin username | +| `ADMIN_PASS` | `punktfri2024` | Admin password | + +Change `ADMIN_USER` and `ADMIN_PASS` before deploying publicly. + +## Routes + +- `/` — Landing page + signup form +- `/tak` — Confirmation page after signup +- `/admin` — Signup table (HTTP Basic Auth) +- `/health` — Health check (returns `{"status": "ok"}`) + +## Data + +Signups are stored in two places: +- **SQLite** (`punktfri.db`) — queryable, used by `/admin` +- **Log file** (`signups.log`) — append-only plaintext log + +Both are created automatically on first run. + +## Admin + +Visit `http://localhost:5000/admin` and enter the credentials above. diff --git a/app.py b/app.py new file mode 100644 index 0000000..7e1eb5f --- /dev/null +++ b/app.py @@ -0,0 +1,137 @@ +import os +import logging +import sqlite3 +from functools import wraps + +from flask import Flask, render_template, request, redirect, url_for, g, Response + +DATABASE = os.environ.get("DATABASE", "punktfri.db") +LOG_FILE = os.environ.get("LOG_FILE", "signups.log") +ADMIN_USER = os.environ.get("ADMIN_USER", "admin") +ADMIN_PASS = os.environ.get("ADMIN_PASS", "punktfri2024") +PORT = int(os.environ.get("PORT", 5000)) + +app = Flask(__name__) + +signup_logger = logging.getLogger("signups") +signup_logger.setLevel(logging.INFO) +_fh = logging.FileHandler(LOG_FILE) +_fh.setFormatter(logging.Formatter("%(asctime)s %(message)s")) +signup_logger.addHandler(_fh) + + +def get_db(): + db = getattr(g, "_database", None) + if db is None: + db = g._database = sqlite3.connect(DATABASE) + db.row_factory = sqlite3.Row + return db + + +@app.teardown_appcontext +def close_db(exception): + db = getattr(g, "_database", None) + if db is not None: + db.close() + + +def init_db(): + with app.app_context(): + db = get_db() + db.execute( + """ + CREATE TABLE IF NOT EXISTS signups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + navn TEXT NOT NULL, + email TEXT NOT NULL, + domaener INTEGER NOT NULL, + egne_ns TEXT NOT NULL, + kommentar TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + db.commit() + + +def require_admin(f): + @wraps(f) + def decorated(*args, **kwargs): + auth = request.authorization + if not auth or auth.username != ADMIN_USER or auth.password != ADMIN_PASS: + return Response( + "Login krævet.", + 401, + {"WWW-Authenticate": 'Basic realm="PunktFri Admin"'}, + ) + return f(*args, **kwargs) + + return decorated + + +@app.route("/", methods=["GET", "POST"]) +def index(): + error = None + if request.method == "POST": + navn = request.form.get("navn", "").strip() + email = request.form.get("email", "").strip().lower() + domaener_raw = request.form.get("domaener", "").strip() + egne_ns = request.form.get("egne_ns", "").strip() + kommentar = request.form.get("kommentar", "").strip() + + if not navn or not email or not domaener_raw or egne_ns not in ("ja", "nej"): + error = "Udfyld venligst alle påkrævede felter." + else: + try: + domaener = int(domaener_raw) + if domaener < 1: + raise ValueError + except ValueError: + error = "Angiv et gyldigt antal domæner (mindst 1)." + else: + db = get_db() + existing = db.execute( + "SELECT id FROM signups WHERE email = ?", (email,) + ).fetchone() + if existing: + error = "Denne e-mail er allerede tilmeldt — tak for din interesse!" + else: + db.execute( + "INSERT INTO signups (navn, email, domaener, egne_ns, kommentar) VALUES (?, ?, ?, ?, ?)", + (navn, email, domaener, egne_ns, kommentar or None), + ) + db.commit() + signup_logger.info( + f"SIGNUP navn={navn!r} email={email!r} domaener={domaener} " + f"egne_ns={egne_ns} kommentar={kommentar!r}" + ) + return redirect(url_for("tak")) + + return render_template("index.html", error=error) + + +@app.route("/tak") +def tak(): + return render_template("tak.html") + + +@app.route("/admin") +@require_admin +def admin(): + db = get_db() + signups = db.execute( + "SELECT * FROM signups ORDER BY timestamp DESC" + ).fetchall() + count = db.execute("SELECT COUNT(*) FROM signups").fetchone()[0] + return render_template("admin.html", signups=signups, count=count) + + +@app.route("/health") +def health(): + return {"status": "ok"}, 200 + + +init_db() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=PORT, debug=False) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..22c34c3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.1.0 +Werkzeug==3.1.3 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..ed3caac --- /dev/null +++ b/static/style.css @@ -0,0 +1,563 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + color: #1a1a1a; + line-height: 1.65; + background: #fff; +} + +/* ── Nav ─────────────────────────────── */ +header { + background: #0f172a; + padding: 1rem 1.5rem; +} + +header nav { + max-width: 860px; + margin: 0 auto; +} + +.logo { + color: #fff; + font-weight: 800; + font-size: 1.2rem; + letter-spacing: -0.02em; + text-decoration: none; +} + +.logo span { + color: #818cf8; +} + +/* ── Hero ────────────────────────────── */ +.hero { + background: #0f172a; + color: #fff; + padding: 5rem 1.5rem 4.5rem; + text-align: center; + border-bottom: 1px solid #1e293b; +} + +.hero h1 { + font-size: clamp(2.2rem, 6vw, 3.8rem); + font-weight: 800; + letter-spacing: -0.04em; + margin-bottom: 0.6rem; + line-height: 1.1; +} + +.hero .tagline { + font-size: clamp(1rem, 2.5vw, 1.3rem); + color: #94a3b8; + margin-bottom: 2rem; + font-style: italic; +} + +.hero .intro { + max-width: 580px; + margin: 0 auto; + font-size: 1.05rem; + color: #cbd5e1; + line-height: 1.75; +} + +/* ── Sections ────────────────────────── */ +.section { + padding: 3.5rem 1.5rem; +} + +.section-inner { + max-width: 760px; + margin: 0 auto; +} + +.section:nth-child(odd) { + background: #f8fafc; +} + +.section:nth-child(even) { + background: #fff; +} + +.section h2 { + font-size: 1.55rem; + font-weight: 700; + margin-bottom: 1.25rem; + letter-spacing: -0.02em; + line-height: 1.2; +} + +.section p { + margin-bottom: 1rem; + font-size: 0.975rem; + color: #374151; +} + +/* ── Problem list ────────────────────── */ +.problem-list { + list-style: none; + margin: 1.25rem 0 0; +} + +.problem-list li { + padding: 0.7rem 0 0.7rem 1.6rem; + position: relative; + border-bottom: 1px solid #e5e7eb; + font-size: 0.95rem; + color: #374151; +} + +.problem-list li:last-child { + border-bottom: none; +} + +.problem-list li::before { + content: "→"; + position: absolute; + left: 0; + color: #dc2626; + font-weight: 700; +} + +.problem-list li strong { + color: #111827; +} + +/* ── Target grid ─────────────────────── */ +.target-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem; + margin-top: 1.25rem; +} + +@media (max-width: 580px) { + .target-grid { + grid-template-columns: 1fr; + } +} + +.target-box { + padding: 1.25rem 1.25rem 1rem; + border-radius: 7px; + border: 1px solid #e5e7eb; +} + +.target-box.not-for { + background: #fff5f5; + border-color: #fecaca; +} + +.target-box.is-for { + background: #f0fdf4; + border-color: #bbf7d0; +} + +.target-box h3 { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + margin-bottom: 0.85rem; +} + +.target-box.not-for h3 { + color: #dc2626; +} + +.target-box.is-for h3 { + color: #16a34a; +} + +.target-box ul { + list-style: none; +} + +.target-box ul li { + padding: 0.3rem 0 0.3rem 1.3rem; + font-size: 0.875rem; + color: #374151; + position: relative; +} + +.target-box.not-for ul li::before { + content: "✕"; + position: absolute; + left: 0; + color: #dc2626; + font-size: 0.8rem; + top: 0.35rem; +} + +.target-box.is-for ul li::before { + content: "✓"; + position: absolute; + left: 0; + color: #16a34a; + font-size: 0.85rem; + top: 0.3rem; +} + +/* ── Idea list ───────────────────────── */ +.idea-items { + list-style: none; + margin-top: 1.25rem; +} + +.idea-items li { + padding: 0.75rem 0 0.75rem 1.6rem; + position: relative; + border-bottom: 1px solid #e5e7eb; + font-size: 0.95rem; + color: #374151; +} + +.idea-items li:last-child { + border-bottom: none; +} + +.idea-items li::before { + content: "·"; + position: absolute; + left: 0.45rem; + top: 0.45rem; + color: #6366f1; + font-size: 1.6rem; + line-height: 1; +} + +.status-badge { + display: inline-block; + background: #fef3c7; + color: #92400e; + border: 1px solid #fcd34d; + border-radius: 4px; + padding: 0.1rem 0.5rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-left: 0.4rem; + vertical-align: middle; + position: relative; + top: -1px; +} + +/* ── Not-list ────────────────────────── */ +.not-list { + list-style: none; + margin-top: 1.25rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +@media (max-width: 480px) { + .not-list { + grid-template-columns: 1fr; + } +} + +.not-list li { + background: #f1f5f9; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 0.7rem 1rem; + font-size: 0.875rem; + color: #475569; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.not-list li::before { + content: "✕"; + color: #94a3b8; + font-size: 0.75rem; + flex-shrink: 0; +} + +/* ── Signup form ─────────────────────── */ +.form-section { + background: #0f172a !important; + color: #fff; +} + +.form-section h2 { + color: #fff; +} + +.form-section .lead { + color: #94a3b8 !important; + margin-bottom: 0.5rem; +} + +.form-section .disclaimer { + color: #64748b !important; + font-size: 0.85rem !important; + margin-bottom: 1.5rem; +} + +.signup-form { + margin-top: 1.5rem; +} + +.form-group { + margin-bottom: 1.25rem; +} + +.form-group label { + display: block; + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.4rem; + color: #e2e8f0; +} + +.form-group .hint { + display: block; + font-size: 0.78rem; + color: #64748b; + font-weight: 400; + margin-top: 0.2rem; +} + +.form-group input[type="text"], +.form-group input[type="email"], +.form-group input[type="number"], +.form-group textarea { + width: 100%; + padding: 0.65rem 0.875rem; + background: #1e293b; + border: 1px solid #334155; + border-radius: 5px; + color: #f1f5f9; + font-size: 1rem; + font-family: inherit; + transition: border-color 0.15s; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #6366f1; + background: #1e293b; +} + +.form-group textarea { + min-height: 90px; + resize: vertical; +} + +.radio-group { + display: flex; + gap: 1.5rem; + margin-top: 0.3rem; +} + +.radio-group label { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 400; + cursor: pointer; + color: #e2e8f0; + font-size: 1rem; +} + +.radio-group input[type="radio"] { + width: 1rem; + height: 1rem; + accent-color: #6366f1; + flex-shrink: 0; +} + +.error-msg { + background: #7f1d1d; + border: 1px solid #ef4444; + color: #fecaca; + padding: 0.75rem 1rem; + border-radius: 5px; + margin-bottom: 1.25rem; + font-size: 0.9rem; +} + +.submit-btn { + background: #6366f1; + color: #fff; + border: none; + padding: 0.875rem 2rem; + font-size: 1rem; + font-weight: 600; + border-radius: 5px; + cursor: pointer; + width: 100%; + transition: background 0.15s; + font-family: inherit; +} + +.submit-btn:hover { + background: #4f46e5; +} + +/* ── Footer ──────────────────────────── */ +footer { + background: #f1f5f9; + border-top: 1px solid #e2e8f0; + padding: 2rem 1.5rem; +} + +.footer-inner { + max-width: 760px; + margin: 0 auto; + text-align: center; +} + +.footer-inner p { + font-size: 0.85rem; + color: #64748b; + margin-bottom: 0.5rem; +} + +.footer-inner a { + color: #6366f1; + text-decoration: none; +} + +.footer-inner a:hover { + text-decoration: underline; +} + +.footer-small { + font-size: 0.75rem !important; + color: #94a3b8 !important; + margin-top: 0.25rem; +} + +/* ── Tak page ────────────────────────── */ +.tak-section { + min-height: 65vh; + display: flex; + align-items: center; + justify-content: center; + padding: 3rem 1.5rem; + background: #f8fafc; +} + +.tak-card { + max-width: 520px; + text-align: center; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 2.5rem 2rem; +} + +.tak-icon { + font-size: 2.8rem; + margin-bottom: 1rem; +} + +.tak-card h1 { + font-size: 1.9rem; + font-weight: 700; + margin-bottom: 0.75rem; + letter-spacing: -0.02em; +} + +.tak-card p { + color: #6b7280; + margin-bottom: 0.75rem; + font-size: 0.95rem; +} + +.back-link { + display: inline-block; + margin-top: 1.25rem; + color: #6366f1; + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; +} + +.back-link:hover { + text-decoration: underline; +} + +/* ── Admin ───────────────────────────── */ +.admin-section { + padding: 2rem 1.5rem; + max-width: 1100px; + margin: 0 auto; +} + +.admin-section h1 { + font-size: 1.4rem; + font-weight: 700; + margin-bottom: 0.4rem; +} + +.admin-meta { + color: #6b7280; + font-size: 0.875rem; + margin-bottom: 1.5rem; +} + +.table-wrapper { + overflow-x: auto; +} + +.signups-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.signups-table th { + background: #f1f5f9; + padding: 0.6rem 0.75rem; + text-align: left; + font-weight: 600; + border-bottom: 2px solid #e2e8f0; + white-space: nowrap; + color: #374151; +} + +.signups-table td { + padding: 0.6rem 0.75rem; + border-bottom: 1px solid #e5e7eb; + vertical-align: top; +} + +.signups-table tr:hover td { + background: #f8fafc; +} + +.badge-ja { + background: #dcfce7; + color: #166534; + padding: 0.1rem 0.45rem; + border-radius: 3px; + font-size: 0.75rem; + font-weight: 700; +} + +.badge-nej { + background: #fee2e2; + color: #991b1b; + padding: 0.1rem 0.45rem; + border-radius: 3px; + font-size: 0.75rem; + font-weight: 700; +} + +.empty-state { + text-align: center; + padding: 3rem; + color: #9ca3af; +} diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..a0710d3 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block title %}Admin — PunktFri{% endblock %} + +{% block content %} +
| # | +Navn | +Domæner | +Egne NS | +Kommentar | +Tidspunkt | +|
|---|---|---|---|---|---|---|
| {{ s.id }} | +{{ s.navn }} | +{{ s.email }} | +{{ s.domaener }} | ++ {{ s.egne_ns | upper }} + | +{{ s.kommentar or '—' }} | +{{ s.timestamp }} | +
Ingen tilmeldinger endnu.
+"Din domæneaftale. Ingen forhandler. Ingen bullshit."
++ Punktum dk tvinger fra 2026 alle .dk-domæneejere til at bruge en kommerciel forhandler. + Det rammer dig, der kører egne navneservere og aldrig har haft brug for hjælp. + Vi undersøger om vi kan gøre noget ved det — på vores egne præmisser. +
++ Punktum dk ændrer reglerne. Fra 2026 kan du ikke längre administrere dit .dk-domæne direkte + — du skal have en akkrediteret forhandler som mellemled. Uanset om du har brug for dem eller ej. +
+PunktFri er tænkt til en meget specifik gruppe. Vær ærlig med dig selv.
++ PunktFri er på nuværende tidspunkt en idé — ikke en organisation. + Vi undersøger om det er muligt og realistisk at etablere følgende: +
++ Intet er besluttet endnu. Denne side er til for at afdække om der er nok interesse til at + det kan bære sig — juridisk, økonomisk og praktisk. +
+For at undgå enhver tvivl:
+Tilmeld dig som interesseret — det forpligter dig til ingenting.
++ Vi bruger dine oplysninger til at vurdere om der er tilstrækkelig interesse til at gå videre. + Ingen spam, ingen salg, ingen automatiske abonnementer. Kun en enkelt opdatering når vi ved mere. +
+ + {% if error %} +Vi har noteret din interesse. Ingen forpligtelse, ingen faktura, intet abonnement.
++ Vi vender tilbage med en enkelt opdatering, når vi ved om der er tilstrækkelig + interesse til at gå videre med idéen. +
++ Spørgsmål? Skriv til info@punktfri.dk +
+ ← Tilbage til forsiden +