Added lots of improvements to the webpage.
This commit is contained in:
22
de_som_forsvinner_i_taaken/webpage/.gitignore
vendored
Normal file
22
de_som_forsvinner_i_taaken/webpage/.gitignore
vendored
Normal 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
|
14
de_som_forsvinner_i_taaken/webpage/Dockerfile
Normal file
14
de_som_forsvinner_i_taaken/webpage/Dockerfile
Normal 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" ]
|
80
de_som_forsvinner_i_taaken/webpage/README.md
Normal file
80
de_som_forsvinner_i_taaken/webpage/README.md
Normal 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.
|
137
de_som_forsvinner_i_taaken/webpage/app.py
Normal file
137
de_som_forsvinner_i_taaken/webpage/app.py
Normal 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)
|
22
de_som_forsvinner_i_taaken/webpage/docker-compose.yml
Normal file
22
de_som_forsvinner_i_taaken/webpage/docker-compose.yml
Normal 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"
|
9
de_som_forsvinner_i_taaken/webpage/gunicorn-cfg.py
Normal file
9
de_som_forsvinner_i_taaken/webpage/gunicorn-cfg.py
Normal 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
|
Before Width: | Height: | Size: 746 KiB After Width: | Height: | Size: 746 KiB |
@@ -1,5 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="no" class="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -9,6 +10,7 @@
|
||||
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">
|
||||
@@ -16,7 +18,8 @@
|
||||
<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">
|
||||
<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>
|
||||
@@ -33,19 +36,21 @@
|
||||
<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.
|
||||
Mens politiet låser seg fast på et enkelt spor, innser han at den
|
||||
virkelige morderen er en iskald skygge som skjuler seg i alles åsyn,
|
||||
og at øyas dypeste hemmeligheter er farligere enn noen kunne forestille seg.
|
||||
Ø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.
|
||||
</p>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
@@ -55,17 +60,17 @@
|
||||
<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,
|
||||
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.
|
||||
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.
|
||||
</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">
|
||||
<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">
|
||||
© 2025 Jonas Daarke. Alle rettigheter reservert.
|
||||
</footer>
|
||||
|
||||
@@ -74,8 +79,8 @@
|
||||
const toggle = document.getElementById('themeToggle');
|
||||
const icon = document.getElementById('themeIcon');
|
||||
|
||||
function applyTheme(mode){
|
||||
if(mode === 'dark') {
|
||||
function applyTheme(mode) {
|
||||
if (mode === 'dark') {
|
||||
html.classList.add('dark');
|
||||
toggle.setAttribute('aria-pressed', 'true');
|
||||
icon.textContent = '☀︎';
|
||||
@@ -88,9 +93,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
(function init(){
|
||||
(function init() {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if(stored === 'light' || stored === 'dark') {
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
applyTheme(stored);
|
||||
} else {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
@@ -103,5 +108,44 @@
|
||||
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>
|
45
de_som_forsvinner_i_taaken/webpage/nginx/localhost.conf
Normal file
45
de_som_forsvinner_i_taaken/webpage/nginx/localhost.conf
Normal 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;
|
||||
}
|
||||
}
|
11
de_som_forsvinner_i_taaken/webpage/requirements.txt
Normal file
11
de_som_forsvinner_i_taaken/webpage/requirements.txt
Normal 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
|
Reference in New Issue
Block a user