Compare commits
14 Commits
85f67871fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b924564c22 | |||
| def78d4754 | |||
| 81efac4a53 | |||
| f26b930b5f | |||
| 6ee4dcfc21 | |||
| 9486d97c73 | |||
| 6debaf93bc | |||
| 5b6e344ee9 | |||
| bb9843fd47 | |||
| 7e445eb2fe | |||
| 6daa001388 | |||
| 1563aff964 | |||
| 692f4d5492 | |||
| 01a11328ec |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,331 @@
|
|||||||
|
# Media Embed Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-09
|
||||||
|
**Status:** Approved
|
||||||
|
**Scope:** Add support for embedding video files, audio files, YouTube, and Vimeo in Markdown documents using the standard image syntax ``.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
The knowledge base currently renders Markdown via `League\CommonMark` with `html_input => 'strip'`, which removes raw HTML. This is a deliberate safety choice: the project is published as OSS and may be deployed in environments with multiple authors or untrusted input, so raw HTML passthrough is undesirable.
|
||||||
|
|
||||||
|
To migrate fixed pages from a previous WordPress site (which used `<video>` tags and YouTube/Vimeo embeds), Markdown needs a safe mechanism to express media embeds. The chosen approach extends the existing image syntax: when an `` URL points to a media resource, the rendered output becomes `<video>`, `<audio>`, or `<iframe>` instead of `<img>`.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Support embedding local video and audio files via `` syntax
|
||||||
|
- Support YouTube and Vimeo embeds via the same syntax
|
||||||
|
- Use privacy-enhanced embed modes (`youtube-nocookie.com`, Vimeo `?dnt=1`)
|
||||||
|
- Preserve existing image rendering and Wiki link behavior unchanged
|
||||||
|
- Maintain `html_input => 'strip'` for safety
|
||||||
|
- Provide unit-test coverage for URL parsing and rendering
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Custom attributes (width, autoplay, poster) — sizing handled via CSS only
|
||||||
|
- Other embed providers (Twitch, SoundCloud, Spotify, etc.)
|
||||||
|
- `og:video` OGP tags
|
||||||
|
- VTT subtitles / `<track>` elements
|
||||||
|
- Download cards for zip/binary files (a separate future task)
|
||||||
|
- Rerendering existing documents (a separate Artisan command may be added later)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Markdown input
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
CommonMarkParser
|
||||||
|
│ (after parse)
|
||||||
|
▼
|
||||||
|
DocumentParsedEvent ───► MediaEmbedExtension listener
|
||||||
|
│
|
||||||
|
│ Walk Image nodes, classify URL:
|
||||||
|
│ ├─ video extension → <video>
|
||||||
|
│ ├─ audio extension → <audio>
|
||||||
|
│ ├─ YouTube URL → <iframe> (nocookie)
|
||||||
|
│ ├─ Vimeo URL → <iframe> (dnt)
|
||||||
|
│ └─ other → leave unchanged (renders as <img>)
|
||||||
|
│
|
||||||
|
│ Replace matching node with HtmlInline
|
||||||
|
▼
|
||||||
|
HTML output (existing render flow unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
The extension lives entirely in CommonMark's event-based AST modification layer. No changes are required to the existing Wiki link, GFM, or image rendering logic.
|
||||||
|
|
||||||
|
### Boundary Summary
|
||||||
|
|
||||||
|
- **Input:** Markdown string (unchanged)
|
||||||
|
- **Output:** HTML string — some `` produce `<video>`, `<audio>`, or `<iframe>` instead of `<img>`
|
||||||
|
- **Untouched:** Wiki links, GFM extension, default image rendering, `html_input => 'strip'` policy
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
#### `src/app/Markdown/MediaEmbedExtension.php`
|
||||||
|
|
||||||
|
CommonMark `ExtensionInterface` implementation. Sole responsibility: register the listener.
|
||||||
|
|
||||||
|
- Public API: `register(EnvironmentBuilderInterface $env): void`
|
||||||
|
- Wires `DocumentParsedEvent` to `MediaEmbedListener::handle`
|
||||||
|
|
||||||
|
#### `src/app/Markdown/MediaUrlResolver.php`
|
||||||
|
|
||||||
|
Pure URL classification class with no external dependencies. Highly testable.
|
||||||
|
|
||||||
|
- Public API: `resolve(string $url): ?string`
|
||||||
|
- Returns the replacement HTML string if URL is a recognized media resource
|
||||||
|
- Returns `null` if URL should fall through to default image rendering
|
||||||
|
- Internal helpers:
|
||||||
|
- `detectVideo(string $url): ?string`
|
||||||
|
- `detectAudio(string $url): ?string`
|
||||||
|
- `detectYouTube(string $url): ?string`
|
||||||
|
- `detectVimeo(string $url): ?string`
|
||||||
|
- Order: video → audio → YouTube → Vimeo → null
|
||||||
|
|
||||||
|
#### `src/app/Markdown/MediaEmbedListener.php`
|
||||||
|
|
||||||
|
Thin glue layer. Receives `DocumentParsedEvent`, walks the AST, and delegates URL classification to `MediaUrlResolver`.
|
||||||
|
|
||||||
|
- Public API: `handle(DocumentParsedEvent $event): void`
|
||||||
|
- For each `Image` node: call resolver; if non-null, replace node with a `MediaEmbedNode`
|
||||||
|
|
||||||
|
#### `src/app/Markdown/MediaEmbedNode.php`
|
||||||
|
|
||||||
|
Custom AST node that carries the pre-rendered embed HTML string.
|
||||||
|
|
||||||
|
- Extends `AbstractStringContainer`
|
||||||
|
- Does NOT implement `RawMarkupContainerInterface` — this is intentional so the node is not subject to `HtmlFilter`
|
||||||
|
- Holds its literal content (the HTML string) for direct output by its renderer
|
||||||
|
|
||||||
|
#### `src/app/Markdown/MediaEmbedNodeRenderer.php`
|
||||||
|
|
||||||
|
Dedicated renderer for `MediaEmbedNode`.
|
||||||
|
|
||||||
|
- Implements `NodeRendererInterface`
|
||||||
|
- Returns the node's literal content directly, without invoking any HTML filter
|
||||||
|
- This is the mechanism that allows trusted embed HTML to survive the `html_input => 'strip'` policy
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
#### `src/app/Models/Document.php` — `renderMarkdown()`
|
||||||
|
|
||||||
|
Add a single line:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
|
||||||
|
```
|
||||||
|
|
||||||
|
No other changes.
|
||||||
|
|
||||||
|
### File-split rationale
|
||||||
|
|
||||||
|
Separating `MediaUrlResolver` from `MediaEmbedListener` isolates "URL parsing / HTML generation" from "AST manipulation." The former is pure and exhaustively testable; the latter is a thin glue layer. This keeps each unit single-purpose and easier to reason about.
|
||||||
|
|
||||||
|
## Data Flow Specification
|
||||||
|
|
||||||
|
### Input → Output reference
|
||||||
|
|
||||||
|
| Markdown input | Output HTML (key parts) |
|
||||||
|
|---|---|
|
||||||
|
| `` | `<img src="/foo.png" alt="alt">` *(default, unchanged)* |
|
||||||
|
| `` | `<video src="/demo.mp4" controls class="kb-video"></video>` |
|
||||||
|
| `` | `<audio src="/audio.mp3" controls class="kb-audio"></audio>` |
|
||||||
|
| `` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-" ...></iframe>` |
|
||||||
|
| `` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-?start=30" ...></iframe>` |
|
||||||
|
| `` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-" ...></iframe>` |
|
||||||
|
| `` | `<iframe src="https://player.vimeo.com/video/123456789?dnt=1" ...></iframe>` |
|
||||||
|
| `` | `<iframe src="https://player.vimeo.com/video/123456789?dnt=1#t=30s" ...></iframe>` |
|
||||||
|
|
||||||
|
### Extension matching (case-insensitive)
|
||||||
|
|
||||||
|
- Video: `mp4`, `webm`, `ogv`, `mov`, `m4v`
|
||||||
|
- Audio: `mp3`, `wav`, `ogg`, `m4a`
|
||||||
|
|
||||||
|
Matching is performed on the URL **path** only (after stripping `?query` and `#fragment`) so signed CDN URLs with `?token=...` are not misclassified.
|
||||||
|
|
||||||
|
### YouTube URL recognition
|
||||||
|
|
||||||
|
The video ID is the strict pattern `[A-Za-z0-9_-]{11}`. Recognized URL forms:
|
||||||
|
|
||||||
|
| Pattern | Example |
|
||||||
|
|---|---|
|
||||||
|
| `youtu.be/{id}` | `https://youtu.be/abc123XYZ_-` |
|
||||||
|
| `youtube.com/watch?v={id}` | `https://www.youtube.com/watch?v=abc123XYZ_-` |
|
||||||
|
| `youtube.com/shorts/{id}` | `https://www.youtube.com/shorts/abc123XYZ_-` |
|
||||||
|
| `youtube.com/embed/{id}` | `https://www.youtube.com/embed/abc123XYZ_-` |
|
||||||
|
| `m.youtube.com/...` | mobile variant of the above |
|
||||||
|
|
||||||
|
Timestamp normalization (first match wins; `t` preferred over `start`):
|
||||||
|
|
||||||
|
- `?t=30s` / `?t=30` / `&t=1m20s` → seconds → `?start=N`
|
||||||
|
- `?start=N` → preserved
|
||||||
|
- No timestamp → no `?start` parameter
|
||||||
|
|
||||||
|
### Vimeo URL recognition
|
||||||
|
|
||||||
|
| Pattern | Example |
|
||||||
|
|---|---|
|
||||||
|
| `vimeo.com/{id}` | `https://vimeo.com/123456789` |
|
||||||
|
| `player.vimeo.com/video/{id}` | `https://player.vimeo.com/video/123456789` |
|
||||||
|
|
||||||
|
ID is digits only.
|
||||||
|
|
||||||
|
Timestamp:
|
||||||
|
- `#t=30s` → preserved as `#t=30s` (Vimeo convention)
|
||||||
|
- `?t=30s` → preserved as `#t=30s`
|
||||||
|
|
||||||
|
### iframe attribute template
|
||||||
|
|
||||||
|
```html
|
||||||
|
<iframe src="..."
|
||||||
|
width="560" height="315"
|
||||||
|
loading="lazy"
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
|
allow="autoplay; encrypted-media; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
frameborder="0"
|
||||||
|
class="kb-embed kb-embed-{provider}">
|
||||||
|
</iframe>
|
||||||
|
```
|
||||||
|
|
||||||
|
`{provider}` is `youtube` or `vimeo`. Class hooks let CSS introduce aspect-ratio control later.
|
||||||
|
|
||||||
|
### Resolution order
|
||||||
|
|
||||||
|
1. Video extension → emit `<video>`, return
|
||||||
|
2. Audio extension → emit `<audio>`, return
|
||||||
|
3. YouTube → emit `<iframe>`, return
|
||||||
|
4. Vimeo → emit `<iframe>`, return
|
||||||
|
5. None match → return `null`; node renders as default `<img>`
|
||||||
|
|
||||||
|
## Error Handling and Edge Cases
|
||||||
|
|
||||||
|
| Case | Behavior | Reason |
|
||||||
|
|---|---|---|
|
||||||
|
| `parse_url` failure | return `null` → default `<img>` | Fall back to CommonMark default |
|
||||||
|
| URL with no extension | return `null` → default `<img>` | Extension matching is path-suffix based |
|
||||||
|
| YouTube ID does not match `[A-Za-z0-9_-]{11}` | return `null` → default `<img>` | Strict matching avoids false positives |
|
||||||
|
| Vimeo ID is not digits | return `null` → default `<img>` | Same |
|
||||||
|
| Empty URL | return `null` | `parse_url` returns empty path |
|
||||||
|
|
||||||
|
**Principle:** Unrecognized URLs are not transformed. Exceptions are not thrown. Default CommonMark rendering handles the fallback.
|
||||||
|
|
||||||
|
### XSS hardening
|
||||||
|
|
||||||
|
All output URLs are passed through `htmlspecialchars($url, ENT_QUOTES, 'UTF-8')` before being embedded in HTML strings. Attack-vector analysis:
|
||||||
|
|
||||||
|
- `)` — does not match a media extension → `null` → CommonMark's `allow_unsafe_links => false` blocks `<img src="javascript:...">`
|
||||||
|
- `` — strict ID regex `[A-Za-z0-9_-]{11}` cannot extract from a URL containing `"` or `>` → `null` → default rendering, where CommonMark also escapes
|
||||||
|
- `` — trailing quote breaks extension matching at the path-cleaning step; even if it passed, `htmlspecialchars` would escape the output
|
||||||
|
|
||||||
|
### Relation to `html_input => 'strip'`
|
||||||
|
|
||||||
|
The `'strip'` setting is preserved. All `HtmlInline` nodes — whether written by the user in Markdown source or inserted programmatically by an extension — go through `HtmlFilter::filter()`, which strips their content under `'strip'` mode. To emit the embed HTML safely without bypassing this policy, the extension introduces a custom node type:
|
||||||
|
|
||||||
|
- `MediaEmbedNode` extends `AbstractStringContainer` and deliberately does NOT implement `RawMarkupContainerInterface`
|
||||||
|
- `MediaEmbedNodeRenderer` returns the node's literal content directly, without invoking any HTML filter
|
||||||
|
|
||||||
|
Therefore:
|
||||||
|
- User-written `<script>` in Markdown source → produces `HtmlInline` → still stripped
|
||||||
|
- `<video>` / `<audio>` / `<iframe>` inserted by `MediaEmbedExtension` → produces `MediaEmbedNode` → output as intended
|
||||||
|
- The security boundary is "only the explicitly trusted node type bypasses filtering," and that node type is reachable only through `MediaEmbedListener` after `MediaUrlResolver` has classified the URL as a known media pattern.
|
||||||
|
|
||||||
|
### `alt` and `title`
|
||||||
|
|
||||||
|
Markdown image syntax allows ``.
|
||||||
|
|
||||||
|
- `<video>` / `<audio>` have no `alt` attribute → ignored
|
||||||
|
- `title` is preserved on `<video>` / `<audio>` as `title="..."` (optional)
|
||||||
|
- iframes ignore both (the YouTube/Vimeo player surfaces its own title)
|
||||||
|
|
||||||
|
VTT subtitles / `<track>` elements are out of scope.
|
||||||
|
|
||||||
|
### Multiple media in one paragraph
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
 and 
|
||||||
|
```
|
||||||
|
|
||||||
|
Two `<video>` elements appear within the same `<p>`. `<video>` is phrasing content per the HTML spec, so this is valid. CSS can apply `display: block` if needed.
|
||||||
|
|
||||||
|
### Existing documents
|
||||||
|
|
||||||
|
Existing rows in `documents.rendered_html` may be stale after this change. Mitigation is left to the implementation phase — most likely a `docs:rerender` Artisan command (or a one-off `tinker` invocation) that re-saves each `Document` to trigger the existing render hook. This is **not part of the design scope** and should be tracked separately during implementation planning.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### `tests/Unit/Markdown/MediaUrlResolverTest.php`
|
||||||
|
|
||||||
|
Pure-unit tests against `MediaUrlResolver::resolve`.
|
||||||
|
|
||||||
|
**Video extensions** (one case per extension):
|
||||||
|
- `/demo.mp4`, `/demo.webm`, `/demo.ogv`, `/demo.mov`, `/demo.m4v` → `<video>` output
|
||||||
|
- `/demo.MP4` (uppercase) → recognized
|
||||||
|
- `https://example.com/path/demo.mp4?token=abc` → query stripped, recognized
|
||||||
|
|
||||||
|
**Audio extensions** (one case per extension):
|
||||||
|
- `/clip.mp3`, `/clip.wav`, `/clip.ogg`, `/clip.m4a` → `<audio>` output
|
||||||
|
|
||||||
|
**YouTube** (full URL pattern coverage):
|
||||||
|
- `https://youtu.be/dQw4w9WgXcQ`
|
||||||
|
- `https://www.youtube.com/watch?v=dQw4w9WgXcQ`
|
||||||
|
- `https://www.youtube.com/shorts/dQw4w9WgXcQ`
|
||||||
|
- `https://www.youtube.com/embed/dQw4w9WgXcQ`
|
||||||
|
- `https://m.youtube.com/watch?v=dQw4w9WgXcQ`
|
||||||
|
- Timestamps: `?t=30s`, `?t=90`, `?t=1m20s`, `?start=30`
|
||||||
|
- Output contains `youtube-nocookie.com`
|
||||||
|
|
||||||
|
**Vimeo:**
|
||||||
|
- `https://vimeo.com/123456789`
|
||||||
|
- `https://player.vimeo.com/video/123456789`
|
||||||
|
- Timestamps: `#t=30s`, `?t=30s`
|
||||||
|
- Output contains `?dnt=1`
|
||||||
|
|
||||||
|
**Fallback (returns `null`):**
|
||||||
|
- Normal images: `/photo.jpg`, `/icon.svg`
|
||||||
|
- No extension: `/foo`
|
||||||
|
- Invalid URL: empty string, `javascript:alert(1)`, `http://`
|
||||||
|
- Negative-match candidates: `https://example.com/youtu.be-fake/abc` (host mismatch)
|
||||||
|
- Invalid YouTube ID: `https://youtu.be/short` (less than 11 chars), special characters
|
||||||
|
|
||||||
|
**XSS resilience:**
|
||||||
|
- `https://youtu.be/abc"><script>` → `null` (strict ID extraction fails)
|
||||||
|
- Video URL containing `"` produces escaped output
|
||||||
|
|
||||||
|
### `tests/Unit/Markdown/MediaEmbedExtensionTest.php`
|
||||||
|
|
||||||
|
Integrated unit tests through `Document::renderMarkdown()`:
|
||||||
|
|
||||||
|
- Default image survives unchanged: `` → `<img>`
|
||||||
|
- Video embed succeeds: `` → `<video>`, no `<img>`
|
||||||
|
- Mixed Markdown: image, video, YouTube, Vimeo coexist correctly
|
||||||
|
- Wiki link coexistence: `[[other-doc]]` is unaffected
|
||||||
|
- Multiple media in one paragraph: ` ` → two `<video>`
|
||||||
|
- List item: `- ` → `<video>` inside `<li>`
|
||||||
|
|
||||||
|
### Test data convention
|
||||||
|
|
||||||
|
No fixture files. Test inputs are inline string literals so they remain greppable.
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec php php artisan test --filter=MediaUrlResolverTest
|
||||||
|
docker compose exec php php artisan test --filter=MediaEmbedExtensionTest
|
||||||
|
```
|
||||||
|
|
||||||
|
`composer test` (full suite) must remain green.
|
||||||
|
|
||||||
|
### Coverage target
|
||||||
|
|
||||||
|
No formal coverage measurement. The bar is: **every URL pattern listed in the Data Flow Specification has at least one corresponding test case.**
|
||||||
|
|
||||||
|
## Open Items for Implementation Phase
|
||||||
|
|
||||||
|
These are deliberately deferred to the planning phase, not the design:
|
||||||
|
|
||||||
|
- Whether to add a `docs:rerender` Artisan command for existing rows
|
||||||
|
- CSS additions for `.kb-video`, `.kb-audio`, `.kb-embed-*` (likely a future task)
|
||||||
|
- Updating `CLAUDE.md` to document the new media-embed convention
|
||||||
@@ -18,6 +18,8 @@ class DocumentEditor extends Component
|
|||||||
public function mount(?Document $document = null)
|
public function mount(?Document $document = null)
|
||||||
{
|
{
|
||||||
if ($document) {
|
if ($document) {
|
||||||
|
$this->authorize('update', $document);
|
||||||
|
|
||||||
$this->document = $document;
|
$this->document = $document;
|
||||||
$this->title = $document->title;
|
$this->title = $document->title;
|
||||||
$this->content = $document->content;
|
$this->content = $document->content;
|
||||||
@@ -40,6 +42,8 @@ public function save(DocumentService $documentService)
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if ($this->isEditMode && $this->document) {
|
if ($this->isEditMode && $this->document) {
|
||||||
|
$this->authorize('update', $this->document);
|
||||||
|
|
||||||
$this->document = $documentService->updateDocument(
|
$this->document = $documentService->updateDocument(
|
||||||
$this->document,
|
$this->document,
|
||||||
$this->title,
|
$this->title,
|
||||||
@@ -71,6 +75,8 @@ public function delete(DocumentService $documentService)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->authorize('delete', $this->document);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$documentService->deleteDocument($this->document);
|
$documentService->deleteDocument($this->document);
|
||||||
session()->flash('message', 'Document deleted successfully!');
|
session()->flash('message', 'Document deleted successfully!');
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Markdown;
|
||||||
|
|
||||||
|
use League\CommonMark\Environment\EnvironmentBuilderInterface;
|
||||||
|
use League\CommonMark\Event\DocumentParsedEvent;
|
||||||
|
use League\CommonMark\Extension\ExtensionInterface;
|
||||||
|
|
||||||
|
class MediaEmbedExtension implements ExtensionInterface
|
||||||
|
{
|
||||||
|
public function register(EnvironmentBuilderInterface $environment): void
|
||||||
|
{
|
||||||
|
$listener = new MediaEmbedListener(new MediaUrlResolver());
|
||||||
|
$environment->addEventListener(DocumentParsedEvent::class, [$listener, 'handle']);
|
||||||
|
$environment->addRenderer(MediaEmbedNode::class, new MediaEmbedNodeRenderer());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Markdown;
|
||||||
|
|
||||||
|
use League\CommonMark\Event\DocumentParsedEvent;
|
||||||
|
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
|
||||||
|
|
||||||
|
class MediaEmbedListener
|
||||||
|
{
|
||||||
|
public function __construct(private readonly MediaUrlResolver $resolver)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(DocumentParsedEvent $event): void
|
||||||
|
{
|
||||||
|
$imagesToReplace = [];
|
||||||
|
foreach ($event->getDocument()->iterator() as $node) {
|
||||||
|
if ($node instanceof Image) {
|
||||||
|
$imagesToReplace[] = $node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($imagesToReplace as $image) {
|
||||||
|
$html = $this->resolver->resolve($image->getUrl());
|
||||||
|
if ($html !== null) {
|
||||||
|
$image->replaceWith(new MediaEmbedNode($html));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Markdown;
|
||||||
|
|
||||||
|
use League\CommonMark\Node\Inline\AbstractStringContainer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom inline node for programmatically generated media embeds.
|
||||||
|
*
|
||||||
|
* Unlike HtmlInline, this node does NOT implement RawMarkupContainerInterface,
|
||||||
|
* so its renderer bypasses the html_input filter entirely, allowing us to emit
|
||||||
|
* safe, programmatically constructed HTML even when html_input is set to 'strip'.
|
||||||
|
*/
|
||||||
|
class MediaEmbedNode extends AbstractStringContainer
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Markdown;
|
||||||
|
|
||||||
|
use League\CommonMark\Node\Node;
|
||||||
|
use League\CommonMark\Renderer\ChildNodeRendererInterface;
|
||||||
|
use League\CommonMark\Renderer\NodeRendererInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a MediaEmbedNode by emitting its literal content directly,
|
||||||
|
* without going through any html_input filtering.
|
||||||
|
*/
|
||||||
|
class MediaEmbedNodeRenderer implements NodeRendererInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param MediaEmbedNode $node
|
||||||
|
*
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* @psalm-suppress MoreSpecificImplementedParamType
|
||||||
|
*/
|
||||||
|
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
|
||||||
|
{
|
||||||
|
MediaEmbedNode::assertInstanceOf($node);
|
||||||
|
|
||||||
|
return $node->getLiteral();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Markdown;
|
||||||
|
|
||||||
|
class MediaUrlResolver
|
||||||
|
{
|
||||||
|
private const VIDEO_EXT = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
|
||||||
|
|
||||||
|
private const AUDIO_EXT = ['mp3', 'wav', 'ogg', 'm4a'];
|
||||||
|
|
||||||
|
public function resolve(string $url): ?string
|
||||||
|
{
|
||||||
|
if ($url === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $this->detectVideo($url)
|
||||||
|
?? $this->detectAudio($url)
|
||||||
|
?? $this->detectYouTube($url)
|
||||||
|
?? $this->detectVimeo($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectVideo(string $url): ?string
|
||||||
|
{
|
||||||
|
if (!in_array($this->getPathExtension($url), self::VIDEO_EXT, true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
|
||||||
|
return "<video src=\"{$safe}\" controls class=\"kb-video\"></video>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectAudio(string $url): ?string
|
||||||
|
{
|
||||||
|
if (!in_array($this->getPathExtension($url), self::AUDIO_EXT, true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
|
||||||
|
return "<audio src=\"{$safe}\" controls class=\"kb-audio\"></audio>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectYouTube(string $url): ?string
|
||||||
|
{
|
||||||
|
$patterns = [
|
||||||
|
'~^https?://youtu\.be/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
|
||||||
|
'~^https?://(?:www\.|m\.)?youtube\.com/watch\?(?:[^#]*&)?v=([A-Za-z0-9_-]{11})(?:[&#]|$)~',
|
||||||
|
'~^https?://(?:www\.|m\.)?youtube\.com/shorts/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
|
||||||
|
'~^https?://(?:www\.|m\.)?youtube\.com/embed/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
|
||||||
|
];
|
||||||
|
$videoId = null;
|
||||||
|
foreach ($patterns as $p) {
|
||||||
|
if (preg_match($p, $url, $m)) {
|
||||||
|
$videoId = $m[1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($videoId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$src = "https://www.youtube-nocookie.com/embed/{$videoId}";
|
||||||
|
$start = $this->extractYouTubeStart($url);
|
||||||
|
if ($start !== null) {
|
||||||
|
$src .= "?start={$start}";
|
||||||
|
}
|
||||||
|
return $this->iframeHtml($src, 'youtube');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractYouTubeStart(string $url): ?int
|
||||||
|
{
|
||||||
|
if (preg_match('/[?&]t=([^&#]+)/', $url, $m)) {
|
||||||
|
$seconds = $this->parseTimestamp($m[1]);
|
||||||
|
if ($seconds !== null) {
|
||||||
|
return $seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (preg_match('/[?&]start=(\d+)/', $url, $m)) {
|
||||||
|
return (int) $m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseTimestamp(string $t): ?int
|
||||||
|
{
|
||||||
|
if (ctype_digit($t)) {
|
||||||
|
return (int) $t;
|
||||||
|
}
|
||||||
|
$total = 0;
|
||||||
|
$matched = false;
|
||||||
|
if (preg_match('/(\d+)h/', $t, $m)) {
|
||||||
|
$total += (int) $m[1] * 3600;
|
||||||
|
$matched = true;
|
||||||
|
}
|
||||||
|
if (preg_match('/(\d+)m/', $t, $m)) {
|
||||||
|
$total += (int) $m[1] * 60;
|
||||||
|
$matched = true;
|
||||||
|
}
|
||||||
|
if (preg_match('/(\d+)s/', $t, $m)) {
|
||||||
|
$total += (int) $m[1];
|
||||||
|
$matched = true;
|
||||||
|
}
|
||||||
|
return $matched ? $total : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectVimeo(string $url): ?string
|
||||||
|
{
|
||||||
|
if (!preg_match('~^https?://(?:www\.|player\.)?vimeo\.com/(?:video/)?(\d+)(?![A-Za-z0-9])~', $url, $m)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$videoId = $m[1];
|
||||||
|
$src = "https://player.vimeo.com/video/{$videoId}?dnt=1";
|
||||||
|
$hash = $this->extractVimeoHash($url);
|
||||||
|
if ($hash !== null) {
|
||||||
|
$src .= '#' . $hash;
|
||||||
|
}
|
||||||
|
return $this->iframeHtml($src, 'vimeo');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractVimeoHash(string $url): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('/#(t=[^&]+)/', $url, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
if (preg_match('/[?&](t=[^&#]+)/', $url, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function iframeHtml(string $src, string $provider): string
|
||||||
|
{
|
||||||
|
$safe = htmlspecialchars($src, ENT_QUOTES, 'UTF-8');
|
||||||
|
return '<iframe src="' . $safe . '" '
|
||||||
|
. 'width="560" height="315" '
|
||||||
|
. 'loading="lazy" '
|
||||||
|
. 'referrerpolicy="strict-origin-when-cross-origin" '
|
||||||
|
. 'allow="autoplay; encrypted-media; picture-in-picture" '
|
||||||
|
. 'allowfullscreen frameborder="0" '
|
||||||
|
. 'class="kb-embed kb-embed-' . $provider . '"></iframe>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPathExtension(string $url): string
|
||||||
|
{
|
||||||
|
$path = parse_url($url, PHP_URL_PATH);
|
||||||
|
if ($path === null || $path === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,7 @@ public static function renderMarkdown(string $markdown): string
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
|
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
|
||||||
|
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
|
||||||
|
|
||||||
return $converter->convert($markdown)->getContent();
|
return $converter->convert($markdown)->getContent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class DocumentPolicy
|
||||||
|
{
|
||||||
|
public function before(User $user): ?bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin() ? true : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Document $document): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Document $document): bool
|
||||||
|
{
|
||||||
|
return $document->created_by === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Document $document): bool
|
||||||
|
{
|
||||||
|
return $document->created_by === $user->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -6,10 +6,10 @@
|
|||||||
"keywords": ["laravel", "framework"],
|
"keywords": ["laravel", "framework"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.3",
|
||||||
"cocur/slugify": "^4.7",
|
"cocur/slugify": "^4.7",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^3.0",
|
||||||
"league/commonmark": "^2.8",
|
"league/commonmark": "^2.8",
|
||||||
"livewire/livewire": "^3.7"
|
"livewire/livewire": "^3.7"
|
||||||
},
|
},
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.41",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
"phpunit/phpunit": "^11.5.3"
|
"phpunit/phpunit": "^12.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|||||||
Generated
+380
-406
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
|||||||
{{ $document->title }}
|
{{ $document->title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@auth
|
@can('update', $document)
|
||||||
<a
|
<a
|
||||||
href="{{ route('documents.edit', $document) }}"
|
href="{{ route('documents.edit', $document) }}"
|
||||||
class="inline-flex items-center justify-center px-3 sm:px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 whitespace-nowrap"
|
class="inline-flex items-center justify-center px-3 sm:px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 whitespace-nowrap"
|
||||||
@@ -16,7 +16,7 @@ class="inline-flex items-center justify-center px-3 sm:px-4 py-2 bg-indigo-600 t
|
|||||||
</svg>
|
</svg>
|
||||||
{{ __('messages.documents.edit') }}
|
{{ __('messages.documents.edit') }}
|
||||||
</a>
|
</a>
|
||||||
@endauth
|
@endcan
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center text-xs sm:text-sm text-gray-500 gap-2 sm:gap-4">
|
<div class="flex flex-col sm:flex-row sm:items-center text-xs sm:text-sm text-gray-500 gap-2 sm:gap-4">
|
||||||
|
|||||||
@@ -7,16 +7,10 @@
|
|||||||
use App\Http\Controllers\Auth\NewPasswordController;
|
use App\Http\Controllers\Auth\NewPasswordController;
|
||||||
use App\Http\Controllers\Auth\PasswordController;
|
use App\Http\Controllers\Auth\PasswordController;
|
||||||
use App\Http\Controllers\Auth\PasswordResetLinkController;
|
use App\Http\Controllers\Auth\PasswordResetLinkController;
|
||||||
use App\Http\Controllers\Auth\RegisteredUserController;
|
|
||||||
use App\Http\Controllers\Auth\VerifyEmailController;
|
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware('guest')->group(function () {
|
Route::middleware('guest')->group(function () {
|
||||||
Route::get('register', [RegisteredUserController::class, 'create'])
|
|
||||||
->name('register');
|
|
||||||
|
|
||||||
Route::post('register', [RegisteredUserController::class, 'store']);
|
|
||||||
|
|
||||||
Route::get('login', [AuthenticatedSessionController::class, 'create'])
|
Route::get('login', [AuthenticatedSessionController::class, 'create'])
|
||||||
->name('login');
|
->name('login');
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -44,7 +44,9 @@
|
|||||||
// 認証が必要なルート(より具体的なルートを先に定義)
|
// 認証が必要なルート(より具体的なルートを先に定義)
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
Route::get('/create', DocumentEditor::class)->name('create');
|
Route::get('/create', DocumentEditor::class)->name('create');
|
||||||
Route::get('/{document}/edit', DocumentEditor::class)->name('edit');
|
Route::get('/{document}/edit', DocumentEditor::class)
|
||||||
|
->middleware('can:update,document')
|
||||||
|
->name('edit');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 公開ルート(動的ルートは最後に)
|
// 公開ルート(動的ルートは最後に)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Markdown;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class MediaEmbedExtensionTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_normal_image_still_renders_as_img(): void
|
||||||
|
{
|
||||||
|
$html = Document::renderMarkdown('');
|
||||||
|
$this->assertStringContainsString('<img', $html);
|
||||||
|
$this->assertStringContainsString('src="/photo.png"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_video_url_renders_as_video_tag(): void
|
||||||
|
{
|
||||||
|
$html = Document::renderMarkdown('');
|
||||||
|
$this->assertStringContainsString('<video', $html);
|
||||||
|
$this->assertStringContainsString('src="/demo.mp4"', $html);
|
||||||
|
$this->assertStringNotContainsString('<img', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_youtube_url_renders_as_iframe(): void
|
||||||
|
{
|
||||||
|
$html = Document::renderMarkdown('');
|
||||||
|
$this->assertStringContainsString('<iframe', $html);
|
||||||
|
$this->assertStringContainsString('youtube-nocookie.com', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_vimeo_url_renders_as_iframe(): void
|
||||||
|
{
|
||||||
|
$html = Document::renderMarkdown('');
|
||||||
|
$this->assertStringContainsString('<iframe', $html);
|
||||||
|
$this->assertStringContainsString('player.vimeo.com', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_image_and_video_coexist_in_same_document(): void
|
||||||
|
{
|
||||||
|
$md = "\n\n";
|
||||||
|
$html = Document::renderMarkdown($md);
|
||||||
|
$this->assertStringContainsString('<img', $html);
|
||||||
|
$this->assertStringContainsString('<video', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_multiple_media_in_same_paragraph(): void
|
||||||
|
{
|
||||||
|
$html = Document::renderMarkdown(' and ');
|
||||||
|
$this->assertSame(2, substr_count($html, '<video'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_video_inside_list_item(): void
|
||||||
|
{
|
||||||
|
$html = Document::renderMarkdown("- ");
|
||||||
|
$this->assertStringContainsString('<li>', $html);
|
||||||
|
$this->assertStringContainsString('<video', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_wiki_link_unaffected_alongside_media(): void
|
||||||
|
{
|
||||||
|
$html = Document::renderMarkdown("\n\n[[Other Doc]]");
|
||||||
|
$this->assertStringContainsString('<video', $html);
|
||||||
|
$this->assertStringContainsString('[[Other Doc]]', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_youtube_with_timestamp_in_document(): void
|
||||||
|
{
|
||||||
|
$html = Document::renderMarkdown('');
|
||||||
|
$this->assertStringContainsString('?start=30', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_audio_url_renders_as_audio_tag(): void
|
||||||
|
{
|
||||||
|
$html = Document::renderMarkdown('');
|
||||||
|
$this->assertStringContainsString('<audio', $html);
|
||||||
|
$this->assertStringContainsString('src="/clip.mp3"', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Markdown;
|
||||||
|
|
||||||
|
use App\Markdown\MediaUrlResolver;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class MediaUrlResolverTest extends TestCase
|
||||||
|
{
|
||||||
|
private MediaUrlResolver $resolver;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->resolver = new MediaUrlResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('nonMediaUrls')]
|
||||||
|
public function test_returns_null_for_non_media_urls(string $url): void
|
||||||
|
{
|
||||||
|
$this->assertNull($this->resolver->resolve($url));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function nonMediaUrls(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'normal image' => ['/photo.jpg'],
|
||||||
|
'svg' => ['/icon.svg'],
|
||||||
|
'png' => ['/avatar.png'],
|
||||||
|
'no extension' => ['/foo'],
|
||||||
|
'empty string' => [''],
|
||||||
|
'javascript scheme' => ['javascript:alert(1)'],
|
||||||
|
'host-only' => ['http://'],
|
||||||
|
'youtu.be lookalike host' => ['https://example.com/youtu.be-fake/abc'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('videoUrls')]
|
||||||
|
public function test_video_urls_produce_video_tag(string $url): void
|
||||||
|
{
|
||||||
|
$html = $this->resolver->resolve($url);
|
||||||
|
$this->assertNotNull($html);
|
||||||
|
$this->assertStringStartsWith('<video', $html);
|
||||||
|
$this->assertStringContainsString('controls', $html);
|
||||||
|
$this->assertStringContainsString('class="kb-video"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function videoUrls(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'mp4' => ['/demo.mp4'],
|
||||||
|
'webm' => ['/demo.webm'],
|
||||||
|
'ogv' => ['/demo.ogv'],
|
||||||
|
'mov' => ['/demo.mov'],
|
||||||
|
'm4v' => ['/demo.m4v'],
|
||||||
|
'uppercase extension' => ['/demo.MP4'],
|
||||||
|
'with query string' => ['https://example.com/path/demo.mp4?token=abc'],
|
||||||
|
'absolute http' => ['https://example.com/demo.mp4'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_video_url_is_html_escaped(): void
|
||||||
|
{
|
||||||
|
$html = $this->resolver->resolve('/path/with"quote.mp4');
|
||||||
|
$this->assertNotNull($html);
|
||||||
|
$this->assertStringNotContainsString('"quote.mp4"', $html);
|
||||||
|
$this->assertStringContainsString('"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('audioUrls')]
|
||||||
|
public function test_audio_urls_produce_audio_tag(string $url): void
|
||||||
|
{
|
||||||
|
$html = $this->resolver->resolve($url);
|
||||||
|
$this->assertNotNull($html);
|
||||||
|
$this->assertStringStartsWith('<audio', $html);
|
||||||
|
$this->assertStringContainsString('controls', $html);
|
||||||
|
$this->assertStringContainsString('class="kb-audio"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function audioUrls(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'mp3' => ['/clip.mp3'],
|
||||||
|
'wav' => ['/clip.wav'],
|
||||||
|
'ogg' => ['/clip.ogg'],
|
||||||
|
'm4a' => ['/clip.m4a'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('youtubeUrls')]
|
||||||
|
public function test_youtube_urls_produce_iframe(string $url): void
|
||||||
|
{
|
||||||
|
$html = $this->resolver->resolve($url);
|
||||||
|
$this->assertNotNull($html);
|
||||||
|
$this->assertStringStartsWith('<iframe', $html);
|
||||||
|
$this->assertStringContainsString('youtube-nocookie.com/embed/dQw4w9WgXcQ', $html);
|
||||||
|
$this->assertStringContainsString('class="kb-embed kb-embed-youtube"', $html);
|
||||||
|
$this->assertStringContainsString('loading="lazy"', $html);
|
||||||
|
$this->assertStringContainsString('allowfullscreen', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function youtubeUrls(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'short youtu.be' => ['https://youtu.be/dQw4w9WgXcQ'],
|
||||||
|
'watch v=' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ'],
|
||||||
|
'shorts' => ['https://www.youtube.com/shorts/dQw4w9WgXcQ'],
|
||||||
|
'embed' => ['https://www.youtube.com/embed/dQw4w9WgXcQ'],
|
||||||
|
'mobile' => ['https://m.youtube.com/watch?v=dQw4w9WgXcQ'],
|
||||||
|
'no www watch' => ['https://youtube.com/watch?v=dQw4w9WgXcQ'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('invalidYoutubeUrls')]
|
||||||
|
public function test_invalid_youtube_urls_return_null(string $url): void
|
||||||
|
{
|
||||||
|
$this->assertNull($this->resolver->resolve($url));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function invalidYoutubeUrls(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'too short id' => ['https://youtu.be/short'],
|
||||||
|
'host mismatch' => ['https://example.com/watch?v=dQw4w9WgXcQ'],
|
||||||
|
'XSS attempt in id' => ['https://youtu.be/abc"><script>'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('youtubeTimestampUrls')]
|
||||||
|
public function test_youtube_timestamp_normalizes_to_start(string $url, int $expectedStart): void
|
||||||
|
{
|
||||||
|
$html = $this->resolver->resolve($url);
|
||||||
|
$this->assertNotNull($html);
|
||||||
|
$this->assertStringContainsString("?start={$expectedStart}", $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function youtubeTimestampUrls(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
't=30s' => ['https://youtu.be/dQw4w9WgXcQ?t=30s', 30],
|
||||||
|
't=30 (no suffix)' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30', 30],
|
||||||
|
't=1m20s' => ['https://youtu.be/dQw4w9WgXcQ?t=1m20s', 80],
|
||||||
|
't=1h2m3s' => ['https://youtu.be/dQw4w9WgXcQ?t=1h2m3s', 3723],
|
||||||
|
'start=45' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=45', 45],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('vimeoUrls')]
|
||||||
|
public function test_vimeo_urls_produce_iframe(string $url): void
|
||||||
|
{
|
||||||
|
$html = $this->resolver->resolve($url);
|
||||||
|
$this->assertNotNull($html);
|
||||||
|
$this->assertStringStartsWith('<iframe', $html);
|
||||||
|
$this->assertStringContainsString('player.vimeo.com/video/123456789', $html);
|
||||||
|
$this->assertStringContainsString('dnt=1', $html);
|
||||||
|
$this->assertStringContainsString('class="kb-embed kb-embed-vimeo"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function vimeoUrls(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'vimeo.com' => ['https://vimeo.com/123456789'],
|
||||||
|
'www.vimeo.com' => ['https://www.vimeo.com/123456789'],
|
||||||
|
'player.vimeo.com' => ['https://player.vimeo.com/video/123456789'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('vimeoTimestampUrls')]
|
||||||
|
public function test_vimeo_timestamp_preserved_as_hash(string $url): void
|
||||||
|
{
|
||||||
|
$html = $this->resolver->resolve($url);
|
||||||
|
$this->assertNotNull($html);
|
||||||
|
$this->assertStringContainsString('#t=30s', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function vimeoTimestampUrls(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'hash form' => ['https://vimeo.com/123456789#t=30s'],
|
||||||
|
'query form' => ['https://vimeo.com/123456789?t=30s'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_vimeo_invalid_id_returns_null(): void
|
||||||
|
{
|
||||||
|
$this->assertNull($this->resolver->resolve('https://vimeo.com/notanumber'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('vimeoFalsePositives')]
|
||||||
|
public function test_vimeo_false_positives_return_null(string $url): void
|
||||||
|
{
|
||||||
|
$this->assertNull($this->resolver->resolve($url));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function vimeoFalsePositives(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'digits then letter' => ['https://vimeo.com/123abc'],
|
||||||
|
'digits then x' => ['https://vimeo.com/123x'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user