diff --git a/src/index.ts b/src/index.ts index a2bc1fa..f86a8bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export {}; import { getServersList } from './servers'; +import { getLatestRelease } from './releases'; // Main entry point. addEventListener('fetch', (event) => { @@ -15,6 +16,8 @@ export async function handleRequest(request: Request) { case '/resources': // Public for resources. case '/servers': return getServersList(request); + case '/releases': + return getLatestRelease(request); } return new Response('', { status: 404 }); } diff --git a/src/releases.ts b/src/releases.ts new file mode 100644 index 0000000..ef611db --- /dev/null +++ b/src/releases.ts @@ -0,0 +1,95 @@ +import { parseAppVersion, parseApkName } from './versions'; + +const GITHUB_RELEASES_URL: string = 'https://api.github.com/repos/organicmaps/organicmaps/releases'; +// https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api?apiVersion=2022-11-28#authenticating-with-a-personal-access-token +const GITHUB_BEARER_TOKEN: string = + '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'; + +interface AppReleaseMetadata { + published_at: Date; + code: number; + flavor?: string; + type?: string; + apk: { + url: string; + name: string; + size: number; + }; + // TODO: figure out how to define map properly. + news: { + 'en-US': string; + }; +} + +interface GitHubReleaseAssetMetadata { + browser_download_url: string; + name: string; + size: number; + content_type: string; + state: string; +} + +interface GitHubReleaseMetadata { + published_at: Date; + draft: boolean; + prerelease: boolean; + body: string; + assets: [GitHubReleaseAssetMetadata]; +} + +export async function getLatestRelease(request: Request) { + const appVersion = parseAppVersion(request.headers.get('x-om-appversion')); + if (!appVersion) return new Response('Unknown app version', { status: 400 }); + + // The release version doesn't have `-release` suffix, thus type should be `undefined`. + if (appVersion.flavor != 'web' || appVersion.type !== undefined) + return new Response('Unknown app version', { status: 400 }); + + const response = await fetch(GITHUB_RELEASES_URL, { + cf: { + // Always cache this fetch (including 404 responses) regardless of content type + // for a max of 30 minutes before revalidating the resource + cacheTtl: 30 * 60, + cacheEverything: true, + }, + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'curl/8.4.0', // GitHub returns 403 without this. + 'X-GitHub-Api-Version': '2022-11-28', + Authorization: `Bearer ${GITHUB_BEARER_TOKEN}`, + }, + }); + if (response.status != 200) + return new Response(`Bad response status ${response.status} ${response.statusText} ${response.body} from GitHub`, { + status: 500, + }); + + const releases = (await response.json()) as [GitHubReleaseMetadata]; + const release = releases.find((release) => release.draft == false && release.prerelease == false); + if (release == undefined) return new Response('No published release in GitHub response', { status: 500 }); + + const apk = release.assets.find( + (asset) => asset.content_type == 'application/vnd.android.package-archive' && asset.name.endsWith('.apk'), + ); + if (!apk) throw new Error('The latest release does not have APK asset'); + const apkVersion = parseApkName(apk.name); + if (!apkVersion) throw new Error(`Failed to parse APK name: ${apk}`); + if (apkVersion.flavor != 'web' || apkVersion.type != 'release') throw new Error(`Unsupported APK name: ${apk}`); + + const result: AppReleaseMetadata = { + published_at: release.published_at, + code: apkVersion.code, + news: { + 'en-US': release.body, + }, + apk: { + name: apk.name, + size: apk.size, + url: apk.browser_download_url, + }, + }; + + return new Response(JSON.stringify(result), { + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/src/versions.ts b/src/versions.ts index 6851527..0608e56 100644 --- a/src/versions.ts +++ b/src/versions.ts @@ -13,17 +13,40 @@ export function parseDataVersion(strDataVersion: string | null): number | null { return dataVersion; } +interface AppVersion { + code: number; + build?: number; + flavor?: string; + type?: string; // 'debug' | 'beta' +} + +const APK_NAME_RE = /^OrganicMaps-(?2\d{7})-(?[A-Za-z3264]+)-(?beta|debug|release)\.apk$/; + +export function parseApkName(apkName: string): AppVersion | null { + const m = apkName.match(APK_NAME_RE); + if (m === null || !m.groups) return null; + const code = parseInt(m.groups.code); + if (Number.isNaN(code) || code < 20000000 || code > 30000000) return null; + const flavor = m.groups.flavor; + const type = m.groups.type; + const apkVersion: AppVersion = { + code: code, + flavor: flavor, + type: type, + }; + return apkVersion; +} + // 2022.11.20 for iOS versions released before November 21 (without donate menu) // 2022.11.24-4-ios for newer iOS versions (with donate menu) // 2022.12.24-10-Google for Android +// 2022.12.24-10-Google-beta for Android // 2022.12.24-3-3f4ca43-Linux or 2022.12.24-3-3f4ca43-dirty-Linux for Linux // 2022.12.24-3-3f4ca43-Darwin for Mac const VERSION_RE = - /(?\d{4})\.(?\d{1,2})\.(?\d{1,2})(?:$|-(?[0-9]+)(?:-[0-9a-f]+)?(?:-dirty)?-(?[A-Za-z3264]+))/; + /(?\d{4})\.(?\d{1,2})\.(?\d{1,2})(?:$|-(?[0-9]+)(?:-[0-9a-f]+)?(?:-dirty)?-(?[A-Za-z3264]+))(?:-(?beta|debug))?/; // Returns code like 221224 for both platforms, build and flavor for Android and newer iOS versions. -export function parseAppVersion( - versionName: string | null, -): { code: number; build?: number; flavor?: string | undefined } | null { +export function parseAppVersion(versionName: string | null): AppVersion | null { if (!versionName) { return null; } @@ -51,14 +74,18 @@ export function parseAppVersion( return { code: code }; } - const buildNumber = parseInt(m.groups.build); - const build = Number.isNaN(buildNumber) ? 0 : buildNumber; // 'ios' for iOS devices. const flavor = (m.groups.flavor !== undefined && m.groups.flavor.toLowerCase()) || undefined; - return { + const appVersion: AppVersion = { code: code, flavor: flavor, - build: build, }; + + const buildNumber = parseInt(m.groups.build); + if (!Number.isNaN(buildNumber)) appVersion.build = buildNumber; + + if (m.groups.type !== undefined) appVersion.type = m.groups.type; + + return appVersion; } diff --git a/test/releases.test.ts b/test/releases.test.ts new file mode 100644 index 0000000..a73db2c --- /dev/null +++ b/test/releases.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from '@jest/globals'; +import { getLatestRelease } from '../src/releases'; + +describe('Get app release version for flavor', () => { + const flavors = ['2022.08.23-1-web']; + for (let flavor of flavors) { + test(flavor, async () => { + let req = new Request('http://127.0.0.1:8787/releases', { + headers: { + 'X-OM-AppVersion': flavor.toLowerCase(), + }, + }); + const response = await getLatestRelease(req); + // TODO: How to print response.text in case of error? + expect(response.status).toBe(200); + const result = JSON.parse(await response.text()); + expect(Number.parseInt(result.code)).toBeGreaterThanOrEqual(23040200); + expect(result.apk).toBeDefined(); + }); + } +}); + +describe('Unsupported flavors for app update checks', () => { + const unsupported = [ + 'garbage', + '', + '20220823', + '2022.08', + '2022.08.23', // Older iOS clients + '2022.08.23-1-Google-beta', + '2022.08.23-5-Google-debug', + '2022.08.23-1-fdroid-beta', + '2022.08.23-1-fdroid-debug', + '2022.08.23-1-web-beta', + '2022.08.23-1-web-debug', + '2022.08.23-1-Huawei-beta', + '2022.08.23-1-Huawei-debug', + // Mac OS version is not published yet anywhere. + '2023.04.28-9-592bca9a-dirty-Darwin', + '2023.04.28-9-592bca9a-Darwin', + ]; + for (let flavor of unsupported) { + test(flavor, async () => { + let req = new Request('http://127.0.0.1:8787/releases', { + headers: { + 'X-OM-AppVersion': flavor.toLowerCase(), + }, + }); + try { + const response = await getLatestRelease(req); + expect(response.status).toBeGreaterThanOrEqual(400); + } catch (err) { + expect(err).toContain('Unsupported app version'); + } + }); + } +}); diff --git a/test/versions.test.ts b/test/versions.test.ts index a86d137..5073f38 100644 --- a/test/versions.test.ts +++ b/test/versions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from '@jest/globals'; -import { parseDataVersion, parseAppVersion } from '../src/versions'; +import { parseDataVersion, parseAppVersion, parseApkName } from '../src/versions'; describe('parseDataVersion', () => { const tests: { [key: string]: number | null } = { @@ -18,6 +18,22 @@ describe('parseDataVersion', () => { test('', () => expect(parseDataVersion(null)).toEqual(null)); }); +describe('parseApkName', () => { + const tests: { [key: string]: object | null } = { + 'OrganicMaps-24020611-web-release.apk': { code: 24020611, flavor: 'web', type: 'release' }, + 'OrganicMaps-24020611-web-release': null, + 'OrganicMaps-24020611-web-.apk': null, + 'OrganicMaps-24020611- -.apk': null, + 'OrganicMaps-2402061-web-release.apk': null, + garbage: null, + '': null, + null: null, + }; + for (const input in tests) { + test(input, () => expect(parseApkName(input)).toEqual(tests[input])); + } +}); + describe('parseAppVersion', () => { const tests: { [key: string]: object | null } = { // Older iOS releases without donate menu @@ -27,8 +43,8 @@ describe('parseAppVersion', () => { // There were no such versions in production. '2022.08.01-1': null, '2022.08.01-1-Google': { code: 220801, build: 1, flavor: 'google' }, - // -debug is ignored - '2022.08.01-1-Google-debug': { code: 220801, build: 1, flavor: 'google' }, + '2022.08.01-1-Google-debug': { code: 220801, build: 1, flavor: 'google', type: 'debug' }, + '2022.08.01-1-Google-beta': { code: 220801, build: 1, flavor: 'google', type: 'beta' }, // TODO: Fix regexp. Not it should not happen in production. //'2022.08.01-1-fd-debug': { code: 220801, build: 1, flavor: 'fd' }, '2022.1.1-0': null,