[Bug][Angular][Backend] Lesson-video and geography-media URLs are root-relative, so the browser asks nginx (learn.spikersoft.com) — which serves index.html with a 200 into the <video> element, never the media #599

Closed
opened 2026-07-14 21:57:07 +00:00 by spikerj · 1 comment
Owner

Found while verifying #528. Verified against production with live HTTP probes, not inferred.

The bug

The backend mints these media URLs as root-relative strings and persists them:

  • LessonVideoProcessor/Services/LessonVideoMoveConsumer.cs:132-133/lesson-videos/{username}/{name}.mp4 (+ .jpg thumbnail). No config override exists — always root-relative.
  • Business/Domain/Geography/Commands/UploadGeographyMedia/UploadGeographyMediaCommandHandler.cs:51/geography-media/{uniqueName}.

Angular binds them raw, with no API base prepended:

  • libraries/platform/clang-runtime/src/lib/playground-videos-pane.component.html:18<video [src]="video.url">
  • projects/spikersoft/src/app/_components/admin/lesson-video-moderation/lesson-video-moderation.component.html:44
  • projects/spikersoft/src/app/_components/learning/geography-explorer/country-detail/country-detail.component.html:71,77<img/video [src]="currentMedia.url">

The SPA is served from learn.spikersoft.com, so a root-relative URL resolves against nginx, not api.spikersoft.com. But nginx's root is /usr/share/nginx/html while the media tree is bind-mounted at html/spikersoft/ (spikersoft-angular/docker-stack.yml:10), and nginx.config has no /lesson-videos or /geography-media location — only /spikersoft/* and a try_files $uri $uri/ /index.html catch-all.

Proof (live, today)

GET https://learn.spikersoft.com/lesson-videos/probe.mp4
  -> HTTP 200   content-type: text/html   59598 bytes      <-- the Angular index.html
GET https://learn.spikersoft.com/geography-media/probe.jpg
  -> HTTP 404
GET https://api.spikersoft.com/lesson-videos/probe.mp4
  -> HTTP 404   (the API DOES mount this route — nothing ever addresses it)

The lesson-video case is the nasty one: HTTP 200 with text/html. The <video> element is handed the SPA shell as its media payload. It fails silently — no console 404, no network error, just a player that never plays.

Impact

  • Lesson videos: latent. There are currently no approved lesson videos in production (/Lessons/{n}/videos returns empty), so nobody has hit it yet. It breaks the instant the first video is approved. Note lesson-video-processor has also never deployed (#581) — these two will surface together.
  • Geography media: hard 404 for any /geography-media/... URL loaded by the SPA.

The API's own /lesson-videos and /geography-media static mounts (and the #528 S3 fallback behind them) are correct and working — they are simply never addressed, because no consumer ever makes the URL absolute against the API origin.

Fix options

  1. Prepend the API base in Angular (${environment.baseUrl}${url}) — this is what blog media already does in blog-entry-component.ts:108-118, so there is an established pattern. Cheapest, no data migration.
  2. Mint absolute URLs backend-side, as BlogMediaProcessor does in prod (BlogMedia:PublicUrlPrefix) — but this bakes an origin into Mongo rows and needs a backfill for existing data.
  3. Add nginx location blocks — rejected: it duplicates the serving topology the #413 epic is actively trying to collapse.

Option 1 is recommended, and it composes cleanly with #528 (the API already serves both routes correctly, from disk today and from S3 after cutover).

Acceptance

  • <video> / <img> for lesson videos and geography media resolve against the API origin.
  • A test pinning that a media URL is never left root-relative for these two families.
  • e2e/route-walk coverage that a media response is not text/html.

Related: #528 (API media serving), #531 (nginx root bind), #581 (lesson-video-processor never deployed). This is the discrepancy README-AUDIT-QUESTIONS.md:250 was chasing: "Who serves /lesson-videos in production today?"Answer: nobody.

Found while verifying #528. **Verified against production with live HTTP probes**, not inferred. ## The bug The backend mints these media URLs as **root-relative** strings and persists them: - `LessonVideoProcessor/Services/LessonVideoMoveConsumer.cs:132-133` → `/lesson-videos/{username}/{name}.mp4` (+ `.jpg` thumbnail). **No config override exists** — always root-relative. - `Business/Domain/Geography/Commands/UploadGeographyMedia/UploadGeographyMediaCommandHandler.cs:51` → `/geography-media/{uniqueName}`. Angular binds them **raw**, with no API base prepended: - `libraries/platform/clang-runtime/src/lib/playground-videos-pane.component.html:18` → `<video [src]="video.url">` - `projects/spikersoft/src/app/_components/admin/lesson-video-moderation/lesson-video-moderation.component.html:44` - `projects/spikersoft/src/app/_components/learning/geography-explorer/country-detail/country-detail.component.html:71,77` → `<img/video [src]="currentMedia.url">` The SPA is served from **learn.spikersoft.com**, so a root-relative URL resolves against **nginx**, not `api.spikersoft.com`. But nginx's root is `/usr/share/nginx/html` while the media tree is bind-mounted at `html/spikersoft/` (`spikersoft-angular/docker-stack.yml:10`), and `nginx.config` has **no `/lesson-videos` or `/geography-media` location** — only `/spikersoft/*` and a `try_files $uri $uri/ /index.html` catch-all. ## Proof (live, today) ``` GET https://learn.spikersoft.com/lesson-videos/probe.mp4 -> HTTP 200 content-type: text/html 59598 bytes <-- the Angular index.html GET https://learn.spikersoft.com/geography-media/probe.jpg -> HTTP 404 GET https://api.spikersoft.com/lesson-videos/probe.mp4 -> HTTP 404 (the API DOES mount this route — nothing ever addresses it) ``` The lesson-video case is the nasty one: **HTTP 200 with `text/html`**. The `<video>` element is handed the SPA shell as its media payload. It fails silently — no console 404, no network error, just a player that never plays. ## Impact - **Lesson videos: latent.** There are currently no approved lesson videos in production (`/Lessons/{n}/videos` returns empty), so nobody has hit it yet. **It breaks the instant the first video is approved.** Note lesson-video-processor has also never deployed (#581) — these two will surface together. - **Geography media: hard 404** for any `/geography-media/...` URL loaded by the SPA. The API's own `/lesson-videos` and `/geography-media` static mounts (and the #528 S3 fallback behind them) are **correct and working** — they are simply never addressed, because no consumer ever makes the URL absolute against the API origin. ## Fix options 1. **Prepend the API base in Angular** (`${environment.baseUrl}${url}`) — this is what blog media already does in `blog-entry-component.ts:108-118`, so there is an established pattern. Cheapest, no data migration. 2. **Mint absolute URLs backend-side**, as BlogMediaProcessor does in prod (`BlogMedia:PublicUrlPrefix`) — but this bakes an origin into Mongo rows and needs a backfill for existing data. 3. Add nginx `location` blocks — rejected: it duplicates the serving topology the #413 epic is actively trying to collapse. Option 1 is recommended, and it composes cleanly with #528 (the API already serves both routes correctly, from disk today and from S3 after cutover). ## Acceptance - `<video>` / `<img>` for lesson videos and geography media resolve against the API origin. - A test pinning that a media URL is never left root-relative for these two families. - e2e/route-walk coverage that a media response is not `text/html`. Related: #528 (API media serving), #531 (nginx root bind), #581 (lesson-video-processor never deployed). This is the discrepancy `README-AUDIT-QUESTIONS.md:250` was chasing: *"Who serves /lesson-videos in production today?"* — **Answer: nobody.**
Author
Owner

Resolved in spikersoft-angular PR #195 (merged to master) — the recommended option 1. A mediaUrl() helper prepends environment.baseUrl to root-relative media URLs in all three consuming surfaces (playground-videos-pane, lesson-video-moderation, geography country-detail), mirroring the blog-media pattern; absolute CDN/S3 URLs pass through untouched. A spec pins that lesson-video URLs are never left root-relative (acceptance bullet 2). Verified: platform-clang-runtime 54/54, the two app component suites 25/25.

Remaining nicety from acceptance bullet 3 (e2e/route-walk check that a media response is not text/html) was not included — it needs an approved lesson video in the environment to be meaningful (none exist yet, see #581). If that coverage is still wanted once lesson-video-processor deploys, it can ride along with #581's verification. Closing.

Resolved in spikersoft-angular PR #195 (merged to `master`) — the recommended option 1. A `mediaUrl()` helper prepends `environment.baseUrl` to root-relative media URLs in all three consuming surfaces (`playground-videos-pane`, `lesson-video-moderation`, geography `country-detail`), mirroring the blog-media pattern; absolute CDN/S3 URLs pass through untouched. A spec pins that lesson-video URLs are never left root-relative (acceptance bullet 2). Verified: `platform-clang-runtime` 54/54, the two app component suites 25/25. Remaining nicety from acceptance bullet 3 (e2e/route-walk check that a media response is not `text/html`) was not included — it needs an approved lesson video in the environment to be meaningful (none exist yet, see #581). If that coverage is still wanted once lesson-video-processor deploys, it can ride along with #581's verification. Closing.
Sign in to join this conversation.