diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index 29293a4a8a..f283211a14 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -55,6 +55,7 @@ import { User } from 'src/user/entities/user' import { GroupWithRole } from 'src/group/entities/group' import { isEqual } from 'lodash' import { InstanceService } from 'src/instance/instance.service' +import { QuotaService } from 'src/user/quota.service' @ApiTags('Application') @Controller('applications') @@ -71,6 +72,7 @@ export class ApplicationController { private readonly account: AccountService, private readonly resource: ResourceService, private readonly runtimeDomain: RuntimeDomainService, + private readonly quotaServiceTsService: QuotaService, ) {} /** @@ -117,10 +119,14 @@ export class ApplicationController { } } - // one user can only have 20 applications in one region - const count = await this.application.countByUser(user._id) - if (count > 20) { - return ResponseUtil.error(`too many applications, limit is 20`) + // check if a user exceeds the resource limit in a region + const limitResource = await this.quotaServiceTsService.resourceLimit( + user._id, + dto.cpu, + dto.memory, + ) + if (limitResource) { + return ResponseUtil.error(limitResource) } // check account balance @@ -311,6 +317,7 @@ export class ApplicationController { @Param('appid') appid: string, @Body() dto: UpdateApplicationBundleDto, @InjectApplication() app: ApplicationWithRelations, + @InjectUser() user: User, ) { const error = dto.autoscaling.validate() if (error) { @@ -341,6 +348,17 @@ export class ApplicationController { return ResponseUtil.error('invalid resource specification') } + // check if a user exceeds the resource limit in a region + const limitResource = await this.quotaServiceTsService.resourceLimit( + user._id, + dto.cpu, + dto.memory, + appid, + ) + if (limitResource) { + return ResponseUtil.error(limitResource) + } + const doc = await this.application.updateBundle(appid, dto, isTrialTier) // restart running application if cpu or memory changed diff --git a/server/src/application/application.module.ts b/server/src/application/application.module.ts index c24670a3e7..3420cafda8 100644 --- a/server/src/application/application.module.ts +++ b/server/src/application/application.module.ts @@ -18,6 +18,8 @@ import { BundleService } from './bundle.service' import { ResourceService } from 'src/billing/resource.service' import { FunctionRecycleBinService } from 'src/recycle-bin/cloud-function/function-recycle-bin.service' import { HttpModule } from '@nestjs/axios' +import { QuotaService } from 'src/user/quota.service' +import { SettingService } from 'src/setting/setting.service' @Module({ imports: [ @@ -41,6 +43,8 @@ import { HttpModule } from '@nestjs/axios' WebsiteService, BundleService, ResourceService, + QuotaService, + SettingService, ], exports: [ ApplicationService, diff --git a/server/src/database/database.controller.ts b/server/src/database/database.controller.ts index b7ff49279f..54be7b033f 100644 --- a/server/src/database/database.controller.ts +++ b/server/src/database/database.controller.ts @@ -32,6 +32,9 @@ import { unlink, writeFile } from 'node:fs/promises' import * as os from 'os' import { ResponseUtil } from 'src/utils/response' import { ImportDatabaseDto } from './dto/import-database.dto' +import { InjectUser } from 'src/utils/decorator' +import { User } from 'src/user/entities/user' +import { QuotaService } from 'src/user/quota.service' @ApiTags('Database') @ApiBearerAuth('Authorization') @@ -39,7 +42,10 @@ import { ImportDatabaseDto } from './dto/import-database.dto' export class DatabaseController { private readonly logger = new Logger(DatabaseController.name) - constructor(private readonly dbService: DatabaseService) {} + constructor( + private readonly dbService: DatabaseService, + private readonly quotaService: QuotaService, + ) {} /** * The database proxy for database management @@ -91,7 +97,15 @@ export class DatabaseController { async exportDatabase( @Param('appid') appid: string, @Res({ passthrough: true }) res: IResponse, + @InjectUser() user: User, ) { + // Check if user data import and export is out of limits + const databaseSyncLimit = await this.quotaService.databaseSyncLimit( + user._id, + ) + if (databaseSyncLimit) { + return ResponseUtil.error('Database sync limit exceeded') + } const tempFilePath = path.join( os.tmpdir(), 'mongodb-data', @@ -104,7 +118,7 @@ export class DatabaseController { mkdirSync(path.dirname(tempFilePath), { recursive: true }) } - await this.dbService.exportDatabase(appid, tempFilePath) + await this.dbService.exportDatabase(appid, tempFilePath, user._id) const filename = path.basename(tempFilePath) res.set({ @@ -132,7 +146,15 @@ export class DatabaseController { @UploadedFile() file: Express.Multer.File, @Body('sourceAppid') sourceAppid: string, @Param('appid') appid: string, + @InjectUser() user: User, ) { + // Check if user data import and export is out of limits + const databaseSyncLimit = await this.quotaService.databaseSyncLimit( + user._id, + ) + if (databaseSyncLimit) { + return ResponseUtil.error('Database sync limit exceeded') + } // check if db is valid if (!/^[a-z0-9]{6}$/.test(sourceAppid)) { return ResponseUtil.error('Invalid source appid') @@ -156,7 +178,12 @@ export class DatabaseController { try { await writeFile(tempFilePath, file.buffer) - await this.dbService.importDatabase(appid, sourceAppid, tempFilePath) + await this.dbService.importDatabase( + appid, + sourceAppid, + tempFilePath, + user._id, + ) return ResponseUtil.ok({}) } finally { if (existsSync(tempFilePath)) await unlink(tempFilePath) diff --git a/server/src/database/database.module.ts b/server/src/database/database.module.ts index 6b0193a16e..73cb008d6a 100644 --- a/server/src/database/database.module.ts +++ b/server/src/database/database.module.ts @@ -12,6 +12,8 @@ import { ApplicationService } from 'src/application/application.service' import { BundleService } from 'src/application/bundle.service' import { DatabaseUsageLimitTaskService } from './database-usage-limit-task.service' import { DatabaseUsageCaptureTaskService } from './database-usage-capture-task.service' +import { QuotaService } from 'src/user/quota.service' +import { SettingService } from 'src/setting/setting.service' @Module({ imports: [], @@ -31,6 +33,8 @@ import { DatabaseUsageCaptureTaskService } from './database-usage-capture-task.s BundleService, DatabaseUsageCaptureTaskService, DatabaseUsageLimitTaskService, + SettingService, + QuotaService, ], exports: [ CollectionService, diff --git a/server/src/database/database.service.ts b/server/src/database/database.service.ts index 707955551d..52b8a9a06f 100644 --- a/server/src/database/database.service.ts +++ b/server/src/database/database.service.ts @@ -16,6 +16,8 @@ import { } from './entities/database' import { exec } from 'node:child_process' import { promisify } from 'node:util' +import { DatabaseSyncRecord } from './entities/database-sync-record' +import { ObjectId } from 'mongodb' const p_exec = promisify(exec) @@ -211,7 +213,7 @@ export class DatabaseService { } } - async exportDatabase(appid: string, filePath: string) { + async exportDatabase(appid: string, filePath: string, uid: ObjectId) { const region = await this.regionService.findByAppId(appid) const database = await this.findOne(appid) assert(database, 'Database not found') @@ -223,6 +225,9 @@ export class DatabaseService { await p_exec( `mongodump --uri='${connectionUri}' --gzip --archive=${filePath}`, ) + await this.db + .collection('DatabaseSyncRecord') + .insertOne({ uid, createdAt: new Date() }) } catch (error) { this.logger.error(`failed to export db ${appid}`, error) throw error @@ -233,6 +238,7 @@ export class DatabaseService { appid: string, dbName: string, filePath: string, + uid: ObjectId, ): Promise { const region = await this.regionService.findByAppId(appid) const database = await this.findOne(appid) @@ -245,6 +251,9 @@ export class DatabaseService { await p_exec( `mongorestore --uri='${connectionUri}' --gzip --archive='${filePath}' --nsFrom="${dbName}.*" --nsTo="${appid}.*" -v --nsInclude="${dbName}.*"`, ) + await this.db + .collection('DatabaseSyncRecord') + .insertOne({ uid, createdAt: new Date() }) } catch (error) { console.error(`failed to import db to ${appid}:`, error) throw error diff --git a/server/src/database/entities/database-sync-record.ts b/server/src/database/entities/database-sync-record.ts new file mode 100644 index 0000000000..ea7a09cf08 --- /dev/null +++ b/server/src/database/entities/database-sync-record.ts @@ -0,0 +1,6 @@ +import { ObjectId } from 'mongodb' + +export class DatabaseSyncRecord { + uid: ObjectId + createdAt: Date +} diff --git a/server/src/initializer/initializer.service.ts b/server/src/initializer/initializer.service.ts index 5b3072a661..d619cffeac 100644 --- a/server/src/initializer/initializer.service.ts +++ b/server/src/initializer/initializer.service.ts @@ -12,6 +12,7 @@ import { AuthProvider, AuthProviderState, } from 'src/authentication/entities/auth-provider' +import { Setting } from 'src/setting/entities/setting' @Injectable() export class InitializerService { @@ -24,6 +25,7 @@ export class InitializerService { await this.createDefaultAuthProvider() await this.createDefaultResourceOptions() await this.createDefaultResourceBundles() + await this.createDefaultSettings() } async createDefaultRegion() { @@ -331,4 +333,53 @@ export class InitializerService { this.logger.verbose('Created default resource templates') } + + // create default settings + async createDefaultSettings() { + // check if exists + const existed = await this.db + .collection('Setting') + .countDocuments() + + if (existed) { + this.logger.debug('default settings already exists') + return + } + + await this.db.collection('Setting').insertOne({ + public: false, + key: 'resource_limit', + value: 'default', + desc: 'resource limit of user', + metadata: { + limitOfCPU: 20000, + limitOfMemory: 20480, + limitCountOfApplication: 20, + limitOfDatabaseSyncCount: { + countLimit: 10, + timePeriodInSeconds: 86400, + }, + }, + }) + + await this.db.collection('Setting').insertOne({ + public: true, + key: 'invitation_profit', + value: '0', + desc: 'Set up invitation rebate', + }) + + await this.db.collection('Setting').insertOne({ + public: true, + key: 'id_verify', + value: 'off', // on | off + desc: 'real name authentication', + metadata: { + message: '', + authenticateSite: '', + }, + }) + + this.logger.verbose('Created default settings') + } } diff --git a/server/src/user/entities/user-quota.ts b/server/src/user/entities/user-quota.ts index b3fc125bae..2921f0730c 100644 --- a/server/src/user/entities/user-quota.ts +++ b/server/src/user/entities/user-quota.ts @@ -1,6 +1,11 @@ import { ApiProperty } from '@nestjs/swagger' import { ObjectId } from 'mongodb' +type LimitOfDatabaseSyncCount = { + countLimit: number + timePeriodInSeconds: number +} + export class UserQuota { @ApiProperty({ type: String }) _id?: ObjectId @@ -17,6 +22,12 @@ export class UserQuota { @ApiProperty() limitCountOfApplication: number + @ApiProperty({ + description: 'Limits of database synchronization count and time period.', + type: { countLimit: Number, timePeriodInSeconds: Number }, + }) + limitOfDatabaseSyncCount: LimitOfDatabaseSyncCount + @ApiProperty() createdAt: Date diff --git a/server/src/user/quota.service.ts b/server/src/user/quota.service.ts new file mode 100644 index 0000000000..741624783e --- /dev/null +++ b/server/src/user/quota.service.ts @@ -0,0 +1,126 @@ +import { Inject, Injectable, Logger, forwardRef } from '@nestjs/common' +import { ObjectId } from 'mongodb' +import { ApplicationService } from 'src/application/application.service' +import { ApplicationWithRelations } from 'src/application/entities/application' +import { SystemDatabase } from 'src/system-database' +import { UserQuota } from './entities/user-quota' +import { SettingService } from 'src/setting/setting.service' +import { DatabaseSyncRecord } from 'src/database/entities/database-sync-record' + +@Injectable() +export class QuotaService { + private readonly logger = new Logger(QuotaService.name) + private db = SystemDatabase.db + constructor( + @Inject(forwardRef(() => ApplicationService)) + private readonly applicationService: ApplicationService, + private readonly settingService: SettingService, + ) {} + + async resourceLimit( + uid: ObjectId, + cpu: number, + memory: number, + appid?: string, + ) { + const userQuota = await this.getUserQuota(uid) + if (!userQuota) { + return 'user quota not found' + } + + const allApplications: ApplicationWithRelations[] = + await this.applicationService.findAllByUser(uid) + + let totalLimitCPU = 0 + let totalLimitMemory = 0 + + for (const app of allApplications) { + if (app.bundle && app.bundle.resource && app.appid !== appid) { + totalLimitCPU += app.bundle.resource.limitCPU + totalLimitMemory += app.bundle.resource.limitMemory + } + } + + if (totalLimitCPU + cpu > userQuota.limitOfCPU) { + return 'cpu exceeds resource limit' + } + + if (totalLimitMemory + memory > userQuota.limitOfMemory) { + return 'memory exceeds resource limit' + } + + if (allApplications.length > userQuota.limitCountOfApplication) { + return 'application counts exceeds resource limit' + } + + return null + } + + async databaseSyncLimit(uid: ObjectId) { + const userQuota = await this.getUserQuota(uid) + if (!userQuota) { + return true + } + // Calculate the time range for counting DatabaseSync documents + const currentTime = new Date() + const startTime = new Date( + currentTime.getTime() - + userQuota.limitOfDatabaseSyncCount.timePeriodInSeconds * 1000, + ) + + const counts = await this.db + .collection('DatabaseSyncRecord') + .countDocuments({ + uid: uid, + createdAt: { + $gte: startTime, + $lte: currentTime, + }, + }) + + if (counts >= userQuota.limitOfDatabaseSyncCount.countLimit) { + return true // The user has exceeded their limit + } + + return false + } + + async getUserQuota(uid: ObjectId) { + const defaultUserQuotaSetting = await this.settingService.findOne( + 'resource_limit', + ) + + if (!defaultUserQuotaSetting) { + return null + } + + const defaultUserQuota: UserQuota = { + uid, + limitOfCPU: defaultUserQuotaSetting.metadata.limitOfCPU, + limitOfMemory: defaultUserQuotaSetting.metadata.limitOfMemory, + limitCountOfApplication: + defaultUserQuotaSetting.metadata.limitCountOfApplication, + limitOfDatabaseSyncCount: { + countLimit: + defaultUserQuotaSetting.metadata.limitOfDatabaseSyncCount.countLimit, + timePeriodInSeconds: + defaultUserQuotaSetting.metadata.limitOfDatabaseSyncCount + .timePeriodInSeconds, + }, + createdAt: new Date(), + updatedAt: new Date(), + } + + let userQuota: UserQuota = await this.db + .collection('UserQuota') + .findOne({ uid }) + + if (!userQuota) { + await this.db + .collection('UserQuota') + .insertOne(defaultUserQuota) + userQuota = defaultUserQuota + } + return userQuota + } +} diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 46679678e9..67c5c2cbc8 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -20,7 +20,11 @@ import { import { UserService } from './user.service' import { FileInterceptor } from '@nestjs/platform-express' import { IRequest, IResponse } from 'src/utils/interface' -import { ApiResponseObject, ResponseUtil } from 'src/utils/response' +import { + ApiResponseObject, + ResponseUtil, + ApiResponseString, +} from 'src/utils/response' import { JwtAuthGuard } from 'src/authentication/jwt.auth.guard' import { User, UserWithProfile } from './entities/user' import { SmsService } from 'src/authentication/phone/sms.service' @@ -32,6 +36,7 @@ import { ObjectId } from 'mongodb' import { EmailService } from 'src/authentication/email/email.service' import { EmailVerifyCodeType } from 'src/authentication/entities/email-verify-code' import { BindEmailDto } from './dto/bind-email.dto' +import { QuotaService } from './quota.service' import { InjectUser } from 'src/utils/decorator' @ApiTags('User') @@ -42,6 +47,7 @@ export class UserController { private readonly userService: UserService, private readonly smsService: SmsService, private readonly emailService: EmailService, + private readonly quotaServiceTsService: QuotaService, ) {} /** diff --git a/server/src/user/user.module.ts b/server/src/user/user.module.ts index 1af6522d3a..ed1aaf7bae 100644 --- a/server/src/user/user.module.ts +++ b/server/src/user/user.module.ts @@ -3,9 +3,18 @@ import { UserService } from './user.service' import { PatService } from './pat.service' import { PatController } from './pat.controller' import { UserController } from './user.controller' +import { QuotaService } from './quota.service' +import { ApplicationService } from 'src/application/application.service' +import { SettingService } from 'src/setting/setting.service' @Module({ - providers: [UserService, PatService], + providers: [ + UserService, + PatService, + QuotaService, + ApplicationService, + SettingService, + ], exports: [UserService], controllers: [PatController, UserController], })