chore: convert api to esm (#30)

This commit is contained in:
Sylver 2022-10-07 22:35:56 +08:00 committed by GitHub
parent f43a8e0cb5
commit 89ff23edfd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 3492 additions and 6001 deletions

View File

@ -28,7 +28,6 @@
// const selectedClass = 'value'
["const [a-zA-Z]+s = ['\"`]([^\"`'`]*)"]
],
"cSpell.words": ["xbytes"],
"yaml.schemas": {
"https://json.schemastore.org/github-workflow.json": "file:///home/ryan/projects/micro/.github/workflows/build.yaml"
}

View File

@ -7,7 +7,7 @@
"private": true,
"packageManager": "pnpm@7.0.0",
"engines": {
"node": ">=16 <17",
"node": ">=16",
"pnpm": ">=7"
},
"scripts": {
@ -17,6 +17,6 @@
"clean": "rm -rf ./packages/*/{tsconfig.tsbuildinfo,lib,dist,yarn-error.log,.next}"
},
"devDependencies": {
"turbo": "1.3.2-canary.0"
"turbo": "1.5.5"
}
}

View File

@ -5,85 +5,83 @@
"author": "Ryan <ryan@sylver.me>",
"license": "GPL-3.0",
"private": true,
"type": "module",
"engine": {
"node": ">=16"
},
"scripts": {
"watch": "rm -rf ./dist && tsup src/main.ts src/migrations/* --watch --onSuccess \"node dist/main.js\" --target node16",
"watch": "tsup src/main.ts src/migrations/* --watch --clean --format esm --sourcemap --onSuccess \"node dist/main.js --inspect --inspect-brk\"",
"build": "rm -rf ./dist && ncc build src/main.ts -o dist --minify --transpile-only --v8-cache --no-source-map-register",
"lint": "eslint src --fix --cache",
"test": "jest"
"test": "vitest run"
},
"dependencies": {
"@fastify/cookie": "^6.0.0",
"@fastify/helmet": "^8.0.0",
"@fastify/multipart": "^6.0.0",
"@fastify/cookie": "^8.3.0",
"@fastify/helmet": "^10.0.1",
"@fastify/multipart": "^7.2.0",
"@jenyus-org/nestjs-graphql-utils": "^1.6.4",
"@mikro-orm/core": "^5.2.2",
"@mikro-orm/migrations": "^5.2.2",
"@mikro-orm/nestjs": "^5.0.2",
"@mikro-orm/postgresql": "^5.2.2",
"@nestjs/common": "^8.4.4",
"@nestjs/core": "^8.4.4",
"@mikro-orm/core": "^5.4.2",
"@mikro-orm/migrations": "^5.4.2",
"@mikro-orm/nestjs": "^5.1.2",
"@mikro-orm/postgresql": "^5.4.2",
"@nestjs/common": "^9.1.4",
"@nestjs/core": "^9.1.4",
"@nestjs/graphql": "^10.0.16",
"@nestjs/jwt": "^8.0.0",
"@nestjs/jwt": "^9.0.0",
"@nestjs/mercurius": "^10.0.16",
"@nestjs/passport": "^8.2.1",
"@nestjs/platform-fastify": "^8.4.4",
"@nestjs/schedule": "^1.1.0",
"@ryanke/thumbnail-generator": "workspace:^0.0.1",
"@ryanke/venera": "^0.0.2",
"@types/bcryptjs": "^2.4.2",
"@types/nodemailer": "^6.4.4",
"@nestjs/passport": "^9.0.0",
"@nestjs/platform-fastify": "^9.1.4",
"@nestjs/schedule": "^2.1.0",
"@ryanke/venera": "^1.0.3",
"bcryptjs": "^2.4.3",
"bytes": "^3.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"content-range": "^2.0.2",
"dedent": "^0.7.0",
"escape-string-regexp": "^4",
"fastify": "^3.29.0",
"file-type": "^16",
"escape-string-regexp": "^5.0.0",
"fastify": "^4.7.0",
"file-type": "^18.0.0",
"fluent-ffmpeg": "^2.1.2",
"get-stream": "^6.0.1",
"graphql": "^16.5.0",
"graphql": "^16.6.0",
"handlebars": "^4.7.7",
"istextorbinary": "^6.0.0",
"luxon": "^2.3.2",
"mercurius": "^9",
"luxon": "^3.0.4",
"mercurius": "^11.0.1",
"mime-types": "^2.1.35",
"ms": "^3.0.0-canary.1",
"nanoid": "^3.3.4",
"nodemailer": "^6.7.6",
"normalize-url": "^6",
"nanoid": "^4.0.0",
"nodemailer": "^6.8.0",
"normalize-url": "^7.2.0",
"otplib": "^12.0.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"pretty-bytes": "^6.0.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.5.5",
"sharp": "^0.30.4",
"stream-size": "^0.0.6",
"xbytes": "^1.7.0"
"rxjs": "^7.5.7",
"sharp": "^0.31.1",
"stream-size": "^0.0.6"
},
"devDependencies": {
"@mikro-orm/cli": "^5.2.2",
"@swc/core": "^1.2.208",
"@sylo-digital/scripts": "^1.0.2",
"@mikro-orm/cli": "^5.4.2",
"@swc/core": "^1.3.5",
"@sylo-digital/scripts": "^1.0.12",
"@types/bcryptjs": "^2.4.2",
"@types/bytes": "^3.1.1",
"@types/dedent": "^0.7.0",
"@types/fluent-ffmpeg": "^2.1.20",
"@types/jest": "^28.1.4",
"@types/luxon": "^2.3.2",
"@types/luxon": "^3.0.1",
"@types/mime-types": "^2.1.1",
"@types/ms": "^0.7.31",
"@types/node": "16",
"@types/passport-jwt": "^3.0.6",
"@types/passport-local": "^1.0.34",
"@types/sharp": "^0.30.2",
"@types/nodemailer": "^6.4.6",
"@types/passport-jwt": "^3.0.7",
"@types/sharp": "^0.31.0",
"@vercel/ncc": "^0.34.0",
"jest": "^28.1.2",
"pretty-bytes": "^6.0.0",
"ts-node": "^10.8.2",
"tsup": "^6.1.3",
"typescript": "^4.7.4"
"ts-node": "^10.9.1",
"tsup": "^6.2.3",
"typescript": "^4.8.4",
"vitest": "^0.24.0"
},
"mikro-orm": {
"useTsNode": true,
@ -91,8 +89,5 @@
"./src/orm.ts",
"./dist/orm.js"
]
},
"jest": {
"preset": "@sylo-digital/scripts/jest/node"
}
}

View File

@ -1,3 +1,4 @@
import bytes from 'bytes';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
@ -13,12 +14,11 @@ import {
ValidateNested,
} from 'class-validator';
import path from 'path';
import xbytes from 'xbytes';
import { expandMime } from '../helpers/expand-mime';
import { MicroConversion } from './MicroConversion';
import { MicroEmail } from './MicroEmail';
import { MicroHost } from './MicroHost';
import { MicroPurge } from './MicroPurge';
import { expandMime } from '../helpers/expand-mime.js';
import { MicroConversion } from './MicroConversion.js';
import { MicroEmail } from './MicroEmail.js';
import { MicroHost } from './MicroHost.js';
import { MicroPurge } from './MicroPurge.js';
export class MicroConfig {
@IsUrl({ require_tld: false, require_protocol: true, protocols: ['postgresql', 'postgres'] })
@ -32,8 +32,8 @@ export class MicroConfig {
inquiries: string;
@IsNumber()
@Transform(({ value }) => xbytes.parseSize(value))
uploadLimit = xbytes.parseSize('50MB');
@Transform(({ value }) => bytes.parse(value))
uploadLimit = bytes.parse('50MB');
@IsNumber()
@IsOptional()
@ -77,6 +77,6 @@ export class MicroConfig {
hosts: MicroHost[];
get rootHost() {
return this.hosts[0];
return this.hosts[0]!;
}
}

View File

@ -1,7 +1,7 @@
import bytes from 'bytes';
import { Transform } from 'class-transformer';
import { IsMimeType, IsNumber, IsOptional, IsString } from 'class-validator';
import xbytes from 'xbytes';
import { expandMime } from '../helpers/expand-mime';
import { expandMime } from '../helpers/expand-mime.js';
export class MicroConversion {
@IsString({ each: true })
@ -16,6 +16,6 @@ export class MicroConversion {
@IsNumber()
@IsOptional()
@Transform(({ value }) => xbytes.parseSize(value))
@Transform(({ value }) => bytes.parse(value))
minSize?: number;
}

View File

@ -1,6 +1,6 @@
import { IsOptional, IsString, IsUrl, Matches } from 'class-validator';
import escapeString from 'escape-string-regexp';
import { HostService } from '../modules/host/host.service';
import { HostService } from '../modules/host/host.service.js';
export class MicroHost {
constructor(url: string, tags?: string[], redirect?: string) {

View File

@ -1,11 +1,11 @@
import bytes from 'bytes';
import { Transform } from 'class-transformer';
import { IsNumber } from 'class-validator';
import ms from 'ms';
import xbytes from 'xbytes';
export class MicroPurge {
@IsNumber()
@Transform(({ value }) => xbytes.parseSize(value))
@Transform(({ value }) => bytes.parse(value))
overLimit: number;
@IsNumber()

View File

@ -1,7 +1,7 @@
import { loadConfig } from '@ryanke/venera';
import { plainToClass } from 'class-transformer';
import { validateSync } from 'class-validator';
import { MicroConfig } from './classes/MicroConfig';
import { MicroConfig } from './classes/MicroConfig.js';
const data = loadConfig('micro');
const config = plainToClass(MicroConfig, data, { exposeDefaultValues: true });

View File

@ -1,5 +1,5 @@
import type { MicroHost } from '../classes/MicroHost';
import type { User } from './user/user.entity';
import type { MicroHost } from '../classes/MicroHost.js';
import type { User } from './user/user.entity.js';
import 'fastify';
declare module 'fastify' {

View File

@ -1,4 +1,5 @@
import { expandMime } from './expand-mime';
import { expect, it } from 'vitest';
import { expandMime } from './expand-mime.js';
it('should expand mime types', () => {
expect(expandMime('video')).toMatchSnapshot();

View File

@ -1,9 +1,9 @@
import { FastifyRequest } from 'fastify';
import type { FastifyRequest } from 'fastify';
export function getHostFromRequest(request: FastifyRequest): string {
if (request.headers['x-forwarded-host']) {
if (Array.isArray(request.headers['x-forwarded-host'])) {
return request.headers['x-forwarded-host'][0];
return request.headers['x-forwarded-host'][0]!;
}
return request.headers['x-forwarded-host'];

View File

@ -1,4 +1,4 @@
import * as fileType from 'file-type';
import { fileTypeFromBuffer } from 'file-type';
import { isBinary } from 'istextorbinary';
import * as mimeType from 'mime-types';
import path from 'path';
@ -29,7 +29,7 @@ export async function getStreamType(fileName: string, stream: PassThrough): Prom
const firstBytes = await readFirstBytes(stream);
const binary = isBinary(fileName, firstBytes);
if (binary) {
const result = await fileType.fromBuffer(firstBytes);
const result = await fileTypeFromBuffer(firstBytes);
return result?.mime ?? DEFAULT_TYPE;
}

View File

@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import type { Edge } from '../types/edge.type';
import type { Paginated } from '../types/paginated.type';
import type { Edge } from '../types/edge.type.js';
import type { Paginated } from '../types/paginated.type.js';
export function createCursor(offset: number) {
return Buffer.from(`${offset}`, 'utf8').toString('base64');

View File

@ -1,12 +1,12 @@
/* eslint-disable sonarjs/no-duplicate-string */
import type { IdentifiedReference } from '@mikro-orm/core';
import { BeforeCreate, Entity, EventArgs, Property } from '@mikro-orm/core';
import { BeforeCreate, Entity, type EventArgs, Property } from '@mikro-orm/core';
import { ObjectType } from '@nestjs/graphql';
import type { FastifyRequest } from 'fastify';
import { config } from '../config';
import type { ResourceLocations } from '../types/resource-locations.type';
import type { User } from '../modules/user/user.entity';
import { getHostFromRequest } from './get-host-from-request';
import { config } from '../config.js';
import type { ResourceLocations } from '../types/resource-locations.type.js';
import type { User } from '../modules/user/user.entity.js';
import { getHostFromRequest } from './get-host-from-request.js';
@Entity({ abstract: true })
@ObjectType({ isAbstract: true })

View File

@ -1,5 +1,5 @@
import nodemailer from 'nodemailer';
import { config } from '../config';
import { config } from '../config.js';
const transport = config.email && nodemailer.createTransport(config.email.smtp);

View File

@ -1,23 +0,0 @@
import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import type { GqlContextType } from '@nestjs/graphql';
import type { FastifyReply } from 'fastify';
import { map } from 'rxjs';
export class SerializerInterceptor implements NestInterceptor {
async intercept(context: ExecutionContext, next: CallHandler) {
if (context.getType<GqlContextType>() === 'graphql') return next.handle();
const response = context.switchToHttp().getResponse<FastifyReply>();
return next.handle().pipe(
map((data) => {
if (data === null || data === undefined) return data;
if (typeof data === 'string') return data;
if (typeof data === 'object') {
void response.header('Content-Type', 'application/json');
return JSON.stringify(data);
}
throw new Error(`Do not know how to serialize "${data}"`);
})
);
}
}

View File

@ -1,19 +1,45 @@
import cookie from '@fastify/cookie';
import helmet from '@fastify/helmet';
import type { FastifyMultipartOptions } from '@fastify/multipart';
import multipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie';
import fastifyHelmet from '@fastify/helmet';
import fastifyMultipart from '@fastify/multipart';
import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import createApp from 'fastify';
import { config } from './config';
import { migrate } from './migrate';
import { AppModule } from './modules/app.module';
import { HostGuard } from './modules/host/host.guard';
import { SerializerInterceptor } from './helpers/serializer.interceptor';
import { fastify } from 'fastify';
import { config } from './config.js';
import { migrate } from './migrate.js';
import { AppModule } from './modules/app.module.js';
import { HostGuard } from './modules/host/host.guard.js';
const limits: FastifyMultipartOptions = {
await migrate();
const logger = new Logger('bootstrap');
const server = fastify({
trustProxy: process.env.TRUST_PROXY === 'true',
maxParamLength: 500,
bodyLimit: config.uploadLimit,
logger: {
level: 'trace',
},
});
const adapter = new FastifyAdapter(server as any);
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter);
app.useGlobalGuards(new HostGuard());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
transformOptions: {
enableImplicitConversion: true,
},
})
);
await app.register(fastifyCookie);
await app.register(fastifyHelmet.default);
await app.register(fastifyMultipart.default, {
limits: {
fieldNameSize: 100,
fieldSize: 100,
@ -21,45 +47,9 @@ const limits: FastifyMultipartOptions = {
files: 1,
headerPairs: 20,
},
};
async function bootstrap(): Promise<void> {
const logger = new Logger('bootstrap');
logger.debug(`Checking for and running migrations`);
await migrate();
logger.debug(`Migrations check complete`);
const fastify = createApp({
trustProxy: process.env.TRUST_PROXY === 'true',
maxParamLength: 500,
bodyLimit: config.uploadLimit,
});
const adapter = new FastifyAdapter(fastify as any);
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter);
app.useGlobalInterceptors(new SerializerInterceptor());
app.useGlobalGuards(new HostGuard());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
transformOptions: {
enableImplicitConversion: true,
},
})
);
await app.register(cookie as any);
await app.register(helmet);
await app.register(multipart as any, limits);
await app.listen(8080, '0.0.0.0', (error, address) => {
if (error) throw error;
logger.log(`Listening at ${address}`);
});
}
bootstrap().catch((error) => {
console.error(error);
process.exit(1);
});
await app.listen(8080, '0.0.0.0', (error, address) => {
if (error) throw error;
logger.log(`Listening at ${address}`);
});

View File

@ -1,13 +1,17 @@
import type { Options } from '@mikro-orm/core';
import { MikroORM } from '@mikro-orm/core';
import type { EntityManager } from '@mikro-orm/postgresql';
import { checkForOldDatabase, migrateOldDatabase } from './helpers/migrate-old-database';
import mikroOrmConfig, { migrationsTableName, ormLogger } from './orm';
import { Logger } from '@nestjs/common';
import { checkForOldDatabase, migrateOldDatabase } from './helpers/migrate-old-database.js';
import mikroOrmConfig, { migrationsTableName, ormLogger } from './orm.js';
const logger = new Logger('migrate');
export const migrate = async (
config: Options = mikroOrmConfig,
skipLock = process.env.SKIP_MIGRATION_LOCK === 'true'
) => {
logger.debug(`Checking for and running migrations`);
const orm = await MikroORM.init(config);
const em = orm.em.fork({ clear: true }) as EntityManager;
const connection = em.getConnection();
@ -32,4 +36,5 @@ export const migrate = async (
});
await orm.close();
logger.debug(`Migrations check complete`);
};

View File

@ -1,9 +1,9 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { FastifyRequest } from 'fastify';
import { config } from '../config';
import { UserId } from './auth/auth.decorators';
import { OptionalJWTAuthGuard } from './auth/guards/optional-jwt.guard';
import { UserService } from './user/user.service';
import type { FastifyRequest } from 'fastify';
import { config } from '../config.js';
import { UserId } from './auth/auth.decorators.js';
import { OptionalJWTAuthGuard } from './auth/guards/optional-jwt.guard.js';
import { UserService } from './user/user.service.js';
@Controller()
export class AppController {

View File

@ -1,3 +1,4 @@
import MikroOrmOptions from '../orm.js';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
@ -5,16 +6,15 @@ import type { MercuriusDriverConfig } from '@nestjs/mercurius';
import { MercuriusDriver } from '@nestjs/mercurius';
import { PassportModule } from '@nestjs/passport';
import { ScheduleModule } from '@nestjs/schedule';
import MikroOrmOptions from '../orm';
import { AppResolver } from './app.resolver';
import { AuthModule } from './auth/auth.module';
import { FileModule } from './file/file.module';
import { HostModule } from './host/host.module';
import { InviteModule } from './invite/invite.module';
import { PasteModule } from './paste/paste.module';
import { StorageModule } from './storage/storage.module';
import { ThumbnailModule } from './thumbnail/thumbnail.module';
import { UserModule } from './user/user.module';
import { AppResolver } from './app.resolver.js';
import { AuthModule } from './auth/auth.module.js';
import { FileModule } from './file/file.module.js';
import { HostModule } from './host/host.module.js';
import { InviteModule } from './invite/invite.module.js';
import { PasteModule } from './paste/paste.module.js';
import { StorageModule } from './storage/storage.module.js';
import { ThumbnailModule } from './thumbnail/thumbnail.module.js';
import { UserModule } from './user/user.module.js';
@Module({
providers: [AppResolver],

View File

@ -1,12 +1,12 @@
import { UseGuards } from '@nestjs/common';
import { Query, Resolver } from '@nestjs/graphql';
import { MicroHost } from '../classes/MicroHost';
import { config } from '../config';
import type { ConfigHost } from '../types/config.type';
import { Config } from '../types/config.type';
import { CurrentHost, UserId } from './auth/auth.decorators';
import { OptionalJWTAuthGuard } from './auth/guards/optional-jwt.guard';
import { UserService } from './user/user.service';
import { MicroHost } from '../classes/MicroHost.js';
import { config } from '../config.js';
import type { ConfigHost } from '../types/config.type.js';
import { Config } from '../types/config.type.js';
import { CurrentHost, UserId } from './auth/auth.decorators.js';
import { OptionalJWTAuthGuard } from './auth/guards/optional-jwt.guard.js';
import { UserService } from './user/user.service.js';
@Resolver(() => Config)
export class AppResolver {
@ -28,6 +28,8 @@ export class AppResolver {
uploadLimit: config.uploadLimit,
allowTypes: config.allowTypes ? [...config.allowTypes?.values()] : [],
requireEmails: !!config.email,
rootHost: this.filterHost(config.rootHost),
currentHost: this.filterHost(currentHost),
hosts: config.hosts
.filter((host) => {
if (!host.tags || !host.tags[0]) return true;
@ -38,8 +40,6 @@ export class AppResolver {
normalised: host.normalised,
redirect: host.redirect,
})),
rootHost: this.filterHost(config.rootHost),
currentHost: this.filterHost(currentHost),
} as const;
}

View File

@ -1,8 +1,8 @@
import { applyDecorators, createParamDecorator, SetMetadata, UseGuards } from '@nestjs/common';
import type { Permission } from '../../constants';
import { getRequest } from '../../helpers/get-request';
import { JWTAuthGuard } from './guards/jwt.guard';
import { PermissionGuard } from './guards/permission.guard';
import type { Permission } from '../../constants.js';
import { getRequest } from '../../helpers/get-request.js';
import { JWTAuthGuard } from './guards/jwt.guard.js';
import { PermissionGuard } from './guards/permission.guard.js';
export const RequirePermissions = (...permissions: Permission[]) => {
let aggregate = 0;

View File

@ -1,14 +1,13 @@
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { config } from '../../config';
import { AuthService } from './auth.service';
import { JWTStrategy } from './strategies/jwt.strategy';
import { User } from '../user/user.entity';
import { AuthResolver } from './auth.resolver';
import { config } from '../../config.js';
import { User } from '../user/user.entity.js';
import { AuthResolver } from './auth.resolver.js';
import { AuthService } from './auth.service.js';
import { JWTStrategy } from './strategies/jwt.strategy.js';
@Module({
controllers: [],
providers: [AuthResolver, AuthService, JWTStrategy],
exports: [AuthService],
imports: [

View File

@ -4,13 +4,13 @@ import { UseGuards } from '@nestjs/common';
import { Args, Context, Mutation, Resolver } from '@nestjs/graphql';
import type { FastifyReply } from 'fastify';
import ms from 'ms';
import { config } from '../../config';
import { User } from '../user/user.entity';
import { UserId } from './auth.decorators';
import { AuthService, TokenType } from './auth.service';
import { OTPEnabledDto } from './dto/otp-enabled.dto';
import { JWTAuthGuard } from './guards/jwt.guard';
import type { JWTPayloadUser } from './strategies/jwt.strategy';
import { config } from '../../config.js';
import { User } from '../user/user.entity.js';
import { UserId } from './auth.decorators.js';
import { AuthService, TokenType } from './auth.service.js';
import { OTPEnabledDto } from './dto/otp-enabled.dto.js';
import { JWTAuthGuard } from './guards/jwt.guard.js';
import type { JWTPayloadUser } from './strategies/jwt.strategy.js';
@Resolver(() => User)
export class AuthResolver {

View File

@ -1,7 +1,7 @@
import type { ExecutionContext } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { getRequest } from '../../../helpers/get-request';
import { getRequest } from '../../../helpers/get-request.js';
@Injectable()
export class JWTAuthGuard extends AuthGuard('jwt') {

View File

@ -1,6 +1,6 @@
import type { ExecutionContext } from '@nestjs/common';
import { UnauthorizedException } from '@nestjs/common';
import { JWTAuthGuard } from './jwt.guard';
import { JWTAuthGuard } from './jwt.guard.js';
export class OptionalJWTAuthGuard extends JWTAuthGuard {
async canActivate(context: ExecutionContext) {

View File

@ -1,9 +1,9 @@
import type { CanActivate, ExecutionContext } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Permission } from '../../../constants';
import { getRequest } from '../../../helpers/get-request';
import { UserService } from '../../user/user.service';
import { Permission } from '../../../constants.js';
import { getRequest } from '../../../helpers/get-request.js';
import { UserService } from '../../user/user.service.js';
@Injectable()
export class PermissionGuard implements CanActivate {

View File

@ -4,9 +4,9 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import type { FastifyRequest } from 'fastify';
import { Strategy } from 'passport-jwt';
import { config } from '../../../config';
import { User } from '../../user/user.entity';
import { TokenType } from '../auth.service';
import { config } from '../../../config.js';
import { User } from '../../user/user.entity.js';
import { TokenType } from '../auth.service.js';
export interface JWTPayloadUser {
id: string;

View File

@ -14,15 +14,15 @@ import {
Res,
UseGuards,
} from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { config } from '../../config';
import { UserId } from '../auth/auth.decorators';
import { JWTAuthGuard } from '../auth/guards/jwt.guard';
import { HostService } from '../host/host.service';
import { LinkService } from '../link/link.service';
import { Paste } from '../paste/paste.entity';
import { UserService } from '../user/user.service';
import { FileService } from './file.service';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { config } from '../../config.js';
import { UserId } from '../auth/auth.decorators.js';
import { JWTAuthGuard } from '../auth/guards/jwt.guard.js';
import { HostService } from '../host/host.service.js';
import { LinkService } from '../link/link.service.js';
import { Paste } from '../paste/paste.entity.js';
import { UserService } from '../user/user.service.js';
import { FileService } from './file.service.js';
@Controller()
export class FileController {

View File

@ -1,7 +1,6 @@
import {
Embedded,
Entity,
IdentifiedReference,
Index,
LoadStrategy,
ManyToOne,
@ -9,17 +8,18 @@ import {
OptionalProps,
PrimaryKey,
Property,
type IdentifiedReference,
} from '@mikro-orm/core';
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { checkThumbnailSupport } from '@ryanke/thumbnail-generator';
import { Exclude } from 'class-transformer';
import mimeType from 'mime-types';
import { generateDeleteKey } from '../../helpers/generate-delete-key.helper';
import { Resource } from '../../helpers/resource.entity-base';
import { Paginated } from '../../types/paginated.type';
import { Thumbnail } from '../thumbnail/thumbnail.entity';
import { User } from '../user/user.entity';
import { FileMetadata } from './file-metadata.embeddable';
import { generateDeleteKey } from '../../helpers/generate-delete-key.helper.js';
import { Resource } from '../../helpers/resource.entity-base.js';
import { Paginated } from '../../types/paginated.type.js';
import { Thumbnail } from '../thumbnail/thumbnail.entity.js';
import { ThumbnailService } from '../thumbnail/thumbnail.service.js';
import { User } from '../user/user.entity.js';
import { FileMetadata } from './file-metadata.embeddable.js';
@Entity({ tableName: 'files' })
@ObjectType()
@ -78,7 +78,7 @@ export class File extends Resource {
const prefix = this.type.startsWith('video') ? '/v' : this.type.startsWith('image') ? '/i' : '/f';
const viewPath = `${prefix}/${this.id}`;
const directPath = `${prefix}/${this.id}.${extension}`;
const thumbnailUrl = checkThumbnailSupport(this.type) ? `/t/${this.id}` : undefined;
const thumbnailUrl = ThumbnailService.checkSupport(this.type) ? `/t/${this.id}` : undefined;
const deletePath = this.deleteKey ? `${prefix}/${this.id}?deleteKey=${this.deleteKey}` : undefined;
return {

View File

@ -1,14 +1,14 @@
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { HostModule } from '../host/host.module';
import { LinkModule } from '../link/link.module';
import { Paste } from '../paste/paste.entity';
import { StorageModule } from '../storage/storage.module';
import { UserModule } from '../user/user.module';
import { FileController } from './file.controller';
import { File } from './file.entity';
import { FileResolver } from './file.resolver';
import { FileService } from './file.service';
import { HostModule } from '../host/host.module.js';
import { LinkModule } from '../link/link.module.js';
import { Paste } from '../paste/paste.entity.js';
import { StorageModule } from '../storage/storage.module.js';
import { UserModule } from '../user/user.module.js';
import { FileController } from './file.controller.js';
import { File } from './file.entity.js';
import { FileResolver } from './file.resolver.js';
import { FileService } from './file.service.js';
@Module({
imports: [StorageModule, HostModule, UserModule, LinkModule, MikroOrmModule.forFeature([File, Paste])],

View File

@ -4,10 +4,10 @@ import { EntityRepository } from '@mikro-orm/postgresql';
import { ForbiddenException, UseGuards } from '@nestjs/common';
import { Args, ID, Mutation, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import prettyBytes from 'pretty-bytes';
import { ResourceLocations } from '../../types/resource-locations.type';
import { UserId } from '../auth/auth.decorators';
import { OptionalJWTAuthGuard } from '../auth/guards/optional-jwt.guard';
import { File } from './file.entity';
import { ResourceLocations } from '../../types/resource-locations.type.js';
import { UserId } from '../auth/auth.decorators.js';
import { OptionalJWTAuthGuard } from '../auth/guards/optional-jwt.guard.js';
import { File } from './file.entity.js';
@Resolver(() => File)
export class FileResolver {

View File

@ -1,10 +1,11 @@
/* eslint-disable sonarjs/no-duplicate-string */
import type { Multipart } from '@fastify/multipart';
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 contentRange from 'content-range';
import type { FastifyReply, FastifyRequest } from 'fastify';
import ffmpeg from 'fluent-ffmpeg';
@ -12,24 +13,23 @@ import { DateTime } from 'luxon';
import mime from 'mime-types';
import sharp from 'sharp';
import { PassThrough } from 'stream';
import xbytes from 'xbytes';
import type { MicroHost } from '../../classes/MicroHost';
import { config } from '../../config';
import { generateContentId } from '../../helpers/generate-content-id.helper';
import { getStreamType } from '../../helpers/get-stream-type.helper';
import { HostService } from '../host/host.service';
import { StorageService } from '../storage/storage.service';
import type { User } from '../user/user.entity';
import { File } from './file.entity';
import type { MicroHost } from '../../classes/MicroHost.js';
import { config } 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>,
@InjectRepository('File') private readonly fileRepo: EntityRepository<File>,
private readonly storageService: StorageService,
private readonly hostService: HostService,
private readonly orm: MikroORM
protected readonly orm: MikroORM
) {}
async getFile(id: string, request: FastifyRequest) {
@ -42,7 +42,7 @@ export class FileService implements OnApplicationBootstrap {
}
async createFile(
multipart: Multipart,
multipart: MultipartFile,
request: FastifyRequest,
owner: User,
host: MicroHost | undefined
@ -51,7 +51,7 @@ export class FileService implements OnApplicationBootstrap {
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 = xbytes(Number(request.headers['content-length']));
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.`
);
@ -69,7 +69,7 @@ export class FileService implements OnApplicationBootstrap {
const conversion = config.conversions?.find((conversion) => {
if (!conversion.from.has(fileType)) return false;
if (conversion.minSize && contentLength < conversion.minSize) return false;
if (conversion.minSize && contentLength < conversion.minSize) return false;
if (conversion.to === fileType) return false; // dont convert to the same type
return true;
});
@ -175,7 +175,6 @@ export class FileService implements OnApplicationBootstrap {
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,
@ -186,7 +185,7 @@ export class FileService implements OnApplicationBootstrap {
});
for (const file of files) {
const size = xbytes(file.size, { space: false });
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})`);
@ -199,7 +198,7 @@ export class FileService implements OnApplicationBootstrap {
onApplicationBootstrap() {
if (config.purge) {
const size = xbytes(config.purge.overLimit, { space: false });
const size = bytes.format(config.purge.overLimit);
// 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}.`);

View File

@ -1,8 +1,8 @@
import type { EventArgs, EventSubscriber } from '@mikro-orm/core';
import { Subscriber } from '@mikro-orm/core';
import { Injectable, Logger } from '@nestjs/common';
import { StorageService } from '../storage/storage.service';
import { File } from './file.entity';
import { StorageService } from '../storage/storage.service.js';
import { File } from './file.entity.js';
@Injectable()
@Subscriber()

View File

@ -1,7 +1,7 @@
import type { CanActivate, ExecutionContext } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { config } from '../../config';
import { getRequest } from '../../helpers/get-request';
import { config } from '../../config.js';
import { getRequest } from '../../helpers/get-request.js';
@Injectable()
export class HostGuard implements CanActivate {

View File

@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { UserModule } from '../user/user.module';
import { HostService } from './host.service';
import { UserModule } from '../user/user.module.js';
import { HostService } from './host.service.js';
@Module({
imports: [UserModule],

View File

@ -1,9 +1,9 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import normalizeUrl from 'normalize-url';
import type { MicroHost } from '../../classes/MicroHost';
import { config } from '../../config';
import { randomItem } from '../../helpers/random-item.helper';
import type { User } from '../user/user.entity';
import type { MicroHost } from '../../classes/MicroHost.js';
import { config } from '../../config.js';
import { randomItem } from '../../helpers/random-item.helper.js';
import type { User } from '../user/user.entity.js';
export class HostService {
formatHostUrl(url: string, username: string, path?: string | null) {

View File

@ -1,8 +1,8 @@
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
import { Permission } from '../../constants';
import { RequirePermissions, UserId } from '../auth/auth.decorators';
import { JWTAuthGuard } from '../auth/guards/jwt.guard';
import { InviteService } from './invite.service';
import { Permission } from '../../constants.js';
import { RequirePermissions, UserId } from '../auth/auth.decorators.js';
import { JWTAuthGuard } from '../auth/guards/jwt.guard.js';
import { InviteService } from './invite.service.js';
@Controller()
export class InviteController {

View File

@ -1,8 +1,8 @@
import { Entity, ManyToOne, OneToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { config } from '../../config';
import { generateDeleteKey } from '../../helpers/generate-delete-key.helper';
import { User } from '../user/user.entity';
import { config } from '../../config.js';
import { generateDeleteKey } from '../../helpers/generate-delete-key.helper.js';
import { User } from '../user/user.entity.js';
@Entity({ tableName: 'invites' })
@ObjectType()

View File

@ -1,12 +1,12 @@
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { forwardRef, Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { User } from '../user/user.entity';
import { UserModule } from '../user/user.module';
import { InviteController } from './invite.controller';
import { Invite } from './invite.entity';
import { InviteResolver } from './invite.resolver';
import { InviteService } from './invite.service';
import { AuthModule } from '../auth/auth.module.js';
import { User } from '../user/user.entity.js';
import { UserModule } from '../user/user.module.js';
import { InviteController } from './invite.controller.js';
import { Invite } from './invite.entity.js';
import { InviteResolver } from './invite.resolver.js';
import { InviteService } from './invite.service.js';
@Module({
controllers: [InviteController],

View File

@ -2,10 +2,10 @@ import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { UseGuards } from '@nestjs/common';
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Permission } from '../../constants';
import { RequirePermissions, UserId } from '../auth/auth.decorators';
import { JWTAuthGuard } from '../auth/guards/jwt.guard';
import { Invite } from './invite.entity';
import { Permission } from '../../constants.js';
import { RequirePermissions, UserId } from '../auth/auth.decorators.js';
import { JWTAuthGuard } from '../auth/guards/jwt.guard.js';
import { Invite } from './invite.entity.js';
@Resolver(() => Invite)
export class InviteResolver {

View File

@ -2,9 +2,9 @@ import { EntityRepository, MikroORM, UseRequestContext } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { OnApplicationBootstrap } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { Permission } from '../../constants';
import { User } from '../user/user.entity';
import { Invite } from './invite.entity';
import { Permission } from '../../constants.js';
import { User } from '../user/user.entity.js';
import { Invite } from './invite.entity.js';
export interface JWTPayloadInvite {
id: string;
@ -23,8 +23,8 @@ export class InviteService implements OnApplicationBootstrap {
async create(inviterId: string | null, permissions: Permission | null) {
const invite = this.inviteRepo.create({
inviter: inviterId,
permissions: permissions,
inviter: inviterId || undefined,
permissions: permissions || undefined,
});
await this.inviteRepo.persistAndFlush(invite);

View File

@ -1,9 +1,9 @@
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { Controller, Get, Param, Request, Res } from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { Link } from './link.entity';
import { LinkService } from './link.service';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Link } from './link.entity.js';
import { LinkService } from './link.service.js';
@Controller()
export class LinkController {

View File

@ -1,9 +1,9 @@
import { Entity, IdentifiedReference, ManyToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
import { Entity, type IdentifiedReference, ManyToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { Exclude } from 'class-transformer';
import { generateContentId } from '../../helpers/generate-content-id.helper';
import { User } from '../user/user.entity';
import { Resource } from '../../helpers/resource.entity-base';
import { generateContentId } from '../../helpers/generate-content-id.helper.js';
import { User } from '../user/user.entity.js';
import { Resource } from '../../helpers/resource.entity-base.js';
@Entity({ tableName: 'links' })
@ObjectType()

View File

@ -1,11 +1,11 @@
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { HostModule } from '../host/host.module';
import { UserModule } from '../user/user.module';
import { LinkController } from './link.controller';
import { Link } from './link.entity';
import { LinkResolver } from './link.resolver';
import { LinkService } from './link.service';
import { HostModule } from '../host/host.module.js';
import { UserModule } from '../user/user.module.js';
import { LinkController } from './link.controller.js';
import { Link } from './link.entity.js';
import { LinkResolver } from './link.resolver.js';
import { LinkService } from './link.service.js';
@Module({
imports: [LinkModule, UserModule, HostModule, MikroOrmModule.forFeature([Link])],

View File

@ -2,12 +2,12 @@ import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { UseGuards } from '@nestjs/common';
import { Args, ID, Mutation, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { ResourceLocations } from '../../types/resource-locations.type';
import { UserId } from '../auth/auth.decorators';
import { JWTAuthGuard } from '../auth/guards/jwt.guard';
import { HostService } from '../host/host.service';
import { UserService } from '../user/user.service';
import { Link } from './link.entity';
import { ResourceLocations } from '../../types/resource-locations.type.js';
import { UserId } from '../auth/auth.decorators.js';
import { JWTAuthGuard } from '../auth/guards/jwt.guard.js';
import { HostService } from '../host/host.service.js';
import { UserService } from '../user/user.service.js';
import { Link } from './link.entity.js';
@Resolver(() => Link)
export class LinkResolver {

View File

@ -2,8 +2,8 @@ import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import type { MicroHost } from '../../classes/MicroHost';
import { Link } from './link.entity';
import type { MicroHost } from '../../classes/MicroHost.js';
import { Link } from './link.entity.js';
@Injectable()
export class LinkService {

View File

@ -1,6 +1,6 @@
import { Controller, Get, Param, Req } from '@nestjs/common';
import { FastifyRequest } from 'fastify';
import { PasteService } from './paste.service';
import type { FastifyRequest } from 'fastify';
import { PasteService } from './paste.service.js';
@Controller('paste')
export class PasteController {

View File

@ -1,13 +1,13 @@
import { Entity, IdentifiedReference, ManyToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
import { Entity, type IdentifiedReference, ManyToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
import { Field, ID, InputType, ObjectType } from '@nestjs/graphql';
import { Exclude } from 'class-transformer';
import { IsBoolean, IsNumber, IsOptional, IsString, Length } from 'class-validator';
import mime from 'mime-types';
import { config } from '../../config';
import { generateContentId } from '../../helpers/generate-content-id.helper';
import { Resource } from '../../helpers/resource.entity-base';
import { Paginated } from '../../types/paginated.type';
import { User } from '../user/user.entity';
import { config } from '../../config.js';
import { generateContentId } from '../../helpers/generate-content-id.helper.js';
import { Resource } from '../../helpers/resource.entity-base.js';
import { Paginated } from '../../types/paginated.type.js';
import { User } from '../user/user.entity.js';
@Entity({ tableName: 'pastes' })
@ObjectType({ isAbstract: true })

View File

@ -1,11 +1,11 @@
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { HostModule } from '../host/host.module';
import { UserModule } from '../user/user.module';
import { PasteController } from './paste.controller';
import { Paste } from './paste.entity';
import { PasteResolver } from './paste.resolver';
import { PasteService } from './paste.service';
import { HostModule } from '../host/host.module.js';
import { UserModule } from '../user/user.module.js';
import { PasteController } from './paste.controller.js';
import { Paste } from './paste.entity.js';
import { PasteResolver } from './paste.resolver.js';
import { PasteService } from './paste.service.js';
@Module({
imports: [MikroOrmModule.forFeature([Paste]), HostModule, UserModule],

View File

@ -3,14 +3,14 @@ import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { BadRequestException, UseGuards } from '@nestjs/common';
import { Args, ID, Mutation, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { generateContentId, generateParanoidId } from '../../helpers/generate-content-id.helper';
import { ResourceLocations } from '../../types/resource-locations.type';
import { UserId } from '../auth/auth.decorators';
import { JWTAuthGuard } from '../auth/guards/jwt.guard';
import { OptionalJWTAuthGuard } from '../auth/guards/optional-jwt.guard';
import { HostService } from '../host/host.service';
import { UserService } from '../user/user.service';
import { CreatePasteDto, Paste } from './paste.entity';
import { generateContentId, generateParanoidId } from '../../helpers/generate-content-id.helper.js';
import { ResourceLocations } from '../../types/resource-locations.type.js';
import { UserId } from '../auth/auth.decorators.js';
import { JWTAuthGuard } from '../auth/guards/jwt.guard.js';
import { OptionalJWTAuthGuard } from '../auth/guards/optional-jwt.guard.js';
import { HostService } from '../host/host.service.js';
import { UserService } from '../user/user.service.js';
import { CreatePasteDto, Paste } from './paste.entity.js';
@Resolver(() => Paste)
export class PasteResolver {

View File

@ -3,7 +3,7 @@ import { InjectRepository } from '@mikro-orm/nestjs';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import type { FastifyRequest } from 'fastify';
import { Paste } from './paste.entity';
import { Paste } from './paste.entity.js';
@Injectable()
export class PasteService {

View File

@ -1,5 +1,5 @@
import { Module } from '@nestjs/common';
import { StorageService } from './storage.service';
import { StorageService } from './storage.service.js';
@Module({
providers: [StorageService],

View File

@ -3,13 +3,10 @@ import crypto from 'crypto';
import fs from 'fs';
import { nanoid } from 'nanoid';
import path from 'path';
import stream from 'stream';
import getSizeTransform from 'stream-size';
import { promisify } from 'util';
import { ExifTransformer } from '../../classes/ExifTransformer';
import { config } from '../../config';
const pipeline = promisify(stream.pipeline);
import { ExifTransformer } from 'src/classes/ExifTransformer.js';
import { default as getSizeTransform } from 'stream-size';
import { pipeline } from 'stream/promises';
import { config } from '../../config.js';
@Injectable()
export class StorageService {
@ -28,7 +25,7 @@ export class StorageService {
try {
const hashStream = crypto.createHash('sha256');
const exifTransform = new ExifTransformer();
const sizeTransform = getSizeTransform(config.uploadLimit);
const sizeTransform = getSizeTransform.default(config.uploadLimit);
const writeStream = fs.createWriteStream(uploadPath);
await Promise.all([
// prettier-ignore

View File

@ -1,6 +1,6 @@
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { ThumbnailService } from './thumbnail.service';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { ThumbnailService } from './thumbnail.service.js';
@Controller()
export class ThumbnailController {

View File

@ -1,6 +1,6 @@
import { BlobType, Entity, OneToOne, OptionalProps, PrimaryKeyType, Property } from '@mikro-orm/core';
import { Field, ObjectType } from '@nestjs/graphql';
import { File } from '../file/file.entity';
import { File } from '../file/file.entity.js';
@Entity({ tableName: 'thumbnails' })
@ObjectType()

View File

@ -1,11 +1,11 @@
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { File } from '../file/file.entity';
import { FileModule } from '../file/file.module';
import { StorageModule } from '../storage/storage.module';
import { ThumbnailController } from './thumbnail.controller';
import { Thumbnail } from './thumbnail.entity';
import { ThumbnailService } from './thumbnail.service';
import { File } from '../file/file.entity.js';
import { FileModule } from '../file/file.module.js';
import { StorageModule } from '../storage/storage.module.js';
import { ThumbnailController } from './thumbnail.controller.js';
import { Thumbnail } from './thumbnail.entity.js';
import { ThumbnailService } from './thumbnail.service.js';
@Module({
imports: [StorageModule, FileModule, MikroOrmModule.forFeature([Thumbnail, File])],

View File

@ -1,23 +1,46 @@
import { EntityRepository } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import { BadRequestException, Injectable } from '@nestjs/common';
import { checkThumbnailSupport, generateThumbnailToStream } from '@ryanke/thumbnail-generator';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { once } from 'events';
import type { FastifyReply, FastifyRequest } from 'fastify';
import getStream from 'get-stream';
import ffmpeg from 'fluent-ffmpeg';
import { readdir, readFile, rm, stat } from 'fs/promises';
import { DateTime } from 'luxon';
import mime from 'mime-types';
import { tmpdir } from 'os';
import { join } from 'path';
import sharp from 'sharp';
import { File } from '../file/file.entity';
import { FileService } from '../file/file.service';
import { StorageService } from '../storage/storage.service';
import { Thumbnail } from './thumbnail.entity';
import type { File } from '../file/file.entity.js';
import { FileService } from '../file/file.service.js';
import { StorageService } from '../storage/storage.service.js';
import type { Thumbnail } from './thumbnail.entity.js';
@Injectable()
export class ThumbnailService {
private static readonly THUMBNAIL_SIZE = 200;
private static readonly THUMBNAIL_TYPE = 'image/webp';
private static readonly IMAGE_TYPES = new Set(
Object.keys(sharp.format)
.map((key) => mime.lookup(key))
.filter((key) => key && key.startsWith('image'))
);
private static readonly VIDEO_TYPES = new Set([
'video/mp4',
'video/webm',
'video/ogg',
'video/x-matroska',
'video/x-ms-wmv',
'video/x-m4v',
'video/x-flv',
]);
private readonly log = new Logger(ThumbnailService.name);
constructor(
@InjectRepository(Thumbnail) private readonly thumbnailRepo: EntityRepository<Thumbnail>,
@InjectRepository(File) private readonly fileRepo: EntityRepository<File>,
@InjectRepository('Thumbnail') private readonly thumbnailRepo: EntityRepository<Thumbnail>,
@InjectRepository('File') private readonly fileRepo: EntityRepository<File>,
private readonly storageService: StorageService,
private readonly fileService: FileService
) {}
@ -28,25 +51,28 @@ export class ThumbnailService {
async createThumbnail(file: File) {
const start = Date.now();
const supported = checkThumbnailSupport(file.type);
if (!supported) {
let data: Buffer;
if (ThumbnailService.IMAGE_TYPES.has(file.type)) {
data = await this.createImageThumbnail(file);
} else if (ThumbnailService.VIDEO_TYPES.has(file.type)) {
data = await this.createVideoThumbnail(file);
} else {
throw new BadRequestException('That file type does not support thumbnails.');
}
const filePath = this.storageService.getPathFromHash(file.hash);
const thumbnailStream = await generateThumbnailToStream(file.type, filePath, {
type: ThumbnailService.THUMBNAIL_TYPE,
size: { height: ThumbnailService.THUMBNAIL_SIZE },
});
const data = await getStream.buffer(thumbnailStream);
// todo: fileMetadata should be added elsewhere or maybe generated by the thumbnail generator
const fileMetadata = await sharp(filePath).metadata();
file.metadata = { height: fileMetadata.height, width: fileMetadata.width };
// todo: thumbnailMetadata should be generated by the thumbnail generator
// todo: ideally we could extract both file and thumbnail metadata during
// thumbnail generation, saving two additional file reads.
const thumbnailMetadata = await sharp(data).metadata();
if (ThumbnailService.IMAGE_TYPES.has(file.type)) {
// todo: this should probably be done elsewhere, this is just a convenient
// time to grab this data.
const filePath = this.storageService.getPathFromHash(file.hash);
const fileMetadata = await sharp(filePath).metadata();
file.metadata = { height: fileMetadata.height, width: fileMetadata.width };
}
const duration = Date.now() - start;
const thumbnail = this.thumbnailRepo.create({
data: data,
@ -64,6 +90,66 @@ export class ThumbnailService {
return thumbnail;
}
private async createImageThumbnail(file: File) {
const supported = ThumbnailService.IMAGE_TYPES.has(file.type);
if (!supported) throw new Error('Unsupported image type.');
this.log.debug(`Generating thumbnail for ${file.id} (${file.type})`);
const filePath = this.storageService.getPathFromHash(file.hash);
return sharp(filePath).resize(ThumbnailService.THUMBNAIL_SIZE).toFormat('webp').toBuffer();
}
private async createVideoThumbnail(file: File) {
const supported = ThumbnailService.VIDEO_TYPES.has(file.type);
if (!supported) throw new Error('Unsupported video type.');
const tempId = randomUUID();
const tempDir = join(tmpdir(), `.thumbnail-workspace-${tempId}`);
const filePath = this.storageService.getPathFromHash(file.hash);
this.log.debug(`Generating video thumbnail at "${tempDir}"`);
// i have no clue why but the internet told me that doing it in multiple invocations is faster
// and it is so whatever. maybe there is a way to do this faster, but this is already pretty fast.
const positions = ['5%', '10%', '20%', '40%'];
const size = `${ThumbnailService.THUMBNAIL_SIZE}x?`;
for (let positionIndex = 0; positionIndex < positions.length; positionIndex++) {
const percent = positions[positionIndex];
const stream = ffmpeg(filePath).screenshot({
count: 1,
timemarks: [percent],
folder: tempDir,
size: size,
fastSeek: true,
filename: `%b-${positionIndex + 1}.webp`,
});
await once(stream, 'end');
}
const files = await readdir(tempDir);
let largest: { size: number; path: string } | undefined;
for (const file of files) {
const path = join(tempDir, file);
const stats = await stat(path);
if (!largest || stats.size > largest.size) {
largest = { size: stats.size, path };
}
}
if (!largest) {
await rm(tempDir, { recursive: true, force: true });
throw new Error('No thumbnails were generated');
}
this.log.debug(`Largest thumbnail is at "${largest.path}", ${largest.size} bytes`);
const content = await readFile(largest.path);
await rm(tempDir, { recursive: true, force: true });
return content;
}
static checkSupport(fileType: string) {
return ThumbnailService.IMAGE_TYPES.has(fileType) || ThumbnailService.VIDEO_TYPES.has(fileType);
}
async sendThumbnail(fileId: string, request: FastifyRequest, reply: FastifyReply) {
const existing = await this.thumbnailRepo.findOne(fileId, { populate: ['data'] });
if (existing) {

View File

@ -1,6 +1,6 @@
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
import { randomBytes } from 'crypto';
import { User } from './user.entity';
import { User } from './user.entity.js';
@Entity({ tableName: 'users_verification' })
export class UserVerification {

View File

@ -1,6 +1,6 @@
import { Controller, Get, Param, Response } from '@nestjs/common';
import { FastifyReply } from 'fastify';
import { UserService } from './user.service';
import type { FastifyReply } from 'fastify';
import { UserService } from './user.service.js';
@Controller()
export class UserController {

View File

@ -11,10 +11,10 @@ import {
} from '@mikro-orm/core';
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { Exclude } from 'class-transformer';
import { generateContentId } from '../../helpers/generate-content-id.helper';
import { File } from '../file/file.entity';
import { Invite } from '../invite/invite.entity';
import { UserVerification } from './user-verification.entity';
import { generateContentId } from '../../helpers/generate-content-id.helper.js';
import { File } from '../file/file.entity.js';
import { Invite } from '../invite/invite.entity.js';
import { UserVerification } from './user-verification.entity.js';
@Entity({ tableName: 'users' })
@ObjectType()

View File

@ -1,15 +1,15 @@
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { forwardRef, Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { File } from '../file/file.entity';
import { FileModule } from '../file/file.module';
import { InviteModule } from '../invite/invite.module';
import { Paste } from '../paste/paste.entity';
import { UserVerification } from './user-verification.entity';
import { UserController } from './user.controller';
import { User } from './user.entity';
import { UserResolver } from './user.resolver';
import { UserService } from './user.service';
import { AuthModule } from '../auth/auth.module.js';
import { File } from '../file/file.entity.js';
import { FileModule } from '../file/file.module.js';
import { InviteModule } from '../invite/invite.module.js';
import { Paste } from '../paste/paste.entity.js';
import { UserVerification } from './user-verification.entity.js';
import { UserController } from './user.controller.js';
import { User } from './user.entity.js';
import { UserResolver } from './user.resolver.js';
import { UserService } from './user.service.js';
@Module({
imports: [

View File

@ -5,19 +5,19 @@ import { BadRequestException, UnauthorizedException, UseGuards } from '@nestjs/c
import { Args, Mutation, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import ms from 'ms';
import { nanoid } from 'nanoid';
import { paginate, parseCursor } from '../../helpers/pagination';
import { UserId } from '../auth/auth.decorators';
import { AuthService, TokenType } from '../auth/auth.service';
import { JWTAuthGuard } from '../auth/guards/jwt.guard';
import type { JWTPayloadUser } from '../auth/strategies/jwt.strategy';
import { File, FilePage } from '../file/file.entity';
import { InviteService } from '../invite/invite.service';
import { Paste, PastePage } from '../paste/paste.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { ResendVerificationEmailDto } from './dto/resend-verification-email.dto';
import { UserVerification } from './user-verification.entity';
import { User } from './user.entity';
import { UserService } from './user.service';
import { paginate, parseCursor } from '../../helpers/pagination.js';
import { UserId } from '../auth/auth.decorators.js';
import { AuthService, TokenType } from '../auth/auth.service.js';
import { JWTAuthGuard } from '../auth/guards/jwt.guard.js';
import type { JWTPayloadUser } from '../auth/strategies/jwt.strategy.js';
import { File, FilePage } from '../file/file.entity.js';
import { InviteService } from '../invite/invite.service.js';
import { Paste, PastePage } from '../paste/paste.entity.js';
import { CreateUserDto } from './dto/create-user.dto.js';
import { ResendVerificationEmailDto } from './dto/resend-verification-email.dto.js';
import { UserVerification } from './user-verification.entity.js';
import { User } from './user.entity.js';
import { UserService } from './user.service.js';
@Resolver(() => User)
export class UserResolver {

View File

@ -4,21 +4,21 @@ import { BadRequestException, ConflictException, ForbiddenException, Injectable
import { Cron, CronExpression } from '@nestjs/schedule';
import bcrypt from 'bcryptjs';
import dedent from 'dedent';
import { compile } from 'handlebars';
import handlebars from 'handlebars';
import ms from 'ms';
import { nanoid } from 'nanoid';
import { config } from '../../config';
import type { Permission } from '../../constants';
import { generateContentId } from '../../helpers/generate-content-id.helper';
import { sendMail } from '../../helpers/send-mail.helper';
import { File } from '../file/file.entity';
import type { Invite } from '../invite/invite.entity';
import { InviteService } from '../invite/invite.service';
import { Paste } from '../paste/paste.entity';
import type { CreateUserDto } from './dto/create-user.dto';
import type { Pagination } from './dto/pagination.dto';
import { UserVerification } from './user-verification.entity';
import { User } from './user.entity';
import { config } from '../../config.js';
import type { Permission } from '../../constants.js';
import { generateContentId } from '../../helpers/generate-content-id.helper.js';
import { sendMail } from '../../helpers/send-mail.helper.js';
import { File } from '../file/file.entity.js';
import type { Invite } from '../invite/invite.entity.js';
import { InviteService } from '../invite/invite.service.js';
import { Paste } from '../paste/paste.entity.js';
import type { CreateUserDto } from './dto/create-user.dto.js';
import type { Pagination } from './dto/pagination.dto.js';
import { UserVerification } from './user-verification.entity.js';
import { User } from './user.entity.js';
const EMAIL_TEMPLATE_SOURCE = dedent`
<body>
@ -31,7 +31,7 @@ const EMAIL_TEMPLATE_SOURCE = dedent`
@Injectable()
export class UserService {
private static readonly VERIFICATION_EXPIRY = ms('6 hours');
private static readonly EMAIL_TEMPLATE = compile<{ verifyUrl: string }>(EMAIL_TEMPLATE_SOURCE);
private static readonly EMAIL_TEMPLATE = handlebars.compile<{ verifyUrl: string }>(EMAIL_TEMPLATE_SOURCE);
constructor(
@InjectRepository(User) private readonly userRepo: EntityRepository<User>,
@ -134,6 +134,7 @@ export class UserService {
username: data.username,
invite: invite.id,
permissions: invite.permissions ?? 0,
otpEnabled: false,
});
if (data.email) {

View File

@ -3,16 +3,17 @@
import { LoadStrategy } from '@mikro-orm/core';
import type { MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs';
import { Logger, NotFoundException } from '@nestjs/common';
import { join } from 'path';
import { config } from './config';
import { FileMetadata } from './modules/file/file-metadata.embeddable';
import { File } from './modules/file/file.entity';
import { Invite } from './modules/invite/invite.entity';
import { Link } from './modules/link/link.entity';
import { Paste } from './modules/paste/paste.entity';
import { Thumbnail } from './modules/thumbnail/thumbnail.entity';
import { UserVerification } from './modules/user/user-verification.entity';
import { User } from './modules/user/user.entity';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { config } from './config.js';
import { FileMetadata } from './modules/file/file-metadata.embeddable.js';
import { File } from './modules/file/file.entity.js';
import { Invite } from './modules/invite/invite.entity.js';
import { Link } from './modules/link/link.entity.js';
import { Paste } from './modules/paste/paste.entity.js';
import { Thumbnail } from './modules/thumbnail/thumbnail.entity.js';
import { UserVerification } from './modules/user/user-verification.entity.js';
import { User } from './modules/user/user.entity.js';
export const ormLogger = new Logger('MikroORM');
export const migrationsTableName = 'mikro_orm_migrations';
@ -30,7 +31,7 @@ export default {
throw new NotFoundException();
},
migrations: {
path: join(__dirname, 'migrations'),
path: join(dirname(fileURLToPath(import.meta.url)), 'migrations'),
tableName: migrationsTableName,
},
} as MikroOrmModuleSyncOptions;

View File

@ -1,7 +1,7 @@
/* eslint-disable unicorn/no-keyword-prefix */
import type { Type } from '@nestjs/common';
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Edge } from './edge.type';
import { Edge } from './edge.type.js';
export interface Paginated<T> {
totalCount: number;

View File

@ -1,13 +1,38 @@
{
"include": ["src"],
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
// https://www.npmjs.com/package/@tsconfig/node16
// https://github.com/sindresorhus/tsconfig/blob/main/tsconfig.json
"module": "node16",
"moduleResolution": "node16",
"moduleDetection": "force",
"target": "es2021", // node 16
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"declaration": true,
"pretty": true,
"newLine": "lf",
"stripInternal": true,
"strict": true,
"noImplicitOverride": false,
"esModuleInterop": true,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true,
"noEmitOnError": true,
"forceConsistentCasingInFileNames": true,
"importsNotUsedAsValues": "error",
"skipLibCheck": true,
"isolatedModules": true,
"outDir": "dist",
"noUncheckedIndexedAccess": false,
"strictPropertyInitialization": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true
},
"exclude": ["node_modules"],
"extends": "@sylo-digital/scripts/tsconfig/base.json",
"include": ["src", "src/blocklist.json", "src/fastify.d.ts"]
"experimentalDecorators": true,
"lib": ["es2021", "dom"],
"baseUrl": ".",
"paths": {
// https://github.com/bevry/istextorbinary/issues/270
"istextorbinary": ["node_modules/istextorbinary/compiled-types/index.d.ts"]
}
}
}

View File

@ -1 +0,0 @@
module.exports = require("@sylo-digital/scripts/eslint/base");

View File

@ -1,29 +0,0 @@
# @ryanke/thumbnail-generator
Generates thumbnails for videos and images. Should be fairly fast but of course not nearly as fast as some other options in other languages and so you should probably not use it for anything on-demand. Or maybe you should, who knows.
> At the moment video thumbnail generation from a stream is not possible, a path must be provided.
## usage
```ts
import {
generateThumbnailToStream,
generateVideoThumbnailToStream,
checkThumbnailSupport,
} from "@ryanke/thumbnail-generator";
// to handle images and videos in one, pass in the mime type with a path, stream or buffer
// in the ideal world you should always prefer a path, but when thats not possible streams and buffers are fine.
const stream = await generateThumbnailToStream("image/jpeg", inputStream);
const stream = await generateThumbnailToStream("video/mp4", "/path/to/video.mp4", {
size: { height: 200 },
});
// if you already know what you want, use a generator directly
const stream = await generateVideoThumbnailToStream(inputStream);
// passing in a mime type that is unsupported will throw an error, so make sure to check
// for support before attempting to generate a thumbnail.
checkThumbnailSupport("image/jpeg"); // true
```

View File

@ -1,36 +0,0 @@
{
"name": "@ryanke/thumbnail-generator",
"version": "0.0.1",
"repository": "https://github.com/sylv/micro",
"author": "Ryan <ryan@sylver.me>",
"private": true,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --sourcemap --format esm,cjs --target node16 --dts --splitting",
"test": "jest",
"lint": "eslint src --fix --cache",
"clean": "rm -rf ./dist"
},
"dependencies": {
"@sylo-digital/scripts": "^1.0.2",
"fluent-ffmpeg": "^2.1.2",
"mime-types": "^2.1.35",
"sharp": "^0.30.4"
},
"devDependencies": {
"@types/fluent-ffmpeg": "^2.1.20",
"@types/jest": "^28.1.4",
"@types/mime-types": "^2.1.1",
"@types/node": "^16.11.21",
"@types/sharp": "^0.30.2",
"eslint": "^8.19.0",
"jest": "^28.1.2",
"tsup": "^6.1.3",
"typescript": "^4.7.4"
},
"jest": {
"preset": "@sylo-digital/scripts/jest/node"
}
}

View File

@ -1,23 +0,0 @@
import mime from 'mime-types';
import sharp from 'sharp';
import { Readable } from 'stream';
import type { ThumbnailOptions } from './types';
export const IMAGE_TYPES = new Set(
Object.keys(sharp.format)
.map((key) => mime.lookup(key))
.filter((key) => key && key.startsWith('image'))
);
export async function imageThumbnailGenerator(input: string | Buffer | Readable, options?: ThumbnailOptions) {
const format = options?.type === 'image/jpeg' ? 'jpeg' : 'webp';
if (input instanceof Readable) {
const transformer = sharp().resize(options?.size?.width, options?.size?.height).toFormat(format);
const transformed = input.pipe(transformer);
return { stream: transformed };
}
return {
stream: sharp(input).resize(options?.size?.width, options?.size?.height).toFormat(format),
};
}

View File

@ -1,135 +0,0 @@
import { createReadStream, createWriteStream } from 'fs';
import { copyFile, rename } from 'fs/promises';
import type { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { imageThumbnailGenerator, IMAGE_TYPES } from './image-generator';
import { logger } from './logger';
import type { ThumbnailOptions } from './types';
import { videoThumbnailGenerator, VIDEO_TYPES } from './video-generator';
export * from './types';
/**
* Check if the given type is supported.
* @returns true if the type is supported, false otherwise.
*/
export function checkThumbnailSupport(type: string) {
return IMAGE_TYPES.has(type) || VIDEO_TYPES.has(type);
}
/**
* Generate a video thumbnail and save it to a file.
* @param input The input video, assumed to be an image with no additional checks.
*/
export async function generateVideoThumbnailToStream(input: string, options?: ThumbnailOptions): Promise<Readable> {
const output = await videoThumbnailGenerator(input, options);
const stream = createReadStream(output.path);
stream.on('end', () => {
output.cleanup().catch((error) => {
logger(`Failed cleaning up "${output.path}"`, error);
});
});
return stream;
}
/**
* Generate a video thumbnail and save it to a file
* @param input The input video, assumed to be a ffmpeg-compatible video with no additional checks
* @param dest The output path
*/
export async function generateVideoThumbnailToPath(
input: string,
dest: string,
options?: ThumbnailOptions
): Promise<void> {
const output = await videoThumbnailGenerator(input, options);
try {
logger(`Moving "${output.path}" to "${dest}"`);
await rename(output.path, dest);
} catch (error: any) {
// cross-device errors
if (error.code === 'EXDEV') {
logger(`Cross-device error moving "${output.path}" to "${dest}", falling back to copy`);
await copyFile(output.path, dest);
return;
}
throw error;
} finally {
output.cleanup().catch((error) => {
logger(`Failed cleaning up "${output.path}"`, error);
});
}
}
/**
* Generate an image thumbnail and return it as a stream
* @param input The input image, assumed to be an image with no additional checks.
* @param dest The output path
*/
export async function generateImageThumbnailToStream(
input: string | Buffer | Readable,
options?: ThumbnailOptions
): Promise<Readable> {
const output = await imageThumbnailGenerator(input, options);
return output.stream;
}
/**
* Generate an image thumbnail and save it to a file.
* @param input The input image, assumed to be an image with no additional checks.
* @param dest The output path
*/
export async function generateImageThumbnailToPath(
input: string | Buffer | Readable,
dest: string,
options?: ThumbnailOptions
): Promise<void> {
const output = await imageThumbnailGenerator(input, options);
const stream = createWriteStream(dest);
await pipeline(output.stream, stream);
}
/**
* Generate a thumbnail and save it to a file.
* @param inputType The type of the input, for example `image/jpeg` or `video/mp4`.
* @param dest The output path
* @throws if the given input type is not supported
*/
export async function generateThumbnailToPath(
inputType: string,
input: string,
dest: string,
options?: ThumbnailOptions
): Promise<void> {
if (IMAGE_TYPES.has(inputType)) {
return generateImageThumbnailToPath(input, dest, options);
} else if (VIDEO_TYPES.has(inputType)) {
return generateVideoThumbnailToPath(input, dest, options);
}
throw new Error(`Unsupported input type "${inputType}"`);
}
/**
* Generate a thumbnail and return it as a stream.
* @param inputType The type of the input, for example `image/jpeg` or `video/mp4`.
* @throws if the given input type is not supported
*/
export async function generateThumbnailToStream(
inputType: string,
input: string | Buffer | Readable,
options?: ThumbnailOptions
): Promise<Readable> {
if (IMAGE_TYPES.has(inputType)) {
return generateImageThumbnailToStream(input, options);
} else if (VIDEO_TYPES.has(inputType)) {
if (typeof input !== 'string') {
throw new TypeError('Video thumbnails can only be generated given a path.');
}
return generateVideoThumbnailToStream(input, options);
}
throw new Error(`Unsupported input type "${inputType}"`);
}

View File

@ -1,3 +0,0 @@
import { debuglog } from 'util';
export const logger = debuglog('thumbnail-generator');

View File

@ -1,13 +0,0 @@
export interface ThumbnailOptions {
/**
* The size of the thumbnail to generate
* Either value can be excluded to maintain aspect ratio.
*/
size?: { height?: number; width?: number };
/**
* Skip some improvements to improve performance.
* The most important one is the video generator generating only one thumbnail instead of multiple and picking the one with the most detail.
*/
fast?: boolean;
type?: 'image/webp' | 'image/jpeg';
}

View File

@ -1,72 +0,0 @@
import { randomUUID } from 'crypto';
import { once } from 'events';
import ffmpeg from 'fluent-ffmpeg';
import { readdir, rm, stat } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { logger } from './logger';
import type { ThumbnailOptions } from './types';
export const VIDEO_TYPES = new Set([
'video/mp4',
'video/webm',
'video/ogg',
'video/x-matroska',
'video/x-ms-wmv',
'video/x-m4v',
'video/x-flv',
]);
/**
* Generate a thumbnail for a video.
* Extracts the "best" thumbnail by extracting multiple jpeg thumbnails and using the largest.
*/
export async function videoThumbnailGenerator(filePath: string, options?: ThumbnailOptions) {
const tempId = randomUUID();
const tempDir = join(tmpdir(), `.thumbnail-workspace-${tempId}`);
logger(`Generating video thumbnail at "${tempDir}"`);
// i have no clue why but the internet told me that doing it in multiple invocations is faster
// and it is so whatever. maybe there is a way to do this faster, but this is already pretty fast.
const positions = options?.fast ? ['5%'] : ['5%', '10%', '20%', '40%'];
const size = options?.size ? `${options.size.width ?? '?'}x${options.size.height ?? '?'}` : '200x?';
const ext = options?.type === 'image/jpeg' ? 'jpg' : 'webp';
for (let positionIndex = 0; positionIndex < positions.length; positionIndex++) {
const percent = positions[positionIndex];
const stream = ffmpeg(filePath).screenshot({
count: 1,
timemarks: [percent],
folder: tempDir,
size: size,
fastSeek: true,
filename: `%b-${positionIndex + 1}.${ext}`,
});
await once(stream, 'end');
}
const files = await readdir(tempDir);
let largest: { size: number; path: string } | undefined;
for (const file of files) {
const path = join(tempDir, file);
const stats = await stat(path);
if (!largest || stats.size > largest.size) {
largest = { size: stats.size, path };
}
}
if (!largest) {
await rm(tempDir, { recursive: true, force: true });
throw new Error('No thumbnails were generated');
}
logger(`Largest thumbnail is at "${largest.path}", ${largest.size} bytes`);
return {
path: largest.path,
cleanup: async () => {
logger(`Cleaning up "${tempDir}"`);
await rm(tempDir, { recursive: true, force: true });
},
};
}

View File

@ -1,12 +0,0 @@
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"isolatedModules": true
},
"exclude": ["node_modules"],
"extends": "@sylo-digital/scripts/tsconfig/base.json",
"include": ["src"]
}

View File

@ -15,24 +15,24 @@
"generate": "graphql-codegen --config codegen.yml"
},
"dependencies": {
"@apollo/client": "^3.6.9",
"@headlessui/react": "^1.6.1",
"@apollo/client": "^3.7.0",
"@headlessui/react": "^1.7.3",
"@ryanke/pandora": "^0.0.9",
"@tailwindcss/typography": "^0.5.2",
"autoprefixer": "^10.4.7",
"classnames": "^2.3.1",
"concurrently": "^7.2.2",
"copy-to-clipboard": "^3.3.1",
"dayjs": "^1.11.1",
"@tailwindcss/typography": "^0.5.7",
"autoprefixer": "^10.4.12",
"classnames": "^2.3.2",
"concurrently": "^7.4.0",
"copy-to-clipboard": "^3.3.2",
"dayjs": "^1.11.5",
"deepmerge": "^4.2.2",
"formik": "^2.2.9",
"generate-avatar": "1.4.10",
"graphql": "^16.5.0",
"graphql": "^16.6.0",
"http-status-codes": "^2.2.0",
"lodash": "^4.17.21",
"nanoid": "^3.3.4",
"nanoid": "^4.0.0",
"next": "12.2.0",
"postcss": "^8.4.13",
"postcss": "^8.4.17",
"prism-react-renderer": "^1.3.5",
"qrcode.react": "^3.1.0",
"react": "18.2.0",
@ -42,19 +42,19 @@
"rehype-raw": "^6.1.1",
"remark-gfm": "^3.0.1",
"swr": "^1.3.0",
"tailwindcss": "^3.0.24",
"tailwindcss": "^3.1.8",
"yup": "^0.32.11"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.7.0",
"@graphql-codegen/typescript": "2.6.0",
"@graphql-codegen/typescript-operations": "2.4.3",
"@graphql-codegen/typescript-react-apollo": "3.2.17",
"@sylo-digital/scripts": "^1.0.2",
"@types/lodash": "^4.14.182",
"@graphql-codegen/cli": "^2.13.5",
"@graphql-codegen/typescript": "2.7.3",
"@graphql-codegen/typescript-operations": "2.5.3",
"@graphql-codegen/typescript-react-apollo": "3.3.3",
"@sylo-digital/scripts": "^1.0.12",
"@types/lodash": "^4.14.186",
"@types/node": "16",
"@types/react": "^18.0.8",
"@types/react": "^18.0.21",
"prettier": "^2.7.1",
"typescript": "^4.7.4"
"typescript": "^4.8.4"
}
}

View File

@ -1,7 +1,7 @@
import { Container, Spinner } from '@ryanke/pandora';
import type { FC } from 'react';
import { useEffect } from 'react';
import { Title } from '../components/title';
import { Title } from './title';
export const PageLoader: FC<{ title?: string }> = ({ title }) => {
useEffect(() => {

View File

@ -20,7 +20,7 @@ export const apiUri = isServer ? process.env.API_URL : `/api`;
export async function http(pathOrUrl: string, options?: RequestInit): Promise<Response> {
const hasProtocol = pathOrUrl.startsWith('http');
const isAbsolute = pathOrUrl.startsWith('/');
const url = hasProtocol || isAbsolute ? pathOrUrl : `${apiUri}${pathOrUrl}`;
const url = hasProtocol || isAbsolute ? pathOrUrl : `${apiUri}${isAbsolute ? '' : '/'}${pathOrUrl}`;
const response = await fetch(url, options);
if (!response.ok) {
const clone = response.clone();

File diff suppressed because it is too large Load Diff