mirror of
https://github.com/CaramelFur/Picsur.git
synced 2025-11-14 07:45:39 +01:00
Improve type validation
Add image metadata endpoint
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuthModule } from './routes/api/auth/auth.module';
|
import { AuthModule } from './routes/api/auth/auth.module';
|
||||||
import { UserEntity } from './collections/userdb/user.entity';
|
|
||||||
import { ImageModule } from './routes/image/imageroute.module';
|
import { ImageModule } from './routes/image/imageroute.module';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
import Config from './env';
|
import Config from './env';
|
||||||
import { ImageEntity } from './collections/imagedb/image.entity';
|
|
||||||
import { DemoManagerModule } from './managers/demo/demomanager.module';
|
import { DemoManagerModule } from './managers/demo/demomanager.module';
|
||||||
|
import { EUser } from 'imagur-shared/dist/entities/user.entity';
|
||||||
|
import { EImage } from 'imagur-shared/dist/entities/image.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -19,7 +19,7 @@ import { DemoManagerModule } from './managers/demo/demomanager.module';
|
|||||||
database: Config.database.database,
|
database: Config.database.database,
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
|
|
||||||
entities: [UserEntity, ImageEntity],
|
entities: [EUser, EImage],
|
||||||
}),
|
}),
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
rootPath: Config.static.frontendRoot,
|
rootPath: Config.static.frontendRoot,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { IsDefined, ValidateNested } from 'class-validator';
|
import { IsDefined, ValidateNested } from 'class-validator';
|
||||||
import { MultiPartFileDto } from './multipart.dto';
|
import { MultiPartFileDto } from './multipart.dto';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class ImageUploadDto {
|
export class ImageUploadDto {
|
||||||
@IsDefined()
|
@IsDefined()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
|
@Type(() => MultiPartFileDto)
|
||||||
image: MultiPartFileDto;
|
image: MultiPartFileDto;
|
||||||
}
|
}
|
||||||
|
|||||||
7
backend/src/collections/collectionutils.ts
Normal file
7
backend/src/collections/collectionutils.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
export function GetCols<T>(repository: Repository<T>): (keyof T)[] {
|
||||||
|
return repository.metadata.columns.map(
|
||||||
|
(col) => col.propertyName,
|
||||||
|
) as (keyof T)[];
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
|
||||||
import { SupportedMime, SupportedMimes } from './mimes.service';
|
|
||||||
|
|
||||||
@Entity()
|
|
||||||
export class ImageEntity {
|
|
||||||
@PrimaryGeneratedColumn()
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
@Index()
|
|
||||||
@Column({ unique: true })
|
|
||||||
hash: string;
|
|
||||||
|
|
||||||
// Binary data
|
|
||||||
@Column({ type: 'bytea', nullable: false })
|
|
||||||
data: Buffer;
|
|
||||||
|
|
||||||
@Column({ enum: SupportedMimes })
|
|
||||||
mime: SupportedMime;
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ImageEntity } from './image.entity';
|
|
||||||
import { ImageDBService } from './imagedb.service';
|
import { ImageDBService } from './imagedb.service';
|
||||||
import { MimesService } from './mimes.service';
|
import { MimesService } from './mimes.service';
|
||||||
|
import { EImage } from 'imagur-shared/dist/entities/image.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([ImageEntity])],
|
imports: [TypeOrmModule.forFeature([EImage])],
|
||||||
providers: [ImageDBService, MimesService],
|
providers: [ImageDBService, MimesService],
|
||||||
exports: [ImageDBService, MimesService],
|
exports: [ImageDBService, MimesService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,49 +1,55 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { ImageEntity } from './image.entity';
|
|
||||||
import Crypto from 'crypto';
|
import Crypto from 'crypto';
|
||||||
import { SupportedMime } from './mimes.service';
|
|
||||||
import {
|
import {
|
||||||
AsyncFailable,
|
AsyncFailable,
|
||||||
Fail,
|
Fail,
|
||||||
HasFailed,
|
HasFailed,
|
||||||
HasSuccess,
|
HasSuccess,
|
||||||
} from 'imagur-shared/dist/types';
|
} from 'imagur-shared/dist/types';
|
||||||
|
import { SupportedMime } from 'imagur-shared/dist/dto/mimes.dto';
|
||||||
|
import { GetCols } from '../collectionutils';
|
||||||
|
import { EImage } from 'imagur-shared/dist/entities/image.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImageDBService {
|
export class ImageDBService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ImageEntity)
|
@InjectRepository(EImage)
|
||||||
private imageRepository: Repository<ImageEntity>,
|
private imageRepository: Repository<EImage>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async create(
|
public async create(
|
||||||
image: Buffer,
|
image: Buffer,
|
||||||
type: SupportedMime,
|
type: SupportedMime,
|
||||||
): AsyncFailable<ImageEntity> {
|
): AsyncFailable<EImage> {
|
||||||
const hash = this.hash(image);
|
const hash = this.hash(image);
|
||||||
const find = await this.findOne(hash);
|
const find = await this.findOne(hash);
|
||||||
if (HasSuccess(find)) return find;
|
if (HasSuccess(find)) return find;
|
||||||
|
|
||||||
const imageEntity = new ImageEntity();
|
const imageEntity = new EImage();
|
||||||
imageEntity.data = image;
|
imageEntity.data = image;
|
||||||
imageEntity.mime = type;
|
imageEntity.mime = type;
|
||||||
imageEntity.hash = hash;
|
imageEntity.hash = hash;
|
||||||
try {
|
try {
|
||||||
await this.imageRepository.save(imageEntity);
|
return await this.imageRepository.save(imageEntity);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return Fail(e?.message);
|
return Fail(e?.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return imageEntity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async findOne(hash: string): AsyncFailable<ImageEntity> {
|
public async findOne<B extends true | undefined = undefined>(
|
||||||
|
hash: string,
|
||||||
|
getPrivate?: B,
|
||||||
|
): AsyncFailable<B extends undefined ? EImage : Required<EImage>> {
|
||||||
try {
|
try {
|
||||||
const found = await this.imageRepository.findOne({ where: { hash } });
|
const found = await this.imageRepository.findOne({
|
||||||
if (found === undefined) return Fail('Image not found');
|
where: { hash },
|
||||||
return found;
|
select: getPrivate ? GetCols(this.imageRepository) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!found) return Fail('Image not found');
|
||||||
|
return found as B extends undefined ? EImage : Required<EImage>;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return Fail(e?.message);
|
return Fail(e?.message);
|
||||||
}
|
}
|
||||||
@@ -52,7 +58,7 @@ export class ImageDBService {
|
|||||||
public async findMany(
|
public async findMany(
|
||||||
startId: number,
|
startId: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
): AsyncFailable<ImageEntity[]> {
|
): AsyncFailable<EImage[]> {
|
||||||
try {
|
try {
|
||||||
const found = await this.imageRepository.find({
|
const found = await this.imageRepository.find({
|
||||||
where: { id: { gte: startId } },
|
where: { id: { gte: startId } },
|
||||||
@@ -67,7 +73,6 @@ export class ImageDBService {
|
|||||||
|
|
||||||
public async delete(hash: string): AsyncFailable<true> {
|
public async delete(hash: string): AsyncFailable<true> {
|
||||||
const image = await this.findOne(hash);
|
const image = await this.findOne(hash);
|
||||||
|
|
||||||
if (HasFailed(image)) return image;
|
if (HasFailed(image)) return image;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,39 +1,11 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Fail, Failable } from 'imagur-shared/dist/types';
|
import { Fail, Failable } from 'imagur-shared/dist/types';
|
||||||
|
import {
|
||||||
const tuple = <T extends string[]>(...args: T): T => args;
|
FullMime,
|
||||||
|
SupportedAnimMimes,
|
||||||
// Config
|
SupportedImageMimes,
|
||||||
|
SupportedMime,
|
||||||
const SupportedImageMimesTuple = tuple(
|
} from 'imagur-shared/dist/dto/mimes.dto';
|
||||||
'image/jpeg',
|
|
||||||
'image/png',
|
|
||||||
'image/webp',
|
|
||||||
'image/tiff',
|
|
||||||
'image/bmp',
|
|
||||||
'image/x-icon',
|
|
||||||
);
|
|
||||||
|
|
||||||
const SupportedAnimMimesTuple = tuple('image/apng', 'image/gif');
|
|
||||||
|
|
||||||
const SupportedMimesTuple = [
|
|
||||||
...SupportedImageMimesTuple,
|
|
||||||
...SupportedAnimMimesTuple,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Derivatives
|
|
||||||
|
|
||||||
export const SupportedImageMimes: string[] = SupportedImageMimesTuple;
|
|
||||||
export const SupportedAnimMimes: string[] = SupportedAnimMimesTuple;
|
|
||||||
|
|
||||||
export const SupportedMimes: string[] = SupportedMimesTuple;
|
|
||||||
export type SupportedMime = typeof SupportedMimesTuple[number];
|
|
||||||
export type SupportedMimeCategory = 'image' | 'anim';
|
|
||||||
|
|
||||||
export interface FullMime {
|
|
||||||
mime: SupportedMime;
|
|
||||||
type: SupportedMimeCategory;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MimesService {
|
export class MimesService {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
|
||||||
|
|
||||||
@Entity()
|
|
||||||
export class UserEntity {
|
|
||||||
@PrimaryGeneratedColumn()
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
@Index()
|
|
||||||
@Column({ unique: true })
|
|
||||||
username: string;
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
password: string;
|
|
||||||
|
|
||||||
@Column({ default: false })
|
|
||||||
isAdmin: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { UserEntity } from './user.entity';
|
import { EUser } from 'imagur-shared/dist/entities/user.entity';
|
||||||
import { UsersService } from './userdb.service';
|
import { UsersService } from './userdb.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([UserEntity])],
|
imports: [TypeOrmModule.forFeature([EUser])],
|
||||||
providers: [UsersService],
|
providers: [UsersService],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,60 +1,71 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { AsyncFailable, Fail, HasFailed, HasSuccess } from 'imagur-shared/dist/types';
|
import { validate } from 'class-validator';
|
||||||
|
import { EUser } from 'imagur-shared/dist/entities/user.entity';
|
||||||
|
import {
|
||||||
|
AsyncFailable,
|
||||||
|
Fail,
|
||||||
|
HasFailed,
|
||||||
|
HasSuccess,
|
||||||
|
} from 'imagur-shared/dist/types';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { UserEntity } from './user.entity';
|
import { GetCols } from '../collectionutils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
|
private readonly logger = new Logger('UsersService');
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(UserEntity)
|
@InjectRepository(EUser)
|
||||||
private usersRepository: Repository<UserEntity>,
|
private usersRepository: Repository<EUser>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async create(
|
public async create(
|
||||||
username: string,
|
username: string,
|
||||||
hashedPassword: string,
|
hashedPassword: string,
|
||||||
): AsyncFailable<UserEntity> {
|
): AsyncFailable<EUser> {
|
||||||
if (await this.exists(username)) return Fail('User already exists');
|
if (await this.exists(username)) return Fail('User already exists');
|
||||||
|
|
||||||
const user = new UserEntity();
|
const user = new EUser();
|
||||||
user.username = username;
|
user.username = username;
|
||||||
user.password = hashedPassword;
|
user.password = hashedPassword;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.usersRepository.save(user);
|
return await this.usersRepository.save(user);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return Fail(e?.message);
|
return Fail(e?.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(user: string | UserEntity): AsyncFailable<UserEntity> {
|
public async delete(user: string | EUser): AsyncFailable<EUser> {
|
||||||
const userToModify = await this.resolve(user);
|
const userToModify = await this.resolve(user);
|
||||||
|
|
||||||
if (HasFailed(userToModify)) return userToModify;
|
if (HasFailed(userToModify)) return userToModify;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.usersRepository.remove(userToModify);
|
return await this.usersRepository.remove(userToModify);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return Fail(e?.message);
|
return Fail(e?.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return userToModify;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async findOne(username: string): AsyncFailable<UserEntity> {
|
public async findOne<B extends true | undefined = undefined>(
|
||||||
|
username: string,
|
||||||
|
getPrivate?: B,
|
||||||
|
): AsyncFailable<B extends undefined ? EUser : Required<EUser>> {
|
||||||
try {
|
try {
|
||||||
const found = await this.usersRepository.findOne({ where: { username } });
|
const found = await this.usersRepository.findOne({
|
||||||
|
where: { username },
|
||||||
|
select: getPrivate ? GetCols(this.usersRepository) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
if (!found) return Fail('User not found');
|
if (!found) return Fail('User not found');
|
||||||
return found;
|
return found as B extends undefined ? EUser : Required<EUser>;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return Fail(e?.message);
|
return Fail(e?.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async findAll(): AsyncFailable<UserEntity[]> {
|
public async findAll(): AsyncFailable<EUser[]> {
|
||||||
try {
|
try {
|
||||||
return await this.usersRepository.find();
|
return await this.usersRepository.find();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -67,11 +78,10 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async modifyAdmin(
|
public async modifyAdmin(
|
||||||
user: string | UserEntity,
|
user: string | EUser,
|
||||||
admin: boolean,
|
admin: boolean,
|
||||||
): AsyncFailable<true> {
|
): AsyncFailable<true> {
|
||||||
const userToModify = await this.resolve(user);
|
const userToModify = await this.resolve(user);
|
||||||
|
|
||||||
if (HasFailed(userToModify)) return userToModify;
|
if (HasFailed(userToModify)) return userToModify;
|
||||||
|
|
||||||
userToModify.isAdmin = admin;
|
userToModify.isAdmin = admin;
|
||||||
@@ -80,12 +90,15 @@ export class UsersService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolve(
|
private async resolve(user: string | EUser): AsyncFailable<EUser> {
|
||||||
user: string | UserEntity,
|
|
||||||
): AsyncFailable<UserEntity> {
|
|
||||||
if (typeof user === 'string') {
|
if (typeof user === 'string') {
|
||||||
return await this.findOne(user);
|
return await this.findOne(user);
|
||||||
} else {
|
} else {
|
||||||
|
const errors = await validate(user);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
this.logger.warn(errors);
|
||||||
|
return Fail('Invalid user');
|
||||||
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isHash } from 'class-validator';
|
import { isHash } from 'class-validator';
|
||||||
import { fileTypeFromBuffer, FileTypeResult } from 'file-type';
|
import { fileTypeFromBuffer, FileTypeResult } from 'file-type';
|
||||||
|
import { FullMime } from 'imagur-shared/dist/dto/mimes.dto';
|
||||||
|
import { EImage } from 'imagur-shared/dist/entities/image.entity';
|
||||||
import { AsyncFailable, Fail, HasFailed } from 'imagur-shared/dist/types';
|
import { AsyncFailable, Fail, HasFailed } from 'imagur-shared/dist/types';
|
||||||
import { ImageEntity } from '../../collections/imagedb/image.entity';
|
|
||||||
import { ImageDBService } from '../../collections/imagedb/imagedb.service';
|
import { ImageDBService } from '../../collections/imagedb/imagedb.service';
|
||||||
import {
|
import { MimesService } from '../../collections/imagedb/mimes.service';
|
||||||
MimesService,
|
|
||||||
FullMime,
|
|
||||||
} from '../../collections/imagedb/mimes.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImageManagerService {
|
export class ImageManagerService {
|
||||||
@@ -16,13 +14,16 @@ export class ImageManagerService {
|
|||||||
private readonly mimesService: MimesService,
|
private readonly mimesService: MimesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async retrieve(hash: string): AsyncFailable<ImageEntity> {
|
public async retrieveInfo(hash: string): AsyncFailable<EImage> {
|
||||||
if (!isHash(hash, 'sha256')) return Fail('Invalid hash');
|
|
||||||
|
|
||||||
return await this.imagesService.findOne(hash);
|
return await this.imagesService.findOne(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async upload(image: Buffer): AsyncFailable<string> {
|
// Image data buffer is not included by default, this also returns that buffer
|
||||||
|
public async retrieveComplete(hash: string): AsyncFailable<Required<EImage>> {
|
||||||
|
return await this.imagesService.findOne(hash, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async upload(image: Buffer): AsyncFailable<EImage> {
|
||||||
const fullMime = await this.getFullMimeFromBuffer(image);
|
const fullMime = await this.getFullMimeFromBuffer(image);
|
||||||
if (HasFailed(fullMime)) return fullMime;
|
if (HasFailed(fullMime)) return fullMime;
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export class ImageManagerService {
|
|||||||
);
|
);
|
||||||
if (HasFailed(imageEntity)) return imageEntity;
|
if (HasFailed(imageEntity)) return imageEntity;
|
||||||
|
|
||||||
return imageEntity.hash;
|
return imageEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async process(image: Buffer, mime: FullMime): Promise<Buffer> {
|
private async process(image: Buffer, mime: FullMime): Promise<Buffer> {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { plainToClass } from 'class-transformer';
|
import { plainToClass } from 'class-transformer';
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
import { User } from 'imagur-shared/dist/dto/user.dto';
|
import { EUser } from 'imagur-shared/dist/entities/user.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminGuard implements CanActivate {
|
export class AdminGuard implements CanActivate {
|
||||||
@@ -15,7 +15,7 @@ export class AdminGuard implements CanActivate {
|
|||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
const user = plainToClass(User, request.user);
|
const user = plainToClass(EUser, request.user);
|
||||||
const errors = await validate(user, {forbidUnknownValues: true});
|
const errors = await validate(user, {forbidUnknownValues: true});
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
this.logger.warn(errors);
|
this.logger.warn(errors);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class AuthController {
|
|||||||
await this.authService.makeAdmin(user);
|
await this.authService.makeAdmin(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.authService.userEntityToUser(user);
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||||
@@ -60,7 +60,7 @@ export class AuthController {
|
|||||||
const user = await this.authService.deleteUser(deleteData.username);
|
const user = await this.authService.deleteUser(deleteData.username);
|
||||||
if (HasFailed(user)) throw new NotFoundException('User does not exist');
|
if (HasFailed(user)) throw new NotFoundException('User does not exist');
|
||||||
|
|
||||||
return this.authService.userEntityToUser(user);
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { JwtDataDto } from 'imagur-shared/dist/dto/auth.dto';
|
import { JwtDataDto } from 'imagur-shared/dist/dto/auth.dto';
|
||||||
import { User } from 'imagur-shared/dist/dto/user.dto';
|
import { EUser } from 'imagur-shared/dist/entities/user.entity';
|
||||||
import { AsyncFailable, HasFailed, Fail } from 'imagur-shared/dist/types';
|
import { AsyncFailable, HasFailed, Fail } from 'imagur-shared/dist/types';
|
||||||
import { UserEntity } from '../../../collections/userdb/user.entity';
|
|
||||||
import { UsersService } from '../../../collections/userdb/userdb.service';
|
import { UsersService } from '../../../collections/userdb/userdb.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -14,62 +13,42 @@ export class AuthService {
|
|||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createUser(
|
async createUser(username: string, password: string): AsyncFailable<EUser> {
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
): AsyncFailable<UserEntity> {
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
return this.usersService.create(username, hashedPassword);
|
return this.usersService.create(username, hashedPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteUser(user: string | UserEntity): AsyncFailable<UserEntity> {
|
async deleteUser(user: string | EUser): AsyncFailable<EUser> {
|
||||||
return this.usersService.delete(user);
|
return this.usersService.delete(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listUsers(): AsyncFailable<User[]> {
|
async listUsers(): AsyncFailable<EUser[]> {
|
||||||
const users = await this.usersService.findAll();
|
return this.usersService.findAll();
|
||||||
if (HasFailed(users)) return users;
|
|
||||||
|
|
||||||
return users.map((user) => this.userEntityToUser(user));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticate(
|
async authenticate(username: string, password: string): AsyncFailable<EUser> {
|
||||||
username: string,
|
const user = await this.usersService.findOne(username, true);
|
||||||
password: string,
|
|
||||||
): AsyncFailable<UserEntity> {
|
|
||||||
const user = await this.usersService.findOne(username);
|
|
||||||
|
|
||||||
if (HasFailed(user)) return user;
|
if (HasFailed(user)) return user;
|
||||||
|
|
||||||
if (!(await bcrypt.compare(password, user.password)))
|
if (!(await bcrypt.compare(password, user.password)))
|
||||||
return Fail('Wrong password');
|
return Fail('Wrong password');
|
||||||
|
|
||||||
return user;
|
return await this.usersService.findOne(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createToken(user: User): Promise<string> {
|
async createToken(user: EUser): Promise<string> {
|
||||||
const jwtData: JwtDataDto = {
|
const jwtData: JwtDataDto = {
|
||||||
user: {
|
user,
|
||||||
username: user.username,
|
|
||||||
isAdmin: user.isAdmin,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.jwtService.signAsync(jwtData);
|
return this.jwtService.signAsync(jwtData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async makeAdmin(user: string | UserEntity): AsyncFailable<true> {
|
async makeAdmin(user: string | EUser): AsyncFailable<true> {
|
||||||
return this.usersService.modifyAdmin(user, true);
|
return this.usersService.modifyAdmin(user, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeAdmin(user: string | UserEntity): AsyncFailable<true> {
|
async revokeAdmin(user: string | EUser): AsyncFailable<true> {
|
||||||
return this.usersService.modifyAdmin(user, false);
|
return this.usersService.modifyAdmin(user, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
userEntityToUser(user: UserEntity): User {
|
|
||||||
return {
|
|
||||||
username: user.username,
|
|
||||||
isAdmin: user.isAdmin,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyRequest } from 'fastify';
|
import { FastifyRequest } from 'fastify';
|
||||||
import { User } from 'imagur-shared/dist/dto/user.dto';
|
import { EUser } from 'imagur-shared/dist/entities/user.entity';
|
||||||
|
|
||||||
export default interface AuthFasityRequest extends FastifyRequest {
|
export default interface AuthFasityRequest extends FastifyRequest {
|
||||||
user: User;
|
user: EUser;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { validate } from 'class-validator';
|
|||||||
import { plainToClass } from 'class-transformer';
|
import { plainToClass } from 'class-transformer';
|
||||||
import Config from '../../../env';
|
import Config from '../../../env';
|
||||||
import { JwtDataDto } from 'imagur-shared/dist/dto/auth.dto';
|
import { JwtDataDto } from 'imagur-shared/dist/dto/auth.dto';
|
||||||
import { User } from 'imagur-shared/dist/dto/user.dto';
|
import { EUser } from 'imagur-shared/dist/entities/user.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
@@ -19,10 +19,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: any): Promise<User> {
|
async validate(payload: any): Promise<EUser> {
|
||||||
const jwt = plainToClass(JwtDataDto, payload);
|
const jwt = plainToClass(JwtDataDto, payload);
|
||||||
|
|
||||||
const errors = await validate(jwt);
|
const errors = await validate(jwt, { forbidUnknownValues: true });
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
this.logger.warn(errors);
|
this.logger.warn(errors);
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport';
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AsyncFailable, HasFailed } from 'imagur-shared/dist/types';
|
import { AsyncFailable, HasFailed } from 'imagur-shared/dist/types';
|
||||||
import { User } from 'imagur-shared/dist/dto/user.dto';
|
import { EUser } from 'imagur-shared/dist/entities/user.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
|
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
|
||||||
@@ -11,15 +11,11 @@ export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(username: string, password: string): AsyncFailable<User> {
|
async validate(username: string, password: string): AsyncFailable<EUser> {
|
||||||
const userEntity = await this.authService.authenticate(username, password);
|
const user = await this.authService.authenticate(username, password);
|
||||||
if (HasFailed(userEntity)) {
|
if (HasFailed(user)) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
const user: User = {
|
|
||||||
username: userEntity.username,
|
|
||||||
isAdmin: userEntity.isAdmin,
|
|
||||||
};
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class ImageController {
|
|||||||
) {
|
) {
|
||||||
if (!isHash(hash, 'sha256')) throw new BadRequestException('Invalid hash');
|
if (!isHash(hash, 'sha256')) throw new BadRequestException('Invalid hash');
|
||||||
|
|
||||||
const image = await this.imagesService.retrieve(hash);
|
const image = await this.imagesService.retrieveComplete(hash);
|
||||||
if (HasFailed(image))
|
if (HasFailed(image))
|
||||||
throw new NotFoundException('Failed to retrieve image');
|
throw new NotFoundException('Failed to retrieve image');
|
||||||
|
|
||||||
@@ -34,17 +34,28 @@ export class ImageController {
|
|||||||
return image.data;
|
return image.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('meta/:hash')
|
||||||
|
async getImageMeta(@Param('hash') hash: string) {
|
||||||
|
if (!isHash(hash, 'sha256')) throw new BadRequestException('Invalid hash');
|
||||||
|
|
||||||
|
const image = await this.imagesService.retrieveInfo(hash);
|
||||||
|
if (HasFailed(image))
|
||||||
|
throw new NotFoundException('Failed to retrieve image');
|
||||||
|
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async uploadImage(
|
async uploadImage(
|
||||||
@Req() req: FastifyRequest,
|
@Req() req: FastifyRequest,
|
||||||
@MultiPart(ImageUploadDto) multipart: ImageUploadDto,
|
@MultiPart(ImageUploadDto) multipart: ImageUploadDto,
|
||||||
) {
|
) {
|
||||||
const fileBuffer = await multipart.image.toBuffer();
|
const fileBuffer = await multipart.image.toBuffer();
|
||||||
const hash = await this.imagesService.upload(fileBuffer);
|
const image = await this.imagesService.upload(fileBuffer);
|
||||||
if (HasFailed(hash)) {
|
if (HasFailed(image)) {
|
||||||
throw new InternalServerErrorException('Failed to upload image');
|
throw new InternalServerErrorException('Failed to upload image');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { hash };
|
return image;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
const scopedcss = require('craco-plugin-scoped-css');
|
const scopedcss = require('craco-plugin-scoped-css');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const webpack = require('webpack');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
webpack: {
|
||||||
|
plugins: {
|
||||||
|
add: [
|
||||||
|
new webpack.IgnorePlugin({resourceRegExp: /react-native-sqlite-storage/}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
devServer: {
|
devServer: {
|
||||||
devMiddleware: {
|
devMiddleware: {
|
||||||
writeToDisk: true,
|
writeToDisk: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AsyncFailable, HasFailed } from 'imagur-shared/dist/types';
|
|
||||||
import { ImageUploadResponse } from 'imagur-shared/dist/dto/images.dto';
|
|
||||||
import { ImageUploadRequest } from '../frontenddto/imageroute.dto';
|
import { ImageUploadRequest } from '../frontenddto/imageroute.dto';
|
||||||
import ImagurApi from './api';
|
import ImagurApi from './api';
|
||||||
|
import { EImage } from 'imagur-shared/dist/entities/image.entity';
|
||||||
|
import { AsyncFailable, HasFailed } from 'imagur-shared/dist/types';
|
||||||
|
|
||||||
export interface ImageLinks {
|
export interface ImageLinks {
|
||||||
source: string;
|
source: string;
|
||||||
@@ -19,7 +19,7 @@ export default class ImagesApi extends ImagurApi {
|
|||||||
|
|
||||||
public async UploadImage(image: File): AsyncFailable<string> {
|
public async UploadImage(image: File): AsyncFailable<string> {
|
||||||
const result = await this.api.postForm(
|
const result = await this.api.postForm(
|
||||||
ImageUploadResponse,
|
EImage,
|
||||||
'/i',
|
'/i',
|
||||||
new ImageUploadRequest(image),
|
new ImageUploadRequest(image),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.13.2",
|
"class-validator": "^0.13.2",
|
||||||
"tsc-watch": "^4.6.0"
|
"tsc-watch": "^4.6.0",
|
||||||
|
"typeorm": "^0.2.44"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^17.0.21",
|
"@types/node": "^17.0.21",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Equals,
|
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDefined,
|
IsDefined,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { User } from './user.dto';
|
import { EUser } from '../entities/user.entity';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
// Api
|
// Api
|
||||||
|
|
||||||
@@ -47,14 +48,15 @@ export class AuthDeleteRequest {
|
|||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthDeleteResponse extends User {}
|
export class AuthDeleteResponse extends EUser {}
|
||||||
|
|
||||||
// Extra
|
// Extra
|
||||||
|
|
||||||
export class JwtDataDto {
|
export class JwtDataDto {
|
||||||
@ValidateNested()
|
|
||||||
@IsDefined()
|
@IsDefined()
|
||||||
user: User;
|
@ValidateNested()
|
||||||
|
@Type(() => EUser)
|
||||||
|
user: EUser;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { IsHash, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class ImageUploadResponse {
|
|
||||||
@IsString()
|
|
||||||
@IsHash('sha256')
|
|
||||||
hash: string;
|
|
||||||
}
|
|
||||||
33
shared/src/dto/mimes.dto.ts
Normal file
33
shared/src/dto/mimes.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
const tuple = <T extends string[]>(...args: T): T => args;
|
||||||
|
|
||||||
|
// Config
|
||||||
|
|
||||||
|
const SupportedImageMimesTuple = tuple(
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/tiff',
|
||||||
|
'image/bmp',
|
||||||
|
'image/x-icon',
|
||||||
|
);
|
||||||
|
|
||||||
|
const SupportedAnimMimesTuple = tuple('image/apng', 'image/gif');
|
||||||
|
|
||||||
|
const SupportedMimesTuple = [
|
||||||
|
...SupportedImageMimesTuple,
|
||||||
|
...SupportedAnimMimesTuple,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Derivatives
|
||||||
|
|
||||||
|
export const SupportedImageMimes: string[] = SupportedImageMimesTuple;
|
||||||
|
export const SupportedAnimMimes: string[] = SupportedAnimMimesTuple;
|
||||||
|
|
||||||
|
export const SupportedMimes: string[] = SupportedMimesTuple;
|
||||||
|
export type SupportedMime = typeof SupportedMimesTuple[number];
|
||||||
|
export type SupportedMimeCategory = 'image' | 'anim';
|
||||||
|
|
||||||
|
export interface FullMime {
|
||||||
|
mime: SupportedMime;
|
||||||
|
type: SupportedMimeCategory;
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { IsBoolean, IsDefined, IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class User {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
username: string;
|
|
||||||
|
|
||||||
@IsDefined()
|
|
||||||
@IsBoolean()
|
|
||||||
isAdmin: boolean;
|
|
||||||
}
|
|
||||||
27
shared/src/entities/image.entity.ts
Normal file
27
shared/src/entities/image.entity.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { IsDefined, IsEnum, IsHash, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import { SupportedMime, SupportedMimes } from '../dto/mimes.dto';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class EImage {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@IsNumber()
|
||||||
|
@IsDefined()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ unique: true })
|
||||||
|
@IsString()
|
||||||
|
@IsHash('sha256')
|
||||||
|
hash: string;
|
||||||
|
|
||||||
|
// Binary data
|
||||||
|
@Column({ type: 'bytea', nullable: false, select: false })
|
||||||
|
@IsOptional()
|
||||||
|
data?: Buffer;
|
||||||
|
|
||||||
|
@Column({ enum: SupportedMimes })
|
||||||
|
@IsEnum(SupportedMimes)
|
||||||
|
@IsDefined()
|
||||||
|
mime: SupportedMime;
|
||||||
|
}
|
||||||
33
shared/src/entities/user.entity.ts
Normal file
33
shared/src/entities/user.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsDefined,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class EUser {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@IsNumber()
|
||||||
|
@IsDefined()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ unique: true })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
@IsDefined()
|
||||||
|
@IsBoolean()
|
||||||
|
isAdmin: boolean;
|
||||||
|
|
||||||
|
@Column({ select: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user