From 7fe85dfe2cd6bcff34f6e37457506cae22169731 Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Sun, 26 Apr 2026 18:20:54 +0200 Subject: [PATCH] Add PunktFri Flask landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Landing page with hero, problemet, målgruppe, idé, disclaimer sections - Signup form → SQLite + file log (navn, email, domæner, egne_ns, kommentar) - /tak confirmation page - /admin with HTTP Basic Auth - /health endpoint - Mobile-first minimal CSS, Danish throughout - Honest 'under undersøgelse' framing — no premature organisational claims --- .gitignore | 6 + README.md | 45 ++++ app.py | 137 +++++++++++ requirements.txt | 2 + static/style.css | 563 +++++++++++++++++++++++++++++++++++++++++++ templates/admin.html | 47 ++++ templates/base.html | 29 +++ templates/index.html | 161 +++++++++++++ templates/tak.html | 21 ++ 9 files changed, 1011 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 static/style.css create mode 100644 templates/admin.html create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 templates/tak.html 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 %} +
+

Tilmeldinger

+

{{ count }} tilmelding{{ 'er' if count != 1 else '' }} i alt

+ + {% if signups %} +
+ + + + + + + + + + + + + + {% for s in signups %} + + + + + + + + + + {% endfor %} + +
#NavnE-mailDomænerEgne NSKommentarTidspunkt
{{ s.id }}{{ s.navn }}{{ s.email }}{{ s.domaener }} + {{ s.egne_ns | upper }} + {{ s.kommentar or '—' }}{{ s.timestamp }}
+
+ {% else %} +
+

Ingen tilmeldinger endnu.

+
+ {% endif %} +
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..03f821f --- /dev/null +++ b/templates/base.html @@ -0,0 +1,29 @@ + + + + + + + {% block title %}PunktFri{% endblock %} + + + +
+ +
+ +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..21bbf94 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,161 @@ +{% extends "base.html" %} + +{% block title %}PunktFri — Din domæneaftale. Ingen forhandler.{% endblock %} + +{% block content %} + + +
+

PunktFri

+

"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. +

+
+ + +
+
+

Problemet

+

+ 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. +

+
    +
  • 1. juli 2026: Nye .dk-domæner kræver en forhandler. Ingen direkte registrering mere.
  • +
  • 1. juli 2028: Alle eksisterende .dk-domæner skal overføres til en forhandler.
  • +
  • Selvhostere rammes direkte: Kører du egne navneservere og har altid klaret dig selv hos Punktum dk, skal du nu have et administrativt mellemled — mod din vilje.
  • +
  • Prisstigningen er reel: Kommercielle forhandlere opkræver typisk 90–150 kr/år per domæne. Du betalte ~50 kr direkte. Det er dobbelt til tredobbelt pris for et lag du ikke har bedt om.
  • +
  • Ingen teknisk gevinst: Du mister ikke kontrol over dine navneservere — men du tvinges alligevel til at betale en forhandler for at "hjælpe" dig.
  • +
+
+
+ + +
+
+

Er det noget for dig?

+

PunktFri er tænkt til en meget specifik gruppe. Vær ærlig med dig selv.

+
+
+

Ikke for dig hvis

+
    +
  • Du vil have webhosting
  • +
  • Du vil have DNS-hosting
  • +
  • Du har brug for support på "min hjemmeside virker ikke"
  • +
  • Du ikke ved hvad en navneserver er
  • +
  • Du leder efter den billigste registrar
  • +
+
+
+

For dig hvis

+
    +
  • Du kører egne navneservere (BIND, PowerDNS, Knot osv.)
  • +
  • Du har administreret direkte hos Punktum dk i årevis
  • +
  • Du vil beholde fuld kontrol over din infrastruktur
  • +
  • Du bare har brug for et tyndt administrativt lag mod Punktum dk
  • +
  • Du er ligeglad med support, panels og features
  • +
+
+
+
+
+ + +
+
+

Idéen bag PunktFri Under undersøgelse

+

+ PunktFri er på nuværende tidspunkt en idé — ikke en organisation. + Vi undersøger om det er muligt og realistisk at etablere følgende: +

+
    +
  • En non-profit forening der ansøger om akkreditering som registrator hos Punktum dk
  • +
  • Kostprismodel — ingen markup, ingen upsell, ingen abonnementsservice du ikke har bedt om
  • +
  • Demokratisk struktur — medlemmerne bestemmer, ikke investorer eller kommercielle interesser
  • +
  • Du beholder dine egne navneservere fuldstændigt — vi rører ikke din infrastruktur
  • +
  • Minimal administration: vi håndterer kun det formelle forhold til Punktum dk på dine vegne
  • +
+

+ 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. +

+
+
+ + +
+
+

Hvad PunktFri ikke er

+

For at undgå enhver tvivl:

+
    +
  • En hostingudbyder
  • +
  • En DNS-udbyder
  • +
  • En supportlinje
  • +
  • En forretning
  • +
  • Et panel med features
  • +
  • Noget du betaler for nu
  • +
+
+
+ + +
+
+

Er du interesseret?

+

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 %} +
{{ error }}
+ {% endif %} + + +
+
+ +{% endblock %} diff --git a/templates/tak.html b/templates/tak.html new file mode 100644 index 0000000..470c8dd --- /dev/null +++ b/templates/tak.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}Tak — PunktFri{% endblock %} + +{% block content %} +
+
+
+

Tak for din tilmelding

+

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 +
+
+{% endblock %}