diff --git a/de_som_forsvinner_i_taaken/webpage/.gitignore b/de_som_forsvinner_i_taaken/webpage/.gitignore new file mode 100644 index 0000000..ec29728 --- /dev/null +++ b/de_som_forsvinner_i_taaken/webpage/.gitignore @@ -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 \ No newline at end of file diff --git a/de_som_forsvinner_i_taaken/webpage/Dockerfile b/de_som_forsvinner_i_taaken/webpage/Dockerfile new file mode 100644 index 0000000..8a31b98 --- /dev/null +++ b/de_som_forsvinner_i_taaken/webpage/Dockerfile @@ -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" ] diff --git a/de_som_forsvinner_i_taaken/webpage/README.md b/de_som_forsvinner_i_taaken/webpage/README.md new file mode 100644 index 0000000..ab0900c --- /dev/null +++ b/de_som_forsvinner_i_taaken/webpage/README.md @@ -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 +
+``` + +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. \ No newline at end of file diff --git a/de_som_forsvinner_i_taaken/webpage/app.py b/de_som_forsvinner_i_taaken/webpage/app.py new file mode 100644 index 0000000..bf8d0d2 --- /dev/null +++ b/de_som_forsvinner_i_taaken/webpage/app.py @@ -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) diff --git a/de_som_forsvinner_i_taaken/webpage/docker-compose.yml b/de_som_forsvinner_i_taaken/webpage/docker-compose.yml new file mode 100644 index 0000000..d041499 --- /dev/null +++ b/de_som_forsvinner_i_taaken/webpage/docker-compose.yml @@ -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" diff --git a/de_som_forsvinner_i_taaken/webpage/gunicorn-cfg.py b/de_som_forsvinner_i_taaken/webpage/gunicorn-cfg.py new file mode 100644 index 0000000..d30966d --- /dev/null +++ b/de_som_forsvinner_i_taaken/webpage/gunicorn-cfg.py @@ -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 \ No newline at end of file diff --git a/de_som_forsvinner_i_taaken/html/frontpage.png b/de_som_forsvinner_i_taaken/webpage/html/frontpage.png similarity index 100% rename from de_som_forsvinner_i_taaken/html/frontpage.png rename to de_som_forsvinner_i_taaken/webpage/html/frontpage.png diff --git a/de_som_forsvinner_i_taaken/html/index.html b/de_som_forsvinner_i_taaken/webpage/html/index.html similarity index 54% rename from de_som_forsvinner_i_taaken/html/index.html rename to de_som_forsvinner_i_taaken/webpage/html/index.html index f323382..9763a14 100644 --- a/de_som_forsvinner_i_taaken/html/index.html +++ b/de_som_forsvinner_i_taaken/webpage/html/index.html @@ -1,5 +1,6 @@ + @@ -9,6 +10,7 @@ tailwind.config = { darkMode: 'class' }; +For Jonas Daarke 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.
-