feat: implement Database Manager module with encryption, CRUD operations, and migration management

This commit is contained in:
lborv
2025-09-27 18:06:50 +03:00
parent 2f848137ed
commit 0d5b2830ed
24 changed files with 541 additions and 303 deletions

View File

@ -0,0 +1,31 @@
import * as crypto from "crypto";
export class DatabaseEncryptionService {
protected encryptPassword(password: string): string {
const iv = crypto.randomBytes(
process.env.IV_LENGTH ? parseInt(process.env.IV_LENGTH) : 16
);
const cipher = crypto.createCipheriv(
"aes-256-cbc",
process.env.ENCRYPTION_KEY,
iv
);
let encrypted = cipher.update(password, "utf8", "base64");
encrypted += cipher.final("base64");
return iv.toString("base64") + ":" + encrypted;
}
protected decryptPassword(encrypted: string): string {
const parts = encrypted.split(":");
const iv = Buffer.from(parts[0], "base64");
const encryptedText = parts[1];
const decipher = crypto.createDecipheriv(
"aes-256-cbc",
process.env.ENCRYPTION_KEY,
iv
);
let decrypted = decipher.update(encryptedText, "base64", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
}

View File

@ -0,0 +1,74 @@
import { Controller, Get, Inject, Post } from "@nestjs/common";
import { DatabaseManagerService } from "./database.manager.service";
import { DatabaseNodeService } from "../databaseNode/database.node.service";
import { MigrationService } from "../migration/migration.service";
@Controller("database")
export class DatabaseManagerController {
constructor(
@Inject("DatabaseService")
private readonly databaseManagerService: DatabaseManagerService,
@Inject("DatabaseNodeService")
private readonly databaseNodeService: DatabaseNodeService,
@Inject("MigrationService")
private readonly migrationService: MigrationService
) {}
@Post("create")
createDatabase(
@Inject("body") body: { projectId: string; databaseNodeId: string }
) {
return this.databaseManagerService.createDatabase(
body.projectId,
body.databaseNodeId
);
}
@Post("node/create")
addDatabaseNode(
@Inject("body")
body: {
host: string;
port: number;
username: string;
password: string;
}
) {
return this.databaseNodeService.create(
body.host,
body.port,
body.username,
body.password
);
}
@Get("migration/up/:databaseId")
migrateUp(@Inject("params") params: { databaseId: string }) {
return this.migrationService.up(params.databaseId);
}
@Get("migration/down/:databaseId")
migrateDown(@Inject("params") params: { databaseId: string }) {
return this.migrationService.down(params.databaseId);
}
@Post("migration/create")
createMigration(
@Inject("body")
body: {
up: string;
down: string;
databaseId: string;
}
) {
return this.migrationService.create(body.up, body.down, body.databaseId);
}
@Post("query/:databaseId")
runQuery(
@Inject("params") params: { databaseId: string },
@Inject("body") body: { query: string }
) {
return this.databaseManagerService.runQuery(params.databaseId, body.query);
}
}

View File

@ -0,0 +1,22 @@
import { forwardRef, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Database } from "../entities/database.entity";
import { MigrationModule } from "../migration/migration.module";
import { ProjectModule } from "src/project/project.module";
import { DatabaseManagerController } from "./database.manager.controller";
import { DatabaseManagerService } from "./database.manager.service";
import { DatabaseNode } from "../entities/database.node.entity";
import { Project } from "src/project/entities/project.entity";
import { DatabaseNodeService } from "../databaseNode/database.node.service";
@Module({
imports: [
forwardRef(() => ProjectModule),
MigrationModule,
TypeOrmModule.forFeature([Database, DatabaseNode, Project]),
],
controllers: [DatabaseManagerController],
providers: [DatabaseManagerService, DatabaseNodeService],
exports: [DatabaseManagerService, DatabaseNodeService],
})
export class DatabaseManagerModule {}

View File

@ -0,0 +1,95 @@
import { Inject, Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Database } from "../entities/database.entity";
import { Repository } from "typeorm";
import { ProjectService } from "src/project/project.service";
import { DatabaseEncryptionService } from "../database.encryption.service";
import { DatabaseNodeService } from "../databaseNode/database.node.service";
import * as mysql from "mysql2/promise";
@Injectable()
export class DatabaseManagerService extends DatabaseEncryptionService {
constructor(
@InjectRepository(Database)
private databaseRepository: Repository<Database>,
@Inject(ProjectService)
private readonly projectService: ProjectService,
@Inject(DatabaseNodeService)
private readonly databaseNodeService: DatabaseNodeService
) {
super();
}
async findById(id: string): Promise<Database | null> {
return this.databaseRepository.findOne({ where: { id } });
}
async runQuery(databaseId: string, query: string) {
const database = await this.findById(databaseId);
if (!database) {
throw new Error("Database not found");
}
const dbConnection = await mysql.createConnection({
host: database.node.host,
port: database.node.port,
user: database.c_username,
password: this.decryptPassword(database.password),
database: database.database,
enableKeepAlive: true,
});
try {
const [results] = await dbConnection.execute(query);
return results;
} finally {
await dbConnection.end();
}
}
async createDatabase(
databaseNodeId: string,
projectId: string
): Promise<Database> {
const node = await this.databaseNodeService.findById(databaseNodeId);
if (!node) {
throw new Error("Database node not found");
}
const project = await this.projectService.findById(projectId);
if (!project) {
throw new Error("Project not found");
}
const c_username = `c_user_${Math.random().toString(36).substring(2, 8)}`;
const q_username = `q_user_${Math.random().toString(36).substring(2, 8)}`;
const databaseName = `db_${Math.random().toString(36).substring(2, 8)}`;
const password = this.encryptPassword(
Math.random().toString(36).substring(2, 10)
);
await this.databaseNodeService.initDatabase(
{
database: databaseName,
c_username,
q_username,
password: this.decryptPassword(password),
},
databaseNodeId
);
const database = this.databaseRepository.create({
q_username,
c_username,
database: databaseName,
password,
node,
project,
});
return await this.databaseRepository.save(database);
}
}

View File

@ -0,0 +1,88 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { DatabaseNode } from "../entities/database.node.entity";
import { Repository } from "typeorm";
import { DatabaseEncryptionService } from "../database.encryption.service";
import * as mysql from "mysql2/promise";
@Injectable()
export class DatabaseNodeService extends DatabaseEncryptionService {
constructor(
@InjectRepository(DatabaseNode)
private databaseNodeRepository: Repository<DatabaseNode>
) {
super();
}
async findById(id: string): Promise<DatabaseNode | null> {
return this.databaseNodeRepository.findOne({ where: { id } });
}
async initDatabase(
data: {
database: string;
c_username: string;
q_username: string;
password: string;
},
databaseNodeId: string
): Promise<void> {
try {
const dbConnection = await mysql.createConnection({
...(await this.getConnectionOptions(databaseNodeId)),
enableKeepAlive: true,
});
await dbConnection.execute(`CREATE DATABASE \`${data.database}\`;`);
await dbConnection.execute(
`CREATE USER '${data.c_username}'@'%' IDENTIFIED BY '${data.password}';`
);
await dbConnection.execute(
`CREATE USER '${data.q_username}'@'%' IDENTIFIED BY '${data.password}';`
);
await dbConnection.execute(
`GRANT ALL PRIVILEGES ON \`${data.database}\`.* TO '${data.c_username}'@'%';`
);
await dbConnection.execute(
`GRANT SELECT, SHOW VIEW ON \`${data.database}\`.* TO '${data.q_username}'@'%';`
);
await dbConnection.execute(`FLUSH PRIVILEGES;`);
await dbConnection.end();
} catch (error) {
console.error("Error initializing database:", error);
throw error;
}
}
async create(
host: string,
port: number,
username: string,
password: string
): Promise<DatabaseNode> {
const encryptedPassword = this.encryptPassword(password);
const databaseNode = this.databaseNodeRepository.create({
host,
port,
username,
password: encryptedPassword,
});
return this.databaseNodeRepository.save(databaseNode);
}
async getConnectionOptions(id: string) {
const node = await this.databaseNodeRepository.findOne({ where: { id } });
if (!node) {
throw new Error("Database node not found");
}
return {
host: node.host,
port: node.port,
username: node.username,
password: this.decryptPassword(node.password),
};
}
}

View File

@ -0,0 +1,38 @@
import {
Column,
Entity,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { Migration } from "./migration.entity";
import { Project } from "src/project/entities/project.entity";
import { DatabaseNode } from "./database.node.entity";
@Entity("database")
export class Database {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ type: "varchar", length: 255 })
q_username: string;
@Column({ type: "varchar", length: 255 })
c_username: string;
@Column({ type: "varchar", length: 255 })
password: string;
@Column({ type: "varchar", length: 255 })
database: string;
@OneToMany(() => Migration, (migration) => migration.database)
migrations: Migration[];
@OneToOne(() => Project, (project) => project.database)
project: Project;
@ManyToOne(() => DatabaseNode, (node) => node.databases)
node: DatabaseNode;
}

View File

@ -0,0 +1,23 @@
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { Database } from "./database.entity";
@Entity("databaseNode")
export class DatabaseNode {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ type: "varchar", length: 255 })
host: string;
@Column({ type: "int" })
port: number;
@Column({ type: "varchar", length: 255 })
username: string;
@Column({ type: "varchar", length: 255 })
password: string;
@OneToMany(() => Database, (database) => database.node)
databases: Database[];
}

View File

@ -0,0 +1,35 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { Database } from "./database.entity";
@Entity("migration")
export class Migration {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
appliedAt: Date;
@Column({ type: "tinyint", default: 1 })
isApplied: number;
@Column({ type: "tinyint", default: 1 })
isValid: number;
@Column({ type: "longtext", nullable: false })
up: string;
@Column({ type: "longtext", nullable: false })
down: string;
@ManyToOne(() => Database, (database) => database.id)
database: Database;
@CreateDateColumn()
createdAt: Date;
}

View File

@ -0,0 +1,11 @@
import { forwardRef, Module } from "@nestjs/common";
import { MigrationService } from "./migration.service";
import { DatabaseModule } from "src/database/database.module";
@Module({
imports: [forwardRef(() => DatabaseModule)],
controllers: [],
providers: [MigrationService],
exports: [MigrationService],
})
export class MigrationModule {}

View File

@ -0,0 +1,95 @@
import { Inject, Injectable } from "@nestjs/common";
import { IsNull, Not, Repository } from "typeorm";
import { Migration } from "../entities/migration.entity";
import { InjectRepository } from "@nestjs/typeorm";
import { DatabaseManagerService } from "../database/database.manager.service";
@Injectable()
export class MigrationService {
constructor(
@InjectRepository(Migration)
private readonly migrationRepository: Repository<Migration>,
@Inject(DatabaseManagerService)
private readonly databaseService: DatabaseManagerService
) {}
async create(
up: string,
down: string,
databaseId: string
): Promise<Migration> {
const database = await this.databaseService.findById(databaseId);
if (!database) {
throw new Error("Database not found");
}
const migration = this.migrationRepository.create({
up,
down,
database,
});
return await this.migrationRepository.save(migration);
}
async up(databaseId: string): Promise<Migration[]> {
const database = await this.databaseService.findById(databaseId);
if (!database) {
throw new Error("Database not found");
}
const migrations = await this.migrationRepository.find({
where: { database: { id: database.id }, appliedAt: null },
order: { createdAt: "ASC" },
});
const completedMigrations: Migration[] = [];
for (const migration of migrations) {
try {
await this.databaseService.runQuery(database.id, migration.up);
migration.appliedAt = new Date();
await this.migrationRepository.save(migration);
completedMigrations.push(migration);
} catch (error) {
throw new Error(
`Failed to apply migration ${migration.id}: ${error.message}`
);
}
}
return completedMigrations;
}
async down(databaseId: string): Promise<Migration[]> {
const database = await this.databaseService.findById(databaseId);
if (!database) {
throw new Error("Database not found");
}
const migrations = await this.migrationRepository.find({
where: { database: { id: database.id }, appliedAt: Not(IsNull()) },
order: { appliedAt: "DESC" },
});
const revertedMigrations: Migration[] = [];
for (const migration of migrations) {
try {
await this.databaseService.runQuery(database.id, migration.down);
migration.appliedAt = null;
await this.migrationRepository.save(migration);
revertedMigrations.push(migration);
} catch (error) {
throw new Error(
`Failed to revert migration ${migration.id}: ${error.message}`
);
}
}
return revertedMigrations;
}
}