import Controller from "swaf/Controller"; import {NextFunction, Request, Response} from "express"; import * as https from "https"; import config from "config"; import {NotFoundHttpError, ServiceUnavailableHttpError} from "swaf/HttpError"; import * as fs from "fs"; import {promisify} from "util"; import path from "path"; import sendRanges, {SendRangeGetStreamFn} from "send-ranges"; import mime from "mime"; import {logger} from "swaf/Logger"; import {ParsedUrlQueryInput} from "querystring"; export const ASSETS_BASE_DIR = config.get('assets_base_dir'); export default class GiteaRepoLatestReleaseController extends Controller { public routes(): void { this.get('/:owner/:name/:file?', this.getFile, 'get-repo-release-file'); } protected async getFile(req: Request, res: Response, next: NextFunction): Promise { logger.info('Serving ' + req.path + ' ...'); const {owner, name, file} = req.params; if (!owner || !name) return next(); // User redirections const userRedirections = config.get<{ from: string, to: string }[]>('user_redirections'); for (const redirection of userRedirections) { if (owner === redirection.from) { return res.redirect(Controller.route('get-repo-release-file', [redirection.to, name, file], req.query as ParsedUrlQueryInput)); } } const httpRequest = https.get(`${config.get('gitea_instance_url')}/api/v1/repos/${owner}/${name}/releases`, { headers: { 'Accept': 'application/json', }, }, r => { let data = ''; r.on('data', c => { data += c; }); r.on('end', async () => { if (r.statusCode === 404) return next(new NotFoundHttpError('file', req.url)); try { const releases = JSON.parse(data); if (file) { for (const release of releases) { for (const asset of release.assets) { if (asset.name === file) { logger.debug('Download', asset.browser_download_url); return await this.download(req, res, next, { repo: { owner: owner, name: name, }, asset: { id: asset.id, name: asset.name, url: asset.browser_download_url, size: asset.size, }, }); } } } throw new NotFoundHttpError('Asset', req.url); } else { logger.debug('List files'); return res.render('list-files', { owner: owner, name: name, releases: releases, }); } } catch (e) { return next(e); } }); }); httpRequest.on('error', err => { logger.error(err); }); httpRequest.end(); } protected async download( req: Request, res: Response, next: NextFunction, downloadProperties: DownloadProperties, ): Promise { // Make base dir if (!await promisify(fs.exists)(ASSETS_BASE_DIR)) { await promisify(fs.mkdir)(ASSETS_BASE_DIR); } const assetPath = path.resolve(ASSETS_BASE_DIR, '' + downloadProperties.asset.id); const tmpAssetPath = assetPath + '.tmp'; // Download asset if it doesn't exist if (!await promisify(fs.exists)(assetPath)) { if (await promisify(fs.exists)(tmpAssetPath)) { throw new ServiceUnavailableHttpError('This file is currently being cached. Please try again later.'); } const file = fs.createWriteStream(tmpAssetPath); await new Promise((resolve, reject) => { const httpRequest = https.get(downloadProperties.asset.url, res => { res.on('end', () => { resolve(); }); res.pipe(file); }); httpRequest.on('error', () => { reject(); }); httpRequest.end(); }); file.close(); await promisify(fs.rename)(tmpAssetPath, assetPath); } logger.debug('Download', assetPath, downloadProperties.asset.name); sendRanges(async _ => { const filePath = assetPath; const getStream: SendRangeGetStreamFn = range => fs.createReadStream(filePath, range); const type = mime.getType(downloadProperties.asset.name) || 'application/json'; const stats = await promisify(fs.stat)(filePath); return {getStream, type, size: stats.size}; }, { maxRanges: 1024, })(req, res, (err: unknown) => { if (err) return next(err); logger.info('Fallback to express download.'); // Respond return res.download(assetPath, downloadProperties.asset.name); }); } } export type DownloadProperties = { repo: { owner: string; name: string; }; asset: { id: string, name: string; url: string; size: number; }; };