feat: Sign in with OTM, health, changelog, tides beacon, Ideas — first OTM release (0.4.0)
Build / Build and release (push) Failing after 19s
Build / Build and release (push) Failing after 19s
app/otm.py is the Python port of pickup's ticket verifier (HS256, mandatory exp, audience check, alg only read to reject, never raises) plus a signed session cookie and an in-memory single-use jti guard. portable.py gates every route but PUBLIC_PATHS behind a session and fails CLOSED (503) with no secret — it is a fetch proxy. /api/health reports the version, /changelog renders CHANGELOG.md, the footer carries version/sign-out/Ideas, tides beacon from TIDES_HOST/TIDES_SITE_ID. Dockerfile: OTM labels + HEALTHCHECK. 17 tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014LCQmFvKshyAJsE8C3B5ET
This commit is contained in:
+109
-1
@@ -11,8 +11,110 @@ from urllib.parse import urljoin, urlparse, quote, unquote
|
||||
from flask import request, Response
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
import otm
|
||||
|
||||
app = flask.Flask(__name__)
|
||||
|
||||
# ── OTM first-party plumbing (see otm.py) ─────────────────────────────────────
|
||||
# Every route except otm.PUBLIC_PATHS needs a Sign-in-with-OTM session. With no
|
||||
# secret configured the app fails CLOSED — it is a fetch proxy, so an open
|
||||
# deployment would be a free proxy for anyone.
|
||||
|
||||
_jti_guard = otm.JtiGuard()
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _otm_gate():
|
||||
flask.g.otm_session = None
|
||||
path = request.path
|
||||
if path in otm.PUBLIC_PATHS:
|
||||
return None
|
||||
secret = otm.session_secret()
|
||||
if not secret:
|
||||
return Response(otm.login_page("/", None, configured=False), status=503, mimetype="text/html")
|
||||
sess = otm.decode_session(secret, request.cookies.get(otm.SESSION_COOKIE))
|
||||
if sess:
|
||||
flask.g.otm_session = sess
|
||||
return None
|
||||
if request.method == "GET":
|
||||
nxt = request.full_path if request.query_string else request.path
|
||||
return flask.redirect("/login?" + urlencode_qs({"next": nxt}), code=302)
|
||||
return Response(json.dumps({"error": "sign in required"}), status=401, mimetype="application/json")
|
||||
|
||||
|
||||
def urlencode_qs(d):
|
||||
from urllib.parse import urlencode
|
||||
return urlencode(d)
|
||||
|
||||
|
||||
def _esc(text):
|
||||
import html as _h
|
||||
return _h.escape(str(text))
|
||||
|
||||
|
||||
@app.route("/api/health")
|
||||
def otm_health():
|
||||
return flask.jsonify({"status": "ok", "version": otm.APP_VERSION})
|
||||
|
||||
|
||||
@app.route("/login")
|
||||
def otm_login():
|
||||
nxt = otm.safe_next(request.args.get("next"), "/")
|
||||
return Response(otm.login_page(nxt, request.args.get("reason"), configured=bool(otm.session_secret())), mimetype="text/html")
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def otm_logout():
|
||||
resp = flask.redirect("/login", code=302)
|
||||
resp.delete_cookie(otm.SESSION_COOKIE, path="/")
|
||||
return resp
|
||||
|
||||
|
||||
@app.route("/api/auth/otm-sso")
|
||||
def otm_sso_callback():
|
||||
def fail(reason):
|
||||
return flask.redirect(f"/login?reason={reason}", code=302)
|
||||
secret = (os.environ.get("OTM_SSO_SECRET") or "").strip()
|
||||
sess_secret = otm.session_secret()
|
||||
if not secret or not sess_secret:
|
||||
return fail("not_enabled")
|
||||
payload = otm.verify_sso_ticket(secret, request.args.get("token"), expected_aud=otm.OTM_AUDIENCE)
|
||||
if not payload:
|
||||
return fail("invalid_token")
|
||||
# Claim the jti BEFORE minting any session so a replay never produces a cookie.
|
||||
if not _jti_guard.claim(payload["jti"]):
|
||||
return fail("link_already_used")
|
||||
now = int(time.time())
|
||||
sess = {"sub": payload["sub"], "email": payload["email"], "name": payload.get("name"), "exp": now + otm.SESSION_TTL_SEC}
|
||||
resp = flask.redirect(otm.safe_next(payload.get("next"), "/"), code=302)
|
||||
secure = request.headers.get("X-Forwarded-Proto", request.scheme).split(",")[0].strip() == "https"
|
||||
resp.set_cookie(otm.SESSION_COOKIE, otm.encode_session(sess_secret, sess), max_age=otm.SESSION_TTL_SEC,
|
||||
httponly=True, secure=secure, samesite="Lax", path="/")
|
||||
return resp
|
||||
|
||||
|
||||
@app.route("/changelog")
|
||||
def otm_changelog():
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "CHANGELOG.md")
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
except OSError:
|
||||
text = f"v{otm.APP_VERSION}"
|
||||
return Response(otm.changelog_page(text), mimetype="text/html")
|
||||
|
||||
|
||||
@app.route("/api/ideas", methods=["POST"])
|
||||
def otm_ideas():
|
||||
who = (flask.g.otm_session or {}).get("email", "")
|
||||
ok, msg = otm.create_idea(request.form.get("title", ""), who, request.headers.get("Referer", "/"))
|
||||
if request.accept_mimetypes.best == "application/json":
|
||||
return flask.jsonify({"ok": ok, "result": msg}), (200 if ok else 400)
|
||||
note = "Thanks — filed." if ok else f"Couldn't file that: {msg}"
|
||||
return Response(
|
||||
f'<!doctype html><meta charset="utf-8"><p style="font-family:system-ui;margin:3rem auto;max-width:28rem">{_esc(note)}'
|
||||
f' <a href="/">Back</a></p>', mimetype="text/html", status=200 if ok else 400)
|
||||
|
||||
_DEFAULT_STRINGS = {
|
||||
"heading": "Enter Website Link",
|
||||
"label": "Link of the website you want to remove paywall for:",
|
||||
@@ -596,12 +698,18 @@ def render_main_page():
|
||||
"elapsed_template": strings["elapsed_template"],
|
||||
"connection_lost_error": strings["connection_lost_error"],
|
||||
}
|
||||
return flask.render_template_string(
|
||||
rendered = flask.render_template_string(
|
||||
html,
|
||||
steps=build_steps(strings),
|
||||
ui_strings=ui_strings,
|
||||
**strings,
|
||||
)
|
||||
# OTM: tides beacon in <head>, footer (version · changelog · sign out ·
|
||||
# Ideas) before </body>. Post-processed so upstream's template stays
|
||||
# mergeable.
|
||||
email = (getattr(flask.g, "otm_session", None) or {}).get("email")
|
||||
rendered = rendered.replace("</head>", otm.tides_beacon() + "</head>", 1)
|
||||
return rendered.replace("</body>", otm.footer_html(email) + "</body>", 1)
|
||||
|
||||
|
||||
def process_html_document(html_content, original_url):
|
||||
|
||||
Reference in New Issue
Block a user