mirror of https://github.com/sylv/micro.git
158 lines
5.7 KiB
TypeScript
158 lines
5.7 KiB
TypeScript
import { EntityRepository } from "@mikro-orm/core";
|
|
import { InjectRepository } from "@mikro-orm/nestjs";
|
|
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
OnApplicationBootstrap,
|
|
PayloadTooLargeException,
|
|
UnauthorizedException,
|
|
} from "@nestjs/common";
|
|
import { Cron, CronExpression } from "@nestjs/schedule";
|
|
import contentRange from "content-range";
|
|
import { FastifyReply, FastifyRequest } from "fastify";
|
|
import { Multipart } from "fastify-multipart";
|
|
import { DateTime } from "luxon";
|
|
import { PassThrough } from "stream";
|
|
import xbytes from "xbytes";
|
|
import { MicroHost } from "../../classes/MicroHost";
|
|
import { config } from "../../config";
|
|
import { contentIdLength, generateContentId } from "../../helpers/generate-content-id.helper";
|
|
import { getStreamType } from "../../helpers/get-stream-type.helper";
|
|
import { HostsService } from "../hosts/hosts.service";
|
|
import { StorageService } from "../storage/storage.service";
|
|
import { File } from "./file.entity";
|
|
|
|
@Injectable()
|
|
export class FileService implements OnApplicationBootstrap {
|
|
private static readonly FILE_KEY_REGEX = new RegExp(`^(?<id>.{${contentIdLength}})(?<ext>\\.[A-z0-9]{2,})?$`);
|
|
private readonly logger = new Logger(FileService.name);
|
|
constructor(
|
|
@InjectRepository(File) private fileRepo: EntityRepository<File>,
|
|
private storageService: StorageService,
|
|
private hostsService: HostsService
|
|
) {}
|
|
|
|
cleanFileKey(key: string): { id: string; ext?: string } {
|
|
const groups = FileService.FILE_KEY_REGEX.exec(key)?.groups;
|
|
if (!groups) throw new BadRequestException("Invalid file key");
|
|
return groups as any;
|
|
}
|
|
|
|
async getFile(id: string, host: MicroHost) {
|
|
const file = await this.fileRepo.findOne(id);
|
|
if (!file) throw new NotFoundException(`Unknown file "${id}"`);
|
|
if (!this.hostsService.checkHostCanSendFile(file, host)) {
|
|
throw new NotFoundException("Your file is in another castle.");
|
|
}
|
|
|
|
return file;
|
|
}
|
|
|
|
async deleteFile(id: string, ownerId: string | null) {
|
|
const file = await this.fileRepo.findOne(id);
|
|
if (!file) throw new NotFoundException();
|
|
if (ownerId && file.owner.id !== ownerId) {
|
|
throw new UnauthorizedException("You cannot delete other users files.");
|
|
}
|
|
|
|
// todo: this should use a subscriber for file delete events, should also do
|
|
// the same for thumbnails or something ig
|
|
const filesWithHash = await this.fileRepo.count({ hash: file.hash });
|
|
if (filesWithHash === 1) {
|
|
await this.storageService.delete(file.hash);
|
|
}
|
|
|
|
await this.fileRepo.removeAndFlush(file);
|
|
}
|
|
|
|
async createFile(
|
|
multipart: Multipart,
|
|
request: FastifyRequest,
|
|
owner: { id: string; tags?: string[] },
|
|
host: MicroHost | undefined
|
|
): Promise<File> {
|
|
if (!request.headers["content-length"]) throw new BadRequestException('Missing "Content-Length" header.');
|
|
if (+request.headers["content-length"] >= config.uploadLimit) {
|
|
const size = xbytes(+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());
|
|
const uploadStream = stream.pipe(new PassThrough());
|
|
const type = (await getStreamType(multipart.filename, typeStream)) ?? multipart.mimetype;
|
|
if (config.allowTypes?.includes(type) === false) {
|
|
throw new BadRequestException(`"${type}" is not supported by this server.`);
|
|
}
|
|
|
|
const fileId = generateContentId();
|
|
const { hash, size } = await this.storageService.create(uploadStream);
|
|
const file = this.fileRepo.create({
|
|
id: fileId,
|
|
type: type,
|
|
name: multipart.filename,
|
|
owner: owner.id,
|
|
host: host?.key,
|
|
hash: hash,
|
|
size: size,
|
|
});
|
|
|
|
await this.fileRepo.persistAndFlush(file);
|
|
return file;
|
|
}
|
|
|
|
async sendFile(fileId: string, request: FastifyRequest, reply: FastifyReply) {
|
|
const file = await this.getFile(fileId, request.host);
|
|
const range = request.headers["content-range"] ? contentRange.parse(request.headers["content-range"]) : null;
|
|
const stream = this.storageService.createReadStream(file.hash, range);
|
|
if (range) 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.displayName}"`)
|
|
.send(stream);
|
|
}
|
|
|
|
@Cron(CronExpression.EVERY_HOUR)
|
|
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 = xbytes(file.size, { space: false });
|
|
const age = DateTime.fromJSDate(file.createdAt).toRelative();
|
|
await this.deleteFile(file.id, null);
|
|
this.logger.log(`Purging ${file.id} (${size}, ${age})`);
|
|
}
|
|
|
|
if (files[0]) {
|
|
this.logger.log(`Purged ${files.length} files`);
|
|
}
|
|
}
|
|
|
|
onApplicationBootstrap() {
|
|
if (config.purge) {
|
|
const size = xbytes(config.purge.overLimit, { space: false });
|
|
// todo: swap out luxon for dayjs
|
|
const age = DateTime.local().minus(config.purge.afterTime).toRelative();
|
|
this.logger.warn(`Purging files is enabled for files over ${size} uploaded more than ${age}.`);
|
|
}
|
|
}
|
|
}
|