micro/packages/api/src/modules/file/file.service.ts

207 lines
7.8 KiB
TypeScript

/* eslint-disable sonarjs/no-duplicate-string */
import type { MultipartFile } from '@fastify/multipart';
import { EntityRepository, MikroORM, UseRequestContext } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { OnApplicationBootstrap } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, NotFoundException, PayloadTooLargeException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import bytes from 'bytes';
import * as contentRange from 'content-range';
import type { FastifyReply, FastifyRequest } from 'fastify';
import ffmpeg from 'fluent-ffmpeg';
import { DateTime } from 'luxon';
import mime from 'mime-types';
import sharp from 'sharp';
import { PassThrough } from 'stream';
import { config, type MicroHost } from '../../config.js';
import { generateContentId } from '../../helpers/generate-content-id.helper.js';
import { getStreamType } from '../../helpers/get-stream-type.helper.js';
import { HostService } from '../host/host.service.js';
import { StorageService } from '../storage/storage.service.js';
import type { User } from '../user/user.entity.js';
import type { File } from './file.entity.js';
@Injectable()
export class FileService implements OnApplicationBootstrap {
private readonly logger = new Logger(FileService.name);
constructor(
@InjectRepository('File') private readonly fileRepo: EntityRepository<File>,
private readonly storageService: StorageService,
private readonly hostService: HostService,
protected readonly orm: MikroORM,
) {}
async getFile(id: string, request: FastifyRequest) {
const file = await this.fileRepo.findOneOrFail(id, { populate: ['owner'] });
if (!file.canSendTo(request)) {
throw new NotFoundException('Your file is in another castle.');
}
return file;
}
async createFile(
multipart: MultipartFile,
request: FastifyRequest,
owner: User,
host: MicroHost | undefined,
): Promise<File> {
if (host) this.hostService.checkUserCanUploadTo(host, owner);
if (!request.headers['content-length']) throw new BadRequestException('Missing "Content-Length" header.');
const contentLength = Number(request.headers['content-length']);
if (Number.isNaN(contentLength) || contentLength >= config.uploadLimit) {
const size = bytes.parse(Number(request.headers['content-length']));
this.logger.warn(
`User ${owner.id} tried uploading a ${size} file, which is over the configured upload size limit.`,
);
throw new PayloadTooLargeException();
}
const stream = multipart.file;
const typeStream = stream.pipe(new PassThrough());
let uploadStream = stream.pipe(new PassThrough());
const fileType = (await getStreamType(multipart.filename, typeStream)) ?? multipart.mimetype;
if (config.allowTypes && !config.allowTypes.has(fileType)) {
throw new BadRequestException(`"${fileType}" is not supported by this server.`);
}
const conversion = config.conversions?.find((conversion) => {
if (!conversion.from.has(fileType)) return false;
if (conversion.minSize && contentLength < conversion.minSize) return false;
if (conversion.to === fileType) return false; // dont convert to the same type
return true;
});
if (conversion) {
this.logger.debug(`Converting ${fileType} to ${conversion.to}`);
const fromGroup = fileType.split('/')[0];
const toGroup = conversion.to.split('/')[0];
if (fromGroup !== toGroup && fileType !== 'image/gif') {
throw new Error(`Cannot convert from ${fromGroup} to ${toGroup}`);
}
switch (toGroup) {
case 'video': {
let fromFormat = fileType.split('/')[1];
if (fromFormat === 'gif') {
// ffmpeg doesnt support piping gifs unless "gif_pipe" is the input format.
// you have no idea how long it took to discover this.
fromFormat = 'gif_pipe';
}
const toFormat = conversion.to.split('/')[1];
const transcodeStream = new PassThrough();
ffmpeg()
.input(uploadStream)
.fromFormat(fromFormat)
.toFormat(toFormat)
.writeToStream(transcodeStream, { end: true });
uploadStream = transcodeStream;
break;
}
case 'image': {
const toFormat = conversion.to.split('/')[1];
if (!(toFormat in sharp.format)) {
throw new Error(`Unknown or unsupported image format ${toFormat}`);
}
// pages: -1 enables support to convert gif to webp without it being a static image
const transformer = sharp({ pages: -1 }).toFormat(toFormat as any, {
effort: 3,
quality: 70,
progressive: true,
});
uploadStream = uploadStream.pipe(transformer).pipe(new PassThrough());
break;
}
default: {
throw new Error(`Unknown or unsupported conversion ${fromGroup} to ${toGroup}`);
}
}
}
const fileId = generateContentId();
const { hash, size } = await this.storageService.create(uploadStream);
const file = this.fileRepo.create({
id: fileId,
type: fileType,
name: multipart.filename,
owner: owner.id,
hostname: host?.normalised.replace('{{username}}', owner.username),
hash: hash,
size: size,
});
if (conversion) {
// swap the file type to the new mime type
const originalExtension = mime.extension(file.type);
file.type = conversion.to;
const conversionExtension = mime.extension(conversion.to);
if (file.name && originalExtension && conversionExtension) {
// "fix" extensions in the ile name, eg "Test.png" > "Test.webp"
file.name = file.name.replace(`.${originalExtension}`, `.${conversionExtension}`);
}
}
await this.fileRepo.persistAndFlush(file);
return file;
}
async sendFile(fileId: string, request: FastifyRequest, reply: FastifyReply) {
const file = await this.getFile(fileId, request);
const range = request.headers['content-range'] ? contentRange.parse(request.headers['content-range']) : null;
const stream = this.storageService.createReadStream(file.hash, range);
if (range) await reply.header('Content-Range', contentRange.format(range));
const type = file.type.startsWith('text') ? `${file.type}; charset=UTF-8` : file.type;
return reply
.header('ETag', `"${file.hash}"`)
.header('Accept-Ranges', 'bytes')
.header('Content-Type', type)
.header('Content-Length', file.size)
.header('Last-Modified', file.createdAt)
.header('Content-Disposition', `inline; filename="${file.getDisplayName()}"`)
.header('Cache-Control', 'public, max-age=31536000')
.header('Expires', DateTime.local().plus({ years: 1 }).toHTTP())
.header('X-Content-Type-Options', 'nosniff')
.send(stream);
}
@Cron(CronExpression.EVERY_HOUR)
@UseRequestContext()
async purgeFiles() {
if (!config.purge) return;
const createdBefore = new Date(Date.now() - config.purge.afterTime);
const files = await this.fileRepo.find({
size: {
$gte: config.purge.overLimit,
},
createdAt: {
$lte: createdBefore,
},
});
for (const file of files) {
const size = bytes.format(file.size);
const age = DateTime.fromJSDate(file.createdAt).toRelative();
await this.fileRepo.removeAndFlush(file);
this.logger.log(`Purging ${file.id} (${size}, ${age})`);
}
if (files[0]) {
this.logger.log(`Purged ${files.length} files`);
}
}
onApplicationBootstrap() {
if (config.purge) {
const size = bytes.format(config.purge.overLimit);
const age = DateTime.local().minus(config.purge.afterTime).toRelative();
this.logger.warn(`Purging files is enabled for files over ${size} uploaded more than ${age}.`);
}
}
}