Initial commit

This commit is contained in:
2022-08-17 21:59:51 +02:00
commit cc6feb3171
48 changed files with 42841 additions and 0 deletions

52
src/app.module.ts Normal file
View File

@@ -0,0 +1,52 @@
import { Controller, Get, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { INode, JWTToken, User, UserRole } from './entities';
import FileSystemModule from './modules/filesystem';
import { JWTAuthGuard, Role, RoleGuard } from './authguards';
import AuthModule from './modules/auth';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
@Controller('test')
class TestController {
@Role(UserRole.USER)
@Get('hello')
getHello(): string {
return 'UwU';
}
@Role(UserRole.ADMIN)
@Get('hello2')
getHelloAdmin(): string {
return 'UwU Admin';
}
}
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: 'sqlite.db',
synchronize: true,
entities: [User, INode, JWTToken]
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', '..', 'frontend', 'dist'),
exclude: ['/api*']
}),
FileSystemModule,
AuthModule
],
controllers: [TestController],
providers: [
{
provide: 'APP_GUARD',
useClass: JWTAuthGuard
},
{
provide: 'APP_GUARD',
useClass: RoleGuard
}
]
})
export class AppModule {}

46
src/authguards.ts Normal file
View File

@@ -0,0 +1,46 @@
import {
CanActivate,
ExecutionContext,
Injectable,
SetMetadata
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { User, UserRole } from './entities';
const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@Injectable()
export class JWTAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_KEY,
[context.getHandler(), context.getClass()]
);
if (isPublic) return true;
return super.canActivate(context);
}
}
const ROLE_KEY = 'role';
export const Role = (role: UserRole) => SetMetadata(ROLE_KEY, role);
@Injectable()
export class RoleGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext) {
const requiredRole = this.reflector.getAllAndOverride<UserRole>(
ROLE_KEY,
[context.getHandler(), context.getClass()]
);
if (!requiredRole) return true;
const user: User = context.switchToHttp().getRequest().user;
return user.role >= requiredRole;
}
}

58
src/controller/auth.ts Normal file
View File

@@ -0,0 +1,58 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
Post,
Request,
UseGuards
} from '@nestjs/common';
import { AuthService } from '../services/auth';
import { AuthGuard } from '@nestjs/passport';
import { Public } from '../authguards';
import {
BaseResponse,
ErrorResponse,
LoginResponse,
RefreshResponse
} from 'dto';
@Controller('api/auth')
export default class AuthController {
constructor(private authService: AuthService) {}
@Public()
@UseGuards(AuthGuard('local'))
@Post('login')
@HttpCode(200)
async login(@Request() req): Promise<LoginResponse> {
return {
statusCode: 200,
jwt: await this.authService.login(req.user)
};
}
@Public()
@Post('signup')
async signup(
@Body('username') username,
@Body('password') password
): Promise<BaseResponse | ErrorResponse> {
if ((await this.authService.findUser(username)) != null)
throw new BadRequestException('Username already taken');
await this.authService.signup(username, password);
return {
statusCode: 200
};
}
@Post('refresh')
async refresh(@Request() req): Promise<RefreshResponse | ErrorResponse> {
const token = await this.authService.login(req.user);
await this.authService.revoke(req.token);
return {
statusCode: 200,
jwt: token
};
}
}

View File

@@ -0,0 +1,141 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Request,
StreamableFile
} from '@nestjs/common';
import {
CreateFileResponse,
CreateFolderResponse,
DeleteResponse,
GetNodeResponse,
GetPathResponse,
GetRootResponse,
UploadFileResponse
} from 'dto/index';
import FileSystemService from '../services/filesystem';
import { UserRole } from '../entities';
import { Role } from '../authguards';
@Controller('api/fs')
export default class FileSystemController {
constructor(private fsService: FileSystemService) {}
@Get('root')
@Role(UserRole.USER)
async getRoot(@Request() req): Promise<GetRootResponse> {
return {
statusCode: 200,
rootId: req.user.rootId
};
}
@Get('node/:node')
@Role(UserRole.USER)
async getNode(
@Request() req,
@Param('node', ParseIntPipe) nodeId
): Promise<GetNodeResponse> {
const node = await this.fsService.getNodeAndValidate(nodeId, req.user);
const data: GetNodeResponse = {
id: nodeId,
statusCode: 200,
name: node.name,
parent: node.parentId,
isFile: node.isFile
};
if (data.isFile) {
data.size = node.size;
} else {
data.children = (await node.children).map((child) => child.id);
}
return data;
}
@Get('path/:node')
@Role(UserRole.USER)
async getPath(
@Request() req,
@Param('node', ParseIntPipe) nodeId
): Promise<GetPathResponse> {
return {
statusCode: 200,
path: await this.fsService.generatePath(
await this.fsService.getNodeAndValidate(nodeId, req.user)
)
};
}
@Post('createFolder')
@Role(UserRole.USER)
async createFolder(
@Request() req,
@Body('parent', ParseIntPipe) parent,
@Body('name') name
): Promise<CreateFolderResponse> {
const newChild = await this.fsService.create(
await this.fsService.getNodeAndValidate(parent, req.user),
name,
req.user,
false
);
return {
statusCode: 200,
id: newChild.id
};
}
@Post('createFile')
@Role(UserRole.USER)
async createFile(
@Request() req,
@Body('parent', ParseIntPipe) parent,
@Body('name') name
): Promise<CreateFileResponse> {
const newChild = await this.fsService.create(
await this.fsService.getNodeAndValidate(parent, req.user),
name,
req.user,
true
);
return {
statusCode: 200,
id: newChild.id
};
}
@Post('delete')
@Role(UserRole.USER)
async delete(
@Request() req,
@Body('node', ParseIntPipe) node_id
): Promise<DeleteResponse> {
await this.fsService.delete(
await this.fsService.getNodeAndValidate(node_id, req.user)
);
return { statusCode: 200 };
}
@Post('upload/:node')
@Role(UserRole.USER)
async upload(
@Request() req,
@Param('node', ParseIntPipe) nodeId
): Promise<UploadFileResponse> {
await this.fsService.uploadFile(await req.file(), nodeId, req.user);
return { statusCode: 200 };
}
@Post('download')
@Role(UserRole.USER)
async download(
@Request() req,
@Body('id', ParseIntPipe) id
): Promise<StreamableFile> {
return this.fsService.downloadFile(id, req.user);
}
}

74
src/entities/index.ts Normal file
View File

@@ -0,0 +1,74 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
OneToMany,
OneToOne
} from 'typeorm';
export enum UserRole {
ADMIN = 2,
USER = 1,
DISABLED = 0
}
@Entity()
export class INode {
@PrimaryGeneratedColumn()
id: number;
@Column()
isFile: boolean;
@Column()
name: string;
@Column({ nullable: true })
size: number;
@Column({ nullable: true })
parentId: number;
@ManyToOne(() => INode, (node) => node.children)
parent: Promise<INode>;
@OneToMany(() => INode, (node) => node.parent)
children: Promise<INode[]>;
@Column({ nullable: true })
ownerId: number;
@ManyToOne(() => User)
owner: Promise<User>;
}
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
password: string;
@Column({
type: 'int',
default: UserRole.DISABLED,
transformer: {
from: (db: number): UserRole => db,
to: (role: UserRole): number => role
}
})
role: UserRole;
@Column({ nullable: true })
rootId: number;
@OneToOne(() => INode)
root: Promise<INode>;
}
@Entity()
export class JWTToken {
@PrimaryGeneratedColumn()
id: number;
@Column()
ownerId: number;
@Column({ nullable: true })
exp: number;
}

20
src/main.ts Normal file
View File

@@ -0,0 +1,20 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import {
FastifyAdapter,
NestFastifyApplication
} from '@nestjs/platform-fastify';
import fastifyMultipart from '@fastify/multipart';
import { existsSync, mkdirSync } from 'fs';
async function bootstrap() {
if (!existsSync('files')) mkdirSync('files');
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: true })
);
await app.register(fastifyMultipart);
await app.listen(8080, '0.0.0.0');
}
bootstrap();

22
src/modules/auth.ts Normal file
View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { INode, JWTToken, User } from '../entities';
import {
AuthService,
AuthLocalService,
AuthJwtService
} from '../services/auth';
import AuthController from '../controller/auth';
import FileSystemService from '../services/filesystem';
@Module({
imports: [TypeOrmModule.forFeature([User, INode, JWTToken])],
providers: [
AuthService,
AuthLocalService,
AuthJwtService,
FileSystemService
],
controllers: [AuthController]
})
export default class AuthModule {}

12
src/modules/filesystem.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { INode } from '../entities';
import FileSystemService from '../services/filesystem';
import FileSystemController from '../controller/filesystem';
@Module({
imports: [TypeOrmModule.forFeature([INode])],
providers: [FileSystemService],
controllers: [FileSystemController]
})
export default class FileSystemModule {}

145
src/services/auth.ts Normal file
View File

@@ -0,0 +1,145 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { JWTToken, User, UserRole } from '../entities';
import { Repository, LessThanOrEqual } from 'typeorm';
import * as argon2 from 'argon2';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy as LocalStrategy } from 'passport-local';
import { ExtractJwt, Strategy as JWTStrategy } from 'passport-jwt';
import FileSystemService from './filesystem';
import * as jwt from 'jsonwebtoken';
const jwtSecret = 'CUM';
interface jwtPayload {
sub: number;
jti: number;
exp?: number;
iat?: number;
}
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
@InjectRepository(JWTToken)
private tokenRepo: Repository<JWTToken>,
private fsService: FileSystemService
) {}
async getUser(userId: number): Promise<User | null> {
return this.userRepo.findOneBy({
id: userId
});
}
async findUser(username: string): Promise<User | null> {
return this.userRepo.findOneBy({
name: username
});
}
async getToken(tokenId: number): Promise<JWTToken | null> {
return this.tokenRepo.findOneBy({
id: tokenId
});
}
async validateUser(username: string, pass: string): Promise<User | null> {
const user = await this.findUser(username);
if (!user)
throw new UnauthorizedException('Invalid username or password');
if (!(await argon2.verify(user.password, pass)))
throw new UnauthorizedException('Invalid username or password');
if (user.role == UserRole.DISABLED)
throw new UnauthorizedException('Account is disabled');
return user;
}
async cleanupTokens(): Promise<void> {
await this.tokenRepo.delete({
exp: LessThanOrEqual(Math.floor(Date.now() / 1000))
});
}
async login(user: User) {
const token = new JWTToken();
token.ownerId = user.id;
const db_token = await this.tokenRepo.save(token);
const payload: jwtPayload = {
sub: user.id,
jti: db_token.id
};
const jwtToken = jwt.sign(payload, jwtSecret, {
mutatePayload: true,
expiresIn: '1d'
});
db_token.exp = payload.exp;
await this.tokenRepo.save(db_token);
return jwtToken;
}
async signup(username: string, password: string) {
const user = new User();
user.name = username;
user.password = await argon2.hash(password);
const dbUser = await this.userRepo.save(user);
const root = await this.fsService.generateRoot(dbUser);
dbUser.rootId = root.id;
await this.userRepo.save(dbUser);
}
async revoke(token: JWTToken) {
await this.tokenRepo.delete({
id: token.id
});
}
}
@Injectable()
export class AuthLocalService extends PassportStrategy(LocalStrategy) {
constructor(private authService: AuthService) {
super();
}
async validate(username: string, pass: string) {
const user = await this.authService.validateUser(username, pass);
if (!user)
throw new UnauthorizedException('Invalid username or password');
return user;
}
}
@Injectable()
export class AuthJwtService extends PassportStrategy(JWTStrategy) {
constructor(private authService: AuthService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
ExtractJwt.fromBodyField('jwtToken')
]),
ignoreExpiration: false,
passReqToCallback: true,
secretOrKey: jwtSecret
});
}
async validate(req: Request, payload: jwtPayload) {
await this.authService.cleanupTokens();
const token = await this.authService.getToken(payload.jti);
if (!token)
throw new UnauthorizedException(
'Invalid token, please log in again'
);
const user = await this.authService.getUser(token.ownerId);
if (!user || user.id != payload.sub)
throw new UnauthorizedException(
'Invalid token, please log in again'
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
req.token = token;
return user;
}
}

129
src/services/filesystem.ts Normal file
View File

@@ -0,0 +1,129 @@
import {
BadRequestException,
Injectable,
NotImplementedException,
StreamableFile,
UnauthorizedException
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { INode, User } from '../entities';
import { Repository } from 'typeorm';
import { Multipart } from '@fastify/multipart';
import { pipeline } from 'stream/promises';
import { createReadStream, createWriteStream, statSync, unlink } from 'fs';
import { Writable } from 'stream';
@Injectable()
export default class FileSystemService {
constructor(
@InjectRepository(INode)
private inodeRepo: Repository<INode>
) {}
async generateRoot(user: User): Promise<INode> {
const node = new INode();
node.isFile = false;
node.name = '';
node.owner = Promise.resolve(user);
return await this.inodeRepo.save(node);
}
async getNode(nodeId: number): Promise<INode> {
return await this.inodeRepo.findOneBy({
id: nodeId
});
}
async getNodeAndValidate(node_id: number, user: User): Promise<INode> {
const node = await this.getNode(node_id);
if (node == null) throw new BadRequestException();
if (node.ownerId != user.id) throw new UnauthorizedException();
return node;
}
async generatePath(node: INode): Promise<string> {
if (node.parentId == null) return '/';
return (
(await this.generatePath(await node.parent)).slice(0, -1) +
'/' +
node.name +
(node.isFile ? '' : '/')
);
}
async delete(node: INode): Promise<void> {
if (node.parentId == null)
throw new BadRequestException("Can't delete root");
if (!node.isFile)
await Promise.all(
(await node.children).map((child) => this.delete(child))
);
else
unlink(`files/${node.id}`, (err) =>
console.error(`Error while deleting ${node.id}`, err)
);
await this.inodeRepo.remove(node);
}
async create(
parent: INode,
full_name: string,
owner: User,
file: boolean
): Promise<INode> {
const name = full_name.trim();
if (name == '') throw new BadRequestException("Name can't be empty");
if (name == '.' || name == '..')
throw new BadRequestException('Invalid name');
if (parent.isFile)
throw new BadRequestException("Can't create file/folder in file");
if (
await this.inodeRepo.findOneBy({
parentId: parent.id,
name: name
})
)
throw new BadRequestException('File/Folder already exists');
const node = new INode();
node.isFile = file;
node.name = name;
node.owner = Promise.resolve(owner);
node.parent = Promise.resolve(parent);
return await this.inodeRepo.save(node);
}
async uploadFile(file: Multipart, nodeId: number, user: User) {
try {
const node = await this.getNodeAndValidate(nodeId, user);
await pipeline(file.file, createWriteStream(`files/${node.id}`));
const stats = statSync(`files/${node.id}`);
node.size = stats.size;
await this.inodeRepo.save(node);
} catch (e) {
await pipeline(
file.file,
new Writable({
write(
chunk: any,
encoding: BufferEncoding,
callback: (error?: Error | null) => void
) {
setImmediate(callback);
}
})
);
}
}
async downloadFile(id: number, user: User): Promise<StreamableFile> {
const node = await this.getNodeAndValidate(id, user);
if (!node.isFile) throw new NotImplementedException();
const stats = statSync(`files/${node.id}`);
return new StreamableFile(createReadStream(`files/${node.id}`), {
disposition: `attachment; filename="${node.name}"`,
length: stats.size
});
}
}