diff --git a/src/langbot/pkg/api/http/controller/main.py b/src/langbot/pkg/api/http/controller/main.py index e19552c65..dfe8a685a 100644 --- a/src/langbot/pkg/api/http/controller/main.py +++ b/src/langbot/pkg/api/http/controller/main.py @@ -174,9 +174,21 @@ class HTTPController: frontend_path = paths.get_frontend_path() + async def frontend_index(): + from .social_preview import cloud_preview_html + + response = await quart.send_from_directory( + frontend_path, 'index.html', mimetype='text/html', conditional=False + ) + if quart.request.host.split(':', 1)[0].lower() == 'cloud.langbot.app': + response.set_data(cloud_preview_html(await response.get_data(as_text=True), quart.request.host)) + response.headers.pop('ETag', None) + response.headers.pop('Last-Modified', None) + return response + @self.quart_app.route('/') async def index(): - response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html') + response = await frontend_index() response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' response.headers['Pragma'] = 'no-cache' response.headers['Expires'] = '0' @@ -207,7 +219,7 @@ class HTTPController: return response # Fallback to index.html for SPA client-side routing - response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html') + response = await frontend_index() response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' response.headers['Pragma'] = 'no-cache' response.headers['Expires'] = '0' diff --git a/src/langbot/pkg/api/http/controller/social_preview.py b/src/langbot/pkg/api/http/controller/social_preview.py new file mode 100644 index 000000000..03e530ee0 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/social_preview.py @@ -0,0 +1,20 @@ +"""Crawler-visible preview for the hosted Cloud frontend only.""" + +CLOUD_IMAGE = 'https://cloud.langbot.app/social/cloud-v3.png' + + +def cloud_preview_html(html: str, hostname: str) -> str: + if hostname.split(':', 1)[0].lower() != 'cloud.langbot.app': + return html + tags = ( + '' + '' + '' + f'' + '' + '' + '' + '' + f'' + ) + return html.replace('', tags + '', 1) diff --git a/tests/unit_tests/test_social_preview.py b/tests/unit_tests/test_social_preview.py new file mode 100644 index 000000000..c71ec6341 --- /dev/null +++ b/tests/unit_tests/test_social_preview.py @@ -0,0 +1,28 @@ +import importlib.util +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('social_preview', ROOT / 'src/langbot/pkg/api/http/controller/social_preview.py') +assert spec is not None and spec.loader is not None +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class SocialPreviewTest(unittest.TestCase): + def test_cloud_has_crawler_visible_image(self): + html = (ROOT / 'web/index.html').read_text() + result = module.cloud_preview_html(html, 'cloud.langbot.app') + self.assertIn(module.CLOUD_IMAGE, result) + self.assertIn('summary_large_image', result) + self.assertEqual(result.count('property="og:image"'), 1) + self.assertTrue((ROOT / 'web/public/social/cloud-v3.png').is_file()) + + def test_self_hosted_is_unchanged(self): + html = 'LangBot' + for host in ['localhost:5300', 'example.com', 'cloud.langbot.app.evil.com']: + self.assertEqual(module.cloud_preview_html(html, host), html) + + +if __name__ == '__main__': + unittest.main() diff --git a/web/public/social/cloud-v3.png b/web/public/social/cloud-v3.png new file mode 100644 index 000000000..5ab66f53f Binary files /dev/null and b/web/public/social/cloud-v3.png differ