import json import os import flask import re import requests import uuid import threading import time from functools import lru_cache 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'
{_esc(note)}' f' Back
', 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:", "submit": "Submit", "toggle_dark_mode": "Toggle Dark Mode", "status_heading": "Fetching Article", "error_title": "Something went wrong", "retry_button": "Try Another URL", "elapsed_template": "{seconds}s elapsed", "connection_lost_error": "Connection to server lost. The page may be taking too long to load.", "step_connect": "Connecting to website...", "step_fetch": "Downloading page content...", "step_detect": "Checking for anti-bot challenges...", "step_fallback_freedium": "Trying Freedium (Medium bypass)...", "step_fallback_org": "Trying archive.org snapshot...", "step_fallback_ph": "Trying archive.today / archive.ph snapshot...", "step_process": "Processing article...", "step_cleanup": "Cleaning up & preparing view...", "step_done": "Done!", "invalid_url": "Invalid URL", "medium_challenge_error": ( "Medium served an anti-bot challenge and Freedium / archive.org / " "archive.today all came back empty. The article may be too new to " "have been archived yet." ), "challenge_error": ( "The site served an anti-bot challenge (Cloudflare or similar) " "and no usable snapshot was found on archive.org or archive.today. " "This site is actively blocking automated requests." ), "timeout_error": "The website took too long to respond (30s timeout). Try again later.", "connection_error": "Could not connect to the website. Check the URL and try again.", "request_error": "Failed to fetch the page.", "unexpected_error": "Unexpected error while fetching the page.", } def get_locale(): locale = os.environ.get("LOCALE", "en") if not re.match(r"^[a-zA-Z0-9_-]+$", locale): locale = "en" return locale @lru_cache(maxsize=None) def load_strings_for_locale(locale): strings = dict(_DEFAULT_STRINGS) locale_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "locales") locale_file = os.path.join(locale_dir, f"{locale}.json") if locale != "en": try: with open(locale_file, "r", encoding="utf-8") as file: strings.update(json.load(file)) except (FileNotFoundError, json.JSONDecodeError, OSError): strings = dict(_DEFAULT_STRINGS) locale = "en" strings["lang"] = locale return strings def load_strings(): return dict(load_strings_for_locale(get_locale())) googlebot_headers = { "User-Agent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.119 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" } jobs = {} jobs_lock = threading.Lock() page_cache = {} page_cache_lock = threading.Lock() PAGE_CACHE_TTL = 300 class UserFacingError(Exception): def __init__(self, user_message): super().__init__(user_message) self.user_message = user_message html = """