From ddf5e983e2a4383609eabaa3e675d8413ff0d954 Mon Sep 17 00:00:00 2001 From: patrickbeane Date: Sat, 30 Aug 2025 13:24:33 -0400 Subject: [PATCH] Initial commit: Quote of the Day API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secure, rate‑limited Flask + Gunicorn microservice with SQLite persistence, delivery tracking, and systemd deployment config. Includes setup script, HTML template, and production‑ready README. --- README.md | 138 ++++++++++++++++++++++++++++++++++++++++++ add-quotes.py | 25 ++++++++ deploy/quotes.service | 17 ++++++ quotes.py | 65 ++++++++++++++++++++ requirements.txt | 4 ++ templates/quotes.html | 11 ++++ 6 files changed, 260 insertions(+) create mode 100644 README.md create mode 100644 add-quotes.py create mode 100644 deploy/quotes.service create mode 100644 quotes.py create mode 100644 requirements.txt create mode 100644 templates/quotes.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c97ce9 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +## Quote of the Day API + +**A secure, rate‑limited microservice** serving curated jokes and quotes from a persistent SQLite database. Built for fun, engineered for production. + +--- + +## 📖 Overview + +Quote of the Day is a small but production-minded API designed to demonstrate: + +- **Rate limiting** – `flask-limiter` enforces per‑IP request caps to prevent abuse. +- **Persistent storage** – SQLite replaces JSON to ensure atomic reads/writes and avoid race conditions under concurrent access. +- **Proxy awareness** – `ProxyFix` middleware ensures correct client IP logging behind reverse proxies. +- **Organic usage** – The live API has been discovered and now serves hundreds of requests daily. + +--- + +## ✨ Features + +- Randomized quote delivery with delivery count tracking +- Plain text and JSON endpoints +- Rate-limited access to prevent abuse +- Systemd-ready deployment with Gunicorn +- SQLite-backed persistence for safe concurrent access + +--- + +## 🗂 Architecture +``` +[ Client ] + | + v +[ Flask + Gunicorn ] + | + v +[ SQLite DB (quotes.db) ] +``` + +--- + +## 🚀 Endpoints + +| Method | Path | Description | Rate Limit | +|--------|--------------|--------------------------------------------|----------------| +| GET | `/` | HTML frontend (quotes.html) | N/A | +| GET | `/quote` | Returns a random quote as plain text | 20/minute | +| GET | `/api/quote` | Returns a random quote as JSON | 50/minute | + +--- + +## 📦 Running Locally + +```bash +# Clone the repo +git clone https://github.com/patrickbeane/quote-of-the-day.git +cd quote-of-the-day + +# (Optional) Create a virtual environment +python3 -m venv venv && source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt + +# Edit `add-quotes.py` with your favorite quotes +nano add-quotes.py + +# Run `add-quotes.py` to generate the DB +python add-quotes.py + +# Run the API +python quotes.py + +``` + +Visit `http://:5051` for the HTML frontend or `http://:5051/api/quote` for JSON output. + +--- + +## 📝 Example Output + +```json +{ + "id": 1, + "quote": "Strive not to be a success, but rather to be of value.", + "author": "Albert Einstein", + "delivered": 3 +} +``` + +## 🛠 Deployment with systemd + +For production use, this service can be run behind Gunicorn and managed via systemd. + +A sample unit file is included in [`deploy/quotes.service`](deploy/quotes.service): + +```bash +sudo cp deploy/quotes.service /etc/systemd/system/ +sudo systemctl daemon-reexec +sudo systemctl enable quotes +sudo systemctl start quotes +``` + +--- + +## 🔒 Production Considerations + +- **Rate limiting** prevents abuse and keeps the service responsive. +- **SQLite** ensures atomic writes and safe concurrent access. +- **ProxyFix** ensures accurate IP logging behind reverse proxies. + +--- + +## 🏗 Production Deployment (on my infra) + +``` +[ Client ] + | + v +[ Hermes (Docker + Caddy) ] + | + v +[ Hades (Flask + Gunicorn) ] + | + v +[ SQLite DB (quotes.db) ] +``` + +--- + +## 📊 Fun Fact + +The live API has been hit by automated clients and bots, resulting in some quotes being "delivered" hundreds of times - a real‑world example of why rate limiting and logging matter. + +--- + +## 📜 License + +MIT License – free to use, modify, and share. \ No newline at end of file diff --git a/add-quotes.py b/add-quotes.py new file mode 100644 index 0000000..b6b04b2 --- /dev/null +++ b/add-quotes.py @@ -0,0 +1,25 @@ +import sqlite3 + +conn = sqlite3.connect("quotes.db") +c = conn.cursor() + +quotes = [ + ("Strive not to be a success, but rather to be of value.", "Albert Einstein"), + ("I find that the harder I work, the more luck I seem to have.", "Thomas Jefferson") +] + +c.execute(""" + CREATE TABLE IF NOT EXISTS quotes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quote TEXT NOT NULL, + author TEXT NOT NULL, + delivered INTEGER DEFAULT 0 + ) +""") +conn.commit() + +for quote, author in quotes: + c.execute("INSERT INTO quotes (quote, author) VALUES (?, ?)", (quote, author)) + +conn.commit() +conn.close() diff --git a/deploy/quotes.service b/deploy/quotes.service new file mode 100644 index 0000000..e622951 --- /dev/null +++ b/deploy/quotes.service @@ -0,0 +1,17 @@ +# /etc/systemd/system/quotes.service +[Unit] +Description=Flask Quote and API (Gunicorn) +After=network.target + +[Service] +User=yourusername +WorkingDirectory=/home/yourusername/quote-of-the-day +ExecStart=/home/yourusername/quote-of-the-day/venv/bin/gunicorn quotes:app --workers 2 --bind 0.0.0.0:5051 +Restart=always +RestartSec=5 +Environment="PATH=/home/yourusername/quote-of-the-day/venv/bin:/usr/bin" +Environment="VIRTUAL_ENV=/home/yourusername/quote-of-the-day/venv" +Environment="FLASK_ENV=production" + +[Install] +WantedBy=multi-user.target diff --git a/quotes.py b/quotes.py new file mode 100644 index 0000000..7a68e9f --- /dev/null +++ b/quotes.py @@ -0,0 +1,65 @@ +from flask import Flask, jsonify, render_template +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from werkzeug.middleware.proxy_fix import ProxyFix +import sqlite3 +import random +import os + +app = Flask(__name__) +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) +limiter = Limiter(key_func=get_remote_address, default_limits=["50/hour"]) +limiter.init_app(app) +random.seed() + +DB_FILE = "quotes.db" + +def verify_db(): + if not os.path.exists(DB_FILE): + raise RuntimeError("quotes.db not found. Run add-quotes.py to initialize.") + +def get_random_quote(): + """Fetch a random quote from the database and increment delivered count""" + conn = sqlite3.connect(DB_FILE) + c = conn.cursor() + + # Pick a random quote ID + c.execute("SELECT id, quote, author, delivered FROM quotes ORDER BY RANDOM() LIMIT 1") + row = c.fetchone() + if not row: + conn.close() + return None + + quote_id, quote_text, author, delivered = row + + # Increment delivered count atomically + c.execute("UPDATE quotes SET delivered = delivered + 1 WHERE id = ?", (quote_id,)) + conn.commit() + conn.close() + + return {"id": quote_id, "quote": quote_text, "author": author, "delivered": delivered + 1} + +@app.route("/") +def main(): + return render_template("quotes.html") + +@app.route("/quote") +@limiter.limit("20/minute") +def quote(): + quote = get_random_quote() + if not quote: + return "No quotes found in database!", 500 + return f"{quote['quote']} - {quote['author']}" + +@app.route("/api/quote") +@limiter.limit("50/minute") +def quote_api(): + quote = get_random_quote() + if not quote: + return jsonify({"error": "No quotes found"}), 500 + return jsonify(quote) + +if __name__ == "__main__": + verify_db() + """Running on Oracle's private network, in my instance - here it is 0.0.0.0 so only one node is needed""" + app.run(host="0.0.0.0", port=5051, debug=True) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1af04dd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask +flask-limiter +Werkzeug +gunicorn \ No newline at end of file diff --git a/templates/quotes.html b/templates/quotes.html new file mode 100644 index 0000000..d162b25 --- /dev/null +++ b/templates/quotes.html @@ -0,0 +1,11 @@ + + + + + Quote of the Day + + +

Quote of the Day

+

Visit /quote for plain text or /api/quote for JSON.

+ + \ No newline at end of file