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

View File

Before

Width:  |  Height:  |  Size: 746 KiB

After

Width:  |  Height:  |  Size: 746 KiB

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="no" class="dark"> <html lang="no" class="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -9,6 +10,7 @@
tailwind.config = { darkMode: 'class' }; tailwind.config = { darkMode: 'class' };
</script> </script>
</head> </head>
<body class="bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100 transition-colors"> <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"> <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"> <div class="max-w-5xl mx-auto px-6 py-4 flex justify-between items-center">
@@ -16,7 +18,8 @@
<nav class="flex items-center space-x-4 text-gray-700 dark:text-gray-200"> <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="#boken" class="hover:underline">Boken</a>
<a href="#om-forfatteren" class="hover:underline">Om forfatteren</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"> <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 aria-hidden="true" id="themeIcon">☀︎</span>
<span class="sr-only">Veksle lys/mørk modus</span> <span class="sr-only">Veksle lys/mørk modus</span>
</button> </button>
@@ -33,19 +36,21 @@
<p class="text-lg text-gray-700 dark:text-gray-300 mb-6"> <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, 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. blir en pensjonert drapsetterforsker dratt inn i en mørk jakt på sannheten.
Mens politiet låser seg fast på et enkelt spor, innser han at den Øyas dypeste hemmeligheter er farligere enn noen kunne forestille seg.
virkelige morderen er en iskald skygge som skjuler seg i alles åsyn,
og at øyas dypeste hemmeligheter er farligere enn noen kunne forestille seg.
<b>Jonas Daarkes debutroman</b> drar deg inn i en verden der grenser <b>Jonas Daarke</b>s debutroman drar deg inn i en verden der grenser
mellom virkelighet og myte viskes ut. mellom virkelighet og myte viskes ut.
</p> </p>
<form class="flex flex-col sm:flex-row gap-4"> <form id="subscribe-form" class="flex flex-col sm:flex-row gap-4">
<input type="email" placeholder="Din e-postadresse" 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> <input type="email" name="email" id="email" placeholder="Din e-postadresse" autocomplete="email"
<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"> 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å Meld meg på
</button> </button>
</form> </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> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Få nyheter om lanseringen rett i innboksen din.</p>
</div> </div>
</section> </section>
@@ -55,17 +60,17 @@
<h2 class="text-2xl font-bold mb-4">Om forfatteren</h2> <h2 class="text-2xl font-bold mb-4">Om forfatteren</h2>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed"> <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. 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, 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. bruker han sin erfaring til å grave frem fortellingene som ligger begravet rett under overflaten.
Han tror fullt og fast på at de mest rystende mysteriene ikke utspiller seg i mørke bakgater, men bak de lukkede dørene
i nabolag akkurat som ditt eget.
"De som forsvinner i tåken" er hans første utgivelse, men neppe den siste. "De som forsvinner i tåken" er hans første utgivelse, men neppe den siste.
</p> </p>
</div> </div>
</section> </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"> <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. &copy; 2025 Jonas Daarke. Alle rettigheter reservert.
</footer> </footer>
@@ -103,5 +108,44 @@
applyTheme(isDark ? 'light' : 'dark'); applyTheme(isDark ? 'light' : 'dark');
}); });
</script> </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> </body>
</html> </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