summaryrefslogtreecommitdiff
path: root/Biz/PodcastItLater/Web.py
diff options
context:
space:
mode:
Diffstat (limited to 'Biz/PodcastItLater/Web.py')
-rw-r--r--Biz/PodcastItLater/Web.py55
1 files changed, 42 insertions, 13 deletions
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)