diff options
| author | Ben Sima <ben@bsima.me> | 2025-11-15 20:26:35 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bsima.me> | 2025-11-15 20:26:35 -0500 |
| commit | 803f82595f307b66e5bc195a02d38effd0a60b3a (patch) | |
| tree | a8b381573567b452a82f2574215a8ffa8f6c8f04 /Biz/PodcastItLater | |
| parent | 8c941c20542c65e7db5d81a3244232620b3c9809 (diff) | |
Use sqids for non-sequential episode URLs
Replace sequential integer IDs with sqids in episode URLs for better
privacy and security. Episode IDs are no longer easily guessable.
- Add sqids dependency to Web.py - Create encode_episode_id() and
decode_episode_id() helper functions - Update /episode/{episode_sqid}
route to accept and decode sqids - Update EpisodeList to generate
sqid-based links - Update RSS feed to use sqids in episode URLs -
Update EpisodeDetailPage to accept and use sqids for share URLs -
Update all tests to use sqids
Episode URLs now look like /episode/AbCd1234 instead of /episode/1
Database still uses integer IDs internally for efficiency.
Amp-Thread-ID:
https://ampcode.com/threads/T-cc5d29f0-454e-4864-8d7e-1ad69a42afa9
Co-authored-by: Amp <amp@ampcode.com>
Diffstat (limited to 'Biz/PodcastItLater')
| -rw-r--r-- | Biz/PodcastItLater/Episode.py | 4 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 55 |
2 files changed, 45 insertions, 14 deletions
diff --git a/Biz/PodcastItLater/Episode.py b/Biz/PodcastItLater/Episode.py index a070d19..a06b8d9 100644 --- a/Biz/PodcastItLater/Episode.py +++ b/Biz/PodcastItLater/Episode.py @@ -192,6 +192,7 @@ class EpisodeDetailPageAttrs(Attrs): """Attributes for EpisodeDetailPage component.""" episode: dict[str, typing.Any] + episode_sqid: str creator_email: str | None user: dict[str, typing.Any] | None base_url: str @@ -203,11 +204,12 @@ class EpisodeDetailPage(Component[AnyChildren, EpisodeDetailPageAttrs]): @override def render(self) -> UI.PageLayout: episode = self.attrs["episode"] + episode_sqid = self.attrs["episode_sqid"] creator_email = self.attrs.get("creator_email") user = self.attrs.get("user") base_url = self.attrs["base_url"] - share_url = f"{base_url}/episode/{episode['id']}" + share_url = f"{base_url}/episode/{episode_sqid}" duration_str = UI.format_duration(episode.get("duration")) # Build page title diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index f63046c..12c45c4 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -16,6 +16,7 @@ Provides ludic + htmx interface and RSS feed generation. # : dep pytest-mock # : dep starlette # : dep stripe +# : dep sqids import Biz.EmailAgent import Biz.PodcastItLater.Admin as Admin import Biz.PodcastItLater.Billing as Billing @@ -48,6 +49,7 @@ from ludic.web import LudicApp from ludic.web import Request from ludic.web.datastructures import FormData from ludic.web.responses import Response +from sqids import Sqids from starlette.middleware.sessions import SessionMiddleware from starlette.responses import RedirectResponse from starlette.testclient import TestClient @@ -62,6 +64,24 @@ area = App.from_env() BASE_URL = os.getenv("BASE_URL", "http://localhost:8000") PORT = int(os.getenv("PORT", "8000")) +# Initialize sqids for episode URL encoding +sqids = Sqids(min_length=8) + + +def encode_episode_id(episode_id: int) -> str: + """Encode episode ID to sqid for URLs.""" + return sqids.encode([episode_id]) + + +def decode_episode_id(sqid: str) -> int | None: + """Decode sqid to episode ID. Returns None if invalid.""" + try: + decoded = sqids.decode(sqid) + return decoded[0] if decoded else None + except (ValueError, IndexError): + return None + + # Authentication configuration MAGIC_LINK_MAX_AGE = 3600 # 1 hour SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days @@ -518,13 +538,14 @@ class EpisodeList(Component[AnyChildren, EpisodeListAttrs]): episode_items = [] for episode in episodes: duration_str = UI.format_duration(episode.get("duration")) + episode_sqid = encode_episode_id(episode["id"]) episode_items.append( html.div( html.div( html.h5( html.a( episode["title"], - href=f"/episode/{episode['id']}", + href=f"/episode/{episode_sqid}", classes=["text-decoration-none"], ), classes=["card-title", "mb-2"], @@ -1238,9 +1259,10 @@ def rss_feed(request: Request, token: str) -> Response: # noqa: ARG001 for episode in episodes: fe = fg.add_entry() - fe.id(f"{RSS_CONFIG['base_url']}/episode/{episode['id']}") + episode_sqid = encode_episode_id(episode["id"]) + fe.id(f"{RSS_CONFIG['base_url']}/episode/{episode_sqid}") fe.title(episode["title"]) - fe.description(f"Episode {episode['id']}: {episode['title']}") + fe.description(episode["title"]) fe.enclosure( episode["audio_url"], str(episode.get("content_length", 0)), @@ -1262,13 +1284,18 @@ def rss_feed(request: Request, token: str) -> Response: # noqa: ARG001 return Response(f"Error generating feed: {e}", status_code=500) -@app.get("/episode/{episode_id}") +@app.get("/episode/{episode_sqid}") def episode_detail( request: Request, - episode_id: int, + episode_sqid: str, ) -> Episode.EpisodeDetailPage | Response: """Display individual episode page (public, no auth required).""" try: + # Decode sqid to episode ID + episode_id = decode_episode_id(episode_sqid) + if episode_id is None: + return Response("Invalid episode ID", status_code=404) + # Get episode from database episode = Core.Database.get_episode_by_id(episode_id) @@ -1289,6 +1316,7 @@ def episode_detail( return Episode.EpisodeDetailPage( episode=episode, + episode_sqid=episode_sqid, creator_email=creator_email, user=user, base_url=BASE_URL, @@ -2031,10 +2059,11 @@ class TestEpisodeDetailPage(BaseWebTest): author="Test Author", original_url="https://example.com/article", ) + self.episode_sqid = encode_episode_id(self.episode_id) def test_episode_page_loads(self) -> None: """Episode page should load successfully.""" - response = self.client.get(f"/episode/{self.episode_id}") + response = self.client.get(f"/episode/{self.episode_sqid}") self.assertEqual(response.status_code, 200) self.assertIn("Test Episode", response.text) @@ -2042,13 +2071,13 @@ class TestEpisodeDetailPage(BaseWebTest): def test_episode_not_found(self) -> None: """Non-existent episode should return 404.""" - response = self.client.get("/episode/99999") + response = self.client.get("/episode/invalidcode") self.assertEqual(response.status_code, 404) def test_audio_player_present(self) -> None: """Audio player should be present on episode page.""" - response = self.client.get(f"/episode/{self.episode_id}") + response = self.client.get(f"/episode/{self.episode_sqid}") self.assertIn("<audio", response.text) self.assertIn("controls", response.text) @@ -2056,21 +2085,21 @@ class TestEpisodeDetailPage(BaseWebTest): def test_share_button_present(self) -> None: """Share button should be present.""" - response = self.client.get(f"/episode/{self.episode_id}") + response = self.client.get(f"/episode/{self.episode_sqid}") self.assertIn("Share Episode", response.text) self.assertIn("navigator.clipboard.writeText", response.text) def test_original_article_link(self) -> None: """Original article link should be present.""" - response = self.client.get(f"/episode/{self.episode_id}") + response = self.client.get(f"/episode/{self.episode_sqid}") self.assertIn("View original article", response.text) self.assertIn("https://example.com/article", response.text) def test_signup_banner_for_non_authenticated(self) -> None: """Non-authenticated users should see signup banner.""" - response = self.client.get(f"/episode/{self.episode_id}") + response = self.client.get(f"/episode/{self.episode_sqid}") self.assertIn("This episode was created by", response.text) self.assertIn("creator@example.com", response.text) @@ -2081,7 +2110,7 @@ class TestEpisodeDetailPage(BaseWebTest): # Login self.client.post("/login", data={"email": "creator@example.com"}) - response = self.client.get(f"/episode/{self.episode_id}") + response = self.client.get(f"/episode/{self.episode_sqid}") self.assertNotIn("This episode was created by", response.text) @@ -2092,7 +2121,7 @@ class TestEpisodeDetailPage(BaseWebTest): response = self.client.get("/") - self.assertIn(f'href="/episode/{self.episode_id}"', response.text) + self.assertIn(f'href="/episode/{self.episode_sqid}"', response.text) self.assertIn("Test Episode", response.text) |
