Added lots of improvements to the webpage.

This commit is contained in:
2025-08-15 22:32:13 +02:00
parent bbf64a14be
commit 41bc0a1355
10 changed files with 401 additions and 17 deletions

View File

@@ -0,0 +1,22 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Virtual environment
.venv/
env/
venv/
# SQLite database
*.db
*.sqlite
*.sqlite3
*db-shm
*db-wal
# Environment variables
.env
# macOS
.DS_Store

View File

@@ -0,0 +1,14 @@
FROM python:3.13.6-slim
EXPOSE 5000
WORKDIR /usr/src/app
COPY requirements.txt .
RUN python3 -m pip install --upgrade pip
RUN python3 -m pip install --no-cache-dir -r requirements.txt
COPY . .
CMD [ "gunicorn", "--config", "gunicorn-cfg.py", "-k", "sync", "app:app" ]

View File

@@ -0,0 +1,80 @@
# Email Capture Flask App
A minimal Flask service that accepts an email from a form POST and stores it in a SQLite database.
## Endpoints
- `POST /subscribe` — Accepts `email` via HTML form or JSON and stores it.
- `GET /stats` — Returns a count of stored subscribers.
- `GET /health` — Basic health check.
## Quick start
1. Create and activate a virtualenv (recommended):
```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run the app:
```bash
python app.py
# or
FLASK_DEBUG=1 python app.py
```
The server listens on `http://localhost:5000`.
## Configure
- `DATABASE_PATH` (optional): path to the SQLite database file. Default is `emails.db` in the project root.
- `PORT` (optional): server port (default `5000`).
## Posting from your HTML form
Your existing form should point to `/subscribe` and include an `email` field:
```html
<form action="http://localhost:5000/subscribe" method="post">
<input type="email" name="email" placeholder="you@example.com" required />
<button type="submit">Subscribe</button>
</form>
```
If your web page is served from a different origin (domain/port), you may need to enable CORS on this service (e.g., via a proxy or using `flask-cors`).
## cURL examples
- Form POST:
```bash
curl -X POST -d "email=test@example.com" http://localhost:5000/subscribe
```
- JSON POST:
```bash
curl -X POST -H "Content-Type: application/json" \
-d '{"email":"test@example.com"}' \
http://localhost:5000/subscribe
```
- Check stats:
```bash
curl http://localhost:5000/stats
```
## Notes
- Emails are normalized to lowercase and checked with a simple regex. For strict validation, consider the [`email-validator`](https://pypi.org/project/email-validator/) package.
- Duplicate submissions return HTTP 409 with status `duplicate`.
- SQLite is suitable for lightweight usage; for higher traffic, consider a server database (PostgreSQL, MySQL) with a proper connection pool.

View File

@@ -0,0 +1,137 @@
import os
import re
import sqlite3
from contextlib import closing
from datetime import datetime
from flask import Flask, request, jsonify, g
# Simple (not perfect) email regex. For stricter validation, use `email-validator` package.
EMAIL_REGEX = re.compile(r"^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$", re.IGNORECASE)
DB_PATH = os.getenv("DATABASE_PATH", "emails.db")
app = Flask(__name__)
def get_db():
if "db" not in g:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
with closing(conn.cursor()) as cur:
# Basic pragmas for better SQLite behavior
cur.execute("PRAGMA foreign_keys = ON;")
cur.execute("PRAGMA journal_mode = WAL;")
g.db = conn
return g.db
def init_db():
conn = get_db()
with closing(conn.cursor()) as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS subscribers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
ip TEXT,
user_agent TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
)
conn.commit()
with app.app_context():
# Ensure the database/table exists before handling traffic
init_db()
@app.teardown_appcontext
def close_db(_e=None):
db = g.pop("db", None)
if db is not None:
db.close()
def normalize_email(email: str) -> str:
return email.strip().lower()
def is_valid_email(email: str) -> bool:
return bool(EMAIL_REGEX.match(email))
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"}), 200
@app.route("/stats", methods=["GET"])
def stats():
db = get_db()
with closing(db.cursor()) as cur:
cur.execute("SELECT COUNT(*) AS cnt FROM subscribers;")
row = cur.fetchone()
return jsonify({"subscribers": row["cnt"] if row else 0}), 200
@app.route("/subscribe", methods=["POST"])
def subscribe():
# Accept both HTML form (application/x-www-form-urlencoded or multipart/form-data)
# and JSON (application/json)
email = None
if request.is_json:
data = request.get_json(silent=True) or {}
email = data.get("email")
else:
email = request.form.get("email")
if not email:
return jsonify({"status": "error", "message": "Email is required."}), 400
email = normalize_email(email)
if not is_valid_email(email):
return jsonify({"status": "error", "message": "Invalid email format."}), 400
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "")
user_agent = request.headers.get("User-Agent", "")
db = get_db()
try:
with closing(db.cursor()) as cur:
cur.execute(
"INSERT INTO subscribers (email, ip, user_agent) VALUES (?, ?, ?)",
(email, ip, user_agent),
)
db.commit()
except sqlite3.IntegrityError:
# Unique constraint violation (already subscribed)
return (
jsonify(
{
"status": "duplicate",
"message": "Email already subscribed.",
"email": email,
}
),
409,
)
return (
jsonify(
{
"status": "ok",
"message": "Subscribed successfully.",
"email": email,
"created_at": datetime.utcnow().isoformat() + "Z",
}
),
201,
)
if __name__ == "__main__":
port = int(os.getenv("PORT", "5000"))
debug = os.getenv("FLASK_DEBUG", "0") == "1"
app.run(host="0.0.0.0", port=port, debug=debug)

View File

@@ -0,0 +1,22 @@
name: daarke
services:
daarke:
build:
context: .
dockerfile: Dockerfile
restart: always
volumes:
- ./emails.db:/usr/src/app/emails.db:rw
ports:
- "5000:5000"
nginx:
image: nginx:1.29.1
restart: always
volumes:
- ./nginx:/etc/nginx/conf.d:ro
- ./html:/usr/share/nginx/html:ro
ports:
- "80:80"

View File

@@ -0,0 +1,9 @@
# -*- encoding: utf-8 -*-
bind = '0.0.0.0:5000'
workers = 4
worker_class = 'uvicorn.workers.UvicornWorker'
accesslog = '-'
loglevel = 'debug'
capture_output = True
enable_stdio_inheritance = True

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

View File

@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="no" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>De som forsvinner i tåken Jonas Daarke</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' };
</script>
</head>
<body class="bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100 transition-colors">
<header class="bg-white dark:bg-gray-800 shadow">
<div class="max-w-5xl mx-auto px-6 py-4 flex justify-between items-center">
<h1 class="text-2xl font-bold">jonasdaarke.no</h1>
<nav class="flex items-center space-x-4 text-gray-700 dark:text-gray-200">
<a href="#boken" class="hover:underline">Boken</a>
<a href="#om-forfatteren" class="hover:underline">Om forfatteren</a>
<button id="themeToggle" aria-pressed="true"
class="ml-4 inline-flex items-center px-3 py-1 rounded-full border border-gray-400 dark:border-gray-500 bg-transparent text-gray-700 hover:bg-gray-200 dark:text-gray-200 dark:hover:bg-gray-700 transition">
<span aria-hidden="true" id="themeIcon">☀︎</span>
<span class="sr-only">Veksle lys/mørk modus</span>
</button>
</nav>
</div>
</header>
<section id="boken" class="max-w-5xl mx-auto px-6 py-16 grid md:grid-cols-2 gap-10 items-center">
<div>
<img src="/frontpage.png" alt="Bokforside" class="rounded shadow-lg">
</div>
<div>
<h2 class="text-3xl font-bold mb-4">De som forsvinner i tåken</h2>
<p class="text-lg text-gray-700 dark:text-gray-300 mb-6">
Da et rituelt drap knuser idyllen på den velstående øya Nesøya,
blir en pensjonert drapsetterforsker dratt inn i en mørk jakt på sannheten.
Øyas dypeste hemmeligheter er farligere enn noen kunne forestille seg.
<b>Jonas Daarke</b>s debutroman drar deg inn i en verden der grenser
mellom virkelighet og myte viskes ut.
</p>
<form id="subscribe-form" class="flex flex-col sm:flex-row gap-4">
<input type="email" name="email" id="email" placeholder="Din e-postadresse" autocomplete="email"
class="flex-1 px-4 py-3 rounded-lg border border-gray-400 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
required />
<button type="submit"
class="px-6 py-3 rounded-lg border border-gray-400 dark:border-gray-600 bg-transparent text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 transition">
Meld meg på
</button>
</form>
<p id="message" class="mt-2 text-sm"></p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Få nyheter om lanseringen rett i innboksen din.</p>
</div>
</section>
<section id="om-forfatteren" class="bg-gray-50 dark:bg-gray-800 py-16">
<div class="max-w-4xl mx-auto px-6 text-center">
<h2 class="text-2xl font-bold mb-4">Om forfatteren</h2>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">
For <b>Jonas Daarke</b> er ikke krimhistorier noe man finner på; det er noe man finner.
Med et skarpt blikk for detaljer og en dyp fascinasjon for hvordan idylliske steder kan skjule mørke
hemmeligheter,
bruker han sin erfaring til å grave frem fortellingene som ligger begravet rett under overflaten.
"De som forsvinner i tåken" er hans første utgivelse, men neppe den siste.
</p>
</div>
</section>
<footer
class="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 py-6 text-center text-gray-500 dark:text-gray-400 text-sm">
&copy; 2025 Jonas Daarke. Alle rettigheter reservert.
</footer>
<script>
const html = document.documentElement;
const toggle = document.getElementById('themeToggle');
const icon = document.getElementById('themeIcon');
function applyTheme(mode) {
if (mode === 'dark') {
html.classList.add('dark');
toggle.setAttribute('aria-pressed', 'true');
icon.textContent = '☀︎';
localStorage.setItem('theme', 'dark');
} else {
html.classList.remove('dark');
toggle.setAttribute('aria-pressed', 'false');
icon.textContent = '☾';
localStorage.setItem('theme', 'light');
}
}
(function init() {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') {
applyTheme(stored);
} else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(prefersDark ? 'dark' : 'light');
}
})();
toggle.addEventListener('click', () => {
const isDark = html.classList.contains('dark');
applyTheme(isDark ? 'light' : 'dark');
});
</script>
<script>
const form = document.getElementById('subscribe-form');
const message = document.getElementById('message');
form.addEventListener('submit', async (e) => {
e.preventDefault();
message.className = 'mt-2 text-sm';
message.textContent = 'Sender...';
const email = form.elements.email.value.trim();
try {
const res = await fetch('/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ email })
});
const isJson = res.headers.get('content-type')?.includes('application/json');
const data = isJson ? await res.json() : { message: await res.text() };
if (res.status === 201) {
message.className = 'mt-2 text-sm text-green-600';
message.textContent = 'Takk! Du er nå påmeldt.';
form.reset();
} else if (res.status === 409) {
message.className = 'mt-2 text-sm text-yellow-600';
message.textContent = 'Denne e-posten er allerede påmeldt.';
} else {
message.className = 'mt-2 text-sm text-red-600';
message.textContent = data.message || 'Noe gikk galt. Prøv igjen.';
}
} catch (err) {
message.className = 'mt-2 text-sm text-red-600';
message.textContent = 'Kunne ikke kontakte serveren.';
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,45 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
server_tokens off;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ =404;
}
location /subscribe {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_pass http://daarke:5000;
proxy_redirect off;
}
location /stats {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_pass http://daarke:5000;
proxy_redirect off;
}
location /health {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_pass http://daarke:5000;
proxy_redirect off;
}
}

View File

@@ -0,0 +1,11 @@
blinker==1.9.0
click==8.2.1
Flask==3.1.1
gunicorn==23.0.0
h11==0.16.0
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.2
packaging==25.0
uvicorn==0.35.0
Werkzeug==3.1.3