diff --git a/specs/01-architecture.md b/specs/01-architecture.md index 2d02d166..8cda2f9f 100644 --- a/specs/01-architecture.md +++ b/specs/01-architecture.md @@ -159,7 +159,7 @@ referred to by package name. **(v2) Adding an app (Drive and the Slides skeleton in M8)** follows the same steps: create the Flutter app in the workspace (`resolution: workspace`, `lib/driver.dart`), add its `webApps` -entry, its `FlutterRoute`, its `flutter_apps` alias and port (§2.4), its origin to +entry, its `FlutterAppRoute` (via `webApps`), its `flutter_apps` alias and port (§2.4), its origin to `allowedOrigins`, its build to `flutter_build`, its CI matrix entry and dependency-rule row (§2.1), its web icons and favicon (02 §5; desktop icons only so the desktop target compiles, A13), its `WonkyAppKind` value and `config.json` URL, and its lines in `AGENTS.md`. The package @@ -334,6 +334,7 @@ controls: toasts for people, none for agents), `WonkyFindBar`, `showConflictRevi | `wonky_server` | `image` | Drive upload previews and thumbnails (Drive 02 §6) | | `wonky_server` | `mime` | MIME sniffing of uploaded files (Drive 02 §5, suite 13 §6) | | `wonky_server` | `dart_jsonwebtoken` | RS256 JWT of the Bugs & improvements GitHub App (specs/16 §13.3) | +| `wonky_server` | `whiskers` | The app shells' templates (`AppShellRoute`, §2.4); Serverpod's template engine (already a dependency of `serverpod`), whose registry only loads `web/templates` | | `wonky_ui` | `flutter_svg` | Empty-state illustrations (already a dependency of `shad`) | | `wonky_ui` | `flutter_test` | `pumpWonky` in `lib/testing.dart` (15 §7) | | `wonky_rich_editor` | `flutter_test` | `enterMarkdown` / `typeMarkdown` in `lib/testing.dart` (15 §7) | @@ -445,14 +446,15 @@ development) from one origin; there is no separate host app. | Path | Served from | Notes | |------|-------------|-------| -| `/drive/…` (v2) | `web/drive/` — `wonky_drive` built with `--base-href /drive/` | `FlutterRoute` (SPA fallback to `index.html`) | -| `/write/…` | `web/write/` — `wonky_write` built with `--base-href /write/` | `FlutterRoute` (SPA fallback to `index.html`) | -| `/sheets/…` | `web/sheets/` — `wonky_sheets` built with `--base-href /sheets/` | `FlutterRoute` | -| `/slides/…` (v2) | `web/slides/` — `wonky_slides` built with `--base-href /slides/` | `FlutterRoute` | +| `/drive/…` (v2) | `web/drive/` — `wonky_drive` built with `--base-href /drive/` | `FlutterAppRoute` (SPA fallback to `index.html`, the shell rendered as a template) | +| `/write/…` | `web/write/` — `wonky_write` built with `--base-href /write/` | `FlutterAppRoute` (SPA fallback to `index.html`, the shell rendered as a template) | +| `/sheets/…` | `web/sheets/` — `wonky_sheets` built with `--base-href /sheets/` | `FlutterAppRoute` | +| `/slides/…` (v2) | `web/slides/` — `wonky_slides` built with `--base-href /slides/` | `FlutterAppRoute` | | `//**/sqlite3.wasm`, `//**/db_worker.js` (nested paths) | `sqliteAssetAlias` middleware | Serves the app's root copies: when `sqlite3_web` opens a database in the page (its worker failed) it resolves the relative default URIs of `sqlite_async` (set inside `serverpod_database`, not configurable) against the page URL, e.g. `/sheets/s/sqlite3.wasm`, which the SPA fallback answered with `index.html` and the local replica could not open | | `//assets/assets/config.json` (each app) | `AppConfigRoute` | Replaces the bundled `assets/config.json` at runtime: `{apiUrl, webUrl, apps: {drive, write, sheets, slides}, cookieAuth, google?: {clientId, redirectUri}}` (the API URL — `wonky.publicApiUrl` / `WONKY_PUBLIC_API_URL`, default `https://api.wonky.work` in production (§2.5), else the API server's public host —, the public web origin for the legal pages, the base URL of each app for cross-app links, and Google sign-in, 04 §1.6). The public web origin is `wonky.publicWebUrl` / `WONKY_PUBLIC_WEB_URL`, default `https://wonky.work` in production (Serverpod Cloud generates the server config, so its own `*.serverpod.space` host is never used in links), else the web server's public host; e-mail and MCP links use it too | | `/auth/callback` | `FlutterWebAuth2CallbackRoute` | OAuth popup callback for Sign in with Google (04 §1.6) | -| `/privacy`, `/terms` | `StaticRoute.file` | Privacy policy and terms of service (`web/pages/privacy.html`, `terms.html`; suite 13 §5) | +| `/web/…` | `StaticRoute.withCacheBusting` (`cacheBustingConfig`) | `web/static/`, `public, immutable, max-age` one year. Pages link these files by their cache-busted URL, `/web/images/og-1@.jpg` (Relic strips the hash): every page that links one is a template (the legal pages, the app shells) and says `{{{@/web/…}}}`, which `WidgetRoute` resolves with `cacheBustingConfig` (the hash is computed once per file) | +| `/privacy`, `/terms` | `LegalPageRoute` (a `WidgetRoute`) | Privacy policy and terms of service, the templates `web/templates/privacy.html`, `terms.html` (suite 13 §5); their `/web/` URLs are cache-busted | | `/` | redirect | (v2) Always to **`/drive/`** — Wonky Drive's home, the default landing app (v2 review B4; `RootRedirectRoute`, `rootRedirectTarget`). The `wonky_last_app` cookie is no longer read (the apps may stop setting it) | | `/mcp`, `/files/…`, `/invite/:token` | server routes | See 09, 05, 06; (v2) `/files/d/:fileUuid/:name` downloads and `PUT /files/upload/:fileUuid` token uploads (MCP P0, chunked P1), Drive 02 §3.3, §4.3, §8 | | anything else unbuilt | `web/pages/build_flutter_app.html` | Template fallback shown when an app's build folder is missing | @@ -476,10 +478,23 @@ development) from one origin; there is no separate host app. `summary_large_image`). The SPA fallback serves that file for every URL of the app, shared documents and invites included, so the tags stay **generic: never an item's title, owner or content**, and there is no `og:url` (it would name another page as the canonical one). The - image is `https://wonky.work/web/images/og-1.jpg` (1200×630 JPEG, under WhatsApp's 300 KB; - source `icons/og.webp`): absolute, as crawlers need, and versioned in its name because - `/web/` is served as immutable — a new image gets a new name. The legal pages carry the - same tags with their own `og:url` and canonical link. + image is `web/static/images/og-1.jpg` (1200×630 JPEG, under WhatsApp's 300 KB; source + `icons/og.webp`), linked by an absolute URL, as crawlers need, that carries its content hash: + `index.html` says `https://wonky.work{{{@/web/images/og-1.jpg}}}`, and the server answers + `https://wonky.work/web/images/og-1@.jpg`. `/web/` is cached as immutable for a year by + browsers and the CDN, and X and LinkedIn cache an image per URL, so a changed image must get a + new URL; the hash does that without renaming the file. The shell is a template: + `FlutterAppRoute` is Serverpod's `FlutterRoute` (the build's files, the SPA fallback, the WASM + headers) except that `//`, `//index.html` and the SPA fallback are `AppShellRoute`, a + `WidgetRoute` with `cacheBustingConfig`, like the legal pages. It parses the build's + `index.html` once (again when the file changes) and answers `private, no-cache` without an + ETag (the shell is about 5 KB). Under `flutter run` the placeholder is served as it is, which + only the crawlers would read; never write `{{` anywhere else in `index.html`. The build's own + files are served without cache busting, which would strip `@…` from asset names such as + `icon@2x.png`. The legal pages carry the same tags (`{{{@/web/images/og-1.jpg}}}` in their + templates) with their own `og:url` and canonical link. + Crawlers also cache each shared page's preview (LinkedIn about 7 days, X about as long): after + a change, re-scrape a URL with LinkedIn's Post Inspector; X refreshes on its own. - **No Grammarly**: the apps check spelling themselves (`wonky_spellcheck`), and Grammarly's overlays on Flutter's hidden text fields interfere with editing. Each app's `web/index.html` opts out: `data-gramm="false" data-gramm_editor="false" data-enable-grammarly="false"` on diff --git a/tool/check_dependencies.dart b/tool/check_dependencies.dart index 88288d89..96779ef3 100644 --- a/tool/check_dependencies.dart +++ b/tool/check_dependencies.dart @@ -363,6 +363,7 @@ const rules = { 'image': 'Drive upload previews and thumbnails (Drive 02 §6)', 'mime': 'MIME sniffing of uploaded files (Drive 02 §5, suite 13 §6)', 'dart_jsonwebtoken': 'RS256 JWT of the GitHub App (specs/16 §13.3)', + 'whiskers': "app shell templates, Serverpod's template engine (§2.4)", }, ), 'wonky_client': PackageRule( diff --git a/wonky_drive/web/index.html b/wonky_drive/web/index.html index 9943b456..f758e326 100644 --- a/wonky_drive/web/index.html +++ b/wonky_drive/web/index.html @@ -25,15 +25,15 @@ Link previews (Open Graph, X cards; specs/01 §2.4). Every URL of the app, a shared document's too, serves this file: keep it generic, never per item. No og:url, so a preview names the link that was shared. The image - URL is absolute and versioned (/web/ is cached as immutable): give a new - image a new name. + URL is absolute; the server renders this file as a template, giving the + image its cache-busted URL, og-1@.jpg (specs/01 §2.4). --> - + diff --git a/wonky_server/lib/server.dart b/wonky_server/lib/server.dart index 0272c879..db59d023 100644 --- a/wonky_server/lib/server.dart +++ b/wonky_server/lib/server.dart @@ -27,9 +27,11 @@ import 'src/sheets/versions/sheets_version_provider.dart'; import 'src/slides/versions/slides_version_provider.dart'; import 'src/web/middleware/sqlite_asset_alias.dart'; import 'src/web/routes/app_config_route.dart'; +import 'src/web/routes/flutter_app_route.dart'; import 'src/web/routes/github_webhook_route.dart'; import 'src/web/routes/improvements_redirect_route.dart'; import 'src/web/routes/invite_route.dart'; +import 'src/web/routes/legal_page_route.dart'; import 'src/web/routes/publication_api_route.dart'; import 'src/web/routes/root_redirect_route.dart'; import 'src/web/web_apps.dart'; @@ -160,12 +162,10 @@ void _configureWebServer(Serverpod pod) { // Sign in with Google on web (specs/04 §1.6): the OAuth2 redirect lands // here and hands the code back to the app's sign-in popup. pod.webServer.addRoute(FlutterWebAuth2CallbackRoute(), '/auth/callback'); - // Legal pages (specs/13 §5), linked from account creation. + // Legal pages (specs/13 §5), linked from account creation: templates, so + // their /web/ asset URLs are cache-busted. for (final page in const ['privacy', 'terms']) { - pod.webServer.addRoute( - StaticRoute.file(File(Uri(path: 'web/pages/$page.html').toFilePath())), - '/$page', - ); + pod.webServer.addRoute(LegalPageRoute(page), '/$page'); } pod.webServer.addRoute(InviteRoute(), '/invite/:token'); // Bugs & improvements links (specs/16 §4.3): `/improvements/**` opens @@ -201,7 +201,8 @@ void _configureWebServer(Serverpod pod) { final dir = Directory(Uri(path: 'web/$app').toFilePath()); if (dir.existsSync()) { // Built with --wasm: cross-origin isolation headers (specs/13 §3). - pod.webServer.addRoute(FlutterRoute(dir), '/$app'); + // The shell, index.html, is a template with cache-busted /web/ URLs. + pod.webServer.addRoute(FlutterAppRoute(dir), '/$app'); // sqlite3.wasm asked for relative to a deep link (`/sheets/s/…`). pod.webServer.addMiddleware(sqliteAssetAlias(app, dir), '/$app'); } else { diff --git a/wonky_server/lib/src/web/routes/flutter_app_route.dart b/wonky_server/lib/src/web/routes/flutter_app_route.dart new file mode 100644 index 00000000..17b138d2 --- /dev/null +++ b/wonky_server/lib/src/web/routes/flutter_app_route.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:serverpod/serverpod.dart'; +import 'package:whiskers/whiskers.dart'; + +import '../../cache_busting.dart'; + +/// A Flutter web app (specs/01-architecture.md §2.4): Serverpod's +/// [FlutterRoute] (the build's files, the SPA fallback, the WASM headers), +/// except that the shell, `index.html`, is a template served by +/// [AppShellRoute], so its `{{{@/web/…}}}` URLs are cache-busted like the +/// other pages'. +class FlutterAppRoute extends FlutterRoute { + FlutterAppRoute(super.directory); + + @override + void injectIn(RelicRouter router) { + final shell = AppShellRoute(indexFile); + final subRouter = Router(); + if (enableWasmHeaders) { + subRouter.use('/', const WasmHeadersMiddleware().call); + } + // App URLs (`/write/d/…`) aren't files: they get the shell. + subRouter.use( + '/', + FallbackMiddleware( + fallback: shell, + on: (response) => response.statusCode == 404, + ).call, + ); + // Literal paths win over the static files' `/**`. + for (final path in const ['/', '/index.html']) { + subRouter.anyOf(shell.methods, path, shell.asHandler); + } + // No cache busting for the build's own files: it would strip `@…` from + // asset names such as `icon@2x.png`. + StaticRoute.directory( + directory, + cacheControlFactory: cacheControlFactory, + ).injectIn(subRouter); + router.attach('/', subRouter); + } +} + +/// An app's shell, the template [indexFile] (the build's `index.html`), +/// rendered with the suite's [cacheBustingConfig]: the link-preview image +/// `https://wonky.work{{{@/web/images/og-1.jpg}}}` becomes +/// `https://wonky.work/web/images/og-1@.jpg`, the URL `/web/` serves as +/// immutable, so the CDN and the link-preview caches of X and LinkedIn see a +/// new URL when the image changes. +/// +/// The template is parsed once and again when the file changes (a new build +/// while the server runs); `WidgetRoute` answers `private, no-cache`. +class AppShellRoute extends WidgetRoute { + AppShellRoute(this.indexFile) + : super( + methods: {Method.get, Method.head}, + cacheBustingConfig: cacheBustingConfig, + ); + + /// The build's `index.html`. + final File indexFile; + + Template? _template; + DateTime? _modified; + + @override + Future build(Session session, Request request) async { + final modified = (await indexFile.stat()).modified; + if (_template == null || modified != _modified) { + _template = Template( + await indexFile.readAsString(), + name: indexFile.path, + lenient: true, + ); + _modified = modified; + } + return _ShellWidget(_template!); + } +} + +/// Renders the shell's template; it has no values, only `{{{@/web/…}}}`. +class _ShellWidget extends WebWidget { + _ShellWidget(this.template); + + final Template template; + + @override + String render({String? Function(String)? onMissingVariable}) => + template.renderString( + const {}, + onMissingVariable: (name, context) => onMissingVariable?.call(name), + ); +} diff --git a/wonky_server/lib/src/web/routes/legal_page_route.dart b/wonky_server/lib/src/web/routes/legal_page_route.dart new file mode 100644 index 00000000..ec490786 --- /dev/null +++ b/wonky_server/lib/src/web/routes/legal_page_route.dart @@ -0,0 +1,22 @@ +import 'package:serverpod/serverpod.dart'; + +import '../../cache_busting.dart'; + +/// A legal page (specs/13 §5), the template `web/templates/.html`. +/// Its `{{{@/web/…}}}` asset URLs get their content hash +/// (`og-1@.jpg`), so the link-preview image and the icon are cached +/// per version by the CDN and by the X and LinkedIn crawlers (specs/01 §2.4). +class LegalPageRoute extends WidgetRoute { + LegalPageRoute(this.name) + : super( + methods: {Method.get, Method.head}, + cacheBustingConfig: cacheBustingConfig, + ); + + /// The template's name: `privacy` or `terms`. + final String name; + + @override + Future build(Session session, Request request) async => + TemplateWidget(name: name); +} diff --git a/wonky_server/pubspec.yaml b/wonky_server/pubspec.yaml index bdc5f7a1..65e7e0bd 100644 --- a/wonky_server/pubspec.yaml +++ b/wonky_server/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: serverpod: 4.1.0-beta.3 serverpod_auth_idp_server: 4.1.0-beta.3 serverpod_cloud_storage: 4.1.0-beta.3 + whiskers: ^0.1.1 wonky_core: path: ../packages/wonky_core wonky_sheets_core: diff --git a/wonky_server/test/integration/web/flutter_app_route_test.dart b/wonky_server/test/integration/web/flutter_app_route_test.dart new file mode 100644 index 00000000..7df3bc24 --- /dev/null +++ b/wonky_server/test/integration/web/flutter_app_route_test.dart @@ -0,0 +1,110 @@ +@Tags(['integration']) +library; + +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:wonky_server/src/cache_busting.dart'; +import 'package:wonky_server/src/web/routes/flutter_app_route.dart'; + +import '../../support/wonky_serverpod.dart'; + +/// The app shell rendered as a template (specs/01 §2.4): its +/// `{{{@/web/…}}}` URLs carry the asset's content hash, the build's own +/// files are served as they are. +void main() { + withWonkyServerpod('Given a Flutter app served by FlutterAppRoute', ( + sessionBuilder, + endpoints, + ) { + final client = HttpClient(); + late Directory build; + late int port; + late String image; + + setUpAll(() async { + build = Directory.systemTemp.createTempSync('wonky_app_'); + File('${build.path}/index.html').writeAsStringSync( + '' + '' + '', + ); + File('${build.path}/main.dart.js').writeAsStringSync('// app'); + File('${build.path}/assets/icon@2x.png') + ..createSync(recursive: true) + ..writeAsBytesSync([1, 2, 3]); + image = await cacheBustingConfig.assetPath('/web/images/og-1.jpg'); + + final pod = sessionBuilder.build().serverpod; + pod.webServer.addRoute(FlutterAppRoute(build), '/testapp'); + if (!pod.webServer.running) await pod.webServer.start(); + port = pod.webServer.port!; + }); + tearDownAll(() { + client.close(force: true); + build.deleteSync(recursive: true); + }); + + Future<(HttpClientResponse, String)> fetch( + String path, { + String method = 'GET', + }) async { + final request = await client.open(method, 'localhost', port, path); + final response = await request.close(); + final body = await response + .transform(const SystemEncoding().decoder) + .join(); + return (response, body); + } + + for (final path in const [ + '/testapp/', + '/testapp/index.html', + '/testapp/d/0192a3b4-1111-7000-8000-000000000001', + ]) { + test('when GET $path then the shell links the image by its ' + 'cache-busted URL', () async { + final (response, body) = await fetch(path); + + expect(response.statusCode, HttpStatus.ok); + expect(response.headers.contentType?.mimeType, 'text/html'); + expect(image, matches(RegExp(r'^/web/images/og-1@[0-9a-f]+\.jpg$'))); + expect(body, contains('content="https://wonky.work$image"')); + expect(body, isNot(contains('{{'))); + expect( + response.headers.value('cross-origin-embedder-policy'), + 'require-corp', + ); + }); + } + + test('when HEAD the shell then it answers 200 without a body', () async { + final (response, body) = await fetch('/testapp/', method: 'HEAD'); + + expect(response.statusCode, HttpStatus.ok); + expect(body, isEmpty); + }); + + test('when GET a build file then it is served as it is', () async { + final (response, body) = await fetch('/testapp/main.dart.js'); + + expect(response.statusCode, HttpStatus.ok); + expect(body, '// app'); + }); + + test('when GET an asset with @ in its name then it is not stripped as a ' + 'cache-busting hash', () async { + final request = await client.get( + 'localhost', + port, + '/testapp/assets/icon@2x.png', + ); + final response = await request.close(); + final bytes = await response.expand((b) => b).toList(); + + expect(response.statusCode, HttpStatus.ok); + expect(bytes, [1, 2, 3]); + }); + }); +} diff --git a/wonky_server/test/unit/web/legal_page_route_test.dart b/wonky_server/test/unit/web/legal_page_route_test.dart new file mode 100644 index 00000000..0313497c --- /dev/null +++ b/wonky_server/test/unit/web/legal_page_route_test.dart @@ -0,0 +1,105 @@ +import 'dart:io'; + +import 'package:serverpod/serverpod.dart'; +import 'package:test/test.dart'; +import 'package:wonky_server/src/cache_busting.dart'; +import 'package:wonky_server/src/web/routes/legal_page_route.dart'; + +/// The legal pages never read the session. +class _NoSession implements Session { + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnsupportedError('LegalPageRoute must not use the session'); +} + +void main() { + group('Given the legal pages and the cache-busted /web/ route', () { + late RelicServer server; + late HttpClient client; + + setUpAll(() async { + await templates.loadAll(Directory('web/templates')); + final app = RelicApp(); + for (final page in const ['privacy', 'terms']) { + final route = LegalPageRoute(page); + app.anyOf( + route.methods, + '/$page', + (req) => route.handleCall(_NoSession(), req), + ); + } + final static = StaticRoute.withCacheBusting(cacheBustingConfig); + app.anyOf( + static.methods, + '/web/**', + (req) => static.handleCall(_NoSession(), req), + ); + server = await app.serve(port: 0); + client = HttpClient(); + }); + + tearDownAll(() async { + client.close(force: true); + await server.close(); + }); + + Future<(HttpClientResponse, String)> get( + String path, { + String method = 'GET', + }) async { + final request = await client.open(method, 'localhost', server.port, path); + final response = await request.close(); + final body = await response + .transform(const SystemEncoding().decoder) + .join(); + return (response, body); + } + + for (final page in const ['privacy', 'terms']) { + test('when GET /$page then the preview image URL carries the image\'s ' + 'content hash', () async { + final image = await cacheBustingConfig.assetPath( + '/web/images/og-1.jpg', + ); + + final (response, body) = await get('/$page'); + + expect(response.statusCode, HttpStatus.ok); + expect(image, matches(RegExp(r'^/web/images/og-1@[0-9a-f]+\.jpg$'))); + expect( + body, + contains( + '', + ), + ); + expect(body, isNot(contains('{{'))); + expect(body, isNot(contains('/web/images/og-1.jpg'))); + expect(body, isNot(contains('/web/images/wonky-work.webp'))); + }); + } + + test('when GET the cache-busted image URL then it serves the JPEG as ' + 'immutable', () async { + final image = await cacheBustingConfig.assetPath('/web/images/og-1.jpg'); + + final request = await client.get('localhost', server.port, image); + final response = await request.close(); + final bytes = await response.fold(0, (n, b) => n + b.length); + + expect(response.statusCode, HttpStatus.ok); + expect(response.headers.contentType?.mimeType, 'image/jpeg'); + expect( + response.headers.value(HttpHeaders.cacheControlHeader), + allOf(contains('immutable'), contains('max-age=31536000')), + ); + expect(bytes, File('web/static/images/og-1.jpg').lengthSync()); + }); + + test('when HEAD /privacy then it answers 200 without a body', () async { + final (response, body) = await get('/privacy', method: 'HEAD'); + + expect(response.statusCode, HttpStatus.ok); + expect(body, isEmpty); + }); + }); +} diff --git a/wonky_server/web/pages/privacy.html b/wonky_server/web/templates/privacy.html similarity index 97% rename from wonky_server/web/pages/privacy.html rename to wonky_server/web/templates/privacy.html index 1d323d5c..22f06188 100644 --- a/wonky_server/web/pages/privacy.html +++ b/wonky_server/web/templates/privacy.html @@ -13,13 +13,13 @@ - + - +