diff --git a/api/http/utils.ts b/api/http/utils.ts index 34717066..b96c51f3 100644 --- a/api/http/utils.ts +++ b/api/http/utils.ts @@ -1,3 +1,5 @@ +import { SUBPROTOCOL } from '../index.ts' + export function isObject(body: unknown): body is object { return typeof body === 'object' && body !== null } @@ -54,10 +56,12 @@ export async function fetchJSON( body: JSON.stringify(body), credentials: 'include', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'X-Subprotocol': String(SUBPROTOCOL) }, method }) + if (opts.response) opts.response(response) return response as HTTPResponse } diff --git a/api/index.ts b/api/index.ts index 235ef378..5763b4b1 100644 --- a/api/index.ts +++ b/api/index.ts @@ -2,6 +2,7 @@ * Client’s protocol version */ export const SUBPROTOCOL = 0 +export const SUBPROTOCOL_ERROR_MESSAGE = 'Outdated client' /** * Project’s version to use in UI. diff --git a/core/client.ts b/core/client.ts index a0c67cb7..c43ddad1 100644 --- a/core/client.ts +++ b/core/client.ts @@ -51,6 +51,7 @@ function getServer(): ClientOptions['server'] { let prevClient: CrossTabClient | undefined export const client = atom() +export const isOutdatedClient = atom(false) onEnvironment(({ logStoreCreator }) => { let unbindUser = effect( @@ -71,6 +72,14 @@ onEnvironment(({ logStoreCreator }) => { encryptActions(logux, key, { ignore: [deleteUser.type] }) + + /* node:coverage disable */ + logux.node.on('error', error => { + if (error.type === 'wrong-subprotocol') { + isOutdatedClient.set(true) + } + }) + /* node:coverage enable */ logux.start(connect) prevClient = logux client.set(logux) diff --git a/core/lib/http.ts b/core/lib/http.ts index 79108fcd..86f949b3 100644 --- a/core/lib/http.ts +++ b/core/lib/http.ts @@ -1,4 +1,4 @@ -import type { Requester } from '@slowreader/api' +import { type Requester, SUBPROTOCOL_ERROR_MESSAGE } from '@slowreader/api' import { getEnvironment } from '../environment.ts' import { @@ -6,6 +6,7 @@ import { HTTPStatusError, UserFacingError } from '../errors.ts' +import { isOutdatedClient } from '../index.ts' /** * Takes fetch() wrapper from `@slowreader/api/http` and do the request @@ -31,6 +32,9 @@ export async function checkErrors( if (!response.ok) { let text = await response.text() if (response.status === 400 && text !== 'Invalid request') { + if (text === SUBPROTOCOL_ERROR_MESSAGE) { + isOutdatedClient.set(true) + } throw new UserFacingError(text) } else { throw new HTTPStatusError( diff --git a/core/messages/index.ts b/core/messages/index.ts index 01849f2f..6b43364c 100644 --- a/core/messages/index.ts +++ b/core/messages/index.ts @@ -12,4 +12,5 @@ export * from './post/en.ts' export * from './profile/en.ts' export * from './refresh/en.ts' export * from './settings/en.ts' +export * from './update-client/en.ts' export * from './welcome/en.ts' diff --git a/core/messages/update-client/en.ts b/core/messages/update-client/en.ts new file mode 100644 index 00000000..dd13f3b3 --- /dev/null +++ b/core/messages/update-client/en.ts @@ -0,0 +1,8 @@ +import { i18n } from '../../i18n.ts' + +export const updateClientMessages = i18n('updateClient', { + pageText: + 'You’re using an outdated client version. Please update the app to continue.', + pageTitle: 'Update Your App', + updateButton: 'Update Now' +}) diff --git a/core/pages/index.ts b/core/pages/index.ts index d887bba0..9889fe1c 100644 --- a/core/pages/index.ts +++ b/core/pages/index.ts @@ -14,6 +14,7 @@ import { importPage } from './import.ts' import { profilePage } from './profile.ts' import { signupPage } from './signup.ts' import { startPage } from './start.ts' +import { updateClientPage } from './update-client.ts' export type { AboutPage } from './about.ts' export type { AddPage } from './add.ts' @@ -26,6 +27,7 @@ export type { ImportPage } from './import.ts' export type { ProfilePage } from './profile.ts' export type { SignupPage } from './signup.ts' export type { StartPage } from './start.ts' +export type { UpdateClientPage } from './update-client.ts' export const pages = { about: aboutPage, @@ -45,6 +47,7 @@ export const pages = { signup: signupPage, slow: slowPage, start: startPage, + updateClient: updateClientPage, welcome: createSimplePage('welcome') } satisfies { [Name in RouteName]: Name extends 'fast' | 'slow' diff --git a/core/pages/update-client.ts b/core/pages/update-client.ts new file mode 100644 index 00000000..64731134 --- /dev/null +++ b/core/pages/update-client.ts @@ -0,0 +1,17 @@ +import { createPage } from './common.ts' + +export const updateClientPage = createPage('updateClient', () => { + /* node:coverage disable */ + function handleUpdateClient(): void { + location.reload() + } + /* node:coverage enable */ + + return { + exit() {}, + handleUpdateClient, + params: {} + } +}) + +export type UpdateClientPage = ReturnType diff --git a/core/router.ts b/core/router.ts index a5bcc59e..a04e78cf 100644 --- a/core/router.ts +++ b/core/router.ts @@ -28,6 +28,7 @@ export interface Routes { from?: number } start: {} + updateClient: {} welcome: {} } diff --git a/core/test/pages/update-client.test.ts b/core/test/pages/update-client.test.ts new file mode 100644 index 00000000..0bc2899e --- /dev/null +++ b/core/test/pages/update-client.test.ts @@ -0,0 +1,28 @@ +import { equal } from 'node:assert' +import { afterEach, beforeEach, test } from 'node:test' + +import { currentPage } from '../../index.ts' +import { + cleanClientTest, + enableClientTest, + openPage, + setBaseTestRoute +} from '../utils.ts' + +beforeEach(() => { + enableClientTest() + setBaseTestRoute({ params: {}, route: 'updateClient' }) +}) + +afterEach(async () => { + await cleanClientTest() +}) + +test('shows update client page', () => { + let page = openPage({ + params: {}, + route: 'updateClient' + }) + + equal(currentPage.get().route, page.route) +}) diff --git a/server/lib/http.ts b/server/lib/http.ts index 8b38d7f5..f4500135 100644 --- a/server/lib/http.ts +++ b/server/lib/http.ts @@ -1,5 +1,5 @@ import type { BaseServer } from '@logux/server' -import type { Endpoint } from '@slowreader/api' +import { type Endpoint, SUBPROTOCOL_ERROR_MESSAGE } from '@slowreader/api' import type { IncomingMessage, ServerResponse } from 'node:http' import { config } from './config.ts' @@ -37,7 +37,7 @@ function allowCors(res: ServerResponse, origin: string): void { 'Access-Control-Allow-Methods', 'OPTIONS, POST, GET, PUT, DELETE' ) - res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Subprotocol') } const LOCALHOST = /:\/\/localhost:/ @@ -67,6 +67,18 @@ export function jsonApi( allowCors(res, req.headers.origin) } } + + if (req.headers['x-subprotocol'] && server.options.minSubprotocol) { + let clientSubprotocol = Number(req.headers['x-subprotocol']) + + if ( + isNaN(clientSubprotocol) || + clientSubprotocol < server.options.minSubprotocol + ) { + return badRequest(res, SUBPROTOCOL_ERROR_MESSAGE) + } + } + if (req.method === 'OPTIONS') { res.writeHead(200) res.end() diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index 450329cb..3d5a7175 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -4,7 +4,8 @@ import { setPassword, signIn, signOut, - signUp + signUp, + SUBPROTOCOL_ERROR_MESSAGE } from '@slowreader/api' import { eq } from 'drizzle-orm' import { deepEqual, equal, notEqual, ok } from 'node:assert/strict' @@ -253,3 +254,19 @@ test('supports CORS', async () => { 'https://dev.slowreader.app' ) }) + +test('rejects old clients', async () => { + server = buildTestServer() + server.options.minSubprotocol = 2 + + let response = await server.fetch('/users/1', { + headers: { + 'Content-Type': 'application/json', + 'X-Subprotocol': '0' + }, + method: 'POST' + }) + equal(response.status, 400) + let text = await response.text() + equal(text, SUBPROTOCOL_ERROR_MESSAGE) +}) diff --git a/web/.size-limit.json b/web/.size-limit.json index 46f2ef92..0f6ddce4 100644 --- a/web/.size-limit.json +++ b/web/.size-limit.json @@ -16,7 +16,7 @@ "!dist/assets/playpen-sans-*.woff2", "!dist/assets/*-{italic,math,latin-ext}-*.woff2" ], - "limit": "185 KB" + "limit": "190 KB" }, { "name": "Core scripts to execute", diff --git a/web/main/main.svelte b/web/main/main.svelte index 0e387a0f..5e12f7af 100644 --- a/web/main/main.svelte +++ b/web/main/main.svelte @@ -2,8 +2,10 @@ import { busy, currentPage, + isOutdatedClient, layoutType, notFound, + pages, popupsStatus, signOut, subscribeUntil, @@ -23,6 +25,7 @@ import ProfilePage from '../pages/profile.svelte' import SignupPage from '../pages/signup.svelte' import StartPage from '../pages/start.svelte' + import UpdateClientPage from '../pages/update-client.svelte' import FeedPopup from '../popups/feed.svelte' import LoadingPopup from '../popups/loading.svelte' import NotFoundPopup from '../popups/not-found.svelte' @@ -59,6 +62,8 @@ {#if !globalLoader} {/if} +{:else if $isOutdatedClient} + {:else if $currentPage.route === 'notFound'} {:else if $currentPage.route === 'fast'} diff --git a/web/pages/update-client.svelte b/web/pages/update-client.svelte new file mode 100644 index 00000000..bb105ba2 --- /dev/null +++ b/web/pages/update-client.svelte @@ -0,0 +1,34 @@ + + + + + + {$t.pageTitle} +

{$t.pageText}

+ +
+
+ + diff --git a/web/stores/url-router.ts b/web/stores/url-router.ts index 6e3d2af6..35e55288 100644 --- a/web/stores/url-router.ts +++ b/web/stores/url-router.ts @@ -34,6 +34,7 @@ export const pathRouter = createRouter({ signup: '/signup', slow: '/slow/:feed?', start: '/start', + updateClient: '/update-client', welcome: '/welcome' }) diff --git a/web/stories/pages/update-client.stories.svelte b/web/stories/pages/update-client.stories.svelte new file mode 100644 index 00000000..54fb7821 --- /dev/null +++ b/web/stories/pages/update-client.stories.svelte @@ -0,0 +1,18 @@ + + + + + + +