Compare commits
18
Commits
e513a8d190
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ed488afa2 | ||
|
|
82f0a1247c | ||
|
|
83f4d0d232 | ||
|
|
59ed6a5951 | ||
|
|
9b37dd5f2b | ||
|
|
f76c562a29 | ||
|
|
71abf89784 | ||
|
|
d2d8c55d46 | ||
|
|
d03b120c41 | ||
|
|
d7505d639a | ||
|
|
f789fdcfd4 | ||
|
|
06746d6017 | ||
|
|
9069a1cf33 | ||
|
|
48bfa0f9d3 | ||
|
|
05fa7f695e | ||
|
|
8783575a33 | ||
|
|
86fdd2ec0d | ||
|
|
161c291e51 |
@@ -0,0 +1,31 @@
|
|||||||
|
# Gitea CI for the OTM fork: the unit + Flask tests, then prove the image builds.
|
||||||
|
# Upstream's .github/workflows/build.yaml pushed to GHCR and could never pass here.
|
||||||
|
# Jobs run in the runner's default node:22-bookworm container (actions/checkout
|
||||||
|
# needs node), so Python comes from Debian; the image job mirrors qr's (buildx,
|
||||||
|
# push: false) — that shape is proven on both runners.
|
||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Python + deps
|
||||||
|
run: |
|
||||||
|
apt-get update -qq && apt-get install -y -qq python3 python3-pip > /dev/null
|
||||||
|
pip3 install -q --break-system-packages -r requirements.txt
|
||||||
|
- run: cd app && python3 -m unittest -v
|
||||||
|
image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Build image (verification only)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
tags: 13ft:ci
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
name: Build
|
|
||||||
|
|
||||||
# Controls when the workflow will run
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
- 'dev'
|
|
||||||
tags:
|
|
||||||
- 'v*.*.*'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
- 'dev'
|
|
||||||
|
|
||||||
# permissions are needed if pushing to ghcr.io
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: "Build and release"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# Get the repository's code
|
|
||||||
- name: ⬇️ checkout
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
# https://github.com/docker/setup-qemu-action
|
|
||||||
- name: ⚙ set up qemu
|
|
||||||
uses: docker/setup-qemu-action@v1
|
|
||||||
|
|
||||||
# https://github.com/docker/setup-buildx-action
|
|
||||||
- name: ⚙ set up docker buildx
|
|
||||||
id: buildx
|
|
||||||
uses: docker/setup-buildx-action@v1
|
|
||||||
|
|
||||||
- name: 👤 login to ghcr
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@v1
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: 👤 login to docker hub
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@v1
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: 🏷 docker meta
|
|
||||||
id: docker_meta # you'll use this in the next step
|
|
||||||
uses: docker/metadata-action@v3
|
|
||||||
with:
|
|
||||||
# list of Docker images to use as base name for tags
|
|
||||||
images: |
|
|
||||||
ghcr.io/${{ github.repository_owner }}/13ft
|
|
||||||
docker.io/wasimaster/13ft
|
|
||||||
# Docker tags based on the following events/attributes
|
|
||||||
tags: |
|
|
||||||
type=schedule
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=sha
|
|
||||||
|
|
||||||
- name: 📦 build and ⬆ push
|
|
||||||
uses: docker/build-push-action@v2
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
|
||||||
@@ -3,3 +3,6 @@ __pycache__
|
|||||||
*.pyo
|
*.pyo
|
||||||
.vercel
|
.vercel
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.DS_Store
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Changelog — 13ft (OTM fork)
|
||||||
|
|
||||||
|
## 0.4.2 — 2026-10-19
|
||||||
|
|
||||||
|
- Sign-in page now wears the shared otm-login module's actual CSS/markup (ported verbatim from `@otm/account-panel` otm-login-core.ts 0.44.0), not a one-off lookalike — same brand/card/button structure every other OTM app uses, accent set to copper (`--otm-accent:#b5562a`) like the platform. Same OTM-only behavior: no "Other ways to sign in", fail-closed "isn't wired to OTM yet" copy unchanged.
|
||||||
|
|
||||||
|
## 0.4.1 — 2026-10-18
|
||||||
|
|
||||||
|
- Sign-in page restyled onto the platform's shared OTM sign-in standard: brand (name + tagline) above one centered card, the "Sign in with OTM" button full-width and primary, a muted Face ID/Touch ID/passkey line underneath. 13ft has no local accounts, so there's no "Other ways to sign in" step — the fail-closed "isn't wired to OTM yet" copy still shows when unconfigured.
|
||||||
|
|
||||||
|
## 0.4.0 — 2026-10-18
|
||||||
|
|
||||||
|
First OTM release of the fork (upstream wasi-master/13ft 0.3.4 underneath).
|
||||||
|
|
||||||
|
- Sign in with OTM replaces Caddy basic auth: every fetch route needs an OTM session (grant on `app:13ft`); no secret configured = the app refuses everyone rather than becoming an open proxy.
|
||||||
|
- `/api/health` reports the version; `/changelog` renders this file; a footer with version, sign-out and an Ideas box that files a Gitea issue.
|
||||||
|
- tides beacon when the platform injects `TIDES_HOST`/`TIDES_SITE_ID`.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 13ft — OTM's fork
|
||||||
|
|
||||||
|
A self-hosted "read the article" fetcher (Python/Flask + gunicorn, ~200 lines
|
||||||
|
in `app/` (`portable.py` is the entrypoint)): it refetches a URL as Googlebot and serves the page
|
||||||
|
without the paywall/ad cruft. Forked 2026-10-14 from
|
||||||
|
[wasi-master/13ft](https://github.com/wasi-master/13ft) (the original project
|
||||||
|
was taken down once already — wasm-fyi → wasi-master — which is WHY we hold a
|
||||||
|
fork, not a dependency on GitHub/Docker Hub).
|
||||||
|
|
||||||
|
- **Remotes:** `gitea` = `tonym/13ft` on git.poweredbyotm.com (ours, push here);
|
||||||
|
`upstream` = GitHub wasi-master/13ft (pull only: `git fetch upstream && git
|
||||||
|
merge upstream/main`). Token for Gitea lives in `~/Projects/CLAUDE.md`.
|
||||||
|
- **Running today (since 2026-10-19, v0.4.0):** https://13ft.poweredbyotm.com on
|
||||||
|
**anvil** as a FIRST-PARTY APP built from this repo (`platform-13ft:latest`,
|
||||||
|
catalogue entry in otm-admin 0.148.0) behind **Sign in with OTM** — grant
|
||||||
|
`app:13ft` on /users. The old stock-image + Caddy basic-auth stack
|
||||||
|
(`platform/infra/13ft/setup-13ft-anvil.sh`, platform#91) is gone; the script is
|
||||||
|
marked SUPERSEDED. Deploy = push → `build_app 13ft` → `deploy_app 13ft`.
|
||||||
|
|
||||||
|
## Where this is going (the reason for the fork)
|
||||||
|
|
||||||
|
Make it a proper **OTM first-party app** per
|
||||||
|
`platform/docs/first-party-app-guide.md` (house rules are CI-enforced by
|
||||||
|
`platform/test/first-party-house-rules.test.ts`):
|
||||||
|
|
||||||
|
1. **Build from this repo** — catalogue entry in
|
||||||
|
`platform/apps/otm-admin/lib/first-party-apps.ts` (`id: "13ft"`, repo
|
||||||
|
`tonym/13ft`, image `platform-13ft:latest`, port 5000, `hostname:
|
||||||
|
13ft.poweredbyotm.com`), then `build_app 13ft` / `deploy_app 13ft` via the
|
||||||
|
platform MCP. **First: destroy or rename the hand-provisioned `13ft` stack**
|
||||||
|
(same slug + hostname + container name) or the two will fight over the
|
||||||
|
Caddy site and the container name.
|
||||||
|
2. **Sign in with OTM** replaces Caddy basic auth: `needsAuthSecret` →
|
||||||
|
`OTM_SSO_SECRET`; add `/api/auth/otm-sso` (verify the HS256 ticket for aud
|
||||||
|
`app:13ft` — port `pickup/src/sso.js`, or in Python: HMAC-SHA256 over
|
||||||
|
`header.payload`, base64url, check `exp`/`aud`/`jti`, single-use) and a
|
||||||
|
session cookie; gate every fetch route on it. Grant-only (NOT
|
||||||
|
`publicAccess`) — a stranger's OTM account must not get a free fetch proxy.
|
||||||
|
3. **tides** (`needsAnalytics`) — the beacon `<script defer
|
||||||
|
src="${TIDES_HOST}/t.js" data-site="${TIDES_SITE_ID}">` in the page
|
||||||
|
template. **Ideas** (`needsSuggestions`) — a lightbulb that files Gitea
|
||||||
|
issues (see `pickup/src/ideas.js` for the plain-HTTP shape). Version +
|
||||||
|
`/changelog` + `/api/health` reporting the version + footer "Powered by OTM".
|
||||||
|
4. Keep the Dockerfile's non-root user (`thirteener`) and gunicorn; add a
|
||||||
|
`HEALTHCHECK`.
|
||||||
|
|
||||||
|
**Status 2026-10-18 (v0.4.0): steps 1–4 are built.** `app/otm.py` holds the ticket
|
||||||
|
verifier + session cookie + health/changelog/footer/Ideas; `portable.py` mounts the
|
||||||
|
`before_request` gate (everything but `otm.PUBLIC_PATHS` needs a session; no secret =
|
||||||
|
503, never open) and the routes. Tests: `cd app && ../.venv/bin/python -m unittest`
|
||||||
|
(pure `test_otm.py` + Flask test-client `test_app.py`). The platform catalogue entry
|
||||||
|
landed in otm-admin 0.148.0. Deploy = stop the hand stack (`cd /srv/otm/clients/13ft
|
||||||
|
&& docker compose down`), then `build_app 13ft` + `deploy_app 13ft`; grant `app:13ft`
|
||||||
|
to each reader on /users. The in-memory jti guard assumes gunicorn's single worker.
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt
|
||||||
|
cd app && python portable.py # http://127.0.0.1:5000 (the app lives in app/)
|
||||||
|
docker build -t 13ft-dev . && docker run --rm -p 5000:5000 13ft-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Upstream README (below in this repo's `README.md`) covers the rest. Keep
|
||||||
|
upstream's MIT license file.
|
||||||
+9
-6
@@ -7,12 +7,12 @@ ARG GID=1000
|
|||||||
RUN addgroup -g $GID thirteener
|
RUN addgroup -g $GID thirteener
|
||||||
RUN adduser -D -g '' -u $UID -G thirteener thirteener
|
RUN adduser -D -g '' -u $UID -G thirteener thirteener
|
||||||
|
|
||||||
# Generic labels
|
# Generic labels (OTM fork of wasi-master/13ft 0.3.4 — see CLAUDE.md)
|
||||||
LABEL maintainer="Arian Mollik Wasi <arianmollik323@gmail.com>"
|
LABEL maintainer="Powered by OTM <tony@poweredbyotm.com>"
|
||||||
LABEL version="0.3.4"
|
LABEL version="0.4.0"
|
||||||
LABEL description="My own custom 12ft.io replacement"
|
LABEL description="13ft (OTM fork): read the article without the cruft, behind Sign in with OTM"
|
||||||
LABEL url="https://github.com/wasi-master/13ft/"
|
LABEL url="https://git.poweredbyotm.com/tonym/13ft"
|
||||||
LABEL documentation="https://github.com/wasi-master/13ft/blob/main/README.md"
|
LABEL documentation="https://git.poweredbyotm.com/tonym/13ft/src/branch/main/CLAUDE.md"
|
||||||
|
|
||||||
# OCI compliant labels
|
# OCI compliant labels
|
||||||
LABEL org.opencontainers.image.source="https://github.com/wasi-master/13ft"
|
LABEL org.opencontainers.image.source="https://github.com/wasi-master/13ft"
|
||||||
@@ -34,4 +34,7 @@ RUN --mount=type=cache,mode=0755,target=/home/thirteener/.cache/pip \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
# /api/health is one of the few unauthenticated routes (app/otm.py PUBLIC_PATHS).
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:5000/api/health || exit 1
|
||||||
CMD [ "python", "-m", "gunicorn", "portable:app" ]
|
CMD [ "python", "-m", "gunicorn", "portable:app" ]
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ Built-in locales:
|
|||||||
- `en` (default)
|
- `en` (default)
|
||||||
- `de`
|
- `de`
|
||||||
- `fr`
|
- `fr`
|
||||||
|
- `ko`
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"heading": "웹사이트 링크 입력",
|
||||||
|
"label": "페이월을 해제하고 싶은 웹사이트의 링크:",
|
||||||
|
"submit": "제출",
|
||||||
|
"toggle_dark_mode": "다크 모드 전환",
|
||||||
|
"status_heading": "기사 가져오는 중",
|
||||||
|
"error_title": "문제가 발생했습니다",
|
||||||
|
"retry_button": "다른 URL 시도하기",
|
||||||
|
"elapsed_template": "{seconds}초 경과",
|
||||||
|
"connection_lost_error": "서버와의 연결이 끊어졌습니다. 페이지 로딩이 너무 오래 걸리고 있을 수 있습니다.",
|
||||||
|
"step_connect": "웹사이트에 연결하는 중...",
|
||||||
|
"step_fetch": "페이지 콘텐츠를 다운로드하는 중...",
|
||||||
|
"step_detect": "봇 차단 여부를 확인하는 중...",
|
||||||
|
"step_fallback_freedium": "Freedium을 시도하는 중 (Medium 우회)...",
|
||||||
|
"step_fallback_org": "archive.org 스냅샷을 시도하는 중...",
|
||||||
|
"step_fallback_ph": "archive.today / archive.ph 스냅샷을 시도하는 중...",
|
||||||
|
"step_process": "기사를 처리하는 중...",
|
||||||
|
"step_cleanup": "화면을 정리하고 준비하는 중...",
|
||||||
|
"step_done": "완료!",
|
||||||
|
"invalid_url": "잘못된 URL입니다",
|
||||||
|
"medium_challenge_error": "Medium이 봇 차단 페이지를 반환했고, Freedium / archive.org / archive.today 모두 결과를 찾지 못했습니다. 아직 아카이빙되지 않은 최신 기사일 수 있습니다.",
|
||||||
|
"challenge_error": "해당 사이트가 봇 차단(Cloudflare 등)을 적용했고, archive.org나 archive.today에서도 사용할 수 있는 스냅샷을 찾지 못했습니다. 이 사이트는 자동화된 요청을 적극적으로 차단하고 있습니다.",
|
||||||
|
"timeout_error": "웹사이트 응답이 너무 오래 걸렸습니다 (30초 시간 초과). 잠시 후 다시 시도해 주세요.",
|
||||||
|
"connection_error": "웹사이트에 연결할 수 없습니다. URL을 확인한 후 다시 시도해 주세요.",
|
||||||
|
"request_error": "페이지를 가져오지 못했습니다.",
|
||||||
|
"unexpected_error": "페이지를 가져오는 중 예기치 않은 오류가 발생했습니다."
|
||||||
|
}
|
||||||
+308
@@ -0,0 +1,308 @@
|
|||||||
|
"""OTM first-party plumbing for 13ft: Sign in with OTM, the session cookie,
|
||||||
|
health/version, the tides beacon, and the Ideas (Gitea issue) endpoint.
|
||||||
|
|
||||||
|
Sign in with OTM is the consumer half of the platform's universal login, a
|
||||||
|
straight port of pickup/src/sso.js (the control plane's lib/sso-core.ts is the
|
||||||
|
canonical format):
|
||||||
|
|
||||||
|
browser -> https://admin.poweredbyotm.com/sso/authorize?aud=app:13ft&next=/
|
||||||
|
OTM checks the account's grant on app:13ft, mints a 60s single-use HS256
|
||||||
|
ticket signed with HMAC(master, "otm-sso:app:13ft") — our OTM_SSO_SECRET —
|
||||||
|
and 302s to https://13ft.poweredbyotm.com/api/auth/otm-sso?token=...
|
||||||
|
|
||||||
|
exp is mandatory, aud is checked, the alg header is only ever read to REJECT,
|
||||||
|
and verify never raises (a raised parse error on a redirect path would be a
|
||||||
|
500, and a distinguishing error would be an oracle).
|
||||||
|
|
||||||
|
13ft is a fetch proxy, so it is grant-only: no publicAccess, and with no
|
||||||
|
secret configured every route fails CLOSED (503), never open.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import html as html_mod
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets as _secrets
|
||||||
|
import time
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
APP_VERSION = "0.4.2"
|
||||||
|
OTM_AUDIENCE = "app:13ft"
|
||||||
|
OTM_BASE = os.environ.get("OTM_SSO_AUTHORIZE_BASE", "https://admin.poweredbyotm.com")
|
||||||
|
SESSION_COOKIE = "thirteen_session"
|
||||||
|
SESSION_TTL_SEC = 7 * 24 * 3600
|
||||||
|
|
||||||
|
# Paths that never need a session. Everything else — including the catch-all
|
||||||
|
# article fetcher — does.
|
||||||
|
PUBLIC_PATHS = frozenset({"/api/health", "/login", "/api/auth/otm-sso", "/favicon.ico"})
|
||||||
|
|
||||||
|
|
||||||
|
# ── base64url + HMAC ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _b64url(data: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(s: str) -> bytes:
|
||||||
|
s = s + "=" * (-len(s) % 4)
|
||||||
|
return base64.urlsafe_b64decode(s.encode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(secret: str, signing_input: str) -> str:
|
||||||
|
return _b64url(hmac.new(secret.encode("utf-8"), signing_input.encode("utf-8"), hashlib.sha256).digest())
|
||||||
|
|
||||||
|
|
||||||
|
# ── the OTM ticket ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def verify_sso_ticket(secret: str, token: Any, expected_aud: Optional[str] = None, now_ms: Optional[int] = None) -> Optional[dict]:
|
||||||
|
"""The ticket's payload, or None for anything that isn't a valid, unexpired
|
||||||
|
HS256 ticket for `expected_aud`. Never raises."""
|
||||||
|
try:
|
||||||
|
parts = str(token).split(".")
|
||||||
|
if len(parts) != 3 or not all(parts):
|
||||||
|
return None
|
||||||
|
header_b64, payload_b64, sig_b64 = parts
|
||||||
|
header = json.loads(_b64url_decode(header_b64))
|
||||||
|
if not isinstance(header, dict) or header.get("alg") != "HS256":
|
||||||
|
return None
|
||||||
|
expected = _sign(secret, f"{header_b64}.{payload_b64}")
|
||||||
|
if not hmac.compare_digest(expected.encode("utf-8"), sig_b64.encode("utf-8")):
|
||||||
|
return None
|
||||||
|
payload = json.loads(_b64url_decode(payload_b64))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if not isinstance(exp, (int, float)) or isinstance(exp, bool):
|
||||||
|
return None
|
||||||
|
now_sec = (now_ms if now_ms is not None else int(time.time() * 1000)) // 1000
|
||||||
|
if now_sec >= exp:
|
||||||
|
return None
|
||||||
|
for k in ("jti", "sub", "aud", "role", "email"):
|
||||||
|
if not isinstance(payload.get(k), str) or not payload[k]:
|
||||||
|
return None
|
||||||
|
if expected_aud is not None and payload["aud"] != expected_aud:
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mint_sso_ticket(secret: str, claims: dict, ttl_sec: int = 60, now_ms: Optional[int] = None) -> str:
|
||||||
|
"""Test/dev helper — mint a ticket the way the control plane does."""
|
||||||
|
now = (now_ms if now_ms is not None else int(time.time() * 1000)) // 1000
|
||||||
|
header = _b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
|
||||||
|
body = dict(claims)
|
||||||
|
body.setdefault("jti", _secrets.token_urlsafe(12))
|
||||||
|
body["iat"] = now
|
||||||
|
body["exp"] = now + ttl_sec
|
||||||
|
payload = _b64url(json.dumps(body, separators=(",", ":")).encode())
|
||||||
|
return f"{header}.{payload}.{_sign(secret, f'{header}.{payload}')}"
|
||||||
|
|
||||||
|
|
||||||
|
class JtiGuard:
|
||||||
|
"""Single-use jti guard. Tickets live 60s and gunicorn runs ONE sync worker
|
||||||
|
here (gunicorn.conf.py), so an in-memory map with a short TTL is a complete
|
||||||
|
replay guard. If workers are ever raised above one, move this to a shared
|
||||||
|
store — per-worker memory would let a ticket replay against another worker."""
|
||||||
|
|
||||||
|
def __init__(self, ttl_sec: int = 600):
|
||||||
|
self.ttl = ttl_sec
|
||||||
|
self.seen: dict[str, float] = {}
|
||||||
|
|
||||||
|
def claim(self, jti: str, now: Optional[float] = None) -> bool:
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
if len(self.seen) > 5000:
|
||||||
|
for k, at in list(self.seen.items()):
|
||||||
|
if now - at > self.ttl:
|
||||||
|
del self.seen[k]
|
||||||
|
at = self.seen.get(jti)
|
||||||
|
if at is not None and now - at < self.ttl:
|
||||||
|
return False
|
||||||
|
self.seen[jti] = now
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── the session cookie ────────────────────────────────────────────────────────
|
||||||
|
# {sub, email, name?, exp} as base64url JSON + HMAC under the session secret.
|
||||||
|
|
||||||
|
def session_secret() -> Optional[str]:
|
||||||
|
"""AUTH_SECRET (injected by the platform for needsAuthSecret apps), else the
|
||||||
|
SSO secret itself. None = not configured → the app fails closed."""
|
||||||
|
for k in ("AUTH_SECRET", "OTM_SSO_SECRET"):
|
||||||
|
v = (os.environ.get(k) or "").strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def encode_session(secret: str, s: dict) -> str:
|
||||||
|
body = _b64url(json.dumps(s, separators=(",", ":")).encode())
|
||||||
|
return f"{body}.{_sign(secret, body)}"
|
||||||
|
|
||||||
|
|
||||||
|
def decode_session(secret: str, value: Any, now_ms: Optional[int] = None) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
body, sig = str(value or "").split(".", 1)
|
||||||
|
if not body or not sig:
|
||||||
|
return None
|
||||||
|
if not hmac.compare_digest(_sign(secret, body).encode(), sig.encode()):
|
||||||
|
return None
|
||||||
|
s = json.loads(_b64url_decode(body))
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
return None
|
||||||
|
exp = s.get("exp")
|
||||||
|
now_sec = (now_ms if now_ms is not None else int(time.time() * 1000)) // 1000
|
||||||
|
if not isinstance(exp, (int, float)) or isinstance(exp, bool) or now_sec >= exp:
|
||||||
|
return None
|
||||||
|
if not isinstance(s.get("email"), str):
|
||||||
|
return None
|
||||||
|
return s
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def safe_next(next_path: Any, default: str = "/") -> str:
|
||||||
|
"""Path-only, single leading slash — never an open redirect."""
|
||||||
|
n = str(next_path or "")
|
||||||
|
if n.startswith("/") and not n.startswith("//") and not n.startswith("/\\"):
|
||||||
|
return n
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def otm_sign_in_url(next_path: str = "/") -> str:
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
return f"{OTM_BASE.rstrip('/')}/sso/authorize?" + urlencode({"aud": OTM_AUDIENCE, "next": safe_next(next_path)})
|
||||||
|
|
||||||
|
|
||||||
|
# ── tides + footer + ideas ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def tides_beacon() -> str:
|
||||||
|
host = (os.environ.get("TIDES_HOST") or "").strip().rstrip("/")
|
||||||
|
site = (os.environ.get("TIDES_SITE_ID") or "").strip()
|
||||||
|
if not host or not site:
|
||||||
|
return ""
|
||||||
|
return f'<script defer src="{html_mod.escape(host)}/t.js" data-site="{html_mod.escape(site)}"></script>'
|
||||||
|
|
||||||
|
|
||||||
|
def footer_html(email: Optional[str]) -> str:
|
||||||
|
who = f'<span>{html_mod.escape(email)}</span> · <a href="/logout">Sign out</a> · ' if email else ""
|
||||||
|
ideas = (
|
||||||
|
'<form id="otm-idea" method="post" action="/api/ideas" style="display:inline">'
|
||||||
|
'<input name="title" maxlength="140" placeholder="💡 an idea for 13ft…" '
|
||||||
|
'style="font:inherit;padding:.2em .5em;border:1px solid #8884;border-radius:6px;background:transparent;color:inherit;width:14em">'
|
||||||
|
' <button type="submit" style="font:inherit;padding:.2em .6em;border:1px solid #8884;border-radius:6px;background:transparent;color:inherit;cursor:pointer">Send</button>'
|
||||||
|
'</form>'
|
||||||
|
if ideas_configured() else ""
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<footer style="margin:2.5rem auto 1rem;max-width:60rem;padding:0 1rem;font-size:.8rem;opacity:.75;'
|
||||||
|
'display:flex;flex-wrap:wrap;gap:.6em;align-items:center;justify-content:space-between">'
|
||||||
|
f'<div>{who}<a href="/changelog">v{APP_VERSION}</a> · Powered by OTM</div>'
|
||||||
|
f'<div>{ideas}</div></footer>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ideas_configured() -> bool:
|
||||||
|
return bool(os.environ.get("GITEA_URL") and os.environ.get("GITEA_TOKEN") and os.environ.get("GITEA_REPO"))
|
||||||
|
|
||||||
|
|
||||||
|
def create_idea(title: str, who: str, page: str = "/") -> tuple[bool, str]:
|
||||||
|
"""File an idea as a Gitea issue on this app's repo (GITEA_URL/TOKEN/REPO
|
||||||
|
injected by the platform's needsSuggestions). Returns (ok, message/url)."""
|
||||||
|
t = (title or "").strip()[:140]
|
||||||
|
if not t:
|
||||||
|
return False, "A title is required."
|
||||||
|
if not ideas_configured():
|
||||||
|
return False, "Ideas isn't configured."
|
||||||
|
base = os.environ["GITEA_URL"].rstrip("/")
|
||||||
|
repo = os.environ["GITEA_REPO"]
|
||||||
|
headers = {"Authorization": f"token {os.environ['GITEA_TOKEN']}", "Content-Type": "application/json", "Accept": "application/json"}
|
||||||
|
body = f"{t}\n\n---\nFiled from 13ft's Ideas box by {who or 'someone signed in'} on `{page}` (v{APP_VERSION})."
|
||||||
|
try:
|
||||||
|
r = requests.post(f"{base}/api/v1/repos/{repo}/issues", headers=headers, json={"title": t, "body": body}, timeout=10)
|
||||||
|
if r.status_code >= 300:
|
||||||
|
return False, f"Gitea said {r.status_code}."
|
||||||
|
return True, r.json().get("html_url", "")
|
||||||
|
except Exception:
|
||||||
|
return False, "Couldn't reach Gitea."
|
||||||
|
|
||||||
|
|
||||||
|
# ── small pages ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SSO_REASONS = {
|
||||||
|
"not_enabled": "Sign in with OTM isn't configured on this deployment.",
|
||||||
|
"invalid_token": "That OTM sign-in link is no longer valid. Try again.",
|
||||||
|
"link_already_used": "That OTM sign-in link was already used. Try again.",
|
||||||
|
"server_error": "Something went wrong completing OTM sign-in. Try again.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Ported from @otm/account-panel otm-login-core.ts 0.44.0 (OTM_LOGIN_CSS) —
|
||||||
|
# keep in sync, verbatim. 13ft is OTM-only (no local accounts), so only the
|
||||||
|
# .otm-login rules are needed — no .otm-menu/.otm-badge here.
|
||||||
|
OTM_LOGIN_CSS = """
|
||||||
|
.otm-login{--otm-accent:#b5562a;--otm-accent-ink:#fff;--otm-bg:#faf8f5;--otm-card:#fff;--otm-fg:#1f1b18;--otm-muted:#6b6560;--otm-line:rgba(31,27,24,.12);--otm-radius:20px;--otm-font-display:inherit;
|
||||||
|
min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px 16px;background:var(--otm-bg);color:var(--otm-fg);box-sizing:border-box}
|
||||||
|
@media (prefers-color-scheme:dark){.otm-login{--otm-bg:#161412;--otm-card:#211e1b;--otm-fg:#f3efe9;--otm-muted:#a39b92;--otm-line:rgba(243,239,233,.12)}}
|
||||||
|
.otm-login *{box-sizing:border-box}
|
||||||
|
.otm-login__col{width:100%;max-width:26rem}
|
||||||
|
.otm-login__brand{text-align:center;margin-bottom:24px}
|
||||||
|
.otm-login__mark{width:56px;height:56px;border-radius:999px;background:var(--otm-accent);color:var(--otm-accent-ink);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:20px;letter-spacing:.02em;margin-bottom:12px}
|
||||||
|
.otm-login__mark svg,.otm-login__mark img{width:28px;height:28px}
|
||||||
|
.otm-login__name{font-family:var(--otm-font-display);font-size:30px;line-height:1.1;letter-spacing:-.01em;font-weight:600;margin:0}
|
||||||
|
.otm-login__tagline{color:var(--otm-muted);font-size:14px;margin:6px 0 0}
|
||||||
|
.otm-login__card{background:var(--otm-card);border:1px solid var(--otm-line);border-radius:var(--otm-radius);padding:24px;box-shadow:0 4px 24px rgba(0,0,0,.06)}
|
||||||
|
.otm-login__btn{display:flex;align-items:center;justify-content:center;width:100%;height:44px;border-radius:12px;border:0;background:var(--otm-accent);color:var(--otm-accent-ink);font:inherit;font-weight:600;font-size:15px;text-decoration:none;cursor:pointer}
|
||||||
|
.otm-login__btn:hover{filter:brightness(1.06)}
|
||||||
|
.otm-login__btn--outline{background:transparent;color:var(--otm-fg);border:1px solid var(--otm-line)}
|
||||||
|
.otm-login__btn--outline:hover{background:rgba(127,127,127,.06);filter:none}
|
||||||
|
.otm-login__btn[disabled]{opacity:.55;cursor:default}
|
||||||
|
.otm-login__helper{color:var(--otm-muted);font-size:14px;text-align:center;margin:12px 0 0;line-height:1.45}
|
||||||
|
.otm-login__error{color:#b3556b;font-size:14px;margin:0 0 12px;text-align:center}
|
||||||
|
.otm-login__link{display:inline-block;background:none;border:0;padding:0;font:inherit;font-size:14px;color:var(--otm-muted);text-decoration:underline;text-underline-offset:3px;cursor:pointer}
|
||||||
|
.otm-login__link:hover{color:var(--otm-fg)}
|
||||||
|
.otm-login__foot{text-align:center;margin-top:16px}
|
||||||
|
.otm-login__stack>*+*{margin-top:10px}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def login_page(next_path: str, reason: Optional[str], configured: bool) -> str:
|
||||||
|
msg = ""
|
||||||
|
if reason:
|
||||||
|
msg = f'<p class="otm-login__error" role="alert">{html_mod.escape(SSO_REASONS.get(reason, "Sign-in failed."))}</p>'
|
||||||
|
if not configured:
|
||||||
|
msg = '<p class="otm-login__error" role="alert">This 13ft isn\'t wired to OTM yet, so nobody can sign in. (Fails closed on purpose — it\'s a fetch proxy.)</p>'
|
||||||
|
# OTM-only app (no local accounts) — the "Sign in with OTM" button is the
|
||||||
|
# whole card, with the passkey helper line under it. There is no "Other
|
||||||
|
# ways to sign in": there is no other way.
|
||||||
|
card = msg + (
|
||||||
|
f'<a class="otm-login__btn" href="{html_mod.escape(otm_sign_in_url(next_path))}">Sign in with OTM</a>'
|
||||||
|
'<p class="otm-login__helper">Your OTM account — Face ID, Touch ID or a passkey works there.</p>'
|
||||||
|
if configured else ""
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||||
|
f'<title>13ft — sign in</title><style>{OTM_LOGIN_CSS}</style></head>'
|
||||||
|
'<body>'
|
||||||
|
'<div class="otm-login" style="--otm-accent:#b5562a;--otm-accent-ink:#fff">'
|
||||||
|
'<div class="otm-login__col">'
|
||||||
|
'<div class="otm-login__brand"><div class="otm-login__mark">13</div>'
|
||||||
|
'<h1 class="otm-login__name">13ft</h1><p class="otm-login__tagline">Read the article without the cruft.</p></div>'
|
||||||
|
f'<div class="otm-login__card">{card}</div>'
|
||||||
|
'</div></div>'
|
||||||
|
f'{tides_beacon()}</body></html>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def changelog_page(markdown_text: str) -> str:
|
||||||
|
return (
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||||
|
f'<title>13ft — changelog</title><style>:root{{color-scheme:light dark}}body{{font-family:system-ui,sans-serif;max-width:40rem;margin:6vh auto;padding:0 1rem;line-height:1.5}}pre{{white-space:pre-wrap}}</style></head>'
|
||||||
|
f'<body><p><a href="/">← 13ft</a></p><h1>Changelog</h1><pre>{html_mod.escape(markdown_text)}</pre>{tides_beacon()}</body></html>'
|
||||||
|
)
|
||||||
+341
-35
@@ -11,8 +11,110 @@ from urllib.parse import urljoin, urlparse, quote, unquote
|
|||||||
from flask import request, Response
|
from flask import request, Response
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
import otm
|
||||||
|
|
||||||
app = flask.Flask(__name__)
|
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 = {
|
_DEFAULT_STRINGS = {
|
||||||
"heading": "Enter Website Link",
|
"heading": "Enter Website Link",
|
||||||
"label": "Link of the website you want to remove paywall for:",
|
"label": "Link of the website you want to remove paywall for:",
|
||||||
@@ -86,6 +188,10 @@ googlebot_headers = {
|
|||||||
jobs = {}
|
jobs = {}
|
||||||
jobs_lock = threading.Lock()
|
jobs_lock = threading.Lock()
|
||||||
|
|
||||||
|
page_cache = {}
|
||||||
|
page_cache_lock = threading.Lock()
|
||||||
|
PAGE_CACHE_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
class UserFacingError(Exception):
|
class UserFacingError(Exception):
|
||||||
def __init__(self, user_message):
|
def __init__(self, user_message):
|
||||||
@@ -101,6 +207,7 @@ html = """
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<title>13ft Ladder</title>
|
<title>13ft Ladder</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" type="image/png">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600&display=swap" rel="stylesheet" async>
|
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600&display=swap" rel="stylesheet" async>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -162,35 +269,103 @@ html = """
|
|||||||
right: 10px;
|
right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark-mode-toggle input { display: none; }
|
.dark-mode-toggle input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.dark-mode-toggle label {
|
.dark-mode-toggle label {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-indent: -9999px;
|
width: 54px;
|
||||||
width: 52px;
|
height: 28px;
|
||||||
height: 27px;
|
background: #cbd5e1;
|
||||||
background: grey;
|
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
transition: background-color 0.3s, border-color 0.3s;
|
||||||
|
border: 1px solid #b8c2cc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark-mode-toggle label:after {
|
.dark-mode-toggle label:hover {
|
||||||
content: '';
|
background: #b8c2cc;
|
||||||
|
border-color: #a1b0be;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode-toggle input:focus-visible + label {
|
||||||
|
outline: 2px solid #a327f0;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .dark-mode-toggle input:focus-visible + label {
|
||||||
|
outline-color: #7359f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode-toggle .thumb {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
left: 2px;
|
left: 2px;
|
||||||
width: 23px;
|
width: 22px;
|
||||||
height: 23px;
|
height: 22px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 90px;
|
border-radius: 50%;
|
||||||
transition: 0.3s;
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.3s, box-shadow 0.3s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark-mode-toggle input:checked+label { background: #7359f8; }
|
.dark-mode-toggle input:checked+label {
|
||||||
.dark-mode-toggle input:checked+label:after {
|
background: #312e81;
|
||||||
left: calc(100% - 2px);
|
border-color: #4338ca;
|
||||||
transform: translateX(-100%);
|
}
|
||||||
|
|
||||||
|
.dark-mode-toggle input:checked+label:hover {
|
||||||
|
background: #2e2a78;
|
||||||
|
border-color: #3b379d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode-toggle input:checked+label .thumb {
|
||||||
|
transform: translateX(26px);
|
||||||
|
background: #1e1b4b;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 0 8px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode-toggle .icon {
|
||||||
|
position: absolute;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode-toggle .icon.sun {
|
||||||
|
color: #ea580c;
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode-toggle .icon.moon {
|
||||||
|
color: #4f46e5;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0) rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode-toggle input:checked+label .icon.sun {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0) rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode-toggle input:checked+label .icon.moon {
|
||||||
|
color: #c7d2fe;
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
@@ -324,7 +499,24 @@ html = """
|
|||||||
<body>
|
<body>
|
||||||
<div class="dark-mode-toggle">
|
<div class="dark-mode-toggle">
|
||||||
<input type="checkbox" id="dark-mode-toggle">
|
<input type="checkbox" id="dark-mode-toggle">
|
||||||
<label for="dark-mode-toggle" title="{{ toggle_dark_mode }}"></label>
|
<label for="dark-mode-toggle" title="{{ toggle_dark_mode }}">
|
||||||
|
<span class="thumb">
|
||||||
|
<svg class="icon sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="5"></circle>
|
||||||
|
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||||
|
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||||
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||||
|
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||||
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||||
|
</svg>
|
||||||
|
<svg class="icon moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="form-view">
|
<div id="form-view">
|
||||||
@@ -445,19 +637,15 @@ html = """
|
|||||||
const pct = Math.min(((idx + 1) / STEPS.length) * 100, 95);
|
const pct = Math.min(((idx + 1) / STEPS.length) * 100, 95);
|
||||||
progressEl.style.width = pct + '%';
|
progressEl.style.width = pct + '%';
|
||||||
}
|
}
|
||||||
});
|
if (data.redirect) {
|
||||||
|
evtSource.close();
|
||||||
evtSource.addEventListener('done', function(e) {
|
clearInterval(timer);
|
||||||
evtSource.close();
|
renderSteps(STEPS.length);
|
||||||
clearInterval(timer);
|
progressEl.style.width = '100%';
|
||||||
renderSteps(STEPS.length);
|
setTimeout(() => {
|
||||||
progressEl.style.width = '100%';
|
window.location.href = data.redirect;
|
||||||
const data = JSON.parse(e.data);
|
}, 400);
|
||||||
setTimeout(() => {
|
}
|
||||||
document.open();
|
|
||||||
document.write(data.html);
|
|
||||||
document.close();
|
|
||||||
}, 400);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
evtSource.addEventListener('error_msg', function(e) {
|
evtSource.addEventListener('error_msg', function(e) {
|
||||||
@@ -473,6 +661,14 @@ html = """
|
|||||||
showError(UI_STRINGS.connection_lost_error);
|
showError(UI_STRINGS.connection_lost_error);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('pageshow', function(event) {
|
||||||
|
if (event.persisted) {
|
||||||
|
document.getElementById('form-view').style.display = '';
|
||||||
|
document.getElementById('status-view').style.display = 'none';
|
||||||
|
document.getElementById('link').value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
@@ -502,12 +698,18 @@ def render_main_page():
|
|||||||
"elapsed_template": strings["elapsed_template"],
|
"elapsed_template": strings["elapsed_template"],
|
||||||
"connection_lost_error": strings["connection_lost_error"],
|
"connection_lost_error": strings["connection_lost_error"],
|
||||||
}
|
}
|
||||||
return flask.render_template_string(
|
rendered = flask.render_template_string(
|
||||||
html,
|
html,
|
||||||
steps=build_steps(strings),
|
steps=build_steps(strings),
|
||||||
ui_strings=ui_strings,
|
ui_strings=ui_strings,
|
||||||
**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):
|
def process_html_document(html_content, original_url):
|
||||||
@@ -524,7 +726,11 @@ def process_html_document(html_content, original_url):
|
|||||||
base_url = urljoin(base_url, parsed_url.path.rsplit("/", 1)[0] + "/")
|
base_url = urljoin(base_url, parsed_url.path.rsplit("/", 1)[0] + "/")
|
||||||
|
|
||||||
base_tag = soup.find("base")
|
base_tag = soup.find("base")
|
||||||
if not base_tag:
|
if base_tag:
|
||||||
|
# Always overwrite — sites may have <base href="/"> which would resolve
|
||||||
|
# root-relative asset paths to the 13ft server instead of the original domain.
|
||||||
|
base_tag["href"] = base_url
|
||||||
|
else:
|
||||||
new_base_tag = soup.new_tag("base", href=base_url)
|
new_base_tag = soup.new_tag("base", href=base_url)
|
||||||
if soup.head:
|
if soup.head:
|
||||||
soup.head.insert(0, new_base_tag)
|
soup.head.insert(0, new_base_tag)
|
||||||
@@ -535,6 +741,20 @@ def process_html_document(html_content, original_url):
|
|||||||
|
|
||||||
domain = parsed_url.netloc.lower()
|
domain = parsed_url.netloc.lower()
|
||||||
|
|
||||||
|
# --- GENERAL FIXES ---
|
||||||
|
|
||||||
|
# Strip anti-hotlinking, anti-embedding, and frame-busting scripts.
|
||||||
|
for script in soup.find_all("script", src=False):
|
||||||
|
content = script.get_text()
|
||||||
|
|
||||||
|
# Target the specific hostname check
|
||||||
|
if "location.hostname" in content and ("location.href" in content or "location.replace" in content):
|
||||||
|
script.decompose()
|
||||||
|
|
||||||
|
# Optional: Catch common frame-busting scripts if you are viewing this via an iframe
|
||||||
|
elif "top.location" in content or "window.top" in content:
|
||||||
|
script.decompose()
|
||||||
|
|
||||||
# --- SITE SPECIFIC FIXES ---
|
# --- SITE SPECIFIC FIXES ---
|
||||||
|
|
||||||
# The Seattle Times
|
# The Seattle Times
|
||||||
@@ -550,9 +770,24 @@ def process_html_document(html_content, original_url):
|
|||||||
current_style = soup.body.get("style", "")
|
current_style = soup.body.get("style", "")
|
||||||
soup.body["style"] = f"{current_style}; overflow: auto !important; position: static !important;"
|
soup.body["style"] = f"{current_style}; overflow: auto !important; position: static !important;"
|
||||||
|
|
||||||
|
# --- FAVICON OVERRIDE ---
|
||||||
|
# Remove any existing favicon links from the source page and inject the
|
||||||
|
# 13ft favicon so the browser tab always shows the 13ft logo.
|
||||||
|
for link_tag in soup.find_all("link", rel=lambda r: r and any(
|
||||||
|
v.lower() in ("icon", "shortcut icon", "apple-touch-icon",
|
||||||
|
"apple-touch-icon-precomposed", "mask-icon")
|
||||||
|
for v in (r if isinstance(r, list) else [r])
|
||||||
|
)):
|
||||||
|
link_tag.decompose()
|
||||||
|
|
||||||
|
favicon_tag = soup.new_tag("link", rel="icon", href="/favicon.ico", type="image/png")
|
||||||
|
if soup.head:
|
||||||
|
soup.head.append(favicon_tag)
|
||||||
|
|
||||||
return str(soup)
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
CHALLENGE_SIGNATURES = [
|
CHALLENGE_SIGNATURES = [
|
||||||
"just a moment",
|
"just a moment",
|
||||||
"checking your browser",
|
"checking your browser",
|
||||||
@@ -562,6 +797,15 @@ CHALLENGE_SIGNATURES = [
|
|||||||
"attention required",
|
"attention required",
|
||||||
"enable javascript and cookies to continue",
|
"enable javascript and cookies to continue",
|
||||||
"please verify you are a human",
|
"please verify you are a human",
|
||||||
|
# Imperva / Incapsula block pages (e.g. bizjournals.com). These are also
|
||||||
|
# captured by archive.org, so an archived snapshot can silently be a block
|
||||||
|
# page rather than the article — detect it and fall through to an error.
|
||||||
|
"_incapsula_resource",
|
||||||
|
"incapsula incident",
|
||||||
|
"request unsuccessful",
|
||||||
|
# DataDome block pages (e.g. nytimes.com, ft.com, wsj.com).
|
||||||
|
"datadome",
|
||||||
|
"enable js and disable any ad blocker",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -590,6 +834,25 @@ ARCHIVE_PH_MIRRORS = ["archive.ph", "archive.today", "archive.is", "archive.li"]
|
|||||||
|
|
||||||
FREEDIUM_MIRRORS = ["freedium-mirror.cfd", "freedium.cfd"]
|
FREEDIUM_MIRRORS = ["freedium-mirror.cfd", "freedium.cfd"]
|
||||||
|
|
||||||
|
# The original Freedium layout wrapped the post in <div id="main-content">, but
|
||||||
|
# the current mirror renders it inside a <main> element instead. Its error pages
|
||||||
|
# (e.g. "Article not found") still ship an empty <article> skeleton, so <article>
|
||||||
|
# on its own is not proof that the post was rendered.
|
||||||
|
FREEDIUM_CONTENT_MARKERS = ("main-content", "<main")
|
||||||
|
|
||||||
|
# SvelteKit streams the failure into the page body instead of using a non-200
|
||||||
|
# status, so the machine-readable error code is what marks a miss.
|
||||||
|
FREEDIUM_ERROR_MARKERS = ("ARTICLE_NOT_FOUND",)
|
||||||
|
|
||||||
|
|
||||||
|
def freedium_has_article(html_text):
|
||||||
|
"""True when a Freedium response actually contains the rendered post."""
|
||||||
|
if not html_text or len(html_text) < 2000:
|
||||||
|
return False
|
||||||
|
if any(marker in html_text for marker in FREEDIUM_ERROR_MARKERS):
|
||||||
|
return False
|
||||||
|
return any(marker in html_text for marker in FREEDIUM_CONTENT_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
def is_medium_url(url):
|
def is_medium_url(url):
|
||||||
try:
|
try:
|
||||||
@@ -618,7 +881,7 @@ def fetch_via_freedium(url, job_id=None):
|
|||||||
text = resp.text
|
text = resp.text
|
||||||
if is_challenge_page(text):
|
if is_challenge_page(text):
|
||||||
continue
|
continue
|
||||||
if 'main-content' not in text or len(text) < 2000:
|
if not freedium_has_article(text):
|
||||||
continue
|
continue
|
||||||
return text, resp.url
|
return text, resp.url
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -709,6 +972,12 @@ def bypass_paywall(url, strings, job_id=None):
|
|||||||
html_text = response.text
|
html_text = response.text
|
||||||
final_url = response.url
|
final_url = response.url
|
||||||
|
|
||||||
|
# Treat error status codes as a challenge so the archive fallbacks trigger.
|
||||||
|
# Sites like News24 return 403 with their homepage HTML (which contains a
|
||||||
|
# JS redirect back to the homepage), causing the browser to redirect away.
|
||||||
|
if response.status_code in (401, 403, 407, 429) or response.status_code >= 500:
|
||||||
|
html_text = ""
|
||||||
|
|
||||||
set_step(job_id, 'detect')
|
set_step(job_id, 'detect')
|
||||||
if not html_text or is_challenge_page(html_text):
|
if not html_text or is_challenge_page(html_text):
|
||||||
recovered = False
|
recovered = False
|
||||||
@@ -740,11 +1009,32 @@ def bypass_paywall(url, strings, job_id=None):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_cache_url(url):
|
||||||
|
if "://" in url:
|
||||||
|
return "https://" + url.split("://", 1)[1]
|
||||||
|
return "https://" + url
|
||||||
|
|
||||||
|
|
||||||
|
def clean_page_cache():
|
||||||
|
now = time.time()
|
||||||
|
expired = [k for k, v in page_cache.items() if now - v['timestamp'] > PAGE_CACHE_TTL]
|
||||||
|
for k in expired:
|
||||||
|
del page_cache[k]
|
||||||
|
|
||||||
|
|
||||||
def fetch_worker(job_id, url, strings):
|
def fetch_worker(job_id, url, strings):
|
||||||
try:
|
try:
|
||||||
result = bypass_paywall(url, strings, job_id)
|
result = bypass_paywall(url, strings, job_id)
|
||||||
|
# Never cache challenge/error pages — only store clean article HTML.
|
||||||
|
if not is_challenge_page(result):
|
||||||
|
cache_key = normalize_cache_url(url)
|
||||||
|
with page_cache_lock:
|
||||||
|
clean_page_cache()
|
||||||
|
page_cache[cache_key] = {'html': result, 'timestamp': time.time()}
|
||||||
|
else:
|
||||||
|
cache_key = normalize_cache_url(url)
|
||||||
with jobs_lock:
|
with jobs_lock:
|
||||||
jobs[job_id]['result'] = result
|
jobs[job_id]['result'] = cache_key
|
||||||
jobs[job_id]['step'] = 'done'
|
jobs[job_id]['step'] = 'done'
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
with jobs_lock:
|
with jobs_lock:
|
||||||
@@ -768,6 +1058,17 @@ def fetch_worker(job_id, url, strings):
|
|||||||
jobs[job_id]['step'] = 'error'
|
jobs[job_id]['step'] = 'error'
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/favicon.ico")
|
||||||
|
def favicon():
|
||||||
|
logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "logo.png")
|
||||||
|
if os.path.exists(logo_path):
|
||||||
|
with open(logo_path, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
return Response(data, mimetype="image/png",
|
||||||
|
headers={"Cache-Control": "public, max-age=86400"})
|
||||||
|
return "", 404
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def main_page():
|
def main_page():
|
||||||
return render_main_page()
|
return render_main_page()
|
||||||
@@ -803,8 +1104,7 @@ def status_stream():
|
|||||||
if current != last_step:
|
if current != last_step:
|
||||||
last_step = current
|
last_step = current
|
||||||
if current == 'done':
|
if current == 'done':
|
||||||
yield f"event: step\ndata: {json.dumps({'step': 'done'})}\n\n"
|
yield f"event: step\ndata: {json.dumps({'step': 'done', 'redirect': '/' + job['result']})}\n\n"
|
||||||
yield f"event: done\ndata: {json.dumps({'html': job['result']})}\n\n"
|
|
||||||
break
|
break
|
||||||
elif current == 'error':
|
elif current == 'error':
|
||||||
yield f"event: error_msg\ndata: {json.dumps({'message': job['error']})}\n\n"
|
yield f"event: error_msg\ndata: {json.dumps({'message': job['error']})}\n\n"
|
||||||
@@ -847,6 +1147,12 @@ def get_article(path):
|
|||||||
parts = full_url.split("/", 4)
|
parts = full_url.split("/", 4)
|
||||||
if len(parts) >= 5:
|
if len(parts) >= 5:
|
||||||
actual_url = "https://" + parts[4].lstrip("/")
|
actual_url = "https://" + parts[4].lstrip("/")
|
||||||
|
|
||||||
|
with page_cache_lock:
|
||||||
|
cached = page_cache.get(actual_url)
|
||||||
|
if cached:
|
||||||
|
return cached['html']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return bypass_paywall(actual_url, strings)
|
return bypass_paywall(actual_url, strings)
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""End-to-end through Flask's test client: the gate, the SSO callback, replay,
|
||||||
|
health. Run with the venv python: `cd app && ../.venv/bin/python -m unittest`."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
os.environ["OTM_SSO_SECRET"] = "sso-secret"
|
||||||
|
os.environ["AUTH_SECRET"] = "auth-secret"
|
||||||
|
|
||||||
|
import portable # noqa: E402 (env must be set before import? no — read per request; set anyway)
|
||||||
|
import otm # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class Gate(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.c = portable.app.test_client()
|
||||||
|
|
||||||
|
def test_health_is_public(self):
|
||||||
|
r = self.c.get("/api/health")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.get_json()["version"], otm.APP_VERSION)
|
||||||
|
|
||||||
|
def test_anonymous_is_bounced_to_login(self):
|
||||||
|
r = self.c.get("/")
|
||||||
|
self.assertEqual(r.status_code, 302)
|
||||||
|
self.assertTrue(r.headers["Location"].startswith("/login?next=%2F"))
|
||||||
|
r = self.c.get("/https://example.com/article")
|
||||||
|
self.assertEqual(r.status_code, 302)
|
||||||
|
self.assertIn("/login", r.headers["Location"])
|
||||||
|
r = self.c.post("/article", data={"link": "https://example.com"})
|
||||||
|
self.assertEqual(r.status_code, 401)
|
||||||
|
|
||||||
|
def test_login_page_offers_otm(self):
|
||||||
|
r = self.c.get("/login?next=/x")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertIn(b"sso/authorize?aud=app%3A13ft&next=%2Fx", r.data)
|
||||||
|
|
||||||
|
def test_ticket_signs_in_once(self):
|
||||||
|
t = otm.mint_sso_ticket("sso-secret", {"aud": "app:13ft", "sub": "u1", "email": "tony@example.com", "role": "admin", "next": "/"})
|
||||||
|
r = self.c.get(f"/api/auth/otm-sso?token={t}")
|
||||||
|
self.assertEqual(r.status_code, 302)
|
||||||
|
self.assertEqual(r.headers["Location"], "/")
|
||||||
|
self.assertIn(otm.SESSION_COOKIE, r.headers.get("Set-Cookie", ""))
|
||||||
|
# Signed in: the home page renders, with the OTM footer.
|
||||||
|
r = self.c.get("/")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertIn(b"Powered by OTM", r.data)
|
||||||
|
self.assertIn(b"tony@example.com", r.data)
|
||||||
|
# Replay of the same ticket is refused.
|
||||||
|
fresh = portable.app.test_client()
|
||||||
|
r = fresh.get(f"/api/auth/otm-sso?token={t}")
|
||||||
|
self.assertEqual(r.headers["Location"], "/login?reason=link_already_used")
|
||||||
|
|
||||||
|
def test_bad_ticket(self):
|
||||||
|
r = self.c.get("/api/auth/otm-sso?token=nope")
|
||||||
|
self.assertEqual(r.headers["Location"], "/login?reason=invalid_token")
|
||||||
|
wrong = otm.mint_sso_ticket("sso-secret", {"aud": "app:qr", "sub": "u1", "email": "e@x", "role": "admin"})
|
||||||
|
r = self.c.get(f"/api/auth/otm-sso?token={wrong}")
|
||||||
|
self.assertEqual(r.headers["Location"], "/login?reason=invalid_token")
|
||||||
|
|
||||||
|
def test_logout_clears(self):
|
||||||
|
t = otm.mint_sso_ticket("sso-secret", {"aud": "app:13ft", "sub": "u1", "email": "e@x.io", "role": "admin"})
|
||||||
|
self.c.get(f"/api/auth/otm-sso?token={t}")
|
||||||
|
self.assertEqual(self.c.get("/").status_code, 200)
|
||||||
|
self.c.get("/logout")
|
||||||
|
self.assertEqual(self.c.get("/").status_code, 302)
|
||||||
|
|
||||||
|
|
||||||
|
class FailClosed(unittest.TestCase):
|
||||||
|
def test_no_secret_means_503_everywhere(self):
|
||||||
|
saved = {k: os.environ.pop(k, None) for k in ("OTM_SSO_SECRET", "AUTH_SECRET")}
|
||||||
|
try:
|
||||||
|
c = portable.app.test_client()
|
||||||
|
self.assertEqual(c.get("/").status_code, 503)
|
||||||
|
self.assertEqual(c.get("/https://example.com").status_code, 503)
|
||||||
|
self.assertEqual(c.get("/api/health").status_code, 200)
|
||||||
|
self.assertIn(b"isn't wired to OTM", c.get("/login").data)
|
||||||
|
finally:
|
||||||
|
for k, v in saved.items():
|
||||||
|
if v is not None:
|
||||||
|
os.environ[k] = v
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""python3 -m unittest app/test_otm.py (or `cd app && python -m unittest`)."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from otm import (
|
||||||
|
JtiGuard,
|
||||||
|
decode_session,
|
||||||
|
encode_session,
|
||||||
|
mint_sso_ticket,
|
||||||
|
otm_sign_in_url,
|
||||||
|
safe_next,
|
||||||
|
verify_sso_ticket,
|
||||||
|
)
|
||||||
|
|
||||||
|
SECRET = "test-secret"
|
||||||
|
CLAIMS = {"aud": "app:13ft", "sub": "u1", "email": "tony@example.com", "role": "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
class Tickets(unittest.TestCase):
|
||||||
|
def test_round_trip(self):
|
||||||
|
t = mint_sso_ticket(SECRET, CLAIMS, now_ms=1_000_000)
|
||||||
|
p = verify_sso_ticket(SECRET, t, expected_aud="app:13ft", now_ms=1_030_000)
|
||||||
|
self.assertIsNotNone(p)
|
||||||
|
self.assertEqual(p["email"], "tony@example.com")
|
||||||
|
self.assertTrue(p["jti"])
|
||||||
|
|
||||||
|
def test_expired(self):
|
||||||
|
t = mint_sso_ticket(SECRET, CLAIMS, ttl_sec=60, now_ms=1_000_000)
|
||||||
|
self.assertIsNone(verify_sso_ticket(SECRET, t, expected_aud="app:13ft", now_ms=1_060_000))
|
||||||
|
|
||||||
|
def test_wrong_secret_and_wrong_audience(self):
|
||||||
|
t = mint_sso_ticket(SECRET, CLAIMS)
|
||||||
|
self.assertIsNone(verify_sso_ticket("other", t, expected_aud="app:13ft"))
|
||||||
|
self.assertIsNone(verify_sso_ticket(SECRET, t, expected_aud="app:qr"))
|
||||||
|
|
||||||
|
def test_tampered_payload_and_junk(self):
|
||||||
|
t = mint_sso_ticket(SECRET, CLAIMS)
|
||||||
|
h, p, s = t.split(".")
|
||||||
|
self.assertIsNone(verify_sso_ticket(SECRET, f"{h}.{p}x.{s}", expected_aud="app:13ft"))
|
||||||
|
for junk in ("", "a.b", "a.b.c", None, 42, "....."):
|
||||||
|
self.assertIsNone(verify_sso_ticket(SECRET, junk, expected_aud="app:13ft"))
|
||||||
|
|
||||||
|
def test_missing_exp_or_claims_rejected(self):
|
||||||
|
import base64, json
|
||||||
|
def b64(o):
|
||||||
|
return base64.urlsafe_b64encode(json.dumps(o).encode()).decode().rstrip("=")
|
||||||
|
from otm import _sign
|
||||||
|
h = b64({"alg": "HS256"})
|
||||||
|
for payload in ({**CLAIMS, "jti": "x"}, {**CLAIMS, "jti": "x", "exp": 9e12, "email": ""}):
|
||||||
|
p = b64(payload)
|
||||||
|
tok = f"{h}.{p}.{_sign(SECRET, f'{h}.{p}')}"
|
||||||
|
self.assertIsNone(verify_sso_ticket(SECRET, tok, expected_aud="app:13ft"))
|
||||||
|
|
||||||
|
def test_alg_none_rejected(self):
|
||||||
|
import base64, json
|
||||||
|
h = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).decode().rstrip("=")
|
||||||
|
p = base64.urlsafe_b64encode(json.dumps({**CLAIMS, "jti": "x", "exp": 9e12}).encode()).decode().rstrip("=")
|
||||||
|
self.assertIsNone(verify_sso_ticket(SECRET, f"{h}.{p}.", expected_aud="app:13ft"))
|
||||||
|
|
||||||
|
|
||||||
|
class Guard(unittest.TestCase):
|
||||||
|
def test_single_use(self):
|
||||||
|
g = JtiGuard(ttl_sec=600)
|
||||||
|
self.assertTrue(g.claim("a", now=0))
|
||||||
|
self.assertFalse(g.claim("a", now=10))
|
||||||
|
self.assertTrue(g.claim("a", now=700))
|
||||||
|
|
||||||
|
|
||||||
|
class Sessions(unittest.TestCase):
|
||||||
|
def test_round_trip_and_expiry(self):
|
||||||
|
s = {"sub": "u1", "email": "tony@example.com", "exp": 2000}
|
||||||
|
v = encode_session(SECRET, s)
|
||||||
|
self.assertEqual(decode_session(SECRET, v, now_ms=1_000_000)["email"], "tony@example.com")
|
||||||
|
self.assertIsNone(decode_session(SECRET, v, now_ms=2_000_000))
|
||||||
|
self.assertIsNone(decode_session("other", v, now_ms=1_000_000))
|
||||||
|
self.assertIsNone(decode_session(SECRET, v + "x", now_ms=1_000_000))
|
||||||
|
self.assertIsNone(decode_session(SECRET, "", now_ms=1_000_000))
|
||||||
|
|
||||||
|
|
||||||
|
class Paths(unittest.TestCase):
|
||||||
|
def test_safe_next(self):
|
||||||
|
self.assertEqual(safe_next("/x?y=1"), "/x?y=1")
|
||||||
|
self.assertEqual(safe_next("//evil.com"), "/")
|
||||||
|
self.assertEqual(safe_next("/\\evil.com"), "/")
|
||||||
|
self.assertEqual(safe_next("https://evil.com"), "/")
|
||||||
|
self.assertEqual(safe_next(None), "/")
|
||||||
|
|
||||||
|
def test_sign_in_url(self):
|
||||||
|
self.assertIn("aud=app%3A13ft", otm_sign_in_url("/foo"))
|
||||||
|
self.assertIn("next=%2Ffoo", otm_sign_in_url("/foo"))
|
||||||
|
self.assertIn("next=%2F", otm_sign_in_url("//evil"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user