diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 8bba368..b8d74a2 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,83 +1,65 @@ -# πŸ› Bug Report / πŸ’‘ Feature Request +# Bug Report -**Thanks for contributing to the oEmbed Craft CMS plugin!** This template helps us understand issues with the field type, embed functionality, caching, and admin interface. +Thanks for reporting an issue. Please fill this out so we can troubleshoot quickly. -## πŸ“‹ Issue Type -- [ ] πŸ› Bug report -- [ ] πŸ’‘ Feature request -- [ ] πŸ“š Documentation issue -- [ ] ❓ Question/Support +## Summary + ---- - -## πŸ” **Bug Report** (Skip if feature request) - -### What's the issue? - - -### Where does it happen? -- [ ] **Admin CP Field**: Issue in the Craft control panel field interface -- [ ] **Frontend Render**: Problem with `{{ entry.field.render() }}` output -- [ ] **Caching**: Cached content not updating or cache errors -- [ ] **GDPR Compliance**: Issues with privacy settings (YouTube no-cookie, Vimeo DNT, etc.) -- [ ] **Network/Provider**: Provider-specific embed failures -- [ ] **GraphQL**: Issues with GraphQL field queries - -### Steps to reproduce +## Steps to Reproduce 1. 2. 3. -### Expected vs Actual -**Expected:** -**Actual:** +## Expected Behavior + -### Your Environment -- **Craft CMS**: -- **oEmbed Plugin**: -- **PHP**: -- **Provider**: -- **Test URL**: +## Actual Behavior + ---- - -## πŸ’‘ **Feature Request** (Skip if bug report) +## Environment +- **CraftCMS version:** +- **oEmbed plugin version:** +- **PHP version:** +- **Database (optional):** +- **Web server (optional):** +- **OS (optional):** -### What feature would you like? - +## URL/Provider Details +- **Provider:** +- **Example URL(s):** -### What problem does this solve? - +## Template / Code Used + -### Suggested implementation - +## Logs / Errors + ---- +## Error Messages / Screenshots + -## πŸ”§ Additional Context +## Additional Context + ### Admin CP Issues (if applicable) - Field preview not showing? - Save/validation problems? - Settings interface issues? -### Frontend Issues (if applicable) +### Frontend / Template Context (if applicable) - Template method used: `render()` / `embed()` / `media()` / `valid()` -- Cache enabled/disabled? +- Cache enabled or disabled? - GDPR settings active? +- Is this GraphQL-related? -### Provider-Specific Issues +### Provider-Specific Notes (if applicable) - Does the URL work on the provider's site? -- Using embed URL vs regular URL? -- API tokens configured (for Instagram/Facebook)? - -### Error Messages/Screenshots - +- Using embed URL vs regular/shared URL? +- Any provider API tokens configured (for Instagram/Facebook)? --- -**πŸ’‘ Pro Tips:** -- Many providers need **embed URLs** not regular URLs (check provider's share β†’ embed option) -- Check your **cache settings** - try disabling cache temporarily to test -- For Instagram: requires Facebook API token in plugin settings -- For GDPR: check if privacy settings are affecting embeds \ No newline at end of file +## Pro Tips +- Some providers require **embed URLs** instead of regular watch/share URLs. +- Try disabling cache temporarily to check whether this is cache-related. +- For Instagram, confirm Facebook API token configuration. +- For GDPR mode, check whether privacy settings are blocking the expected embed behavior. diff --git a/CHANGELOG.md b/CHANGELOG.md index 081adf0..b018970 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # oEmbed Changelog +## 3.2.1 - 2026-03-03 + +### Fixed + +- URLs without scheme (e.g., `youtube.com/watch?v=xxx`) are now automatically normalized with `https://` instead of being silently cleared on save (fixes #177) +- Fixed inconsistent return type in `normalizeValue()` when input was already an `OembedModel` - now consistently returns `OembedModel` instead of a string + +### Added + +- Unit tests for URL normalization in `OembedFieldTest` + ## 3.2.0 - 2026-01-14 ### Fixed @@ -50,6 +61,7 @@ ### Fixed - Fixed DOM parsing errors by properly escaping unencoded ampersands before parsing HTML. Thanks @mofman +- Fixed issue from #178 craft\web\View::renderJsFile() in "oembed/settings" ## 3.1.5 - 2024-05-22 diff --git a/composer.json b/composer.json index 95a5769..06b67b4 100755 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "wrav/oembed", "description": "A simple plugin to extract media information from websites, like youtube videos, twitter statuses or blog articles.", "type": "craft-plugin", - "version": "3.2.0", + "version": "3.2.1", "keywords": [ "craft", "cms", diff --git a/src/assetbundles/oembed/dist/js/oembed.js b/src/assetbundles/oembed/dist/js/oembed.js index 53639e1..fcff709 100755 --- a/src/assetbundles/oembed/dist/js/oembed.js +++ b/src/assetbundles/oembed/dist/js/oembed.js @@ -25,7 +25,7 @@ $('body').on('click', '.oembed-header', function () { } }); -$('body').on('keyup blur change', 'input.oembed-field', function () { +$('body').on('input paste keyup blur change', 'input.oembed-field', function () { var that = $(this); if (oembedOnChangeTimeout != null) { @@ -39,9 +39,10 @@ $('body').on('keyup blur change', 'input.oembed-field', function () { var cpTrigger = Craft && Craft.cpTrigger ? Craft.cpTrigger : 'admin'; if(val) { + var encodedVal = encodeURIComponent(val); $.ajax({ type: "GET", - url: "/"+cpTrigger.toString()+"/oembed/preview?url=" + val + "&options[]=", + url: "/"+cpTrigger.toString()+"/oembed/preview?url=" + encodedVal + "&options[]=", async: true }).done(function (res) { var preview = that.parent().find('.oembed-preview'); diff --git a/src/fields/OembedField.php b/src/fields/OembedField.php index 44fd7a9..2a59a6f 100644 --- a/src/fields/OembedField.php +++ b/src/fields/OembedField.php @@ -134,6 +134,34 @@ public function getContentGqlType(): \GraphQL\Type\Definition\Type|array ]; } + /** + * Normalize a URL by adding https:// scheme if missing. + * + * @param string $url The URL to normalize + * @return string The normalized URL + */ + private function normalizeUrl(string $url): string + { + $url = trim($url); + + if (empty($url)) { + return $url; + } + + // If URL already has a scheme, return as-is + if (preg_match('#^https?://#i', $url)) { + return $url; + } + + // If URL starts with //, add https: + if (str_starts_with($url, '//')) { + return 'https:' . $url; + } + + // Add https:// to URLs without a scheme + return 'https://' . $url; + } + /** * @param mixed $value The raw field value * @param ElementInterface|null $element The element the field is associated with, if there is one @@ -153,8 +181,9 @@ public function normalizeValue(mixed $value, ?craft\base\ElementInterface $eleme // If an instance of `OembedModel` and URL is set, return it if ($value instanceof OembedModel && $value->url) { - if (UrlHelper::isFullUrl($value->url)) { - return $this->value = $value->url; + $normalizedUrl = $this->normalizeUrl($value->url); + if (UrlHelper::isFullUrl($normalizedUrl)) { + return $this->value = new OembedModel($normalizedUrl); } else { // If we get here, something’s gone wrong return new OembedModel(null); @@ -170,7 +199,12 @@ public function normalizeValue(mixed $value, ?craft\base\ElementInterface $eleme $value = ArrayHelper::getValue($value, 'url'); } - // If URL stri ng, return an instance of `OembedModel` + // Normalize URL by adding scheme if missing (fixes #177) + if (is_string($value)) { + $value = $this->normalizeUrl($value); + } + + // If URL string, return an instance of `OembedModel` if (is_string($value) && UrlHelper::isFullUrl($value)) { return $this->value = new OembedModel($value); } diff --git a/src/services/OembedService.php b/src/services/OembedService.php index f363a84..4d8610a 100755 --- a/src/services/OembedService.php +++ b/src/services/OembedService.php @@ -113,6 +113,28 @@ public function render($url, array $options = [], array $cacheProps = []) return Template::raw($code); } + /** + * Normalize a URL by adding https:// when no scheme is present. + */ + private function normalizeUrl(string $url): string + { + $url = trim($url); + + if ($url === '') { + return $url; + } + + if (preg_match('#^https?://#i', $url)) { + return $url; + } + + if (str_starts_with($url, '//')) { + return 'https:' . $url; + } + + return 'https://' . $url; + } + /** * @param string $input * @param array $options @@ -555,6 +577,10 @@ public function embed($url, array $options = [], array $cacheProps = [], $factor // Normalize null/empty URLs immediately to prevent type errors $url = $url ?: ''; + if (is_string($url)) { + $url = $this->normalizeUrl($url); + } + // Check cache first $cacheKey = $this->generateCacheKey($url, $options, $cacheProps); $cachedResult = $this->getCachedEmbed($cacheKey); diff --git a/src/templates/settings.twig b/src/templates/settings.twig index 831ca48..fc179db 100755 --- a/src/templates/settings.twig +++ b/src/templates/settings.twig @@ -65,6 +65,3 @@ name: 'facebookKey', value: settings.facebookKey, }) }} - - -{{ craft.app.view.renderJsFile('@wrav/oembed/js/settings.js') }} \ No newline at end of file diff --git a/tests/unit/fields/OembedFieldTest.php b/tests/unit/fields/OembedFieldTest.php new file mode 100644 index 0000000..48e8278 --- /dev/null +++ b/tests/unit/fields/OembedFieldTest.php @@ -0,0 +1,96 @@ +field = new OembedField(); + } + + /** + * Test that URLs without scheme get https:// prepended + * @dataProvider urlNormalizationProvider + */ + public function testNormalizeValueAddsScheme(string $input, string $expectedUrl) + { + $result = $this->field->normalizeValue($input, null); + + $this->assertInstanceOf(OembedModel::class, $result); + $this->assertEquals($expectedUrl, $result->url); + } + + public static function urlNormalizationProvider(): array + { + return [ + 'youtube without scheme' => [ + 'youtube.com/watch?v=dQw4w9WgXcQ', + 'https://youtube.com/watch?v=dQw4w9WgXcQ' + ], + 'youtube with www without scheme' => [ + 'www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + ], + 'youtube with https scheme' => [ + 'https://youtube.com/watch?v=dQw4w9WgXcQ', + 'https://youtube.com/watch?v=dQw4w9WgXcQ' + ], + 'youtube with http scheme' => [ + 'http://youtube.com/watch?v=dQw4w9WgXcQ', + 'http://youtube.com/watch?v=dQw4w9WgXcQ' + ], + 'vimeo without scheme' => [ + 'vimeo.com/123456789', + 'https://vimeo.com/123456789' + ], + 'protocol-relative URL' => [ + '//youtube.com/watch?v=dQw4w9WgXcQ', + 'https://youtube.com/watch?v=dQw4w9WgXcQ' + ], + 'url with whitespace' => [ + ' youtube.com/watch?v=dQw4w9WgXcQ ', + 'https://youtube.com/watch?v=dQw4w9WgXcQ' + ], + ]; + } + + public function testNormalizeValueWithNull() + { + $result = $this->field->normalizeValue(null, null); + + $this->assertInstanceOf(OembedModel::class, $result); + $this->assertEquals('', $result->url); + } + + public function testNormalizeValueWithOembedModel() + { + $model = new OembedModel('youtube.com/watch?v=dQw4w9WgXcQ'); + $result = $this->field->normalizeValue($model, null); + + $this->assertInstanceOf(OembedModel::class, $result); + $this->assertEquals('https://youtube.com/watch?v=dQw4w9WgXcQ', $result->url); + } + + public function testNormalizeValueWithJsonString() + { + $json = json_encode(['url' => 'youtube.com/watch?v=dQw4w9WgXcQ']); + $result = $this->field->normalizeValue($json, null); + + $this->assertInstanceOf(OembedModel::class, $result); + $this->assertEquals('https://youtube.com/watch?v=dQw4w9WgXcQ', $result->url); + } +} diff --git a/tests/unit/services/OembedServiceTest.php b/tests/unit/services/OembedServiceTest.php index 08bc259..c1cdb4a 100644 --- a/tests/unit/services/OembedServiceTest.php +++ b/tests/unit/services/OembedServiceTest.php @@ -182,6 +182,31 @@ public function testRenderReturnsFallbackForFailedEmbed() $this->assertStringContainsString('invalid-url', (string)$result); } + public function testRenderNormalizesSchemeLessUrlForFallback() + { + $this->mockSettings->enableCache = false; + + $mockPlugin = $this->createMock(\craft\base\Plugin::class); + $mockPlugin->method('getVersion')->willReturn('3.1.5'); + $this->mockPlugin->method('getPlugin')->with('oembed')->willReturn($mockPlugin); + + $service = $this->getMockBuilder(OembedService::class) + ->onlyMethods(['createEmbed']) + ->getMock(); + + $service->setCacheService($this->mockCache); + $service->setPluginService($this->mockPlugin); + $service->setSettings($this->mockSettings); + $service->setEventDispatcher($this->mockEventDispatcher); + $service->method('createEmbed')->willReturn(null); + + $result = $service->render('www.youtube.com/watch?v=gUO37bwl7Dc'); + + $this->assertNotNull($result); + $this->assertStringContainsString('iframe', (string)$result); + $this->assertStringContainsString('https://www.youtube.com/watch?v=gUO37bwl7Dc', (string)$result); + } + public function testRenderHandlesEmptyUrl() { $this->mockSettings->enableCache = false; @@ -229,4 +254,4 @@ public function testBrokenUrlNotificationWithEmptyUrl() // If we get here without exceptions, the validation is working $this->assertTrue(true); } -} \ No newline at end of file +}