From 9bcfaf4d631ae1ec3569e22ce60a789efac06763 Mon Sep 17 00:00:00 2001 From: IanM Date: Wed, 28 Oct 2026 22:59:14 +0100 Subject: [PATCH] feat: OpenSearch, so browsers can add the forum as a search engine --- .../js/src/admin/components/BasicsPage.tsx | 7 + framework/core/locale/core.yml | 2 + .../ShowOpenSearchSuggestionsController.php | 75 ++++++++ framework/core/src/Api/routes.php | 7 + .../core/src/Forum/Branding/Branding.php | 19 ++ .../core/src/Forum/Branding/ShortName.php | 54 ++++++ .../core/src/Forum/Branding/ThemeImages.php | 8 + .../core/src/Forum/Branding/WebManifest.php | 4 +- .../Forum/Controller/OpenSearchController.php | 37 ++++ .../OpenSearch/OpenSearchDescription.php | 135 +++++++++++++ framework/core/src/Forum/routes.php | 6 + framework/core/src/Frontend/Content/Meta.php | 8 +- .../core/src/Settings/SettingsValidator.php | 5 + .../api/OpenSearchSuggestionsTest.php | 99 ++++++++++ .../integration/forum/OpenSearchTest.php | 178 ++++++++++++++++++ 15 files changed, 642 insertions(+), 2 deletions(-) create mode 100644 framework/core/src/Api/Controller/ShowOpenSearchSuggestionsController.php create mode 100644 framework/core/src/Forum/Branding/ShortName.php create mode 100644 framework/core/src/Forum/Controller/OpenSearchController.php create mode 100644 framework/core/src/Forum/OpenSearch/OpenSearchDescription.php create mode 100644 framework/core/tests/integration/api/OpenSearchSuggestionsTest.php create mode 100644 framework/core/tests/integration/forum/OpenSearchTest.php diff --git a/framework/core/js/src/admin/components/BasicsPage.tsx b/framework/core/js/src/admin/components/BasicsPage.tsx index 995149ad1c..de94644ce7 100644 --- a/framework/core/js/src/admin/components/BasicsPage.tsx +++ b/framework/core/js/src/admin/components/BasicsPage.tsx @@ -148,6 +148,13 @@ export default class BasicsPage ext setting: 'forum_title', label: app.translator.trans('core.admin.basics.forum_title_heading'), }) + .registerSetting({ + type: 'text', + setting: 'forum_short_name', + label: app.translator.trans('core.admin.basics.forum_short_name_heading'), + help: app.translator.trans('core.admin.basics.forum_short_name_text'), + maxlength: 16, + }) .registerSetting({ type: 'text', setting: 'forum_description', diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 3d8f4d0d3f..800b7810ca 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -196,6 +196,8 @@ core: avatar_driver_text: Select a driver that should be used for users' avatars when no user-uploaded avatar is available. By default, no avatar will be displayed when a user has not uploaded one. forum_description_heading: Forum Description forum_description_text: Enter a short sentence or two that describes your forum. This will appear in the meta tag and show up in search engines. + forum_short_name_heading: Short Name + forum_short_name_text: "Up to 16 characters, used where the forum title doesn't fit: under the forum's icon on a phone's home screen, and when someone adds the forum as a search engine in their browser. Without one, the forum title is used, cut short at a whole word if it is too long." forum_title_heading: Forum Title home_page_heading: Home Page home_page_text: Choose the page which users will first see when they visit your forum. diff --git a/framework/core/src/Api/Controller/ShowOpenSearchSuggestionsController.php b/framework/core/src/Api/Controller/ShowOpenSearchSuggestionsController.php new file mode 100644 index 0000000000..ca68a2f68f --- /dev/null +++ b/framework/core/src/Api/Controller/ShowOpenSearchSuggestionsController.php @@ -0,0 +1,75 @@ +getQueryParams(), 'q', '')); + $words = preg_split('/[^\p{L}\p{N}\p{M}]+/u', $query, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $titles = $descriptions = $urls = []; + + if (mb_strlen(implode('', $words)) >= self::MIN_LENGTH) { + $discussions = Discussion::whereVisibleTo(RequestUtil::getActor($request)) + ->where(function (Builder $titles) use ($words) { + foreach ($words as $word) { + $titles->where(fn (Builder $title) => $title + ->whereLike('discussions.title', "$word%") + ->orWhereLike('discussions.title', "% $word%")); + } + }) + ->orderByDesc('discussions.last_posted_at') + ->limit(self::LIMIT) + ->get(); + + foreach ($discussions as $discussion) { + $titles[] = $discussion->title; + $descriptions[] = ''; + $urls[] = $this->url->to('forum')->route('discussion', ['id' => $this->slugs->forResource(Discussion::class)->toSlug($discussion)]); + } + } + + return new JsonResponse([$query, $titles, $descriptions, $urls], 200, [ + 'Content-Type' => 'application/x-suggestions+json', + 'Cache-Control' => 'private, max-age=60', + ], JsonResponse::DEFAULT_JSON_FLAGS | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } +} diff --git a/framework/core/src/Api/routes.php b/framework/core/src/Api/routes.php index ff4210c499..d7b95c446d 100644 --- a/framework/core/src/Api/routes.php +++ b/framework/core/src/Api/routes.php @@ -197,6 +197,13 @@ $route->toController(Controller\DeleteFaviconController::class) ); + // Discussions a browser suggests when the forum is its search engine + $map->get( + '/opensearch/suggestions', + 'opensearch.suggestions', + $route->toController(Controller\ShowOpenSearchSuggestionsController::class) + ); + // Upload an app icon $map->post( '/app-icon', diff --git a/framework/core/src/Forum/Branding/Branding.php b/framework/core/src/Forum/Branding/Branding.php index 6cd31fa3b5..5bf49a6503 100644 --- a/framework/core/src/Forum/Branding/Branding.php +++ b/framework/core/src/Forum/Branding/Branding.php @@ -81,6 +81,25 @@ public function faviconIcoUrl(): ?string return $this->appIconUrls()['favicon.ico'] ?? null; } + /** + * Where the favicon a page can show is stored on the assets disk: a PNG, or + * an uploaded .ico. + */ + public function faviconPath(): ?string + { + if ($this->theme->has('favicon')) { + return $this->theme->paths('favicon')['png'] ?? null; + } + + if ($path = $this->settings->get('favicon_path')) { + return $path; + } + + $appIcon = $this->theme->has('app_icon') ? $this->theme->paths('app_icon') : $this->files->paths('app_icon_paths'); + + return $appIcon['favicon.png'] ?? null; + } + public function faviconSvgUrl(): ?string { return $this->theme->urls('favicon')['svg'] ?? null; diff --git a/framework/core/src/Forum/Branding/ShortName.php b/framework/core/src/Forum/Branding/ShortName.php new file mode 100644 index 0000000000..7f756a60ca --- /dev/null +++ b/framework/core/src/Forum/Branding/ShortName.php @@ -0,0 +1,54 @@ +settings->get('forum_short_name')); + + return $name === '' ? null : $name; + } + + /** + * The short name, or else the forum title, cut at the last whole word + * that fits when it is too long. + */ + public function get(): string + { + $name = $this->setting() ?? trim((string) $this->settings->get('forum_title')); + + if (mb_strlen($name) <= self::MAX_LENGTH) { + return $name; + } + + $cut = mb_substr($name, 0, self::MAX_LENGTH + 1); + $space = mb_strrpos($cut, ' '); + + return rtrim($space ? mb_substr($cut, 0, $space) : mb_substr($cut, 0, self::MAX_LENGTH)); + } +} diff --git a/framework/core/src/Forum/Branding/ThemeImages.php b/framework/core/src/Forum/Branding/ThemeImages.php index 658273594d..e1c28559d5 100644 --- a/framework/core/src/Forum/Branding/ThemeImages.php +++ b/framework/core/src/Forum/Branding/ThemeImages.php @@ -75,6 +75,14 @@ public function urls(string $image): array return array_map(fn (string $path) => $this->assets->url($path), $this->sync()[$image]['paths'] ?? []); } + /** + * @return array name => path on the assets disk + */ + public function paths(string $image): array + { + return $this->sync()[$image]['paths'] ?? []; + } + /** * The email copy of a theme image, as EmailLogo resolves it. * diff --git a/framework/core/src/Forum/Branding/WebManifest.php b/framework/core/src/Forum/Branding/WebManifest.php index a39a35d11d..5896cd02dc 100644 --- a/framework/core/src/Forum/Branding/WebManifest.php +++ b/framework/core/src/Forum/Branding/WebManifest.php @@ -26,7 +26,8 @@ public function __construct( protected Branding $branding, protected AppIcon $appIcon, protected UrlGenerator $url, - protected Container $container + protected Container $container, + protected ShortName $shortName ) { } @@ -45,6 +46,7 @@ public function build(): array $manifest = array_filter([ 'name' => $this->settings->get('forum_title'), + 'short_name' => $this->shortName->setting(), 'description' => $this->settings->get('forum_description'), 'start_url' => $base, 'scope' => $base, diff --git a/framework/core/src/Forum/Controller/OpenSearchController.php b/framework/core/src/Forum/Controller/OpenSearchController.php new file mode 100644 index 0000000000..41695d695e --- /dev/null +++ b/framework/core/src/Forum/Controller/OpenSearchController.php @@ -0,0 +1,37 @@ +description->build(), 200, [ + 'Content-Type' => 'application/opensearchdescription+xml; charset=utf-8', + 'Cache-Control' => 'public, max-age=3600', + ]); + } +} diff --git a/framework/core/src/Forum/OpenSearch/OpenSearchDescription.php b/framework/core/src/Forum/OpenSearch/OpenSearchDescription.php new file mode 100644 index 0000000000..f969e32e64 --- /dev/null +++ b/framework/core/src/Forum/OpenSearch/OpenSearchDescription.php @@ -0,0 +1,135 @@ +assets = $filesystemFactory->disk('flarum-assets'); + } + + public function build(): string + { + $forum = $this->url->to('forum')->base(); + + $xml = new XMLWriter(); + $xml->openMemory(); + $xml->setIndent(true); + $xml->startDocument('1.0', 'UTF-8'); + + $xml->startElement('OpenSearchDescription'); + $xml->writeAttribute('xmlns', 'http://a9.com/-/spec/opensearch/1.1/'); + $xml->writeAttribute('xmlns:moz', 'http://www.mozilla.org/2006/browser/search/'); + + $xml->writeElement('ShortName', $this->shortName->get()); + $xml->writeElement('Description', $this->description()); + $xml->writeElement('InputEncoding', 'UTF-8'); + + foreach ($this->images() as [$width, $height, $type, $data]) { + $xml->startElement('Image'); + $xml->writeAttribute('width', (string) $width); + $xml->writeAttribute('height', (string) $height); + $xml->writeAttribute('type', $type); + $xml->text("data:$type;base64,".base64_encode($data)); + $xml->endElement(); + } + + $this->url($xml, 'text/html', "$forum/?q={searchTerms}"); + $this->url($xml, 'application/x-suggestions+json', $this->url->to('api')->route('opensearch.suggestions').'?q={searchTerms}'); + $this->url($xml, 'application/opensearchdescription+xml', $this->url->to('forum')->route('opensearch'), 'self'); + + $xml->writeElement('moz:SearchForm', "$forum/"); + + $xml->endElement(); + $xml->endDocument(); + + return $xml->outputMemory(); + } + + protected function url(XMLWriter $xml, string $type, string $template, ?string $rel = null): void + { + $xml->startElement('Url'); + $xml->writeAttribute('type', $type); + + if ($rel) { + $xml->writeAttribute('rel', $rel); + } + + $xml->writeAttribute('template', $template); + $xml->endElement(); + } + + protected function description(): string + { + $description = trim((string) preg_replace('/\s+/u', ' ', strip_tags((string) $this->settings->get('forum_description')))); + + return mb_substr($description ?: (string) $this->settings->get('forum_title'), 0, self::MAX_DESCRIPTION_LENGTH); + } + + /** + * The favicon, embedded rather than linked: Firefox refuses linked icons + * over 10KB, and an embedded one doesn't depend on where assets are served. + * + * @return list width, height, type and data + */ + protected function images(): array + { + $path = $this->branding->faviconPath(); + + if (! $path || ! $this->assets->exists($path)) { + return []; + } + + $data = (string) $this->assets->get($path); + $size = @getimagesizefromstring($data); + + if (! $size) { + return []; + } + + if ($size[2] === IMAGETYPE_ICO) { + return [[16, 16, 'image/x-icon', $data]]; + } + + $favicon = $this->icons->favicon($data); + $png = getimagesizefromstring($favicon['png']); + + return [ + [16, 16, 'image/x-icon', $favicon['ico']], + [$png[0], $png[1], 'image/png', $favicon['png']], + ]; + } +} diff --git a/framework/core/src/Forum/routes.php b/framework/core/src/Forum/routes.php index a7b4f7e7b7..73bb5b6a12 100644 --- a/framework/core/src/Forum/routes.php +++ b/framework/core/src/Forum/routes.php @@ -132,4 +132,10 @@ 'favicon.ico', $route->toController(Controller\FaviconController::class) ); + + $map->get( + '/opensearch', + 'opensearch', + $route->toController(Controller\OpenSearchController::class) + ); }; diff --git a/framework/core/src/Frontend/Content/Meta.php b/framework/core/src/Frontend/Content/Meta.php index a834235f2a..f2960dcc65 100644 --- a/framework/core/src/Frontend/Content/Meta.php +++ b/framework/core/src/Frontend/Content/Meta.php @@ -10,6 +10,7 @@ namespace Flarum\Frontend\Content; use Flarum\Forum\Branding\Branding; +use Flarum\Forum\Branding\ShortName; use Flarum\Forum\Branding\WebManifest; use Flarum\Frontend\Document; use Flarum\Http\UrlGenerator; @@ -22,16 +23,19 @@ private Branding $branding; private WebManifest $manifest; private UrlGenerator $url; + private ShortName $shortName; public function __construct( private LocaleManager $locales, ?Branding $branding = null, ?WebManifest $manifest = null, - ?UrlGenerator $url = null + ?UrlGenerator $url = null, + ?ShortName $shortName = null ) { $this->branding = $branding ?? resolve(Branding::class); $this->manifest = $manifest ?? resolve(WebManifest::class); $this->url = $url ?? resolve(UrlGenerator::class); + $this->shortName = $shortName ?? resolve(ShortName::class); } public function __invoke(Document $document, Request $request): void @@ -72,6 +76,8 @@ private function buildHead(Document $document): array $head['apple-touch-icon'] = ''; } + $head['opensearch'] = ''; + if ($this->manifest->isLinked()) { $head['manifest'] = ''; } diff --git a/framework/core/src/Settings/SettingsValidator.php b/framework/core/src/Settings/SettingsValidator.php index e87094c3ee..f59c16fb83 100644 --- a/framework/core/src/Settings/SettingsValidator.php +++ b/framework/core/src/Settings/SettingsValidator.php @@ -9,6 +9,7 @@ namespace Flarum\Settings; +use Flarum\Forum\Branding\ShortName; use Flarum\Foundation\AbstractValidator; use Illuminate\Validation\Validator; @@ -24,6 +25,10 @@ class SettingsValidator extends AbstractValidator 'max:65000', ]; + protected array $rules = [ + 'forum_short_name' => ['max:'.ShortName::MAX_LENGTH], + ]; + protected function makeValidator(array $attributes): Validator { // Apply global rules first. diff --git a/framework/core/tests/integration/api/OpenSearchSuggestionsTest.php b/framework/core/tests/integration/api/OpenSearchSuggestionsTest.php new file mode 100644 index 0000000000..552bfd817c --- /dev/null +++ b/framework/core/tests/integration/api/OpenSearchSuggestionsTest.php @@ -0,0 +1,99 @@ +prepareDatabase([ + Discussion::class => [ + ['id' => 1, 'title' => 'Lightsail rigging guide', 'slug' => 'lightsail-rigging-guide', 'user_id' => 1, 'last_posted_at' => '2026-10-01 10:00:00'], + ['id' => 2, 'title' => 'Lightsail tuning', 'slug' => 'lightsail-tuning', 'user_id' => 1, 'last_posted_at' => '2026-10-02 10:00:00'], + ['id' => 3, 'title' => 'Something else', 'slug' => 'something-else', 'user_id' => 1], + ['id' => 4, 'title' => 'Lightsail secrets', 'slug' => 'lightsail-secrets', 'user_id' => 1, 'is_private' => true], + ['id' => 5, 'title' => 'Boat insurance claims', 'slug' => 'boat-insurance-claims', 'user_id' => 1], + ], + Post::class => [ + ['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'content' => '

How to rig it.

'], + ['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'content' => '

How to tune it.

'], + ['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'content' => '

Nothing to see.

'], + ['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'content' => '

Private.

'], + ['id' => 5, 'discussion_id' => 5, 'user_id' => 1, 'content' => '

Who covers what.

'], + ], + ]); + } + + private function suggest(string $q): array + { + $response = $this->send($this->request('GET', '/api/opensearch/suggestions')->withQueryParams(['q' => $q])); + + $this->assertEquals(200, $response->getStatusCode(), (string) $response->getBody()); + $this->assertSame('application/x-suggestions+json', $response->getHeaderLine('Content-Type')); + + return json_decode((string) $response->getBody(), true); + } + + #[Test] + public function matching_discussions_are_suggested_with_their_addresses(): void + { + [$query, $titles, $descriptions, $urls] = $this->suggest('lightsail'); + + $suggestions = array_combine($titles, $urls); + ksort($suggestions); + + $this->assertSame('lightsail', $query); + $this->assertSame([ + 'Lightsail rigging guide' => 'http://localhost/d/1-lightsail-rigging-guide', + 'Lightsail tuning' => 'http://localhost/d/2-lightsail-tuning', + ], $suggestions); + $this->assertCount(2, $descriptions); + } + + #[Test] + public function a_discussion_the_visitor_cannot_see_is_not_suggested(): void + { + $this->assertNotContains('Lightsail secrets', $this->suggest('lightsail')[1]); + } + + #[Test] + public function a_partly_typed_word_is_completed(): void + { + $this->assertSame(['Boat insurance claims'], $this->suggest('INSUR')[1]); + } + + #[Test] + public function a_word_is_completed_from_its_start_not_its_middle(): void + { + $this->assertSame([], $this->suggest('sura')[1]); + } + + #[Test] + public function every_word_typed_must_match(): void + { + $this->assertSame(['Lightsail tuning'], $this->suggest('lightsail tu')[1]); + } + + #[Test] + public function a_single_character_suggests_nothing(): void + { + $this->assertSame(['l', [], [], []], $this->suggest('l')); + } +} diff --git a/framework/core/tests/integration/forum/OpenSearchTest.php b/framework/core/tests/integration/forum/OpenSearchTest.php new file mode 100644 index 0000000000..ada8405abb --- /dev/null +++ b/framework/core/tests/integration/forum/OpenSearchTest.php @@ -0,0 +1,178 @@ +setting('forum_title', 'Acme Forum'); + $this->setting('forum_description', 'Talk about rockets & more.'); + } + + private function description(): SimpleXMLElement + { + $response = $this->send($this->request('GET', '/opensearch')); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertSame('application/opensearchdescription+xml; charset=utf-8', $response->getHeaderLine('Content-Type')); + + $xml = simplexml_load_string((string) $response->getBody()); + + $this->assertNotFalse($xml, 'The description is not well-formed XML.'); + $this->assertSame(self::NS, $xml->getNamespaces()['']); + + return $xml; + } + + /** + * @return array> type => attributes + */ + private function urls(SimpleXMLElement $xml): array + { + $urls = []; + + foreach ($xml->Url as $url) { + $urls[(string) $url['type']] = array_map('strval', iterator_to_array($url->attributes())); + } + + return $urls; + } + + private function html(): string + { + return (string) $this->send($this->request('GET', '/'))->getBody(); + } + + #[Test] + public function the_description_tells_browsers_how_to_search_the_forum(): void + { + $xml = $this->description(); + $urls = $this->urls($xml); + + $this->assertSame('Acme Forum', (string) $xml->ShortName); + $this->assertSame('Talk about rockets & more.', (string) $xml->Description); + $this->assertSame('UTF-8', (string) $xml->InputEncoding); + $this->assertSame('http://localhost/?q={searchTerms}', $urls['text/html']['template']); + $this->assertSame('http://localhost/api/opensearch/suggestions?q={searchTerms}', $urls['application/x-suggestions+json']['template']); + $this->assertSame(['type' => 'application/opensearchdescription+xml', 'rel' => 'self', 'template' => 'http://localhost/opensearch'], $urls['application/opensearchdescription+xml']); + $this->assertSame('http://localhost/', (string) $xml->children('http://www.mozilla.org/2006/browser/search/')->SearchForm); + } + + #[Test] + public function every_page_links_the_description(): void + { + $this->assertStringContainsString( + '', + $this->html() + ); + } + + #[Test] + public function a_long_title_is_cut_at_the_last_whole_word_that_fits(): void + { + $this->setting('forum_title', 'The Acme Rocket Builders Forum'); + + $this->assertSame('The Acme Rocket', (string) $this->description()->ShortName); + $this->assertStringContainsString('title="The Acme Rocket"', $this->html()); + } + + #[Test] + public function the_short_name_is_used_when_set(): void + { + $this->setting('forum_short_name', 'Rockets'); + + $this->assertSame('Rockets', (string) $this->description()->ShortName); + $this->assertStringContainsString('title="Rockets"', $this->html()); + $this->assertSame('Rockets', json_decode((string) $this->send($this->request('GET', '/manifest'))->getBody(), true)['short_name']); + } + + #[Test] + public function characters_that_mean_something_in_xml_are_escaped(): void + { + $this->setting('forum_title', 'R&D '); + + $this->assertSame('R&D ', (string) $this->description()->ShortName); + } + + #[Test] + public function the_favicon_is_embedded_in_the_description(): void + { + $path = tempnam(sys_get_temp_dir(), 'flarum-favicon-').'.png'; + $image = imagecreatetruecolor(128, 128); + imagefill($image, 0, 0, imagecolorallocate($image, 200, 30, 60)); + imagepng($image, $path); + + $this->send( + $this->request('POST', '/api/favicon', ['authenticatedAs' => 1]) + ->withUploadedFiles(['favicon' => new UploadedFile(new Stream($path), filesize($path), UPLOAD_ERR_OK, 'favicon.png', 'image/png')]) + ); + @unlink($path); + + $images = []; + + foreach ($this->description()->Image as $element) { + $images[(string) $element['type']] = [(int) $element['width'], (int) $element['height'], (string) $element]; + } + + $this->assertSame([16, 16], array_slice($images['image/x-icon'], 0, 2)); + $this->assertStringStartsWith('data:image/x-icon;base64,', $images['image/x-icon'][2]); + $this->assertSame([64, 64], array_slice($images['image/png'], 0, 2)); + $this->assertSame([64, 64, IMAGETYPE_PNG], array_slice(getimagesizefromstring(base64_decode(substr($images['image/png'][2], strlen('data:image/png;base64,')))), 0, 3)); + } + + #[Test] + public function a_large_favicon_stored_before_is_embedded_at_64px(): void + { + $this->setting('favicon_path', 'favicon-old.png'); + + $image = imagecreatetruecolor(192, 192); + ob_start(); + imagepng($image); + $this->app()->getContainer()->make('filesystem')->disk('flarum-assets')->put('favicon-old.png', (string) ob_get_clean()); + + foreach ($this->description()->Image as $element) { + if ((string) $element['type'] === 'image/png') { + $this->assertSame([64, 64], [(int) $element['width'], (int) $element['height']]); + $this->assertSame([64, 64], array_slice(getimagesizefromstring(base64_decode(substr((string) $element, strlen('data:image/png;base64,')))), 0, 2)); + + return; + } + } + + $this->fail('The favicon is not embedded as a PNG.'); + } + + #[Test] + public function a_short_name_longer_than_16_characters_is_refused(): void + { + $response = $this->send( + $this->request('POST', '/api/settings', [ + 'authenticatedAs' => 1, + 'json' => ['forum_short_name' => 'Seventeen chars!!'], + ]) + ); + + $this->assertEquals(422, $response->getStatusCode()); + } +}