update.eternae.ink/src/controllers/GiteaRepoLatestReleaseContr...

160 lines
5.8 KiB
TypeScript

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<string>('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<void> {
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<void> {
// 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<void>((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;
};
};