summaryrefslogtreecommitdiff
path: root/Biz
diff options
context:
space:
mode:
Diffstat (limited to 'Biz')
-rw-r--r--Biz/PodcastItLater/Episode.py4
-rw-r--r--Biz/PodcastItLater/Web.py55
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)