commit d8f5a7f77c77b1f3f1a2851fd05e26231c16e6d2 Author: Kosma Moczek Date: Wed Mar 4 14:53:29 2026 +0100 Initial commit: Flask app translating Discourse User API Key flow Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b6b072 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..25af0ba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["python", "app.py", "/config/config.yaml"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..a2a25e5 --- /dev/null +++ b/app.py @@ -0,0 +1,80 @@ +import argparse +import base64 +import json +import secrets + +import yaml +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from flask import Flask, redirect, render_template, request, session, url_for + +parser = argparse.ArgumentParser() +parser.add_argument("config", help="Path to configuration file") +args = parser.parse_args() + +with open(args.config) as f: + config = yaml.safe_load(f) + +app = Flask(__name__) +app.secret_key = config["secret_key"] + +private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@app.route("/") +def index(): + return render_template("index.html", application_name=config["application_name"]) + + +@app.route("/authorize") +def authorize(): + public_key = private_key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + + nonce = secrets.token_hex(16) + client_id = secrets.token_hex(48) + session["nonce"] = nonce + session["client_id"] = client_id + + params = { + "auth_redirect": config["redirect_url"], + "application_name": config["application_name"], + "scopes": "read", + "client_id": client_id, + "nonce": nonce, + "public_key": public_key, + } + query = "&".join(f"{k}={v}" for k, v in params.items()) + return redirect(f"{config['forum_url']}/user-api-key/new?{query}") + + +@app.route("/callback") +def callback(): + payload_b64 = request.args.get("payload") + if not payload_b64: + return "Missing payload", 400 + + try: + payload = base64.b64decode(payload_b64) + data = private_key.decrypt(payload, padding.PKCS1v15()) + response = json.loads(data) + except Exception: + return "Failed to decrypt or decode payload", 400 + + if response.get("nonce") != session.get("nonce"): + return "Invalid nonce", 400 + + key = response["key"] + calendar_url = f"{config['forum_url']}/discourse-post-event/events.ics?order=desc&api_key={key}" + + return render_template( + "result.html", + application_name=config["application_name"], + calendar_url=calendar_url, + ) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000) diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..98f44d2 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,4 @@ +forum_url: "https://forum.example.com" +redirect_url: "https://myapp.example.com/callback" +application_name: "My Calendar App" +secret_key: "change-me-to-a-long-random-string" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f1a9e39 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask +cryptography +pyyaml diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..1a2cbdd --- /dev/null +++ b/templates/index.html @@ -0,0 +1,12 @@ + + + + + {{ application_name }} + + +

{{ application_name }}

+

Click the button below to authorize access and generate your personal calendar link.

+ + + diff --git a/templates/result.html b/templates/result.html new file mode 100644 index 0000000..1dd733c --- /dev/null +++ b/templates/result.html @@ -0,0 +1,12 @@ + + + + + {{ application_name }} + + +

{{ application_name }}

+

Your calendar URL — save it, it won't be displayed again:

+
{{ calendar_url }}
+ +