Files
fx-test/fluxer_api/src/download/DownloadController.ts
Hampus Kraft 2f557eda8c initial commit
2026-01-01 21:05:54 +00:00

227 lines
6.9 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Readable} from 'node:stream';
import {S3ServiceException} from '@aws-sdk/client-s3';
import type {Context, Hono} from 'hono';
import type {HonoEnv} from '~/App';
import {Config} from '~/Config';
const DOWNLOAD_PREFIX = '/dl';
const DESKTOP_REDIRECT_PREFIX = `${DOWNLOAD_PREFIX}/desktop`;
type DesktopChannel = 'stable' | 'canary';
type DesktopPlatform = 'win32' | 'darwin' | 'linux';
type DesktopArch = 'x64' | 'arm64';
type DesktopFormat = 'setup' | 'dmg' | 'zip' | 'appimage' | 'deb' | 'rpm' | 'tar_gz';
type DesktopManifest = {
channel: DesktopChannel;
platform: DesktopPlatform;
arch: DesktopArch;
version: string;
pub_date: string;
files: Record<string, string>;
};
const isDesktopChannel = (value: string): value is DesktopChannel => value === 'stable' || value === 'canary';
const isDesktopPlatform = (value: string): value is DesktopPlatform =>
value === 'win32' || value === 'darwin' || value === 'linux';
const isDesktopArch = (value: string): value is DesktopArch => value === 'x64' || value === 'arm64';
const isDesktopFormat = (value: string): value is DesktopFormat =>
value === 'setup' ||
value === 'dmg' ||
value === 'zip' ||
value === 'appimage' ||
value === 'deb' ||
value === 'rpm' ||
value === 'tar_gz';
const buildKeyFromPath = (path: string): string | null => {
if (!path.startsWith(DOWNLOAD_PREFIX)) {
return null;
}
const stripped = path.slice(DOWNLOAD_PREFIX.length);
const normalized = stripped.replace(/^\/+/u, '');
return normalized.length > 0 ? normalized : null;
};
const normalizePlatformArchKey = (key: string): string | null => {
const match = key.match(/^(desktop\/(stable|canary)\/(win32|darwin|linux))-(x64|arm64)(\/.*)$/u);
if (!match) {
return null;
}
const [, prefix, , , arch, suffix] = match;
return `${prefix}/${arch}${suffix}`;
};
const buildDownloadHeaders = (metadata: {
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
}) => {
const headers = new Headers();
headers.set('Accept-Ranges', 'bytes');
headers.set('Vary', 'Accept-Encoding, Range');
if (metadata.cacheControl) {
headers.set('Cache-Control', metadata.cacheControl);
}
headers.set('Content-Type', metadata.contentType ?? 'application/octet-stream');
headers.set('Content-Length', metadata.contentLength.toString());
if (metadata.contentDisposition) {
headers.set('Content-Disposition', metadata.contentDisposition);
}
if (metadata.etag) {
headers.set('ETag', metadata.etag);
}
if (metadata.expires) {
headers.set('Expires', metadata.expires.toUTCString());
}
if (metadata.lastModified) {
headers.set('Last-Modified', metadata.lastModified.toUTCString());
}
if (metadata.contentRange) {
headers.set('Content-Range', metadata.contentRange);
}
return headers;
};
const readJsonObjectFromStorage = async <T>(ctx: Context, key: string): Promise<T | null> => {
const storageService = ctx.get('storageService');
const streamResult = await storageService.streamObject({
bucket: Config.s3.buckets.downloads,
key,
});
if (!streamResult) {
return null;
}
const body = Readable.toWeb(streamResult.body);
const text = await new Response(body as BodyInit).text();
return JSON.parse(text) as T;
};
export function DownloadController(routes: Hono<HonoEnv>): void {
routes.get(`${DESKTOP_REDIRECT_PREFIX}/:channel/:plat/:arch/latest/:format`, async (ctx) => {
const channelRaw = ctx.req.param('channel') ?? '';
const platRaw = ctx.req.param('plat') ?? '';
const archRaw = ctx.req.param('arch') ?? '';
const formatRaw = ctx.req.param('format') ?? '';
if (
!isDesktopChannel(channelRaw) ||
!isDesktopPlatform(platRaw) ||
!isDesktopArch(archRaw) ||
!isDesktopFormat(formatRaw)
) {
return ctx.text('Not Found', 404);
}
const manifestKey = `desktop/${channelRaw}/${platRaw}/${archRaw}/manifest.json`;
try {
const manifest = await readJsonObjectFromStorage<DesktopManifest>(ctx, manifestKey);
if (!manifest || !manifest.files) {
return ctx.text('Not Found', 404);
}
const filename = manifest.files[formatRaw];
if (!filename || filename.trim().length === 0) {
return ctx.text('Not Found', 404);
}
const encodedFilename = encodeURIComponent(filename);
const dest = new URL(ctx.req.url);
dest.hostname = ctx.req.header('host') ?? dest.hostname;
const forwardedProto = ctx.req.header('x-forwarded-proto') ?? '';
const scheme = forwardedProto.length > 0 ? forwardedProto.split(',')[0].trim() : 'https';
dest.protocol = scheme.endsWith(':') ? scheme : `${scheme}:`;
if (scheme === 'https') {
dest.port = '';
}
dest.pathname = `${DOWNLOAD_PREFIX}/desktop/${channelRaw}/${platRaw}/${archRaw}/${encodedFilename}`;
const res = ctx.redirect(dest.toString(), 302);
res.headers.set('Cache-Control', 'no-store');
return res;
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
return ctx.text('Not Found', 404);
}
throw error;
}
});
routes.get(`${DOWNLOAD_PREFIX}/*`, async (ctx) => {
const key = buildKeyFromPath(ctx.req.path);
if (!key) {
return ctx.text('Not Found', 404);
}
const storageService = ctx.get('storageService');
const rangeHeader = ctx.req.header('range');
const keysToTry = [key];
const normalizedKey = normalizePlatformArchKey(key);
if (normalizedKey) {
keysToTry.push(normalizedKey);
}
for (const candidateKey of keysToTry) {
try {
const streamResult = await storageService.streamObject({
bucket: Config.s3.buckets.downloads,
key: candidateKey,
range: rangeHeader ?? undefined,
});
if (!streamResult) {
continue;
}
const headers = buildDownloadHeaders(streamResult);
const status = streamResult.contentRange ? 206 : 200;
const body = Readable.toWeb(streamResult.body);
return new Response(body as BodyInit, {headers, status});
} catch (error) {
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
continue;
}
throw error;
}
}
return ctx.text('Not Found', 404);
});
}