mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(api): redirect desktop downloads to pkgs (#2853)
This commit is contained in:
1 parent
487febac8e
commit
e2abfd476a
47 files changed
+679
-3940
No files matched your search
@@ -3,7 +3,6 @@
|
||||
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
|
||||
|
||||
@@ -92,18 +91,6 @@ 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<{
|
||||
@@ -157,7 +144,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
const s3Buckets = s3Config.buckets ?? {
|
||||
cdn: '',
|
||||
uploads: '',
|
||||
downloads: '',
|
||||
reports: '',
|
||||
harvests: '',
|
||||
};
|
||||
@@ -174,10 +160,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
requestTimeoutMs: master.services.api.request_timeout_ms,
|
||||
maxInflightRequests: master.services.api.max_inflight_requests,
|
||||
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,
|
||||
@@ -304,7 +286,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
|
||||
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
|
||||
},
|
||||
s3Downloads: resolveDownloadsProvider(master),
|
||||
s3: {
|
||||
endpoint: s3Config.endpoint,
|
||||
presignedUrlBase: s3Config.presigned_url_base,
|
||||
@@ -523,7 +504,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
validateResponses: resolveValidateResponses(master),
|
||||
},
|
||||
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
|
||||
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
|
||||
presignedHarvestDownloadsEnabled: master.services.api.presigned_harvest_downloads_enabled ?? true,
|
||||
attachmentDecayEnabled: master.attachment_decay_enabled,
|
||||
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import type {CachePurgeAdapterName} from '@fluxer/config/src/MasterConfig';
|
||||
import type {ResolvedDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
|
||||
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
|
||||
export type APIWorkerLaneName = 'realtime' | 'unfurl' | 'lifecycle' | 'batch';
|
||||
@@ -48,7 +47,6 @@ export interface APIConfig {
|
||||
requestTimeoutMs: number;
|
||||
maxInflightRequests: number;
|
||||
ipBanExemptIps: Array<string>;
|
||||
desktopGitHubRedirectCountries: ReadonlySet<string>;
|
||||
cassandra: {
|
||||
hosts: string;
|
||||
port: number;
|
||||
@@ -171,10 +169,8 @@ export interface APIConfig {
|
||||
uploads: string;
|
||||
reports: string;
|
||||
harvests: string;
|
||||
downloads: string;
|
||||
};
|
||||
};
|
||||
s3Downloads: ResolvedDownloadsProvider;
|
||||
email: {
|
||||
enabled: boolean;
|
||||
provider: 'smtp' | 'none';
|
||||
@@ -368,7 +364,6 @@ export interface APIConfig {
|
||||
validateResponses: boolean;
|
||||
};
|
||||
presignedAttachmentUploadsEnabled: boolean;
|
||||
presignedDownloadsEnabled: boolean;
|
||||
presignedHarvestDownloadsEnabled: boolean;
|
||||
attachmentDecayEnabled: boolean;
|
||||
deletionGracePeriodHours: number;
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
|
||||
import type {DesktopArch, DesktopChannel, DesktopPlatform} from '@fluxer/schema/src/domains/download/DownloadSchemas';
|
||||
|
||||
const DESKTOP_BUCKET_PREFIX = 'desktop';
|
||||
const MIN_RELEASE_ROUTE_COUNT = 28;
|
||||
const MAX_RELEASE_ROUTE_COUNT = 128;
|
||||
const MIN_RELEASE_ASSET_COUNT = 24;
|
||||
|
||||
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}-`;
|
||||
const descriptorName = `${expectedReleasePrefix}release-manifest.json`;
|
||||
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 releaseAssetNames = new Map<string, string>([[descriptorName.toLowerCase(), descriptorName]]);
|
||||
const assets: Array<DesktopReleaseAsset> = [];
|
||||
for (const rawAsset of value.assets) {
|
||||
const asset = parseDesktopReleaseAsset(rawAsset);
|
||||
if (
|
||||
!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 releaseFilename =
|
||||
platform === 'darwin' && filename.toLowerCase() === 'releases.json' ? 'releases.json' : filename;
|
||||
const expectedReleaseAsset = filename.startsWith(expectedReleasePrefix)
|
||||
? filename
|
||||
: `${expectedReleasePrefix}${platformToken}-${arch}-${releaseFilename}`;
|
||||
if (
|
||||
asset.release_asset !== expectedReleaseAsset ||
|
||||
asset.release_asset.toLowerCase() === descriptorName.toLowerCase()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const caseFoldedReleaseAsset = asset.release_asset.toLowerCase();
|
||||
const existingReleaseAssetName = releaseAssetNames.get(caseFoldedReleaseAsset);
|
||||
if (existingReleaseAssetName && existingReleaseAssetName !== asset.release_asset) {
|
||||
return null;
|
||||
}
|
||||
releaseAssetNames.set(caseFoldedReleaseAsset, asset.release_asset);
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,370 +1,75 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Readable} from 'node:stream';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {resolveArtifactRoute} from '@app/api/download/DownloadRouting';
|
||||
import type {DesktopChecksumFile, DownloadService, DownloadStreamResult} from '@app/api/download/DownloadService';
|
||||
import {
|
||||
DESKTOP_REDIRECT_PREFIX,
|
||||
DOWNLOAD_PREFIX,
|
||||
downloadCacheControlForKey,
|
||||
UnsatisfiableRangeError,
|
||||
} from '@app/api/download/DownloadService';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {DESKTOP_REDIRECT_PREFIX, DOWNLOAD_PREFIX, resolveDownloadRedirect} from '@app/api/download/DownloadRedirects';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {
|
||||
DesktopChecksumRedirectParam,
|
||||
DesktopRedirectParam,
|
||||
DesktopTestBuildQuery,
|
||||
DesktopVersionedChecksumRedirectParam,
|
||||
DesktopVersionedRedirectParam,
|
||||
DesktopVersionedZsyncRedirectParam,
|
||||
DesktopVersionsParam,
|
||||
DesktopVersionsQuery,
|
||||
DesktopVersionsResponse,
|
||||
DownloadChecksumResponse,
|
||||
DownloadFileResponse,
|
||||
VersionInfoResponse,
|
||||
DesktopZsyncRedirectParam,
|
||||
} from '@fluxer/schema/src/domains/download/DownloadSchemas';
|
||||
import type {Context, Hono} from 'hono';
|
||||
|
||||
function artifactFilename(key: string, filenameOverride?: string): string {
|
||||
return filenameOverride ?? key.split('/').pop() ?? 'download';
|
||||
}
|
||||
|
||||
function artifactRedirectResponse(location: string, cacheControl = 'no-store'): Response {
|
||||
function redirectToPackageOrigin(ctx: Context<HonoEnv>): Response {
|
||||
const redirect = resolveDownloadRedirect(ctx.req.path);
|
||||
if (!redirect) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: new Headers({
|
||||
Location: location,
|
||||
'Cache-Control': cacheControl,
|
||||
Location: redirect.location,
|
||||
'Cache-Control': redirect.cacheControl,
|
||||
'Accept-Ranges': 'bytes',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function setCommonArtifactHeaders(
|
||||
headers: Headers,
|
||||
key: string,
|
||||
cacheControl: string,
|
||||
filenameOverride: string | undefined,
|
||||
contentType: string | null | undefined,
|
||||
contentDisposition: string | null | undefined,
|
||||
etag: string | null | undefined,
|
||||
lastModified: Date | null | undefined,
|
||||
): void {
|
||||
const filename = artifactFilename(key, filenameOverride);
|
||||
headers.set('Content-Type', contentType ?? 'application/octet-stream');
|
||||
headers.set('Content-Disposition', contentDisposition ?? `attachment; filename="${encodeURIComponent(filename)}"`);
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
headers.set('Cache-Control', cacheControl);
|
||||
if (etag) {
|
||||
headers.set('ETag', etag);
|
||||
}
|
||||
if (lastModified) {
|
||||
headers.set('Last-Modified', lastModified.toUTCString());
|
||||
}
|
||||
}
|
||||
|
||||
async function headArtifactResponse(
|
||||
ctx: Context<HonoEnv>,
|
||||
downloadService: DownloadService,
|
||||
key: string,
|
||||
cacheControl: string,
|
||||
filenameOverride?: string,
|
||||
): Promise<Response> {
|
||||
const metadata = await downloadService.getDownloadMetadata({key});
|
||||
if (!metadata) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
const headers = new Headers();
|
||||
setCommonArtifactHeaders(
|
||||
headers,
|
||||
key,
|
||||
cacheControl,
|
||||
filenameOverride,
|
||||
metadata.contentType,
|
||||
undefined,
|
||||
metadata.etag,
|
||||
metadata.lastModified,
|
||||
);
|
||||
headers.set('Content-Length', String(metadata.contentLength));
|
||||
return new Response(null, {status: 200, headers});
|
||||
}
|
||||
|
||||
const PRESIGNED_DOWNLOAD_TTL_SECONDS = 900;
|
||||
|
||||
async function streamArtifactResponse(
|
||||
ctx: Context<HonoEnv>,
|
||||
downloadService: DownloadService,
|
||||
key: string,
|
||||
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, route.cacheControl, filenameOverride);
|
||||
}
|
||||
if (downloadService.isPresignedDownloadEnabled()) {
|
||||
const location = await downloadService.getPresignedDownloadRedirect({
|
||||
key,
|
||||
filename: artifactFilename(key, filenameOverride),
|
||||
expiresIn: PRESIGNED_DOWNLOAD_TTL_SECONDS,
|
||||
});
|
||||
if (!location) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return artifactRedirectResponse(location);
|
||||
}
|
||||
const range = ctx.req.header('range') ?? undefined;
|
||||
let result: DownloadStreamResult | null;
|
||||
try {
|
||||
result = await downloadService.streamDownload({key, range});
|
||||
} catch (error) {
|
||||
if (error instanceof UnsatisfiableRangeError) {
|
||||
const headers = new Headers();
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
headers.set('Content-Range', `bytes */${error.totalSize}`);
|
||||
headers.set('Cache-Control', route.cacheControl);
|
||||
return new Response(null, {status: 416, headers});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!result) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
const headers = new Headers();
|
||||
setCommonArtifactHeaders(
|
||||
headers,
|
||||
key,
|
||||
route.cacheControl,
|
||||
filenameOverride,
|
||||
result.contentType,
|
||||
result.contentDisposition,
|
||||
result.etag,
|
||||
result.lastModified,
|
||||
);
|
||||
headers.set('Content-Length', String(result.contentLength));
|
||||
if (result.contentRange) {
|
||||
headers.set('Content-Range', result.contentRange);
|
||||
}
|
||||
const body = Readable.toWeb(result.body) as ReadableStream;
|
||||
return new Response(body, {status: result.contentRange ? 206 : 200, headers});
|
||||
}
|
||||
|
||||
function checksumFileResponse(ctx: Context<HonoEnv>, checksum: DesktopChecksumFile, cacheControl: string): Response {
|
||||
const headers = new Headers();
|
||||
const body = ctx.req.method === 'HEAD' ? null : checksum.body;
|
||||
headers.set('Content-Type', 'text/plain; charset=utf-8');
|
||||
headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(`${checksum.filename}.sha256`)}"`);
|
||||
headers.set('Cache-Control', cacheControl);
|
||||
headers.set('Content-Length', String(new TextEncoder().encode(checksum.body).byteLength));
|
||||
return new Response(body, {status: 200, headers});
|
||||
}
|
||||
|
||||
export function DownloadController(routes: Hono<HonoEnv>): void {
|
||||
routes.get(
|
||||
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest`,
|
||||
Validator('param', DesktopVersionsParam),
|
||||
Validator('query', DesktopTestBuildQuery),
|
||||
OpenAPI({
|
||||
operationId: 'get_latest_desktop_version',
|
||||
summary: 'Get latest desktop version',
|
||||
responseSchema: VersionInfoResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Downloads'],
|
||||
description:
|
||||
'Returns metadata for the latest desktop version including download URLs and SHA-256 checksums for all available formats. Pass ?test=1 to resolve against unreleased test builds.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {channel, plat, arch} = ctx.req.valid('param');
|
||||
const {test} = ctx.req.valid('query');
|
||||
const result = await ctx.get('downloadService').getLatestDesktopVersion({
|
||||
channel,
|
||||
plat,
|
||||
arch,
|
||||
baseUrl: Config.endpoints.apiClient,
|
||||
test,
|
||||
});
|
||||
if (!result) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return ctx.json(result, 200, {
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
});
|
||||
},
|
||||
async (ctx) => redirectToPackageOrigin(ctx),
|
||||
);
|
||||
routes.on(
|
||||
['GET', 'HEAD'],
|
||||
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest/:format{[a-z_]+\\.sha256}`,
|
||||
Validator('param', DesktopChecksumRedirectParam),
|
||||
Validator('query', DesktopTestBuildQuery),
|
||||
OpenAPI({
|
||||
operationId: 'download_latest_desktop_version_checksum',
|
||||
summary: 'Download latest desktop version checksum',
|
||||
responseSchema: DownloadChecksumResponse,
|
||||
responseContentType: 'text/plain',
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Downloads'],
|
||||
description:
|
||||
'Returns a plain text SHA-256 checksum file for the latest available desktop application version. The format path segment must end in .sha256, for example appimage.sha256.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {channel, plat, arch, format} = ctx.req.valid('param');
|
||||
const {test} = ctx.req.valid('query');
|
||||
const checksum = await ctx
|
||||
.get('downloadService')
|
||||
.resolveLatestDesktopChecksumFile({channel, plat, arch, format, test});
|
||||
if (!checksum) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return checksumFileResponse(ctx, checksum, 'no-store');
|
||||
},
|
||||
async (ctx) => redirectToPackageOrigin(ctx),
|
||||
);
|
||||
routes.on(
|
||||
['GET', 'HEAD'],
|
||||
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest/:format{[a-z_]+\\.zsync}`,
|
||||
Validator('param', DesktopZsyncRedirectParam),
|
||||
async (ctx) => redirectToPackageOrigin(ctx),
|
||||
);
|
||||
routes.on(
|
||||
['GET', 'HEAD'],
|
||||
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest/:format`,
|
||||
Validator('param', DesktopRedirectParam),
|
||||
Validator('query', DesktopTestBuildQuery),
|
||||
OpenAPI({
|
||||
operationId: 'download_latest_desktop_version',
|
||||
summary: 'Download latest desktop version',
|
||||
responseSchema: DownloadFileResponse,
|
||||
responseContentType: '*/*',
|
||||
statusCode: [200, 206, 302],
|
||||
bodylessStatusCodes: [302],
|
||||
security: [],
|
||||
tags: ['Downloads'],
|
||||
description:
|
||||
'Streams the latest available desktop application version for the specified platform and architecture. Pass ?test=1 to download an unreleased test build.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {channel, plat, arch, format} = ctx.req.valid('param');
|
||||
const {test} = ctx.req.valid('query');
|
||||
const downloadService = ctx.get('downloadService');
|
||||
const key = await downloadService.resolveLatestDesktopKey({channel, plat, arch, format, test});
|
||||
if (!key) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return streamArtifactResponse(ctx, downloadService, key, 'no-store');
|
||||
},
|
||||
async (ctx) => redirectToPackageOrigin(ctx),
|
||||
);
|
||||
routes.get(
|
||||
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/versions`,
|
||||
Validator('param', DesktopVersionsParam),
|
||||
Validator('query', DesktopVersionsQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_desktop_versions',
|
||||
summary: 'List desktop versions',
|
||||
responseSchema: DesktopVersionsResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Downloads'],
|
||||
description: 'Lists available desktop versions with pagination for the specified platform and architecture.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {channel, plat, arch} = ctx.req.valid('param');
|
||||
const {limit, before, after, test} = ctx.req.valid('query');
|
||||
const {versions, hasMore} = await ctx.get('downloadService').listDesktopVersions({
|
||||
channel,
|
||||
plat,
|
||||
arch,
|
||||
limit,
|
||||
before,
|
||||
after,
|
||||
baseUrl: Config.endpoints.apiClient,
|
||||
test,
|
||||
});
|
||||
return ctx.json({versions, has_more: hasMore}, 200, {
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
});
|
||||
},
|
||||
routes.on(
|
||||
['GET', 'HEAD'],
|
||||
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:version/:format{[a-z_]+\\.zsync}`,
|
||||
Validator('param', DesktopVersionedZsyncRedirectParam),
|
||||
async (ctx) => redirectToPackageOrigin(ctx),
|
||||
);
|
||||
routes.on(
|
||||
['GET', 'HEAD'],
|
||||
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:version/:format{[a-z_]+\\.sha256}`,
|
||||
Validator('param', DesktopVersionedChecksumRedirectParam),
|
||||
Validator('query', DesktopTestBuildQuery),
|
||||
OpenAPI({
|
||||
operationId: 'download_desktop_version_checksum',
|
||||
summary: 'Download desktop version checksum',
|
||||
responseSchema: DownloadChecksumResponse,
|
||||
responseContentType: 'text/plain',
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Downloads'],
|
||||
description:
|
||||
'Returns a plain text SHA-256 checksum file for a specific desktop application version. The format path segment must end in .sha256, for example appimage.sha256.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {channel, plat, arch, version, format} = ctx.req.valid('param');
|
||||
const {test} = ctx.req.valid('query');
|
||||
const checksum = await ctx
|
||||
.get('downloadService')
|
||||
.resolveVersionedDesktopChecksumFile({channel, plat, arch, version, format, test});
|
||||
if (!checksum) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return checksumFileResponse(ctx, checksum, downloadCacheControlForKey(checksum.key));
|
||||
},
|
||||
async (ctx) => redirectToPackageOrigin(ctx),
|
||||
);
|
||||
routes.on(
|
||||
['GET', 'HEAD'],
|
||||
`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:version/:format`,
|
||||
Validator('param', DesktopVersionedRedirectParam),
|
||||
Validator('query', DesktopTestBuildQuery),
|
||||
OpenAPI({
|
||||
operationId: 'download_desktop_version',
|
||||
summary: 'Download desktop version',
|
||||
responseSchema: DownloadFileResponse,
|
||||
responseContentType: '*/*',
|
||||
statusCode: [200, 206, 302],
|
||||
bodylessStatusCodes: [302],
|
||||
security: [],
|
||||
tags: ['Downloads'],
|
||||
description:
|
||||
'Streams a specific desktop application version for the given platform and architecture. Pass ?test=1 to download an unreleased test build.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {channel, plat, arch, version, format} = ctx.req.valid('param');
|
||||
const {test} = ctx.req.valid('query');
|
||||
const downloadService = ctx.get('downloadService');
|
||||
const key = await downloadService.resolveVersionedDesktopKey({channel, plat, arch, version, format, test});
|
||||
if (!key) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
|
||||
},
|
||||
);
|
||||
routes.on(
|
||||
['GET', 'HEAD'],
|
||||
`${DOWNLOAD_PREFIX}/*`,
|
||||
Validator('query', DesktopTestBuildQuery),
|
||||
OpenAPI({
|
||||
operationId: 'download_file',
|
||||
summary: 'Download file',
|
||||
responseSchema: DownloadFileResponse,
|
||||
responseContentType: '*/*',
|
||||
statusCode: [200, 206, 302],
|
||||
bodylessStatusCodes: [302],
|
||||
security: [],
|
||||
tags: ['Downloads'],
|
||||
description:
|
||||
'Streams the requested file from storage. Pass ?test=1 on a desktop/ path to resolve against the desktop-test/ bucket prefix instead.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {test} = ctx.req.valid('query');
|
||||
const downloadService = ctx.get('downloadService');
|
||||
const key = await downloadService.resolveDownloadKey({path: ctx.req.path, test});
|
||||
if (!key) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
|
||||
},
|
||||
async (ctx) => redirectToPackageOrigin(ctx),
|
||||
);
|
||||
routes.on(['GET', 'HEAD'], `${DOWNLOAD_PREFIX}/*`, async (ctx) => redirectToPackageOrigin(ctx));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {posix} from 'node:path';
|
||||
|
||||
export const PKGS_BASE_URL = 'https://pkgs.fluxer.com';
|
||||
export const DOWNLOAD_PREFIX = '/dl';
|
||||
export const DESKTOP_REDIRECT_PREFIX = `${DOWNLOAD_PREFIX}/desktop`;
|
||||
|
||||
export const DESKTOP_COORDINATE_DOCUMENTS = new Map<string, string>([['latest', 'latest.json']]);
|
||||
|
||||
const DESKTOP_PATH_PREFIX = 'desktop/';
|
||||
const PLATFORM_ARCH_PATH = /^(desktop\/(?:stable|canary)\/(?:win32|darwin|linux))-(x64|arm64)(\/.+)$/u;
|
||||
const COORDINATE_DOCUMENT_PATH = /^(desktop\/(?:stable|canary)\/(?:win32|darwin|linux)\/(?:x64|arm64))\/([a-z]+)$/u;
|
||||
const MUTABLE_REDIRECT_CACHE_CONTROL = 'no-store';
|
||||
const VERSIONED_REDIRECT_CACHE_CONTROL = 'public, max-age=31536000';
|
||||
const SCOPE_SEGMENT_INDEX = 4;
|
||||
|
||||
interface DownloadRedirect {
|
||||
location: string;
|
||||
cacheControl: string;
|
||||
}
|
||||
|
||||
function isReleaseFeedFilename(filename: string): boolean {
|
||||
return (
|
||||
filename === 'manifest.json' ||
|
||||
filename === 'latest.json' ||
|
||||
filename === 'version.json' ||
|
||||
filename.endsWith('.yml') ||
|
||||
filename.endsWith('.yaml') ||
|
||||
filename.startsWith('RELEASES') ||
|
||||
(filename.startsWith('releases') && filename.endsWith('.json')) ||
|
||||
(filename.startsWith('assets') && filename.endsWith('.json'))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePlatformArchPath(path: string): string {
|
||||
const match = path.match(PLATFORM_ARCH_PATH);
|
||||
return match ? `${match[1]}/${match[2]}${match[3]}` : path;
|
||||
}
|
||||
|
||||
function resolveCoordinateDocument(path: string): string {
|
||||
const match = path.match(COORDINATE_DOCUMENT_PATH);
|
||||
const document = match ? DESKTOP_COORDINATE_DOCUMENTS.get(match[2]) : undefined;
|
||||
return match && document ? `${match[1]}/${document}` : path;
|
||||
}
|
||||
|
||||
export function resolveDownloadObjectPath(requestPath: string): string | null {
|
||||
if (!requestPath.startsWith(DOWNLOAD_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = posix.normalize(requestPath.slice(DOWNLOAD_PREFIX.length).replace(/^\/+/u, ''));
|
||||
if (normalized.length === 0 || normalized.startsWith('/') || normalized.startsWith('..')) {
|
||||
return null;
|
||||
}
|
||||
for (const segment of normalized.split('/')) {
|
||||
if (segment.length === 0 || segment === '.' || segment === '..' || segment.includes('\0')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const objectPath = resolveCoordinateDocument(normalizePlatformArchPath(normalized));
|
||||
return objectPath.startsWith(DESKTOP_PATH_PREFIX) ? objectPath : null;
|
||||
}
|
||||
|
||||
export function downloadRedirectCacheControl(objectPath: string): string {
|
||||
const segments = objectPath.split('/');
|
||||
if (segments[SCOPE_SEGMENT_INDEX] === 'latest') {
|
||||
return MUTABLE_REDIRECT_CACHE_CONTROL;
|
||||
}
|
||||
return isReleaseFeedFilename(segments[segments.length - 1])
|
||||
? MUTABLE_REDIRECT_CACHE_CONTROL
|
||||
: VERSIONED_REDIRECT_CACHE_CONTROL;
|
||||
}
|
||||
|
||||
export function resolveDownloadRedirect(requestPath: string): DownloadRedirect | null {
|
||||
const objectPath = resolveDownloadObjectPath(requestPath);
|
||||
if (!objectPath) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
location: `${PKGS_BASE_URL}/${objectPath}`,
|
||||
cacheControl: downloadRedirectCacheControl(objectPath),
|
||||
};
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {parseDesktopArtifactScope} from '@app/api/download/DesktopReleaseContract';
|
||||
import type {DownloadService, GitHubDesktopReleaseResolution} from '@app/api/download/DownloadService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
|
||||
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};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,187 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DownloadController} from '@app/api/download/DownloadController';
|
||||
import {PKGS_BASE_URL} from '@app/api/download/DownloadRedirects';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {Hono} from 'hono';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const COUNTRY_HEADERS = {'cf-ipcountry': 'BR', 'x-forwarded-for': '203.0.113.7'};
|
||||
|
||||
function createApp() {
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.onError((_error, ctx) => ctx.text('Bad Request', 400));
|
||||
DownloadController(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function request(path: string, init?: RequestInit) {
|
||||
const response = await createApp().request(path, init);
|
||||
return {
|
||||
status: response.status,
|
||||
location: response.headers.get('Location'),
|
||||
cacheControl: response.headers.get('Cache-Control'),
|
||||
body: await response.text(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('legacy desktop download routes', () => {
|
||||
it('redirects the latest metadata route at the document the publisher writes', async () => {
|
||||
const response = await request('/dl/desktop/stable/darwin/arm64/latest');
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/darwin/arm64/latest.json`);
|
||||
expect(response.cacheControl).toBe('no-store');
|
||||
expect(response.body).toBe('');
|
||||
});
|
||||
|
||||
it('redirects the latest artifact route', async () => {
|
||||
const response = await request('/dl/desktop/stable/linux/x64/latest/appimage');
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/linux/x64/latest/appimage`);
|
||||
expect(response.cacheControl).toBe('no-store');
|
||||
});
|
||||
|
||||
it('redirects the latest checksum route', async () => {
|
||||
const response = await request('/dl/desktop/stable/linux/x64/latest/appimage.sha256');
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/linux/x64/latest/appimage.sha256`);
|
||||
expect(response.cacheControl).toBe('no-store');
|
||||
});
|
||||
|
||||
it('stops mapping the retired version listing route, so it passes through to an origin path that serves nothing', async () => {
|
||||
const response = await request('/dl/desktop/canary/linux/arm64/versions');
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/canary/linux/arm64/versions`);
|
||||
});
|
||||
|
||||
it('redirects the latest appimage zsync sidecar rather than rejecting it', async () => {
|
||||
const response = await request('/dl/desktop/canary/linux/x64/latest/appimage.zsync');
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/canary/linux/x64/latest/appimage.zsync`);
|
||||
expect(response.cacheControl).toBe('no-store');
|
||||
});
|
||||
|
||||
it('redirects the versioned appimage zsync sidecar and lets the redirect be cached', async () => {
|
||||
const response = await request('/dl/desktop/canary/linux/x64/1.4.2/appimage.zsync');
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/canary/linux/x64/1.4.2/appimage.zsync`);
|
||||
expect(response.cacheControl).toBe('public, max-age=31536000');
|
||||
});
|
||||
|
||||
it('answers HEAD on the zsync sidecar the way it answers GET', async () => {
|
||||
const response = await request('/dl/desktop/canary/linux/x64/latest/appimage.zsync', {method: 'HEAD'});
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/canary/linux/x64/latest/appimage.zsync`);
|
||||
});
|
||||
|
||||
it('still rejects a zsync sidecar for a format that publishes none', async () => {
|
||||
const response = await request('/dl/desktop/canary/linux/x64/latest/deb.zsync');
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('redirects the versioned artifact route and lets the redirect be cached', async () => {
|
||||
const response = await request('/dl/desktop/stable/win32/x64/1.4.2/setup');
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/win32/x64/1.4.2/setup`);
|
||||
expect(response.cacheControl).toBe('public, max-age=31536000');
|
||||
});
|
||||
|
||||
it('redirects the versioned checksum route', async () => {
|
||||
const response = await request('/dl/desktop/stable/win32/x64/1.4.2/setup.sha256');
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/win32/x64/1.4.2/setup.sha256`);
|
||||
expect(response.cacheControl).toBe('public, max-age=31536000');
|
||||
});
|
||||
|
||||
it('answers HEAD on the artifact routes the way it answers GET', async () => {
|
||||
const response = await request('/dl/desktop/stable/win32/x64/1.4.2/setup', {method: 'HEAD'});
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/win32/x64/1.4.2/setup`);
|
||||
});
|
||||
|
||||
it('rejects a format outside the closed registry before it reaches the redirector', async () => {
|
||||
const response = await request('/dl/desktop/stable/linux/x64/latest/msix');
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('release feed routes', () => {
|
||||
it.each([
|
||||
'/dl/desktop/stable/darwin/arm64/RELEASES.json',
|
||||
'/dl/desktop/stable/win32/x64/RELEASES',
|
||||
'/dl/desktop/canary/win32/arm64/releases.canary.json',
|
||||
'/dl/desktop/stable/win32/x64/releases.win.json',
|
||||
'/dl/desktop/stable/linux/x64/manifest.json',
|
||||
])('redirects %s without caching the redirect', async (path) => {
|
||||
const response = await request(path);
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}${path.slice('/dl'.length)}`);
|
||||
expect(response.cacheControl).toBe('no-store');
|
||||
});
|
||||
|
||||
it('redirects a nupkg named by a RELEASES body', async () => {
|
||||
const response = await request('/dl/desktop/stable/win32/x64/fluxer_app-0.0.8-full.nupkg');
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/stable/win32/x64/fluxer_app-0.0.8-full.nupkg`);
|
||||
expect(response.cacheControl).toBe('public, max-age=31536000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel, platform and architecture targeting', () => {
|
||||
it.each([
|
||||
['stable', 'darwin', 'arm64'],
|
||||
['stable', 'darwin', 'x64'],
|
||||
['stable', 'win32', 'x64'],
|
||||
['stable', 'win32', 'arm64'],
|
||||
['stable', 'linux', 'x64'],
|
||||
['stable', 'linux', 'arm64'],
|
||||
['canary', 'darwin', 'arm64'],
|
||||
['canary', 'win32', 'x64'],
|
||||
['canary', 'linux', 'arm64'],
|
||||
])('keeps %s/%s/%s in the redirect target', async (channel, plat, arch) => {
|
||||
const response = await request(`/dl/desktop/${channel}/${plat}/${arch}/latest`);
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/${channel}/${plat}/${arch}/latest.json`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the geoip and github release route is gone', () => {
|
||||
it('sends every country to the package origin with the same cache control', async () => {
|
||||
const path = '/dl/desktop/stable/darwin/arm64/1.4.2/dmg';
|
||||
const withCountry = await request(path, {headers: COUNTRY_HEADERS});
|
||||
const withoutCountry = await request(path);
|
||||
expect(withCountry).toEqual(withoutCountry);
|
||||
expect(withCountry.location).toBe(`${PKGS_BASE_URL}/desktop/stable/darwin/arm64/1.4.2/dmg`);
|
||||
expect(withCountry.cacheControl).not.toBe('private, no-store');
|
||||
});
|
||||
|
||||
it('never points a download at github', async () => {
|
||||
const response = await request('/dl/desktop/stable/darwin/arm64/1.4.2/zip', {headers: COUNTRY_HEADERS});
|
||||
expect(response.location).not.toContain('github.com');
|
||||
expect(response.location?.startsWith(`${PKGS_BASE_URL}/`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paths the redirector refuses', () => {
|
||||
it('answers 404 for a key outside the desktop prefix', async () => {
|
||||
const response = await request('/dl/harvests/dump.zip');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toBe('Not Found');
|
||||
});
|
||||
|
||||
it('answers 404 for a traversal attempt', async () => {
|
||||
const response = await request('/dl/desktop/../harvests/dump.zip');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('answers 404 for the retired test build prefix', async () => {
|
||||
const response = await request('/dl/desktop-test/canary/linux/x64/latest/appimage');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ignores a test query parameter rather than resolving another prefix', async () => {
|
||||
const response = await request('/dl/desktop/canary/linux/x64/latest/appimage?test=1');
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.location).toBe(`${PKGS_BASE_URL}/desktop/canary/linux/x64/latest/appimage`);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DownloadService} from '@app/api/download/DownloadService';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const OBJECT_METADATA = {
|
||||
contentLength: 285_567_850,
|
||||
contentType: 'application/x-apple-diskimage',
|
||||
etag: '"abc123"',
|
||||
lastModified: new Date('2026-08-17T00:00:00Z'),
|
||||
};
|
||||
|
||||
interface PresignCall {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expiresIn?: number;
|
||||
responseContentType?: string;
|
||||
responseContentDisposition?: string;
|
||||
}
|
||||
|
||||
function createService(overrides: {metadata?: typeof OBJECT_METADATA | null} = {}) {
|
||||
const presignCalls: Array<PresignCall> = [];
|
||||
const storageService = {
|
||||
getObjectMetadata: async () => (overrides.metadata === undefined ? OBJECT_METADATA : overrides.metadata),
|
||||
getPresignedDownloadURL: async (params: PresignCall) => {
|
||||
presignCalls.push(params);
|
||||
return `https://storage.example.test/${params.key}?signed=1`;
|
||||
},
|
||||
} as unknown as IStorageService;
|
||||
return {service: new DownloadService(storageService), presignCalls};
|
||||
}
|
||||
|
||||
describe('presigned download redirects', () => {
|
||||
it('signs the requested object and returns its URL', async () => {
|
||||
const {service, presignCalls} = createService();
|
||||
const url = await service.getPresignedDownloadRedirect({
|
||||
key: 'desktop/canary/darwin/universal/Fluxer.dmg',
|
||||
filename: 'Fluxer.dmg',
|
||||
expiresIn: 900,
|
||||
});
|
||||
expect(url).toBe('https://storage.example.test/desktop/canary/darwin/universal/Fluxer.dmg?signed=1');
|
||||
expect(presignCalls).toHaveLength(1);
|
||||
expect(presignCalls[0]?.key).toBe('desktop/canary/darwin/universal/Fluxer.dmg');
|
||||
expect(presignCalls[0]?.expiresIn).toBe(900);
|
||||
});
|
||||
|
||||
it('preserves the download filename and content type through the redirect', async () => {
|
||||
const {service, presignCalls} = createService();
|
||||
await service.getPresignedDownloadRedirect({
|
||||
key: 'desktop/canary/darwin/universal/Fluxer.dmg',
|
||||
filename: 'Fluxer Canary.dmg',
|
||||
expiresIn: 900,
|
||||
});
|
||||
expect(presignCalls[0]?.responseContentType).toBe('application/x-apple-diskimage');
|
||||
expect(presignCalls[0]?.responseContentDisposition).toBe(
|
||||
`attachment; filename="${encodeURIComponent('Fluxer Canary.dmg')}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to a binary content type when storage reports none', async () => {
|
||||
const {service, presignCalls} = createService({
|
||||
metadata: {...OBJECT_METADATA, contentType: null} as unknown as typeof OBJECT_METADATA,
|
||||
});
|
||||
await service.getPresignedDownloadRedirect({key: 'desktop/x.bin', filename: 'x.bin', expiresIn: 900});
|
||||
expect(presignCalls[0]?.responseContentType).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('returns null for a missing object so the caller can answer 404 without signing', async () => {
|
||||
const {service, presignCalls} = createService({metadata: null});
|
||||
const url = await service.getPresignedDownloadRedirect({
|
||||
key: 'desktop/missing.dmg',
|
||||
filename: 'missing.dmg',
|
||||
expiresIn: 900,
|
||||
});
|
||||
expect(url).toBeNull();
|
||||
expect(presignCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
DESKTOP_COORDINATE_DOCUMENTS,
|
||||
downloadRedirectCacheControl,
|
||||
PKGS_BASE_URL,
|
||||
resolveDownloadObjectPath,
|
||||
resolveDownloadRedirect,
|
||||
} from '@app/api/download/DownloadRedirects';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const MUTABLE = 'no-store';
|
||||
const IMMUTABLE = 'public, max-age=31536000';
|
||||
|
||||
describe('the coordinate document contract the publisher writes', () => {
|
||||
it('names the exact files scripts/packages/stage-desktop.sh and retention.sh publish', () => {
|
||||
expect(Object.fromEntries(DESKTOP_COORDINATE_DOCUMENTS)).toEqual({
|
||||
latest: 'latest.json',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the bare coordinate names to those files on every coordinate', () => {
|
||||
for (const channel of ['stable', 'canary']) {
|
||||
for (const plat of ['win32', 'darwin', 'linux']) {
|
||||
for (const arch of ['x64', 'arm64']) {
|
||||
expect(resolveDownloadObjectPath(`/dl/desktop/${channel}/${plat}/${arch}/latest`)).toBe(
|
||||
`desktop/${channel}/${plat}/${arch}/latest.json`,
|
||||
);
|
||||
expect(resolveDownloadObjectPath(`/dl/desktop/${channel}/${plat}/${arch}/versions`)).toBe(
|
||||
`desktop/${channel}/${plat}/${arch}/versions`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the latest directory alone when a format or a sidecar follows it', () => {
|
||||
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux/x64/latest/appimage')).toBe(
|
||||
'desktop/stable/linux/x64/latest/appimage',
|
||||
);
|
||||
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux/x64/latest/appimage.zsync')).toBe(
|
||||
'desktop/stable/linux/x64/latest/appimage.zsync',
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites the legacy platform-arch form to the same documents', () => {
|
||||
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux-x64/latest')).toBe(
|
||||
'desktop/stable/linux/x64/latest.json',
|
||||
);
|
||||
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux-x64/versions')).toBe(
|
||||
'desktop/stable/linux/x64/versions',
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites nothing else that sits at the coordinate root', () => {
|
||||
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux/x64/manifest.json')).toBe(
|
||||
'desktop/stable/linux/x64/manifest.json',
|
||||
);
|
||||
expect(resolveDownloadObjectPath('/dl/desktop/stable/win32/x64/RELEASES')).toBe(
|
||||
'desktop/stable/win32/x64/RELEASES',
|
||||
);
|
||||
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux/x64/constructor')).toBe(
|
||||
'desktop/stable/linux/x64/constructor',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download object paths', () => {
|
||||
it('strips the /dl prefix and keeps the rest of the path verbatim', () => {
|
||||
expect(resolveDownloadObjectPath('/dl/desktop/stable/darwin/arm64/RELEASES.json')).toBe(
|
||||
'desktop/stable/darwin/arm64/RELEASES.json',
|
||||
);
|
||||
expect(resolveDownloadObjectPath('/dl/desktop/stable/win32/x64/1.4.2/Fluxer-1.4.2-win-x64.exe')).toBe(
|
||||
'desktop/stable/win32/x64/1.4.2/Fluxer-1.4.2-win-x64.exe',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalises the legacy platform-arch segment to the published layout', () => {
|
||||
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux-x64/manifest.json')).toBe(
|
||||
'desktop/stable/linux/x64/manifest.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a key outside the desktop prefix', () => {
|
||||
expect(resolveDownloadObjectPath('/dl/reports/secret.json')).toBeNull();
|
||||
expect(resolveDownloadObjectPath('/dl/desktop-test/canary/linux/x64/latest/appimage')).toBeNull();
|
||||
expect(resolveDownloadObjectPath('/dl/')).toBeNull();
|
||||
expect(resolveDownloadObjectPath('/other/desktop/stable/linux/x64/latest')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a traversal attempt rather than pointing at another prefix', () => {
|
||||
expect(resolveDownloadObjectPath('/dl/desktop/../harvests/dump.zip')).toBeNull();
|
||||
expect(resolveDownloadObjectPath('/dl/../desktop/stable/linux/x64/latest')).toBeNull();
|
||||
expect(resolveDownloadObjectPath('/dl/desktop/stable/linux/x64/lat\0est')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('download redirect cache control', () => {
|
||||
it('never caches a redirect to a mutable document', () => {
|
||||
expect(downloadRedirectCacheControl('desktop/stable/darwin/arm64/latest.json')).toBe(MUTABLE);
|
||||
expect(downloadRedirectCacheControl('desktop/stable/darwin/arm64/version.json')).toBe(MUTABLE);
|
||||
expect(downloadRedirectCacheControl('desktop/stable/linux/x64/latest/appimage')).toBe(MUTABLE);
|
||||
expect(downloadRedirectCacheControl('desktop/stable/linux/x64/latest/appimage.sha256')).toBe(MUTABLE);
|
||||
expect(downloadRedirectCacheControl('desktop/stable/linux/x64/latest/appimage.zsync')).toBe(MUTABLE);
|
||||
expect(downloadRedirectCacheControl('desktop/stable/darwin/arm64/RELEASES.json')).toBe(MUTABLE);
|
||||
expect(downloadRedirectCacheControl('desktop/stable/win32/x64/RELEASES')).toBe(MUTABLE);
|
||||
expect(downloadRedirectCacheControl('desktop/canary/win32/x64/releases.canary.json')).toBe(MUTABLE);
|
||||
expect(downloadRedirectCacheControl('desktop/stable/linux/x64/manifest.json')).toBe(MUTABLE);
|
||||
expect(downloadRedirectCacheControl('desktop/stable/linux/x64/latest-linux.yml')).toBe(MUTABLE);
|
||||
});
|
||||
|
||||
it('caches a redirect to a version pinned artifact for a year', () => {
|
||||
expect(downloadRedirectCacheControl('desktop/stable/darwin/arm64/1.4.2/dmg')).toBe(IMMUTABLE);
|
||||
expect(downloadRedirectCacheControl('desktop/stable/darwin/arm64/1.4.2/dmg.sha256')).toBe(IMMUTABLE);
|
||||
expect(downloadRedirectCacheControl('desktop/stable/linux/x64/1.4.2/appimage.zsync')).toBe(IMMUTABLE);
|
||||
expect(downloadRedirectCacheControl('desktop/stable/win32/x64/fluxer_app-0.0.8-full.nupkg')).toBe(IMMUTABLE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download redirects', () => {
|
||||
it('points every desktop path at the package origin', () => {
|
||||
expect(resolveDownloadRedirect('/dl/desktop/stable/darwin/arm64/1.4.2/dmg')).toEqual({
|
||||
location: `${PKGS_BASE_URL}/desktop/stable/darwin/arm64/1.4.2/dmg`,
|
||||
cacheControl: IMMUTABLE,
|
||||
});
|
||||
expect(resolveDownloadRedirect('/dl/desktop/canary/linux/arm64/latest')).toEqual({
|
||||
location: `${PKGS_BASE_URL}/desktop/canary/linux/arm64/latest.json`,
|
||||
cacheControl: MUTABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for a path it refuses to map', () => {
|
||||
expect(resolveDownloadRedirect('/dl/harvests/dump.zip')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Readable} from 'node:stream';
|
||||
import {DownloadService} from '@app/api/download/DownloadService';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const PREFIX = 'desktop/stable/darwin/x64';
|
||||
const MANIFEST_KEY = `${PREFIX}/manifest.json`;
|
||||
const LISTED_FILENAME = 'Fluxer-1.2.3-mac-universal.dmg';
|
||||
const MANIFEST_FILENAME = 'Fluxer-1.3.0-mac-universal.dmg';
|
||||
|
||||
const LATEST_PARAMS = {
|
||||
channel: 'stable',
|
||||
plat: 'darwin',
|
||||
arch: 'x64',
|
||||
format: 'dmg',
|
||||
} as const;
|
||||
|
||||
function createService(overrides: {manifestBody?: string | null; objectKeys?: Array<string>} = {}) {
|
||||
const objectKeys = overrides.objectKeys ?? [`${PREFIX}/${LISTED_FILENAME}`];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
if (params.key !== MANIFEST_KEY) {
|
||||
return null;
|
||||
}
|
||||
const body = overrides.manifestBody;
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(body, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async () => objectKeys.map((key) => ({key})),
|
||||
getObjectMetadata: async (_bucket: string, key: string) =>
|
||||
objectKeys.includes(key) ? {contentLength: 1, contentType: 'application/x-apple-diskimage'} : null,
|
||||
} as unknown as IStorageService;
|
||||
return new DownloadService(storageService);
|
||||
}
|
||||
|
||||
describe('desktop manifest parsing', () => {
|
||||
it('falls back to the object listing when the manifest is not valid JSON', async () => {
|
||||
const service = createService({manifestBody: '{not json'});
|
||||
await expect(service.resolveLatestDesktopKey({...LATEST_PARAMS})).resolves.toBe(`${PREFIX}/${LISTED_FILENAME}`);
|
||||
});
|
||||
|
||||
it('returns null rather than throwing when the manifest is malformed and no artifact is listed', async () => {
|
||||
const service = createService({manifestBody: '{not json', objectKeys: []});
|
||||
await expect(service.resolveLatestDesktopKey({...LATEST_PARAMS})).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the object listing when the manifest parses to an array', async () => {
|
||||
const service = createService({manifestBody: '[]'});
|
||||
await expect(service.resolveLatestDesktopKey({...LATEST_PARAMS})).resolves.toBe(`${PREFIX}/${LISTED_FILENAME}`);
|
||||
});
|
||||
|
||||
it('still resolves through a well-formed manifest', async () => {
|
||||
const service = createService({
|
||||
manifestBody: JSON.stringify({
|
||||
channel: 'stable',
|
||||
platform: 'darwin',
|
||||
arch: 'x64',
|
||||
version: '1.3.0',
|
||||
pub_date: '2026-08-17T00:00:00Z',
|
||||
files: {dmg: MANIFEST_FILENAME},
|
||||
}),
|
||||
objectKeys: [`${PREFIX}/${LISTED_FILENAME}`, `${PREFIX}/${MANIFEST_FILENAME}`],
|
||||
});
|
||||
await expect(service.resolveLatestDesktopKey({...LATEST_PARAMS})).resolves.toBe(`${PREFIX}/${MANIFEST_FILENAME}`);
|
||||
});
|
||||
});
|
||||
@@ -1,340 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {Readable} from 'node:stream';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {DownloadService} from '@app/api/download/DownloadService';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import {S3ServiceException} from '@aws-sdk/client-s3';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const PREFIX = 'desktop/canary/linux/x64';
|
||||
const TEST_PREFIX = 'desktop-test/canary/linux/x64';
|
||||
const RELEASES_PREFIX = 'desktop/canary/github-releases';
|
||||
const SOURCE_SHA = 'b'.repeat(40);
|
||||
const V904 = '2026.904.135113';
|
||||
const V908 = '2026.908.173325';
|
||||
const V909 = '2026.909.202036';
|
||||
|
||||
const LATEST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64'} as const;
|
||||
const APPIMAGE_PARAMS = {...LATEST_PARAMS, format: 'appimage'} as const;
|
||||
|
||||
const RELEASE_ROUTES: ReadonlyArray<readonly [string, string, number]> = [
|
||||
['darwin', 'arm64', 4],
|
||||
['darwin', 'x64', 4],
|
||||
['linux', 'arm64', 4],
|
||||
['linux', 'x64', 4],
|
||||
['win32', 'arm64', 6],
|
||||
['win32', 'x64', 6],
|
||||
];
|
||||
|
||||
type StoredObjects = Map<string, string>;
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function appImageFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
|
||||
}
|
||||
|
||||
function uploadBuild(objects: StoredObjects, version: string, options: {prefix?: string; checksum?: boolean} = {}) {
|
||||
const prefix = options.prefix ?? PREFIX;
|
||||
const filename = appImageFilename(version);
|
||||
objects.set(`${prefix}/${filename}`, filename);
|
||||
if (options.checksum !== false) {
|
||||
objects.set(`${prefix}/${filename}.sha256`, `${sha256Hex(filename)} ${filename}`);
|
||||
}
|
||||
objects.set(
|
||||
`${prefix}/manifest.json`,
|
||||
JSON.stringify({
|
||||
channel: 'canary',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
version,
|
||||
pub_date: '2026-09-08T18:06:00Z',
|
||||
files: {appimage: {filename, sha256: sha256Hex(filename)}},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function publishDescriptor(objects: StoredObjects, version: string, routes = RELEASE_ROUTES): string {
|
||||
const assets = routes.flatMap(([plat, arch, count]) =>
|
||||
Array.from({length: count}, (_, index) => {
|
||||
const filename =
|
||||
plat === 'linux' && arch === 'x64' && index === 0
|
||||
? appImageFilename(version)
|
||||
: `Fluxer-Canary-${version}-${plat}-${arch}-${index}.bin`;
|
||||
return {
|
||||
storage_key: `desktop/canary/${plat}/${arch}/${filename}`,
|
||||
release_asset: filename,
|
||||
sha256: sha256Hex(filename),
|
||||
size: 1,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const descriptor = JSON.stringify({
|
||||
schema_version: 1,
|
||||
channel: 'canary',
|
||||
version,
|
||||
release_tag: `fluxer-desktop-canary@${version}`,
|
||||
source_sha: SOURCE_SHA,
|
||||
assets,
|
||||
});
|
||||
objects.set(`${RELEASES_PREFIX}/${version}.json`, descriptor);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function publishMarker(objects: StoredObjects, version: string, descriptor: string) {
|
||||
objects.set(
|
||||
`${RELEASES_PREFIX}/${version}.ready.json`,
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
channel: 'canary',
|
||||
version,
|
||||
release_tag: `fluxer-desktop-canary@${version}`,
|
||||
source_sha: SOURCE_SHA,
|
||||
descriptor_sha256: sha256Hex(descriptor),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function releaseBuild(objects: StoredObjects, version: string) {
|
||||
const descriptor = publishDescriptor(objects, version);
|
||||
uploadBuild(objects, version);
|
||||
publishMarker(objects, version, descriptor);
|
||||
}
|
||||
|
||||
function incidentObjects(): StoredObjects {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908);
|
||||
return objects;
|
||||
}
|
||||
|
||||
function createService(objects: StoredObjects, onRead?: (key: string) => void) {
|
||||
const reads: Array<string> = [];
|
||||
const listings: Array<string> = [];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
reads.push(params.key);
|
||||
onRead?.(params.key);
|
||||
const body = objects.get(params.key);
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(body, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async (params: {prefix: string}) => {
|
||||
listings.push(params.prefix);
|
||||
return Array.from(objects.keys())
|
||||
.filter((key) => key.startsWith(params.prefix))
|
||||
.sort()
|
||||
.map((key) => ({key}));
|
||||
},
|
||||
getObjectMetadata: async (_bucket: string, key: string) =>
|
||||
objects.has(key) ? {contentLength: 1, contentType: 'application/octet-stream'} : null,
|
||||
} as unknown as IStorageService;
|
||||
return {service: new DownloadService(storageService), reads, listings};
|
||||
}
|
||||
|
||||
async function resolveLatest(service: DownloadService, test?: boolean) {
|
||||
const metadata = await service.getLatestDesktopVersion({...LATEST_PARAMS, test});
|
||||
const key = await service.resolveLatestDesktopKey({...APPIMAGE_PARAMS, test});
|
||||
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS, test});
|
||||
return {version: metadata?.version, key, checksum: checksum?.body};
|
||||
}
|
||||
|
||||
function latestOf(version: string, prefix = PREFIX) {
|
||||
const filename = appImageFilename(version);
|
||||
return {version, key: `${prefix}/${filename}`, checksum: `${sha256Hex(filename)} ${filename}\n`};
|
||||
}
|
||||
|
||||
describe('desktop release readiness', () => {
|
||||
it('offers a published manifest version after reading only its release state', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
releaseBuild(objects, V909);
|
||||
const {service, reads, listings} = createService(objects);
|
||||
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V909});
|
||||
expect(reads).toEqual([
|
||||
`${PREFIX}/manifest.json`,
|
||||
`${RELEASES_PREFIX}/${V909}.json`,
|
||||
`${RELEASES_PREFIX}/${V909}.ready.json`,
|
||||
]);
|
||||
expect(listings).toEqual([]);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
|
||||
});
|
||||
|
||||
it('falls back to the newest published version while the manifest version awaits its release', async () => {
|
||||
const {service} = createService(incidentObjects());
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
|
||||
it('reads each release state once and one checksum while the manifest version awaits its release', async () => {
|
||||
const {service, reads, listings} = createService(incidentObjects());
|
||||
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V904});
|
||||
expect(reads).toEqual([
|
||||
`${PREFIX}/manifest.json`,
|
||||
`${RELEASES_PREFIX}/${V908}.json`,
|
||||
`${RELEASES_PREFIX}/${V908}.ready.json`,
|
||||
`${RELEASES_PREFIX}/${V904}.json`,
|
||||
`${RELEASES_PREFIX}/${V904}.ready.json`,
|
||||
`${PREFIX}/${appImageFilename(V904)}.sha256`,
|
||||
]);
|
||||
expect(listings).toEqual([`${PREFIX}/`]);
|
||||
});
|
||||
|
||||
it('offers a manifest version that has no release descriptor', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
uploadBuild(objects, V904);
|
||||
uploadBuild(objects, V908);
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
});
|
||||
|
||||
it('treats a readiness marker that does not match the stored descriptor as unpublished', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908);
|
||||
publishMarker(objects, V908, 'another descriptor');
|
||||
const {service} = createService(objects);
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
|
||||
kind: 'awaiting_release',
|
||||
});
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
|
||||
it('offers a version whose descriptor the parser rejects when its readiness marker matches', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
const descriptor = publishDescriptor(
|
||||
objects,
|
||||
V908,
|
||||
RELEASE_ROUTES.map(([plat, arch, count]) => [plat, arch, plat === 'linux' ? count - 1 : count] as const),
|
||||
);
|
||||
uploadBuild(objects, V908);
|
||||
publishMarker(objects, V908, descriptor);
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).rejects.toThrow(
|
||||
'Invalid GitHub desktop release descriptor',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['descriptor', `${RELEASES_PREFIX}/${V908}.json`],
|
||||
['readiness marker', `${RELEASES_PREFIX}/${V908}.ready.json`],
|
||||
])(
|
||||
'offers the manifest version when reading its release %s fails with a storage error',
|
||||
async (_name, failingKey) => {
|
||||
const {service} = createService(incidentObjects(), (key) => {
|
||||
if (key === failingKey) {
|
||||
throw new S3ServiceException({
|
||||
name: 'SlowDown',
|
||||
$fault: 'server',
|
||||
$metadata: {httpStatusCode: 503},
|
||||
message: 'Please reduce your request rate.',
|
||||
});
|
||||
}
|
||||
});
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
},
|
||||
);
|
||||
|
||||
it('still resolves the unpublished version through versioned routes', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908, {checksum: false});
|
||||
const {service} = createService(objects);
|
||||
const params = {...APPIMAGE_PARAMS, version: V908};
|
||||
const filename = appImageFilename(V908);
|
||||
await expect(service.resolveVersionedDesktopKey(params)).resolves.toBe(`${PREFIX}/${filename}`);
|
||||
await expect(service.resolveVersionedDesktopChecksumFile(params)).resolves.toMatchObject({
|
||||
sha256: sha256Hex(filename),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps offering the manifest version on self-hosted instances', async () => {
|
||||
const config = getConfig();
|
||||
const originalSelfHosted = config.instance.selfHosted;
|
||||
config.instance.selfHosted = true;
|
||||
try {
|
||||
const {service, reads} = createService(incidentObjects());
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
|
||||
} finally {
|
||||
config.instance.selfHosted = originalSelfHosted;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps offering the newest test build', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908, {prefix: TEST_PREFIX});
|
||||
const {service, reads} = createService(objects);
|
||||
await expect(resolveLatest(service, true)).resolves.toEqual(latestOf(V908, TEST_PREFIX));
|
||||
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
|
||||
});
|
||||
|
||||
it('offers 904 while 908 awaits its release, then 909 once its marker lands', async () => {
|
||||
const objects = incidentObjects();
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
|
||||
kind: 'awaiting_release',
|
||||
});
|
||||
const descriptor = publishDescriptor(objects, V909);
|
||||
uploadBuild(objects, V909);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
publishMarker(objects, V909, descriptor);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V909)}`)).resolves.toEqual({
|
||||
kind: 'ready',
|
||||
location: `https://github.com/fluxerapp/fluxer/releases/download/${encodeURIComponent(`fluxer-desktop-canary@${V909}`)}/${appImageFilename(V909)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers the newest version when ten unpublished versions hide a published one', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
const newest = '2026.908.170009';
|
||||
for (let build = 0; build < 10; build++) {
|
||||
const version = `2026.908.${170000 + build}`;
|
||||
publishDescriptor(objects, version);
|
||||
uploadBuild(objects, version);
|
||||
}
|
||||
const {service, reads} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(newest));
|
||||
expect(reads).not.toContain(`${RELEASES_PREFIX}/${V904}.ready.json`);
|
||||
});
|
||||
|
||||
it('pairs the latest checksum with the filename of the same version when a marker lands mid-request', async () => {
|
||||
const objects = incidentObjects();
|
||||
const descriptor = publishDescriptor(objects, V909);
|
||||
uploadBuild(objects, V909);
|
||||
let manifestReads = 0;
|
||||
const {service} = createService(objects, (key) => {
|
||||
if (key !== `${PREFIX}/manifest.json`) {
|
||||
return;
|
||||
}
|
||||
manifestReads += 1;
|
||||
if (manifestReads === 2) {
|
||||
publishMarker(objects, V909, descriptor);
|
||||
}
|
||||
});
|
||||
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS});
|
||||
expect(checksum?.body).toBe(latestOf(V904).checksum);
|
||||
});
|
||||
|
||||
it('lists an unpublished version while latest skips it', async () => {
|
||||
const {service} = createService(incidentObjects());
|
||||
const listed = await service.listDesktopVersions({...LATEST_PARAMS, limit: 10});
|
||||
expect(listed.versions.map((entry) => entry.version)).toEqual([V908, V904]);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Readable} from 'node:stream';
|
||||
import {DownloadService} from '@app/api/download/DownloadService';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const PREFIX = 'desktop/canary/linux/x64';
|
||||
const BASE_URL = 'https://api.example.test';
|
||||
const V1 = '2026.901.100000';
|
||||
const V2 = '2026.902.100000';
|
||||
const V3 = '2026.903.100000';
|
||||
const V4 = '2026.904.100000';
|
||||
const V5 = '2026.905.100000';
|
||||
|
||||
const LIST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64', baseUrl: BASE_URL} as const;
|
||||
|
||||
type StoredObject = {body?: string; lastModified?: Date};
|
||||
type StoredObjects = Map<string, StoredObject>;
|
||||
|
||||
function appImageFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
|
||||
}
|
||||
|
||||
function debFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-amd64.deb`;
|
||||
}
|
||||
|
||||
function addArtifact(objects: StoredObjects, filename: string, options: {sha256?: string; lastModified?: Date} = {}) {
|
||||
objects.set(`${PREFIX}/${filename}`, {lastModified: options.lastModified});
|
||||
if (options.sha256 !== undefined) {
|
||||
objects.set(`${PREFIX}/${filename}.sha256`, {body: `${options.sha256} ${filename}\n`});
|
||||
}
|
||||
}
|
||||
|
||||
function createService(objects: StoredObjects) {
|
||||
const reads: Array<string> = [];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
reads.push(params.key);
|
||||
const object = objects.get(params.key);
|
||||
if (object?.body == null) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(object.body, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async (params: {prefix: string}) =>
|
||||
Array.from(objects.entries())
|
||||
.filter(([key]) => key.startsWith(params.prefix))
|
||||
.sort(([left], [right]) => (left < right ? -1 : 1))
|
||||
.map(([key, object]) => ({key, lastModified: object.lastModified})),
|
||||
getObjectMetadata: async () => null,
|
||||
} as unknown as IStorageService;
|
||||
return {service: new DownloadService(storageService), reads};
|
||||
}
|
||||
|
||||
function versionNumbers(versions: Array<{version: string}>): Array<string> {
|
||||
return versions.map((entry) => entry.version);
|
||||
}
|
||||
|
||||
describe('desktop version listing', () => {
|
||||
it('lists versions newest first with the files of each version', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V1), {lastModified: new Date('2026-09-01T10:00:00Z')});
|
||||
addArtifact(objects, appImageFilename(V3), {lastModified: new Date('2026-09-03T10:00:00Z')});
|
||||
addArtifact(objects, debFilename(V3), {lastModified: new Date('2026-09-03T12:00:00Z')});
|
||||
addArtifact(objects, appImageFilename(V5), {lastModified: new Date('2026-09-05T10:00:00Z')});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(versionNumbers(listed.versions)).toEqual([V5, V3, V1]);
|
||||
expect(listed.hasMore).toBe(false);
|
||||
expect(Object.keys(listed.versions[1].files).sort()).toEqual(['appimage', 'deb']);
|
||||
expect(listed.versions[1].pub_date).toBe('2026-09-03T12:00:00.000Z');
|
||||
expect(listed.versions[0].files.appimage.url).toBe(`${BASE_URL}/dl/desktop/canary/linux/x64/${V5}/appimage`);
|
||||
});
|
||||
|
||||
it('excludes names that are not artefacts for the requested coordinate', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V3), {sha256: 'a'.repeat(64)});
|
||||
objects.set(`${PREFIX}/nested/${appImageFilename(V5)}`, {});
|
||||
objects.set(`${PREFIX}/manifest.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/RELEASES.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/releases.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/latest-linux.yml`, {body: 'version: 1'});
|
||||
objects.set(`${PREFIX}/${appImageFilename(V4)}.blockmap`, {});
|
||||
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-linux-aarch64.AppImage`, {});
|
||||
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-mac-universal.dmg`, {});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(versionNumbers(listed.versions)).toEqual([V3]);
|
||||
expect(Object.keys(listed.versions[0].files)).toEqual(['appimage']);
|
||||
});
|
||||
|
||||
it('pages with limit, before and after and reports whether more remain', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
for (const version of [V1, V2, V3, V4, V5]) {
|
||||
addArtifact(objects, appImageFilename(version));
|
||||
}
|
||||
const {service} = createService(objects);
|
||||
const firstPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
|
||||
expect(versionNumbers(firstPage.versions)).toEqual([V5, V4]);
|
||||
expect(firstPage.hasMore).toBe(true);
|
||||
const olderPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, before: V3});
|
||||
expect(versionNumbers(olderPage.versions)).toEqual([V2, V1]);
|
||||
expect(olderPage.hasMore).toBe(false);
|
||||
const newerPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, after: V3});
|
||||
expect(versionNumbers(newerPage.versions)).toEqual([V5, V4]);
|
||||
expect(newerPage.hasMore).toBe(false);
|
||||
const between = await service.listDesktopVersions({...LIST_PARAMS, limit: 1, before: V5, after: V1});
|
||||
expect(versionNumbers(between.versions)).toEqual([V4]);
|
||||
expect(between.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the sibling hash and treats a missing or malformed one as absent', async () => {
|
||||
const hash = 'b'.repeat(64);
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V3), {sha256: hash});
|
||||
addArtifact(objects, appImageFilename(V2));
|
||||
addArtifact(objects, appImageFilename(V1), {sha256: 'C'.repeat(64)});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(listed.versions[0].files.appimage).toEqual({
|
||||
url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage`,
|
||||
sha256: hash,
|
||||
checksum_url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage.sha256`,
|
||||
});
|
||||
expect(listed.versions[1].files.appimage.sha256).toBeNull();
|
||||
expect(listed.versions[1].files.appimage.checksum_url).toBeNull();
|
||||
expect(listed.versions[2].files.appimage.sha256).toBeNull();
|
||||
expect(listed.versions[2].files.appimage.checksum_url).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a checksum only for the versions it returns', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
for (const version of [V1, V2, V3, V4, V5]) {
|
||||
addArtifact(objects, appImageFilename(version), {sha256: 'd'.repeat(64)});
|
||||
}
|
||||
const {service, reads} = createService(objects);
|
||||
await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
|
||||
expect(reads).toEqual([`${PREFIX}/${appImageFilename(V5)}.sha256`, `${PREFIX}/${appImageFilename(V4)}.sha256`]);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Readable} from 'node:stream';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {DownloadService} from '@app/api/download/DownloadService';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const PREFIX = 'desktop/stable/darwin/x64';
|
||||
const MANIFEST_KEY = `${PREFIX}/manifest.json`;
|
||||
const FILENAME = 'Fluxer-1.3.0-mac-universal.dmg';
|
||||
const SHA256 = 'a'.repeat(64);
|
||||
|
||||
const LATEST_PARAMS = {
|
||||
channel: 'stable',
|
||||
plat: 'darwin',
|
||||
arch: 'x64',
|
||||
} as const;
|
||||
|
||||
const MANIFEST_BODY = JSON.stringify({
|
||||
channel: 'stable',
|
||||
platform: 'darwin',
|
||||
arch: 'x64',
|
||||
version: '1.3.0',
|
||||
pub_date: '2026-08-17T00:00:00Z',
|
||||
files: {dmg: {filename: FILENAME, sha256: SHA256}},
|
||||
});
|
||||
|
||||
function createService() {
|
||||
const objectKeys = [`${PREFIX}/${FILENAME}`];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
if (params.key !== MANIFEST_KEY) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(MANIFEST_BODY, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async () => objectKeys.map((key) => ({key})),
|
||||
getObjectMetadata: async (_bucket: string, key: string) =>
|
||||
objectKeys.includes(key) ? {contentLength: 1, contentType: 'application/x-apple-diskimage'} : null,
|
||||
} as unknown as IStorageService;
|
||||
return new DownloadService(storageService);
|
||||
}
|
||||
|
||||
describe('desktop download base url', () => {
|
||||
it('builds artifact urls from the configured client API endpoint', async () => {
|
||||
const version = await createService().getLatestDesktopVersion({...LATEST_PARAMS});
|
||||
const base = Config.endpoints.apiClient.replace(/\/+$/u, '');
|
||||
expect(base.length).toBeGreaterThan(0);
|
||||
expect(version?.files.dmg?.url).toBe(`${base}/dl/desktop/stable/darwin/x64/1.3.0/dmg`);
|
||||
expect(version?.files.dmg?.checksum_url).toBe(`${base}/dl/desktop/stable/darwin/x64/1.3.0/dmg.sha256`);
|
||||
});
|
||||
|
||||
it('prefers an explicit base url and strips its trailing slashes', async () => {
|
||||
const version = await createService().getLatestDesktopVersion({
|
||||
...LATEST_PARAMS,
|
||||
baseUrl: 'https://chat.example.com/api//',
|
||||
});
|
||||
expect(version?.files.dmg?.url).toBe('https://chat.example.com/api/dl/desktop/stable/darwin/x64/1.3.0/dmg');
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
} from '@aws-sdk/client-s3';
|
||||
import {Upload} from '@aws-sdk/lib-storage';
|
||||
import {getSignedUrl} from '@aws-sdk/s3-request-presigner';
|
||||
import type {S3ProviderSettings} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import type {S3ProviderSettings} from '@fluxer/config/src/S3ProviderSettings';
|
||||
import {isSupportedMediaContentType} from '@pkgs/mime_utils/src/ContentTypeUtils';
|
||||
import {seconds} from 'itty-time';
|
||||
import {temporaryFile} from 'tempy';
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {createDownloadsStorageService} from '@app/api/infrastructure/StorageServiceFactory';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('createDownloadsStorageService', () => {
|
||||
it('returns null when no downloads override is configured', () => {
|
||||
expect(Config.s3Downloads.isOverridden).toBe(false);
|
||||
expect(createDownloadsStorageService()).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the downloads provider to the shared provider by default', () => {
|
||||
expect(Config.s3Downloads.settings.endpoint).toBe(Config.s3.endpoint);
|
||||
expect(Config.s3Downloads.settings.region).toBe(Config.s3.region);
|
||||
expect(Config.s3Downloads.settings.accessKeyId).toBe(Config.s3.accessKeyId);
|
||||
});
|
||||
});
|
||||
@@ -25,13 +25,6 @@ export function createStorageService(): IStorageService {
|
||||
return withChangeFeed(new StorageService());
|
||||
}
|
||||
|
||||
export function createDownloadsStorageService(): IStorageService | null {
|
||||
if (!Config.s3Downloads.isOverridden) {
|
||||
return null;
|
||||
}
|
||||
return withChangeFeed(new StorageService(Config.s3Downloads.settings));
|
||||
}
|
||||
|
||||
export async function shutdownStorageChangeFeed(): Promise<void> {
|
||||
const feed = changeFeed;
|
||||
changeFeed = null;
|
||||
|
||||
@@ -77,7 +77,6 @@ import {
|
||||
getContactChangeLogService,
|
||||
getDiscriminatorService,
|
||||
getDonationRepository,
|
||||
getDownloadService,
|
||||
getEmailChangeRepository,
|
||||
getEmailDnsValidationService,
|
||||
getEmailService,
|
||||
@@ -535,10 +534,6 @@ class RequestServices implements RequestScopedServices {
|
||||
return getContactChangeLogService();
|
||||
}
|
||||
|
||||
get downloadService() {
|
||||
return getDownloadService();
|
||||
}
|
||||
|
||||
get emailService() {
|
||||
return getEmailService();
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {createNcmecApiConfig, NcmecReporter} from '@app/api/csam/NcmecReporter';
|
||||
import {NcmecRepository} from '@app/api/csam/NcmecRepository';
|
||||
import {NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
|
||||
import {DonationRepository} from '@app/api/donation/DonationRepository';
|
||||
import {DownloadService} from '@app/api/download/DownloadService';
|
||||
import {createEmailProvider} from '@app/api/email/EmailProviderFactory';
|
||||
import {FavoriteMemeRepository} from '@app/api/favorite_meme/FavoriteMemeRepository';
|
||||
import {GatewayRequestService} from '@app/api/gateway/GatewayRequestService';
|
||||
@@ -46,7 +45,7 @@ import {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
|
||||
import {KVBulkMessageDeletionQueueService} from '@app/api/infrastructure/KVBulkMessageDeletionQueueService';
|
||||
import {NatsUnfurlerService} from '@app/api/infrastructure/NatsUnfurlerService';
|
||||
import {PremiumStateReconciliationQueueService} from '@app/api/infrastructure/PremiumStateReconciliationQueueService';
|
||||
import {createDownloadsStorageService, createStorageService} from '@app/api/infrastructure/StorageServiceFactory';
|
||||
import {createStorageService} from '@app/api/infrastructure/StorageServiceFactory';
|
||||
import {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClient';
|
||||
import {VirusScanService} from '@app/api/infrastructure/VirusScanService';
|
||||
@@ -217,10 +216,6 @@ export const getStorageService: () => IStorageService = (() => {
|
||||
const fallback = singleton(() => createStorageService());
|
||||
return () => _injectedStorageService ?? fallback();
|
||||
})();
|
||||
const getDownloadsStorageService: () => IStorageService = (() => {
|
||||
const override = singleton(() => createDownloadsStorageService());
|
||||
return () => override() ?? getStorageService();
|
||||
})();
|
||||
export const getErrorI18nService = singleton(() => new ErrorI18nService());
|
||||
let limitConfigServiceInstance: LimitConfigService | null = null;
|
||||
export const getLimitConfigService = singleton(
|
||||
@@ -304,7 +299,6 @@ export function getKVAccountDeletionQueue(): KVAccountDeletionQueueService {
|
||||
return accountDeletionQueue;
|
||||
}
|
||||
|
||||
export const getDownloadService = singleton(() => new DownloadService(getDownloadsStorageService()));
|
||||
export const getThemeService = singleton(() => new ThemeService(getStorageService()));
|
||||
const getNcmecReporter = singleton(() => new NcmecReporter({config: createNcmecApiConfig(), fetch}));
|
||||
const getNcmecRepository = singleton(() => new NcmecRepository());
|
||||
|
||||
@@ -28,7 +28,6 @@ const REQUEST_SERVICE_VARIABLES: ReadonlyArray<keyof HonoEnv['Variables']> = [
|
||||
'contactChangeLogService',
|
||||
'desktopHandoffService',
|
||||
'discoveryService',
|
||||
'downloadService',
|
||||
'emailChangeService',
|
||||
'emailService',
|
||||
'embedService',
|
||||
|
||||
@@ -4942,380 +4942,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/dl/desktop/{channel}/{plat}/{arch}/latest": {
|
||||
"get": {
|
||||
"operationId": "get_latest_desktop_version",
|
||||
"summary": "Get latest desktop version",
|
||||
"tags": ["Downloads"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/VersionInfoResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Returns metadata for the latest desktop version including download URLs and SHA-256 checksums for all available formats. Pass ?test=1 to resolve against unreleased test builds.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "channel",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopChannelEnum"},
|
||||
"description": "The release channel"
|
||||
},
|
||||
{
|
||||
"name": "plat",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopPlatformEnum"},
|
||||
"description": "The operating system platform"
|
||||
},
|
||||
{
|
||||
"name": "arch",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopArchEnum"},
|
||||
"description": "The CPU architecture"
|
||||
},
|
||||
{
|
||||
"name": "test",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/dl/desktop/{channel}/{plat}/{arch}/latest/{format}": {
|
||||
"get": {
|
||||
"operationId": "download_latest_desktop_version",
|
||||
"summary": "Download latest desktop version",
|
||||
"tags": ["Downloads"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"*/*": {"schema": {"$ref": "#/components/schemas/DownloadFileResponse"}}}
|
||||
},
|
||||
"206": {
|
||||
"description": "Success",
|
||||
"content": {"*/*": {"schema": {"$ref": "#/components/schemas/DownloadFileResponse"}}}
|
||||
},
|
||||
"302": {"description": "Success"},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Streams the latest available desktop application version for the specified platform and architecture. Pass ?test=1 to download an unreleased test build.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "channel",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopChannelEnum"},
|
||||
"description": "The release channel"
|
||||
},
|
||||
{
|
||||
"name": "plat",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopPlatformEnum"},
|
||||
"description": "The operating system platform"
|
||||
},
|
||||
{
|
||||
"name": "arch",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopArchEnum"},
|
||||
"description": "The CPU architecture"
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopFormatEnum"},
|
||||
"description": "The package format"
|
||||
},
|
||||
{
|
||||
"name": "test",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/dl/desktop/{channel}/{plat}/{arch}/versions": {
|
||||
"get": {
|
||||
"operationId": "list_desktop_versions",
|
||||
"summary": "List desktop versions",
|
||||
"tags": ["Downloads"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/DesktopVersionsResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Lists available desktop versions with pagination for the specified platform and architecture.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "channel",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopChannelEnum"},
|
||||
"description": "The release channel"
|
||||
},
|
||||
{
|
||||
"name": "plat",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopPlatformEnum"},
|
||||
"description": "The operating system platform"
|
||||
},
|
||||
{
|
||||
"name": "arch",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopArchEnum"},
|
||||
"description": "The CPU architecture"
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 25,
|
||||
"description": "Maximum number of versions to return",
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100
|
||||
},
|
||||
"description": "Maximum number of versions to return"
|
||||
},
|
||||
{
|
||||
"name": "before",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "Return versions before this version",
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"description": "Return versions before this version"
|
||||
},
|
||||
{
|
||||
"name": "after",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "Return versions after this version",
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"description": "Return versions after this version"
|
||||
},
|
||||
{
|
||||
"name": "test",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/dl/desktop/{channel}/{plat}/{arch}/{version}/{format}": {
|
||||
"get": {
|
||||
"operationId": "download_desktop_version",
|
||||
"summary": "Download desktop version",
|
||||
"tags": ["Downloads"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"*/*": {"schema": {"$ref": "#/components/schemas/DownloadFileResponse"}}}
|
||||
},
|
||||
"206": {
|
||||
"description": "Success",
|
||||
"content": {"*/*": {"schema": {"$ref": "#/components/schemas/DownloadFileResponse"}}}
|
||||
},
|
||||
"302": {"description": "Success"},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Streams a specific desktop application version for the given platform and architecture. Pass ?test=1 to download an unreleased test build.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "channel",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopChannelEnum"},
|
||||
"description": "The release channel"
|
||||
},
|
||||
{
|
||||
"name": "plat",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopPlatformEnum"},
|
||||
"description": "The operating system platform"
|
||||
},
|
||||
{
|
||||
"name": "arch",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopArchEnum"},
|
||||
"description": "The CPU architecture"
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"$ref": "#/components/schemas/DesktopFormatEnum"},
|
||||
"description": "The package format"
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$", "description": "Semantic version string"},
|
||||
"description": "Semantic version string"
|
||||
},
|
||||
{
|
||||
"name": "test",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": "When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/donations/checkout": {
|
||||
"post": {
|
||||
"operationId": "create_donation_checkout",
|
||||
@@ -27514,103 +27140,6 @@
|
||||
"required": ["url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DesktopFormatEnum": {
|
||||
"description": "The package format",
|
||||
"x-enumNames": ["Setup", "DMG", "ZIP", "AppImage", "DEB", "RPM", "TAR.GZ", "Portable"],
|
||||
"x-enumDescriptions": [
|
||||
"Windows installer executable",
|
||||
"macOS disk image",
|
||||
"Compressed archive",
|
||||
"Linux portable application",
|
||||
"Debian/Ubuntu package",
|
||||
"Red Hat/Fedora package",
|
||||
"Compressed tarball archive",
|
||||
"Windows portable ZIP archive (no installer, stores data next to the executable)"
|
||||
],
|
||||
"enum": ["setup", "dmg", "zip", "appimage", "deb", "rpm", "tar_gz", "portable"],
|
||||
"type": "string"
|
||||
},
|
||||
"DesktopArchEnum": {
|
||||
"description": "The CPU architecture",
|
||||
"x-enumNames": ["x64", "ARM64"],
|
||||
"x-enumDescriptions": [
|
||||
"64-bit x86 architecture (Intel/AMD)",
|
||||
"64-bit ARM architecture (Apple Silicon, ARM processors)"
|
||||
],
|
||||
"enum": ["x64", "arm64"],
|
||||
"type": "string"
|
||||
},
|
||||
"DesktopPlatformEnum": {
|
||||
"description": "The operating system platform",
|
||||
"x-enumNames": ["Windows", "macOS", "Linux"],
|
||||
"x-enumDescriptions": [
|
||||
"Microsoft Windows operating system",
|
||||
"Apple macOS operating system",
|
||||
"Linux operating system"
|
||||
],
|
||||
"enum": ["win32", "darwin", "linux"],
|
||||
"type": "string"
|
||||
},
|
||||
"DesktopChannelEnum": {
|
||||
"description": "The release channel",
|
||||
"x-enumNames": ["Stable", "Canary"],
|
||||
"x-enumDescriptions": [
|
||||
"The stable release channel for production use",
|
||||
"The canary release channel for early access to new features"
|
||||
],
|
||||
"enum": ["stable", "canary"],
|
||||
"type": "string"
|
||||
},
|
||||
"DownloadFileResponse": {
|
||||
"type": "string",
|
||||
"format": "binary",
|
||||
"contentEncoding": "binary",
|
||||
"description": "The downloadable release file"
|
||||
},
|
||||
"DesktopVersionsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"versions": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/VersionInfoResponse"},
|
||||
"description": "Array of available versions"
|
||||
},
|
||||
"has_more": {"type": "boolean", "description": "Whether more versions are available to fetch"}
|
||||
},
|
||||
"required": ["versions", "has_more"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VersionInfoResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {"type": "string", "description": "Semantic version string (e.g., 1.0.0)"},
|
||||
"pub_date": {"type": "string", "description": "ISO 8601 date when this version was published"},
|
||||
"minimum_system_version": {
|
||||
"description": "Minimum operating system version required by this release, when applicable",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"files": {
|
||||
"type": "object",
|
||||
"propertyNames": {"$ref": "#/components/schemas/DesktopFormatEnum"},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "Download URL for this file"},
|
||||
"sha256": {"description": "SHA-256 hash of the file for verification", "type": ["string", "null"]},
|
||||
"checksum_url": {
|
||||
"description": "Plain text .sha256 checksum file URL for this file",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
},
|
||||
"required": ["url", "sha256", "checksum_url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"description": "Map of package format to download files"
|
||||
}
|
||||
},
|
||||
"required": ["version", "pub_date", "files"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DiscoveryGuildListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -34303,7 +33832,6 @@
|
||||
{"name": "Read States", "description": "Message read state tracking"},
|
||||
{"name": "Saved Media", "description": "User saved media management"},
|
||||
{"name": "Themes", "description": "User interface themes"},
|
||||
{"name": "Downloads", "description": "App downloads"},
|
||||
{"name": "Reports", "description": "Content reporting"},
|
||||
{"name": "Instance", "description": "Instance configuration and info"},
|
||||
{"name": "Billing", "description": "Subscription and payment management via Stripe"},
|
||||
|
||||
@@ -19,7 +19,6 @@ import type {ConnectionRequestService} from '@app/api/connection/ConnectionReque
|
||||
import type {ConnectionService} from '@app/api/connection/ConnectionService';
|
||||
import type {NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
|
||||
import type {DonationService} from '@app/api/donation/DonationService';
|
||||
import type {DownloadService} from '@app/api/download/DownloadService';
|
||||
import type {FavoriteMemeRequestService} from '@app/api/favorite_meme/FavoriteMemeRequestService';
|
||||
import type {FavoriteMemeService} from '@app/api/favorite_meme/FavoriteMemeService';
|
||||
import type {GatewayRequestService} from '@app/api/gateway/GatewayRequestService';
|
||||
@@ -128,7 +127,6 @@ export interface HonoEnv {
|
||||
connectionRequestService: ConnectionRequestService;
|
||||
blueskyOAuthService: IBlueskyOAuthService;
|
||||
donationService: DonationService;
|
||||
downloadService: DownloadService;
|
||||
streamPreviewService: StreamPreviewService;
|
||||
streamService: StreamService;
|
||||
emailService: IEmailService;
|
||||
|
||||
Reference in new issue
Block a user