feat(api): redirect desktop downloads to pkgs (#2853)

This commit is contained in:
Hampus
2026-09-20 01:20:30 +02:00
committed by GitHub
parent 487febac8e
commit e2abfd476a
47 changed files with 679 additions and 3940 deletions
-1
View File
@@ -52,7 +52,6 @@ FLUXER_S3_SECRET_ACCESS_KEY=fluxer-secret
FLUXER_S3_FORCE_PATH_STYLE=true
FLUXER_S3_BUCKET_CDN=fluxer
FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
FLUXER_S3_BUCKET_REPORTS=fluxer-reports
FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
FLUXER_S3_BUCKET_STATIC=fluxer-static
-1
View File
@@ -82,7 +82,6 @@ MEILI_MASTER_KEY=CHANGE_ME
# exist already.
#FLUXER_S3_BUCKET_CDN=fluxer
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
#FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
+1 -3
View File
@@ -46,7 +46,6 @@ x-fluxer-env: &fluxer-env
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
@@ -295,14 +294,13 @@ services:
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
entrypoint:
- /bin/sh
- -c
- >
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
missing="$$buckets";
for attempt in $$(seq 1 60); do
if ! nc -z seaweedfs 9333 2>/dev/null; then
-20
View File
@@ -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,
-5
View File
@@ -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,
};
}
+27 -322
View File
@@ -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',
-472
View File
@@ -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"},
-2
View File
@@ -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;
@@ -414,7 +414,6 @@ class Updater {
channel: this.channel ?? Config.PUBLIC_RELEASE_CHANNEL,
arch: this.desktopArch,
version: event.version ?? null,
apiEndpoint: Config.PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT,
knownOptions: options,
});
}
@@ -30,13 +30,10 @@ const LINUX_MANUAL_ARCH_TOKENS: Record<LinuxManualDownloadFormat, Record<LinuxDo
tar_gz: {x64: 'x64', arm64: 'arm64'},
};
const DEFAULT_API_ENDPOINTS: Record<DesktopDownloadChannel, string> = {
stable: 'https://api.fluxer.app',
canary: 'https://api.canary.fluxer.app',
};
const PACKAGE_ORIGIN_BASE = 'https://pkgs.fluxer.com';
interface ParsedLinuxDownloadUrl {
apiEndpoint: string;
downloadBase: string;
channel: DesktopDownloadChannel;
arch: LinuxDownloadArch;
search: string;
@@ -47,7 +44,6 @@ export interface LinuxManualUpdateOptionsInput {
channel?: string | null;
arch?: string | null;
version?: string | null;
apiEndpoint?: string | null;
knownOptions?: ReadonlyArray<UpdaterDownloadOption>;
}
@@ -71,14 +67,6 @@ function normalizeDesktopDownloadChannel(value: string | null | undefined): Desk
return value?.trim().toLowerCase() === 'canary' ? 'canary' : 'stable';
}
function normalizeApiEndpoint(value: string | null | undefined, channel: DesktopDownloadChannel): string {
const trimmed = value?.trim();
if (trimmed) {
return trimmed.replace(/\/+$/u, '');
}
return DEFAULT_API_ENDPOINTS[channel];
}
function parseLinuxDownloadUrl(value: string | null | undefined): ParsedLinuxDownloadUrl | null {
if (!value) {
return null;
@@ -86,22 +74,21 @@ function parseLinuxDownloadUrl(value: string | null | undefined): ParsedLinuxDow
try {
const parsed = new URL(value);
const segments = parsed.pathname.split('/').filter(Boolean);
const dlIndex = segments.findIndex((segment, index) => segment === 'dl' && segments[index + 1] === 'desktop');
if (dlIndex < 0) {
const start = segments.findIndex((segment, index) => segment === 'desktop' && segments[index + 2] === 'linux');
if (start < 0) {
return null;
}
const channel = normalizeDesktopDownloadChannel(segments[dlIndex + 2]);
const platform = segments[dlIndex + 3];
const arch = normalizeLinuxDownloadArch(segments[dlIndex + 4]);
const version = segments[dlIndex + 5];
const format = segments[dlIndex + 6];
if (platform !== 'linux' || !version || !format) {
const channel = normalizeDesktopDownloadChannel(segments[start + 1]);
const arch = normalizeLinuxDownloadArch(segments[start + 3]);
const version = segments[start + 4];
const format = segments[start + 5];
if (!version || !format) {
return null;
}
const endpointSegments = segments.slice(0, dlIndex);
const endpointPath = endpointSegments.length > 0 ? `/${endpointSegments.join('/')}` : '';
const prefix = segments.slice(0, start);
const prefixPath = prefix.length > 0 ? `/${prefix.join('/')}` : '';
return {
apiEndpoint: `${parsed.origin}${endpointPath}`,
downloadBase: `${parsed.origin}${prefixPath}`,
channel,
arch,
search: parsed.search,
@@ -146,14 +133,14 @@ function getKnownLinuxManualOption(
}
function buildLinuxDownloadUrl(params: {
apiEndpoint: string;
downloadBase: string;
channel: DesktopDownloadChannel;
arch: LinuxDownloadArch;
versionToken: string;
format: LinuxManualDownloadFormat;
search: string;
}): string {
return `${params.apiEndpoint}/dl/desktop/${params.channel}/linux/${params.arch}/${params.versionToken}/${params.format}${params.search}`;
return `${params.downloadBase}/desktop/${params.channel}/linux/${params.arch}/${params.versionToken}/${params.format}${params.search}`;
}
function getModernProductName(channel: DesktopDownloadChannel): string {
@@ -175,7 +162,7 @@ export function buildLinuxManualUpdateOptions(input: LinuxManualUpdateOptionsInp
const parsedUrl = parseLinuxDownloadUrl(input.downloadUrl);
const channel = parsedUrl?.channel ?? normalizeDesktopDownloadChannel(input.channel);
const arch = normalizeLinuxDownloadArchOrNull(input.arch) ?? parsedUrl?.arch ?? 'x64';
const apiEndpoint = parsedUrl?.apiEndpoint ?? normalizeApiEndpoint(input.apiEndpoint, channel);
const downloadBase = parsedUrl?.downloadBase ?? PACKAGE_ORIGIN_BASE;
const search = parsedUrl?.search ?? '';
const version = input.version?.trim() ?? '';
const hasVersion = /^\d+\.\d+\.\d+$/u.test(version);
@@ -186,7 +173,7 @@ export function buildLinuxManualUpdateOptions(input: LinuxManualUpdateOptionsInp
return {
format,
label: LINUX_MANUAL_FORMAT_LABELS[format],
url: buildLinuxDownloadUrl({apiEndpoint, channel, arch, versionToken, format, search}),
url: buildLinuxDownloadUrl({downloadBase, channel, arch, versionToken, format, search}),
suggestedName: knownOption?.suggestedName ?? getSuggestedName(format, channel, arch, versionToken),
sha256: knownOption?.sha256 ?? null,
};
+5 -1
View File
@@ -240,7 +240,7 @@ export default defineConfig({
},
{
label: 'Client surfaces',
items: ['http-api/experiments', 'http-api/themes', 'http-api/downloads'],
items: ['http-api/experiments', 'http-api/themes'],
},
{
label: 'Safety',
@@ -257,6 +257,10 @@ export default defineConfig({
'gateway/opcodes-and-close-codes',
],
},
{
label: 'Downloads',
items: ['downloads/overview', 'downloads/desktop', 'downloads/linux-repositories'],
},
{
label: 'Media proxy',
items: [
+32 -16
View File
@@ -74,6 +74,22 @@ const MAIN_SPEC_EXEMPT = new Map<string, {file: string; anchor: string; reason:
reason: 'the path constrains :format by regex and has no OpenAPI path template',
},
],
[
'GET /dl/desktop/{}/{}/{}/latest/{}.zsync',
{
file: DOWNLOAD_CONTROLLER,
anchor: '`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest/:format{[a-z_]+\\\\.zsync}`,',
reason: 'the path constrains :format by regex and has no OpenAPI path template',
},
],
[
'GET /dl/desktop/{}/{}/{}/{}/{}.zsync',
{
file: DOWNLOAD_CONTROLLER,
anchor: '`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/:version/:format{[a-z_]+\\\\.zsync}`,',
reason: 'the path constrains :format by regex and has no OpenAPI path template',
},
],
[
'GET /dl/{}',
{
@@ -164,6 +180,18 @@ const EXEMPTION_RULES: ReadonlyArray<ExemptionRule> = [
],
covers: (_shape, routePath) => routePath.startsWith('/test/'),
},
{
name: 'deprecated desktop download redirect',
justification:
'every /dl route is an undocumented deprecated redirect onto pkgs.fluxer.com, kept only for desktop clients already in the field. Nothing current calls one, so documenting them would advertise a path new callers must not use',
anchors: [
{
file: 'fluxer_api/src/api/download/DownloadController.ts',
anchor: 'function redirectToPackageOrigin',
},
],
covers: (_shape, routePath) => routePath === '/dl' || routePath.startsWith('/dl/'),
},
{
name: 'backported separately',
justification:
@@ -1637,27 +1665,15 @@ console.log('unthrottled routes and global bucket claims');
const problems: Array<string> = [];
const uniquePublic = [...new Set(publicUnthrottled)].sort();
const unthrottledByDesign = new Set([
'GET /dl/desktop/{}/{}/{}/latest',
'GET /dl/desktop/{}/{}/{}/latest/{}',
'GET /dl/desktop/{}/{}/{}/versions',
'GET /dl/desktop/{}/{}/{}/{}/{}',
]);
const unexpected = uniquePublic.filter((shape) => !unthrottledByDesign.has(shape));
const nowThrottled = [...unthrottledByDesign].filter((shape) => !uniquePublic.includes(shape)).sort();
const unexpected = uniquePublic;
if (unexpected.length > 0) {
problems.push(
`rate-limits.md says every HTTP API operation outside the desktop downloads declares a bucket, but ${unexpected.length.toString()} more declare none: ${unexpected.join(', ')}`,
`rate-limits.md says every HTTP API operation declares a bucket, but ${unexpected.length.toString()} declare none: ${unexpected.join(', ')}`,
);
}
if (nowThrottled.length > 0) {
problems.push(
`rate-limits.md names the desktop downloads as the only operations with no bucket, but ${nowThrottled.length.toString()} now declare one: ${nowThrottled.join(', ')}`,
);
}
if (!page.includes('[desktop download](/http-api/downloads/)')) {
problems.push('rate-limits.md no longer names the desktop downloads as the operations with no bucket');
if (!page.includes('Every HTTP API and Admin API operation declares a bucket')) {
problems.push('rate-limits.md no longer states that every operation declares a bucket');
}
if (adminUnthrottled.length > 0) {
const named = adminUnthrottled.map((entry) => `${entry.method} ${entry.route}`).sort();
@@ -0,0 +1,49 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Desktop builds
description: Paths for desktop installers, checksums and the update feeds each platform reads.
---
Every desktop path below is served from `https://pkgs.fluxer.com` and is built from four coordinates.
| Coordinate | Values |
| --- | --- |
| `channel` | `stable`, `canary` |
| `platform` | `darwin`, `linux`, `win32` |
| `arch` | `x64`, `arm64` |
| `format` | `dmg`, `zip`, `setup`, `portable`, `appimage`, `deb`, `rpm`, `tar_gz` |
A format is only published for the platform it belongs to. `dmg` and `zip` are macOS, `setup` and `portable` are Windows, and the other four are Linux.
## Release metadata
```
GET /desktop/{channel}/{platform}/{arch}/latest
```
`latest` returns a JSON document naming the current version, its publication date, and a per-format URL and SHA256.
## Artifacts
```
GET /desktop/{channel}/{platform}/{arch}/{version}/{format}
GET /desktop/{channel}/{platform}/{arch}/{version}/{format}.sha256
```
`version` is either a published version or the literal `latest`, which resolves to the current one. The `.sha256` sibling holds the checksum for the artifact beside it.
Linux AppImage builds also publish a zsync control file, so `AppImageUpdate`, `AppImageLauncher` and Gear Lever can fetch only the blocks that changed:
```
GET /desktop/{channel}/linux/{arch}/{version}/appimage.zsync
```
## Update feeds
Each platform's updater reads the feed its own framework expects, beside the artifacts:
| Platform | Feed |
| --- | --- |
| macOS | `RELEASES.json`, `releases.json` |
| Windows | `RELEASES`, `releases.win.json`, `assets.win.json` |
| All | `manifest.json`, `version.json`, `latest.json` |
@@ -0,0 +1,72 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Linux repositories
description: The apt, dnf, pacman and Flatpak repositories Fluxer publishes, and how a client adds them.
---
Fluxer publishes four Linux repositories. The apt, dnf and pacman entrypoints each subscribe to one channel, so the file you install decides whether you track stable or canary. The package name follows from that, `fluxer` for stable and `fluxer-canary` for canary. Flatpak is the exception: one remote serves both, and the application id selects the channel.
## apt
One repository serves Debian and Ubuntu. It uses the standard `dists` and `pool` layout, publishes both SHA256 and SHA512 by-hash indexes, and is signed.
```
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL -o /etc/apt/keyrings/fluxer-archive-keyring.gpg \
https://pkgs.fluxer.com/keys/fluxer-archive-keyring.gpg
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources \
https://pkgs.fluxer.com/deb/fluxer.sources
sudo apt update && sudo apt install fluxer
```
The `.sources` entry uses `Signed-By` rather than `Trusted: yes`, so `apt update` verifies the repository and prints nothing.
## dnf
One repository serves Fedora and the RHEL family, split by channel and architecture. It is signed, with both `gpgcheck` and `repo_gpgcheck` enabled.
```
sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo \
https://pkgs.fluxer.com/rpm/fluxer.repo
sudo dnf install fluxer
```
Metadata expires after six hours, so a freshly published build becomes visible within that window, or immediately with `dnf --refresh upgrade`.
:::caution[RHEL, Rocky, Alma and CentOS Stream need EPEL]
The package depends on `libXScrnSaver`, which the EL base repositories do not ship. Run `sudo dnf install epel-release` first. Fedora does not need this.
:::
## pacman
```
sudo tee -a /etc/pacman.conf >/dev/null <<'REPO'
[fluxer]
SigLevel = Never
Server = https://pkgs.fluxer.com/arch/$repo/os/$arch
REPO
sudo pacman -Syu --noconfirm fluxer
```
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, which is why the heredoc above is quoted. `$repo` expands to the section name, so the same line works for `fluxer` and `fluxer-canary`.
A pacman sync database records one version per package name, so only the current release is installable by name. An older build is still served, and `curl` followed by `pacman -U ./<file>` installs it.
## Flatpak
One remote named `fluxer` serves both application ids, `app.fluxer.Fluxer` and `app.fluxer.FluxerCanary`.
```
flatpak install https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
```
Use `fluxer-canary.flatpakref` for the canary channel. The reference file names the remote and resolves the runtime the application builds against, so this works on a machine with no remotes configured.
Once the remote exists, either application installs by id:
```
flatpak install fluxer app.fluxer.FluxerCanary
```
No `--no-gpg-verify` flag is required. The repository is unsigned, and flatpak reads that from the repository metadata itself.
@@ -0,0 +1,34 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Package origin overview
description: The public origin that serves Fluxer desktop builds and Linux package repositories.
---
Fluxer publishes every desktop build and every Linux package repository to one public origin, `https://pkgs.fluxer.com`. It is a static file origin. It serves bytes and nothing else, it needs no credential, and it declares no rate limit bucket.
## Channels
Every path names a channel, either `stable` or `canary`. The two are published independently and never share a file. The package name follows the channel: `fluxer` on stable, `fluxer-canary` on canary. A machine may install both at once.
## What the origin serves
| Prefix | Contents |
| --- | --- |
| `/desktop/` | Desktop installers, checksums and update feeds |
| `/flatpak/` | An ostree repository holding both application ids |
| `/arch/` | A pacman repository, one directory per architecture |
| `/deb/` | An apt repository in the `dists` and `pool` layout |
| `/rpm/` | A dnf repository, one directory per channel and architecture |
| `/keys/` | The public signing key for the apt and rpm repositories |
## What it guarantees
The origin answers `Range`, `If-Range` and `If-None-Match` on every artifact, so a client may resume an interrupted download and revalidate a cached one. It never lists a directory. A request for a path that does not exist returns 404 with no body.
Artifacts under a version directory never change once published, so they are cached for a year. The files that move when a release ships, the `latest` documents and every repository index, are cached for minutes.
## Signing
The apt and rpm repositories are signed. The public key lives at `/keys/fluxer-archive-keyring.asc`, and the entrypoint files reference it, so a client verifies every package it installs.
The flatpak and pacman repositories are unsigned and rely on HTTPS for transport integrity. pacman still verifies each package against the SHA256 its index records, and flatpak verifies each object against its content address.
@@ -1,504 +0,0 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Desktop downloads
description: Desktop release metadata, artifact and checksum downloads, test builds, and release feeds.
---
import RouteHeader from '@/components/RouteHeader.astro';
The download resource serves the desktop application builds a deployment has stored. It also serves a SHA-256 checksum for each build and the release feed files beside them. Every route here is unauthenticated. A request that sends a credential anyway receives the same status and body. No route here declares a rate limit bucket, and none draws on the global allowance.
## Prefix and mounting
Fluxer registers the routes under `/dl`, and the desktop routes under `/dl/desktop`. A client builds the URL against `api_client` from the [instance endpoints object](/http-api/instance/#instance-endpoints-object).
Fluxer mounts the prefix at the root and at `/v1`. Every desktop route below also answers under `/v1/dl/desktop/...`.
:::caution[The catch-all has no `/v1` form]
[Download stored object](#download-stored-object) requires the `/dl` prefix. Its `/v1/dl/...` equivalent returns 404.
:::
Every other route on this reference takes the `/v1` form, and [HTTP API](/http-api/) states that a client MUST use it.
## Methods
Every route answers `GET`. The checksum routes, the artifact routes, and the catch-all also bind `HEAD`. [Get latest desktop version](#get-latest-desktop-version) and [List desktop versions](#list-desktop-versions) bind `GET` alone, and a `HEAD` still reaches them under the [shared rule for HEAD](/http-api/#request-format).
A `HEAD` on either JSON route runs the same resolution as the `GET` and returns its status and headers with no body. It can therefore answer 404.
A checksum route answers a `HEAD` with 200 and no body. The headers are `Content-Type`, `Content-Disposition`, `Cache-Control`, and the `Content-Length` of the checksum line.
Artifact and catch-all `HEAD` requests return 200 with `Content-Type`, `Content-Disposition`, `Accept-Ranges`, `Cache-Control` and `Content-Length`, plus `ETag` and `Last-Modified` when available. They ignore `Range` and never return 206, 416 or a [presigned URL redirect](#redirects). [Country redirects](#redirects) still return 302 for `HEAD`, as for `GET`.
## Release channels
| Value | Name | Description |
| --- | --- | --- |
| stable | Stable | The channel a deployment publishes for general use |
| canary | Canary | The channel that receives a build ahead of `stable` |
The channel selects the release series and product name. A `canary` file is named `Fluxer-Canary` or `Fluxer Canary`, and a `stable` file is named `Fluxer`.
## Platforms and architectures
| Value | Name | Description |
| --- | --- | --- |
| win32 | Windows | The Windows build target |
| darwin | macOS | The macOS build target |
| linux | Linux | The Linux build target |
The path segment with one of those values is named `plat`.
| Value | Name | Description |
| --- | --- | --- |
| x64 | x64 | The 64-bit x86 architecture |
| arm64 | ARM64 | The 64-bit ARM architecture |
A channel, a platform, and an architecture together name one coordinate.
## Package formats
| Value | Name | Description |
| --- | --- | --- |
| setup | Setup | The Windows installer executable |
| portable | Portable | The Windows archive that stores its data beside the executable |
| dmg | DMG | The macOS disk image |
| zip | ZIP | The macOS application archive |
| appimage | AppImage | The Linux portable application image |
| deb | DEB | The Debian package |
| rpm | RPM | The RPM package |
| tar_gz | TAR.GZ | The Linux compressed tarball |
The registry is closed, and a `format` outside it returns 400 `INVALID_FORM_BODY`.
Each format resolves on one platform. `setup` and `portable` resolve on `win32`, `dmg` and `zip` on `darwin`, and `appimage`, `deb`, `rpm`, and `tar_gz` on `linux`. A format paired with any other platform returns 404.
On `darwin` a `dmg` or `zip` request tries the `universal` file before the architecture-specific one, so `x64` and `arm64` can resolve to the same file.
## Checksum requests
A client appends `.sha256` to the format segment to read the checksum, and sends the bare format to read the file. `tar_gz.sha256` is the checksum of `tar_gz`.
Use the lowercase `.sha256` suffix with a valid [package format](#package-formats). An invalid format or suffix returns 400 `INVALID_FORM_BODY`.
## Version info object
A version info object describes one release at a coordinate and the file it has for each format.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| version | string | The release version in `MAJOR.MINOR.PATCH` form |
| pub_date<sup>1</sup> | ISO8601 timestamp | The moment the release was published |
| minimum_system_version?<sup>2</sup> | ?string | The lowest operating system version the release supports |
| files<sup>3</sup> | map[string, [version file](#version-file-object) object] | The download entry for each [package format](#package-formats) the release has |
<sup>1</sup> Publication time reported for the release
<sup>2</sup> Present when the release specifies a minimum system version
<sup>3</sup> A format that resolves no file is absent from the map
### Example
```json
{
"version": "1.4.2",
"pub_date": "2026-08-19T11:04:00.000Z",
"minimum_system_version": "10.15.0",
"files": {
"dmg": {
"url": "https://api.example.com/dl/desktop/stable/darwin/arm64/1.4.2/dmg",
"sha256": "3b1f5c0d9e7a24486cf0b1d3a5e87209cc4d61fba0937e5528d1c4a67b0e93f2",
"checksum_url": "https://api.example.com/dl/desktop/stable/darwin/arm64/1.4.2/dmg.sha256"
}
}
}
```
## Version file object
A version file object is the download entry for one format of one release.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| url<sup>1</sup> | string | The absolute URL of the versioned download for this format |
| sha256<sup>2</sup> | ?string | The hash of the file as 64 lowercase hexadecimal characters, or null where no checksum was found |
| checksum_url<sup>1</sup> | ?string | The absolute URL of the checksum file, or null where `sha256` is null |
<sup>1</sup> Built from `api_client` in the [instance endpoints object](/http-api/instance/#instance-endpoints-object), with no `/v1` segment
<sup>2</sup> A 64-character lowercase hexadecimal hash, or null when no valid checksum is available
## Checksum files
A checksum response is one `sha256sum` line: the hash, two spaces, the resolved filename, and a trailing newline.
```text
2f6d1a4b8c3e07f95ab61d4c2e8035971fd0ba46c7e213985cd0f74a6b1e82c3 Fluxer-1.4.2-linux-x86_64.AppImage
```
The response has `Content-Type: text/plain; charset=utf-8`, a `Content-Disposition` of `attachment` naming the resolved filename with `.sha256` appended, and a `Content-Length` counting the encoded line. It sets no `Accept-Ranges`, `ETag`, or `Last-Modified`, and it ignores a `Range` header.
A missing or invalid checksum returns 404.
## Artifact resolution
Use `latest` for the release currently offered at a channel, platform and architecture, or a versioned URL to request a specific release. A missing file returns 404 with the plain text body `Not Found`.
## Response headers and caching
Every artifact response has `Accept-Ranges: bytes` and a `Content-Disposition` of `attachment` naming the resolved file, unless the stored object has its own `Content-Disposition`. The filename is percent-encoded inside the header, so a name containing a space is written with `%20`.
| Response | Cache-Control |
| --- | --- |
| Latest version metadata and the version list | `public, max-age=300` |
| Latest artifact and latest checksum | `no-store` |
| Versioned artifact and versioned checksum under `desktop/` | `public, max-age=31536000` |
| A release feed filename, and anything under `desktop-test/` | `public, max-age=300` |
| Any `desktop/` artifact on a deployment that configures country redirects | `private, no-store` |
| A redirect to a presigned storage URL | `no-store` |
A release feed filename is any of these:
- `manifest.json`
- A name ending in `.yml` or `.yaml`
- A name beginning `RELEASES`
- A name beginning `releases` or `assets` and ending in `.json`
## Redirects
The deployment settings below answer a download with 302.
A hosted deployment can list countries whose downloads of a file under `desktop/` Fluxer can redirect to GitHub release assets. Once the list is set, every response for such a file has `Cache-Control: private, no-store`, including a response Fluxer serves from storage.
A deployment that issues presigned download URLs answers a `GET` with 302 to a storage URL valid for 900 seconds. That redirect has `Cache-Control: no-store` and `Accept-Ranges: bytes`. The setting is off by default.
:::caution[A 302 has no bytes and no range]
The client follows `Location` to read the file. Fluxer produces the redirect before it reads any `Range` header, so the server at `Location` answers any `Range` header on a redirected download.
:::
## Test builds
Every route accepts the `test` query parameter. `1` or `true`, matched without regard to case, selects test releases. Any other value is false.
On the desktop routes the flag makes Fluxer resolve every file under `desktop-test/`. On [Download stored object](#download-stored-object) it rewrites a key beginning `desktop/` and leaves any other key unchanged, and a path that already names `desktop-test/` resolves there with no flag at all.
A `url` and a `checksum_url` built for a request that sent the flag repeat `?test=1`, so a client following either one stays on the test prefix.
:::caution[`?test=` fails the schema with 400 `INVALID_FORM_BODY`]
The empty value normalises to null under [input normalisation](/http-api/#input-normalisation). `?limit=`, `?before=`, and `?after=` fail the same way.
:::
## Get latest desktop version
<RouteHeader method="GET" path="/v1/dl/desktop/{channel}/{plat}/{arch}/latest" unauthenticated />
Returns the [version info](#version-info-object) object for the newest release at the coordinate.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| channel | string | The [release channel](#release-channels) to resolve |
| plat | string | The [platform](#platforms-and-architectures) to resolve |
| arch | string | The [architecture](#platforms-and-architectures) to resolve |
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [version info](#version-info-object) object | A release resolved at the coordinate |
| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
| 404 | `Not Found` | No release resolved at the coordinate |
A client reads `files` for the format it wants. A format whose file resolved no checksum reports `sha256` and `checksum_url` as null.
### Response headers
The 200 has `Cache-Control: public, max-age=300`. The 404 has `Content-Type: text/plain`.
## List desktop versions
<RouteHeader method="GET" path="/v1/dl/desktop/{channel}/{plat}/{arch}/versions" unauthenticated />
Returns the releases stored at the coordinate, newest first.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| channel | string | The [release channel](#release-channels) to resolve |
| plat | string | The [platform](#platforms-and-architectures) to resolve |
| arch | string | The [architecture](#platforms-and-architectures) to resolve |
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| limit? | integer | The maximum number of releases to return (1 through 100, default 25) |
| before?<sup>1</sup> | string | The version to page below, exclusive |
| after?<sup>1</sup> | string | The version to page above, exclusive |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
<sup>1</sup> Compared component by component as numbers, and both bounds can be sent together to select a range
### Response body
| Field | Type | Description |
| --- | --- | --- |
| versions | array[[version info](#version-info-object) object] | The releases on this page, ordered newest first |
| has_more | boolean | Whether the filtered set held more releases than `limit` |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | response body | The listing completed, possibly with no release |
| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
This route never answers 404. A coordinate that holds no object returns 200 with an empty array.
Only available files are listed. This response omits `minimum_system_version` and reports `sha256` only when a valid checksum is available.
### Response headers
The 200 has `Cache-Control: public, max-age=300`.
## Download latest desktop artifact
<RouteHeader method="GET" path="/v1/dl/desktop/{channel}/{plat}/{arch}/latest/{format}" unauthenticated />
Streams the newest file at the coordinate for one package format.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| channel | string | The [release channel](#release-channels) to resolve |
| plat | string | The [platform](#platforms-and-architectures) to resolve |
| arch | string | The [architecture](#platforms-and-architectures) to resolve |
| format | string | The [package format](#package-formats) to resolve |
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
### Request headers
| Field | Type | Description |
| --- | --- | --- |
| Range? | string | The standard byte range, answered with 206 and a `Content-Range` header |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | artifact bytes | The complete file was streamed |
| 206 | artifact bytes | The requested byte range was streamed |
| 302 | empty | The deployment [redirects](#redirects) this download |
| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
| 404 | `Not Found` | No file resolved for the format at the coordinate |
| 416 | empty | The requested range is unsatisfiable |
The stored file keeps its own name, so the resolved filename has the release version even though the request named `latest`. A client that follows this route on every check reads a different file once a newer release is stored at the coordinate.
### Response headers
The 200 has `Content-Type`<sup>1</sup>, `Content-Disposition`<sup>2</sup>, `Content-Length`, `Accept-Ranges: bytes`, `Cache-Control: no-store`, `ETag`<sup>3</sup>, and `Last-Modified`<sup>3</sup>. The 206 has the 200 headers plus `Content-Range`. The 302 has `Location`, `Accept-Ranges: bytes`, and `Cache-Control`. The 404 has `Content-Type: text/plain`. The 416 has `Accept-Ranges: bytes`, `Content-Range: bytes */{size}`, and `Cache-Control`.
<sup>1</sup> The stored media type, and `application/octet-stream` where storage reports none
<sup>2</sup> `attachment` naming the resolved filename, percent-encoded
<sup>3</sup> Sent where storage reports the value
## Download latest desktop checksum
<RouteHeader method="GET" path="/v1/dl/desktop/{channel}/{plat}/{arch}/latest/{format}.sha256" unauthenticated />
Returns the [checksum file](#checksum-files) for the newest file at the coordinate.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| channel | string | The [release channel](#release-channels) to resolve |
| plat | string | The [platform](#platforms-and-architectures) to resolve |
| arch | string | The [architecture](#platforms-and-architectures) to resolve |
| format<sup>1</sup> | string | The [package format](#package-formats) to resolve, followed by `.sha256` |
<sup>1</sup> The complete segment matches `[a-z_]+\.sha256` for the route to claim it, and the part before the suffix names a format in the closed registry
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | checksum line | The file and a valid hash both resolved |
| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
| 404 | `Not Found` | No file resolved for the format, or the resolved file has no valid hash |
This hash is the value [Get latest desktop version](#get-latest-desktop-version) reports as `sha256` for the same format. The line names the file this coordinate's `latest` download streams.
### Response headers
The 200 has `Content-Type: text/plain; charset=utf-8`, `Content-Disposition`, `Content-Length`, and `Cache-Control: no-store`. The 404 has `Content-Type: text/plain`.
## Download desktop artifact
<RouteHeader method="GET" path="/v1/dl/desktop/{channel}/{plat}/{arch}/{version}/{format}" unauthenticated />
Streams one released file by version and package format.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| channel | string | The [release channel](#release-channels) to resolve |
| plat | string | The [platform](#platforms-and-architectures) to resolve |
| arch | string | The [architecture](#platforms-and-architectures) to resolve |
| version<sup>1</sup> | string | The release version to resolve |
| format | string | The [package format](#package-formats) to resolve |
<sup>1</sup> Three decimal components matching `^\d+\.\d+\.\d+$`, so a two-component or suffixed version returns 400 `INVALID_FORM_BODY`
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
### Request headers
| Field | Type | Description |
| --- | --- | --- |
| Range? | string | The standard byte range, answered with 206 and a `Content-Range` header |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | artifact bytes | The complete file was streamed |
| 206 | artifact bytes | The requested byte range was streamed |
| 302 | empty | The deployment [redirects](#redirects) this download |
| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
| 404 | `Not Found` | No file resolved for the version and format |
| 416 | empty | The requested range is unsatisfiable |
### Response headers
The 200 has `Content-Type`, `Content-Disposition`, `Content-Length`, `Accept-Ranges: bytes`, `Cache-Control`<sup>1</sup>, `ETag`<sup>2</sup>, and `Last-Modified`<sup>2</sup>. The 206 has the 200 headers plus `Content-Range`. The 302 has `Location`, `Accept-Ranges: bytes`, and `Cache-Control`. The 404 has `Content-Type: text/plain`. The 416 has `Accept-Ranges: bytes`, `Content-Range: bytes */{size}`, and `Cache-Control`.
<sup>1</sup> `public, max-age=31536000` for a released file, and `public, max-age=300` under `desktop-test/` or for a [release feed filename](#response-headers-and-caching)
<sup>2</sup> Sent where storage reports the value
## Download desktop checksum
<RouteHeader method="GET" path="/v1/dl/desktop/{channel}/{plat}/{arch}/{version}/{format}.sha256" unauthenticated />
Returns the [checksum file](#checksum-files) for one released file.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| channel | string | The [release channel](#release-channels) to resolve |
| plat | string | The [platform](#platforms-and-architectures) to resolve |
| arch | string | The [architecture](#platforms-and-architectures) to resolve |
| version | string | The release version to resolve |
| format<sup>1</sup> | string | The [package format](#package-formats) to resolve, followed by `.sha256` |
<sup>1</sup> The complete segment matches `[a-z_]+\.sha256` for the route to claim it, and the part before the suffix names a format in the closed registry
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | checksum line | The file and a valid hash both resolved |
| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
| 404 | `Not Found` | No file resolved for the version and format, or no valid hash was found for it |
Returns 404 when the requested file has no available checksum.
### Response headers
The 200 has `Content-Type: text/plain; charset=utf-8`, `Content-Disposition`, `Content-Length`, and `Cache-Control`<sup>1</sup>. The 404 has `Content-Type: text/plain`.
<sup>1</sup> `public, max-age=31536000` for a released file, and `public, max-age=300` under `desktop-test/`
## Download stored object
<RouteHeader method="GET" path="/dl/{path}" unauthenticated />
Downloads a file by path. Use this route for release feeds, which have no format or version segment.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| path<sup>1</sup> | string | The download path, which can contain `/` separators |
<sup>1</sup> Taken from the request path with the `/dl` prefix removed, then normalised. It begins `desktop/` or `desktop-test/` after normalisation, and every other key returns 404
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| test? | string | The [test build](#test-builds) flag, `1` or `true` to rewrite a `desktop/` key to `desktop-test/` |
### Request headers
| Field | Type | Description |
| --- | --- | --- |
| Range? | string | The standard byte range, answered with 206 and a `Content-Range` header |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | object bytes | The complete object was streamed |
| 206 | object bytes | The requested byte range was streamed |
| 302 | empty | The deployment [redirects](#redirects) this download |
| 400 | [error response](/http-api/#error-response) | The `test` value fails its schema and the request returns `INVALID_FORM_BODY` |
| 404 | `Not Found` | The key is outside the permitted prefixes, the request has the `/v1` prefix, or storage holds no such object |
| 416 | empty | The requested range is unsatisfiable |
:::caution[The catch-all reaches the permitted prefixes only]
A path outside `desktop/` or `desktop-test/` returns 404.
:::
Fluxer also rejects the key when it is empty, when normalisation leaves it beginning `..` or `/`, or when any segment is `.`, `..`, or contains a NUL character. Each of those returns the same 404, so a caller cannot tell a traversal attempt from a missing object.
A request can name `desktop/stable/linux-x64/manifest.json` or `desktop/stable/linux/x64/manifest.json`. For the hyphen form, Fluxer returns that object when storage holds it, and otherwise returns the object at the slash form.
### Response headers
The 200 has `Content-Type`, `Content-Disposition`, `Content-Length`, `Accept-Ranges: bytes`, `Cache-Control`<sup>1</sup>, `ETag`<sup>2</sup>, and `Last-Modified`<sup>2</sup>. The 206 has the 200 headers plus `Content-Range`. The 302 has `Location`, `Accept-Ranges: bytes`, and `Cache-Control`. The 404 has `Content-Type: text/plain`. The 416 has `Accept-Ranges: bytes`, `Content-Range: bytes */{size}`, and `Cache-Control`.
<sup>1</sup> `public, max-age=31536000` for a released artifact, and `public, max-age=300` for a [release feed filename](#response-headers-and-caching) or a key under `desktop-test/`
<sup>2</sup> Sent where storage reports the value
@@ -6,7 +6,7 @@ description: Request format, body representations, shared headers, cross-origin
Use the HTTP API to read and change resources. Discover the base URL through [`/.well-known/fluxer`](/http-api/instance/#get-instance-discovery). Third-party clients use `endpoints.api_public`, and the first-party web application uses `endpoints.api_client`.
Use `/v1`, the only API version. [Download stored object](/http-api/downloads/#download-stored-object) is unversioned and uses its documented root path.
Use `/v1`, the only API version.
Read resource limits from [instance discovery](/http-api/instance/#limit-keys). Attachment counts, expression counts and profile field lengths can differ between deployments.
@@ -497,10 +497,6 @@ Default `fluxer`. Processed assets. `media-proxy` defaults to `cdn`.
Default `fluxer-uploads`. Raw uploads. `media-proxy` defaults to `uploads`, `app-proxy` to `fluxer-uploads`.
#### `FLUXER_S3_BUCKET_DOWNLOADS`
Default `fluxer-downloads`. Desktop build artefacts. Read by `api` and `worker`.
#### `FLUXER_S3_BUCKET_REPORTS`
Default `fluxer-reports`. Abuse report evidence. Read by `api` and `worker`.
@@ -513,8 +509,6 @@ Default `fluxer-harvests`. Data archives. Read by `api` and `worker`.
Default `static`. Static assets. Read by `media-proxy` only in `static` mode, which the stack does not use. `api` and `worker` never read it, and `seaweedfs-init` does not create this bucket.
A separate downloads provider is available. `FLUXER_S3_DOWNLOADS_ENDPOINT`, `FLUXER_S3_DOWNLOADS_PUBLIC_ENDPOINT`, `FLUXER_S3_DOWNLOADS_FORCE_PATH_STYLE`, `FLUXER_S3_DOWNLOADS_REGION`, `FLUXER_S3_DOWNLOADS_ACCESS_KEY_ID`, and `FLUXER_S3_DOWNLOADS_SECRET_ACCESS_KEY` take effect only when `FLUXER_S3_DOWNLOADS_ENDPOINT` is non-empty, and they then replace the primary configuration for the downloads bucket.
The Media Proxy read path has overrides of its own. All are optional.
#### `FLUXER_S3_READ_ENDPOINT`
@@ -1137,10 +1131,6 @@ Default `336`. How long a deleted account is recoverable. Forced to 0.01 hours w
Default `false`. Presigned attachment uploads. Compose sets `true`.
#### `FLUXER_API_PRESIGNED_DOWNLOADS_ENABLED`
Default `false`. Presigned downloads. Applies to the downloads bucket.
#### `FLUXER_API_PRESIGNED_HARVEST_DOWNLOADS_ENABLED`
Default `true`. Presigned harvest downloads. Applies to the harvests bucket.
@@ -1177,10 +1167,6 @@ Default `true`. Whether a remote cache header is honoured. Clamped by `FLUXER_AP
Default empty. Hosts never unfurled. Comma separated, unvalidated.
#### `FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES`
Default empty. Countries redirected to GitHub for desktop downloads. Each entry must be two uppercase letters or the API fails at boot.
#### `FLUXER_TEST_MODE_ENABLED`
Default `false`. Test mode. Collapses the deletion grace period. Reaches production containers if set.
@@ -217,7 +217,7 @@ FLUXER_S3_REGION=eu-central-1
FLUXER_S3_FORCE_PATH_STYLE=false
```
`FLUXER_S3_PUBLIC_ENDPOINT` follows `FLUXER_S3_ENDPOINT` when it is not set on its own, and the credentials stay `FLUXER_S3_ACCESS_KEY` and `FLUXER_S3_SECRET_KEY`. `FLUXER_S3_BUCKET_CDN`, `FLUXER_S3_BUCKET_UPLOADS`, `FLUXER_S3_BUCKET_DOWNLOADS`, `FLUXER_S3_BUCKET_REPORTS` and `FLUXER_S3_BUCKET_HARVESTS` name the buckets. The bundled object store creates whichever names they hold, and a store outside the stack needs those buckets to exist already.
`FLUXER_S3_PUBLIC_ENDPOINT` follows `FLUXER_S3_ENDPOINT` when it is not set on its own, and the credentials stay `FLUXER_S3_ACCESS_KEY` and `FLUXER_S3_SECRET_KEY`. `FLUXER_S3_BUCKET_CDN`, `FLUXER_S3_BUCKET_UPLOADS`, `FLUXER_S3_BUCKET_REPORTS` and `FLUXER_S3_BUCKET_HARVESTS` name the buckets. The bundled object store creates whichever names they hold, and a store outside the stack needs those buckets to exist already.
`FLUXER_KV_URL`, `FLUXER_NATS_URL`, `FLUXER_SEARCH_URL` and `FLUXER_LIVEKIT_INTERNAL_URL` move the other bundled services the same way, and `FLUXER_NATS_JETSTREAM_URL` and `FLUXER_SVC_NATS_URL` follow `FLUXER_NATS_URL` when they are not set on their own.
@@ -18,7 +18,7 @@ A request also counts against the global bucket, unless its route bucket is decl
These buckets are exempt, and each is the only bucket its route declares: `webhook:execute::webhook_id`, `webhook:message_get::webhook_id`, `webhook:message_edit::webhook_id`, `webhook:message_delete::webhook_id`, `webhook:github::webhook_id`, `webhook:instatus::webhook_id`, and `stripe:webhook`. Those routes draw on no global allowance. The `user:group_dm:create` and `user:group_dm:recipient:add` buckets are exempt as well. Each is a second bucket on a route whose first bucket is not exempt, so both routes still draw on the global allowance.
Every HTTP API and Admin API operation declares a bucket, apart from the [desktop download](/http-api/downloads/) routes, which declare none. A caller that sends no credential on a [Bluesky client document](/http-api/connections/#get-bluesky-client-metadata) is keyed by the client IP address.
Every HTTP API and Admin API operation declares a bucket. A caller that sends no credential on a [Bluesky client document](/http-api/connections/#get-bluesky-client-metadata) is keyed by the client IP address.
The global window is one second. The default allowance is 50 requests per second, and an account holding the [`HIGH_GLOBAL_RATE_LIMIT`](/admin-api/users/#account-flags) flag receives 1,200 requests per second instead. The [`RATE_LIMIT_BYPASS`](/admin-api/users/#account-flags) flag exempts an account from the global bucket and from every route bucket. A successful response to that account has no rate limit header.
+1
View File
@@ -31,6 +31,7 @@
"fluxer_desktop/src/main/NotificationState.ts": ["exports", "types"],
"packages/schema/src/domains/admin/AdminUserSchemas.ts": ["exports"],
"packages/schema/src/domains/admin/VoiceNoiseSuppressionSchemas.ts": ["exports"],
"packages/schema/src/domains/download/DownloadSchemas.ts": ["exports"],
"packages/schema/src/domains/geolocation/GeolocationSchemas.ts": ["exports"],
"packages/schema/src/domains/guild/GuildAuditLogSchemas.ts": ["exports"],
"pnpm-workspace.yaml": ["catalog"]
-7
View File
@@ -92,7 +92,6 @@ function defaultConfig(): MasterConfig {
buckets: {
cdn: 'fluxer',
uploads: 'fluxer-uploads',
downloads: 'fluxer-downloads',
reports: 'fluxer-reports',
harvests: 'fluxer-harvests',
},
@@ -105,9 +104,7 @@ function defaultConfig(): MasterConfig {
max_inflight_requests: 512,
ip_ban_exempt_ips: [],
donation_proxy_key: '',
desktop_github_redirect_countries: [],
presigned_attachment_uploads_enabled: false,
presigned_downloads_enabled: false,
presigned_harvest_downloads_enabled: true,
unfurl_ignored_hosts: [],
embeds: {
@@ -646,10 +643,6 @@ function applyPublicPort(config: MasterConfig, endpoints: DerivedEndpoints): Mas
},
endpoints: normalizedEndpoints,
s3: config.s3 && {...config.s3, presigned_url_base: normalizeOptional(config.s3.presigned_url_base)},
s3_downloads: config.s3_downloads && {
...config.s3_downloads,
presigned_url_base: normalizeOptional(config.s3_downloads.presigned_url_base),
},
services: {
...config.services,
media_proxy: {
-11
View File
@@ -79,19 +79,10 @@ export interface MasterConfig {
buckets: {
cdn: string;
uploads: string;
downloads: string;
reports: string;
harvests: string;
};
};
s3_downloads?: {
endpoint: string;
presigned_url_base?: string;
force_path_style?: boolean;
region?: string;
access_key_id?: string;
secret_access_key?: string;
};
services: {
api: {
port: number;
@@ -100,9 +91,7 @@ export interface MasterConfig {
max_inflight_requests: number;
ip_ban_exempt_ips: Array<string>;
donation_proxy_key: string;
desktop_github_redirect_countries: Array<string>;
presigned_attachment_uploads_enabled: boolean;
presigned_downloads_enabled: boolean;
presigned_harvest_downloads_enabled: boolean;
unfurl_ignored_hosts: Array<string>;
embeds: {
@@ -1,47 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
export interface S3ProviderSettings {
endpoint: string;
presignedUrlBase?: string;
forcePathStyle: boolean;
region: string;
accessKeyId: string;
secretAccessKey: string;
}
export interface ResolvedDownloadsProvider {
settings: S3ProviderSettings;
isOverridden: boolean;
}
export function resolveDownloadsProvider(master: Pick<MasterConfig, 's3' | 's3_downloads'>): ResolvedDownloadsProvider {
const base = master.s3;
if (!base) {
throw new Error('S3 configuration is required to resolve the downloads provider');
}
const baseSettings: S3ProviderSettings = {
endpoint: base.endpoint,
presignedUrlBase: base.presigned_url_base,
forcePathStyle: base.force_path_style,
region: base.region,
accessKeyId: base.access_key_id,
secretAccessKey: base.secret_access_key,
};
const override = master.s3_downloads;
if (!override?.endpoint) {
return {settings: baseSettings, isOverridden: false};
}
return {
settings: {
endpoint: override.endpoint,
presignedUrlBase: override.presigned_url_base ?? undefined,
forcePathStyle: override.force_path_style ?? baseSettings.forcePathStyle,
region: override.region ?? baseSettings.region,
accessKeyId: override.access_key_id ?? baseSettings.accessKeyId,
secretAccessKey: override.secret_access_key ?? baseSettings.secretAccessKey,
},
isOverridden: true,
};
}
+10
View File
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface S3ProviderSettings {
endpoint: string;
presignedUrlBase?: string;
forcePathStyle: boolean;
region: string;
accessKeyId: string;
secretAccessKey: string;
}
@@ -1,63 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import {describe, expect, test} from 'vitest';
const base: Pick<MasterConfig, 's3' | 's3_downloads'>['s3'] = {
endpoint: 'https://main.example.com',
presigned_url_base: 'https://public.example.com',
force_path_style: true,
region: 'us-east-1',
access_key_id: 'MAIN_KEY',
secret_access_key: 'MAIN_SECRET',
buckets: {cdn: 'cdn', uploads: 'uploads', downloads: 'downloads', reports: 'r', harvests: 'h'},
};
describe('resolveDownloadsProvider', () => {
test('falls back to the main provider when no override is set', () => {
const resolved = resolveDownloadsProvider({s3: base});
expect(resolved.isOverridden).toBe(false);
expect(resolved.settings.endpoint).toBe('https://main.example.com');
expect(resolved.settings.accessKeyId).toBe('MAIN_KEY');
expect(resolved.settings.secretAccessKey).toBe('MAIN_SECRET');
expect(resolved.settings.region).toBe('us-east-1');
expect(resolved.settings.presignedUrlBase).toBe('https://public.example.com');
});
test('ignores a partial override that does not set an endpoint', () => {
const resolved = resolveDownloadsProvider({s3: base, s3_downloads: {endpoint: '', access_key_id: 'OTHER'}});
expect(resolved.isOverridden).toBe(false);
expect(resolved.settings.accessKeyId).toBe('MAIN_KEY');
});
test('uses the override provider when an endpoint is set', () => {
const resolved = resolveDownloadsProvider({
s3: base,
s3_downloads: {
endpoint: 'https://downloads.example.net',
region: 'eu-central-1',
access_key_id: 'DL_KEY',
secret_access_key: 'DL_SECRET',
},
});
expect(resolved.isOverridden).toBe(true);
expect(resolved.settings.endpoint).toBe('https://downloads.example.net');
expect(resolved.settings.region).toBe('eu-central-1');
expect(resolved.settings.accessKeyId).toBe('DL_KEY');
});
test('inherits unspecified fields from the main provider', () => {
const resolved = resolveDownloadsProvider({s3: base, s3_downloads: {endpoint: 'https://downloads.example.net'}});
expect(resolved.isOverridden).toBe(true);
expect(resolved.settings.endpoint).toBe('https://downloads.example.net');
expect(resolved.settings.region).toBe('us-east-1');
expect(resolved.settings.accessKeyId).toBe('MAIN_KEY');
expect(resolved.settings.forcePathStyle).toBe(true);
});
test('does not inherit the main presigned base for an override provider', () => {
const resolved = resolveDownloadsProvider({s3: base, s3_downloads: {endpoint: 'https://downloads.example.net'}});
expect(resolved.settings.presignedUrlBase).toBeUndefined();
});
});
@@ -67,15 +67,8 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
FLUXER_S3_SECRET_ACCESS_KEY: {path: ['s3', 'secret_access_key']},
FLUXER_S3_BUCKET_CDN: {path: ['s3', 'buckets', 'cdn']},
FLUXER_S3_BUCKET_UPLOADS: {path: ['s3', 'buckets', 'uploads']},
FLUXER_S3_BUCKET_DOWNLOADS: {path: ['s3', 'buckets', 'downloads']},
FLUXER_S3_BUCKET_REPORTS: {path: ['s3', 'buckets', 'reports']},
FLUXER_S3_BUCKET_HARVESTS: {path: ['s3', 'buckets', 'harvests']},
FLUXER_S3_DOWNLOADS_ENDPOINT: {path: ['s3_downloads', 'endpoint']},
FLUXER_S3_DOWNLOADS_PUBLIC_ENDPOINT: {path: ['s3_downloads', 'presigned_url_base']},
FLUXER_S3_DOWNLOADS_FORCE_PATH_STYLE: {path: ['s3_downloads', 'force_path_style'], parse: parseBoolean},
FLUXER_S3_DOWNLOADS_REGION: {path: ['s3_downloads', 'region']},
FLUXER_S3_DOWNLOADS_ACCESS_KEY_ID: {path: ['s3_downloads', 'access_key_id']},
FLUXER_S3_DOWNLOADS_SECRET_ACCESS_KEY: {path: ['s3_downloads', 'secret_access_key']},
FLUXER_NATS_URL: {path: ['services', 'nats', 'core_url']},
FLUXER_NATS_JETSTREAM_URL: {path: ['services', 'nats', 'jetstream_url']},
FLUXER_NATS_AUTH_TOKEN: {path: ['services', 'nats', 'auth_token']},
@@ -85,18 +78,10 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
FLUXER_API_MAX_INFLIGHT_REQUESTS: {path: ['services', 'api', 'max_inflight_requests'], parse: parseInteger},
FLUXER_API_IP_BAN_EXEMPT_IPS: {path: ['services', 'api', 'ip_ban_exempt_ips'], parse: parseCsv},
FLUXER_API_DONATION_PROXY_KEY: {path: ['services', 'api', 'donation_proxy_key']},
FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES: {
path: ['services', 'api', 'desktop_github_redirect_countries'],
parse: parseCsv,
},
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: {
path: ['services', 'api', 'presigned_attachment_uploads_enabled'],
parse: parseBoolean,
},
FLUXER_API_PRESIGNED_DOWNLOADS_ENABLED: {
path: ['services', 'api', 'presigned_downloads_enabled'],
parse: parseBoolean,
},
FLUXER_API_PRESIGNED_HARVEST_DOWNLOADS_ENABLED: {
path: ['services', 'api', 'presigned_harvest_downloads_enabled'],
parse: parseBoolean,
@@ -69,18 +69,9 @@ describe('OpenAPI generation from API controllers', () => {
expect(responses).not.toHaveProperty('204');
});
it.each([
'/dl/desktop/{channel}/{plat}/{arch}/latest/{format}',
'/dl/desktop/{channel}/{plat}/{arch}/{version}/{format}',
])('distinguishes streamed download bytes from bodyless redirects for %s', (path) => {
const responses = document.paths[path].get.responses;
for (const status of ['200', '206']) {
expect(responses[status].content).toEqual({
'*/*': {schema: {$ref: '#/components/schemas/DownloadFileResponse'}},
});
}
expect(responses['302']).toEqual({description: 'Success'});
expect(responses).not.toHaveProperty('204');
it('keeps every desktop download redirect out of the published document', () => {
const published = Object.keys(document.paths).filter((path) => path.startsWith('/dl'));
expect(published).toEqual([]);
});
it('publishes stream preview images as binary responses', () => {
@@ -53,17 +53,13 @@ describe('discoverControllerFiles', () => {
const route = routes.find((route) => route.path === '/donations/manage');
expect(route?.explicitSecurity).toEqual([]);
});
it.each(['download_latest_desktop_version_checksum', 'download_desktop_version_checksum'])(
'preserves the plain-text checksum response for %s',
(operationId) => {
const route = routes.find((route) => route.explicitOperationId === operationId);
expect(route).toMatchObject({
responseSchemaName: 'DownloadChecksumResponse',
responseContentType: 'text/plain',
explicitStatusCodes: [200],
it('leaves every desktop download redirect undocumented', () => {
const downloads = routes.filter((route) => route.path.startsWith('/dl'));
expect(downloads.length).toBeGreaterThan(0);
for (const route of downloads) {
expect(route.explicitOperationId).toBeFalsy();
}
});
},
);
it('rejects a bodyless status that is absent from the route response statuses', () => {
const route = routes.find((route) => route.path === '/donations/manage');
assert(route);
@@ -14,8 +14,6 @@ export const DesktopChannelEnum = withOpenApiType(
'DesktopChannel',
);
export type DesktopChannel = z.infer<typeof DesktopChannelEnum>;
export const DesktopPlatformEnum = withOpenApiType(
createNamedStringLiteralUnion(
[
@@ -28,8 +26,6 @@ export const DesktopPlatformEnum = withOpenApiType(
'DesktopPlatform',
);
export type DesktopPlatform = z.infer<typeof DesktopPlatformEnum>;
export const DesktopArchEnum = withOpenApiType(
createNamedStringLiteralUnion(
[
@@ -41,8 +37,6 @@ export const DesktopArchEnum = withOpenApiType(
'DesktopArch',
);
export type DesktopArch = z.infer<typeof DesktopArchEnum>;
export const DesktopFormatEnum = withOpenApiType(
createNamedStringLiteralUnion(
[
@@ -60,22 +54,10 @@ export const DesktopFormatEnum = withOpenApiType(
'DesktopFormat',
);
export type DesktopFormat = z.infer<typeof DesktopFormatEnum>;
const VersionString = z
.string()
.regex(/^\d+\.\d+\.\d+$/u)
.describe('Semantic version string');
const TestBuildFlag = z
.string()
.optional()
.transform((value) => value === '1' || value?.toLowerCase() === 'true')
.describe('When set to 1/true, resolve against the desktop-test/ bucket prefix instead of desktop/.');
export const DesktopTestBuildQuery = z.object({
test: TestBuildFlag,
});
export type DesktopTestBuildQuery = z.infer<typeof DesktopTestBuildQuery>;
export const DesktopVersionsParam = z.object({
channel: DesktopChannelEnum,
@@ -115,44 +97,20 @@ export const DesktopVersionedChecksumRedirectParam = DesktopChecksumRedirectPara
export type DesktopVersionedChecksumRedirectParam = z.infer<typeof DesktopVersionedChecksumRedirectParam>;
export const DesktopVersionsQuery = z.object({
limit: z.coerce.number().int().min(1).max(100).default(25).describe('Maximum number of versions to return'),
before: VersionString.optional().describe('Return versions before this version'),
after: VersionString.optional().describe('Return versions after this version'),
test: TestBuildFlag,
const DesktopZsyncFormat = z
.templateLiteral([DesktopFormatEnum, '.zsync'])
.transform((value) => value.slice(0, -'.zsync'.length))
.pipe(z.literal('appimage'))
.describe('Package format followed by .zsync, which only appimage publishes');
export const DesktopZsyncRedirectParam = DesktopVersionsParam.extend({
format: DesktopZsyncFormat,
});
export type DesktopVersionsQuery = z.infer<typeof DesktopVersionsQuery>;
export type DesktopZsyncRedirectParam = z.infer<typeof DesktopZsyncRedirectParam>;
const VersionFileResponse = z.object({
url: z.string().describe('Download URL for this file'),
sha256: z.string().nullable().describe('SHA-256 hash of the file for verification'),
checksum_url: z.string().nullable().describe('Plain text .sha256 checksum file URL for this file'),
export const DesktopVersionedZsyncRedirectParam = DesktopZsyncRedirectParam.extend({
version: VersionString,
});
export const VersionInfoResponse = z.object({
version: z.string().describe('Semantic version string (e.g., 1.0.0)'),
pub_date: z.string().describe('ISO 8601 date when this version was published'),
minimum_system_version: z
.string()
.nullable()
.optional()
.describe('Minimum operating system version required by this release, when applicable'),
files: z
.partialRecord(DesktopFormatEnum, VersionFileResponse.optional())
.describe('Map of package format to download files'),
});
export type VersionInfoResponse = z.infer<typeof VersionInfoResponse>;
export const DesktopVersionsResponse = z.object({
versions: z.array(VersionInfoResponse).describe('Array of available versions'),
has_more: z.boolean().describe('Whether more versions are available to fetch'),
});
export type DesktopVersionsResponse = z.infer<typeof DesktopVersionsResponse>;
export const DownloadFileResponse = z.file().describe('The downloadable release file');
export const DownloadChecksumResponse = z
.string()
.describe('The release file checksum in SHA-256 checksum file format');
export type DesktopVersionedZsyncRedirectParam = z.infer<typeof DesktopVersionedZsyncRedirectParam>;