mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(downloads): route desktop releases through GitHub
This commit is contained in:
1 parent
dc230926a4
commit
094fb0d1c8
15 files changed
+1093
-155
No files matched your search
@@ -23,6 +23,7 @@ type CacheEntry = {
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const CACHE_MAX_ENTRIES = 10_000;
|
||||
const geoipCache = new Map<string, CacheEntry>();
|
||||
|
||||
let maxmindReader: Reader<CityResponse> | null = null;
|
||||
@@ -85,6 +86,32 @@ function isAsciiUpperAlpha2(value: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function getCachedGeoipResult(cacheKey: string, normalizedIp: string): GeoipResult | null {
|
||||
const cached = geoipCache.get(cacheKey);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() >= cached.expiresAt) {
|
||||
geoipCache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
geoipCache.delete(cacheKey);
|
||||
geoipCache.set(cacheKey, cached);
|
||||
return {...cached.result, normalizedIp};
|
||||
}
|
||||
|
||||
function setCachedGeoipResult(cacheKey: string, result: GeoipResult): void {
|
||||
geoipCache.delete(cacheKey);
|
||||
if (geoipCache.size >= CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = geoipCache.keys().next().value;
|
||||
if (oldestKey === undefined) {
|
||||
throw new Error('GeoIP cache reached capacity without an entry to evict');
|
||||
}
|
||||
geoipCache.delete(oldestKey);
|
||||
}
|
||||
geoipCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
|
||||
}
|
||||
|
||||
async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult> {
|
||||
try {
|
||||
const reader = await ensureReader(dbPath);
|
||||
@@ -108,14 +135,13 @@ async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult
|
||||
}
|
||||
|
||||
async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult> {
|
||||
const now = Date.now();
|
||||
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
|
||||
const cached = geoipCache.get(cacheKey);
|
||||
if (cached && now < cached.expiresAt) {
|
||||
return {...cached.result, normalizedIp: clean};
|
||||
const cached = getCachedGeoipResult(cacheKey, clean);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const result = await lookupMaxmind(clean, dbPath);
|
||||
geoipCache.set(cacheKey, {result, expiresAt: now + CACHE_TTL_MS});
|
||||
setCachedGeoipResult(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,18 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
|
||||
return Array.from(normalized);
|
||||
}
|
||||
|
||||
function normalizeCountryCodes(values: Array<string>, configName: string): ReadonlySet<string> {
|
||||
const normalized = new Set<string>();
|
||||
for (const value of values) {
|
||||
const countryCode = value.trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}$/u.test(countryCode)) {
|
||||
throw new Error(`${configName} contains an invalid ISO 3166-1 alpha-2 country code: ${value}`);
|
||||
}
|
||||
normalized.add(countryCode);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mapPushProviderApps(
|
||||
apps:
|
||||
| Array<{
|
||||
@@ -142,6 +154,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
nodeEnv: master.env === 'test' ? 'development' : master.env,
|
||||
port: master.services.api.port,
|
||||
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
|
||||
desktopGitHubRedirectCountries: normalizeCountryCodes(
|
||||
master.services.api.desktop_github_redirect_countries,
|
||||
'FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES',
|
||||
),
|
||||
cassandra: {
|
||||
hosts: cassandraSource?.hosts.join(',') ?? '',
|
||||
port: cassandraSource?.port ?? 9042,
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface APIConfig {
|
||||
nodeEnv: 'development' | 'production';
|
||||
port: number;
|
||||
ipBanExemptIps: Array<string>;
|
||||
desktopGitHubRedirectCountries: ReadonlySet<string>;
|
||||
cassandra: {
|
||||
hosts: string;
|
||||
port: number;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {DesktopArch, DesktopChannel, DesktopPlatform} from '@fluxer/schema/src/domains/download/DownloadSchemas';
|
||||
import {isJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
|
||||
const DESKTOP_BUCKET_PREFIX = 'desktop';
|
||||
const MIN_RELEASE_ROUTE_COUNT = 28;
|
||||
const MAX_RELEASE_ROUTE_COUNT = 128;
|
||||
const MIN_RELEASE_ASSET_COUNT = 26;
|
||||
|
||||
interface DesktopReleaseAsset {
|
||||
storage_key: string;
|
||||
release_asset: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface DesktopReleaseDescriptor {
|
||||
schema_version: 1;
|
||||
channel: DesktopChannel;
|
||||
version: string;
|
||||
release_tag: string;
|
||||
source_sha: string;
|
||||
assets: Array<DesktopReleaseAsset>;
|
||||
}
|
||||
|
||||
interface DesktopReleaseReadiness {
|
||||
schema_version: 1;
|
||||
channel: DesktopChannel;
|
||||
version: string;
|
||||
release_tag: string;
|
||||
source_sha: string;
|
||||
descriptor_sha256: string;
|
||||
}
|
||||
|
||||
interface DesktopArtifactScope {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
}
|
||||
|
||||
export function parseDesktopArtifactScope(key: string): DesktopArtifactScope | null {
|
||||
const segments = key.split('/');
|
||||
if (segments.length !== 5 || segments[0] !== DESKTOP_BUCKET_PREFIX || segments[4].length === 0) {
|
||||
return null;
|
||||
}
|
||||
const [, channel, plat, arch] = segments;
|
||||
if (
|
||||
(channel !== 'stable' && channel !== 'canary') ||
|
||||
(plat !== 'win32' && plat !== 'darwin' && plat !== 'linux') ||
|
||||
(arch !== 'x64' && arch !== 'arm64')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {channel, plat, arch};
|
||||
}
|
||||
|
||||
function parseDesktopReleaseAsset(value: unknown): DesktopReleaseAsset | null {
|
||||
if (
|
||||
!isJsonRecord(value) ||
|
||||
typeof value.storage_key !== 'string' ||
|
||||
typeof value.release_asset !== 'string' ||
|
||||
typeof value.sha256 !== 'string' ||
|
||||
typeof value.size !== 'number'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!/^desktop\/(?:stable|canary)\/(?:win32|darwin|linux)\/(?:x64|arm64)\/[A-Za-z0-9._-]+$/u.test(value.storage_key) ||
|
||||
!/^[A-Za-z0-9._-]+$/u.test(value.release_asset) ||
|
||||
!/^[a-f0-9]{64}$/u.test(value.sha256) ||
|
||||
!Number.isSafeInteger(value.size) ||
|
||||
value.size <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
storage_key: value.storage_key,
|
||||
release_asset: value.release_asset,
|
||||
sha256: value.sha256,
|
||||
size: value.size,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDesktopReleaseDescriptor(value: unknown): DesktopReleaseDescriptor | null {
|
||||
if (
|
||||
!isJsonRecord(value) ||
|
||||
value.schema_version !== 1 ||
|
||||
(value.channel !== 'stable' && value.channel !== 'canary') ||
|
||||
typeof value.version !== 'string' ||
|
||||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
|
||||
typeof value.release_tag !== 'string' ||
|
||||
typeof value.source_sha !== 'string' ||
|
||||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
|
||||
!Array.isArray(value.assets) ||
|
||||
value.assets.length < MIN_RELEASE_ROUTE_COUNT ||
|
||||
value.assets.length > MAX_RELEASE_ROUTE_COUNT
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const expectedTag = `fluxer-desktop-${value.channel}@${value.version}`;
|
||||
const expectedStoragePrefix = `desktop/${value.channel}/`;
|
||||
const expectedReleasePrefix = `${value.channel === 'canary' ? 'Fluxer-Canary' : 'Fluxer'}-${value.version}-`;
|
||||
if (value.release_tag !== expectedTag) {
|
||||
return null;
|
||||
}
|
||||
const storageKeys = new Set<string>();
|
||||
const routeCounts = new Map<string, number>();
|
||||
const releaseAssets = new Map<string, {sha256: string; size: number}>();
|
||||
const assets: Array<DesktopReleaseAsset> = [];
|
||||
for (const rawAsset of value.assets) {
|
||||
const asset = parseDesktopReleaseAsset(rawAsset);
|
||||
if (
|
||||
!asset ||
|
||||
!asset.storage_key.startsWith(expectedStoragePrefix) ||
|
||||
!asset.release_asset.startsWith(expectedReleasePrefix) ||
|
||||
storageKeys.has(asset.storage_key)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
storageKeys.add(asset.storage_key);
|
||||
const [, , platform, arch, filename] = asset.storage_key.split('/');
|
||||
const platformToken = platform === 'win32' ? 'win' : platform === 'darwin' ? 'mac' : 'linux';
|
||||
const expectedReleaseAsset = filename.startsWith(expectedReleasePrefix)
|
||||
? filename
|
||||
: `${expectedReleasePrefix}${platformToken}-${arch}-${filename}`;
|
||||
if (asset.release_asset !== expectedReleaseAsset) {
|
||||
return null;
|
||||
}
|
||||
const scope = `${platform}/${arch}`;
|
||||
routeCounts.set(scope, (routeCounts.get(scope) ?? 0) + 1);
|
||||
const existing = releaseAssets.get(asset.release_asset);
|
||||
if (existing && (existing.sha256 !== asset.sha256 || existing.size !== asset.size)) {
|
||||
return null;
|
||||
}
|
||||
releaseAssets.set(asset.release_asset, {sha256: asset.sha256, size: asset.size});
|
||||
assets.push(asset);
|
||||
}
|
||||
if (releaseAssets.size < MIN_RELEASE_ASSET_COUNT || releaseAssets.size > MAX_RELEASE_ROUTE_COUNT) {
|
||||
return null;
|
||||
}
|
||||
const expectedRouteCounts = new Map([
|
||||
['darwin/arm64', 4],
|
||||
['darwin/x64', 4],
|
||||
['linux/arm64', 4],
|
||||
['linux/x64', 4],
|
||||
['win32/arm64', 6],
|
||||
['win32/x64', 6],
|
||||
]);
|
||||
if (
|
||||
routeCounts.size !== expectedRouteCounts.size ||
|
||||
Array.from(expectedRouteCounts).some(([scope, count]) => (routeCounts.get(scope) ?? 0) < count)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
channel: value.channel,
|
||||
version: value.version,
|
||||
release_tag: value.release_tag,
|
||||
source_sha: value.source_sha,
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDesktopReleaseReadiness(value: unknown): DesktopReleaseReadiness | null {
|
||||
if (
|
||||
!isJsonRecord(value) ||
|
||||
value.schema_version !== 1 ||
|
||||
(value.channel !== 'stable' && value.channel !== 'canary') ||
|
||||
typeof value.version !== 'string' ||
|
||||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
|
||||
typeof value.release_tag !== 'string' ||
|
||||
typeof value.source_sha !== 'string' ||
|
||||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
|
||||
typeof value.descriptor_sha256 !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/u.test(value.descriptor_sha256)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
channel: value.channel,
|
||||
version: value.version,
|
||||
release_tag: value.release_tag,
|
||||
source_sha: value.source_sha,
|
||||
descriptor_sha256: value.descriptor_sha256,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {Config} from '../Config';
|
||||
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
import type {HonoEnv} from '../types/HonoEnv';
|
||||
import {Validator} from '../Validator';
|
||||
import {resolveArtifactRoute} from './DownloadRouting';
|
||||
import type {DesktopChecksumFile, DownloadService, DownloadStreamResult} from './DownloadService';
|
||||
import {
|
||||
DESKTOP_REDIRECT_PREFIX,
|
||||
@@ -29,6 +30,17 @@ function artifactFilename(key: string, filenameOverride?: string): string {
|
||||
return filenameOverride ?? key.split('/').pop() ?? 'download';
|
||||
}
|
||||
|
||||
function artifactRedirectResponse(location: string, cacheControl = 'no-store'): Response {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: new Headers({
|
||||
Location: location,
|
||||
'Cache-Control': cacheControl,
|
||||
'Accept-Ranges': 'bytes',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function setCommonArtifactHeaders(
|
||||
headers: Headers,
|
||||
key: string,
|
||||
@@ -87,8 +99,12 @@ async function streamArtifactResponse(
|
||||
cacheControl: string,
|
||||
filenameOverride?: string,
|
||||
): Promise<Response> {
|
||||
const route = await resolveArtifactRoute({request: ctx.req.raw, downloadService, key, cacheControl});
|
||||
if (route.kind === 'redirect') {
|
||||
return artifactRedirectResponse(route.location, route.cacheControl);
|
||||
}
|
||||
if (ctx.req.method === 'HEAD') {
|
||||
return headArtifactResponse(ctx, downloadService, key, cacheControl, filenameOverride);
|
||||
return headArtifactResponse(ctx, downloadService, key, route.cacheControl, filenameOverride);
|
||||
}
|
||||
if (downloadService.isPresignedDownloadEnabled()) {
|
||||
const location = await downloadService.getPresignedDownloadRedirect({
|
||||
@@ -99,14 +115,7 @@ async function streamArtifactResponse(
|
||||
if (!location) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: new Headers({
|
||||
Location: location,
|
||||
'Cache-Control': 'no-store',
|
||||
'Accept-Ranges': 'bytes',
|
||||
}),
|
||||
});
|
||||
return artifactRedirectResponse(location);
|
||||
}
|
||||
const range = ctx.req.header('range') ?? undefined;
|
||||
let result: DownloadStreamResult | null;
|
||||
@@ -117,7 +126,7 @@ async function streamArtifactResponse(
|
||||
const headers = new Headers();
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
headers.set('Content-Range', `bytes */${error.totalSize}`);
|
||||
headers.set('Cache-Control', cacheControl);
|
||||
headers.set('Cache-Control', route.cacheControl);
|
||||
return new Response(null, {status: 416, headers});
|
||||
}
|
||||
throw error;
|
||||
@@ -129,7 +138,7 @@ async function streamArtifactResponse(
|
||||
setCommonArtifactHeaders(
|
||||
headers,
|
||||
key,
|
||||
cacheControl,
|
||||
route.cacheControl,
|
||||
filenameOverride,
|
||||
result.contentType,
|
||||
result.contentDisposition,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '../Config';
|
||||
import {Logger} from '../Logger';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {parseDesktopArtifactScope} from './DesktopReleaseContract';
|
||||
import type {DownloadService, GitHubDesktopReleaseResolution} from './DownloadService';
|
||||
|
||||
const COUNTRY_DEPENDENT_CACHE_CONTROL = 'private, no-store';
|
||||
|
||||
type ArtifactRoute =
|
||||
| {kind: 'storage'; cacheControl: string}
|
||||
| {kind: 'redirect'; cacheControl: string; location: string};
|
||||
|
||||
export async function resolveArtifactRoute(params: {
|
||||
request: Request;
|
||||
downloadService: DownloadService;
|
||||
key: string;
|
||||
cacheControl: string;
|
||||
}): Promise<ArtifactRoute> {
|
||||
if (Config.instance.selfHosted) {
|
||||
return {kind: 'storage', cacheControl: params.cacheControl};
|
||||
}
|
||||
if (Config.desktopGitHubRedirectCountries.size === 0) {
|
||||
return {kind: 'storage', cacheControl: params.cacheControl};
|
||||
}
|
||||
if (!parseDesktopArtifactScope(params.key)) {
|
||||
return {kind: 'storage', cacheControl: params.cacheControl};
|
||||
}
|
||||
const geoip = await lookupGeoip(params.request);
|
||||
const countryCode = geoip.countryCode?.trim().toUpperCase();
|
||||
if (!countryCode || !Config.desktopGitHubRedirectCountries.has(countryCode)) {
|
||||
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
|
||||
}
|
||||
let release: GitHubDesktopReleaseResolution;
|
||||
try {
|
||||
release = await params.downloadService.resolveGitHubDesktopRelease(params.key);
|
||||
} catch (error) {
|
||||
Logger.error({error, key: params.key}, 'Failed to resolve GitHub desktop download route');
|
||||
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
|
||||
}
|
||||
if (release.kind === 'not_current') {
|
||||
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
|
||||
}
|
||||
if (release.kind === 'ready') {
|
||||
return {
|
||||
kind: 'redirect',
|
||||
cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL,
|
||||
location: release.location,
|
||||
};
|
||||
}
|
||||
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {posix} from 'node:path';
|
||||
import {Readable} from 'node:stream';
|
||||
import {S3ServiceException} from '@aws-sdk/client-s3';
|
||||
@@ -12,6 +13,11 @@ import type {
|
||||
import {Config} from '../Config';
|
||||
import type {IStorageService} from '../infrastructure/IStorageService';
|
||||
import {isJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
|
||||
import {
|
||||
parseDesktopArtifactScope,
|
||||
parseDesktopReleaseDescriptor,
|
||||
parseDesktopReleaseReadiness,
|
||||
} from './DesktopReleaseContract';
|
||||
|
||||
export const DOWNLOAD_PREFIX = '/dl';
|
||||
export const DESKTOP_REDIRECT_PREFIX = `${DOWNLOAD_PREFIX}/desktop`;
|
||||
@@ -46,6 +52,8 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
|
||||
const DESKTOP_BUCKET_PREFIX = 'desktop';
|
||||
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
|
||||
const DEFAULT_API_CLIENT_BASE_URL = 'https://api.fluxer.app';
|
||||
const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/fluxerapp/fluxer/releases/download';
|
||||
const GITHUB_RELEASE_MARKER_DIRECTORY = 'github-releases';
|
||||
|
||||
function desktopBucketPrefix(test?: boolean): string {
|
||||
return test ? DESKTOP_TEST_BUCKET_PREFIX : DESKTOP_BUCKET_PREFIX;
|
||||
@@ -176,9 +184,70 @@ interface ManifestFilenameResolutionParams extends LatestFilenameLookupParams {
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export type GitHubDesktopReleaseResolution =
|
||||
| {kind: 'not_current'}
|
||||
| {kind: 'awaiting_release'}
|
||||
| {kind: 'ready'; location: string};
|
||||
|
||||
export class DownloadService {
|
||||
constructor(private readonly storageService: IStorageService) {}
|
||||
|
||||
async resolveGitHubDesktopRelease(key: string): Promise<GitHubDesktopReleaseResolution> {
|
||||
const scope = parseDesktopArtifactScope(key);
|
||||
if (!scope) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const manifestKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${scope.plat}/${scope.arch}/manifest.json`;
|
||||
const manifest = await this.readOptionalJsonObjectFromStorage(manifestKey);
|
||||
if (
|
||||
!isDesktopManifest(manifest) ||
|
||||
manifest.channel !== scope.channel ||
|
||||
manifest.platform !== scope.plat ||
|
||||
manifest.arch !== scope.arch
|
||||
) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`;
|
||||
const descriptorText = await this.readOptionalTextFromStorage(descriptorKey);
|
||||
if (descriptorText == null) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(descriptorText));
|
||||
if (
|
||||
!descriptor ||
|
||||
descriptor.channel !== scope.channel ||
|
||||
descriptor.version !== manifest.version ||
|
||||
descriptor.release_tag !== `fluxer-desktop-${scope.channel}@${manifest.version}`
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release descriptor: ${descriptorKey}`);
|
||||
}
|
||||
const releaseAsset = descriptor.assets.find((asset) => asset.storage_key === key);
|
||||
if (!releaseAsset) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.ready.json`;
|
||||
const marker = await this.readOptionalJsonObjectFromStorage(markerKey);
|
||||
if (marker == null) {
|
||||
return {kind: 'awaiting_release'};
|
||||
}
|
||||
const readiness = parseDesktopReleaseReadiness(marker);
|
||||
const descriptorSha256 = createHash('sha256').update(descriptorText).digest('hex');
|
||||
if (
|
||||
!readiness ||
|
||||
readiness.channel !== descriptor.channel ||
|
||||
readiness.version !== descriptor.version ||
|
||||
readiness.release_tag !== descriptor.release_tag ||
|
||||
readiness.source_sha !== descriptor.source_sha ||
|
||||
readiness.descriptor_sha256 !== descriptorSha256
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release readiness marker: ${markerKey}`);
|
||||
}
|
||||
return {
|
||||
kind: 'ready',
|
||||
location: `${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${encodeURIComponent(descriptor.release_tag)}/${encodeURIComponent(releaseAsset.release_asset)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveLatestDesktopKey(params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
@@ -659,6 +728,11 @@ export class DownloadService {
|
||||
}
|
||||
|
||||
private async readJsonObjectFromStorage(key: string): Promise<unknown | null> {
|
||||
const text = await this.readTextFromStorage(key);
|
||||
return text == null ? null : parseJsonUnknown(text);
|
||||
}
|
||||
|
||||
private async readTextFromStorage(key: string): Promise<string | null> {
|
||||
const streamResult = await this.storageService.streamObject({
|
||||
bucket: Config.s3.buckets.downloads,
|
||||
key,
|
||||
@@ -667,8 +741,29 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
const body = Readable.toWeb(streamResult.body);
|
||||
const text = await new Response(body as ReadableStream).text();
|
||||
return parseJsonUnknown(text);
|
||||
return new Response(body as ReadableStream).text();
|
||||
}
|
||||
|
||||
private async readOptionalJsonObjectFromStorage(key: string): Promise<unknown | null> {
|
||||
try {
|
||||
return await this.readJsonObjectFromStorage(key);
|
||||
} catch (error) {
|
||||
if (isStorageNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async readOptionalTextFromStorage(key: string): Promise<string | null> {
|
||||
try {
|
||||
return await this.readTextFromStorage(key);
|
||||
} catch (error) {
|
||||
if (isStorageNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private isValidSha256(value: string): boolean {
|
||||
@@ -774,9 +869,11 @@ export class DownloadService {
|
||||
const {ext, arch: archMap} = mapping;
|
||||
const filenames = new Set<string>();
|
||||
for (const archSuffix of this.getArchTokens(archMap[arch as 'x64' | 'arm64'])) {
|
||||
const modernFilename = this.buildModernArtifactFilename(channel, version, plat, archSuffix, ext);
|
||||
if (modernFilename) {
|
||||
filenames.add(modernFilename);
|
||||
for (const productName of this.getModernProductNames(channel)) {
|
||||
filenames.add(`${productName}-${version}-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`);
|
||||
if (format === 'portable') {
|
||||
filenames.add(`${productName}-${version}-portable-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`);
|
||||
}
|
||||
}
|
||||
if (format === 'setup') {
|
||||
filenames.add(`fluxer-${channel}-${version}-${archSuffix}-setup${ext}`);
|
||||
@@ -784,9 +881,6 @@ export class DownloadService {
|
||||
filenames.add(`fluxer-${version}-${archSuffix}-setup${ext}`);
|
||||
filenames.add(`Fluxer-${version}-${archSuffix}-Setup${ext}`);
|
||||
} else if (format === 'portable') {
|
||||
filenames.add(
|
||||
`${this.getModernProductName(channel)}-${version}-portable-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`,
|
||||
);
|
||||
filenames.add(`fluxer-${channel}-${version}-portable-${archSuffix}${ext}`);
|
||||
filenames.add(`Fluxer-${version}-portable-${archSuffix}${ext}`);
|
||||
} else {
|
||||
@@ -816,7 +910,6 @@ export class DownloadService {
|
||||
}
|
||||
const {ext, arch: archMap} = mapping;
|
||||
const escapedExt = this.escapeRegex(ext);
|
||||
const escapedModernFilenamePrefix = this.escapeRegex(this.getModernProductName(channel));
|
||||
const modernPlatformToken = MODERN_PLATFORM_TOKENS[plat];
|
||||
for (const archSuffix of this.getArchTokens(archMap[arch as 'x64' | 'arm64'])) {
|
||||
const patterns = [
|
||||
@@ -828,18 +921,23 @@ export class DownloadService {
|
||||
`^[Ff]luxer-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(archSuffix)}(?:-[Ss]etup)?${escapedExt}$`,
|
||||
'u',
|
||||
),
|
||||
new RegExp(
|
||||
`^${escapedModernFilenamePrefix}-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
|
||||
'iu',
|
||||
),
|
||||
];
|
||||
if (format === 'portable') {
|
||||
for (const productName of this.getModernProductNames(channel)) {
|
||||
const escapedProductName = this.escapeRegex(productName);
|
||||
patterns.push(
|
||||
new RegExp(
|
||||
`^${escapedModernFilenamePrefix}-(\\d+\\.\\d+\\.\\d+)-portable-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
|
||||
`^${escapedProductName}-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
|
||||
'iu',
|
||||
),
|
||||
);
|
||||
if (format === 'portable') {
|
||||
patterns.push(
|
||||
new RegExp(
|
||||
`^${escapedProductName}-(\\d+\\.\\d+\\.\\d+)-portable-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
|
||||
'iu',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const pattern of patterns) {
|
||||
const match = filename.match(pattern);
|
||||
@@ -852,18 +950,8 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private getModernProductName(channel: DesktopChannel): string {
|
||||
return channel === 'canary' ? 'Fluxer Canary' : 'Fluxer';
|
||||
}
|
||||
|
||||
private buildModernArtifactFilename(
|
||||
channel: DesktopChannel,
|
||||
version: string,
|
||||
plat: DesktopPlatform,
|
||||
archToken: string,
|
||||
ext: string,
|
||||
): string {
|
||||
return `${this.getModernProductName(channel)}-${version}-${MODERN_PLATFORM_TOKENS[plat]}-${archToken}${ext}`;
|
||||
private getModernProductNames(channel: DesktopChannel): Array<string> {
|
||||
return channel === 'canary' ? ['Fluxer-Canary', 'Fluxer Canary'] : ['Fluxer'];
|
||||
}
|
||||
|
||||
private getArchTokens(archToken: string | Array<string>): Array<string> {
|
||||
|
||||
Reference in new issue
Block a user