From 19acb208cab7e9f7a7f0e90239e289f5b3194f5c Mon Sep 17 00:00:00 2001 From: maslow Date: Tue, 9 May 2023 16:37:49 +0800 Subject: [PATCH 01/48] design metering schema --- server/prisma/schema.prisma | 67 +++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 29503c2ace..f431223226 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -54,6 +54,13 @@ type Note { level NoteLevel @default(Info) } +enum BaseState { + Active + Inactive + + @@map("CommonState") +} + // user schemas model User { @@ -189,7 +196,7 @@ model Bundle { displayName String regionId String @db.ObjectId priority Int @default(0) - state String @default("Active") // Active, Inactive + state BaseState @default(Active) limitCountPerUser Int // limit count of application per user could create subscriptionOptions BundleSubscriptionOption[] maxRenewalTime Int // in seconds @@ -333,14 +340,13 @@ model SubscriptionUpgrade { } // accounts schemas - model Account { - id String @id @default(auto()) @map("_id") @db.ObjectId - balance Int @default(0) - state String @default("Active") // Active, Inactive - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String @unique @db.ObjectId + id String @id @default(auto()) @map("_id") @db.ObjectId + balance Int @default(0) + state BaseState @default(Active) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy String @unique @db.ObjectId } model AccountTransaction { @@ -393,13 +399,54 @@ model PaymentChannel { type PaymentChannelType name String spec Json - state String @default("Active") // Active, Inactive + state BaseState @default(Active) notes Note[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } -// application schemas +// Ponit account schemas + +model PointAccount { + id String @id @default(auto()) @map("_id") @db.ObjectId + balance Int @default(0) + state BaseState @default(Active) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy String @unique @db.ObjectId +} + +model PointAccountTransaction { + id String @id @default(auto()) @map("_id") @db.ObjectId + accountId String @db.ObjectId + amount Int + balance Int + message String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum PointAccountChargePhase { + Pending + Paid + Failed +} + +model PointAccountChargeOrder { + id String @id @default(auto()) @map("_id") @db.ObjectId + accountId String @db.ObjectId + amount Int + phase PointAccountChargePhase @default(Pending) + channel PaymentChannelType + result Json? + message String? + createdAt DateTime @default(now()) + lockedAt DateTime + updatedAt DateTime @updatedAt + createdBy String @db.ObjectId +} + +// Application schemas // desired state of application enum ApplicationState { From ef434e783140e33df050d15508014a51ef70787c Mon Sep 17 00:00:00 2001 From: maslow Date: Sun, 14 May 2023 17:19:56 +0000 Subject: [PATCH 02/48] add region bundle schema --- server/prisma/schema.prisma | 32 +++++++++++++++---- .../application/dto/create-application.dto.ts | 24 ++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 server/src/application/dto/create-application.dto.ts diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 7e4601d306..180d82219b 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -57,8 +57,6 @@ type Note { enum BaseState { Active Inactive - - @@map("CommonState") } // user schemas @@ -162,6 +160,28 @@ model Region { bundles Bundle[] } +enum RegionBundleType { + CPU + Memory + Database + Storage + Network +} + +type RegionBundleOption { + value Int +} + +model RegionBundle { + id String @id @default(auto()) @map("_id") @db.ObjectId + regionId String @db.ObjectId + type RegionBundleType @unique + price Int @default(0) + options RegionBundleOption[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + type BundleResource { limitCPU Int // 1000 = 1 core limitMemory Int // in MB @@ -170,7 +190,7 @@ type BundleResource { databaseCapacity Int // in MB storageCapacity Int // in MB - networkTrafficOutbound Int // in MB + networkTrafficOutbound Int? // in MB limitCountOfCloudFunction Int // limit count of cloud function per application limitCountOfBucket Int // limit count of bucket per application @@ -216,9 +236,9 @@ model Bundle { model ApplicationBundle { id String @id @default(auto()) @map("_id") @db.ObjectId appid String @unique - bundleId String @db.ObjectId - name String - displayName String + bundleId String? @db.ObjectId + name String? + displayName String? resource BundleResource createdAt DateTime @default(now()) diff --git a/server/src/application/dto/create-application.dto.ts b/server/src/application/dto/create-application.dto.ts new file mode 100644 index 0000000000..7a3237df17 --- /dev/null +++ b/server/src/application/dto/create-application.dto.ts @@ -0,0 +1,24 @@ +import { ApiPropertyOptional } from '@nestjs/swagger' +import { ApplicationState } from '@prisma/client' +import { IsIn, IsString, Length } from 'class-validator' + +const STATES = [ApplicationState.Running, ApplicationState.Stopped] +export class CreateApplicationDto { + /** + * Application name + */ + @ApiPropertyOptional() + @IsString() + @Length(1, 64) + name?: string + + @ApiPropertyOptional({ + enum: ApplicationState, + }) + @IsIn(STATES) + state?: ApplicationState + + validate() { + return null + } +} From bc369802ba5c51acb86fd12b24ba2d2b83e64849 Mon Sep 17 00:00:00 2001 From: maslow Date: Tue, 16 May 2023 13:25:14 +0000 Subject: [PATCH 03/48] remove prisma in some modules --- server/prisma/schema.prisma | 141 +------ server/src/app.module.ts | 2 - .../application/application-task.service.ts | 21 +- .../src/application/application.controller.ts | 72 +++- server/src/application/application.module.ts | 3 +- server/src/application/application.service.ts | 390 ++++++++++++------ .../src/application/configuration.service.ts | 19 +- .../application/dto/create-application.dto.ts | 43 +- .../application/dto/update-application.dto.ts | 5 +- .../entities/application-bundle.ts | 34 ++ .../entities/application-configuration.ts | 15 + .../src/application/entities/application.ts | 49 +++ server/src/application/entities/runtime.ts | 21 + server/src/application/environment.service.ts | 54 +-- .../collection/collection.controller.ts | 2 +- server/src/database/database.service.ts | 41 +- .../{collection.entity.ts => collection.ts} | 0 .../src/database/entities/database-policy.ts | 32 ++ server/src/database/entities/database.ts | 31 ++ server/src/database/entities/policy.entity.ts | 1 - server/src/database/mongo.service.ts | 2 +- .../database/policy/policy-rule.controller.ts | 8 +- .../database/policy/policy-rule.service.ts | 109 ++--- .../src/database/policy/policy.controller.ts | 4 +- server/src/database/policy/policy.service.ts | 193 +++++---- .../src/gateway/apisix-custom-cert.service.ts | 3 +- server/src/gateway/apisix.service.ts | 3 +- server/src/instance/instance.service.ts | 2 +- server/src/region/cluster/cluster.service.ts | 2 +- server/src/region/entities/region.ts | 50 +++ server/src/region/region.service.ts | 97 ++--- server/src/storage/bucket.service.ts | 8 +- server/src/storage/entities/storage-bucket.ts | 33 ++ server/src/storage/entities/storage-user.ts | 30 ++ server/src/storage/minio/minio.service.ts | 3 +- server/src/storage/storage.service.ts | 5 +- .../dto/create-subscription.dto.ts | 47 --- .../dto/renew-subscription.dto.ts | 9 - .../dto/upgrade-subscription.dto.ts | 11 - .../src/subscription/renewal-task.service.ts | 170 -------- .../subscription/subscription-task.service.ts | 287 ------------- .../subscription/subscription.controller.ts | 275 ------------ .../src/subscription/subscription.module.ts | 19 - .../src/subscription/subscription.service.ts | 124 ------ server/src/utils/interface.ts | 3 +- 45 files changed, 933 insertions(+), 1540 deletions(-) create mode 100644 server/src/application/entities/application-bundle.ts create mode 100644 server/src/application/entities/application-configuration.ts create mode 100644 server/src/application/entities/application.ts create mode 100644 server/src/application/entities/runtime.ts rename server/src/database/entities/{collection.entity.ts => collection.ts} (100%) create mode 100644 server/src/database/entities/database-policy.ts create mode 100644 server/src/database/entities/database.ts delete mode 100644 server/src/database/entities/policy.entity.ts create mode 100644 server/src/region/entities/region.ts create mode 100644 server/src/storage/entities/storage-bucket.ts create mode 100644 server/src/storage/entities/storage-user.ts delete mode 100644 server/src/subscription/dto/create-subscription.dto.ts delete mode 100644 server/src/subscription/dto/renew-subscription.dto.ts delete mode 100644 server/src/subscription/dto/upgrade-subscription.dto.ts delete mode 100644 server/src/subscription/renewal-task.service.ts delete mode 100644 server/src/subscription/subscription-task.service.ts delete mode 100644 server/src/subscription/subscription.controller.ts delete mode 100644 server/src/subscription/subscription.module.ts delete mode 100644 server/src/subscription/subscription.service.ts diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 180d82219b..53e57b43dd 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -236,8 +236,11 @@ model Bundle { model ApplicationBundle { id String @id @default(auto()) @map("_id") @db.ObjectId appid String @unique + // @decrecapted bundleId String? @db.ObjectId + // @decrecapted name String? + // @decrecapted displayName String? resource BundleResource @@ -264,102 +267,6 @@ model Runtime { Application Application[] } -// subscriptions schemas - -// Subscription section mainly consists of two models: Subscription and SubscriptionRenewal. -// -// 1. Subscription: Represents the state, phase, and renewal plan of a subscription. It includes -// the created, updated, and deleted states (SubscriptionState enum); the pending, valid, expired, -// expired and stopped, and deleted phases (SubscriptionPhase enum); and manual, monthly, or -// yearly renewal plans (SubscriptionRenewalPlan enum). This model also contains the associated -// application (Application). -// -// 2. SubscriptionRenewal: Represents the state, duration, and amount of a subscription renewal. -// It includes the pending, paid, and failed renewal phases (SubscriptionRenewalPhase enum). - -enum SubscriptionState { - Created - Deleted -} - -enum SubscriptionPhase { - Pending - Valid - Expired - ExpiredAndStopped - Deleted -} - -enum SubscriptionRenewalPlan { - Manual - Monthly - Yearly -} - -type SubscriptionApplicationCreateInput { - name String - state String - runtimeId String - regionId String -} - -model Subscription { - id String @id @default(auto()) @map("_id") @db.ObjectId - input SubscriptionApplicationCreateInput - bundleId String @db.ObjectId - appid String @unique - state SubscriptionState @default(Created) - phase SubscriptionPhase @default(Pending) - renewalPlan SubscriptionRenewalPlan @default(Manual) - expiredAt DateTime - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String @db.ObjectId - - application Application? -} - -enum SubscriptionRenewalPhase { - Pending - Paid - Failed -} - -model SubscriptionRenewal { - id String @id @default(auto()) @map("_id") @db.ObjectId - subscriptionId String @db.ObjectId - duration Int // in seconds - amount Int - phase SubscriptionRenewalPhase @default(Pending) - message String? - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String @db.ObjectId -} - -// desired state of resource -enum SubscriptionUpgradePhase { - Pending - Completed - Failed -} - -model SubscriptionUpgrade { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - subscriptionId String - originalBundleId String @db.ObjectId - targetBundleId String @db.ObjectId - phase SubscriptionUpgradePhase @default(Pending) - restart Boolean @default(false) - message String? - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - // accounts schemas model Account { id String @id @default(auto()) @map("_id") @db.ObjectId @@ -426,47 +333,6 @@ model PaymentChannel { updatedAt DateTime @updatedAt } -// Ponit account schemas - -model PointAccount { - id String @id @default(auto()) @map("_id") @db.ObjectId - balance Int @default(0) - state BaseState @default(Active) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String @unique @db.ObjectId -} - -model PointAccountTransaction { - id String @id @default(auto()) @map("_id") @db.ObjectId - accountId String @db.ObjectId - amount Int - balance Int - message String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -enum PointAccountChargePhase { - Pending - Paid - Failed -} - -model PointAccountChargeOrder { - id String @id @default(auto()) @map("_id") @db.ObjectId - accountId String @db.ObjectId - amount Int - phase PointAccountChargePhase @default(Pending) - channel PaymentChannelType - result Json? - message String? - createdAt DateTime @default(now()) - lockedAt DateTime - updatedAt DateTime @updatedAt - createdBy String @db.ObjectId -} - // Application schemas // desired state of application @@ -511,7 +377,6 @@ model Application { database Database? domain RuntimeDomain? bundle ApplicationBundle? - subscription Subscription @relation(fields: [appid], references: [appid]) } type EnvironmentVariable { diff --git a/server/src/app.module.ts b/server/src/app.module.ts index eb1e6eadb5..1d5f0ca606 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -18,7 +18,6 @@ import { TriggerModule } from './trigger/trigger.module' import { RegionModule } from './region/region.module' import { GatewayModule } from './gateway/gateway.module' import { PrismaModule } from './prisma/prisma.module' -import { SubscriptionModule } from './subscription/subscription.module' import { AccountModule } from './account/account.module' import { SettingModule } from './setting/setting.module' @@ -44,7 +43,6 @@ import { SettingModule } from './setting/setting.module' RegionModule, GatewayModule, PrismaModule, - SubscriptionModule, AccountModule, SettingModule, ], diff --git a/server/src/application/application-task.service.ts b/server/src/application/application-task.service.ts index d17cc33fec..7639e5a3ea 100644 --- a/server/src/application/application-task.service.ts +++ b/server/src/application/application-task.service.ts @@ -1,13 +1,6 @@ import { Injectable, Logger } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' -import { - Application, - ApplicationPhase, - ApplicationState, - DatabasePhase, - DomainPhase, - StoragePhase, -} from '@prisma/client' +import { DomainPhase, StoragePhase } from '@prisma/client' import * as assert from 'node:assert' import { StorageService } from '../storage/storage.service' import { DatabaseService } from '../database/database.service' @@ -23,6 +16,12 @@ import { BundleService } from 'src/region/bundle.service' import { WebsiteService } from 'src/website/website.service' import { PolicyService } from 'src/database/policy/policy.service' import { BucketDomainService } from 'src/gateway/bucket-domain.service' +import { + Application, + ApplicationPhase, + ApplicationState, +} from './entities/application' +import { DatabasePhase } from 'src/database/entities/database' @Injectable() export class ApplicationTaskService { @@ -103,7 +102,11 @@ export class ApplicationTaskService { const namespace = await this.clusterService.getAppNamespace(region, appid) if (!namespace) { this.logger.debug(`Creating namespace for application ${appid}`) - await this.clusterService.createAppNamespace(region, appid, app.createdBy) + await this.clusterService.createAppNamespace( + region, + appid, + app.createdBy.toString(), + ) return await this.unlock(appid) } diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index cea4492c9b..ec288e5e54 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -7,6 +7,7 @@ import { UseGuards, Req, Logger, + Post, } from '@nestjs/common' import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' import { IRequest } from '../utils/interface' @@ -18,11 +19,12 @@ import { ApplicationService } from './application.service' import { FunctionService } from '../function/function.service' import { StorageService } from 'src/storage/storage.service' import { RegionService } from 'src/region/region.service' -import { - ApplicationPhase, - ApplicationState, - SubscriptionPhase, -} from '@prisma/client' +import { CreateApplicationDto } from './dto/create-application.dto' +import { AccountService } from 'src/account/account.service' +import { ApplicationPhase, ApplicationState } from './entities/application' +import { SystemDatabase } from 'src/database/system-database' +import { Runtime } from './entities/runtime' +import { ObjectId } from 'mongodb' @ApiTags('Application') @Controller('applications') @@ -35,8 +37,49 @@ export class ApplicationController { private readonly funcService: FunctionService, private readonly regionService: RegionService, private readonly storageService: StorageService, + private readonly accountService: AccountService, ) {} + /** + * Create application + */ + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Create application' }) + @Post() + async create(@Req() req: IRequest, @Body() dto: CreateApplicationDto) { + const user = req.user + + // check regionId exists + const region = await this.regionService.findOneDesensitized( + new ObjectId(dto.regionId), + ) + if (!region) { + return ResponseUtil.error(`region ${dto.regionId} not found`) + } + + // check runtimeId exists + const runtime = await SystemDatabase.db + .collection('Runtime') + .findOne({ _id: new ObjectId(dto.runtimeId) }) + if (!runtime) { + return ResponseUtil.error(`runtime ${dto.runtimeId} not found`) + } + + // check account balance + const account = await this.accountService.findOne(user.id) + const balance = account?.balance || 0 + if (balance <= 0) { + return ResponseUtil.error(`account balance is not enough`) + } + + // create application + const appid = await this.appService.tryGenerateUniqueAppid() + await this.appService.create(user.id, appid, dto) + + const app = await this.appService.findOne(appid) + return ResponseUtil.ok(app) + } + /** * Get user application list * @param req @@ -60,11 +103,7 @@ export class ApplicationController { @UseGuards(JwtAuthGuard, ApplicationAuthGuard) @Get(':appid') async findOne(@Param('appid') appid: string) { - const data = await this.appService.findOne(appid, { - configuration: true, - domain: true, - subscription: true, - }) + const data = await this.appService.findOne(appid) // SECURITY ALERT!!! // DO NOT response this region object to client since it contains sensitive information @@ -136,12 +175,7 @@ export class ApplicationController { } // check if the corresponding subscription status has expired - const app = await this.appService.findOne(appid, { - subscription: true, - }) - if (app.subscription.phase !== SubscriptionPhase.Valid) { - return ResponseUtil.error('subscription has expired, you can not update') - } + const app = await this.appService.findOne(appid) // check: only running application can restart if ( @@ -177,10 +211,10 @@ export class ApplicationController { } // update app - const res = await this.appService.update(appid, dto) - if (res === null) { + const doc = await this.appService.update(appid, dto) + if (!doc) { return ResponseUtil.error('update application error') } - return ResponseUtil.ok(res) + return ResponseUtil.ok(doc) } } diff --git a/server/src/application/application.module.ts b/server/src/application/application.module.ts index 292227ce85..4fb1a96d72 100644 --- a/server/src/application/application.module.ts +++ b/server/src/application/application.module.ts @@ -13,9 +13,10 @@ import { GatewayModule } from 'src/gateway/gateway.module' import { ApplicationConfigurationService } from './configuration.service' import { TriggerService } from 'src/trigger/trigger.service' import { WebsiteService } from 'src/website/website.service' +import { AccountModule } from 'src/account/account.module' @Module({ - imports: [StorageModule, DatabaseModule, GatewayModule], + imports: [StorageModule, DatabaseModule, GatewayModule, AccountModule], controllers: [ApplicationController, EnvironmentVariableController], providers: [ ApplicationService, diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index 0ac0c946dd..c3ead41e16 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -1,7 +1,5 @@ import { Injectable, Logger } from '@nestjs/common' import * as nanoid from 'nanoid' -import { ApplicationPhase, ApplicationState, Prisma } from '@prisma/client' -import { PrismaService } from '../prisma/prisma.service' import { UpdateApplicationDto } from './dto/update-application.dto' import { APPLICATION_SECRET_KEY, @@ -9,158 +7,264 @@ import { TASK_LOCK_INIT_TIME, } from '../constants' import { GenerateAlphaNumericPassword } from '../utils/random' -import { CreateSubscriptionDto } from 'src/subscription/dto/create-subscription.dto' +import { CreateApplicationDto } from './dto/create-application.dto' +import { SystemDatabase } from 'src/database/system-database' +import { + Application, + ApplicationPhase, + ApplicationState, + ApplicationWithRelations, +} from './entities/application' +import { ObjectId } from 'mongodb' +import { ApplicationConfiguration } from './entities/application-configuration' +import { + ApplicationBundle, + ApplicationBundleResource, +} from './entities/application-bundle' @Injectable() export class ApplicationService { private readonly logger = new Logger(ApplicationService.name) - constructor(private readonly prisma: PrismaService) {} - async create(userid: string, appid: string, dto: CreateSubscriptionDto) { - try { - // get bundle - const bundle = await this.prisma.bundle.findFirstOrThrow({ - where: { - id: dto.bundleId, - region: { - id: dto.regionId, - }, - }, - }) + /** + * Create application + * - create configuration + * - create bundle + * - create application + */ + async create(userid: string, appid: string, dto: CreateApplicationDto) { + const client = SystemDatabase.client + const db = client.db() + const session = client.startSession() - console.log(bundle, dto.bundleId) + try { + // start transaction + session.startTransaction() - // create app in db + // create application configuration const appSecret = { name: APPLICATION_SECRET_KEY, value: GenerateAlphaNumericPassword(64), } - - const data: Prisma.ApplicationCreateInput = { - name: dto.name, - state: dto.state || ApplicationState.Running, - phase: ApplicationPhase.Creating, - tags: [], - createdBy: userid, - lockedAt: TASK_LOCK_INIT_TIME, - region: { - connect: { - id: dto.regionId, - }, - }, - bundle: { - create: { - bundleId: bundle.id, - name: bundle.name, - displayName: bundle.displayName, - resource: { ...bundle.resource }, - }, - }, - runtime: { - connect: { - id: dto.runtimeId, - }, - }, - configuration: { - create: { + await db + .collection('ApplicationConfiguration') + .insertOne( + { + appid, environments: [appSecret], dependencies: [], + createdAt: new Date(), + updatedAt: new Date(), }, + { session }, + ) + + // create application bundle + await db.collection('ApplicationBundle').insertOne( + { + appid, + resource: this.buildBundleResource(dto), + createdAt: new Date(), + updatedAt: new Date(), }, - subscription: { - connect: { - appid, - }, - }, - } + { session }, + ) - const application = await this.prisma.application.create({ data }) - if (!application) { - throw new Error('create application failed') - } + // create application + await db.collection('Application').insertOne( + { + appid, + name: dto.name, + state: dto.state || ApplicationState.Running, + phase: ApplicationPhase.Creating, + tags: [], + createdBy: new ObjectId(userid), + lockedAt: TASK_LOCK_INIT_TIME, + regionId: new ObjectId(dto.regionId), + runtimeId: new ObjectId(dto.runtimeId), + createdAt: new Date(), + updatedAt: new Date(), + }, + { session }, + ) - return application + // commit transaction + await session.commitTransaction() } catch (error) { - this.logger.error(error, error.response?.body) - return null + await session.abortTransaction() + throw Error(error) + } finally { + if (session) await session.endSession() } } async findAllByUser(userid: string) { - return this.prisma.application.findMany({ - where: { - createdBy: userid, - phase: { - not: ApplicationPhase.Deleted, - }, - }, - include: { - region: false, - bundle: true, - runtime: true, - subscription: true, - }, - }) + const db = SystemDatabase.db + + const doc = await db + .collection('Application') + .aggregate() + .match({ + createdBy: new ObjectId(userid), + phase: { $ne: ApplicationPhase.Deleted }, + }) + .lookup({ + from: 'ApplicationBundle', + localField: 'appid', + foreignField: 'appid', + as: 'bundle', + }) + .unwind('$bundle') + .lookup({ + from: 'Runtime', + localField: 'runtimeId', + foreignField: '_id', + as: 'runtime', + }) + .unwind('$runtime') + .project({ + 'bundle.resource.requestCPU': 0, + 'bundle.resource.requestMemory': 0, + }) + .toArray() + + return doc } - async findOne(appid: string, include?: Prisma.ApplicationInclude) { - const application = await this.prisma.application.findUnique({ - where: { appid }, - include: { - region: false, - bundle: include?.bundle, - runtime: include?.runtime, - configuration: include?.configuration, - domain: include?.domain, - subscription: include?.subscription, - }, - }) + async findOne(appid: string) { + const db = SystemDatabase.db - return application + const doc = await db + .collection('Application') + .aggregate() + .match({ appid }) + .lookup({ + from: 'ApplicationBundle', + localField: 'appid', + foreignField: 'appid', + as: 'bundle', + }) + .unwind('$bundle') + .lookup({ + from: 'Runtime', + localField: 'runtimeId', + foreignField: '_id', + as: 'runtime', + }) + .unwind('$runtime') + .lookup({ + from: 'ApplicationConfiguration', + localField: 'appid', + foreignField: 'appid', + as: 'configuration', + }) + .unwind('$configuration') + .lookup({ + from: 'RuntimeDomain', + localField: 'appid', + foreignField: 'appid', + as: 'domain', + }) + .unwind({ path: '$domain', preserveNullAndEmptyArrays: true }) + .project({ + 'bundle.resource.requestCPU': 0, + 'bundle.resource.requestMemory': 0, + }) + .next() + + return doc } - async update(appid: string, dto: UpdateApplicationDto) { - try { - // update app in db - const data: Prisma.ApplicationUpdateInput = { - updatedAt: new Date(), - } - if (dto.name) { - data.name = dto.name - } - if (dto.state) { - data.state = dto.state - } + async findOneUnsafe(appid: string) { + const db = SystemDatabase.db - const application = await this.prisma.application.updateMany({ - where: { - appid, - phase: { - notIn: [ApplicationPhase.Deleting, ApplicationPhase.Deleted], - }, - }, - data, + const doc = await db + .collection('Application') + .aggregate() + .match({ appid }) + .lookup({ + from: 'Region', + localField: 'regionId', + foreignField: '_id', + as: 'region', + }) + .unwind('$region') + .lookup({ + from: 'ApplicationBundle', + localField: 'appid', + foreignField: 'appid', + as: 'bundle', + }) + .unwind('$bundle') + .lookup({ + from: 'Runtime', + localField: 'runtimeId', + foreignField: '_id', + as: 'runtime', + }) + .unwind('$runtime') + .lookup({ + from: 'ApplicationConfiguration', + localField: 'appid', + foreignField: 'appid', + as: 'configuration', }) + .unwind('$configuration') + .lookup({ + from: 'RuntimeDomain', + localField: 'appid', + foreignField: 'appid', + as: 'domain', + }) + .unwind({ path: '$domain', preserveNullAndEmptyArrays: true }) + .next() - return application - } catch (error) { - this.logger.error(error, error.response?.body) - return null - } + return doc + } + + async update(appid: string, dto: UpdateApplicationDto) { + const db = SystemDatabase.db + const data: Partial = { updatedAt: new Date() } + + if (dto.name) data.name = dto.name + if (dto.state) data.state = dto.state + + const doc = await db + .collection('Application') + .findOneAndUpdate({ appid }, { $set: data }) + + return doc } async remove(appid: string) { - try { - const res = await this.prisma.application.updateMany({ - where: { appid }, - data: { state: ApplicationState.Deleted }, - }) + const db = SystemDatabase.db + const doc = await db + .collection('Application') + .findOneAndUpdate( + { appid }, + { $set: { phase: ApplicationPhase.Deleted } }, + ) - return res - } catch (error) { - this.logger.error(error, error.response?.body) - return null + return doc.value + } + + /** + * Generate unique application id + * @returns + */ + async tryGenerateUniqueAppid() { + const db = SystemDatabase.db + + for (let i = 0; i < 10; i++) { + const appid = this.generateAppID(ServerConfig.APPID_LENGTH) + const existed = await db + .collection('Application') + .findOne({ appid }) + + if (!existed) return appid } + + throw new Error('Generate appid failed') } private generateAppID(len: number) { @@ -174,22 +278,38 @@ export class ApplicationService { return prefix + nano() } - /** - * Generate unique application id - * @returns - */ - async tryGenerateUniqueAppid() { - for (let i = 0; i < 10; i++) { - const appid = this.generateAppID(ServerConfig.APPID_LENGTH) - const existed = await this.prisma.application.findUnique({ - where: { appid }, - select: { appid: true }, - }) - if (!existed) { - return appid - } - } + private buildBundleResource(dto: CreateApplicationDto) { + const requestCPU = Math.floor(dto.cpu * 0.1) + const requestMemory = Math.floor(dto.memory * 0.5) + const limitCountOfCloudFunction = Math.floor(dto.cpu * 1) - throw new Error('Generate appid failed') + const magicNumber = Math.floor(dto.cpu * 0.01) + const limitCountOfBucket = Math.max(3, magicNumber) + const limitCountOfDatabasePolicy = Math.max(3, magicNumber) + const limitCountOfTrigger = Math.max(1, magicNumber) + const limitCountOfWebsiteHosting = Math.max(3, magicNumber) + const limitDatabaseTPS = Math.floor(dto.cpu * 0.1) + const limitStorageTPS = Math.floor(dto.cpu * 1) + const reservedTimeAfterExpired = 60 * 60 * 24 * 31 // 31 days + + const resource = new ApplicationBundleResource({ + limitCPU: dto.cpu, + limitMemory: dto.memory, + requestCPU, + requestMemory, + databaseCapacity: dto.databaseCapacity, + storageCapacity: dto.storageCapacity, + + limitCountOfCloudFunction, + limitCountOfBucket, + limitCountOfDatabasePolicy, + limitCountOfTrigger, + limitCountOfWebsiteHosting, + limitDatabaseTPS, + limitStorageTPS, + reservedTimeAfterExpired, + }) + + return resource } } diff --git a/server/src/application/configuration.service.ts b/server/src/application/configuration.service.ts index cc7974b3de..5d35dc53e3 100644 --- a/server/src/application/configuration.service.ts +++ b/server/src/application/configuration.service.ts @@ -1,24 +1,26 @@ import { Injectable, Logger } from '@nestjs/common' -import { ApplicationConfiguration } from '@prisma/client' import { CN_PUBLISHED_CONF } from 'src/constants' import { DatabaseService } from 'src/database/database.service' -import { PrismaService } from 'src/prisma/prisma.service' +import { SystemDatabase } from 'src/database/system-database' +import { ApplicationConfiguration } from './entities/application-configuration' @Injectable() export class ApplicationConfigurationService { + private readonly db = SystemDatabase.db private readonly logger = new Logger(ApplicationConfigurationService.name) - constructor( - private readonly prisma: PrismaService, - private readonly databaseService: DatabaseService, - ) {} + constructor(private readonly databaseService: DatabaseService) {} async count(appid: string) { - return this.prisma.applicationConfiguration.count({ where: { appid } }) + return this.db + .collection('ApplicationConfiguration') + .countDocuments({ appid }) } async remove(appid: string) { - return this.prisma.applicationConfiguration.delete({ where: { appid } }) + return this.db + .collection('ApplicationConfiguration') + .deleteOne({ appid }) } async publish(conf: ApplicationConfiguration) { @@ -31,6 +33,7 @@ export class ApplicationConfigurationService { await coll.insertOne(conf, { session }) }) } finally { + await session.endSession() await client.close() } } diff --git a/server/src/application/dto/create-application.dto.ts b/server/src/application/dto/create-application.dto.ts index 7a3237df17..e1bbb73677 100644 --- a/server/src/application/dto/create-application.dto.ts +++ b/server/src/application/dto/create-application.dto.ts @@ -1,8 +1,9 @@ -import { ApiPropertyOptional } from '@nestjs/swagger' -import { ApplicationState } from '@prisma/client' -import { IsIn, IsString, Length } from 'class-validator' +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { IsIn, IsInt, IsNotEmpty, IsString, Length } from 'class-validator' +import { ApplicationState } from '../entities/application' + +const STATES = [ApplicationState.Running] -const STATES = [ApplicationState.Running, ApplicationState.Stopped] export class CreateApplicationDto { /** * Application name @@ -13,11 +14,43 @@ export class CreateApplicationDto { name?: string @ApiPropertyOptional({ - enum: ApplicationState, + default: ApplicationState.Running, + enum: STATES, }) @IsIn(STATES) state?: ApplicationState + @ApiProperty() + @IsNotEmpty() + @IsString() + regionId: string + + @ApiProperty() + @IsNotEmpty() + @IsString() + runtimeId: string + + // build resources + @ApiProperty({ example: 200 }) + @IsNotEmpty() + @IsInt() + cpu: number + + @ApiProperty({ example: 256 }) + @IsNotEmpty() + @IsInt() + memory: number + + @ApiProperty({ example: 2048 }) + @IsNotEmpty() + @IsInt() + databaseCapacity: number + + @ApiProperty({ example: 4096 }) + @IsNotEmpty() + @IsInt() + storageCapacity: number + validate() { return null } diff --git a/server/src/application/dto/update-application.dto.ts b/server/src/application/dto/update-application.dto.ts index 8fd34c1821..8136ac3c68 100644 --- a/server/src/application/dto/update-application.dto.ts +++ b/server/src/application/dto/update-application.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger' -import { ApplicationState } from '@prisma/client' import { IsIn, IsString, Length } from 'class-validator' +import { ApplicationState } from '../entities/application' const STATES = [ ApplicationState.Running, @@ -8,9 +8,6 @@ const STATES = [ ApplicationState.Restarting, ] export class UpdateApplicationDto { - /** - * Application name - */ @ApiPropertyOptional() @IsString() @Length(1, 64) diff --git a/server/src/application/entities/application-bundle.ts b/server/src/application/entities/application-bundle.ts new file mode 100644 index 0000000000..ac4770ddaa --- /dev/null +++ b/server/src/application/entities/application-bundle.ts @@ -0,0 +1,34 @@ +import { ObjectId } from 'mongodb' + +export class ApplicationBundle { + _id?: ObjectId + appid: string + resource: ApplicationBundleResource + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} + +export class ApplicationBundleResource { + limitCPU: number + limitMemory: number + requestCPU: number + requestMemory: number + databaseCapacity: number + storageCapacity: number + limitCountOfCloudFunction: number + limitCountOfBucket: number + limitCountOfDatabasePolicy: number + limitCountOfTrigger: number + limitCountOfWebsiteHosting: number + reservedTimeAfterExpired: number + limitDatabaseTPS: number + limitStorageTPS: number + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/application/entities/application-configuration.ts b/server/src/application/entities/application-configuration.ts new file mode 100644 index 0000000000..d8dfd31857 --- /dev/null +++ b/server/src/application/entities/application-configuration.ts @@ -0,0 +1,15 @@ +import { ObjectId } from 'mongodb' + +export type EnvironmentVariable = { + name: string + value: string +} + +export class ApplicationConfiguration { + _id?: ObjectId + appid: string + environments: EnvironmentVariable[] + dependencies: string[] + createdAt: Date + updatedAt: Date +} diff --git a/server/src/application/entities/application.ts b/server/src/application/entities/application.ts new file mode 100644 index 0000000000..55078d5c71 --- /dev/null +++ b/server/src/application/entities/application.ts @@ -0,0 +1,49 @@ +import { ObjectId } from 'mongodb' +import { Region } from 'src/region/entities/region' +import { ApplicationBundle } from './application-bundle' +import { Runtime } from './runtime' +import { ApplicationConfiguration } from './application-configuration' + +export enum ApplicationPhase { + Creating = 'Creating', + Created = 'Created', + Starting = 'Starting', + Started = 'Started', + Stopping = 'Stopping', + Stopped = 'Stopped', + Deleting = 'Deleting', + Deleted = 'Deleted', +} + +export enum ApplicationState { + Running = 'Running', + Stopped = 'Stopped', + Restarting = 'Restarting', + Deleted = 'Deleted', +} + +export class Application { + _id?: ObjectId + name: string + appid: string + regionId: ObjectId + runtimeId: ObjectId + tags: string[] + state: ApplicationState + phase: ApplicationPhase + createdAt: Date + updatedAt: Date + lockedAt: Date + createdBy: ObjectId + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} + +export interface ApplicationWithRelations extends Application { + region?: Region + bundle?: ApplicationBundle + runtime?: Runtime + configuration?: ApplicationConfiguration +} diff --git a/server/src/application/entities/runtime.ts b/server/src/application/entities/runtime.ts new file mode 100644 index 0000000000..c160b3deb6 --- /dev/null +++ b/server/src/application/entities/runtime.ts @@ -0,0 +1,21 @@ +import { ObjectId } from 'mongodb' + +export type RuntimeImageGroup = { + main: string + init: string | null + sidecar: string | null +} + +export class Runtime { + _id?: ObjectId + name: string + type: string + image: RuntimeImageGroup + state: string + version: string + latest: boolean + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/application/environment.service.ts b/server/src/application/environment.service.ts index d837e0cf21..5f0f29738a 100644 --- a/server/src/application/environment.service.ts +++ b/server/src/application/environment.service.ts @@ -1,25 +1,25 @@ import { Injectable, Logger } from '@nestjs/common' -import { PrismaService } from 'src/prisma/prisma.service' import { CreateEnvironmentDto } from './dto/create-env.dto' import { ApplicationConfigurationService } from './configuration.service' +import { SystemDatabase } from 'src/database/system-database' +import { ApplicationConfiguration } from './entities/application-configuration' +import * as assert from 'node:assert' @Injectable() export class EnvironmentVariableService { + private readonly db = SystemDatabase.db private readonly logger = new Logger(EnvironmentVariableService.name) - constructor( - private readonly prisma: PrismaService, - private readonly confService: ApplicationConfigurationService, - ) {} + constructor(private readonly confService: ApplicationConfigurationService) {} async updateAll(appid: string, dto: CreateEnvironmentDto[]) { - const res = await this.prisma.applicationConfiguration.update({ - where: { appid }, - data: { environments: { set: dto } }, - }) + const res = await this.db + .collection('ApplicationConfiguration') + .findOneAndUpdate({ appid }, { $set: { environments: dto } }) - await this.confService.publish(res) - return res.environments + assert(res?.value, 'application configuration not found') + await this.confService.publish(res.value) + return res.value.environments } /** @@ -37,30 +37,30 @@ export class EnvironmentVariableService { origin.push(dto) } - const res = await this.prisma.applicationConfiguration.update({ - where: { appid }, - data: { environments: { set: origin } }, - }) + const res = await this.db + .collection('ApplicationConfiguration') + .findOneAndUpdate({ appid }, { $set: { environments: origin } }) - await this.confService.publish(res) - return res.environments + assert(res?.value, 'application configuration not found') + await this.confService.publish(res.value) + return res.value.environments } async findAll(appid: string) { - const res = await this.prisma.applicationConfiguration.findUnique({ - where: { appid }, - }) + const doc = await this.db + .collection('ApplicationConfiguration') + .findOne({ appid }) - return res.environments + return doc.environments } async deleteOne(appid: string, name: string) { - const res = await this.prisma.applicationConfiguration.update({ - where: { appid }, - data: { environments: { deleteMany: { where: { name } } } }, - }) + const res = await this.db + .collection('ApplicationConfiguration') + .findOneAndUpdate({ appid }, { $pull: { environments: { name } } }) - await this.confService.publish(res) - return res + assert(res?.value, 'application configuration not found') + await this.confService.publish(res.value) + return res.value.environments } } diff --git a/server/src/database/collection/collection.controller.ts b/server/src/database/collection/collection.controller.ts index 6778e158d7..3191761a8c 100644 --- a/server/src/database/collection/collection.controller.ts +++ b/server/src/database/collection/collection.controller.ts @@ -21,7 +21,7 @@ import { ApiResponseUtil, ResponseUtil } from '../../utils/response' import { CollectionService } from './collection.service' import { CreateCollectionDto } from '../dto/create-collection.dto' import { UpdateCollectionDto } from '../dto/update-collection.dto' -import { Collection } from '../entities/collection.entity' +import { Collection } from '../entities/collection' @ApiTags('Database') @ApiBearerAuth('Authorization') diff --git a/server/src/database/database.service.ts b/server/src/database/database.service.ts index 72c24924e2..c86b9db168 100644 --- a/server/src/database/database.service.ts +++ b/server/src/database/database.service.ts @@ -1,21 +1,22 @@ import { Injectable, Logger } from '@nestjs/common' import * as assert from 'node:assert' import { MongoAccessor } from 'database-proxy' -import { PrismaService } from '../prisma/prisma.service' -import { Database, DatabasePhase, DatabaseState, Region } from '@prisma/client' import { GenerateAlphaNumericPassword } from 'src/utils/random' import { MongoService } from './mongo.service' import * as mongodb_uri from 'mongodb-uri' import { RegionService } from 'src/region/region.service' import { TASK_LOCK_INIT_TIME } from 'src/constants' +import { Region } from 'src/region/entities/region' +import { SystemDatabase } from './system-database' +import { Database, DatabasePhase, DatabaseState } from './entities/database' @Injectable() export class DatabaseService { + private readonly db = SystemDatabase.db private readonly logger = new Logger(DatabaseService.name) constructor( private readonly mongoService: MongoService, - private readonly prisma: PrismaService, private readonly regionService: RegionService, ) {} @@ -34,29 +35,29 @@ export class DatabaseService { password, ) - this.logger.debug('Create database result: ', res) assert.equal(res.ok, 1, 'Create app database failed: ' + appid) // create app database in database - const database = await this.prisma.database.create({ - data: { - appid: appid, - name: dbName, - user: username, - password: password, - state: DatabaseState.Active, - phase: DatabasePhase.Created, - lockedAt: TASK_LOCK_INIT_TIME, - }, + await this.db.collection('Database').insertOne({ + appid: appid, + name: dbName, + user: username, + password: password, + state: DatabaseState.Active, + phase: DatabasePhase.Created, + lockedAt: TASK_LOCK_INIT_TIME, + createdAt: new Date(), + updatedAt: new Date(), }) + const database = await this.findOne(appid) return database } async findOne(appid: string) { - const database = await this.prisma.database.findUnique({ - where: { appid }, - }) + const database = await this.db + .collection('Database') + .findOne({ appid }) return database } @@ -69,9 +70,9 @@ export class DatabaseService { if (!res) return false // delete app database in database - const doc = await this.prisma.database.delete({ - where: { appid: database.appid }, - }) + const doc = await this.db + .collection('Database') + .deleteOne({ appid: database.appid }) return doc } diff --git a/server/src/database/entities/collection.entity.ts b/server/src/database/entities/collection.ts similarity index 100% rename from server/src/database/entities/collection.entity.ts rename to server/src/database/entities/collection.ts diff --git a/server/src/database/entities/database-policy.ts b/server/src/database/entities/database-policy.ts new file mode 100644 index 0000000000..c554e9ca9b --- /dev/null +++ b/server/src/database/entities/database-policy.ts @@ -0,0 +1,32 @@ +import { ObjectId } from 'mongodb' + +export class DatabasePolicy { + _id?: ObjectId + appid: string + name: string + injector?: string + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} + +export class DatabasePolicyRule { + _id?: ObjectId + appid: string + policyName: string + collectionName: string + value: any + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} + +export type DatabasePolicyWithRules = DatabasePolicy & { + rules: DatabasePolicyRule[] +} diff --git a/server/src/database/entities/database.ts b/server/src/database/entities/database.ts new file mode 100644 index 0000000000..048b8dc608 --- /dev/null +++ b/server/src/database/entities/database.ts @@ -0,0 +1,31 @@ +import { ObjectId } from 'mongodb' + +export enum DatabasePhase { + Creating = 'Creating', + Created = 'Created', + Deleting = 'Deleting', + Deleted = 'Deleted', +} + +export enum DatabaseState { + Active = 'Active', + Inactive = 'Inactive', + Deleted = 'Deleted', +} + +export class Database { + _id?: ObjectId + appid: string + name: string + user: string + password: string + state: DatabaseState + phase: DatabasePhase + lockedAt: Date + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/database/entities/policy.entity.ts b/server/src/database/entities/policy.entity.ts deleted file mode 100644 index 4a4ebf70c2..0000000000 --- a/server/src/database/entities/policy.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export class Policy {} diff --git a/server/src/database/mongo.service.ts b/server/src/database/mongo.service.ts index ec829bff75..be658d9887 100644 --- a/server/src/database/mongo.service.ts +++ b/server/src/database/mongo.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common' -import { Region } from '@prisma/client' import { MongoClient } from 'mongodb' import * as assert from 'node:assert' +import { Region } from 'src/region/entities/region' @Injectable() export class MongoService { diff --git a/server/src/database/policy/policy-rule.controller.ts b/server/src/database/policy/policy-rule.controller.ts index b877266341..d3b9326ec5 100644 --- a/server/src/database/policy/policy-rule.controller.ts +++ b/server/src/database/policy/policy-rule.controller.ts @@ -89,7 +89,7 @@ export class PolicyRuleController { return ResponseUtil.error('rule not found') } - const res = await this.ruleService.update( + const res = await this.ruleService.updateOne( appid, policyName, collectionName, @@ -117,7 +117,11 @@ export class PolicyRuleController { return ResponseUtil.error('rule not found') } - const res = await this.ruleService.remove(appid, policyName, collectionName) + const res = await this.ruleService.removeOne( + appid, + policyName, + collectionName, + ) return ResponseUtil.ok(res) } } diff --git a/server/src/database/policy/policy-rule.service.ts b/server/src/database/policy/policy-rule.service.ts index 356fa65a90..850774f9bc 100644 --- a/server/src/database/policy/policy-rule.service.ts +++ b/server/src/database/policy/policy-rule.service.ts @@ -1,115 +1,86 @@ import { Injectable } from '@nestjs/common' import * as assert from 'node:assert' -import { PrismaService } from 'src/prisma/prisma.service' import { CreatePolicyRuleDto } from '../dto/create-rule.dto' import { UpdatePolicyRuleDto } from '../dto/update-rule.dto' import { PolicyService } from './policy.service' +import { SystemDatabase } from '../system-database' +import { DatabasePolicyRule } from '../entities/database-policy' @Injectable() export class PolicyRuleService { - constructor( - private readonly prisma: PrismaService, - private readonly policyService: PolicyService, - ) {} + private readonly db = SystemDatabase.db + constructor(private readonly policyService: PolicyService) {} async create(appid: string, policyName: string, dto: CreatePolicyRuleDto) { - const res = await this.prisma.databasePolicyRule.create({ - data: { - policy: { - connect: { - appid_name: { - appid, - name: policyName, - }, - }, - }, + await this.db + .collection('DatabasePolicyRule') + .insertOne({ + appid, + policyName, collectionName: dto.collectionName, value: JSON.parse(dto.value), - }, - }) + createdAt: new Date(), + updatedAt: new Date(), + }) const policy = await this.policyService.findOne(appid, policyName) assert(policy, 'policy not found') await this.policyService.publish(policy) - return res + return policy } async count(appid: string, policyName: string) { - const res = await this.prisma.databasePolicyRule.count({ - where: { - policy: { - appid, - name: policyName, - }, - }, - }) + const res = await this.db + .collection('DatabasePolicyRule') + .countDocuments({ appid, policyName }) + return res } async findAll(appid: string, policyName: string) { - const res = await this.prisma.databasePolicyRule.findMany({ - where: { - policy: { - appid, - name: policyName, - }, - }, - }) + const res = await this.db + .collection('DatabasePolicyRule') + .find({ appid, policyName }) + .toArray() + return res } async findOne(appid: string, policyName: string, collectionName: string) { - const res = await this.prisma.databasePolicyRule.findUnique({ - where: { - appid_policyName_collectionName: { - appid, - policyName, - collectionName, - }, - }, - }) - return res + const doc = await this.db + .collection('DatabasePolicyRule') + .findOne({ appid, policyName, collectionName }) + + return doc } - async update( + async updateOne( appid: string, policyName: string, collectionName: string, dto: UpdatePolicyRuleDto, ) { - const res = await this.prisma.databasePolicyRule.update({ - where: { - appid_policyName_collectionName: { - appid, - policyName, - collectionName: collectionName, - }, - }, - data: { - value: JSON.parse(dto.value), - }, - }) + await this.db + .collection('DatabasePolicyRule') + .findOneAndUpdate( + { appid, policyName, collectionName }, + { $set: { value: JSON.parse(dto.value), updatedAt: new Date() } }, + ) const policy = await this.policyService.findOne(appid, policyName) assert(policy, 'policy not found') await this.policyService.publish(policy) - return res + return policy } - async remove(appid: string, policyName: string, collectionName: string) { - const res = await this.prisma.databasePolicyRule.delete({ - where: { - appid_policyName_collectionName: { - appid, - policyName, - collectionName, - }, - }, - }) + async removeOne(appid: string, policyName: string, collectionName: string) { + await this.db + .collection('DatabasePolicyRule') + .deleteOne({ appid, policyName, collectionName }) const policy = await this.policyService.findOne(appid, policyName) assert(policy, 'policy not found') await this.policyService.publish(policy) - return res + return policy } } diff --git a/server/src/database/policy/policy.controller.ts b/server/src/database/policy/policy.controller.ts index 1c80e92599..2da6fd0db0 100644 --- a/server/src/database/policy/policy.controller.ts +++ b/server/src/database/policy/policy.controller.ts @@ -76,7 +76,7 @@ export class PolicyController { if (!existed) { return ResponseUtil.error('Policy not found') } - const res = await this.policiesService.update(appid, name, dto) + const res = await this.policiesService.updateOne(appid, name, dto) return ResponseUtil.ok(res) } @@ -91,7 +91,7 @@ export class PolicyController { return ResponseUtil.error('Policy not found') } - const res = await this.policiesService.remove(appid, name) + const res = await this.policiesService.removeOne(appid, name) return ResponseUtil.ok(res) } } diff --git a/server/src/database/policy/policy.service.ts b/server/src/database/policy/policy.service.ts index b35ee25f78..84d62b3c47 100644 --- a/server/src/database/policy/policy.service.ts +++ b/server/src/database/policy/policy.service.ts @@ -1,119 +1,142 @@ -import { Injectable } from '@nestjs/common' -import { DatabasePolicy, DatabasePolicyRule } from '@prisma/client' +import { Injectable, Logger } from '@nestjs/common' import { CN_PUBLISHED_POLICIES } from 'src/constants' -import { PrismaService } from 'src/prisma/prisma.service' import { DatabaseService } from '../database.service' import { CreatePolicyDto } from '../dto/create-policy.dto' import { UpdatePolicyDto } from '../dto/update-policy.dto' +import { SystemDatabase } from '../system-database' +import { + DatabasePolicy, + DatabasePolicyRule, + DatabasePolicyWithRules, +} from '../entities/database-policy' @Injectable() export class PolicyService { - constructor( - private readonly prisma: PrismaService, - private readonly databaseService: DatabaseService, - ) {} + private readonly logger = new Logger(PolicyService.name) + private readonly db = SystemDatabase.db + + constructor(private readonly databaseService: DatabaseService) {} async create(appid: string, createPolicyDto: CreatePolicyDto) { - const res = await this.prisma.databasePolicy.create({ - data: { - appid, - name: createPolicyDto.name, - }, - include: { - rules: true, - }, + await this.db.collection('DatabasePolicy').insertOne({ + appid, + name: createPolicyDto.name, + createdAt: new Date(), + updatedAt: new Date(), }) - await this.publish(res) - return res + const doc = await this.findOne(appid, createPolicyDto.name) + + await this.publish(doc) + return doc } async count(appid: string) { - const res = await this.prisma.databasePolicy.count({ - where: { - appid, - }, - }) + const res = await this.db + .collection('DatabasePolicy') + .countDocuments({ appid }) + return res } async findAll(appid: string) { - const res = await this.prisma.databasePolicy.findMany({ - where: { - appid, - }, - include: { - rules: true, - }, - }) + const res = await this.db + .collection('DatabasePolicy') + .aggregate() + .match({ appid }) + .lookup({ + from: 'DatabasePolicyRule', + let: { name: '$name', appid: '$appid' }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ['$appid', '$$appid'] }, + { $eq: ['$policyName', '$$name'] }, + ], + }, + }, + }, + ], + as: 'rules', + }) + .toArray() + return res } async findOne(appid: string, name: string) { - const res = await this.prisma.databasePolicy.findUnique({ - where: { - appid_name: { - appid, - name, - }, - }, - include: { - rules: true, - }, - }) - return res + const policy = await this.db + .collection('DatabasePolicy') + .findOne({ appid, name }) + + if (!policy) { + return null + } + + const rules = await this.db + .collection('DatabasePolicyRule') + .find({ appid, policyName: name }) + .toArray() + + return { + ...policy, + rules, + } as DatabasePolicyWithRules } - async update(appid: string, name: string, dto: UpdatePolicyDto) { - const res = await this.prisma.databasePolicy.update({ - where: { - appid_name: { - appid, - name, - }, - }, - data: { - injector: dto.injector, - }, - include: { - rules: true, - }, - }) + async updateOne(appid: string, name: string, dto: UpdatePolicyDto) { + await this.db + .collection('DatabasePolicy') + .findOneAndUpdate( + { appid, name }, + { $set: { injector: dto.injector, updatedAt: new Date() } }, + ) - await this.publish(res) - return res + const doc = await this.findOne(appid, name) + await this.publish(doc) + return doc } - async remove(appid: string, name: string) { - const res = await this.prisma.databasePolicy.delete({ - where: { - appid_name: { - appid, - name, - }, - }, - include: { - rules: true, - }, - }) - await this.unpublish(appid, name) - return res + async removeOne(appid: string, name: string) { + const client = SystemDatabase.client + const session = client.startSession() + + try { + await session.withTransaction(async () => { + await this.db + .collection('DatabasePolicy') + .deleteOne({ appid, name }, { session }) + + await this.db + .collection('DatabasePolicyRule') + .deleteMany({ appid, policyName: name }, { session }) + + await this.unpublish(appid, name) + }) + } finally { + await session.endSession() + } } async removeAll(appid: string) { - // delete rules first - await this.prisma.databasePolicyRule.deleteMany({ - where: { - appid, - }, - }) + const client = SystemDatabase.client + const session = client.startSession() - const res = await this.prisma.databasePolicy.deleteMany({ - where: { - appid, - }, - }) - return res + try { + await session.withTransaction(async () => { + await this.db + .collection('DatabasePolicy') + .deleteMany({ appid }, { session }) + + await this.db + .collection('DatabasePolicyRule') + .deleteMany({ appid }, { session }) + }) + } finally { + await session.endSession() + } } async publish(policy: DatabasePolicy & { rules: DatabasePolicyRule[] }) { diff --git a/server/src/gateway/apisix-custom-cert.service.ts b/server/src/gateway/apisix-custom-cert.service.ts index cf067f4c11..bb905d6ba9 100644 --- a/server/src/gateway/apisix-custom-cert.service.ts +++ b/server/src/gateway/apisix-custom-cert.service.ts @@ -1,7 +1,8 @@ import { Injectable, Logger } from '@nestjs/common' -import { Region, WebsiteHosting } from '@prisma/client' +import { WebsiteHosting } from '@prisma/client' import { LABEL_KEY_APP_ID, ServerConfig } from 'src/constants' import { ClusterService } from 'src/region/cluster/cluster.service' +import { Region } from 'src/region/entities/region' import { GetApplicationNamespaceByAppId } from 'src/utils/getter' // This class handles the creation and deletion of website domain certificates diff --git a/server/src/gateway/apisix.service.ts b/server/src/gateway/apisix.service.ts index 19ef5a0455..c03da643b4 100644 --- a/server/src/gateway/apisix.service.ts +++ b/server/src/gateway/apisix.service.ts @@ -1,7 +1,8 @@ import { HttpService } from '@nestjs/axios' import { Injectable, Logger } from '@nestjs/common' -import { Region, WebsiteHosting } from '@prisma/client' +import { WebsiteHosting } from '@prisma/client' import { GetApplicationNamespaceByAppId } from '../utils/getter' +import { Region } from 'src/region/entities/region' @Injectable() export class ApisixService { diff --git a/server/src/instance/instance.service.ts b/server/src/instance/instance.service.ts index af572f67c9..788ef94cf1 100644 --- a/server/src/instance/instance.service.ts +++ b/server/src/instance/instance.service.ts @@ -16,10 +16,10 @@ import { Application, ApplicationBundle, ApplicationConfiguration, - Region, Runtime, } from '@prisma/client' import { RegionService } from 'src/region/region.service' +import { Region } from 'src/region/entities/region' type ApplicationWithRegion = Application & { region: Region } diff --git a/server/src/region/cluster/cluster.service.ts b/server/src/region/cluster/cluster.service.ts index 4653e0c8d4..92f02debbb 100644 --- a/server/src/region/cluster/cluster.service.ts +++ b/server/src/region/cluster/cluster.service.ts @@ -1,7 +1,6 @@ import { Injectable, Logger } from '@nestjs/common' import { KubernetesObject } from '@kubernetes/client-node' import * as k8s from '@kubernetes/client-node' -import { Region } from '@prisma/client' import { GetApplicationNamespaceByAppId } from 'src/utils/getter' import { compare } from 'fast-json-patch' import { GroupVersionKind } from 'src/region/cluster/types' @@ -10,6 +9,7 @@ import { LABEL_KEY_NAMESPACE_TYPE, LABEL_KEY_USER_ID, } from 'src/constants' +import { Region } from '../entities/region' @Injectable() export class ClusterService { diff --git a/server/src/region/entities/region.ts b/server/src/region/entities/region.ts new file mode 100644 index 0000000000..17399db420 --- /dev/null +++ b/server/src/region/entities/region.ts @@ -0,0 +1,50 @@ +import { ObjectId } from 'mongodb' + +export type RegionClusterConf = { + driver: string + kubeconfig: string | null + npmInstallFlags: string +} + +export type RegionDatabaseConf = { + driver: string + connectionUri: string + controlConnectionUri: string +} + +export type RegionGatewayConf = { + driver: string + runtimeDomain: string + websiteDomain: string + port: number + apiUrl: string + apiKey: string +} + +export type RegionStorageConf = { + driver: string + domain: string + externalEndpoint: string + internalEndpoint: string + accessKey: string + secretKey: string + controlEndpoint: string +} + +export class Region { + _id?: ObjectId + name: string + displayName: string + clusterConf: RegionClusterConf + databaseConf: RegionDatabaseConf + gatewayConf: RegionGatewayConf + storageConf: RegionStorageConf + tls: boolean + state: string + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/region/region.service.ts b/server/src/region/region.service.ts index e027ff8bb9..fc1b6ed578 100644 --- a/server/src/region/region.service.ts +++ b/server/src/region/region.service.ts @@ -1,81 +1,66 @@ import { Injectable } from '@nestjs/common' import { PrismaService } from '../prisma/prisma.service' +import { SystemDatabase } from 'src/database/system-database' +import { Region } from './entities/region' +import { Application } from 'src/application/entities/application' +import { assert } from 'console' +import { ObjectId } from 'mongodb' @Injectable() export class RegionService { + private readonly db = SystemDatabase.db constructor(private readonly prisma: PrismaService) {} async findByAppId(appid: string) { - const app = await this.prisma.application.findUnique({ - where: { appid }, - select: { - region: true, - }, - }) + const app = await this.db + .collection('Application') + .findOne({ appid }) - return app.region - } + assert(app, `Application ${appid} not found`) + const doc = await this.db + .collection('Region') + .findOne({ _id: app.regionId }) - async findOne(id: string) { - const region = await this.prisma.region.findUnique({ - where: { id }, - }) + return doc + } - return region + async findOne(id: ObjectId) { + const doc = await this.db.collection('Region').findOne({ _id: id }) + return doc } async findAll() { - const regions = await this.prisma.region.findMany() + const regions = await this.db.collection('Region').find().toArray() return regions } - async findOneDesensitized(id: string) { - const region = await this.prisma.region.findUnique({ - where: { id }, - select: { - id: true, - name: true, - displayName: true, - state: true, - storageConf: false, - gatewayConf: false, - databaseConf: false, - clusterConf: false, - createdAt: false, - updatedAt: false, - }, - }) + async findOneDesensitized(id: ObjectId) { + const projection = { + _id: 1, + name: 1, + displayName: 1, + state: 1, + } + + const region = await this.db + .collection('Region') + .findOne({ _id: new ObjectId(id) }, { projection }) return region } async findAllDesensitized() { - const regions = await this.prisma.region.findMany({ - select: { - id: true, - name: true, - displayName: true, - state: true, - storageConf: false, - gatewayConf: false, - databaseConf: false, - clusterConf: false, - notes: true, - bundles: { - select: { - id: true, - name: true, - displayName: true, - priority: true, - state: true, - resource: true, - limitCountPerUser: true, - subscriptionOptions: true, - notes: true, - }, - }, - }, - }) + const projection = { + _id: 1, + name: 1, + displayName: 1, + state: 1, + } + + const regions = await this.db + .collection('Region') + .find({}, { projection }) + .toArray() return regions } diff --git a/server/src/storage/bucket.service.ts b/server/src/storage/bucket.service.ts index f0eccd64a3..9dd5766426 100644 --- a/server/src/storage/bucket.service.ts +++ b/server/src/storage/bucket.service.ts @@ -1,16 +1,12 @@ import { Injectable, Logger } from '@nestjs/common' -import { - Application, - StorageBucket, - StoragePhase, - StorageState, -} from '@prisma/client' +import { StorageBucket, StoragePhase, StorageState } from '@prisma/client' import { TASK_LOCK_INIT_TIME } from 'src/constants' import { PrismaService } from '../prisma/prisma.service' import { RegionService } from '../region/region.service' import { CreateBucketDto } from './dto/create-bucket.dto' import { UpdateBucketDto } from './dto/update-bucket.dto' import { MinioService } from './minio/minio.service' +import { Application } from 'src/application/entities/application' @Injectable() export class BucketService { diff --git a/server/src/storage/entities/storage-bucket.ts b/server/src/storage/entities/storage-bucket.ts new file mode 100644 index 0000000000..2584562c9f --- /dev/null +++ b/server/src/storage/entities/storage-bucket.ts @@ -0,0 +1,33 @@ +import { ObjectId } from 'mongodb' + +export enum BucketPolicy { + readwrite = 'readwrite', + readonly = 'readonly', + private = 'private', +} + +export enum StoragePhase { + Creating = 'Creating', + Created = 'Created', + Deleting = 'Deleting', + Deleted = 'Deleted', +} + +export enum StorageState { + Active = 'Active', + Inactive = 'Inactive', + Deleted = 'Deleted', +} + +export class StorageBucket { + _id?: ObjectId + appid: string + name: string + shortName: string + policy: BucketPolicy + state: StorageState + phase: StoragePhase + lockedAt: Date + createdAt: Date + updatedAt: Date +} diff --git a/server/src/storage/entities/storage-user.ts b/server/src/storage/entities/storage-user.ts new file mode 100644 index 0000000000..c5e5b0019f --- /dev/null +++ b/server/src/storage/entities/storage-user.ts @@ -0,0 +1,30 @@ +import { ObjectId } from 'mongodb' + +export enum StoragePhase { + Creating = 'Creating', + Created = 'Created', + Deleting = 'Deleting', + Deleted = 'Deleted', +} + +export enum StorageState { + Active = 'Active', + Inactive = 'Inactive', + Deleted = 'Deleted', +} + +export class StorageUser { + _id?: ObjectId + appid: string + accessKey: string + secretKey: string + state: StorageState + phase: StoragePhase + lockedAt: Date + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/storage/minio/minio.service.ts b/server/src/storage/minio/minio.service.ts index feabc667dd..95a4a2cab8 100644 --- a/server/src/storage/minio/minio.service.ts +++ b/server/src/storage/minio/minio.service.ts @@ -8,12 +8,13 @@ import { PutBucketVersioningCommand, S3, } from '@aws-sdk/client-s3' -import { BucketPolicy, Region } from '@prisma/client' +import { BucketPolicy } from '@prisma/client' import * as assert from 'node:assert' import * as cp from 'child_process' import { promisify } from 'util' import { MinioCommandExecOutput } from './types' import { MINIO_COMMON_USER_GROUP } from 'src/constants' +import { Region } from 'src/region/entities/region' const exec = promisify(cp.exec) diff --git a/server/src/storage/storage.service.ts b/server/src/storage/storage.service.ts index 43801bb638..6e6413f485 100644 --- a/server/src/storage/storage.service.ts +++ b/server/src/storage/storage.service.ts @@ -1,15 +1,18 @@ import { Injectable, Logger } from '@nestjs/common' -import { Region, StoragePhase, StorageState, StorageUser } from '@prisma/client' +import { StoragePhase, StorageState, StorageUser } from '@prisma/client' import { PrismaService } from 'src/prisma/prisma.service' import { GenerateAlphaNumericPassword } from 'src/utils/random' import { MinioService } from './minio/minio.service' import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts' import { RegionService } from 'src/region/region.service' import { TASK_LOCK_INIT_TIME } from 'src/constants' +import { Region } from 'src/region/entities/region' +import { SystemDatabase } from 'src/database/system-database' @Injectable() export class StorageService { private readonly logger = new Logger(StorageService.name) + private readonly db = SystemDatabase.db constructor( private readonly minioService: MinioService, diff --git a/server/src/subscription/dto/create-subscription.dto.ts b/server/src/subscription/dto/create-subscription.dto.ts deleted file mode 100644 index 082735a3a3..0000000000 --- a/server/src/subscription/dto/create-subscription.dto.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' -import { ApplicationState } from '@prisma/client' -import { IsEnum, IsInt, IsNotEmpty, IsString, Length } from 'class-validator' - -enum CreateApplicationState { - Running = 'Running', - Stopped = 'Stopped', -} - -export class CreateSubscriptionDto { - @ApiProperty({ required: true }) - @Length(1, 64) - @IsNotEmpty() - name: string - - @ApiPropertyOptional({ - default: CreateApplicationState.Running, - enum: CreateApplicationState, - }) - @IsNotEmpty() - @IsEnum(CreateApplicationState) - state: ApplicationState - - @ApiProperty() - @IsNotEmpty() - @IsString() - regionId: string - - @ApiProperty() - @IsNotEmpty() - @IsString() - bundleId: string - - @ApiProperty() - @IsNotEmpty() - @IsString() - runtimeId: string - - @ApiProperty() - @IsInt() - @IsNotEmpty() - duration: number - - validate(): string | null { - return null - } -} diff --git a/server/src/subscription/dto/renew-subscription.dto.ts b/server/src/subscription/dto/renew-subscription.dto.ts deleted file mode 100644 index f43bc305e5..0000000000 --- a/server/src/subscription/dto/renew-subscription.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger' -import { IsInt, IsNotEmpty } from 'class-validator' - -export class RenewSubscriptionDto { - @ApiProperty() - @IsInt() - @IsNotEmpty() - duration: number -} diff --git a/server/src/subscription/dto/upgrade-subscription.dto.ts b/server/src/subscription/dto/upgrade-subscription.dto.ts deleted file mode 100644 index 4765628cac..0000000000 --- a/server/src/subscription/dto/upgrade-subscription.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsBoolean, IsNotEmpty, IsString } from 'class-validator' - -export class UpgradeSubscriptionDto { - @IsString() - @IsNotEmpty() - targetBundleId: string - - @IsBoolean() - @IsNotEmpty() - restart: boolean -} diff --git a/server/src/subscription/renewal-task.service.ts b/server/src/subscription/renewal-task.service.ts deleted file mode 100644 index da7440c74c..0000000000 --- a/server/src/subscription/renewal-task.service.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { - Account, - SubscriptionRenewal, - SubscriptionRenewalPhase, -} from '.prisma/client' -import { Injectable, Logger } from '@nestjs/common' -import { Cron, CronExpression } from '@nestjs/schedule' -import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' -import { ObjectId } from 'mongodb' -import { AccountService } from 'src/account/account.service' -import { Subscription } from '@prisma/client' - -@Injectable() -export class SubscriptionRenewalTaskService { - readonly lockTimeout = 30 // in second - readonly concurrency = 1 // concurrency count - - private readonly logger = new Logger(SubscriptionRenewalTaskService.name) - - constructor(private readonly accountService: AccountService) {} - - @Cron(CronExpression.EVERY_SECOND) - async tick() { - if (ServerConfig.DISABLED_SUBSCRIPTION_TASK) { - return - } - - // Phase `Pending` -> `Paid` - this.handlePendingPhase() - } - - /** - * Phase `Pending`: - * 1. Pay the subscription renewal order from account balance (Transaction) - * 2. Update subscription 'expiredAt' time (Transaction) (lock document) - * 3. Update subscription renewal order phase to ‘Paid’ (Transaction) - */ - async handlePendingPhase() { - const db = SystemDatabase.db - const client = SystemDatabase.client - - const doc = await db - .collection('SubscriptionRenewal') - .findOneAndUpdate( - { - phase: SubscriptionRenewalPhase.Pending, - lockedAt: { $lte: new Date(Date.now() - this.lockTimeout * 1000) }, - }, - { $set: { lockedAt: new Date() } }, - ) - - if (!doc.value) { - return - } - - const renewal = doc.value - - // check account balance - const userid = renewal.createdBy - const session = client.startSession() - await session - .withTransaction(async () => { - const account = await db - .collection('Account') - .findOne({ createdBy: userid }, { session }) - - // if account balance is not enough, delete the subscription & renewal order - if (account?.balance < renewal.amount) { - await db - .collection('SubscriptionRenewal') - .deleteOne({ _id: renewal._id }, { session }) - - await db - .collection('Subscription') - .deleteOne( - { _id: new ObjectId(renewal.subscriptionId) }, - { session }, - ) - return - } - - // Pay the subscription renewal order from account balance - const priceAmount = renewal.amount - if (priceAmount !== 0) { - await db.collection('Account').updateOne( - { - _id: account._id, - balance: { $gte: priceAmount }, - }, - { $inc: { balance: -priceAmount } }, - { session }, - ) - - // Create account transaction - await db.collection('AccountTransaction').insertOne( - { - accountId: account._id, - amount: -priceAmount, - balance: account.balance - priceAmount, - message: `subscription renewal order ${renewal._id}`, - createdAt: new Date(), - updatedAt: new Date(), - }, - { session }, - ) - } - - // Update subscription 'expiredAt' time - await db.collection('Subscription').updateOne( - { _id: new ObjectId(renewal.subscriptionId) }, - [ - { - $set: { - expiredAt: { $add: ['$expiredAt', renewal.duration * 1000] }, - }, - }, - ], - { session }, - ) - - // Update subscription renewal order phase to ‘Paid’ - await db - .collection('SubscriptionRenewal') - .updateOne( - { _id: renewal._id }, - { - $set: { - phase: SubscriptionRenewalPhase.Paid, - lockedAt: TASK_LOCK_INIT_TIME, - }, - }, - { session }, - ) - }) - .catch((err) => { - this.logger.debug(renewal._id, err.toString()) - }) - } - - @Cron(CronExpression.EVERY_MINUTE) - async handlePendingTimeout() { - const timeout = 30 * 60 * 1000 - - const db = SystemDatabase.db - await db.collection('SubscriptionRenewal').updateMany( - { - phase: SubscriptionRenewalPhase.Pending, - lockedAt: { $lte: new Date(Date.now() - this.lockTimeout * 1000) }, - createdAt: { $lte: new Date(Date.now() - timeout) }, - }, - { - $set: { - phase: SubscriptionRenewalPhase.Failed, - message: `Timeout exceeded ${timeout / 1000} seconds`, - }, - }, - ) - } - - /** - * Unlock subscription - */ - async unlock(id: ObjectId) { - const db = SystemDatabase.db - await db - .collection('SubscriptionRenewal') - .updateOne({ _id: id }, { $set: { lockedAt: TASK_LOCK_INIT_TIME } }) - } -} diff --git a/server/src/subscription/subscription-task.service.ts b/server/src/subscription/subscription-task.service.ts deleted file mode 100644 index 5ab5b49bc3..0000000000 --- a/server/src/subscription/subscription-task.service.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { Application, Subscription, SubscriptionPhase } from '.prisma/client' -import { Injectable, Logger } from '@nestjs/common' -import { Cron, CronExpression } from '@nestjs/schedule' -import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' -import * as assert from 'node:assert' -import { ApplicationService } from 'src/application/application.service' -import { ApplicationState, SubscriptionState } from '@prisma/client' -import { ObjectId } from 'mongodb' -import { BundleService } from 'src/region/bundle.service' -import { CreateSubscriptionDto } from './dto/create-subscription.dto' - -@Injectable() -export class SubscriptionTaskService { - readonly lockTimeout = 30 // in second - - private readonly logger = new Logger(SubscriptionTaskService.name) - - constructor( - private readonly bundleService: BundleService, - private readonly applicationService: ApplicationService, - ) {} - - @Cron(CronExpression.EVERY_SECOND) - async tick() { - if (ServerConfig.DISABLED_SUBSCRIPTION_TASK) { - return - } - - // Phase `Pending` -> `Valid` - this.handlePendingPhaseAndNotExpired() - - // Phase `Valid` -> `Expired` - this.handleValidPhaseAndExpired() - - // Phase `Expired` -> `ExpiredAndStopped` - this.handleExpiredPhase() - - // Phase `ExpiredAndStopped` -> `Valid` - this.handleExpiredAndStoppedPhaseAndNotExpired() - - // Phase `ExpiredAndStopped` -> `Deleted` - this.handleExpiredAndStoppedPhase() - - // State `Deleted` - this.handleDeletedState() - } - - /** - * Phase `Pending` and not expired: - * - if appid is null, generate appid - * - if appid exists, but application is not found - * - create application - * - update subscription phase to `Valid` - */ - async handlePendingPhaseAndNotExpired() { - const db = SystemDatabase.db - - const res = await db - .collection('Subscription') - .findOneAndUpdate( - { - phase: SubscriptionPhase.Pending, - expiredAt: { $gt: new Date() }, - lockedAt: { $lt: new Date(Date.now() - this.lockTimeout * 1000) }, - }, - { $set: { lockedAt: new Date() } }, - ) - if (!res.value) return - - // get region by appid - const doc = res.value - - // if application not found, create application - const application = await this.applicationService.findOne(doc.appid) - if (!application) { - const userid = doc.createdBy.toString() - const dto = new CreateSubscriptionDto() - dto.name = doc.input.name - dto.regionId = doc.input.regionId - dto.state = doc.input.state as ApplicationState - dto.runtimeId = doc.input.runtimeId - // doc.bundleId is ObjectId, but prisma typed it as string, so we need to convert it - dto.bundleId = doc.bundleId.toString() - this.logger.debug(dto) - - await this.applicationService.create(userid, doc.appid, dto) - return await this.unlock(doc._id) - } - - // update subscription phase to `Valid` - await db.collection('Subscription').updateOne( - { _id: doc._id }, - { - $set: { phase: SubscriptionPhase.Valid, lockedAt: TASK_LOCK_INIT_TIME }, - }, - ) - } - - /** - * Phase ‘Valid’ with expiredAt < now - * - update subscription phase to ‘Expired’ - */ - async handleValidPhaseAndExpired() { - const db = SystemDatabase.db - - await db.collection('Subscription').updateMany( - { - phase: SubscriptionPhase.Valid, - expiredAt: { $lt: new Date() }, - }, - { $set: { phase: SubscriptionPhase.Expired } }, - ) - } - - /** - * Phase 'Expired': - * - update application state to 'Stopped' - * - update subscription phase to 'ExpiredAndStopped' - */ - async handleExpiredPhase() { - const db = SystemDatabase.db - - const res = await db - .collection('Subscription') - .findOneAndUpdate( - { - phase: SubscriptionPhase.Expired, - lockedAt: { $lt: new Date(Date.now() - this.lockTimeout * 1000) }, - }, - { $set: { lockedAt: new Date() } }, - ) - if (!res.value) return - - const doc = res.value - - // update application state to 'Stopped' - await db - .collection('Application') - .updateOne( - { appid: doc.appid }, - { $set: { state: ApplicationState.Stopped } }, - ) - - // update subscription phase to 'ExpiredAndStopped' - await db.collection('Subscription').updateOne( - { _id: doc._id }, - { - $set: { - phase: SubscriptionPhase.ExpiredAndStopped, - lockedAt: TASK_LOCK_INIT_TIME, - }, - }, - ) - } - - /** - * Phase 'ExpiredAndStopped' but not expired (renewal case): - * - update subscription phase to ‘Valid’ - * (TODO) update application state to ‘Running’ - */ - async handleExpiredAndStoppedPhaseAndNotExpired() { - const db = SystemDatabase.db - - await db.collection('Subscription').updateMany( - { - phase: SubscriptionPhase.ExpiredAndStopped, - expiredAt: { $gt: new Date() }, - }, - { $set: { phase: SubscriptionPhase.Valid } }, - ) - } - - /** - * Phase 'ExpiredAndStopped': - * -if ‘Bundle.reservedTimeAfterExpired’ expired - * 1. Update application state to ‘Deleted’ - * 2. Update subscription phase to ‘ExpiredAndDeleted’ - */ - async handleExpiredAndStoppedPhase() { - const db = SystemDatabase.db - - const specialLockTimeout = 60 * 60 // 1 hour - - const res = await db - .collection('Subscription') - .findOneAndUpdate( - { - phase: SubscriptionPhase.ExpiredAndStopped, - lockedAt: { $lt: new Date(Date.now() - specialLockTimeout * 1000) }, - }, - { $set: { lockedAt: new Date() } }, - ) - if (!res.value) return - - const doc = res.value - - // if ‘Bundle.reservedTimeAfterExpired’ expired - const bundle = await this.bundleService.findApplicationBundle(doc.appid) - assert(bundle, 'bundle not found') - - const reservedTimeAfterExpired = - bundle.resource.reservedTimeAfterExpired * 1000 - const expiredTime = Date.now() - doc.expiredAt.getTime() - if (expiredTime < reservedTimeAfterExpired) { - return // return directly without unlocking it! - } - - // 2. Update subscription state to 'Deleted' - await db.collection('Subscription').updateOne( - { _id: doc._id }, - { - $set: { - state: SubscriptionState.Deleted, - lockedAt: TASK_LOCK_INIT_TIME, - }, - }, - ) - } - - /** - * State `Deleted` - */ - async handleDeletedState() { - const db = SystemDatabase.db - const res = await db - .collection('Subscription') - .findOneAndUpdate( - { - state: SubscriptionState.Deleted, - phase: { $not: { $eq: SubscriptionPhase.Deleted } }, - lockedAt: { $lt: new Date(Date.now() - this.lockTimeout * 1000) }, - }, - { $set: { lockedAt: new Date() } }, - ) - if (!res.value) return - - const doc = res.value - - const app = await this.applicationService.findOne(doc.appid) - if (app && app.state !== ApplicationState.Deleted) { - // delete application, update application state to ‘Deleted’ - await this.applicationService.remove(doc.appid) - this.logger.debug(`deleting application: ${doc.appid}`) - } - - // wait for application to be deleted - if (app) { - this.logger.debug(`waiting for application to be deleted: ${doc.appid}`) - return // return directly without unlocking it - } - - // Update subscription phase to 'Deleted' - await db.collection('Subscription').updateOne( - { _id: doc._id }, - { - $set: { - phase: SubscriptionPhase.Deleted, - lockedAt: TASK_LOCK_INIT_TIME, - }, - }, - ) - this.logger.debug(`subscription phase updated to deleted: ${doc.appid}`) - } - - @Cron(CronExpression.EVERY_MINUTE) - async handlePendingTimeout() { - const timeout = 10 * 60 * 1000 - - const db = SystemDatabase.db - await db.collection('Subscription').deleteMany({ - phase: SubscriptionPhase.Pending, - lockedAt: { $lte: new Date(Date.now() - this.lockTimeout * 1000) }, - createdAt: { $lte: new Date(Date.now() - timeout) }, - }) - } - - /** - * Unlock subscription - */ - async unlock(id: ObjectId) { - const db = SystemDatabase.db - await db - .collection('Subscription') - .updateOne({ _id: id }, { $set: { lockedAt: TASK_LOCK_INIT_TIME } }) - } -} diff --git a/server/src/subscription/subscription.controller.ts b/server/src/subscription/subscription.controller.ts deleted file mode 100644 index f170b93906..0000000000 --- a/server/src/subscription/subscription.controller.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { - Controller, - Get, - Post, - Body, - Patch, - Param, - Delete, - Logger, - UseGuards, - Req, -} from '@nestjs/common' -import { SubscriptionService } from './subscription.service' -import { CreateSubscriptionDto } from './dto/create-subscription.dto' -import { UpgradeSubscriptionDto } from './dto/upgrade-subscription.dto' -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' -import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' -import { IRequest } from 'src/utils/interface' -import { ResponseUtil } from 'src/utils/response' -import { BundleService } from 'src/region/bundle.service' -import { PrismaService } from 'src/prisma/prisma.service' -import { ApplicationService } from 'src/application/application.service' -import { RegionService } from 'src/region/region.service' -import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' -import { RenewSubscriptionDto } from './dto/renew-subscription.dto' -import * as assert from 'assert' -import { SubscriptionState } from '@prisma/client' -import { AccountService } from 'src/account/account.service' - -@ApiTags('Subscription') -@Controller('subscriptions') -@ApiBearerAuth('Authorization') -export class SubscriptionController { - private readonly logger = new Logger(SubscriptionController.name) - - constructor( - private readonly subscriptService: SubscriptionService, - private readonly applicationService: ApplicationService, - private readonly bundleService: BundleService, - private readonly prisma: PrismaService, - private readonly regionService: RegionService, - private readonly accountService: AccountService, - ) {} - - /** - * Create a new subscription - */ - @ApiOperation({ summary: 'Create a new subscription' }) - @UseGuards(JwtAuthGuard) - @Post() - async create(@Body() dto: CreateSubscriptionDto, @Req() req: IRequest) { - const user = req.user - - // check regionId exists - const region = await this.regionService.findOneDesensitized(dto.regionId) - if (!region) { - return ResponseUtil.error(`region ${dto.regionId} not found`) - } - - // check runtimeId exists - const runtime = await this.prisma.runtime.findUnique({ - where: { id: dto.runtimeId }, - }) - if (!runtime) { - return ResponseUtil.error(`runtime ${dto.runtimeId} not found`) - } - - // check bundleId exists - const bundle = await this.bundleService.findOne(dto.bundleId, region.id) - if (!bundle) { - return ResponseUtil.error(`bundle ${dto.bundleId} not found`) - } - - // check app count limit - const LIMIT_COUNT = bundle.limitCountPerUser || 0 - const count = await this.prisma.subscription.count({ - where: { - createdBy: user.id, - bundleId: dto.bundleId, - state: { not: SubscriptionState.Deleted }, - }, - }) - if (count >= LIMIT_COUNT) { - return ResponseUtil.error( - `application count limit is ${LIMIT_COUNT} for bundle ${bundle.name}`, - ) - } - - // check duration supported - const option = this.bundleService.getSubscriptionOption( - bundle, - dto.duration, - ) - if (!option) { - return ResponseUtil.error(`duration not supported in bundle`) - } - - // check account balance - const account = await this.accountService.findOne(user.id) - const balance = account?.balance || 0 - const priceAmount = option.specialPrice - if (balance < priceAmount) { - return ResponseUtil.error( - `account balance is not enough, need ${priceAmount} but only ${account.balance}`, - ) - } - - // create subscription - const appid = await this.applicationService.tryGenerateUniqueAppid() - const subscription = await this.subscriptService.create( - user.id, - appid, - dto, - option, - ) - return ResponseUtil.ok(subscription) - } - - /** - * Get user's subscriptions - */ - @ApiOperation({ summary: "Get user's subscriptions" }) - @UseGuards(JwtAuthGuard) - @Get() - async findAll(@Req() req: IRequest) { - const user = req.user - const subscriptions = await this.subscriptService.findAll(user.id) - return ResponseUtil.ok(subscriptions) - } - - /** - * Get subscription by appid - */ - @ApiOperation({ summary: 'Get subscription by appid' }) - @UseGuards(JwtAuthGuard, ApplicationAuthGuard) - @Get(':appid') - async findOne(@Param('appid') appid: string) { - const subscription = await this.subscriptService.findOneByAppid(appid) - if (!subscription) { - return ResponseUtil.error(`subscription ${appid} not found`) - } - - return ResponseUtil.ok(subscription) - } - - /** - * Renew a subscription - */ - @ApiOperation({ summary: 'Renew a subscription' }) - @UseGuards(JwtAuthGuard) - @Post(':id/renewal') - async renew( - @Param('id') id: string, - @Body() dto: RenewSubscriptionDto, - @Req() req: IRequest, - ) { - const { duration } = dto - - // get subscription - const user = req.user - const subscription = await this.subscriptService.findOne(user.id, id) - if (!subscription) { - return ResponseUtil.error(`subscription ${id} not found`) - } - - const app = subscription.application - const bundle = await this.bundleService.findOne( - subscription.bundleId, - app.regionId, - ) - assert(bundle, `bundle ${subscription.bundleId} not found`) - - const option = this.bundleService.getSubscriptionOption(bundle, duration) - if (!option) { - return ResponseUtil.error(`duration not supported in bundle`) - } - const priceAmount = option.specialPrice - - // check max renewal time - const MAX_RENEWAL_AT = Date.now() + bundle.maxRenewalTime * 1000 - const newExpiredAt = subscription.expiredAt.getTime() + duration * 1000 - if (newExpiredAt > MAX_RENEWAL_AT) { - const dateStr = new Date(MAX_RENEWAL_AT).toLocaleString() - return ResponseUtil.error( - `max renewal time is ${dateStr} for bundle ${bundle.name}`, - ) - } - - // check account balance - const account = await this.accountService.findOne(user.id) - const balance = account?.balance || 0 - if (balance < priceAmount) { - return ResponseUtil.error( - `account balance is not enough, need ${priceAmount} but only ${account.balance}`, - ) - } - - // renew subscription - const res = await this.subscriptService.renew(subscription, option) - return ResponseUtil.ok(res) - } - - /** - * TODO: Upgrade a subscription - */ - @ApiOperation({ summary: 'Upgrade a subscription - TODO' }) - @UseGuards(JwtAuthGuard) - @Patch(':id/upgrade') - async upgrade( - @Param('id') id: string, - @Body() dto: UpgradeSubscriptionDto, - @Req() req: IRequest, - ) { - const { targetBundleId, restart } = dto - - // get subscription - const user = req.user - const subscription = await this.subscriptService.findOne(user.id, id) - if (!subscription) { - return ResponseUtil.error(`subscription ${id} not found`) - } - - // get target bundle - const app = subscription.application - const targetBundle = await this.bundleService.findOne( - targetBundleId, - app.regionId, - ) - if (!targetBundle) { - return ResponseUtil.error(`bundle ${targetBundleId} not found`) - } - - // check bundle is upgradeable - const bundle = await this.bundleService.findOne( - subscription.bundleId, - app.regionId, - ) - assert(bundle, `bundle ${subscription.bundleId} not found`) - - if (bundle.id === targetBundle.id) { - return ResponseUtil.error(`bundle is the same`) - } - - // check if target bundle limit count is reached - const LIMIT_COUNT = targetBundle.limitCountPerUser || 0 - const count = await this.prisma.subscription.count({ - where: { - createdBy: user.id, - bundleId: targetBundle.id, - state: { not: SubscriptionState.Deleted }, - }, - }) - if (count >= LIMIT_COUNT) { - return ResponseUtil.error( - `application count limit is ${LIMIT_COUNT} for bundle ${targetBundle.name}`, - ) - } - - return ResponseUtil.error(`not implemented`) - } - - /** - * Delete a subscription - * @param id - * @returns - */ - @ApiOperation({ summary: 'Delete a subscription' }) - @UseGuards(JwtAuthGuard) - @Delete(':id') - async remove(@Param('id') id: string, @Req() req: IRequest) { - const userid = req.user.id - const res = await this.subscriptService.remove(userid, id) - return ResponseUtil.ok(res) - } -} diff --git a/server/src/subscription/subscription.module.ts b/server/src/subscription/subscription.module.ts deleted file mode 100644 index e5a1f6301a..0000000000 --- a/server/src/subscription/subscription.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Module } from '@nestjs/common' -import { SubscriptionService } from './subscription.service' -import { SubscriptionController } from './subscription.controller' -import { SubscriptionTaskService } from './subscription-task.service' -import { ApplicationService } from 'src/application/application.service' -import { SubscriptionRenewalTaskService } from './renewal-task.service' -import { AccountModule } from 'src/account/account.module' - -@Module({ - imports: [AccountModule], - controllers: [SubscriptionController], - providers: [ - SubscriptionService, - SubscriptionTaskService, - ApplicationService, - SubscriptionRenewalTaskService, - ], -}) -export class SubscriptionModule {} diff --git a/server/src/subscription/subscription.service.ts b/server/src/subscription/subscription.service.ts deleted file mode 100644 index 729efa6622..0000000000 --- a/server/src/subscription/subscription.service.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common' -import { - BundleSubscriptionOption, - Subscription, - SubscriptionPhase, - SubscriptionRenewalPhase, - SubscriptionRenewalPlan, - SubscriptionState, -} from '@prisma/client' -import { TASK_LOCK_INIT_TIME } from 'src/constants' -import { PrismaService } from 'src/prisma/prisma.service' -import { BundleService } from 'src/region/bundle.service' -import { CreateSubscriptionDto } from './dto/create-subscription.dto' - -@Injectable() -export class SubscriptionService { - private readonly logger = new Logger(SubscriptionService.name) - - constructor( - private readonly prisma: PrismaService, - private readonly bundleService: BundleService, - ) {} - - async create( - userid: string, - appid: string, - dto: CreateSubscriptionDto, - option: BundleSubscriptionOption, - ) { - // start transaction - const res = await this.prisma.$transaction(async (tx) => { - // create subscription - const subscription = await tx.subscription.create({ - data: { - input: { - name: dto.name, - state: dto.state, - regionId: dto.regionId, - runtimeId: dto.runtimeId, - }, - appid: appid, - bundleId: dto.bundleId, - phase: SubscriptionPhase.Pending, - renewalPlan: SubscriptionRenewalPlan.Manual, - expiredAt: new Date(), - lockedAt: TASK_LOCK_INIT_TIME, - createdBy: userid, - }, - }) - - // create subscription renewal - await tx.subscriptionRenewal.create({ - data: { - subscriptionId: subscription.id, - duration: option.duration, - amount: option.specialPrice, - phase: SubscriptionRenewalPhase.Pending, - lockedAt: TASK_LOCK_INIT_TIME, - createdBy: userid, - }, - }) - - return subscription - }) - - return res - } - - async findAll(userid: string) { - const res = await this.prisma.subscription.findMany({ - where: { createdBy: userid }, - include: { application: true }, - }) - - return res - } - - async findOne(userid: string, id: string) { - const res = await this.prisma.subscription.findUnique({ - where: { id }, - include: { application: true }, - }) - - return res - } - - async findOneByAppid(appid: string) { - const res = await this.prisma.subscription.findUnique({ - where: { - appid, - }, - }) - - return res - } - - async remove(userid: string, id: string) { - const res = await this.prisma.subscription.updateMany({ - where: { id, createdBy: userid, state: SubscriptionState.Created }, - data: { state: SubscriptionState.Deleted }, - }) - - return res - } - - /** - * Renew a subscription by creating a subscription renewal - */ - async renew(subscription: Subscription, option: BundleSubscriptionOption) { - // create subscription renewal - const res = await this.prisma.subscriptionRenewal.create({ - data: { - subscriptionId: subscription.id, - duration: option.duration, - amount: option.specialPrice, - phase: SubscriptionRenewalPhase.Pending, - lockedAt: TASK_LOCK_INIT_TIME, - createdBy: subscription.createdBy, - }, - }) - - return res - } -} diff --git a/server/src/utils/interface.ts b/server/src/utils/interface.ts index a91eab7883..14ec2a4e0b 100644 --- a/server/src/utils/interface.ts +++ b/server/src/utils/interface.ts @@ -1,5 +1,6 @@ -import { Application, User } from '@prisma/client' +import { User } from '@prisma/client' import { Request, Response } from 'express' +import { Application } from 'src/application/entities/application' export interface IRequest extends Request { user?: User From e7f360d2b06e0de1f281d0a5ae5baded63ae9615 Mon Sep 17 00:00:00 2001 From: maslow Date: Tue, 16 May 2023 15:01:30 +0000 Subject: [PATCH 04/48] remove prisma in gateway, website, storage --- .../application/application-task.service.ts | 5 +- .../src/application/entities/application.ts | 2 + .../src/gateway/apisix-custom-cert.service.ts | 24 +-- server/src/gateway/apisix.service.ts | 2 +- .../src/gateway/bucket-domain-task.service.ts | 3 +- server/src/gateway/bucket-domain.service.ts | 85 ++++------ server/src/gateway/entities/bucket-domain.ts | 18 +++ server/src/gateway/entities/runtime-domain.ts | 29 ++++ .../gateway/runtime-domain-task.service.ts | 6 +- server/src/gateway/runtime-domain.service.ts | 58 ++++--- server/src/gateway/website-task.service.ts | 9 +- server/src/storage/bucket-task.service.ts | 48 +++--- server/src/storage/bucket.controller.ts | 4 +- server/src/storage/bucket.service.ts | 138 +++++++++------- server/src/storage/dto/create-bucket.dto.ts | 2 +- server/src/storage/dto/update-bucket.dto.ts | 2 +- server/src/storage/entities/storage-bucket.ts | 21 +-- server/src/storage/minio/minio.service.ts | 2 +- server/src/storage/storage.service.ts | 89 +++++------ server/src/website/dto/create-website.dto.ts | 2 +- server/src/website/entities/website.ts | 24 +++ server/src/website/website.controller.ts | 15 +- server/src/website/website.service.ts | 149 ++++++++---------- 23 files changed, 400 insertions(+), 337 deletions(-) create mode 100644 server/src/gateway/entities/bucket-domain.ts create mode 100644 server/src/gateway/entities/runtime-domain.ts create mode 100644 server/src/website/entities/website.ts diff --git a/server/src/application/application-task.service.ts b/server/src/application/application-task.service.ts index 7639e5a3ea..cafe78c8ac 100644 --- a/server/src/application/application-task.service.ts +++ b/server/src/application/application-task.service.ts @@ -1,6 +1,5 @@ import { Injectable, Logger } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' -import { DomainPhase, StoragePhase } from '@prisma/client' import * as assert from 'node:assert' import { StorageService } from '../storage/storage.service' import { DatabaseService } from '../database/database.service' @@ -22,6 +21,8 @@ import { ApplicationState, } from './entities/application' import { DatabasePhase } from 'src/database/entities/database' +import { DomainPhase } from 'src/gateway/entities/runtime-domain' +import { StoragePhase } from 'src/storage/entities/storage-user' @Injectable() export class ApplicationTaskService { @@ -240,7 +241,7 @@ export class ApplicationTaskService { // delete runtime domain const runtimeDomain = await this.runtimeDomainService.findOne(appid) if (runtimeDomain) { - await this.runtimeDomainService.delete(appid) + await this.runtimeDomainService.deleteOne(appid) return await this.unlock(appid) } diff --git a/server/src/application/entities/application.ts b/server/src/application/entities/application.ts index 55078d5c71..c673f64c62 100644 --- a/server/src/application/entities/application.ts +++ b/server/src/application/entities/application.ts @@ -3,6 +3,7 @@ import { Region } from 'src/region/entities/region' import { ApplicationBundle } from './application-bundle' import { Runtime } from './runtime' import { ApplicationConfiguration } from './application-configuration' +import { RuntimeDomain } from 'src/gateway/entities/runtime-domain' export enum ApplicationPhase { Creating = 'Creating', @@ -46,4 +47,5 @@ export interface ApplicationWithRelations extends Application { bundle?: ApplicationBundle runtime?: Runtime configuration?: ApplicationConfiguration + domain?: RuntimeDomain } diff --git a/server/src/gateway/apisix-custom-cert.service.ts b/server/src/gateway/apisix-custom-cert.service.ts index bb905d6ba9..921b511daf 100644 --- a/server/src/gateway/apisix-custom-cert.service.ts +++ b/server/src/gateway/apisix-custom-cert.service.ts @@ -1,9 +1,9 @@ import { Injectable, Logger } from '@nestjs/common' -import { WebsiteHosting } from '@prisma/client' import { LABEL_KEY_APP_ID, ServerConfig } from 'src/constants' import { ClusterService } from 'src/region/cluster/cluster.service' import { Region } from 'src/region/entities/region' import { GetApplicationNamespaceByAppId } from 'src/utils/getter' +import { WebsiteHosting } from 'src/website/entities/website' // This class handles the creation and deletion of website domain certificates // and ApisixTls resources using Kubernetes Custom Resource Definitions (CRDs). @@ -26,7 +26,7 @@ export class ApisixCustomCertService { 'v1', namespace, 'certificates', - website.id, + website._id.toString(), ) return res.body @@ -51,17 +51,17 @@ export class ApisixCustomCertService { kind: 'Certificate', // Set the metadata for the Certificate resource metadata: { - name: website.id, + name: website._id.toString(), namespace, labels: { - 'laf.dev/website': website.id, + 'laf.dev/website': website._id.toString(), 'laf.dev/website-domain': website.domain, [LABEL_KEY_APP_ID]: website.appid, }, }, // Define the specification for the Certificate resource spec: { - secretName: website.id, + secretName: website._id.toString(), dnsNames: [website.domain], issuerRef: { name: ServerConfig.CertManagerIssuerName, @@ -84,7 +84,7 @@ export class ApisixCustomCertService { apiVersion: 'cert-manager.io/v1', kind: 'Certificate', metadata: { - name: website.id, + name: website._id.toString(), namespace, }, }) @@ -95,7 +95,7 @@ export class ApisixCustomCertService { apiVersion: 'v1', kind: 'Secret', metadata: { - name: website.id, + name: website._id.toString(), namespace, }, }) @@ -122,7 +122,7 @@ export class ApisixCustomCertService { 'v2', namespace, 'apisixtlses', - website.id, + website._id.toString(), ) return res.body } catch (err) { @@ -146,10 +146,10 @@ export class ApisixCustomCertService { kind: 'ApisixTls', // Set the metadata for the ApisixTls resource metadata: { - name: website.id, + name: website._id.toString(), namespace, labels: { - 'laf.dev/website': website.id, + 'laf.dev/website': website._id.toString(), 'laf.dev/website-domain': website.domain, [LABEL_KEY_APP_ID]: website.appid, }, @@ -158,7 +158,7 @@ export class ApisixCustomCertService { spec: { hosts: [website.domain], secret: { - name: website.id, + name: website._id.toString(), namespace, }, }, @@ -179,7 +179,7 @@ export class ApisixCustomCertService { apiVersion: 'apisix.apache.org/v2', kind: 'ApisixTls', metadata: { - name: website.id, + name: website._id.toString(), namespace, }, }) diff --git a/server/src/gateway/apisix.service.ts b/server/src/gateway/apisix.service.ts index c03da643b4..957a0217e5 100644 --- a/server/src/gateway/apisix.service.ts +++ b/server/src/gateway/apisix.service.ts @@ -1,8 +1,8 @@ import { HttpService } from '@nestjs/axios' import { Injectable, Logger } from '@nestjs/common' -import { WebsiteHosting } from '@prisma/client' import { GetApplicationNamespaceByAppId } from '../utils/getter' import { Region } from 'src/region/entities/region' +import { WebsiteHosting } from 'src/website/entities/website' @Injectable() export class ApisixService { diff --git a/server/src/gateway/bucket-domain-task.service.ts b/server/src/gateway/bucket-domain-task.service.ts index 6be9e5f20e..cbd50416ce 100644 --- a/server/src/gateway/bucket-domain-task.service.ts +++ b/server/src/gateway/bucket-domain-task.service.ts @@ -1,11 +1,12 @@ import { Injectable, Logger } from '@nestjs/common' -import { BucketDomain, DomainPhase, DomainState } from '@prisma/client' import { RegionService } from 'src/region/region.service' import { ApisixService } from './apisix.service' import * as assert from 'node:assert' import { Cron, CronExpression } from '@nestjs/schedule' import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' import { SystemDatabase } from 'src/database/system-database' +import { BucketDomain } from './entities/bucket-domain' +import { DomainPhase, DomainState } from './entities/runtime-domain' @Injectable() export class BucketDomainTaskService { diff --git a/server/src/gateway/bucket-domain.service.ts b/server/src/gateway/bucket-domain.service.ts index 45e274ec9a..e61d8d95e6 100644 --- a/server/src/gateway/bucket-domain.service.ts +++ b/server/src/gateway/bucket-domain.service.ts @@ -1,18 +1,18 @@ import { Injectable, Logger } from '@nestjs/common' -import { DomainPhase, DomainState, StorageBucket } from '@prisma/client' -import { PrismaService } from '../prisma/prisma.service' import { RegionService } from '../region/region.service' import * as assert from 'node:assert' import { TASK_LOCK_INIT_TIME } from 'src/constants' +import { StorageBucket } from 'src/storage/entities/storage-bucket' +import { SystemDatabase } from 'src/database/system-database' +import { BucketDomain } from './entities/bucket-domain' +import { DomainPhase, DomainState } from './entities/runtime-domain' @Injectable() export class BucketDomainService { private readonly logger = new Logger(BucketDomainService.name) + private readonly db = SystemDatabase.db - constructor( - private readonly prisma: PrismaService, - private readonly regionService: RegionService, - ) {} + constructor(private readonly regionService: RegionService) {} /** * Create app domain in database @@ -23,45 +23,36 @@ export class BucketDomainService { // create domain in db const bucket_domain = `${bucket.name}.${region.storageConf.domain}` - const doc = await this.prisma.bucketDomain.create({ - data: { - appid: bucket.appid, - domain: bucket_domain, - bucket: { - connect: { - name: bucket.name, - }, - }, - state: DomainState.Active, - phase: DomainPhase.Creating, - lockedAt: TASK_LOCK_INIT_TIME, - }, + await this.db.collection('BucketDomain').insertOne({ + appid: bucket.appid, + domain: bucket_domain, + bucketName: bucket.name, + state: DomainState.Active, + phase: DomainPhase.Creating, + lockedAt: TASK_LOCK_INIT_TIME, + createdAt: new Date(), + updatedAt: new Date(), }) - return doc + return this.findOne(bucket) } /** * Find an app domain in database */ async findOne(bucket: StorageBucket) { - const doc = await this.prisma.bucketDomain.findFirst({ - where: { - bucket: { - name: bucket.name, - }, - }, + const doc = await this.db.collection('BucketDomain').findOne({ + appid: bucket.appid, + bucketName: bucket.name, }) return doc } async count(appid: string) { - const count = await this.prisma.bucketDomain.count({ - where: { - appid, - }, - }) + const count = await this.db + .collection('BucketDomain') + .countDocuments({ appid }) return count } @@ -70,30 +61,22 @@ export class BucketDomainService { * Delete app domain in database: * - turn to `Deleted` state */ - async delete(bucket: StorageBucket) { - const doc = await this.prisma.bucketDomain.update({ - where: { - id: bucket.id, - bucketName: bucket.name, - }, - data: { - state: DomainState.Deleted, - }, - }) + async deleteOne(bucket: StorageBucket) { + await this.db + .collection('BucketDomain') + .findOneAndUpdate( + { _id: bucket._id }, + { $set: { state: DomainState.Deleted } }, + ) - return doc + return await this.findOne(bucket) } async deleteAll(appid: string) { - const docs = await this.prisma.bucketDomain.updateMany({ - where: { - appid, - }, - data: { - state: DomainState.Deleted, - }, - }) + const res = await this.db + .collection('BucketDomain') + .updateMany({ appid }, { $set: { state: DomainState.Deleted } }) - return docs + return res } } diff --git a/server/src/gateway/entities/bucket-domain.ts b/server/src/gateway/entities/bucket-domain.ts new file mode 100644 index 0000000000..05a4880d78 --- /dev/null +++ b/server/src/gateway/entities/bucket-domain.ts @@ -0,0 +1,18 @@ +import { ObjectId } from 'mongodb' +import { DomainPhase, DomainState } from './runtime-domain' + +export class BucketDomain { + _id?: ObjectId + appid: string + bucketName: string + domain: string + state: DomainState + phase: DomainPhase + lockedAt: Date + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/gateway/entities/runtime-domain.ts b/server/src/gateway/entities/runtime-domain.ts new file mode 100644 index 0000000000..a5f7191e01 --- /dev/null +++ b/server/src/gateway/entities/runtime-domain.ts @@ -0,0 +1,29 @@ +import { ObjectId } from 'mongodb' + +export enum DomainPhase { + Creating = 'Creating', + Created = 'Created', + Deleting = 'Deleting', + Deleted = 'Deleted', +} + +export enum DomainState { + Active = 'Active', + Inactive = 'Inactive', + Deleted = 'Deleted', +} + +export class RuntimeDomain { + _id?: ObjectId + appid: string + domain: string + state: DomainState + phase: DomainPhase + lockedAt: Date + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/gateway/runtime-domain-task.service.ts b/server/src/gateway/runtime-domain-task.service.ts index 426b3d04b8..88b9076436 100644 --- a/server/src/gateway/runtime-domain-task.service.ts +++ b/server/src/gateway/runtime-domain-task.service.ts @@ -1,11 +1,15 @@ import { Injectable, Logger } from '@nestjs/common' -import { RuntimeDomain, DomainPhase, DomainState } from '@prisma/client' import { RegionService } from '../region/region.service' import { ApisixService } from './apisix.service' import * as assert from 'node:assert' import { Cron, CronExpression } from '@nestjs/schedule' import { ServerConfig, TASK_LOCK_INIT_TIME } from '../constants' import { SystemDatabase } from '../database/system-database' +import { + DomainPhase, + DomainState, + RuntimeDomain, +} from './entities/runtime-domain' @Injectable() export class RuntimeDomainTaskService { diff --git a/server/src/gateway/runtime-domain.service.ts b/server/src/gateway/runtime-domain.service.ts index 50f235c05c..789868322c 100644 --- a/server/src/gateway/runtime-domain.service.ts +++ b/server/src/gateway/runtime-domain.service.ts @@ -1,20 +1,20 @@ import { Injectable, Logger } from '@nestjs/common' -import { PrismaService } from '../prisma/prisma.service' import * as assert from 'assert' import { RegionService } from '../region/region.service' -import { ApisixService } from './apisix.service' -import { DomainPhase, DomainState } from '@prisma/client' import { TASK_LOCK_INIT_TIME } from 'src/constants' +import { SystemDatabase } from 'src/database/system-database' +import { + DomainPhase, + DomainState, + RuntimeDomain, +} from './entities/runtime-domain' @Injectable() export class RuntimeDomainService { private readonly logger = new Logger(RuntimeDomainService.name) + private readonly db = SystemDatabase.db - constructor( - private readonly prisma: PrismaService, - private readonly regionService: RegionService, - private readonly apisixService: ApisixService, - ) {} + constructor(private readonly regionService: RegionService) {} /** * Create app domain in database @@ -25,28 +25,26 @@ export class RuntimeDomainService { // create domain in db const app_domain = `${appid}.${region.gatewayConf.runtimeDomain}` - const doc = await this.prisma.runtimeDomain.create({ - data: { - appid: appid, - domain: app_domain, - state: DomainState.Active, - phase: DomainPhase.Creating, - lockedAt: TASK_LOCK_INIT_TIME, - }, + await this.db.collection('RuntimeDomain').insertOne({ + appid: appid, + domain: app_domain, + state: DomainState.Active, + phase: DomainPhase.Creating, + lockedAt: TASK_LOCK_INIT_TIME, + createdAt: new Date(), + updatedAt: new Date(), }) - return doc + return await this.findOne(appid) } /** * Find an app domain in database */ async findOne(appid: string) { - const doc = await this.prisma.runtimeDomain.findFirst({ - where: { - appid: appid, - }, - }) + const doc = await this.db + .collection('RuntimeDomain') + .findOne({ appid }) return doc } @@ -55,15 +53,13 @@ export class RuntimeDomainService { * Delete app domain in database: * - turn to `Deleted` state */ - async delete(appid: string) { - const doc = await this.prisma.runtimeDomain.update({ - where: { - appid: appid, - }, - data: { - state: DomainState.Deleted, - }, - }) + async deleteOne(appid: string) { + const doc = await this.db + .collection('RuntimeDomain') + .findOneAndUpdate( + { appid: appid }, + { $set: { state: DomainState.Deleted } }, + ) return doc } diff --git a/server/src/gateway/website-task.service.ts b/server/src/gateway/website-task.service.ts index a8634158a6..0b7ee4b9db 100644 --- a/server/src/gateway/website-task.service.ts +++ b/server/src/gateway/website-task.service.ts @@ -1,11 +1,5 @@ import { Injectable, Logger } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' -import { - BucketDomain, - DomainPhase, - DomainState, - WebsiteHosting, -} from '@prisma/client' import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' import { SystemDatabase } from 'src/database/system-database' import { RegionService } from 'src/region/region.service' @@ -14,6 +8,9 @@ import { ApisixService } from './apisix.service' import { ApisixCustomCertService } from './apisix-custom-cert.service' import { ObjectId } from 'mongodb' import { isConditionTrue } from 'src/utils/getter' +import { WebsiteHosting } from 'src/website/entities/website' +import { DomainPhase, DomainState } from './entities/runtime-domain' +import { BucketDomain } from './entities/bucket-domain' @Injectable() export class WebsiteTaskService { diff --git a/server/src/storage/bucket-task.service.ts b/server/src/storage/bucket-task.service.ts index bf54a56b74..052a54226a 100644 --- a/server/src/storage/bucket-task.service.ts +++ b/server/src/storage/bucket-task.service.ts @@ -1,10 +1,4 @@ import { Injectable, Logger } from '@nestjs/common' -import { - DomainPhase, - DomainState, - StorageBucket, - WebsiteHosting, -} from '@prisma/client' import { RegionService } from 'src/region/region.service' import * as assert from 'node:assert' import { Cron, CronExpression } from '@nestjs/schedule' @@ -12,6 +6,10 @@ import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' import { SystemDatabase } from 'src/database/system-database' import { MinioService } from './minio/minio.service' import { BucketDomainService } from 'src/gateway/bucket-domain.service' +import { StorageBucket } from './entities/storage-bucket' +import { StoragePhase, StorageState } from './entities/storage-user' +import { DomainState } from 'src/gateway/entities/runtime-domain' +import { WebsiteHosting } from 'src/website/entities/website' @Injectable() export class BucketTaskService { @@ -68,7 +66,7 @@ export class BucketTaskService { .collection('StorageBucket') .findOneAndUpdate( { - phase: DomainPhase.Creating, + phase: StoragePhase.Creating, lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, @@ -109,8 +107,10 @@ export class BucketTaskService { const updated = await db .collection('StorageBucket') .updateOne( - { _id: doc._id, phase: DomainPhase.Creating }, - { $set: { phase: DomainPhase.Created, lockedAt: TASK_LOCK_INIT_TIME } }, + { _id: doc._id, phase: StoragePhase.Creating }, + { + $set: { phase: StoragePhase.Created, lockedAt: TASK_LOCK_INIT_TIME }, + }, ) if (updated.modifiedCount > 0) @@ -129,7 +129,7 @@ export class BucketTaskService { .collection('StorageBucket') .findOneAndUpdate( { - phase: DomainPhase.Deleting, + phase: StoragePhase.Deleting, lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, @@ -156,7 +156,7 @@ export class BucketTaskService { // delete bucket domain const domain = await this.bucketDomainService.findOne(doc) if (domain) { - await this.bucketDomainService.delete(doc) + await this.bucketDomainService.deleteOne(doc) this.logger.debug('bucket domain deleted:', domain) } @@ -176,8 +176,10 @@ export class BucketTaskService { const updated = await db .collection('StorageBucket') .updateOne( - { _id: doc._id, phase: DomainPhase.Deleting }, - { $set: { phase: DomainPhase.Deleted, lockedAt: TASK_LOCK_INIT_TIME } }, + { _id: doc._id, phase: StoragePhase.Deleting }, + { + $set: { phase: StoragePhase.Deleted, lockedAt: TASK_LOCK_INIT_TIME }, + }, ) if (updated.modifiedCount > 0) @@ -193,12 +195,12 @@ export class BucketTaskService { await db.collection('StorageBucket').updateMany( { - state: DomainState.Active, - phase: DomainPhase.Deleted, + state: StorageState.Active, + phase: StoragePhase.Deleted, lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { - $set: { phase: DomainPhase.Creating, lockedAt: TASK_LOCK_INIT_TIME }, + $set: { phase: StoragePhase.Creating, lockedAt: TASK_LOCK_INIT_TIME }, }, ) } @@ -212,12 +214,12 @@ export class BucketTaskService { await db.collection('StorageBucket').updateMany( { - state: DomainState.Inactive, - phase: DomainPhase.Created, + state: StorageState.Inactive, + phase: StoragePhase.Created, lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { - $set: { phase: DomainPhase.Deleting, lockedAt: TASK_LOCK_INIT_TIME }, + $set: { phase: StoragePhase.Deleting, lockedAt: TASK_LOCK_INIT_TIME }, }, ) } @@ -232,17 +234,17 @@ export class BucketTaskService { await db.collection('StorageBucket').updateMany( { - state: DomainState.Deleted, - phase: { $in: [DomainPhase.Created, DomainPhase.Creating] }, + state: StorageState.Deleted, + phase: { $in: [StoragePhase.Created, StoragePhase.Creating] }, lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { - $set: { phase: DomainPhase.Deleting, lockedAt: TASK_LOCK_INIT_TIME }, + $set: { phase: StoragePhase.Deleting, lockedAt: TASK_LOCK_INIT_TIME }, }, ) await db .collection('StorageBucket') - .deleteMany({ state: DomainState.Deleted, phase: DomainPhase.Deleted }) + .deleteMany({ state: StorageState.Deleted, phase: StoragePhase.Deleted }) } } diff --git a/server/src/storage/bucket.controller.ts b/server/src/storage/bucket.controller.ts index 812fb13a6d..7cd5a5ae75 100644 --- a/server/src/storage/bucket.controller.ts +++ b/server/src/storage/bucket.controller.ts @@ -133,7 +133,7 @@ export class BucketController { throw new HttpException('bucket not found', HttpStatus.NOT_FOUND) } - const res = await this.bucketService.update(bucket, dto) + const res = await this.bucketService.updateOne(bucket, dto) if (!res) { return ResponseUtil.error('update bucket failed') } @@ -162,7 +162,7 @@ export class BucketController { ) } - const res = await this.bucketService.delete(bucket) + const res = await this.bucketService.deleteOne(bucket) if (!res) { return ResponseUtil.error('delete bucket failed') } diff --git a/server/src/storage/bucket.service.ts b/server/src/storage/bucket.service.ts index 9dd5766426..99d77a9acc 100644 --- a/server/src/storage/bucket.service.ts +++ b/server/src/storage/bucket.service.ts @@ -1,82 +1,112 @@ import { Injectable, Logger } from '@nestjs/common' -import { StorageBucket, StoragePhase, StorageState } from '@prisma/client' import { TASK_LOCK_INIT_TIME } from 'src/constants' -import { PrismaService } from '../prisma/prisma.service' import { RegionService } from '../region/region.service' import { CreateBucketDto } from './dto/create-bucket.dto' import { UpdateBucketDto } from './dto/update-bucket.dto' import { MinioService } from './minio/minio.service' import { Application } from 'src/application/entities/application' +import { SystemDatabase } from 'src/database/system-database' +import { StorageBucket, StorageWithRelations } from './entities/storage-bucket' +import { StoragePhase, StorageState } from './entities/storage-user' @Injectable() export class BucketService { private readonly logger = new Logger(BucketService.name) + private readonly db = SystemDatabase.db constructor( private readonly minioService: MinioService, private readonly regionService: RegionService, - private readonly prisma: PrismaService, ) {} async create(app: Application, dto: CreateBucketDto) { const bucketName = dto.fullname(app.appid) // create bucket in db - const bucket = await this.prisma.storageBucket.create({ - data: { - appid: app.appid, - name: bucketName, - policy: dto.policy, - shortName: dto.shortName, - state: StorageState.Active, - phase: StoragePhase.Creating, - lockedAt: TASK_LOCK_INIT_TIME, - }, + await this.db.collection('StorageBucket').insertOne({ + appid: app.appid, + name: bucketName, + policy: dto.policy, + shortName: dto.shortName, + state: StorageState.Active, + phase: StoragePhase.Creating, + lockedAt: TASK_LOCK_INIT_TIME, + updatedAt: new Date(), + createdAt: new Date(), }) - return bucket + return this.findOne(app.appid, bucketName) } async count(appid: string) { - const count = await this.prisma.storageBucket.count({ - where: { - appid, - }, - }) + const count = await this.db + .collection('StorageBucket') + .countDocuments({ appid }) return count } async findOne(appid: string, name: string) { - const bucket = await this.prisma.storageBucket.findFirst({ - where: { - appid, - name, - }, - include: { - domain: true, - websiteHosting: true, - }, - }) + const bucket = await this.db + .collection('StorageBucket') + .aggregate() + .match({ appid, name }) + .lookup({ + from: 'BucketDomain', + localField: 'name', + foreignField: 'bucketName', + as: 'domain', + }) + .unwind({ + path: '$domain', + preserveNullAndEmptyArrays: true, + }) + .lookup({ + from: 'WebsiteHosting', + localField: 'name', + foreignField: 'bucketName', + as: 'websiteHosting', + }) + .unwind({ + path: '$websiteHosting', + preserveNullAndEmptyArrays: true, + }) + .next() return bucket } async findAll(appid: string) { - const buckets = await this.prisma.storageBucket.findMany({ - where: { - appid, - }, - include: { - domain: true, - websiteHosting: true, - }, - }) + const buckets = await this.db + .collection('StorageBucket') + .aggregate() + .match({ appid }) + .lookup({ + from: 'BucketDomain', + localField: 'name', + foreignField: 'bucketName', + as: 'domain', + }) + .unwind({ + path: '$domain', + preserveNullAndEmptyArrays: true, + }) + .lookup({ + from: 'WebsiteHosting', + localField: 'name', + foreignField: 'bucketName', + as: 'websiteHosting', + }) + .unwind({ + path: '$websiteHosting', + preserveNullAndEmptyArrays: true, + }) + .toArray() return buckets } - async update(bucket: StorageBucket, dto: UpdateBucketDto) { + async updateOne(bucket: StorageBucket, dto: UpdateBucketDto) { // update bucket in minio const region = await this.regionService.findByAppId(bucket.appid) const out = await this.minioService.updateBucketPolicy( @@ -90,27 +120,23 @@ export class BucketService { } // update bucket in db - const res = await this.prisma.storageBucket.update({ - where: { - name: bucket.name, - }, - data: { - policy: dto.policy, - }, - }) + const res = await this.db + .collection('StorageBucket') + .findOneAndUpdate( + { appid: bucket.appid, name: bucket.name }, + { $set: { policy: dto.policy, updatedAt: new Date() } }, + ) return res } - async delete(bucket: StorageBucket) { - const res = await this.prisma.storageBucket.update({ - where: { - name: bucket.name, - }, - data: { - state: StorageState.Deleted, - }, - }) + async deleteOne(bucket: StorageBucket) { + const res = await this.db + .collection('StorageBucket') + .findOneAndUpdate( + { appid: bucket.appid, name: bucket.name }, + { $set: { state: StorageState.Deleted } }, + ) return res } diff --git a/server/src/storage/dto/create-bucket.dto.ts b/server/src/storage/dto/create-bucket.dto.ts index f3d7404104..ebddff3cd2 100644 --- a/server/src/storage/dto/create-bucket.dto.ts +++ b/server/src/storage/dto/create-bucket.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger' -import { BucketPolicy } from '@prisma/client' import { IsEnum, IsNotEmpty, Matches } from 'class-validator' +import { BucketPolicy } from '../entities/storage-bucket' export class CreateBucketDto { @ApiProperty({ diff --git a/server/src/storage/dto/update-bucket.dto.ts b/server/src/storage/dto/update-bucket.dto.ts index 7c3c2569c4..14e13bb1bd 100644 --- a/server/src/storage/dto/update-bucket.dto.ts +++ b/server/src/storage/dto/update-bucket.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger' -import { BucketPolicy } from '@prisma/client' import { IsEnum } from 'class-validator' +import { BucketPolicy } from '../entities/storage-bucket' export class UpdateBucketDto { @ApiProperty({ enum: BucketPolicy }) diff --git a/server/src/storage/entities/storage-bucket.ts b/server/src/storage/entities/storage-bucket.ts index 2584562c9f..f54a9cfd59 100644 --- a/server/src/storage/entities/storage-bucket.ts +++ b/server/src/storage/entities/storage-bucket.ts @@ -1,4 +1,7 @@ import { ObjectId } from 'mongodb' +import { StoragePhase, StorageState } from './storage-user' +import { WebsiteHosting } from 'src/website/entities/website' +import { BucketDomain } from 'src/gateway/entities/bucket-domain' export enum BucketPolicy { readwrite = 'readwrite', @@ -6,19 +9,6 @@ export enum BucketPolicy { private = 'private', } -export enum StoragePhase { - Creating = 'Creating', - Created = 'Created', - Deleting = 'Deleting', - Deleted = 'Deleted', -} - -export enum StorageState { - Active = 'Active', - Inactive = 'Inactive', - Deleted = 'Deleted', -} - export class StorageBucket { _id?: ObjectId appid: string @@ -31,3 +21,8 @@ export class StorageBucket { createdAt: Date updatedAt: Date } + +export type StorageWithRelations = StorageBucket & { + domain: BucketDomain + websiteHosting: WebsiteHosting +} diff --git a/server/src/storage/minio/minio.service.ts b/server/src/storage/minio/minio.service.ts index 95a4a2cab8..b9eba9b2de 100644 --- a/server/src/storage/minio/minio.service.ts +++ b/server/src/storage/minio/minio.service.ts @@ -8,13 +8,13 @@ import { PutBucketVersioningCommand, S3, } from '@aws-sdk/client-s3' -import { BucketPolicy } from '@prisma/client' import * as assert from 'node:assert' import * as cp from 'child_process' import { promisify } from 'util' import { MinioCommandExecOutput } from './types' import { MINIO_COMMON_USER_GROUP } from 'src/constants' import { Region } from 'src/region/entities/region' +import { BucketPolicy } from '../entities/storage-bucket' const exec = promisify(cp.exec) diff --git a/server/src/storage/storage.service.ts b/server/src/storage/storage.service.ts index 6e6413f485..f03cb15683 100644 --- a/server/src/storage/storage.service.ts +++ b/server/src/storage/storage.service.ts @@ -1,6 +1,4 @@ import { Injectable, Logger } from '@nestjs/common' -import { StoragePhase, StorageState, StorageUser } from '@prisma/client' -import { PrismaService } from 'src/prisma/prisma.service' import { GenerateAlphaNumericPassword } from 'src/utils/random' import { MinioService } from './minio/minio.service' import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts' @@ -8,6 +6,12 @@ import { RegionService } from 'src/region/region.service' import { TASK_LOCK_INIT_TIME } from 'src/constants' import { Region } from 'src/region/entities/region' import { SystemDatabase } from 'src/database/system-database' +import { + StoragePhase, + StorageState, + StorageUser, +} from './entities/storage-user' +import { StorageBucket } from './entities/storage-bucket' @Injectable() export class StorageService { @@ -17,7 +21,6 @@ export class StorageService { constructor( private readonly minioService: MinioService, private readonly regionService: RegionService, - private readonly prisma: PrismaService, ) {} async create(appid: string) { @@ -28,49 +31,43 @@ export class StorageService { // create storage user in minio if not exists const minioUser = await this.minioService.getUser(region, accessKey) if (!minioUser) { - const r0 = await this.minioService.createUser( + const res = await this.minioService.createUser( region, accessKey, secretKey, ) - if (r0.error) { - this.logger.error(r0.error) + if (res.error) { + this.logger.error(res.error) return null } } // add storage user to common user group in minio - const r1 = await this.minioService.addUserToGroup(region, accessKey) - if (r1.error) { - this.logger.error(r1.error) + const res = await this.minioService.addUserToGroup(region, accessKey) + if (res.error) { + this.logger.error(res.error) return null } // create storage user in database - const user = await this.prisma.storageUser.create({ - data: { - accessKey, - secretKey, - state: StorageState.Active, - phase: StoragePhase.Created, - lockedAt: TASK_LOCK_INIT_TIME, - application: { - connect: { - appid: appid, - }, - }, - }, + await this.db.collection('StorageUser').insertOne({ + appid, + accessKey, + secretKey, + state: StorageState.Active, + phase: StoragePhase.Created, + lockedAt: TASK_LOCK_INIT_TIME, + updatedAt: new Date(), + createdAt: new Date(), }) - return user + return await this.findOne(appid) } async findOne(appid: string) { - const user = await this.prisma.storageUser.findUnique({ - where: { - appid, - }, - }) + const user = await this.db + .collection('StorageUser') + .findOne({ appid }) return user } @@ -79,31 +76,31 @@ export class StorageService { // delete user in minio const region = await this.regionService.findByAppId(appid) - // delete buckets & files in minio - const count = await this.prisma.storageBucket.count({ - where: { appid }, - }) + // delete buckets & files + const count = await this.db + .collection('StorageBucket') + .countDocuments({ appid }) if (count > 0) { - await this.prisma.storageBucket.updateMany({ - where: { - appid, - state: { not: StorageState.Deleted }, - }, - data: { state: StorageState.Deleted }, - }) - + await this.db + .collection('StorageBucket') + .updateMany( + { appid, state: { $ne: StorageState.Deleted } }, + { $set: { state: StorageState.Deleted } }, + ) + + // just return to wait for buckets deletion return } + // delete user in minio await this.minioService.deleteUser(region, appid) - const user = await this.prisma.storageUser.delete({ - where: { - appid, - }, - }) + // delete user in database + const res = await this.db + .collection('StorageUser') + .findOneAndDelete({ appid }) - return user + return res.value } /** diff --git a/server/src/website/dto/create-website.dto.ts b/server/src/website/dto/create-website.dto.ts index 8d32e9a206..d18673395b 100644 --- a/server/src/website/dto/create-website.dto.ts +++ b/server/src/website/dto/create-website.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger' -import { DomainState } from '@prisma/client' import { IsEnum, IsNotEmpty, IsString } from 'class-validator' +import { DomainState } from 'src/gateway/entities/runtime-domain' export class CreateWebsiteDto { @ApiProperty() diff --git a/server/src/website/entities/website.ts b/server/src/website/entities/website.ts new file mode 100644 index 0000000000..93b05cc48a --- /dev/null +++ b/server/src/website/entities/website.ts @@ -0,0 +1,24 @@ +import { ObjectId } from 'mongodb' +import { DomainPhase, DomainState } from 'src/gateway/entities/runtime-domain' +import { StorageBucket } from 'src/storage/entities/storage-bucket' + +export class WebsiteHosting { + _id?: ObjectId + appid: string + bucketName: string + domain: string + isCustom: boolean + state: DomainState + phase: DomainPhase + createdAt: Date + updatedAt: Date + lockedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} + +export type WebsiteHostingWithBucket = WebsiteHosting & { + bucket: StorageBucket +} diff --git a/server/src/website/website.controller.ts b/server/src/website/website.controller.ts index 27acba0b13..24522519f9 100644 --- a/server/src/website/website.controller.ts +++ b/server/src/website/website.controller.ts @@ -22,7 +22,8 @@ import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' import { ResponseUtil } from 'src/utils/response' import { BundleService } from 'src/region/bundle.service' import { BucketService } from 'src/storage/bucket.service' -import { DomainState } from '@prisma/client' +import { ObjectId } from 'mongodb' +import { DomainState } from 'src/gateway/entities/runtime-domain' @ApiTags('WebsiteHosting') @ApiBearerAuth('Authorization') @@ -104,7 +105,7 @@ export class WebsiteController { @UseGuards(JwtAuthGuard, ApplicationAuthGuard) @Get(':id') async findOne(@Param('appid') _appid: string, @Param('id') id: string) { - const site = await this.websiteService.findOne(id) + const site = await this.websiteService.findOne(new ObjectId(id)) if (!site) { return ResponseUtil.error('website hosting not found') } @@ -129,7 +130,7 @@ export class WebsiteController { @Body() dto: BindCustomDomainDto, ) { // get website - const site = await this.websiteService.findOne(id) + const site = await this.websiteService.findOne(new ObjectId(id)) if (!site) { return ResponseUtil.error('website hosting not found') } @@ -144,7 +145,7 @@ export class WebsiteController { // bind domain const binded = await this.websiteService.bindCustomDomain( - site.id, + site._id, dto.domain, ) if (!binded) { @@ -170,7 +171,7 @@ export class WebsiteController { @Body() dto: BindCustomDomainDto, ) { // get website - const site = await this.websiteService.findOne(id) + const site = await this.websiteService.findOne(new ObjectId(id)) if (!site) { return ResponseUtil.error('website hosting not found') } @@ -194,12 +195,12 @@ export class WebsiteController { @UseGuards(JwtAuthGuard, ApplicationAuthGuard) @Delete(':id') async remove(@Param('appid') _appid: string, @Param('id') id: string) { - const site = await this.websiteService.findOne(id) + const site = await this.websiteService.findOne(new ObjectId(id)) if (!site) { return ResponseUtil.error('website hosting not found') } - const deleted = await this.websiteService.remove(site.id) + const deleted = await this.websiteService.removeOne(site._id) if (!deleted) { return ResponseUtil.error('failed to delete website hosting') } diff --git a/server/src/website/website.service.ts b/server/src/website/website.service.ts index b876bbbda0..5cd77d665e 100644 --- a/server/src/website/website.service.ts +++ b/server/src/website/website.service.ts @@ -1,20 +1,21 @@ import { Injectable, Logger } from '@nestjs/common' -import { DomainPhase, DomainState, WebsiteHosting } from '@prisma/client' import { TASK_LOCK_INIT_TIME } from 'src/constants' -import { PrismaService } from 'src/prisma/prisma.service' import { RegionService } from 'src/region/region.service' import { CreateWebsiteDto } from './dto/create-website.dto' import * as assert from 'node:assert' import * as dns from 'node:dns' +import { SystemDatabase } from 'src/database/system-database' +import { WebsiteHosting, WebsiteHostingWithBucket } from './entities/website' +import { DomainPhase, DomainState } from 'src/gateway/entities/runtime-domain' +import { ObjectId } from 'mongodb' +import { BucketDomain } from 'src/gateway/entities/bucket-domain' @Injectable() export class WebsiteService { private readonly logger = new Logger(WebsiteService.name) + private readonly db = SystemDatabase.db - constructor( - private readonly prisma: PrismaService, - private readonly regionService: RegionService, - ) {} + constructor(private readonly regionService: RegionService) {} async create(appid: string, dto: CreateWebsiteDto) { const region = await this.regionService.findByAppId(appid) @@ -23,69 +24,73 @@ export class WebsiteService { // generate default website domain const domain = `${dto.bucketName}.${region.gatewayConf.websiteDomain}` - const website = await this.prisma.websiteHosting.create({ - data: { + const res = await this.db + .collection('WebsiteHosting') + .insertOne({ appid: appid, + bucketName: dto.bucketName, domain: domain, isCustom: false, state: DomainState.Active, phase: DomainPhase.Creating, lockedAt: TASK_LOCK_INIT_TIME, - bucket: { - connect: { - name: dto.bucketName, - }, - }, - }, - }) + createdAt: new Date(), + updatedAt: new Date(), + }) - return website + return await this.findOne(res.insertedId) } async count(appid: string) { - const count = await this.prisma.websiteHosting.count({ - where: { - appid: appid, - }, - }) + const count = await this.db + .collection('WebsiteHosting') + .countDocuments({ appid }) return count } async findAll(appid: string) { - const websites = await this.prisma.websiteHosting.findMany({ - where: { - appid: appid, - }, - include: { - bucket: true, - }, - }) + const websites = await this.db + .collection('WebsiteHosting') + .aggregate() + .match({ appid }) + .lookup({ + from: 'Bucket', + localField: 'bucketName', + foreignField: 'name', + as: 'bucket', + }) + .unwind('$bucket') + .toArray() return websites } - async findOne(id: string) { - const website = await this.prisma.websiteHosting.findFirst({ - where: { - id, - }, - include: { - bucket: true, - }, - }) + async findOne(id: ObjectId) { + const website = await this.db + .collection('WebsiteHosting') + .aggregate() + .match({ _id: id }) + .lookup({ + from: 'Bucket', + localField: 'bucketName', + foreignField: 'name', + as: 'bucket', + }) + .unwind('$bucket') + .next() return website } async checkResolved(website: WebsiteHosting, customDomain: string) { // get bucket domain - const bucketDomain = await this.prisma.bucketDomain.findFirst({ - where: { + const bucketDomain = await this.db + .collection('BucketDomain') + .findOne({ appid: website.appid, bucketName: website.bucketName, - }, - }) + }) const cnameTarget = bucketDomain.domain @@ -97,55 +102,37 @@ export class WebsiteService { return }) - if (!result) { - return false - } - - if (false === (result || []).includes(cnameTarget)) { - return false - } - + if (!result) return false + if (false === (result || []).includes(cnameTarget)) return false return true } - async bindCustomDomain(id: string, domain: string) { - const website = await this.prisma.websiteHosting.update({ - where: { - id, - }, - data: { - domain: domain, - isCustom: true, - phase: DomainPhase.Deleting, - }, - }) + async bindCustomDomain(id: ObjectId, domain: string) { + const res = await this.db + .collection('WebsiteHosting') + .findOneAndUpdate( + { _id: id }, + { + $set: { domain: domain, isCustom: true, phase: DomainPhase.Deleting }, + }, + ) - return website + return res.value } - async remove(id: string) { - const website = await this.prisma.websiteHosting.update({ - where: { - id, - }, - data: { - state: DomainState.Deleted, - }, - }) + async removeOne(id: ObjectId) { + const res = await this.db + .collection('WebsiteHosting') + .findOneAndUpdate({ _id: id }, { $set: { state: DomainState.Deleted } }) - return website + return res.value } async removeAll(appid: string) { - const websites = await this.prisma.websiteHosting.updateMany({ - where: { - appid, - }, - data: { - state: DomainState.Deleted, - }, - }) + const res = await this.db + .collection('WebsiteHosting') + .updateMany({ appid }, { $set: { state: DomainState.Deleted } }) - return websites + return res } } From d3b68886eca1d27f9871fd68657f35b0fda2d174 Mon Sep 17 00:00:00 2001 From: maslow Date: Tue, 16 May 2023 15:26:52 +0000 Subject: [PATCH 05/48] remove prisma in dependency module --- server/src/application/application.service.ts | 2 +- server/src/application/environment.service.ts | 10 +++- .../src/dependency/dependency.controller.ts | 2 +- server/src/dependency/dependency.service.ts | 47 +++++++++++-------- server/src/gateway/bucket-domain.service.ts | 7 ++- server/src/gateway/runtime-domain.service.ts | 2 +- server/src/storage/bucket.service.ts | 2 +- server/src/storage/storage.service.ts | 2 +- server/src/website/website.service.ts | 12 ++++- 9 files changed, 56 insertions(+), 30 deletions(-) diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index c3ead41e16..10cd6edb77 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -242,7 +242,7 @@ export class ApplicationService { .collection('Application') .findOneAndUpdate( { appid }, - { $set: { phase: ApplicationPhase.Deleted } }, + { $set: { phase: ApplicationPhase.Deleted, updatedAt: new Date() } }, ) return doc.value diff --git a/server/src/application/environment.service.ts b/server/src/application/environment.service.ts index 5f0f29738a..87ff151f33 100644 --- a/server/src/application/environment.service.ts +++ b/server/src/application/environment.service.ts @@ -15,7 +15,10 @@ export class EnvironmentVariableService { async updateAll(appid: string, dto: CreateEnvironmentDto[]) { const res = await this.db .collection('ApplicationConfiguration') - .findOneAndUpdate({ appid }, { $set: { environments: dto } }) + .findOneAndUpdate( + { appid }, + { $set: { environments: dto, updatedAt: new Date() } }, + ) assert(res?.value, 'application configuration not found') await this.confService.publish(res.value) @@ -39,7 +42,10 @@ export class EnvironmentVariableService { const res = await this.db .collection('ApplicationConfiguration') - .findOneAndUpdate({ appid }, { $set: { environments: origin } }) + .findOneAndUpdate( + { appid }, + { $set: { environments: origin, updatedAt: new Date() } }, + ) assert(res?.value, 'application configuration not found') await this.confService.publish(res.value) diff --git a/server/src/dependency/dependency.controller.ts b/server/src/dependency/dependency.controller.ts index dda782f779..d705b2a51a 100644 --- a/server/src/dependency/dependency.controller.ts +++ b/server/src/dependency/dependency.controller.ts @@ -96,7 +96,7 @@ export class DependencyController { @Param('appid') appid: string, @Body() dto: DeleteDependencyDto, ) { - const res = await this.depsService.remove(appid, dto.name) + const res = await this.depsService.removeOne(appid, dto.name) return ResponseUtil.ok(res) } } diff --git a/server/src/dependency/dependency.service.ts b/server/src/dependency/dependency.service.ts index 8802ee3f9b..5edbe03c96 100644 --- a/server/src/dependency/dependency.service.ts +++ b/server/src/dependency/dependency.service.ts @@ -1,9 +1,10 @@ import { Injectable, Logger } from '@nestjs/common' -import { PrismaService } from 'src/prisma/prisma.service' import { RUNTIME_BUILTIN_DEPENDENCIES } from 'src/runtime-builtin-deps' import * as npa from 'npm-package-arg' import { CreateDependencyDto } from './dto/create-dependency.dto' import { UpdateDependencyDto } from './dto/update-dependency.dto' +import { SystemDatabase } from 'src/database/system-database' +import { ApplicationConfiguration } from 'src/application/entities/application-configuration' export class Dependency { name: string @@ -15,8 +16,7 @@ export class Dependency { @Injectable() export class DependencyService { private readonly logger = new Logger(DependencyService.name) - - constructor(private readonly prisma: PrismaService) {} + private readonly db = SystemDatabase.db /** * Get app merged dependencies in `Dependency` array @@ -59,10 +59,13 @@ export class DependencyService { // add const new_deps = dto.map((dep) => `${dep.name}@${dep.spec}`) const deps = extras.concat(new_deps) - await this.prisma.applicationConfiguration.update({ - where: { appid }, - data: { dependencies: deps }, - }) + + await this.db + .collection('ApplicationConfiguration') + .updateOne( + { appid }, + { $set: { dependencies: deps, updatedAt: new Date() } }, + ) return true } @@ -87,15 +90,18 @@ export class DependencyService { }) const deps = filtered.concat(new_deps) - await this.prisma.applicationConfiguration.update({ - where: { appid }, - data: { dependencies: deps }, - }) + + await this.db + .collection('ApplicationConfiguration') + .updateOne( + { appid }, + { $set: { dependencies: deps, updatedAt: new Date() } }, + ) return true } - async remove(appid: string, name: string) { + async removeOne(appid: string, name: string) { const deps = await this.getExtras(appid) const filtered = deps.filter((dep) => { const r = npa(dep) @@ -104,10 +110,13 @@ export class DependencyService { if (filtered.length === deps.length) return false - await this.prisma.applicationConfiguration.update({ - where: { appid }, - data: { dependencies: filtered }, - }) + await this.db + .collection('ApplicationConfiguration') + .updateOne( + { appid }, + { $set: { dependencies: filtered, updatedAt: new Date() } }, + ) + return true } @@ -117,9 +126,9 @@ export class DependencyService { * @returns */ private async getExtras(appid: string) { - const conf = await this.prisma.applicationConfiguration.findUnique({ - where: { appid }, - }) + const conf = await this.db + .collection('ApplicationConfiguration') + .findOne({ appid }) const deps = conf?.dependencies ?? [] return deps diff --git a/server/src/gateway/bucket-domain.service.ts b/server/src/gateway/bucket-domain.service.ts index e61d8d95e6..f23097eb84 100644 --- a/server/src/gateway/bucket-domain.service.ts +++ b/server/src/gateway/bucket-domain.service.ts @@ -66,7 +66,7 @@ export class BucketDomainService { .collection('BucketDomain') .findOneAndUpdate( { _id: bucket._id }, - { $set: { state: DomainState.Deleted } }, + { $set: { state: DomainState.Deleted, updatedAt: new Date() } }, ) return await this.findOne(bucket) @@ -75,7 +75,10 @@ export class BucketDomainService { async deleteAll(appid: string) { const res = await this.db .collection('BucketDomain') - .updateMany({ appid }, { $set: { state: DomainState.Deleted } }) + .updateMany( + { appid }, + { $set: { state: DomainState.Deleted, updatedAt: new Date() } }, + ) return res } diff --git a/server/src/gateway/runtime-domain.service.ts b/server/src/gateway/runtime-domain.service.ts index 789868322c..b9e6a152d4 100644 --- a/server/src/gateway/runtime-domain.service.ts +++ b/server/src/gateway/runtime-domain.service.ts @@ -58,7 +58,7 @@ export class RuntimeDomainService { .collection('RuntimeDomain') .findOneAndUpdate( { appid: appid }, - { $set: { state: DomainState.Deleted } }, + { $set: { state: DomainState.Deleted, updatedAt: new Date() } }, ) return doc diff --git a/server/src/storage/bucket.service.ts b/server/src/storage/bucket.service.ts index 99d77a9acc..22caf0b88e 100644 --- a/server/src/storage/bucket.service.ts +++ b/server/src/storage/bucket.service.ts @@ -135,7 +135,7 @@ export class BucketService { .collection('StorageBucket') .findOneAndUpdate( { appid: bucket.appid, name: bucket.name }, - { $set: { state: StorageState.Deleted } }, + { $set: { state: StorageState.Deleted, updatedAt: new Date() } }, ) return res diff --git a/server/src/storage/storage.service.ts b/server/src/storage/storage.service.ts index f03cb15683..5f580f47ce 100644 --- a/server/src/storage/storage.service.ts +++ b/server/src/storage/storage.service.ts @@ -85,7 +85,7 @@ export class StorageService { .collection('StorageBucket') .updateMany( { appid, state: { $ne: StorageState.Deleted } }, - { $set: { state: StorageState.Deleted } }, + { $set: { state: StorageState.Deleted, updatedAt: new Date() } }, ) // just return to wait for buckets deletion diff --git a/server/src/website/website.service.ts b/server/src/website/website.service.ts index 5cd77d665e..b2995d5ecb 100644 --- a/server/src/website/website.service.ts +++ b/server/src/website/website.service.ts @@ -113,7 +113,12 @@ export class WebsiteService { .findOneAndUpdate( { _id: id }, { - $set: { domain: domain, isCustom: true, phase: DomainPhase.Deleting }, + $set: { + domain: domain, + isCustom: true, + phase: DomainPhase.Deleting, + updatedAt: new Date(), + }, }, ) @@ -131,7 +136,10 @@ export class WebsiteService { async removeAll(appid: string) { const res = await this.db .collection('WebsiteHosting') - .updateMany({ appid }, { $set: { state: DomainState.Deleted } }) + .updateMany( + { appid }, + { $set: { state: DomainState.Deleted, updatedAt: new Date() } }, + ) return res } From 45c0dd3b3488816151a3d021e4d708534609216a Mon Sep 17 00:00:00 2001 From: maslow Date: Wed, 17 May 2023 07:59:58 +0000 Subject: [PATCH 06/48] remove prisma in account module --- server/src/account/account.controller.ts | 103 +++++++++++------- server/src/account/account.service.ts | 69 +++++++----- .../account/dto/create-charge-order.dto.ts | 2 +- .../account/entities/account-charge-order.ts | 40 +++++++ .../account/entities/account-transaction.ts | 16 +++ server/src/account/entities/account.ts | 19 ++++ .../src/account/entities/payment-channel.ts | 17 +++ .../payment/payment-channel.service.ts | 49 ++++----- server/src/account/payment/types.ts | 4 + 9 files changed, 224 insertions(+), 95 deletions(-) create mode 100644 server/src/account/entities/account-charge-order.ts create mode 100644 server/src/account/entities/account-transaction.ts create mode 100644 server/src/account/entities/account.ts create mode 100644 server/src/account/entities/payment-channel.ts diff --git a/server/src/account/account.controller.ts b/server/src/account/account.controller.ts index 920de193b9..d64172896c 100644 --- a/server/src/account/account.controller.ts +++ b/server/src/account/account.controller.ts @@ -10,19 +10,26 @@ import { UseGuards, } from '@nestjs/common' import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' -import { AccountChargePhase } from '@prisma/client' import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' -import { PrismaService } from 'src/prisma/prisma.service' import { IRequest } from 'src/utils/interface' import { ResponseUtil } from 'src/utils/response' import { AccountService } from './account.service' import { CreateChargeOrderDto } from './dto/create-charge-order.dto' import { PaymentChannelService } from './payment/payment-channel.service' -import { WeChatPayOrderResponse, WeChatPayTradeState } from './payment/types' +import { + WeChatPayChargeOrder, + WeChatPayOrderResponse, + WeChatPayTradeState, +} from './payment/types' import { WeChatPayService } from './payment/wechat-pay.service' import { Response } from 'express' import * as assert from 'assert' import { ServerConfig } from 'src/constants' +import { AccountChargePhase } from './entities/account-charge-order' +import { ObjectId } from 'mongodb' +import { SystemDatabase } from 'src/database/system-database' +import { Account } from './entities/account' +import { AccountTransaction } from './entities/account-transaction' @ApiTags('Account') @Controller('accounts') @@ -34,7 +41,6 @@ export class AccountController { private readonly accountService: AccountService, private readonly paymentService: PaymentChannelService, private readonly wechatPayService: WeChatPayService, - private readonly prisma: PrismaService, ) {} /** @@ -57,7 +63,10 @@ export class AccountController { @Get('charge-order/:id') async getChargeOrder(@Req() req: IRequest, @Param('id') id: string) { const user = req.user - const data = await this.accountService.findOneChargeOrder(user.id, id) + const data = await this.accountService.findOneChargeOrder( + user.id, + new ObjectId(id), + ) return data } @@ -82,7 +91,7 @@ export class AccountController { // invoke payment const result = await this.accountService.pay( channel, - order.id, + order._id, amount, currency, `${ServerConfig.SITE_NAME} recharge`, @@ -124,15 +133,17 @@ export class AccountController { this.logger.debug(result) - const tradeOrderId = result.out_trade_no + const db = SystemDatabase.db + + const tradeOrderId = new ObjectId(result.out_trade_no) if (result.trade_state !== WeChatPayTradeState.SUCCESS) { - await this.prisma.accountChargeOrder.update({ - where: { id: tradeOrderId }, - data: { - phase: AccountChargePhase.Failed, - result: result as any, - }, - }) + await db + .collection('AccountChargeOrder') + .updateOne( + { _id: tradeOrderId }, + { $set: { phase: AccountChargePhase.Failed, result: result } }, + ) + this.logger.log( `wechatpay order failed: ${tradeOrderId} ${result.trade_state}`, ) @@ -140,48 +151,62 @@ export class AccountController { } // start transaction - await this.prisma.$transaction(async (tx) => { + const client = SystemDatabase.client + const session = client.startSession() + await session.withTransaction(async () => { // get order - const order = await tx.accountChargeOrder.findFirst({ - where: { id: tradeOrderId, phase: AccountChargePhase.Pending }, - }) + const order = await db + .collection('AccountChargeOrder') + .findOne( + { _id: tradeOrderId, phase: AccountChargePhase.Pending }, + { session }, + ) if (!order) { this.logger.error(`wechatpay order not found: ${tradeOrderId}`) return } // update order to success - const res = await tx.accountChargeOrder.updateMany({ - where: { id: tradeOrderId, phase: AccountChargePhase.Pending }, - data: { phase: AccountChargePhase.Paid, result: result as any }, - }) - - if (res.count === 0) { + const res = await db + .collection('AccountChargeOrder') + .updateOne( + { _id: tradeOrderId, phase: AccountChargePhase.Pending }, + { $set: { phase: AccountChargePhase.Paid, result: result } }, + { session }, + ) + + if (res.modifiedCount === 0) { this.logger.error(`wechatpay order not found: ${tradeOrderId}`) return } // get account - const account = await tx.account.findFirst({ - where: { id: order.accountId }, - }) - assert(account, `account not found ${order.accountId}`) + const account = await db + .collection('Account') + .findOne({ _id: order.accountId }, { session }) + assert(account, `account not found: ${order.accountId}`) // update account balance - await tx.account.update({ - where: { id: order.accountId }, - data: { balance: { increment: order.amount } }, - }) - - // create account transaction - await tx.accountTransaction.create({ - data: { + await db + .collection('Account') + .updateOne( + { _id: order.accountId }, + { $inc: { balance: order.amount } }, + { session }, + ) + + // create transaction + await db.collection('AccountTransaction').insertOne( + { accountId: order.accountId, amount: order.amount, - balance: order.amount + account.balance, - message: 'account charge', + balance: account.balance + order.amount, + message: 'Recharge by WeChat Pay', + orderId: order._id, + createdAt: new Date(), }, - }) + { session }, + ) this.logger.log(`wechatpay order success: ${tradeOrderId}`) }) diff --git a/server/src/account/account.service.ts b/server/src/account/account.service.ts index affb080c52..49bec830d9 100644 --- a/server/src/account/account.service.ts +++ b/server/src/account/account.service.ts @@ -1,46 +1,50 @@ import { Injectable, Logger } from '@nestjs/common' -import { PrismaService } from 'src/prisma/prisma.service' import * as assert from 'assert' +import { WeChatPayService } from './payment/wechat-pay.service' +import { PaymentChannelService } from './payment/payment-channel.service' +import { TASK_LOCK_INIT_TIME } from 'src/constants' +import { SystemDatabase } from 'src/database/system-database' +import { Account, BaseState } from './entities/account' +import { ObjectId } from 'mongodb' import { + AccountChargeOrder, AccountChargePhase, Currency, PaymentChannelType, -} from '@prisma/client' -import { WeChatPayService } from './payment/wechat-pay.service' -import { PaymentChannelService } from './payment/payment-channel.service' -import { TASK_LOCK_INIT_TIME } from 'src/constants' +} from './entities/account-charge-order' @Injectable() export class AccountService { private readonly logger = new Logger(AccountService.name) + private readonly db = SystemDatabase.db constructor( - private readonly prisma: PrismaService, private readonly wechatPayService: WeChatPayService, private readonly chanelService: PaymentChannelService, ) {} - async create(userid: string) { - const account = await this.prisma.account.create({ - data: { - balance: 0, - createdBy: userid, - }, + async create(userid: string): Promise { + await this.db.collection('Account').insertOne({ + balance: 0, + state: BaseState.Active, + createdBy: new ObjectId(userid), + createdAt: new Date(), + updatedAt: new Date(), }) - return account + return await this.findOne(userid) } async findOne(userid: string) { - const account = await this.prisma.account.findUnique({ - where: { createdBy: userid }, - }) + const account = await this.db + .collection('Account') + .findOne({ createdBy: new ObjectId(userid) }) if (account) { return account } - return this.create(userid) + return await this.create(userid) } async createChargeOrder( @@ -53,32 +57,37 @@ export class AccountService { assert(account, 'Account not found') // create charge order - const order = await this.prisma.accountChargeOrder.create({ - data: { - accountId: account.id, + await this.db + .collection('AccountChargeOrder') + .insertOne({ + accountId: account._id, amount, currency: currency, phase: AccountChargePhase.Pending, channel: channel, - createdBy: userid, + createdBy: new ObjectId(userid), lockedAt: TASK_LOCK_INIT_TIME, - }, - }) + createdAt: new Date(), + updatedAt: new Date(), + }) - return order + return await this.findOneChargeOrder(userid, account._id) } - async findOneChargeOrder(userid: string, id: string) { - const order = await this.prisma.accountChargeOrder.findFirst({ - where: { id, createdBy: userid }, - }) + async findOneChargeOrder(userid: string, id: ObjectId) { + const order = await this.db + .collection('AccountChargeOrder') + .findOne({ + _id: id, + createdBy: new ObjectId(userid), + }) return order } async pay( channel: PaymentChannelType, - orderNumber: string, + orderNumber: ObjectId, amount: number, currency: Currency, description = 'laf account charge', @@ -90,7 +99,7 @@ export class AccountService { mchid: spec.mchid, appid: spec.appid, description, - out_trade_no: orderNumber, + out_trade_no: orderNumber.toString(), notify_url: this.wechatPayService.getNotifyUrl(), amount: { total: amount, diff --git a/server/src/account/dto/create-charge-order.dto.ts b/server/src/account/dto/create-charge-order.dto.ts index 91be6e7c3f..1aa048e774 100644 --- a/server/src/account/dto/create-charge-order.dto.ts +++ b/server/src/account/dto/create-charge-order.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger' -import { Currency, PaymentChannelType } from '@prisma/client' import { IsEnum, IsInt, IsPositive, IsString, Max, Min } from 'class-validator' +import { Currency, PaymentChannelType } from '../entities/account-charge-order' export class CreateChargeOrderDto { @ApiProperty({ example: 1000 }) diff --git a/server/src/account/entities/account-charge-order.ts b/server/src/account/entities/account-charge-order.ts new file mode 100644 index 0000000000..5d636065ac --- /dev/null +++ b/server/src/account/entities/account-charge-order.ts @@ -0,0 +1,40 @@ +import { ObjectId } from 'mongodb' + +export enum Currency { + CNY = 'CNY', + USD = 'USD', +} + +export enum AccountChargePhase { + Pending = 'Pending', + Paid = 'Paid', + Failed = 'Failed', +} + +export enum PaymentChannelType { + Manual = 'Manual', + Alipay = 'Alipay', + WeChat = 'WeChat', + Stripe = 'Stripe', + Paypal = 'Paypal', + Google = 'Google', +} + +export class AccountChargeOrder { + _id?: ObjectId + accountId: ObjectId + amount: number + currency: Currency + phase: AccountChargePhase + channel: PaymentChannelType + result?: R + message?: string + createdAt: Date + lockedAt: Date + updatedAt: Date + createdBy: ObjectId + + constructor(partial: Partial>) { + Object.assign(this, partial) + } +} diff --git a/server/src/account/entities/account-transaction.ts b/server/src/account/entities/account-transaction.ts new file mode 100644 index 0000000000..d819999e1c --- /dev/null +++ b/server/src/account/entities/account-transaction.ts @@ -0,0 +1,16 @@ +import { ObjectId } from 'mongodb' + +export class AccountTransaction { + _id?: ObjectId + accountId: ObjectId + amount: number + balance: number + message: string + orderId?: ObjectId + createdAt: Date + updatedAt?: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/account/entities/account.ts b/server/src/account/entities/account.ts new file mode 100644 index 0000000000..6ef9d1ed05 --- /dev/null +++ b/server/src/account/entities/account.ts @@ -0,0 +1,19 @@ +import { ObjectId } from 'mongodb' + +export enum BaseState { + Active = 'Active', + Inactive = 'Inactive', +} + +export class Account { + _id?: ObjectId + balance: number + state: BaseState + createdAt: Date + updatedAt: Date + createdBy: ObjectId + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/account/entities/payment-channel.ts b/server/src/account/entities/payment-channel.ts new file mode 100644 index 0000000000..30e7264c17 --- /dev/null +++ b/server/src/account/entities/payment-channel.ts @@ -0,0 +1,17 @@ +import { ObjectId } from 'mongodb' +import { PaymentChannelType } from './account-charge-order' +import { BaseState } from './account' + +export class PaymentChannel { + _id?: ObjectId + type: PaymentChannelType + name: string + spec: S + state: BaseState + createdAt: Date + updatedAt: Date + + constructor(partial: Partial>) { + Object.assign(this, partial) + } +} diff --git a/server/src/account/payment/payment-channel.service.ts b/server/src/account/payment/payment-channel.service.ts index d94eafd0f5..66b772b5e0 100644 --- a/server/src/account/payment/payment-channel.service.ts +++ b/server/src/account/payment/payment-channel.service.ts @@ -1,47 +1,46 @@ import { Injectable, Logger } from '@nestjs/common' -import { PaymentChannelType } from '@prisma/client' -import { PrismaService } from 'src/prisma/prisma.service' import { WeChatPaySpec } from './types' +import { SystemDatabase } from 'src/database/system-database' +import { PaymentChannel } from '../entities/payment-channel' +import { BaseState } from '../entities/account' +import { PaymentChannelType } from '../entities/account-charge-order' @Injectable() export class PaymentChannelService { private readonly logger = new Logger(PaymentChannelService.name) - - constructor(private readonly prisma: PrismaService) {} + private readonly db = SystemDatabase.db /** * Get all payment channels * @returns */ async findAll() { - const res = await this.prisma.paymentChannel.findMany({ - where: { - state: 'Inactive', - }, - select: { - id: true, - type: true, - name: true, - state: true, - /** - * Security Warning: DO NOT response sensitive information to client. - * KEEP IT false! - */ - spec: false, - }, - }) + const res = await this.db + .collection('PaymentChannel') + .find( + { state: BaseState.Active }, + { + projection: { + // Security Warning: DO NOT response sensitive information to client. + // KEEP IT false! + spec: false, + }, + }, + ) + .toArray() + return res } - async getWeChatPaySpec(): Promise { - const res = await this.prisma.paymentChannel.findFirst({ - where: { type: PaymentChannelType.WeChat }, - }) + async getWeChatPaySpec() { + const res = await this.db + .collection>('PaymentChannel') + .findOne({ type: PaymentChannelType.WeChat }) if (!res) { throw new Error('No WeChat Pay channel found') } - return res.spec as any + return res.spec } } diff --git a/server/src/account/payment/types.ts b/server/src/account/payment/types.ts index d3bc9aa6b8..82e633e048 100644 --- a/server/src/account/payment/types.ts +++ b/server/src/account/payment/types.ts @@ -1,3 +1,5 @@ +import { AccountChargeOrder } from '../entities/account-charge-order' + export interface WeChatPaySpec { mchid: string appid: string @@ -63,3 +65,5 @@ export interface WeChatPayDecryptedResult { payer_currency: string } } + +export type WeChatPayChargeOrder = AccountChargeOrder From 29ee47bbd30a8c282b72c8ececb35a7b50bab052 Mon Sep 17 00:00:00 2001 From: maslow Date: Wed, 17 May 2023 08:46:28 +0000 Subject: [PATCH 07/48] remove prisma in instance module --- server/src/constants.ts | 1 - server/src/instance/instance-task.service.ts | 16 +- server/src/instance/instance.module.ts | 3 +- server/src/instance/instance.service.ts | 231 ++++++++----------- 4 files changed, 105 insertions(+), 146 deletions(-) diff --git a/server/src/constants.ts b/server/src/constants.ts index 6636a2b1e2..f257be04bb 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -133,7 +133,6 @@ export class ServerConfig { export const LABEL_KEY_USER_ID = 'laf.dev/user.id' export const LABEL_KEY_APP_ID = 'laf.dev/appid' export const LABEL_KEY_NAMESPACE_TYPE = 'laf.dev/namespace.type' -export const LABEL_KEY_BUNDLE = 'laf.dev/bundle' export const LABEL_KEY_NODE_TYPE = 'laf.dev/node.type' export enum NodeType { Runtime = 'runtime', diff --git a/server/src/instance/instance-task.service.ts b/server/src/instance/instance-task.service.ts index 93bf805d50..bf68ca2ecc 100644 --- a/server/src/instance/instance-task.service.ts +++ b/server/src/instance/instance-task.service.ts @@ -1,11 +1,15 @@ import { Injectable, Logger } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' -import { Application, ApplicationPhase, ApplicationState } from '@prisma/client' import { isConditionTrue } from '../utils/getter' import { InstanceService } from './instance.service' import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' import { SystemDatabase } from 'src/database/system-database' import { CronJobService } from 'src/trigger/cron-job.service' +import { + Application, + ApplicationPhase, + ApplicationState, +} from 'src/application/entities/application' @Injectable() export class InstanceTaskService { @@ -100,7 +104,7 @@ export class InstanceTaskService { const app = res.value // create instance - await this.instanceService.create(app) + await this.instanceService.create(app.appid) // if waiting time is more than 5 minutes, stop the application const waitingTime = Date.now() - app.updatedAt.getTime() @@ -122,7 +126,7 @@ export class InstanceTaskService { } const appid = app.appid - const instance = await this.instanceService.get(app) + const instance = await this.instanceService.get(appid) const unavailable = instance.deployment?.status?.unavailableReplicas || false if (unavailable) { @@ -218,16 +222,16 @@ export class InstanceTaskService { const waitingTime = Date.now() - app.updatedAt.getTime() // check if the instance is removed - const instance = await this.instanceService.get(app) + const instance = await this.instanceService.get(app.appid) if (instance.deployment) { - await this.instanceService.remove(app) + await this.instanceService.remove(app.appid) await this.relock(appid, waitingTime) return } // check if the service is removed if (instance.service) { - await this.instanceService.remove(app) + await this.instanceService.remove(app.appid) await this.relock(appid, waitingTime) return } diff --git a/server/src/instance/instance.module.ts b/server/src/instance/instance.module.ts index 87287d8741..ac61be1768 100644 --- a/server/src/instance/instance.module.ts +++ b/server/src/instance/instance.module.ts @@ -4,9 +4,10 @@ import { InstanceTaskService } from './instance-task.service' import { StorageModule } from '../storage/storage.module' import { DatabaseModule } from '../database/database.module' import { TriggerModule } from 'src/trigger/trigger.module' +import { ApplicationModule } from 'src/application/application.module' @Module({ - imports: [StorageModule, DatabaseModule, TriggerModule], + imports: [StorageModule, DatabaseModule, TriggerModule, ApplicationModule], providers: [InstanceService, InstanceTaskService], }) export class InstanceModule {} diff --git a/server/src/instance/instance.service.ts b/server/src/instance/instance.service.ts index 788ef94cf1..05d1515513 100644 --- a/server/src/instance/instance.service.ts +++ b/server/src/instance/instance.service.ts @@ -3,82 +3,129 @@ import { Injectable, Logger } from '@nestjs/common' import { GetApplicationNamespaceByAppId } from '../utils/getter' import { LABEL_KEY_APP_ID, - LABEL_KEY_BUNDLE, LABEL_KEY_NODE_TYPE, MB, NodeType, } from '../constants' -import { PrismaService } from '../prisma/prisma.service' import { StorageService } from '../storage/storage.service' import { DatabaseService } from 'src/database/database.service' import { ClusterService } from 'src/region/cluster/cluster.service' -import { - Application, - ApplicationBundle, - ApplicationConfiguration, - Runtime, -} from '@prisma/client' -import { RegionService } from 'src/region/region.service' -import { Region } from 'src/region/entities/region' - -type ApplicationWithRegion = Application & { region: Region } +import { SystemDatabase } from 'src/database/system-database' +import { ApplicationWithRelations } from 'src/application/entities/application' +import { ApplicationService } from 'src/application/application.service' @Injectable() export class InstanceService { - private logger = new Logger('InstanceService') + private readonly logger = new Logger('InstanceService') + private readonly db = SystemDatabase.db + constructor( - private readonly clusterService: ClusterService, - private readonly regionService: RegionService, + private readonly cluster: ClusterService, private readonly storageService: StorageService, private readonly databaseService: DatabaseService, - private readonly prisma: PrismaService, + private readonly applicationService: ApplicationService, ) {} - async create(app: Application) { - const appid = app.appid + public async create(appid: string) { + const app = await this.applicationService.findOneUnsafe(appid) const labels = { [LABEL_KEY_APP_ID]: appid } - const region = await this.regionService.findByAppId(appid) - const appWithRegion = { ...app, region } as ApplicationWithRegion + const region = app.region // Although a namespace has already been created during application creation, // we still need to check it again here in order to handle situations where the cluster is rebuilt. - const namespace = await this.clusterService.getAppNamespace(region, appid) + const namespace = await this.cluster.getAppNamespace(region, appid) if (!namespace) { this.logger.debug(`Creating namespace for application ${appid}`) - await this.clusterService.createAppNamespace(region, appid, app.createdBy) + await this.cluster.createAppNamespace( + region, + appid, + app.createdBy.toString(), + ) } - const res = await this.get(appWithRegion) + // ensure deployment created + const res = await this.get(app.appid) if (!res.deployment) { - await this.createDeployment(appid, labels) + await this.createDeployment(app, labels) } + // ensure service created if (!res.service) { - await this.createService(appWithRegion, labels) + await this.createService(app, labels) } } - async createDeployment(appid: string, labels: any) { + public async remove(appid: string) { + const app = await this.applicationService.findOneUnsafe(appid) + const region = app.region + const { deployment, service } = await this.get(appid) + + const namespace = await this.cluster.getAppNamespace(region, app.appid) + if (!namespace) return // namespace not found, nothing to do + + const appsV1Api = this.cluster.makeAppsV1Api(region) + const coreV1Api = this.cluster.makeCoreV1Api(region) + + // ensure deployment deleted + if (deployment) { + await appsV1Api.deleteNamespacedDeployment(appid, namespace.metadata.name) + } + + // ensure service deleted + if (service) { + const name = appid + await coreV1Api.deleteNamespacedService(name, namespace.metadata.name) + } + this.logger.log(`remove k8s deployment ${deployment?.metadata?.name}`) + } + + public async get(appid: string) { + const app = await this.applicationService.findOneUnsafe(appid) + const region = app.region + const namespace = await this.cluster.getAppNamespace(region, app.appid) + if (!namespace) { + return { deployment: null, service: null } + } + + const deployment = await this.getDeployment(app) + const service = await this.getService(app) + return { deployment, service } + } + + public async restart(appid: string) { + const app = await this.applicationService.findOneUnsafe(appid) + const region = app.region + const { deployment } = await this.get(appid) + if (!deployment) { + await this.create(appid) + return + } + + deployment.spec = await this.makeDeploymentSpec( + app, + deployment.spec.template.metadata.labels, + ) + const appsV1Api = this.cluster.makeAppsV1Api(region) const namespace = GetApplicationNamespaceByAppId(appid) - const app = await this.prisma.application.findUnique({ - where: { appid }, - include: { - configuration: true, - bundle: true, - runtime: true, - region: true, - }, - }) + const res = await appsV1Api.replaceNamespacedDeployment( + app.appid, + namespace, + deployment, + ) + + this.logger.log(`restart k8s deployment ${res.body?.metadata?.name}`) + } - // add bundle label - labels[LABEL_KEY_BUNDLE] = app.bundle.name + private async createDeployment(app: ApplicationWithRelations, labels: any) { + const appid = app.appid + const namespace = GetApplicationNamespaceByAppId(appid) // create deployment const data = new V1Deployment() data.metadata = { name: app.appid, labels } data.spec = await this.makeDeploymentSpec(app, labels) - const appsV1Api = this.clusterService.makeAppsV1Api(app.region) + const appsV1Api = this.cluster.makeAppsV1Api(app.region) const res = await appsV1Api.createNamespacedDeployment(namespace, data) this.logger.log(`create k8s deployment ${res.body?.metadata?.name}`) @@ -86,10 +133,10 @@ export class InstanceService { return res.body } - async createService(app: ApplicationWithRegion, labels: any) { + private async createService(app: ApplicationWithRelations, labels: any) { const namespace = GetApplicationNamespaceByAppId(app.appid) const serviceName = app.appid - const coreV1Api = this.clusterService.makeCoreV1Api(app.region) + const coreV1Api = this.cluster.makeCoreV1Api(app.region) const res = await coreV1Api.createNamespacedService(namespace, { metadata: { name: serviceName, labels }, spec: { @@ -102,49 +149,9 @@ export class InstanceService { return res.body } - async remove(app: Application) { + private async getDeployment(app: ApplicationWithRelations) { const appid = app.appid - const region = await this.regionService.findByAppId(appid) - const { deployment, service } = await this.get(app) - - const namespace = await this.clusterService.getAppNamespace( - region, - app.appid, - ) - if (!namespace) return - - const appsV1Api = this.clusterService.makeAppsV1Api(region) - const coreV1Api = this.clusterService.makeCoreV1Api(region) - - if (deployment) { - await appsV1Api.deleteNamespacedDeployment(appid, namespace.metadata.name) - } - if (service) { - const name = appid - await coreV1Api.deleteNamespacedService(name, namespace.metadata.name) - } - this.logger.log(`remove k8s deployment ${deployment?.metadata?.name}`) - } - - async get(app: Application) { - const region = await this.regionService.findByAppId(app.appid) - const namespace = await this.clusterService.getAppNamespace( - region, - app.appid, - ) - if (!namespace) { - return { deployment: null, service: null } - } - - const appWithRegion = { ...app, region } - const deployment = await this.getDeployment(appWithRegion) - const service = await this.getService(appWithRegion) - return { deployment, service } - } - - async getDeployment(app: ApplicationWithRegion) { - const appid = app.appid - const appsV1Api = this.clusterService.makeAppsV1Api(app.region) + const appsV1Api = this.cluster.makeAppsV1Api(app.region) try { const namespace = GetApplicationNamespaceByAppId(appid) const res = await appsV1Api.readNamespacedDeployment(appid, namespace) @@ -155,9 +162,9 @@ export class InstanceService { } } - async getService(app: ApplicationWithRegion) { + private async getService(app: ApplicationWithRelations) { const appid = app.appid - const coreV1Api = this.clusterService.makeCoreV1Api(app.region) + const coreV1Api = this.cluster.makeCoreV1Api(app.region) try { const serviceName = appid @@ -170,45 +177,8 @@ export class InstanceService { } } - async restart(appid: string) { - const app = await this.prisma.application.findUnique({ - where: { appid }, - include: { - configuration: true, - bundle: true, - runtime: true, - region: true, - }, - }) - const { deployment } = await this.get(app) - if (!deployment) { - await this.create(app) - return - } - - deployment.spec = await this.makeDeploymentSpec( - app, - deployment.spec.template.metadata.labels, - ) - const region = await this.regionService.findByAppId(app.appid) - const appsV1Api = this.clusterService.makeAppsV1Api(region) - const namespace = GetApplicationNamespaceByAppId(app.appid) - const res = await appsV1Api.replaceNamespacedDeployment( - app.appid, - namespace, - deployment, - ) - - this.logger.log(`restart k8s deployment ${res.body?.metadata?.name}`) - } - - async makeDeploymentSpec( - app: Application & { - region: Region - bundle: ApplicationBundle - configuration: ApplicationConfiguration - runtime: Runtime - }, + private async makeDeploymentSpec( + app: ApplicationWithRelations, labels: any, ): Promise { // prepare params @@ -386,21 +356,6 @@ export class InstanceService { }, ], }, - // preferred to schedule on bundle matched node - preferredDuringSchedulingIgnoredDuringExecution: [ - { - weight: 10, - preference: { - matchExpressions: [ - { - key: LABEL_KEY_BUNDLE, - operator: 'In', - values: [app.bundle.name], - }, - ], - }, - }, - ], }, // end of nodeAffinity {} }, // end of affinity {} }, // end of spec {} From 7bc52d4378f1006d2dc14e63e023559f0d4a1fb3 Mon Sep 17 00:00:00 2001 From: maslow Date: Wed, 17 May 2023 14:12:17 +0000 Subject: [PATCH 08/48] remove prisma in auth and user module --- .vscode/settings.json | 21 ++- server/src/auth/application.auth.guard.ts | 5 +- server/src/auth/auth.service.ts | 9 +- server/src/auth/authentication.controller.ts | 31 ++-- server/src/auth/authentication.service.ts | 41 +++--- server/src/auth/dto/passwd-reset.dto.ts | 2 +- server/src/auth/dto/passwd-signup.dto.ts | 2 +- server/src/auth/dto/send-phone-code.dto.ts | 2 +- server/src/auth/entities/auth-provider.ts | 18 +++ server/src/auth/entities/sms-verify-code.ts | 26 ++++ server/src/auth/jwt.strategy.ts | 2 +- server/src/auth/phone/phone.controller.ts | 6 +- server/src/auth/phone/phone.service.ts | 121 +++++++++------- server/src/auth/phone/sms.service.ts | 111 ++++++++------- server/src/auth/types.ts | 10 -- .../user-passwd/user-password.controller.ts | 33 +++-- .../auth/user-passwd/user-password.service.ts | 134 ++++++++++++------ server/src/instance/instance.service.ts | 2 + server/src/setting/entities/setting.ts | 9 ++ server/src/setting/setting.service.ts | 12 +- server/src/user/dto/user.response.ts | 15 +- server/src/user/entities/pat.ts | 15 ++ server/src/user/entities/user-password.ts | 15 ++ server/src/user/entities/user-profile.ts | 11 ++ server/src/user/entities/user.ts | 10 ++ server/src/user/pat.controller.ts | 10 +- server/src/user/pat.service.ts | 94 +++++++----- server/src/user/user.service.ts | 110 +++++--------- 28 files changed, 499 insertions(+), 378 deletions(-) create mode 100644 server/src/auth/entities/auth-provider.ts create mode 100644 server/src/auth/entities/sms-verify-code.ts create mode 100644 server/src/setting/entities/setting.ts create mode 100644 server/src/user/entities/pat.ts create mode 100644 server/src/user/entities/user-password.ts create mode 100644 server/src/user/entities/user-profile.ts create mode 100644 server/src/user/entities/user.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 32ad6dfda4..57cc8f9be7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,6 +22,7 @@ }, "cSpell.words": [ "aarch", + "alicloud", "alipay", "alisms", "apiextensions", @@ -49,6 +50,8 @@ "datepicker", "dockerode", "doctag", + "dysmsapi", + "Dysmsapi", "EJSON", "entrypoint", "finalizers", @@ -75,7 +78,9 @@ "MONOG", "nestjs", "objs", + "openapi", "openebs", + "OVERLIMIT", "passw", "pgdb", "presigner", @@ -115,12 +120,20 @@ "zustand" ], "i18n-ally.localesPaths": "web/public/locales", - "i18n-ally.enabledParsers": ["json"], - "i18n-ally.enabledFrameworks": ["react", "i18next", "general"], + "i18n-ally.enabledParsers": [ + "json" + ], + "i18n-ally.enabledFrameworks": [ + "react", + "i18next", + "general" + ], "i18n-ally.sourceLanguage": "zh-CN", "i18n-ally.displayLanguage": "en,zh", "i18n-ally.namespace": false, "i18n-ally.pathMatcher": "{locale}/translation.json", "i18n-ally.keystyle": "nested", - "i18n-ally.keysInUse": ["description.part2_whatever"] -} + "i18n-ally.keysInUse": [ + "description.part2_whatever" + ] +} \ No newline at end of file diff --git a/server/src/auth/application.auth.guard.ts b/server/src/auth/application.auth.guard.ts index a184862158..0252421392 100644 --- a/server/src/auth/application.auth.guard.ts +++ b/server/src/auth/application.auth.guard.ts @@ -4,9 +4,9 @@ import { Injectable, Logger, } from '@nestjs/common' -import { User } from '@prisma/client' import { ApplicationService } from '../application/application.service' import { IRequest } from '../utils/interface' +import { User } from 'src/user/entities/user' @Injectable() export class ApplicationAuthGuard implements CanActivate { @@ -22,9 +22,8 @@ export class ApplicationAuthGuard implements CanActivate { return false } - // Call toString() to convert to string in case it is ObjectID const author_id = app.createdBy?.toString() - if (author_id !== user.id) { + if (author_id !== user._id.toString()) { return false } diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index 11f5e6cb99..cf27f9ae7f 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -1,12 +1,11 @@ import { Injectable, Logger } from '@nestjs/common' import { JwtService } from '@nestjs/jwt' -import { User } from '@prisma/client' -import * as assert from 'node:assert' +import { User } from 'src/user/entities/user' import { PatService } from 'src/user/pat.service' @Injectable() export class AuthService { - logger: Logger = new Logger(AuthService.name) + private readonly logger = new Logger(AuthService.name) constructor( private readonly jwtService: JwtService, private readonly patService: PatService, @@ -19,7 +18,7 @@ export class AuthService { * @returns */ async pat2token(token: string): Promise { - const pat = await this.patService.findOne(token) + const pat = await this.patService.findOneByToken(token) if (!pat) return null // check pat expired @@ -34,7 +33,7 @@ export class AuthService { * @returns */ getAccessTokenByUser(user: User): string { - const payload = { sub: user.id } + const payload = { sub: user._id.toString() } const token = this.jwtService.sign(payload) return token } diff --git a/server/src/auth/authentication.controller.ts b/server/src/auth/authentication.controller.ts index 50d8ee6dd7..32818df8e4 100644 --- a/server/src/auth/authentication.controller.ts +++ b/server/src/auth/authentication.controller.ts @@ -7,8 +7,9 @@ import { BindUsernameDto } from './dto/bind-username.dto' import { IRequest } from 'src/utils/interface' import { BindPhoneDto } from './dto/bind-phone.dto' import { SmsService } from './phone/sms.service' -import { SmsVerifyCodeType } from '@prisma/client' import { UserService } from 'src/user/user.service' +import { ObjectId } from 'mongodb' +import { SmsVerifyCodeType } from './entities/sms-verify-code' @ApiTags('Authentication - New') @Controller('auth') @@ -40,7 +41,7 @@ export class AuthenticationController { async bindPhone(@Body() dto: BindPhoneDto, @Req() req: IRequest) { const { phone, code } = dto // check code valid - const err = await this.smsService.validCode( + const err = await this.smsService.validateCode( phone, code, SmsVerifyCodeType.Bind, @@ -50,20 +51,13 @@ export class AuthenticationController { } // check phone if have already been bound - const user = await this.userService.find(phone) + const user = await this.userService.findOneByUsernameOrPhoneOrEmail(phone) if (user) { return ResponseUtil.error('phone already been bound') } // bind phone - await this.userService.updateUser({ - where: { - id: req.user.id, - }, - data: { - phone, - }, - }) + await this.userService.updateUser(new ObjectId(req.user.id), { phone }) } /** @@ -77,7 +71,7 @@ export class AuthenticationController { const { username, phone, code } = dto // check code valid - const err = await this.smsService.validCode( + const err = await this.smsService.validateCode( phone, code, SmsVerifyCodeType.Bind, @@ -87,19 +81,14 @@ export class AuthenticationController { } // check username if have already been bound - const user = await this.userService.find(username) + const user = await this.userService.findOneByUsernameOrPhoneOrEmail( + username, + ) if (user) { return ResponseUtil.error('username already been bound') } // bind username - await this.userService.updateUser({ - where: { - id: req.user.id, - }, - data: { - username, - }, - }) + await this.userService.updateUser(new ObjectId(req.user.id), { username }) } } diff --git a/server/src/auth/authentication.service.ts b/server/src/auth/authentication.service.ts index 67d058a661..0886ca404c 100644 --- a/server/src/auth/authentication.service.ts +++ b/server/src/auth/authentication.service.ts @@ -1,37 +1,32 @@ import { JwtService } from '@nestjs/jwt' -import { PrismaService } from 'src/prisma/prisma.service' import { Injectable, Logger } from '@nestjs/common' -import { AuthProviderState, User } from '@prisma/client' import { PASSWORD_AUTH_PROVIDER_NAME, PHONE_AUTH_PROVIDER_NAME, } from 'src/constants' +import { SystemDatabase } from 'src/database/system-database' +import { AuthProvider, AuthProviderState } from './entities/auth-provider' +import { User } from 'src/user/entities/user' @Injectable() export class AuthenticationService { - logger: Logger = new Logger(AuthenticationService.name) - constructor( - private readonly prismaService: PrismaService, - private readonly jwtService: JwtService, - ) {} + private readonly logger = new Logger(AuthenticationService.name) + private readonly db = SystemDatabase.db + + constructor(private readonly jwtService: JwtService) {} /** * Get all auth provides * @returns */ async getProviders() { - return await this.prismaService.authProvider.findMany({ - where: { state: AuthProviderState.Enabled }, - select: { - id: false, - name: true, - bind: true, - register: true, - default: true, - state: true, - config: false, - }, - }) + return await this.db + .collection('AuthProvider') + .find( + { state: AuthProviderState.Enabled }, + { projection: { _id: 0, config: 0 } }, + ) + .toArray() } async getPhoneProvider() { @@ -44,9 +39,9 @@ export class AuthenticationService { // Get auth provider by name async getProvider(name: string) { - return await this.prismaService.authProvider.findUnique({ - where: { name }, - }) + return await this.db + .collection('AuthProvider') + .findOne({ name }) } /** @@ -56,7 +51,7 @@ export class AuthenticationService { */ getAccessTokenByUser(user: User): string { const payload = { - sub: user.id, + sub: user._id.toString(), } const token = this.jwtService.sign(payload) return token diff --git a/server/src/auth/dto/passwd-reset.dto.ts b/server/src/auth/dto/passwd-reset.dto.ts index 9ea7407548..3890265f48 100644 --- a/server/src/auth/dto/passwd-reset.dto.ts +++ b/server/src/auth/dto/passwd-reset.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger' -import { SmsVerifyCodeType } from '@prisma/client' import { IsEnum, IsNotEmpty, IsString, Length, Matches } from 'class-validator' +import { SmsVerifyCodeType } from '../entities/sms-verify-code' export class PasswdResetDto { @ApiProperty({ diff --git a/server/src/auth/dto/passwd-signup.dto.ts b/server/src/auth/dto/passwd-signup.dto.ts index 41df857b02..12973e5c5b 100644 --- a/server/src/auth/dto/passwd-signup.dto.ts +++ b/server/src/auth/dto/passwd-signup.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' -import { SmsVerifyCodeType } from '@prisma/client' import { IsEnum, IsNotEmpty, @@ -8,6 +7,7 @@ import { Length, Matches, } from 'class-validator' +import { SmsVerifyCodeType } from '../entities/sms-verify-code' export class PasswdSignupDto { @ApiProperty({ diff --git a/server/src/auth/dto/send-phone-code.dto.ts b/server/src/auth/dto/send-phone-code.dto.ts index 1d6dda8e06..a36e28795e 100644 --- a/server/src/auth/dto/send-phone-code.dto.ts +++ b/server/src/auth/dto/send-phone-code.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger' -import { SmsVerifyCodeType } from '@prisma/client' import { IsEnum, IsNotEmpty, IsString, Matches } from 'class-validator' +import { SmsVerifyCodeType } from '../entities/sms-verify-code' export class SendPhoneCodeDto { @ApiProperty({ diff --git a/server/src/auth/entities/auth-provider.ts b/server/src/auth/entities/auth-provider.ts new file mode 100644 index 0000000000..604f688b03 --- /dev/null +++ b/server/src/auth/entities/auth-provider.ts @@ -0,0 +1,18 @@ +import { ObjectId } from 'mongodb' + +export enum AuthProviderState { + Enabled = 'Enabled', + Disabled = 'Disabled', +} + +export class AuthProvider { + _id?: ObjectId + name: string + bind: any + register: boolean + default: boolean + state: AuthProviderState + config: any + createdAt: Date + updatedAt: Date +} diff --git a/server/src/auth/entities/sms-verify-code.ts b/server/src/auth/entities/sms-verify-code.ts new file mode 100644 index 0000000000..9fcfd6ae10 --- /dev/null +++ b/server/src/auth/entities/sms-verify-code.ts @@ -0,0 +1,26 @@ +import { ObjectId } from 'mongodb' + +export enum SmsVerifyCodeType { + Signin = 'Signin', + Signup = 'Signup', + ResetPassword = 'ResetPassword', + Bind = 'Bind', + Unbind = 'Unbind', + ChangePhone = 'ChangePhone', +} + +export enum SmsVerifyCodeState { + Unused = 0, + Used = 1, +} + +export class SmsVerifyCode { + _id?: ObjectId + phone: string + code: string + ip: string + type: SmsVerifyCodeType + state: SmsVerifyCodeState + createdAt: Date + updatedAt: Date +} diff --git a/server/src/auth/jwt.strategy.ts b/server/src/auth/jwt.strategy.ts index fc87ced624..88ca43572a 100644 --- a/server/src/auth/jwt.strategy.ts +++ b/server/src/auth/jwt.strategy.ts @@ -21,7 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { */ async validate(payload: any) { const id = payload.sub - const user = await this.userService.user({ id }, true) + const user = await this.userService.findOneById(id) return user } } diff --git a/server/src/auth/phone/phone.controller.ts b/server/src/auth/phone/phone.controller.ts index b13b2d75c8..aec59eb857 100644 --- a/server/src/auth/phone/phone.controller.ts +++ b/server/src/auth/phone/phone.controller.ts @@ -1,5 +1,4 @@ import { SmsService } from 'src/auth/phone/sms.service' -import { SmsVerifyCodeType } from '@prisma/client' import { IRequest } from 'src/utils/interface' import { Body, Controller, Logger, Post, Req } from '@nestjs/common' import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' @@ -10,6 +9,7 @@ import { PhoneSigninDto } from '../dto/phone-signin.dto' import { AuthenticationService } from '../authentication.service' import { UserService } from 'src/user/user.service' import { AuthBindingType, AuthProviderBinding } from '../types' +import { SmsVerifyCodeType } from '../entities/sms-verify-code' @ApiTags('Authentication - New') @Controller('auth') @@ -49,7 +49,7 @@ export class PhoneController { async signin(@Body() dto: PhoneSigninDto) { const { phone, code } = dto // check if code valid - const err = await this.smsService.validCode( + const err = await this.smsService.validateCode( phone, code, SmsVerifyCodeType.Signin, @@ -57,7 +57,7 @@ export class PhoneController { if (err) return ResponseUtil.error(err) // check if user exists - const user = await this.userService.findByPhone(phone) + const user = await this.userService.findOneByPhone(phone) if (user) { const token = this.phoneService.signin(user) return ResponseUtil.ok(token) diff --git a/server/src/auth/phone/phone.service.ts b/server/src/auth/phone/phone.service.ts index a5093a14b0..babd0d333d 100644 --- a/server/src/auth/phone/phone.service.ts +++ b/server/src/auth/phone/phone.service.ts @@ -1,21 +1,27 @@ import { Injectable, Logger } from '@nestjs/common' -import { SmsVerifyCodeType, User } from '@prisma/client' -import { PrismaService } from 'src/prisma/prisma.service' import { SmsService } from 'src/auth/phone/sms.service' -import { UserService } from 'src/user/user.service' import { AuthenticationService } from '../authentication.service' import { PhoneSigninDto } from '../dto/phone-signin.dto' import { hashPassword } from 'src/utils/crypto' -import { SmsVerifyCodeState } from '../types' +import { SmsVerifyCodeType } from '../entities/sms-verify-code' +import { User } from 'src/user/entities/user' +import { SystemDatabase } from 'src/database/system-database' +import { UserService } from 'src/user/user.service' +import { + UserPassword, + UserPasswordState, +} from 'src/user/entities/user-password' +import { UserProfile } from 'src/user/entities/user-profile' @Injectable() export class PhoneService { private readonly logger = new Logger(PhoneService.name) + private readonly db = SystemDatabase.db + constructor( - private readonly prisma: PrismaService, private readonly smsService: SmsService, - private readonly userService: UserService, private readonly authService: AuthenticationService, + private readonly userService: UserService, ) {} /** @@ -40,26 +46,10 @@ export class PhoneService { } // disable previous sms code - await this.prisma.smsVerifyCode.updateMany({ - where: { - phone, - type, - state: SmsVerifyCodeState.Active, - }, - data: { - state: SmsVerifyCodeState.Used, - }, - }) + await this.smsService.disableSameTypeCode(phone, type) // Save new sms code to database - await this.prisma.smsVerifyCode.create({ - data: { - phone, - code, - type, - ip, - }, - }) + await this.smsService.saveCode(phone, code, type, ip) return null } @@ -72,30 +62,58 @@ export class PhoneService { async signup(dto: PhoneSigninDto, withUsername = false) { const { phone, username, password } = dto - // start transaction - const user = await this.prisma.$transaction(async (tx) => { + const client = SystemDatabase.client + const session = client.startSession() + + try { + session.startTransaction() + // create user - const user = await tx.user.create({ - data: { + const res = await this.db.collection('User').insertOne( + { phone, username: username || phone, - profile: { create: { name: username || phone } }, + createdAt: new Date(), + updatedAt: new Date(), }, - }) - if (!withUsername) { - return user - } - // create password if need - await tx.userPassword.create({ - data: { - uid: user.id, - password: hashPassword(password), - state: 'Active', + { session }, + ) + + const user = await this.userService.findOneById(res.insertedId) + + // create profile + await this.db.collection('UserProfile').insertOne( + { + uid: user._id, + name: username, + createdAt: new Date(), + updatedAt: new Date(), }, - }) + { session }, + ) + + if (withUsername) { + // create password + await this.db.collection('UserPassword').insertOne( + { + uid: user._id, + password: hashPassword(password), + state: UserPasswordState.Active, + createdAt: new Date(), + updatedAt: new Date(), + }, + { session }, + ) + } + + await session.commitTransaction() return user - }) - return user + } catch (err) { + await session.abortTransaction() + throw err + } finally { + await session.endSession() + } } /** @@ -110,16 +128,13 @@ export class PhoneService { // check if current user has bind password async ifBindPassword(user: User) { - const count = await this.prisma.userPassword.count({ - where: { - uid: user.id, - state: 'Active', - }, - }) - - if (count === 0) { - return false - } - return true + const count = await this.db + .collection('UserPassword') + .countDocuments({ + uid: user._id, + state: UserPasswordState.Active, + }) + + return count > 0 } } diff --git a/server/src/auth/phone/sms.service.ts b/server/src/auth/phone/sms.service.ts index 5cf4ccda3e..81eeb8037d 100644 --- a/server/src/auth/phone/sms.service.ts +++ b/server/src/auth/phone/sms.service.ts @@ -1,6 +1,5 @@ import { AuthenticationService } from '../authentication.service' import { Injectable, Logger } from '@nestjs/common' -import { Prisma, SmsVerifyCodeType } from '@prisma/client' import Dysmsapi, * as dysmsapi from '@alicloud/dysmsapi20170525' import * as OpenApi from '@alicloud/openapi-client' import * as Util from '@alicloud/tea-util' @@ -11,16 +10,19 @@ import { MILLISECONDS_PER_MINUTE, CODE_VALIDITY, } from 'src/constants' -import { PrismaService } from 'src/prisma/prisma.service' -import { SmsVerifyCodeState } from '../types' +import { SystemDatabase } from 'src/database/system-database' +import { + SmsVerifyCode, + SmsVerifyCodeState, + SmsVerifyCodeType, +} from '../entities/sms-verify-code' @Injectable() export class SmsService { - private logger = new Logger(SmsService.name) - constructor( - private readonly prisma: PrismaService, - private readonly authService: AuthenticationService, - ) {} + private readonly logger = new Logger(SmsService.name) + private readonly db = SystemDatabase.db + + constructor(private readonly authService: AuthenticationService) {} /** * send sms login code to given phone number @@ -50,27 +52,25 @@ export class SmsService { } // Check if phone number has been send sms code in 1 minute - const count = await this.prisma.smsVerifyCode.count({ - where: { + const count = await this.db + .collection('SmsVerifyCode') + .countDocuments({ phone: phone, - createdAt: { - gt: new Date(Date.now() - MILLISECONDS_PER_MINUTE), - }, - }, - }) + createdAt: { $gt: new Date(Date.now() - MILLISECONDS_PER_MINUTE) }, + }) + if (count > 0) { return 'REQUEST_OVERLIMIT: phone number has been send sms code in 1 minute' } // Check if ip has been send sms code beyond 30 times in 24 hours - const countIps = await this.prisma.smsVerifyCode.count({ - where: { + const countIps = await this.db + .collection('SmsVerifyCode') + .countDocuments({ ip: ip, - createdAt: { - gt: new Date(Date.now() - MILLISECONDS_PER_DAY), - }, - }, - }) + createdAt: { $gt: new Date(Date.now() - MILLISECONDS_PER_DAY) }, + }) + if (countIps > LIMIT_CODE_PER_IP_PER_DAY) { return `REQUEST_OVERLIMIT: ip has been send sms code beyond ${LIMIT_CODE_PER_IP_PER_DAY} times in 24 hours` } @@ -78,26 +78,36 @@ export class SmsService { return null } - // save sended code to database - async saveSmsCode(data: Prisma.SmsVerifyCodeCreateInput) { - await this.prisma.smsVerifyCode.create({ - data, + async saveCode( + phone: string, + code: string, + type: SmsVerifyCodeType, + ip: string, + ) { + await this.db.collection('SmsVerifyCode').insertOne({ + phone, + code, + type, + ip, + createdAt: new Date(), + updatedAt: new Date(), + state: SmsVerifyCodeState.Unused, }) } - // Valid given phone and code with code type - async validCode(phone: string, code: string, type: SmsVerifyCodeType) { - const total = await this.prisma.smsVerifyCode.count({ - where: { + async validateCode(phone: string, code: string, type: SmsVerifyCodeType) { + const total = await this.db + .collection('SmsVerifyCode') + .countDocuments({ phone, code, type, - state: SmsVerifyCodeState.Active, - createdAt: { gte: new Date(Date.now() - CODE_VALIDITY) }, - }, - }) + state: SmsVerifyCodeState.Unused, + createdAt: { $gt: new Date(Date.now() - CODE_VALIDITY) }, + }) if (total === 0) return 'invalid code' + // Disable verify code after valid await this.disableCode(phone, code, type) return null @@ -105,31 +115,22 @@ export class SmsService { // Disable verify code async disableCode(phone: string, code: string, type: SmsVerifyCodeType) { - await this.prisma.smsVerifyCode.updateMany({ - where: { - phone, - code, - type, - state: SmsVerifyCodeState.Active, - }, - data: { - state: SmsVerifyCodeState.Used, - }, - }) + await this.db + .collection('SmsVerifyCode') + .updateMany( + { phone, code, type, state: SmsVerifyCodeState.Unused }, + { $set: { state: SmsVerifyCodeState.Used } }, + ) } // Disable same type verify code async disableSameTypeCode(phone: string, type: SmsVerifyCodeType) { - await this.prisma.smsVerifyCode.updateMany({ - where: { - phone, - type, - state: SmsVerifyCodeState.Active, - }, - data: { - state: SmsVerifyCodeState.Used, - }, - }) + await this.db + .collection('SmsVerifyCode') + .updateMany( + { phone, type, state: SmsVerifyCodeState.Unused }, + { $set: { state: SmsVerifyCodeState.Used } }, + ) } // send sms code to phone using alisms diff --git a/server/src/auth/types.ts b/server/src/auth/types.ts index fc7772f9df..022ad47be7 100644 --- a/server/src/auth/types.ts +++ b/server/src/auth/types.ts @@ -4,16 +4,6 @@ export enum AuthBindingType { None = 'none', } -export enum SmsVerifyCodeState { - Active = 0, - Used = 1, -} - -export enum UserPasswordState { - Active = 'Active', - Inactive = 'Inactive', -} - export interface AuthProviderBinding { username: AuthBindingType phone: AuthBindingType diff --git a/server/src/auth/user-passwd/user-password.controller.ts b/server/src/auth/user-passwd/user-password.controller.ts index bcbf1090b7..a318074443 100644 --- a/server/src/auth/user-passwd/user-password.controller.ts +++ b/server/src/auth/user-passwd/user-password.controller.ts @@ -1,6 +1,6 @@ import { AuthenticationService } from '../authentication.service' import { UserPasswordService } from './user-password.service' -import { Body, Controller, Logger, Post, Req } from '@nestjs/common' +import { Body, Controller, Logger, Post } from '@nestjs/common' import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' import { ResponseUtil } from 'src/utils/response' import { UserService } from '../../user/user.service' @@ -31,7 +31,7 @@ export class UserPasswordController { async signup(@Body() dto: PasswdSignupDto) { const { username, password, phone } = dto // check if user exists - const doc = await this.userService.user({ username }) + const doc = await this.userService.findOneByUsername(username) if (doc) { return ResponseUtil.error('user already exists') } @@ -47,11 +47,11 @@ export class UserPasswordController { if (bind.phone === AuthBindingType.Required) { const { phone, code, type } = dto // valid phone has been binded - const user = await this.userService.findByPhone(phone) + const user = await this.userService.findOneByPhone(phone) if (user) { return ResponseUtil.error('phone has been binded') } - const err = await this.smsService.validCode(phone, code, type) + const err = await this.smsService.validateCode(phone, code, type) if (err) { return ResponseUtil.error(err) } @@ -76,13 +76,18 @@ export class UserPasswordController { @Post('passwd/signin') async signin(@Body() dto: PasswdSigninDto) { // check if user exists - const user = await this.userService.find(dto.username) + const user = await this.userService.findOneByUsernameOrPhoneOrEmail( + dto.username, + ) if (!user) { return ResponseUtil.error('user not found') } // check if password is correct - const err = await this.passwdService.validPasswd(user.id, dto.password) + const err = await this.passwdService.validatePassword( + user._id, + dto.password, + ) if (err) { return ResponseUtil.error(err) } @@ -104,23 +109,19 @@ export class UserPasswordController { async reset(@Body() dto: PasswdResetDto) { // valid phone code const { phone, code, type } = dto - let err = await this.smsService.validCode(phone, code, type) + const err = await this.smsService.validateCode(phone, code, type) if (err) { return ResponseUtil.error(err) } // find user by phone - const user = await this.userService.findByPhone(phone) + const user = await this.userService.findOneByPhone(phone) if (!user) { return ResponseUtil.error('user not found') } // reset password - err = await this.passwdService.resetPasswd(user.id, dto.password) - if (err) { - return ResponseUtil.error(err) - } - + await this.passwdService.resetPassword(user._id, dto.password) return ResponseUtil.ok('success') } @@ -133,12 +134,14 @@ export class UserPasswordController { async check(@Body() dto: PasswdCheckDto) { const { username } = dto // check if user exists - const user = await this.userService.find(username) + const user = await this.userService.findOneByUsernameOrPhoneOrEmail( + username, + ) if (!user) { return ResponseUtil.error('user not found') } // find if set password - const hasPasswd = await this.passwdService.hasPasswd(user.id) + const hasPasswd = await this.passwdService.hasPassword(user._id) return ResponseUtil.ok(hasPasswd) } diff --git a/server/src/auth/user-passwd/user-password.service.ts b/server/src/auth/user-passwd/user-password.service.ts index 0e43bb606b..44d68db1db 100644 --- a/server/src/auth/user-passwd/user-password.service.ts +++ b/server/src/auth/user-passwd/user-password.service.ts @@ -1,44 +1,76 @@ import { Injectable, Logger } from '@nestjs/common' -import { PrismaService } from 'src/prisma/prisma.service' -import { User } from '@prisma/client' import { hashPassword } from 'src/utils/crypto' import { AuthenticationService } from '../authentication.service' -import { UserPasswordState } from '../types' +import { SystemDatabase } from 'src/database/system-database' +import { User } from 'src/user/entities/user' +import { + UserPassword, + UserPasswordState, +} from 'src/user/entities/user-password' +import { UserProfile } from 'src/user/entities/user-profile' +import { UserService } from 'src/user/user.service' +import { ObjectId } from 'mongodb' @Injectable() export class UserPasswordService { private readonly logger = new Logger(UserPasswordService.name) + private readonly db = SystemDatabase.db + constructor( - private readonly prisma: PrismaService, private readonly authService: AuthenticationService, + private readonly userService: UserService, ) {} // Singup by username and password async signup(username: string, password: string, phone: string) { - // start transaction - const user = await this.prisma.$transaction(async (tx) => { + const client = SystemDatabase.client + const session = client.startSession() + + try { + session.startTransaction() // create user - const user = await tx.user.create({ - data: { + const res = await this.db.collection('User').insertOne( + { username, phone, - profile: { create: { name: username } }, + email: null, + createdAt: new Date(), + updatedAt: new Date(), }, - }) + { session }, + ) // create password - await tx.userPassword.create({ - data: { - uid: user.id, + await this.db.collection('UserPassword').insertOne( + { + uid: res.insertedId, password: hashPassword(password), state: UserPasswordState.Active, + createdAt: new Date(), + updatedAt: new Date(), }, - }) + { session }, + ) - return user - }) + // create profile + await this.db.collection('UserProfile').insertOne( + { + uid: res.insertedId, + name: username, + createdAt: new Date(), + updatedAt: new Date(), + }, + { session }, + ) - return user + await session.commitTransaction() + return this.userService.findOneById(res.insertedId) + } catch (error) { + await session.abortTransaction() + throw error + } finally { + await session.endSession() + } } // Signin for user, means get access token @@ -46,16 +78,17 @@ export class UserPasswordService { return this.authService.getAccessTokenByUser(user) } - // valid if password is correct - async validPasswd(uid: string, passwd: string) { - const userPasswd = await this.prisma.userPassword.findFirst({ - where: { uid, state: UserPasswordState.Active }, - }) + // validate if password is correct + async validatePassword(uid: ObjectId, password: string) { + const userPasswd = await this.db + .collection('UserPassword') + .findOne({ uid, state: UserPasswordState.Active }) + if (!userPasswd) { return 'password not exists' } - if (userPasswd.password !== hashPassword(passwd)) { + if (userPasswd.password !== hashPassword(password)) { return 'password incorrect' } @@ -63,38 +96,47 @@ export class UserPasswordService { } // reset password - async resetPasswd(uid: string, passwd: string) { - // start transaction - const update = await this.prisma.$transaction(async (tx) => { + async resetPassword(uid: ObjectId, password: string) { + const client = SystemDatabase.client + const session = client.startSession() + + try { + session.startTransaction() // disable old password - await tx.userPassword.updateMany({ - where: { uid }, - data: { state: UserPasswordState.Inactive }, - }) + await this.db + .collection('UserPassword') + .updateMany( + { uid }, + { $set: { state: UserPasswordState.Inactive } }, + { session }, + ) // create new password - const np = await tx.userPassword.create({ - data: { + await this.db.collection('UserPassword').insertOne( + { uid, - password: hashPassword(passwd), + password: hashPassword(password), state: UserPasswordState.Active, + createdAt: new Date(), + updatedAt: new Date(), }, - }) - - return np - }) - if (!update) { - return 'reset password failed' + { session }, + ) + await session.commitTransaction() + } catch (error) { + await session.abortTransaction() + throw error + } finally { + await session.endSession() } - - return null } // check if set password - async hasPasswd(uid: string) { - const userPasswd = await this.prisma.userPassword.findFirst({ - where: { uid, state: UserPasswordState.Active }, - }) - return userPasswd ? true : false // true means has password + async hasPassword(uid: ObjectId) { + const res = await this.db + .collection('UserPassword') + .findOne({ uid, state: UserPasswordState.Active }) + + return res ? true : false } } diff --git a/server/src/instance/instance.service.ts b/server/src/instance/instance.service.ts index 05d1515513..307ef05c06 100644 --- a/server/src/instance/instance.service.ts +++ b/server/src/instance/instance.service.ts @@ -264,6 +264,7 @@ export class InstanceService { requests: { cpu: `${requestCpu}m`, memory: `${requestMemory}Mi`, + 'ephemeral-storage': '64Mi', }, }, volumeMounts: [ @@ -323,6 +324,7 @@ export class InstanceService { requests: { cpu: '5m', memory: '32Mi', + 'ephemeral-storage': '64Mi', }, }, securityContext: { diff --git a/server/src/setting/entities/setting.ts b/server/src/setting/entities/setting.ts new file mode 100644 index 0000000000..afc6eef4c2 --- /dev/null +++ b/server/src/setting/entities/setting.ts @@ -0,0 +1,9 @@ +import { ObjectId } from 'mongodb' + +export class Setting { + _id?: ObjectId + key: string + value: string + desc?: string + metadata?: any +} diff --git a/server/src/setting/setting.service.ts b/server/src/setting/setting.service.ts index a906cb7a31..c1e5351431 100644 --- a/server/src/setting/setting.service.ts +++ b/server/src/setting/setting.service.ts @@ -1,19 +1,17 @@ import { Injectable, Logger } from '@nestjs/common' -import { PrismaService } from 'src/prisma/prisma.service' +import { SystemDatabase } from 'src/database/system-database' +import { Setting } from './entities/setting' @Injectable() export class SettingService { private readonly logger = new Logger(SettingService.name) - - constructor(private readonly prisma: PrismaService) {} + private readonly db = SystemDatabase.db async findAll() { - return await this.prisma.setting.findMany() + return await this.db.collection('Setting').find().toArray() } async findOne(key: string) { - return await this.prisma.setting.findUnique({ - where: { key }, - }) + return await this.db.collection('Setting').findOne({ key }) } } diff --git a/server/src/user/dto/user.response.ts b/server/src/user/dto/user.response.ts index 8c5e465269..e5be356afc 100644 --- a/server/src/user/dto/user.response.ts +++ b/server/src/user/dto/user.response.ts @@ -1,14 +1,13 @@ import { ApiProperty } from '@nestjs/swagger' -import { User, UserProfile } from '@prisma/client' +import { User } from '../entities/user' +import { UserProfile } from '../entities/user-profile' +import { ObjectId } from 'mongodb' export class UserProfileDto implements UserProfile { - id: string + _id?: ObjectId @ApiProperty() - uid: string - - @ApiProperty() - openid: string + uid: ObjectId @ApiProperty() avatar: string @@ -16,8 +15,6 @@ export class UserProfileDto implements UserProfile { @ApiProperty() name: string - from: string - @ApiProperty() openData: any @@ -30,7 +27,7 @@ export class UserProfileDto implements UserProfile { export class UserDto implements User { @ApiProperty() - id: string + _id?: ObjectId @ApiProperty() email: string diff --git a/server/src/user/entities/pat.ts b/server/src/user/entities/pat.ts new file mode 100644 index 0000000000..6934c6bfaa --- /dev/null +++ b/server/src/user/entities/pat.ts @@ -0,0 +1,15 @@ +import { ObjectId } from 'mongodb' +import { User } from './user' + +export class PersonalAccessToken { + _id?: ObjectId + uid: ObjectId + name: string + token: string + expiredAt: Date + createdAt: Date +} + +export type PersonalAccessTokenWithUser = PersonalAccessToken & { + user: User +} diff --git a/server/src/user/entities/user-password.ts b/server/src/user/entities/user-password.ts new file mode 100644 index 0000000000..cc1da3cc3d --- /dev/null +++ b/server/src/user/entities/user-password.ts @@ -0,0 +1,15 @@ +import { ObjectId } from 'mongodb' + +export enum UserPasswordState { + Active = 'Active', + Inactive = 'Inactive', +} + +export class UserPassword { + _id?: ObjectId + uid: ObjectId + password: string + state: UserPasswordState + createdAt: Date + updatedAt: Date +} diff --git a/server/src/user/entities/user-profile.ts b/server/src/user/entities/user-profile.ts new file mode 100644 index 0000000000..8daaa9c6e5 --- /dev/null +++ b/server/src/user/entities/user-profile.ts @@ -0,0 +1,11 @@ +import { ObjectId } from 'mongodb' + +export class UserProfile { + _id?: ObjectId + uid: ObjectId + openData?: any + avatar?: string + name?: string + createdAt: Date + updatedAt: Date +} diff --git a/server/src/user/entities/user.ts b/server/src/user/entities/user.ts new file mode 100644 index 0000000000..e070ea0c30 --- /dev/null +++ b/server/src/user/entities/user.ts @@ -0,0 +1,10 @@ +import { ObjectId } from 'mongodb' + +export class User { + _id?: ObjectId + username: string + email?: string + phone?: string + createdAt: Date + updatedAt: Date +} diff --git a/server/src/user/pat.controller.ts b/server/src/user/pat.controller.ts index 929e79decc..35cfb85e65 100644 --- a/server/src/user/pat.controller.ts +++ b/server/src/user/pat.controller.ts @@ -20,6 +20,7 @@ import { ResponseUtil } from 'src/utils/response' import { IRequest } from 'src/utils/interface' import { CreatePATDto } from './dto/create-pat.dto' import { PatService } from './pat.service' +import { ObjectId } from 'mongodb' @ApiTags('Authentication') @ApiBearerAuth('Authorization') @@ -40,7 +41,7 @@ export class PatController { @UseGuards(JwtAuthGuard) @Post() async create(@Req() req: IRequest, @Body() dto: CreatePATDto) { - const uid = req.user.id + const uid = new ObjectId(req.user.id) // check max count, 10 const count = await this.patService.count(uid) if (count >= 10) { @@ -61,7 +62,7 @@ export class PatController { @UseGuards(JwtAuthGuard) @Get() async findAll(@Req() req: IRequest) { - const uid = req.user.id + const uid = new ObjectId(req.user.id) const pats = await this.patService.findAll(uid) return ResponseUtil.ok(pats) } @@ -78,7 +79,10 @@ export class PatController { @Delete(':id') async remove(@Req() req: IRequest, @Param('id') id: string) { const uid = req.user.id - const pat = await this.patService.remove(uid, id) + const pat = await this.patService.removeOne( + new ObjectId(uid), + new ObjectId(id), + ) return ResponseUtil.ok(pat) } } diff --git a/server/src/user/pat.service.ts b/server/src/user/pat.service.ts index e3b515776d..5d30b5071c 100644 --- a/server/src/user/pat.service.ts +++ b/server/src/user/pat.service.ts @@ -1,66 +1,82 @@ import { Injectable, Logger } from '@nestjs/common' import { GenerateAlphaNumericPassword } from 'src/utils/random' -import { PrismaService } from '../prisma/prisma.service' import { CreatePATDto } from './dto/create-pat.dto' +import { SystemDatabase } from 'src/database/system-database' +import { + PersonalAccessToken, + PersonalAccessTokenWithUser, +} from './entities/pat' +import { ObjectId } from 'mongodb' @Injectable() export class PatService { private readonly logger = new Logger(PatService.name) + private readonly db = SystemDatabase.db - constructor(private readonly prisma: PrismaService) {} - - async create(userid: string, dto: CreatePATDto) { + async create(userid: ObjectId, dto: CreatePATDto) { const { name, expiresIn } = dto const token = 'laf_' + GenerateAlphaNumericPassword(60) - const pat = await this.prisma.personalAccessToken.create({ - data: { + const res = await this.db + .collection('PersonalAccessToken') + .insertOne({ + uid: userid, name, token, expiredAt: new Date(Date.now() + expiresIn * 1000), - user: { - connect: { id: userid }, - }, - }, - }) - return pat + createdAt: new Date(), + }) + + return this.findOne(res.insertedId) } - async findAll(userid: string) { - const pats = await this.prisma.personalAccessToken.findMany({ - where: { uid: userid }, - select: { - id: true, - uid: true, - name: true, - expiredAt: true, - createdAt: true, - }, - }) + async findAll(userid: ObjectId) { + const pats = await this.db + .collection('PersonalAccessToken') + .find({ uid: userid }, { projection: { token: 0 } }) + .toArray() + return pats } - async findOne(token: string) { - const pat = await this.prisma.personalAccessToken.findFirst({ - where: { token }, - include: { - user: true, - }, - }) + async findOneByToken(token: string) { + const pat = await this.db + .collection('PersonalAccessToken') + .aggregate() + .match({ token }) + .lookup({ + from: 'User', + localField: 'uid', + foreignField: '_id', + as: 'user', + }) + .unwind('$user') + .next() + + return pat + } + + async findOne(id: ObjectId) { + const pat = await this.db + .collection('PersonalAccessToken') + .findOne({ _id: id }) + return pat } - async count(userid: string) { - const count = await this.prisma.personalAccessToken.count({ - where: { uid: userid }, - }) + async count(userid: ObjectId) { + const count = await this.db + .collection('PersonalAccessToken') + .countDocuments({ uid: userid }) + return count } - async remove(userid: string, id: string) { - const pat = await this.prisma.personalAccessToken.deleteMany({ - where: { id, uid: userid }, - }) - return pat + async removeOne(userid: ObjectId, id: ObjectId) { + const doc = await this.db + .collection('PersonalAccessToken') + .findOneAndDelete({ _id: id, uid: userid }) + + return doc.value } } diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 7bd7a3715b..2dc0485f84 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -1,102 +1,56 @@ import { Injectable } from '@nestjs/common' -import { Prisma, User } from '@prisma/client' -import { PrismaService } from '../prisma/prisma.service' -import * as nanoid from 'nanoid' +import { SystemDatabase } from 'src/database/system-database' +import { User } from './entities/user' +import { ObjectId } from 'mongodb' @Injectable() export class UserService { - constructor(private prisma: PrismaService) {} + private readonly db = SystemDatabase.db - /** - * @deprecated - * @returns - */ - generateUserId() { - const nano = nanoid.customAlphabet( - '1234567890abcdefghijklmnopqrstuvwxyz', - 12, - ) - return nano() - } - - async create(data: Prisma.UserCreateInput): Promise { - return this.prisma.user.create({ - data, - }) - } - - async user(input: Prisma.UserWhereUniqueInput, withProfile = false) { - return this.prisma.user.findUnique({ - where: input, - include: { - profile: withProfile, - }, + async create(data: Partial) { + const res = await this.db.collection('User').insertOne({ + username: data.username, + email: data.email, + phone: data.phone, + createdAt: new Date(), + updatedAt: new Date(), }) - } - async profile(input: Prisma.UserProfileWhereInput, withUser = true) { - return this.prisma.userProfile.findFirst({ - where: input, - include: { - user: withUser, - }, - }) + return await this.findOneById(res.insertedId) } - async getProfileByOpenid(openid: string) { - return this.profile({ openid }, true) + async findOneById(id: ObjectId) { + return this.db.collection('User').findOne({ _id: id }) } - async users(params: { - skip?: number - take?: number - cursor?: Prisma.UserWhereUniqueInput - where?: Prisma.UserWhereInput - orderBy?: Prisma.UserOrderByWithRelationInput - }): Promise { - const { skip, take, cursor, where, orderBy } = params - return this.prisma.user.findMany({ - skip, - take, - cursor, - where, - orderBy, - }) + async findOneByUsername(username: string) { + return this.db.collection('User').findOne({ username }) } - async updateUser(params: { - where: Prisma.UserWhereUniqueInput - data: Prisma.UserUpdateInput - }) { - const { where, data } = params - return this.prisma.user.update({ - data, - where, + // find user by phone + async findOneByPhone(phone: string) { + const user = await this.db.collection('User').findOne({ + phone, }) - } - async deleteUser(where: Prisma.UserWhereUniqueInput) { - return this.prisma.user.delete({ - where, - }) + return user } // find user by username | phone | email - async find(username: string) { + async findOneByUsernameOrPhoneOrEmail(key: string) { // match either username or phone or email - return await this.prisma.user.findFirst({ - where: { - OR: [{ username }, { phone: username }, { email: username }], - }, + const user = await this.db.collection('User').findOne({ + $or: [{ username: key }, { phone: key }, { email: key }], }) + + return user } - // find user by phone - async findByPhone(phone: string) { - return await this.prisma.user.findFirst({ - where: { - phone, - }, - }) + async updateUser(id: ObjectId, data: Partial) { + await this.db + .collection('User') + .updateOne({ _id: id }, { $set: data }) + + return await this.findOneById(id) } } From 54550c84ea34d94ee36f869fca2e8c459d15b3e5 Mon Sep 17 00:00:00 2001 From: maslow Date: Thu, 18 May 2023 01:34:07 +0800 Subject: [PATCH 09/48] remove prisma in function and trigger module --- server/src/account/account.controller.ts | 6 +- server/src/account/account.service.ts | 8 +- server/src/app.controller.ts | 7 +- .../src/application/application.controller.ts | 6 +- server/src/application/application.service.ts | 4 +- server/src/application/entities/runtime.ts | 2 +- server/src/auth/authentication.controller.ts | 4 +- server/src/auth/jwt.strategy.ts | 3 +- .../src/function/dto/create-function.dto.ts | 2 +- .../src/function/dto/update-function.dto.ts | 2 +- .../src/function/entities/cloud-function.ts | 33 +++++ server/src/function/function.controller.ts | 6 +- server/src/function/function.service.ts | 114 ++++++++++-------- server/src/region/bundle.service.ts | 44 ++----- server/src/region/region.service.ts | 2 - server/src/trigger/cron-job.service.ts | 16 +-- server/src/trigger/entities/cron-trigger.ts | 27 +++++ server/src/trigger/trigger-task.service.ts | 18 ++- server/src/trigger/trigger.controller.ts | 5 +- server/src/trigger/trigger.service.ts | 91 +++++++------- server/src/user/pat.controller.ts | 11 +- server/src/utils/interface.ts | 2 +- 22 files changed, 229 insertions(+), 184 deletions(-) create mode 100644 server/src/function/entities/cloud-function.ts create mode 100644 server/src/trigger/entities/cron-trigger.ts diff --git a/server/src/account/account.controller.ts b/server/src/account/account.controller.ts index d64172896c..cb7202b0b3 100644 --- a/server/src/account/account.controller.ts +++ b/server/src/account/account.controller.ts @@ -51,7 +51,7 @@ export class AccountController { @Get() async findOne(@Req() req: IRequest) { const user = req.user - const data = await this.accountService.findOne(user.id) + const data = await this.accountService.findOne(user._id) return data } @@ -64,7 +64,7 @@ export class AccountController { async getChargeOrder(@Req() req: IRequest, @Param('id') id: string) { const user = req.user const data = await this.accountService.findOneChargeOrder( - user.id, + user._id, new ObjectId(id), ) return data @@ -82,7 +82,7 @@ export class AccountController { // create charge order const order = await this.accountService.createChargeOrder( - user.id, + user._id, amount, currency, channel, diff --git a/server/src/account/account.service.ts b/server/src/account/account.service.ts index 49bec830d9..b8d6fbe2c6 100644 --- a/server/src/account/account.service.ts +++ b/server/src/account/account.service.ts @@ -23,7 +23,7 @@ export class AccountService { private readonly chanelService: PaymentChannelService, ) {} - async create(userid: string): Promise { + async create(userid: ObjectId): Promise { await this.db.collection('Account').insertOne({ balance: 0, state: BaseState.Active, @@ -35,7 +35,7 @@ export class AccountService { return await this.findOne(userid) } - async findOne(userid: string) { + async findOne(userid: ObjectId) { const account = await this.db .collection('Account') .findOne({ createdBy: new ObjectId(userid) }) @@ -48,7 +48,7 @@ export class AccountService { } async createChargeOrder( - userid: string, + userid: ObjectId, amount: number, currency: Currency, channel: PaymentChannelType, @@ -74,7 +74,7 @@ export class AccountService { return await this.findOneChargeOrder(userid, account._id) } - async findOneChargeOrder(userid: string, id: ObjectId) { + async findOneChargeOrder(userid: ObjectId, id: ObjectId) { const order = await this.db .collection('AccountChargeOrder') .findOne({ diff --git a/server/src/app.controller.ts b/server/src/app.controller.ts index d5a57e0537..244feeb8d7 100644 --- a/server/src/app.controller.ts +++ b/server/src/app.controller.ts @@ -1,13 +1,14 @@ import { Controller, Get, Logger } from '@nestjs/common' import { ApiOperation, ApiTags } from '@nestjs/swagger' import { ResponseUtil } from './utils/response' -import { PrismaService } from './prisma/prisma.service' +import { SystemDatabase } from './database/system-database' +import { Runtime } from './application/entities/runtime' @ApiTags('Public') @Controller() export class AppController { private readonly logger = new Logger(AppController.name) - constructor(private readonly prisma: PrismaService) {} + private readonly db = SystemDatabase.db /** * Get runtime list @@ -16,7 +17,7 @@ export class AppController { @ApiOperation({ summary: 'Get application runtime list' }) @Get('runtimes') async getRuntimes() { - const data = await this.prisma.runtime.findMany({}) + const data = await this.db.collection('Runtime').find({}).toArray() return ResponseUtil.ok(data) } } diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index ec288e5e54..71d84a0d38 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -66,7 +66,7 @@ export class ApplicationController { } // check account balance - const account = await this.accountService.findOne(user.id) + const account = await this.accountService.findOne(user._id) const balance = account?.balance || 0 if (balance <= 0) { return ResponseUtil.error(`account balance is not enough`) @@ -74,7 +74,7 @@ export class ApplicationController { // create application const appid = await this.appService.tryGenerateUniqueAppid() - await this.appService.create(user.id, appid, dto) + await this.appService.create(user._id, appid, dto) const app = await this.appService.findOne(appid) return ResponseUtil.ok(app) @@ -90,7 +90,7 @@ export class ApplicationController { @ApiOperation({ summary: 'Get user application list' }) async findAll(@Req() req: IRequest) { const user = req.user - const data = await this.appService.findAllByUser(user.id) + const data = await this.appService.findAllByUser(user._id) return ResponseUtil.ok(data) } diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index 10cd6edb77..bcac21a66c 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -32,7 +32,7 @@ export class ApplicationService { * - create bundle * - create application */ - async create(userid: string, appid: string, dto: CreateApplicationDto) { + async create(userid: ObjectId, appid: string, dto: CreateApplicationDto) { const client = SystemDatabase.client const db = client.db() const session = client.startSession() @@ -98,7 +98,7 @@ export class ApplicationService { } } - async findAllByUser(userid: string) { + async findAllByUser(userid: ObjectId) { const db = SystemDatabase.db const doc = await db diff --git a/server/src/application/entities/runtime.ts b/server/src/application/entities/runtime.ts index c160b3deb6..170d3266a6 100644 --- a/server/src/application/entities/runtime.ts +++ b/server/src/application/entities/runtime.ts @@ -11,7 +11,7 @@ export class Runtime { name: string type: string image: RuntimeImageGroup - state: string + state: 'Active' | 'Inactive' version: string latest: boolean diff --git a/server/src/auth/authentication.controller.ts b/server/src/auth/authentication.controller.ts index 32818df8e4..d8e4e20165 100644 --- a/server/src/auth/authentication.controller.ts +++ b/server/src/auth/authentication.controller.ts @@ -57,7 +57,7 @@ export class AuthenticationController { } // bind phone - await this.userService.updateUser(new ObjectId(req.user.id), { phone }) + await this.userService.updateUser(new ObjectId(req.user._id), { phone }) } /** @@ -89,6 +89,6 @@ export class AuthenticationController { } // bind username - await this.userService.updateUser(new ObjectId(req.user.id), { username }) + await this.userService.updateUser(new ObjectId(req.user._id), { username }) } } diff --git a/server/src/auth/jwt.strategy.ts b/server/src/auth/jwt.strategy.ts index 88ca43572a..e0265e79bc 100644 --- a/server/src/auth/jwt.strategy.ts +++ b/server/src/auth/jwt.strategy.ts @@ -3,6 +3,7 @@ import { PassportStrategy } from '@nestjs/passport' import { ExtractJwt, Strategy } from 'passport-jwt' import { ServerConfig } from '../constants' import { UserService } from '../user/user.service' +import { ObjectId } from 'mongodb' @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -20,7 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { * @returns */ async validate(payload: any) { - const id = payload.sub + const id = new ObjectId(payload.sub as string) const user = await this.userService.findOneById(id) return user } diff --git a/server/src/function/dto/create-function.dto.ts b/server/src/function/dto/create-function.dto.ts index 0a86d79314..5c51b850bd 100644 --- a/server/src/function/dto/create-function.dto.ts +++ b/server/src/function/dto/create-function.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' -import { HttpMethod } from '@prisma/client' import { IsArray, IsIn, @@ -9,6 +8,7 @@ import { MaxLength, } from 'class-validator' import { HTTP_METHODS } from '../../constants' +import { HttpMethod } from '../entities/cloud-function' export class CreateFunctionDto { @ApiProperty({ diff --git a/server/src/function/dto/update-function.dto.ts b/server/src/function/dto/update-function.dto.ts index f68798a463..9d2c77925a 100644 --- a/server/src/function/dto/update-function.dto.ts +++ b/server/src/function/dto/update-function.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' -import { HttpMethod } from '@prisma/client' import { IsArray, IsIn, @@ -9,6 +8,7 @@ import { MaxLength, } from 'class-validator' import { HTTP_METHODS } from '../../constants' +import { HttpMethod } from '../entities/cloud-function' export class UpdateFunctionDto { @ApiPropertyOptional() diff --git a/server/src/function/entities/cloud-function.ts b/server/src/function/entities/cloud-function.ts new file mode 100644 index 0000000000..ee00976c58 --- /dev/null +++ b/server/src/function/entities/cloud-function.ts @@ -0,0 +1,33 @@ +import { ObjectId } from 'mongodb' + +export type CloudFunctionSource = { + code: string + compiled: string + uri?: string + version: number + hash?: string + lang?: string +} + +export enum HttpMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', + PATCH = 'PATCH', + HEAD = 'HEAD', +} + +export class CloudFunction { + _id?: ObjectId + appid: string + name: string + source: CloudFunctionSource + desc: string + tags: string[] + methods: HttpMethod[] + params?: any + createdAt: Date + updatedAt: Date + createdBy: ObjectId +} diff --git a/server/src/function/function.controller.ts b/server/src/function/function.controller.ts index a25a821f65..6846d13329 100644 --- a/server/src/function/function.controller.ts +++ b/server/src/function/function.controller.ts @@ -69,7 +69,7 @@ export class FunctionController { return ResponseUtil.error(`function count limit is ${MAX_FUNCTION_COUNT}`) } - const res = await this.functionsService.create(appid, req.user.id, dto) + const res = await this.functionsService.create(appid, req.user._id, dto) if (!res) { return ResponseUtil.error('create function error') } @@ -127,7 +127,7 @@ export class FunctionController { throw new HttpException('function not found', HttpStatus.NOT_FOUND) } - const res = await this.functionsService.update(func, dto) + const res = await this.functionsService.updateOne(func, dto) if (!res) { return ResponseUtil.error('update function error') } @@ -150,7 +150,7 @@ export class FunctionController { throw new HttpException('function not found', HttpStatus.NOT_FOUND) } - const res = await this.functionsService.remove(func) + const res = await this.functionsService.removeOne(func) if (!res) { return ResponseUtil.error('delete function error') } diff --git a/server/src/function/function.service.ts b/server/src/function/function.service.ts index 2ad96fb315..5e5e5ba5d1 100644 --- a/server/src/function/function.service.ts +++ b/server/src/function/function.service.ts @@ -1,12 +1,10 @@ import { Injectable, Logger } from '@nestjs/common' -import { CloudFunction, Prisma } from '@prisma/client' import { compileTs2js } from '../utils/lang' import { APPLICATION_SECRET_KEY, CN_FUNCTION_LOGS, CN_PUBLISHED_FUNCTIONS, } from '../constants' -import { PrismaService } from '../prisma/prisma.service' import { CreateFunctionDto } from './dto/create-function.dto' import { UpdateFunctionDto } from './dto/update-function.dto' import * as assert from 'node:assert' @@ -14,17 +12,22 @@ import { JwtService } from '@nestjs/jwt' import { CompileFunctionDto } from './dto/compile-function.dto' import { DatabaseService } from 'src/database/database.service' import { GetApplicationNamespaceByAppId } from 'src/utils/getter' +import { SystemDatabase } from 'src/database/system-database' +import { ObjectId } from 'mongodb' +import { CloudFunction } from './entities/cloud-function' +import { ApplicationConfiguration } from 'src/application/entities/application-configuration' @Injectable() export class FunctionService { private readonly logger = new Logger(FunctionService.name) + private readonly db = SystemDatabase.db + constructor( private readonly databaseService: DatabaseService, - private readonly prisma: PrismaService, private readonly jwtService: JwtService, ) {} - async create(appid: string, userid: string, dto: CreateFunctionDto) { - const data: Prisma.CloudFunctionCreateInput = { + async create(appid: string, userid: ObjectId, dto: CreateFunctionDto) { + await this.db.collection('CloudFunction').insertOne({ appid, name: dto.name, source: { @@ -36,66 +39,79 @@ export class FunctionService { createdBy: userid, methods: dto.methods, tags: dto.tags || [], - } - const res = await this.prisma.cloudFunction.create({ data }) - await this.publish(res) - return res + createdAt: new Date(), + updatedAt: new Date(), + }) + + const fn = await this.findOne(appid, dto.name) + await this.publish(fn) + return fn } async findAll(appid: string) { - const res = await this.prisma.cloudFunction.findMany({ - where: { appid }, - }) + const res = await this.db + .collection('CloudFunction') + .find({ appid }) + .toArray() return res } async count(appid: string) { - const res = await this.prisma.cloudFunction.count({ where: { appid } }) + const res = await this.db + .collection('CloudFunction') + .countDocuments({ appid }) + return res } async findOne(appid: string, name: string) { - const res = await this.prisma.cloudFunction.findUnique({ - where: { appid_name: { appid, name } }, - }) + const res = await this.db + .collection('CloudFunction') + .findOne({ appid, name }) + return res } - async update(func: CloudFunction, dto: UpdateFunctionDto) { - const data: Prisma.CloudFunctionUpdateInput = { - source: { - code: dto.code, - compiled: compileTs2js(dto.code), - version: func.source.version + 1, + async updateOne(func: CloudFunction, dto: UpdateFunctionDto) { + await this.db.collection('CloudFunction').updateOne( + { appid: func.appid, name: func.name }, + { + $set: { + source: { + code: dto.code, + compiled: compileTs2js(dto.code), + version: func.source.version + 1, + }, + desc: dto.description, + methods: dto.methods, + tags: dto.tags || [], + params: dto.params, + updatedAt: new Date(), + }, }, - desc: dto.description, - methods: dto.methods, - tags: dto.tags || [], - params: dto.params, - } - const res = await this.prisma.cloudFunction.update({ - where: { appid_name: { appid: func.appid, name: func.name } }, - data, - }) + ) - await this.publish(res) - return res + const fn = await this.findOne(func.appid, func.name) + await this.publish(fn) + return fn } - async remove(func: CloudFunction) { + async removeOne(func: CloudFunction) { const { appid, name } = func - const res = await this.prisma.cloudFunction.delete({ - where: { appid_name: { appid, name } }, - }) + const res = await this.db + .collection('CloudFunction') + .findOneAndDelete({ appid, name }) + await this.unpublish(appid, name) - return res + return res.value } async removeAll(appid: string) { - const res = await this.prisma.cloudFunction.deleteMany({ - where: { appid }, - }) + const res = await this.db + .collection('CloudFunction') + .deleteMany({ appid }) + return res } @@ -109,6 +125,7 @@ export class FunctionService { await coll.insertOne(func, { session }) }) } finally { + await session.endSession() await client.close() } } @@ -145,12 +162,14 @@ export class FunctionService { assert(appid, 'appid is required') assert(type, 'type is required') - const conf = await this.prisma.applicationConfiguration.findUnique({ - where: { appid }, - }) + const conf = await this.db + .collection('ApplicationConfiguration') + .findOne({ appid }) + + assert(conf, 'ApplicationConfiguration not found') // get secret from envs - const secret = conf?.environments.find( + const secret = conf?.environments?.find( (env) => env.name === APPLICATION_SECRET_KEY, ) assert(secret?.value, 'application secret not found') @@ -209,10 +228,7 @@ export class FunctionService { .toArray() const total = await coll.countDocuments(query) - return { - data, - total, - } + return { data, total } } finally { await client.close() } diff --git a/server/src/region/bundle.service.ts b/server/src/region/bundle.service.ts index 0616a880a3..462a088dba 100644 --- a/server/src/region/bundle.service.ts +++ b/server/src/region/bundle.service.ts @@ -1,45 +1,25 @@ import { Injectable, Logger } from '@nestjs/common' -import { Bundle } from '@prisma/client' -import { PrismaService } from 'src/prisma/prisma.service' +import { ApplicationBundle } from 'src/application/entities/application-bundle' +import { SystemDatabase } from 'src/database/system-database' @Injectable() export class BundleService { private readonly logger = new Logger(BundleService.name) - - constructor(private readonly prisma: PrismaService) {} - - async findOne(id: string, regionId: string) { - return this.prisma.bundle.findFirst({ - where: { id, regionId }, - }) - } - - async findOneByName(name: string, regionName: string) { - return this.prisma.bundle.findFirst({ - where: { - name: name, - region: { - name: regionName, - }, - }, - }) - } + private readonly db = SystemDatabase.db async findApplicationBundle(appid: string) { - return this.prisma.applicationBundle.findUnique({ - where: { appid }, - }) + const bundle = await this.db + .collection('ApplicationBundle') + .findOne({ appid }) + + return bundle } async deleteApplicationBundle(appid: string) { - return this.prisma.applicationBundle.delete({ - where: { appid }, - }) - } + const res = await this.db + .collection('ApplicationBundle') + .findOneAndDelete({ appid }) - getSubscriptionOption(bundle: Bundle, duration: number) { - const options = bundle.subscriptionOptions - const found = options.find((option) => option.duration === duration) - return found ? found : null + return res.value } } diff --git a/server/src/region/region.service.ts b/server/src/region/region.service.ts index fc1b6ed578..50a035e062 100644 --- a/server/src/region/region.service.ts +++ b/server/src/region/region.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common' -import { PrismaService } from '../prisma/prisma.service' import { SystemDatabase } from 'src/database/system-database' import { Region } from './entities/region' import { Application } from 'src/application/entities/application' @@ -9,7 +8,6 @@ import { ObjectId } from 'mongodb' @Injectable() export class RegionService { private readonly db = SystemDatabase.db - constructor(private readonly prisma: PrismaService) {} async findByAppId(appid: string) { const app = await this.db diff --git a/server/src/trigger/cron-job.service.ts b/server/src/trigger/cron-job.service.ts index 2d56082dca..8bf59a045c 100644 --- a/server/src/trigger/cron-job.service.ts +++ b/server/src/trigger/cron-job.service.ts @@ -1,5 +1,4 @@ import { Injectable, Logger } from '@nestjs/common' -import { CronTrigger, TriggerPhase } from '@prisma/client' import { ClusterService } from 'src/region/cluster/cluster.service' import * as assert from 'node:assert' import { RegionService } from 'src/region/region.service' @@ -8,6 +7,7 @@ import { FunctionService } from 'src/function/function.service' import { FOREVER_IN_SECONDS, X_LAF_TRIGGER_TOKEN_KEY } from 'src/constants' import { TriggerService } from './trigger.service' import * as k8s from '@kubernetes/client-node' +import { CronTrigger, TriggerPhase } from './entities/cron-trigger' @Injectable() export class CronJobService { @@ -31,14 +31,14 @@ export class CronJobService { // create cronjob const ns = GetApplicationNamespaceByAppId(appid) const batchApi = this.clusterService.makeBatchV1Api(region) - const name = `cron-${trigger.id}` + const name = `cron-${trigger._id}` const command = await this.getTriggerCommand(trigger) const res = await batchApi.createNamespacedCronJob(ns, { metadata: { name, labels: { appid, - id: trigger.id, + id: trigger._id.toString(), }, }, spec: { @@ -81,7 +81,7 @@ export class CronJobService { const region = await this.regionService.findByAppId(appid) try { const batchApi = this.clusterService.makeBatchV1Api(region) - const name = `cron-${trigger.id}` + const name = `cron-${trigger._id}` const res = await batchApi.readNamespacedCronJob(name, ns) return res.body } catch (err) { @@ -105,7 +105,7 @@ export class CronJobService { for (const trigger of triggers) { if (trigger.phase !== TriggerPhase.Created) continue await this.suspend(trigger) - this.logger.log(`suspend cronjob ${trigger.id} success of ${appid}`) + this.logger.log(`suspend cronjob ${trigger._id} success of ${appid}`) } } @@ -114,7 +114,7 @@ export class CronJobService { for (const trigger of triggers) { if (trigger.phase !== TriggerPhase.Created) continue await this.resume(trigger) - this.logger.log(`resume cronjob ${trigger.id} success of ${appid}`) + this.logger.log(`resume cronjob ${trigger._id} success of ${appid}`) } } @@ -123,7 +123,7 @@ export class CronJobService { const ns = GetApplicationNamespaceByAppId(appid) const region = await this.regionService.findByAppId(appid) const batchApi = this.clusterService.makeBatchV1Api(region) - const name = `cron-${trigger.id}` + const name = `cron-${trigger._id}` const res = await batchApi.deleteNamespacedCronJob(name, ns) return res.body } @@ -150,7 +150,7 @@ export class CronJobService { const ns = GetApplicationNamespaceByAppId(appid) const region = await this.regionService.findByAppId(appid) const batchApi = this.clusterService.makeBatchV1Api(region) - const name = `cron-${trigger.id}` + const name = `cron-${trigger._id}` const body = [{ op: 'replace', path: '/spec/suspend', value: suspend }] try { const res = await batchApi.patchNamespacedCronJob( diff --git a/server/src/trigger/entities/cron-trigger.ts b/server/src/trigger/entities/cron-trigger.ts new file mode 100644 index 0000000000..da8de20b7f --- /dev/null +++ b/server/src/trigger/entities/cron-trigger.ts @@ -0,0 +1,27 @@ +import { ObjectId } from 'mongodb' + +export enum TriggerState { + Active = 'Active', + Inactive = 'Inactive', + Deleted = 'Deleted', +} + +export enum TriggerPhase { + Creating = 'Creating', + Created = 'Created', + Deleting = 'Deleting', + Deleted = 'Deleted', +} + +export class CronTrigger { + _id?: ObjectId + appid: string + desc: string + cron: string + target: string + state: TriggerState + phase: TriggerPhase + lockedAt: Date + createdAt: Date + updatedAt: Date +} diff --git a/server/src/trigger/trigger-task.service.ts b/server/src/trigger/trigger-task.service.ts index 5222b896f4..a2316ac594 100644 --- a/server/src/trigger/trigger-task.service.ts +++ b/server/src/trigger/trigger-task.service.ts @@ -3,7 +3,11 @@ import { Cron, CronExpression } from '@nestjs/schedule' import { TASK_LOCK_INIT_TIME } from 'src/constants' import { SystemDatabase } from 'src/database/system-database' import { CronJobService } from './cron-job.service' -import { CronTrigger, TriggerPhase, TriggerState } from '@prisma/client' +import { + CronTrigger, + TriggerPhase, + TriggerState, +} from './entities/cron-trigger' @Injectable() export class TriggerTaskService { @@ -60,11 +64,7 @@ export class TriggerTaskService { ) if (!res.value) return - // fix id for prisma type - const doc = { - ...res.value, - id: res.value._id.toString(), - } + const doc = res.value // create cron job if not exists const job = await this.cronService.findOne(doc) @@ -103,11 +103,7 @@ export class TriggerTaskService { ) if (!res.value) return - // fix id for prisma type - const doc = { - ...res.value, - id: res.value._id.toString(), - } + const doc = res.value // delete cron job if exists const job = await this.cronService.findOne(doc) diff --git a/server/src/trigger/trigger.controller.ts b/server/src/trigger/trigger.controller.ts index 3c3781dc16..f6a8813806 100644 --- a/server/src/trigger/trigger.controller.ts +++ b/server/src/trigger/trigger.controller.ts @@ -20,6 +20,7 @@ import { import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' import { BundleService } from 'src/region/bundle.service' +import { ObjectId } from 'mongodb' @ApiTags('Trigger') @Controller('apps/:appid/triggers') @@ -87,12 +88,12 @@ export class TriggerController { @Delete(':id') async remove(@Param('id') id: string, @Param('appid') appid: string) { // check if trigger exists - const trigger = await this.triggerService.findOne(appid, id) + const trigger = await this.triggerService.findOne(appid, new ObjectId(id)) if (!trigger) { return ResponseUtil.error('Trigger not found') } - const res = await this.triggerService.remove(appid, id) + const res = await this.triggerService.removeOne(appid, new ObjectId(id)) return ResponseUtil.ok(res) } } diff --git a/server/src/trigger/trigger.service.ts b/server/src/trigger/trigger.service.ts index 6060ed394d..b3f207139a 100644 --- a/server/src/trigger/trigger.service.ts +++ b/server/src/trigger/trigger.service.ts @@ -1,80 +1,75 @@ import { Injectable, Logger } from '@nestjs/common' -import { TriggerPhase, TriggerState } from '@prisma/client' import { TASK_LOCK_INIT_TIME } from 'src/constants' -import { PrismaService } from 'src/prisma/prisma.service' import { CreateTriggerDto } from './dto/create-trigger.dto' import CronValidate from 'cron-validate' +import { SystemDatabase } from 'src/database/system-database' +import { + CronTrigger, + TriggerPhase, + TriggerState, +} from './entities/cron-trigger' +import { ObjectId } from 'mongodb' @Injectable() export class TriggerService { private readonly logger = new Logger(TriggerService.name) - - constructor(private readonly prisma: PrismaService) {} + private readonly db = SystemDatabase.db async create(appid: string, dto: CreateTriggerDto) { const { desc, cron, target } = dto - const trigger = await this.prisma.cronTrigger.create({ - data: { - desc, - cron, - state: TriggerState.Active, - phase: TriggerPhase.Creating, - lockedAt: TASK_LOCK_INIT_TIME, - cloudFunction: { - connect: { - appid_name: { - appid, - name: target, - }, - }, - }, - }, + const res = await this.db.collection('CronTrigger').insertOne({ + appid, + desc, + cron, + target, + state: TriggerState.Active, + phase: TriggerPhase.Creating, + lockedAt: TASK_LOCK_INIT_TIME, + createdAt: new Date(), + updatedAt: new Date(), }) - return trigger + return this.findOne(appid, res.insertedId) } async count(appid: string) { - const res = await this.prisma.cronTrigger.count({ - where: { appid }, - }) + const count = await this.db + .collection('CronTrigger') + .countDocuments({ appid }) - return res + return count } - async findOne(appid: string, id: string) { - const res = await this.prisma.cronTrigger.findFirst({ - where: { id, appid }, - }) + async findOne(appid: string, id: ObjectId) { + const doc = await this.db + .collection('CronTrigger') + .findOne({ appid, _id: id }) - return res + return doc } async findAll(appid: string) { - const res = await this.prisma.cronTrigger.findMany({ - where: { appid, state: TriggerState.Active }, - }) + const docs = await this.db + .collection('CronTrigger') + .find({ appid, state: TriggerState.Active }) + .toArray() - return res + return docs } - async remove(appid: string, id: string) { - const res = await this.prisma.cronTrigger.updateMany({ - where: { id, appid }, - data: { - state: TriggerState.Deleted, - }, - }) - return res + async removeOne(appid: string, id: ObjectId) { + await this.db + .collection('CronTrigger') + .updateOne({ appid, _id: id }, { $set: { state: TriggerState.Deleted } }) + + return this.findOne(appid, id) } async removeAll(appid: string) { - const res = await this.prisma.cronTrigger.updateMany({ - where: { appid }, - data: { - state: TriggerState.Deleted, - }, - }) + const res = await this.db + .collection('CronTrigger') + .updateMany({ appid }, { $set: { state: TriggerState.Deleted } }) + return res } diff --git a/server/src/user/pat.controller.ts b/server/src/user/pat.controller.ts index 35cfb85e65..78e3ca8f06 100644 --- a/server/src/user/pat.controller.ts +++ b/server/src/user/pat.controller.ts @@ -41,7 +41,7 @@ export class PatController { @UseGuards(JwtAuthGuard) @Post() async create(@Req() req: IRequest, @Body() dto: CreatePATDto) { - const uid = new ObjectId(req.user.id) + const uid = req.user._id // check max count, 10 const count = await this.patService.count(uid) if (count >= 10) { @@ -62,7 +62,7 @@ export class PatController { @UseGuards(JwtAuthGuard) @Get() async findAll(@Req() req: IRequest) { - const uid = new ObjectId(req.user.id) + const uid = req.user._id const pats = await this.patService.findAll(uid) return ResponseUtil.ok(pats) } @@ -78,11 +78,8 @@ export class PatController { @UseGuards(JwtAuthGuard) @Delete(':id') async remove(@Req() req: IRequest, @Param('id') id: string) { - const uid = req.user.id - const pat = await this.patService.removeOne( - new ObjectId(uid), - new ObjectId(id), - ) + const uid = req.user._id + const pat = await this.patService.removeOne(uid, new ObjectId(id)) return ResponseUtil.ok(pat) } } diff --git a/server/src/utils/interface.ts b/server/src/utils/interface.ts index 14ec2a4e0b..71459b6a96 100644 --- a/server/src/utils/interface.ts +++ b/server/src/utils/interface.ts @@ -1,6 +1,6 @@ -import { User } from '@prisma/client' import { Request, Response } from 'express' import { Application } from 'src/application/entities/application' +import { User } from 'src/user/entities/user' export interface IRequest extends Request { user?: User From 5076425db31a7cc94cc08755f16e40c47b5706da Mon Sep 17 00:00:00 2001 From: maslow Date: Thu, 18 May 2023 01:59:54 +0800 Subject: [PATCH 10/48] remove prisma totally --- server/Dockerfile | 7 +- server/README.md | 4 - server/package-lock.json | 79 -- server/package.json | 8 +- server/prisma/schema.prisma | 678 ------------------ server/src/app.module.ts | 2 - server/src/application/entities/runtime.ts | 4 +- server/src/initializer/initializer.service.ts | 254 +++---- server/src/instance/instance.service.ts | 2 +- server/src/main.ts | 1 - server/src/prisma/prisma.module.ts | 9 - server/src/prisma/prisma.service.ts | 22 - server/src/region/entities/region.ts | 4 +- 13 files changed, 96 insertions(+), 978 deletions(-) delete mode 100644 server/prisma/schema.prisma delete mode 100644 server/src/prisma/prisma.module.ts delete mode 100644 server/src/prisma/prisma.service.ts diff --git a/server/Dockerfile b/server/Dockerfile index b14442e4b7..8d384ec2e1 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -15,11 +15,6 @@ EXPOSE 3000 COPY . /app # All commands in one line will reduce the size of the image -RUN npm install @nestjs/cli@9.0.0 prisma@4.9.0 -g && npm install --omit=dev && npm run build && npm remove @nestjs/cli prisma -g && npm cache clean --force && rm -rf /app/src/* - -# RUN npm install --omit=dev -# RUN npm run build -# RUN npm remove @nestjs/cli prisma -g -# RUN npm cache clean --force +RUN npm install --omit=dev && npm run build && npm cache clean --force && rm -rf /app/src/* CMD [ "node", "dist/main" ] \ No newline at end of file diff --git a/server/README.md b/server/README.md index 27bf478362..e44bae2c6a 100644 --- a/server/README.md +++ b/server/README.md @@ -20,7 +20,6 @@ - [Kubernetes](https://kubernetes.io) basic use - [Telepresence](https://www.telepresence.io) for local development - [MongoDb](https://docs.mongodb.com) basic use -- [Prisma](https://www.prisma.io) - [MinIO](https://min.io) object storage - [APISIX](https://apisix.apache.org) gateway @@ -45,9 +44,6 @@ telepresence list -n laf-system telepresence intercept laf-server -n laf-system -p 3000:3000 -e $(pwd)/.env npm install -npx prisma generate -npx prisma db push - npm run watch ``` diff --git a/server/package-lock.json b/server/package-lock.json index 01043a82fc..23823c447b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -26,7 +26,6 @@ "@nestjs/schedule": "^2.1.0", "@nestjs/swagger": "^6.1.3", "@nestjs/throttler": "^3.1.0", - "@prisma/client": "^4.9.0", "class-validator": "^0.14.0", "compression": "^1.7.4", "cron-validate": "^1.4.5", @@ -69,7 +68,6 @@ "eslint-plugin-prettier": "^4.0.0", "jest": "28.1.3", "prettier": "^2.3.2", - "prisma": "^4.9.0", "source-map-support": "^0.5.20", "supertest": "^6.1.3", "ts-jest": "28.0.8", @@ -7089,38 +7087,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@prisma/client": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.9.0.tgz", - "integrity": "sha512-bz6QARw54sWcbyR1lLnF2QHvRW5R/Jxnbbmwh3u+969vUKXtBkXgSgjDA85nji31ZBlf7+FrHDy5x+5ydGyQDg==", - "hasInstallScript": true, - "dependencies": { - "@prisma/engines-version": "4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5" - }, - "engines": { - "node": ">=14.17" - }, - "peerDependencies": { - "prisma": "*" - }, - "peerDependenciesMeta": { - "prisma": { - "optional": true - } - } - }, - "node_modules/@prisma/engines": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.9.0.tgz", - "integrity": "sha512-t1pt0Gsp+HcgPJrHFc+d/ZSAaKKWar2G/iakrE07yeKPNavDP3iVKPpfXP22OTCHZUWf7OelwKJxQgKAm5hkgw==", - "devOptional": true, - "hasInstallScript": true - }, - "node_modules/@prisma/engines-version": { - "version": "4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5.tgz", - "integrity": "sha512-M16aibbxi/FhW7z1sJCX8u+0DriyQYY5AyeTH7plQm9MLnURoiyn3CZBqAyIoQ+Z1pS77usCIibYJWSgleBMBA==" - }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -14429,23 +14395,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/prisma": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.9.0.tgz", - "integrity": "sha512-bS96oZ5oDFXYgoF2l7PJ3Mp1wWWfLOo8B/jAfbA2Pn0Wm5Z/owBHzaMQKS3i1CzVBDWWPVnOohmbJmjvkcHS5w==", - "devOptional": true, - "hasInstallScript": true, - "dependencies": { - "@prisma/engines": "4.9.0" - }, - "bin": { - "prisma": "build/index.js", - "prisma2": "build/index.js" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/proc-log": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", @@ -22611,25 +22560,6 @@ } } }, - "@prisma/client": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.9.0.tgz", - "integrity": "sha512-bz6QARw54sWcbyR1lLnF2QHvRW5R/Jxnbbmwh3u+969vUKXtBkXgSgjDA85nji31ZBlf7+FrHDy5x+5ydGyQDg==", - "requires": { - "@prisma/engines-version": "4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5" - } - }, - "@prisma/engines": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.9.0.tgz", - "integrity": "sha512-t1pt0Gsp+HcgPJrHFc+d/ZSAaKKWar2G/iakrE07yeKPNavDP3iVKPpfXP22OTCHZUWf7OelwKJxQgKAm5hkgw==", - "devOptional": true - }, - "@prisma/engines-version": { - "version": "4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5.tgz", - "integrity": "sha512-M16aibbxi/FhW7z1sJCX8u+0DriyQYY5AyeTH7plQm9MLnURoiyn3CZBqAyIoQ+Z1pS77usCIibYJWSgleBMBA==" - }, "@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -28274,15 +28204,6 @@ } } }, - "prisma": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.9.0.tgz", - "integrity": "sha512-bS96oZ5oDFXYgoF2l7PJ3Mp1wWWfLOo8B/jAfbA2Pn0Wm5Z/owBHzaMQKS3i1CzVBDWWPVnOohmbJmjvkcHS5w==", - "devOptional": true, - "requires": { - "@prisma/engines": "4.9.0" - } - }, "proc-log": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", diff --git a/server/package.json b/server/package.json index 7241787ad8..954cac0bcd 100644 --- a/server/package.json +++ b/server/package.json @@ -8,11 +8,9 @@ "scripts": { "intercept": "telepresence intercept laf-server -n laf-system -p 3000:3000 -e $(pwd)/.env", "leave": "telepresence leave laf-server-laf-system", - "prebuild": "npm run generate && rimraf dist", - "generate": "prisma generate", + "prebuild": "rimraf dist", "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "push-db": "prisma db push --skip-generate", "start": "nest start", "watch": "nest start --watch", "start:dev": "nest start --watch", @@ -43,7 +41,6 @@ "@nestjs/schedule": "^2.1.0", "@nestjs/swagger": "^6.1.3", "@nestjs/throttler": "^3.1.0", - "@prisma/client": "^4.9.0", "class-validator": "^0.14.0", "compression": "^1.7.4", "cron-validate": "^1.4.5", @@ -86,7 +83,6 @@ "eslint-plugin-prettier": "^4.0.0", "jest": "28.1.3", "prettier": "^2.3.2", - "prisma": "^4.9.0", "source-map-support": "^0.5.20", "supertest": "^6.1.3", "ts-jest": "28.0.8", @@ -111,4 +107,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} +} \ No newline at end of file diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma deleted file mode 100644 index 53e57b43dd..0000000000 --- a/server/prisma/schema.prisma +++ /dev/null @@ -1,678 +0,0 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -// These models cover multiple aspects, such as subscriptions, accounts, applications, -// storage, databases, cloud functions, gateways, and SMS verification codes. -// Here's a brief description: -// -// 1. Subscription models (Subscription and SubscriptionRenewal): Represent the state -// and plans of subscriptions and their renewals. -// 2. Account models (Account and AccountChargeOrder): Track account balances and -// recharge records. -// 3. Application models (Application and ApplicationConfiguration): Represent -// application configurations and states. -// 4. Storage models (StorageUser and StorageBucket): Represent the state and policies -// of storage users and buckets. -// 5. Database models (Database, DatabasePolicy, and DatabasePolicyRule): Represent the -// state, policies, and rules of databases. -// 6. Cloud Function models (CloudFunction and CronTrigger): Represent the configuration -// and state of cloud functions and scheduled triggers. -// 7. Gateway models (RuntimeDomain, BucketDomain, and WebsiteHosting): Represent the -// state and configuration of runtime domains, bucket domains, and website hosting. -// 8. Authentication provider models (AuthProvider): Represent the configuration and state -// of authentication providers. -// 9. SMS verification code models (SmsVerifyCode): Represent the type, state, and -// related information of SMS verification codes. -// -// These models together form a complete cloud service system, covering subscription -// management, account management, application deployment, storage management, database -// management, cloud function deployment and execution, gateway configuration, and SMS -// verification, among other functionalities. - -generator client { - provider = "prisma-client-js" - binaryTargets = ["native"] -} - -datasource db { - provider = "mongodb" - url = env("DATABASE_URL") -} - -enum NoteLevel { - Info - Warning - Danger - Error -} - -type Note { - title String? - content String? - link String? - lang String? - level NoteLevel @default(Info) -} - -enum BaseState { - Active - Inactive -} - -// user schemas - -model User { - id String @id @default(auto()) @map("_id") @db.ObjectId - username String @unique - email String? - phone String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - profile UserProfile? - personalAccessTokens PersonalAccessToken[] -} - -model UserPassword { - id String @id @default(auto()) @map("_id") @db.ObjectId - uid String @db.ObjectId - password String - state String @default("Active") // Active, Inactive - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -model UserProfile { - id String @id @default(auto()) @map("_id") @db.ObjectId - uid String @unique @db.ObjectId - openid String? - from String? - openData Json? - avatar String? - name String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [uid], references: [id]) -} - -model PersonalAccessToken { - id String @id @default(auto()) @map("_id") @db.ObjectId - uid String @db.ObjectId - name String - token String @unique - expiredAt DateTime - createdAt DateTime @default(now()) - - user User @relation(fields: [uid], references: [id]) -} - -// region schemas - -type RegionClusterConf { - driver String // kubernetes - kubeconfig String? - npmInstallFlags String @default("") -} - -type RegionDatabaseConf { - driver String // mongodb - connectionUri String - controlConnectionUri String -} - -type RegionGatewayConf { - driver String // apisix - runtimeDomain String // runtime domain (cloud function) - websiteDomain String // website domain - port Int @default(80) - apiUrl String - apiKey String -} - -type RegionStorageConf { - driver String // minio - domain String - externalEndpoint String - internalEndpoint String - accessKey String - secretKey String - controlEndpoint String -} - -model Region { - id String @id @default(auto()) @map("_id") @db.ObjectId - name String @unique - displayName String - clusterConf RegionClusterConf - databaseConf RegionDatabaseConf - gatewayConf RegionGatewayConf - storageConf RegionStorageConf - tls Boolean @default(false) - state String @default("Active") // Active, Inactive - - notes Note[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - applications Application[] - bundles Bundle[] -} - -enum RegionBundleType { - CPU - Memory - Database - Storage - Network -} - -type RegionBundleOption { - value Int -} - -model RegionBundle { - id String @id @default(auto()) @map("_id") @db.ObjectId - regionId String @db.ObjectId - type RegionBundleType @unique - price Int @default(0) - options RegionBundleOption[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -type BundleResource { - limitCPU Int // 1000 = 1 core - limitMemory Int // in MB - requestCPU Int // 1000 = 1 core - requestMemory Int // in MB - - databaseCapacity Int // in MB - storageCapacity Int // in MB - networkTrafficOutbound Int? // in MB - - limitCountOfCloudFunction Int // limit count of cloud function per application - limitCountOfBucket Int // limit count of bucket per application - limitCountOfDatabasePolicy Int // limit count of database policy per application - limitCountOfTrigger Int // limit count of trigger per application - limitCountOfWebsiteHosting Int // limit count of website hosting per application - reservedTimeAfterExpired Int // in seconds - - limitDatabaseTPS Int // limit count of database TPS per application - limitStorageTPS Int // limit count of storage TPS per application -} - -type BundleSubscriptionOption { - name String - displayName String - duration Int // in seconds - price Int - specialPrice Int -} - -model Bundle { - id String @id @default(auto()) @map("_id") @db.ObjectId - name String - displayName String - regionId String @db.ObjectId - priority Int @default(0) - state BaseState @default(Active) - limitCountPerUser Int // limit count of application per user could create - subscriptionOptions BundleSubscriptionOption[] - maxRenewalTime Int // in seconds - - resource BundleResource - notes Note[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - region Region @relation(fields: [regionId], references: [id]) - - @@unique([regionId, name]) -} - -model ApplicationBundle { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String @unique - // @decrecapted - bundleId String? @db.ObjectId - // @decrecapted - name String? - // @decrecapted - displayName String? - resource BundleResource - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - application Application @relation(fields: [appid], references: [appid]) -} - -type RuntimeImageGroup { - main String - init String? - sidecar String? -} - -model Runtime { - id String @id @default(auto()) @map("_id") @db.ObjectId - name String @unique - type String - image RuntimeImageGroup - state String @default("Active") // Active, Inactive - version String - latest Boolean - Application Application[] -} - -// accounts schemas -model Account { - id String @id @default(auto()) @map("_id") @db.ObjectId - balance Int @default(0) - state BaseState @default(Active) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String @unique @db.ObjectId -} - -model AccountTransaction { - id String @id @default(auto()) @map("_id") @db.ObjectId - accountId String @db.ObjectId - amount Int - balance Int - message String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -enum AccountChargePhase { - Pending - Paid - Failed -} - -enum Currency { - CNY - USD -} - -model AccountChargeOrder { - id String @id @default(auto()) @map("_id") @db.ObjectId - accountId String @db.ObjectId - amount Int - currency Currency - phase AccountChargePhase @default(Pending) - channel PaymentChannelType - result Json? - message String? - createdAt DateTime @default(now()) - lockedAt DateTime - updatedAt DateTime @updatedAt - createdBy String @db.ObjectId -} - -enum PaymentChannelType { - Manual - Alipay - WeChat - Stripe - Paypal - Google -} - -model PaymentChannel { - id String @id @default(auto()) @map("_id") @db.ObjectId - type PaymentChannelType - name String - spec Json - state BaseState @default(Active) - notes Note[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -// Application schemas - -// desired state of application -enum ApplicationState { - Running - Stopped - Restarting - Deleted -} - -// actual state of application -enum ApplicationPhase { - Creating // app resources creating - Created // app resources created - Starting // instance starting - Started // instance started (Running, Ready) - Stopping // instance stopping - Stopped // instance stopped - Deleting // app resources deleting - Deleted // app resources deleted -} - -model Application { - id String @id @default(auto()) @map("_id") @db.ObjectId - name String - appid String @unique - regionId String @db.ObjectId - runtimeId String @db.ObjectId - tags String[] - state ApplicationState @default(Running) - phase ApplicationPhase @default(Creating) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lockedAt DateTime - createdBy String @db.ObjectId - - region Region @relation(fields: [regionId], references: [id]) - runtime Runtime @relation(fields: [runtimeId], references: [id]) - - configuration ApplicationConfiguration? - storageUser StorageUser? - database Database? - domain RuntimeDomain? - bundle ApplicationBundle? -} - -type EnvironmentVariable { - name String - value String -} - -model ApplicationConfiguration { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String @unique - environments EnvironmentVariable[] - dependencies String[] @default([]) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - application Application @relation(fields: [appid], references: [appid]) -} - -// storage schemas - -enum StorageState { - Active - Inactive - Deleted -} - -enum StoragePhase { - Creating - Created - Deleting - Deleted -} - -model StorageUser { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String @unique - accessKey String - secretKey String - state StorageState @default(Active) - phase StoragePhase @default(Creating) - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - application Application @relation(fields: [appid], references: [appid]) -} - -enum BucketPolicy { - readwrite - readonly - private -} - -model StorageBucket { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - name String @unique - shortName String - policy BucketPolicy - state StorageState @default(Active) - phase StoragePhase @default(Creating) - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - domain BucketDomain? - websiteHosting WebsiteHosting? -} - -// database schemas - -enum DatabaseState { - Active - Inactive - Deleted -} - -enum DatabasePhase { - Creating - Created - Deleting - Deleted -} - -model Database { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String @unique - name String - user String - password String - state DatabaseState @default(Active) - phase DatabasePhase @default(Creating) - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - application Application @relation(fields: [appid], references: [appid]) -} - -model DatabasePolicy { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - name String - injector String? - rules DatabasePolicyRule[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([appid, name]) -} - -model DatabasePolicyRule { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - policyName String - collectionName String - value Json - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - policy DatabasePolicy @relation(fields: [appid, policyName], references: [appid, name], onDelete: Cascade) - - @@unique([appid, policyName, collectionName]) -} - -// cloud function schemas - -enum HttpMethod { - GET - POST - PUT - DELETE - PATCH - HEAD -} - -type CloudFunctionSource { - code String - compiled String? - uri String? - version Int @default(0) - hash String? - lang String? -} - -model CloudFunction { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - name String - source CloudFunctionSource - desc String - tags String[] - methods HttpMethod[] - params Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String @db.ObjectId - - cronTriggers CronTrigger[] - - @@unique([appid, name]) -} - -// diresired state of resource -enum TriggerState { - Active - Inactive - Deleted -} - -// actual state of resource -enum TriggerPhase { - Creating - Created - Deleting - Deleted -} - -model CronTrigger { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - desc String - cron String - target String - state TriggerState @default(Active) - phase TriggerPhase @default(Creating) - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - cloudFunction CloudFunction @relation(fields: [appid, target], references: [appid, name]) -} - -// gateway schemas - -// diresired state of resource -enum DomainState { - Active - Inactive - Deleted -} - -// actual state of resource -enum DomainPhase { - Creating - Created - Deleting - Deleted -} - -model RuntimeDomain { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String @unique - domain String @unique - state DomainState @default(Active) - phase DomainPhase @default(Creating) - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - application Application @relation(fields: [appid], references: [appid]) -} - -model BucketDomain { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - bucketName String @unique - domain String @unique - state DomainState @default(Active) - phase DomainPhase @default(Creating) - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - bucket StorageBucket @relation(fields: [bucketName], references: [name]) -} - -model WebsiteHosting { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - bucketName String @unique - domain String @unique // auto-generated domain by default, custom domain if set - isCustom Boolean @default(false) // if true, domain is custom domain - state DomainState @default(Active) - phase DomainPhase @default(Creating) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lockedAt DateTime - - bucket StorageBucket @relation(fields: [bucketName], references: [name]) -} - -enum AuthProviderState { - Enabled - Disabled -} - -model AuthProvider { - id String @id @default(auto()) @map("_id") @db.ObjectId - name String @unique - bind Json - register Boolean - default Boolean - state AuthProviderState - config Json - notes Note[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -// Sms schemas -enum SmsVerifyCodeType { - Signin - Signup - ResetPassword - Bind - Unbind - ChangePhone -} - -model SmsVerifyCode { - id String @id @default(auto()) @map("_id") @db.ObjectId - phone String - code String - ip String - type SmsVerifyCodeType - state Int @default(0) // 0: created, 1: used - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -model Setting { - id String @id @default(auto()) @map("_id") @db.ObjectId - key String @unique - value String - desc String - metadata Json? // extra meta data -} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 1d5f0ca606..759470c787 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -17,7 +17,6 @@ import { DependencyModule } from './dependency/dependency.module' import { TriggerModule } from './trigger/trigger.module' import { RegionModule } from './region/region.module' import { GatewayModule } from './gateway/gateway.module' -import { PrismaModule } from './prisma/prisma.module' import { AccountModule } from './account/account.module' import { SettingModule } from './setting/setting.module' @@ -42,7 +41,6 @@ import { SettingModule } from './setting/setting.module' TriggerModule, RegionModule, GatewayModule, - PrismaModule, AccountModule, SettingModule, ], diff --git a/server/src/application/entities/runtime.ts b/server/src/application/entities/runtime.ts index 170d3266a6..4f2a2c3ddb 100644 --- a/server/src/application/entities/runtime.ts +++ b/server/src/application/entities/runtime.ts @@ -2,8 +2,8 @@ import { ObjectId } from 'mongodb' export type RuntimeImageGroup = { main: string - init: string | null - sidecar: string | null + init: string + sidecar?: string } export class Runtime { diff --git a/server/src/initializer/initializer.service.ts b/server/src/initializer/initializer.service.ts index a2547da013..d9e2fee973 100644 --- a/server/src/initializer/initializer.service.ts +++ b/server/src/initializer/initializer.service.ts @@ -1,216 +1,138 @@ import { Injectable, Logger } from '@nestjs/common' -import { AuthProviderState } from '@prisma/client' -import { RegionService } from 'src/region/region.service' -import { MinioService } from 'src/storage/minio/minio.service' -import { CPU_UNIT, ServerConfig } from '../constants' -import { PrismaService } from '../prisma/prisma.service' +import { ServerConfig } from '../constants' +import { SystemDatabase } from 'src/database/system-database' +import { Region } from 'src/region/entities/region' +import { Runtime } from 'src/application/entities/runtime' +import { + AuthProvider, + AuthProviderState, +} from 'src/auth/entities/auth-provider' @Injectable() export class InitializerService { private readonly logger = new Logger(InitializerService.name) - constructor( - private readonly prisma: PrismaService, - private readonly minioService: MinioService, - private readonly regionService: RegionService, - ) {} + private readonly db = SystemDatabase.db async createDefaultRegion() { // check if exists - const existed = await this.prisma.region.count() + const existed = await this.db.collection('Region').countDocuments() if (existed) { this.logger.debug('region already exists') return } // create default region - const res = await this.prisma.region.create({ - data: { - name: 'default', - displayName: 'Default', - tls: ServerConfig.DEFAULT_REGION_TLS, - clusterConf: { - driver: 'kubernetes', - }, - databaseConf: { - set: { - driver: 'mongodb', - connectionUri: ServerConfig.DEFAULT_REGION_DATABASE_URL, - controlConnectionUri: ServerConfig.DEFAULT_REGION_DATABASE_URL, - }, - }, - storageConf: { - set: { - driver: 'minio', - domain: ServerConfig.DEFAULT_REGION_MINIO_DOMAIN, - externalEndpoint: - ServerConfig.DEFAULT_REGION_MINIO_EXTERNAL_ENDPOINT, - internalEndpoint: - ServerConfig.DEFAULT_REGION_MINIO_INTERNAL_ENDPOINT, - accessKey: ServerConfig.DEFAULT_REGION_MINIO_ROOT_ACCESS_KEY, - secretKey: ServerConfig.DEFAULT_REGION_MINIO_ROOT_SECRET_KEY, - controlEndpoint: - ServerConfig.DEFAULT_REGION_MINIO_INTERNAL_ENDPOINT, - }, - }, - gatewayConf: { - set: { - driver: 'apisix', - runtimeDomain: ServerConfig.DEFAULT_REGION_RUNTIME_DOMAIN, - websiteDomain: ServerConfig.DEFAULT_REGION_WEBSITE_DOMAIN, - port: ServerConfig.DEFAULT_REGION_APISIX_PUBLIC_PORT, - apiUrl: ServerConfig.DEFAULT_REGION_APISIX_API_URL, - apiKey: ServerConfig.DEFAULT_REGION_APISIX_API_KEY, - }, - }, + const res = await this.db.collection('Region').insertOne({ + name: 'default', + displayName: 'Default', + tls: ServerConfig.DEFAULT_REGION_TLS, + clusterConf: { + driver: 'kubernetes', + kubeconfig: null, + npmInstallFlags: '', }, - }) - this.logger.verbose(`Created default region: ${res.name}`) - return res - } - - async createDefaultBundle() { - // check if exists - const existed = await this.prisma.bundle.count() - if (existed) { - this.logger.debug('default bundle already exists') - return - } - - // create default bundle - const res = await this.prisma.bundle.create({ - data: { - name: 'standard', - displayName: 'Standard', - limitCountPerUser: 10, - priority: 0, - maxRenewalTime: 3600 * 24 * 365 * 10, - resource: { - limitCPU: 1 * CPU_UNIT, - limitMemory: 512, - requestCPU: 0.05 * CPU_UNIT, - requestMemory: 128, - - databaseCapacity: 1024, - storageCapacity: 1024 * 5, - networkTrafficOutbound: 1024 * 5, - - limitCountOfCloudFunction: 500, - limitCountOfBucket: 10, - limitCountOfDatabasePolicy: 10, - limitCountOfTrigger: 10, - limitCountOfWebsiteHosting: 10, - reservedTimeAfterExpired: 3600 * 24 * 7, - - limitDatabaseTPS: 100, - limitStorageTPS: 1000, - }, - subscriptionOptions: [ - { - name: 'monthly', - displayName: '1 Month', - duration: 31 * 24 * 3600, - price: 0, - specialPrice: 0, - }, - { - name: 'half-yearly', - displayName: '6 Months', - duration: 6 * 31 * 24 * 3600, - price: 0, - specialPrice: 0, - }, - { - name: 'yearly', - displayName: '12 Months', - duration: 12 * 31 * 24 * 3600, - price: 0, - specialPrice: 0, - }, - ], - region: { - connect: { - name: 'default', - }, - }, + databaseConf: { + driver: 'mongodb', + connectionUri: ServerConfig.DEFAULT_REGION_DATABASE_URL, + controlConnectionUri: ServerConfig.DEFAULT_REGION_DATABASE_URL, + }, + storageConf: { + driver: 'minio', + domain: ServerConfig.DEFAULT_REGION_MINIO_DOMAIN, + externalEndpoint: ServerConfig.DEFAULT_REGION_MINIO_EXTERNAL_ENDPOINT, + internalEndpoint: ServerConfig.DEFAULT_REGION_MINIO_INTERNAL_ENDPOINT, + accessKey: ServerConfig.DEFAULT_REGION_MINIO_ROOT_ACCESS_KEY, + secretKey: ServerConfig.DEFAULT_REGION_MINIO_ROOT_SECRET_KEY, + controlEndpoint: ServerConfig.DEFAULT_REGION_MINIO_INTERNAL_ENDPOINT, }, + gatewayConf: { + driver: 'apisix', + runtimeDomain: ServerConfig.DEFAULT_REGION_RUNTIME_DOMAIN, + websiteDomain: ServerConfig.DEFAULT_REGION_WEBSITE_DOMAIN, + port: ServerConfig.DEFAULT_REGION_APISIX_PUBLIC_PORT, + apiUrl: ServerConfig.DEFAULT_REGION_APISIX_API_URL, + apiKey: ServerConfig.DEFAULT_REGION_APISIX_API_KEY, + }, + updatedAt: new Date(), + createdAt: new Date(), + state: 'Active', }) - this.logger.verbose('Created default bundle: ' + res.name) + + this.logger.verbose(`Created default region`) return res } async createDefaultRuntime() { // check if exists - const existed = await this.prisma.runtime.count() + const existed = await this.db + .collection('Runtime') + .countDocuments() if (existed) { this.logger.debug('default runtime already exists') return } // create default runtime - const res = await this.prisma.runtime.create({ - data: { - name: 'node', - type: 'node:laf', - image: { - main: ServerConfig.DEFAULT_RUNTIME_IMAGE.image.main, - init: ServerConfig.DEFAULT_RUNTIME_IMAGE.image.init, - }, - version: ServerConfig.DEFAULT_RUNTIME_IMAGE.version, - latest: true, + const res = await this.db.collection('Runtime').insertOne({ + name: 'node', + type: 'node:laf', + image: { + main: ServerConfig.DEFAULT_RUNTIME_IMAGE.image.main, + init: ServerConfig.DEFAULT_RUNTIME_IMAGE.image.init, }, + version: ServerConfig.DEFAULT_RUNTIME_IMAGE.version, + latest: true, + state: 'Active', }) - this.logger.verbose('Created default runtime: ' + res.name) + + this.logger.verbose('Created default runtime') return res } async createDefaultAuthProvider() { // check if exists - const existed = await this.prisma.authProvider.count() + const existed = await this.db + .collection('AuthProvider') + .countDocuments() if (existed) { this.logger.debug('default auth provider already exists') return } // create default auth provider - user-password - const resPassword = await this.prisma.authProvider.create({ - data: { - name: 'user-password', - bind: { - password: 'optional', - phone: 'optional', - email: 'optional', - }, - register: true, - default: true, - state: AuthProviderState.Enabled, - config: { usernameField: 'username', passwordField: 'password' }, + await this.db.collection('AuthProvider').insertOne({ + name: 'user-password', + bind: { + password: 'optional', + phone: 'optional', + email: 'optional', }, + register: true, + default: true, + state: AuthProviderState.Enabled, + config: { usernameField: 'username', passwordField: 'password' }, + createdAt: new Date(), + updatedAt: new Date(), }) // create auth provider - phone code - const resPhone = await this.prisma.authProvider.create({ - data: { - name: 'phone', - bind: { - password: 'optional', - phone: 'optional', - email: 'optional', - }, - register: true, - default: false, - state: AuthProviderState.Disabled, - config: { - alisms: {}, - }, + await this.db.collection('AuthProvider').insertOne({ + name: 'phone', + bind: { + password: 'optional', + phone: 'optional', + email: 'optional', + }, + register: true, + default: false, + state: AuthProviderState.Disabled, + config: { + alisms: {}, }, + createdAt: new Date(), + updatedAt: new Date(), }) - this.logger.verbose( - 'Created default auth providers: ' + - resPassword.name + - ' ' + - resPhone.name, - ) - return resPhone + this.logger.verbose('Created default auth providers') } } diff --git a/server/src/instance/instance.service.ts b/server/src/instance/instance.service.ts index 307ef05c06..42db6cec41 100644 --- a/server/src/instance/instance.service.ts +++ b/server/src/instance/instance.service.ts @@ -297,7 +297,7 @@ export class InstanceService { }, securityContext: { allowPrivilegeEscalation: false, - readOnlyRootFilesystem: true, + readOnlyRootFilesystem: false, privileged: false, }, }, diff --git a/server/src/main.ts b/server/src/main.ts index 0271bf2952..3b204d511e 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -48,7 +48,6 @@ async function bootstrap() { try { const initService = app.get(InitializerService) await initService.createDefaultRegion() - await initService.createDefaultBundle() await initService.createDefaultRuntime() await initService.createDefaultAuthProvider() } catch (error) { diff --git a/server/src/prisma/prisma.module.ts b/server/src/prisma/prisma.module.ts deleted file mode 100644 index 4501415d70..0000000000 --- a/server/src/prisma/prisma.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Global, Module } from '@nestjs/common' -import { PrismaService } from './prisma.service' - -@Global() -@Module({ - providers: [PrismaService], - exports: [PrismaService], -}) -export class PrismaModule {} diff --git a/server/src/prisma/prisma.service.ts b/server/src/prisma/prisma.service.ts deleted file mode 100644 index 976fc249b3..0000000000 --- a/server/src/prisma/prisma.service.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - INestApplication, - Injectable, - Logger, - OnModuleInit, -} from '@nestjs/common' -import { PrismaClient } from '@prisma/client' - -@Injectable() -export class PrismaService extends PrismaClient implements OnModuleInit { - private readonly logger = new Logger(PrismaService.name) - async onModuleInit() { - await this.$connect() - this.logger.debug('PrismaService connected') - } - - async enableShutdownHooks(app: INestApplication) { - this.$on('beforeExit', async () => { - await app.close() - }) - } -} diff --git a/server/src/region/entities/region.ts b/server/src/region/entities/region.ts index 17399db420..73cbb7928c 100644 --- a/server/src/region/entities/region.ts +++ b/server/src/region/entities/region.ts @@ -2,7 +2,7 @@ import { ObjectId } from 'mongodb' export type RegionClusterConf = { driver: string - kubeconfig: string | null + kubeconfig: string npmInstallFlags: string } @@ -40,7 +40,7 @@ export class Region { gatewayConf: RegionGatewayConf storageConf: RegionStorageConf tls: boolean - state: string + state: 'Active' | 'Inactive' createdAt: Date updatedAt: Date From 398c3a1e4c5ae903d4698cd2a9e202f111768632 Mon Sep 17 00:00:00 2001 From: maslow Date: Fri, 19 May 2023 21:51:01 +0800 Subject: [PATCH 11/48] add resource option & template api --- server/README.md | 4 +- .../application/application-task.service.ts | 6 +- server/src/application/application.module.ts | 4 +- .../{region => application}/bundle.service.ts | 4 +- server/src/database/database.module.ts | 2 + .../src/database/policy/policy.controller.ts | 4 +- server/src/function/function.controller.ts | 4 +- server/src/initializer/initializer.service.ts | 179 ++++++++++++++++++ server/src/main.ts | 4 +- server/src/region/entities/resource.ts | 43 +++++ server/src/region/region.controller.ts | 42 +++- server/src/region/region.module.ts | 6 +- server/src/region/region.service.ts | 11 +- server/src/region/resource-option.service.ts | 42 ++++ server/src/storage/bucket.controller.ts | 4 +- server/src/storage/storage.module.ts | 2 + server/src/trigger/trigger.controller.ts | 4 +- server/src/trigger/trigger.module.ts | 2 + server/src/website/website.controller.ts | 4 +- server/src/website/website.module.ts | 3 +- web/package.json | 3 +- 21 files changed, 347 insertions(+), 30 deletions(-) rename server/src/{region => application}/bundle.service.ts (86%) create mode 100644 server/src/region/entities/resource.ts create mode 100644 server/src/region/resource-option.service.ts diff --git a/server/README.md b/server/README.md index 174a9feb66..18972d6a87 100644 --- a/server/README.md +++ b/server/README.md @@ -35,12 +35,10 @@ ```bash cd server/ -# Install telepresence traffic manager +# Install telepresence traffic manager (only telepresence helm install # Connect your computer to laf-dev cluster telepresence connect -# view the available services, service status needs to be Ready, `ready to intercept` -telepresence list -n laf-system # Connect local server to laf server cluster telepresence intercept laf-server -n laf-system -p 3000:3000 -e $(pwd)/.env diff --git a/server/src/application/application-task.service.ts b/server/src/application/application-task.service.ts index cafe78c8ac..253f2e9bb6 100644 --- a/server/src/application/application-task.service.ts +++ b/server/src/application/application-task.service.ts @@ -11,7 +11,7 @@ import { SystemDatabase } from 'src/database/system-database' import { TriggerService } from 'src/trigger/trigger.service' import { FunctionService } from 'src/function/function.service' import { ApplicationConfigurationService } from './configuration.service' -import { BundleService } from 'src/region/bundle.service' +import { BundleService } from 'src/application/bundle.service' import { WebsiteService } from 'src/website/website.service' import { PolicyService } from 'src/database/policy/policy.service' import { BucketDomainService } from 'src/gateway/bucket-domain.service' @@ -225,9 +225,9 @@ export class ApplicationTaskService { } // delete application bundle - const bundle = await this.bundleService.findApplicationBundle(appid) + const bundle = await this.bundleService.findOne(appid) if (bundle) { - await this.bundleService.deleteApplicationBundle(appid) + await this.bundleService.deleteOne(appid) return await this.unlock(appid) } diff --git a/server/src/application/application.module.ts b/server/src/application/application.module.ts index 4fb1a96d72..ec00c8b210 100644 --- a/server/src/application/application.module.ts +++ b/server/src/application/application.module.ts @@ -14,6 +14,7 @@ import { ApplicationConfigurationService } from './configuration.service' import { TriggerService } from 'src/trigger/trigger.service' import { WebsiteService } from 'src/website/website.service' import { AccountModule } from 'src/account/account.module' +import { BundleService } from './bundle.service' @Module({ imports: [StorageModule, DatabaseModule, GatewayModule, AccountModule], @@ -28,7 +29,8 @@ import { AccountModule } from 'src/account/account.module' ApplicationConfigurationService, TriggerService, WebsiteService, + BundleService, ], - exports: [ApplicationService], + exports: [ApplicationService, BundleService], }) export class ApplicationModule {} diff --git a/server/src/region/bundle.service.ts b/server/src/application/bundle.service.ts similarity index 86% rename from server/src/region/bundle.service.ts rename to server/src/application/bundle.service.ts index 462a088dba..7994fded29 100644 --- a/server/src/region/bundle.service.ts +++ b/server/src/application/bundle.service.ts @@ -7,7 +7,7 @@ export class BundleService { private readonly logger = new Logger(BundleService.name) private readonly db = SystemDatabase.db - async findApplicationBundle(appid: string) { + async findOne(appid: string) { const bundle = await this.db .collection('ApplicationBundle') .findOne({ appid }) @@ -15,7 +15,7 @@ export class BundleService { return bundle } - async deleteApplicationBundle(appid: string) { + async deleteOne(appid: string) { const res = await this.db .collection('ApplicationBundle') .findOneAndDelete({ appid }) diff --git a/server/src/database/database.module.ts b/server/src/database/database.module.ts index d73814cd4d..4faad95a54 100644 --- a/server/src/database/database.module.ts +++ b/server/src/database/database.module.ts @@ -9,6 +9,7 @@ import { PolicyRuleService } from './policy/policy-rule.service' import { PolicyRuleController } from './policy/policy-rule.controller' import { MongoService } from './mongo.service' import { ApplicationService } from 'src/application/application.service' +import { BundleService } from 'src/application/bundle.service' @Module({ imports: [], @@ -25,6 +26,7 @@ import { ApplicationService } from 'src/application/application.service' PolicyRuleService, MongoService, ApplicationService, + BundleService, ], exports: [ CollectionService, diff --git a/server/src/database/policy/policy.controller.ts b/server/src/database/policy/policy.controller.ts index 2da6fd0db0..504a4941cf 100644 --- a/server/src/database/policy/policy.controller.ts +++ b/server/src/database/policy/policy.controller.ts @@ -20,7 +20,7 @@ import { UpdatePolicyDto } from '../dto/update-policy.dto' import { ResponseUtil } from 'src/utils/response' import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' -import { BundleService } from 'src/region/bundle.service' +import { BundleService } from 'src/application/bundle.service' @ApiTags('Database') @ApiBearerAuth('Authorization') @@ -37,7 +37,7 @@ export class PolicyController { @UseGuards(JwtAuthGuard, ApplicationAuthGuard) async create(@Param('appid') appid: string, @Body() dto: CreatePolicyDto) { // check policy count limit - const bundle = await this.bundleService.findApplicationBundle(appid) + const bundle = await this.bundleService.findOne(appid) const LIMIT_COUNT = bundle?.resource?.limitCountOfDatabasePolicy || 0 const count = await this.policiesService.count(appid) if (count >= LIMIT_COUNT) { diff --git a/server/src/function/function.controller.ts b/server/src/function/function.controller.ts index be83fc62d0..7880293565 100644 --- a/server/src/function/function.controller.ts +++ b/server/src/function/function.controller.ts @@ -25,7 +25,7 @@ import { ApplicationAuthGuard } from '../auth/application.auth.guard' import { FunctionService } from './function.service' import { IRequest } from '../utils/interface' import { CompileFunctionDto } from './dto/compile-function.dto' -import { BundleService } from 'src/region/bundle.service' +import { BundleService } from 'src/application/bundle.service' import { I18n, I18nContext, I18nService } from 'nestjs-i18n' import { I18nTranslations } from '../generated/i18n.generated' @@ -68,7 +68,7 @@ export class FunctionController { } // check if meet the count limit - const bundle = await this.bundleService.findApplicationBundle(appid) + const bundle = await this.bundleService.findOne(appid) const MAX_FUNCTION_COUNT = bundle?.resource?.limitCountOfCloudFunction || 0 const count = await this.functionsService.count(appid) if (count >= MAX_FUNCTION_COUNT) { diff --git a/server/src/initializer/initializer.service.ts b/server/src/initializer/initializer.service.ts index d9e2fee973..1021551d60 100644 --- a/server/src/initializer/initializer.service.ts +++ b/server/src/initializer/initializer.service.ts @@ -7,12 +7,25 @@ import { AuthProvider, AuthProviderState, } from 'src/auth/entities/auth-provider' +import { + ResourceOption, + ResourceTemplate, + ResourceType, +} from 'src/region/entities/resource' @Injectable() export class InitializerService { private readonly logger = new Logger(InitializerService.name) private readonly db = SystemDatabase.db + async init() { + await this.createDefaultRegion() + await this.createDefaultRuntime() + await this.createDefaultAuthProvider() + await this.createDefaultResourceOptions() + await this.createDefaultResourceTemplates() + } + async createDefaultRegion() { // check if exists const existed = await this.db.collection('Region').countDocuments() @@ -135,4 +148,170 @@ export class InitializerService { this.logger.verbose('Created default auth providers') } + + async createDefaultResourceOptions() { + // check if exists + const existed = await this.db + .collection('ResourceOption') + .countDocuments() + if (existed) { + this.logger.debug('default resource options already exists') + return + } + + // get default region + const region = await this.db.collection('Region').findOne({}) + + // create default resource options + await this.db.collection('ResourceOption').insertMany([ + { + regionId: region._id, + type: ResourceType.CPU, + price: 0.072, + specs: [ + { label: '0.2 Core', value: 200 }, + { label: '0.5 Core', value: 500 }, + { label: '1 Core', value: 1000 }, + { label: '2 Core', value: 2000 }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + type: ResourceType.Memory, + price: 0.036, + specs: [ + { label: '256 MB', value: 256 }, + { label: '512 MB', value: 512 }, + { label: '1 GB', value: 1024 }, + { label: '2 GB', value: 2048 }, + { label: '4 GB', value: 4096 }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + type: ResourceType.DatabaseCapacity, + price: 0.0072, + specs: [ + { label: '1 GB', value: 1024 }, + { label: '4 GB', value: 4096 }, + { label: '16 GB', value: 16384 }, + { label: '64 GB', value: 65536 }, + { label: '256 GB', value: 262144 }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + type: ResourceType.StorageCapacity, + price: 0.002, + specs: [ + { label: '1 GB', value: 1024 }, + { label: '4 GB', value: 4096 }, + { label: '16 GB', value: 16384 }, + { label: '64 GB', value: 65536 }, + { label: '256 GB', value: 262144 }, + { label: '1 TB', value: 1048576 }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + type: ResourceType.NetworkTraffic, + price: 0.8, + specs: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + this.logger.verbose('Created default resource options') + } + + async createDefaultResourceTemplates() { + // check if exists + const existed = await this.db + .collection('ResourceTemplate') + .countDocuments() + + if (existed) { + this.logger.debug('default resource templates already exists') + return + } + + // get default region + const region = await this.db.collection('Region').findOne({}) + + // create default resource templates + await this.db.collection('ResourceTemplate').insertMany([ + { + regionId: region._id, + name: 'trial', + displayName: 'Trial', + spec: { + [ResourceType.CPU]: { value: 200 }, + [ResourceType.Memory]: { value: 256 }, + [ResourceType.DatabaseCapacity]: { value: 1024 }, + [ResourceType.StorageCapacity]: { value: 1024 }, + [ResourceType.NetworkTraffic]: { value: 0 }, + }, + enableFreeTier: true, + limitCountOfFreeTierPerUser: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + name: 'lite', + displayName: 'Lite', + spec: { + [ResourceType.CPU]: { value: 500 }, + [ResourceType.Memory]: { value: 512 }, + [ResourceType.DatabaseCapacity]: { value: 4096 }, + [ResourceType.StorageCapacity]: { value: 4096 }, + [ResourceType.NetworkTraffic]: { value: 0 }, + }, + enableFreeTier: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + name: 'standard', + displayName: 'Standard', + spec: { + [ResourceType.CPU]: { value: 1000 }, + [ResourceType.Memory]: { value: 2048 }, + [ResourceType.DatabaseCapacity]: { value: 16384 }, + [ResourceType.StorageCapacity]: { value: 65536 }, + [ResourceType.NetworkTraffic]: { value: 0 }, + }, + enableFreeTier: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + name: 'pro', + displayName: 'Pro', + spec: { + [ResourceType.CPU]: { value: 2000 }, + [ResourceType.Memory]: { value: 4096 }, + [ResourceType.DatabaseCapacity]: { value: 65536 }, + [ResourceType.StorageCapacity]: { value: 262144 }, + [ResourceType.NetworkTraffic]: { value: 0 }, + }, + enableFreeTier: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + this.logger.verbose('Created default resource templates') + } } diff --git a/server/src/main.ts b/server/src/main.ts index 3b204d511e..6d083b2d6f 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -47,9 +47,7 @@ async function bootstrap() { try { const initService = app.get(InitializerService) - await initService.createDefaultRegion() - await initService.createDefaultRuntime() - await initService.createDefaultAuthProvider() + await initService.init() } catch (error) { console.error(error) process.exit(1) diff --git a/server/src/region/entities/resource.ts b/server/src/region/entities/resource.ts new file mode 100644 index 0000000000..e690622214 --- /dev/null +++ b/server/src/region/entities/resource.ts @@ -0,0 +1,43 @@ +import { ObjectId } from 'mongodb' + +export enum ResourceType { + CPU = 'cpu', + Memory = 'memory', + DatabaseCapacity = 'databaseCapacity', + StorageCapacity = 'storageCapacity', + NetworkTraffic = 'networkTraffic', +} + +export interface ResourceSpec { + value: number + label?: string +} + +export class ResourceOption { + _id?: ObjectId + regionId: ObjectId + type: ResourceType + price: number + specs: ResourceSpec[] + createdAt: Date + updatedAt: Date +} + +export class ResourceTemplate { + _id?: ObjectId + regionId: ObjectId + name: string + displayName: string + spec: { + [ResourceType.CPU]: ResourceSpec + [ResourceType.Memory]: ResourceSpec + [ResourceType.DatabaseCapacity]: ResourceSpec + [ResourceType.StorageCapacity]: ResourceSpec + [ResourceType.NetworkTraffic]?: ResourceSpec + } + enableFreeTier?: boolean + limitCountOfFreeTierPerUser?: number + message?: string + createdAt: Date + updatedAt: Date +} diff --git a/server/src/region/region.controller.ts b/server/src/region/region.controller.ts index d108126ba4..075d207047 100644 --- a/server/src/region/region.controller.ts +++ b/server/src/region/region.controller.ts @@ -1,13 +1,18 @@ -import { Controller, Get, Logger } from '@nestjs/common' +import { Controller, Get, Logger, Param } from '@nestjs/common' import { ApiOperation, ApiTags } from '@nestjs/swagger' import { ResponseUtil } from '../utils/response' import { RegionService } from './region.service' +import { ResourceOptionService } from './resource-option.service' +import { ObjectId } from 'mongodb' @ApiTags('Public') @Controller('regions') export class RegionController { private readonly logger = new Logger(RegionController.name) - constructor(private readonly regionService: RegionService) {} + constructor( + private readonly regionService: RegionService, + private readonly resourceService: ResourceOptionService, + ) {} /** * Get region list @@ -19,4 +24,37 @@ export class RegionController { const data = await this.regionService.findAllDesensitized() return ResponseUtil.ok(data) } + + /** + * Get resource option list + */ + @ApiOperation({ summary: 'Get resource option list' }) + @Get('resource-options') + async getResourceOptions() { + const data = await this.resourceService.findAll() + return ResponseUtil.ok(data) + } + + /** + * Get resource option list by region id + */ + @ApiOperation({ summary: 'Get resource option list by region id' }) + @Get('resource-options/:regionId') + async getResourceOptionsByRegionId(@Param('regionId') regionId: string) { + const data = await this.resourceService.findAllByRegionId( + new ObjectId(regionId), + ) + return ResponseUtil.ok(data) + } + + /** + * Get resource template list + * @returns + */ + @ApiOperation({ summary: 'Get resource template list' }) + @Get('resource-templates') + async getResourceTemplates() { + const data = await this.resourceService.findAllTemplates() + return ResponseUtil.ok(data) + } } diff --git a/server/src/region/region.module.ts b/server/src/region/region.module.ts index 18294f5f99..d2cdc0781b 100644 --- a/server/src/region/region.module.ts +++ b/server/src/region/region.module.ts @@ -2,12 +2,12 @@ import { Global, Module } from '@nestjs/common' import { RegionService } from './region.service' import { RegionController } from './region.controller' import { ClusterService } from './cluster/cluster.service' -import { BundleService } from './bundle.service' +import { ResourceOptionService } from './resource-option.service' @Global() @Module({ - providers: [RegionService, ClusterService, BundleService], + providers: [RegionService, ClusterService, ResourceOptionService], controllers: [RegionController], - exports: [RegionService, ClusterService, BundleService], + exports: [RegionService, ClusterService], }) export class RegionModule {} diff --git a/server/src/region/region.service.ts b/server/src/region/region.service.ts index 50a035e062..4043fda30e 100644 --- a/server/src/region/region.service.ts +++ b/server/src/region/region.service.ts @@ -53,11 +53,20 @@ export class RegionService { name: 1, displayName: 1, state: 1, + resourceTemplates: 1, } const regions = await this.db .collection('Region') - .find({}, { projection }) + .aggregate() + .match({}) + .lookup({ + from: 'ResourceTemplate', + localField: '_id', + foreignField: 'regionId', + as: 'resourceTemplates', + }) + .project(projection) .toArray() return regions diff --git a/server/src/region/resource-option.service.ts b/server/src/region/resource-option.service.ts new file mode 100644 index 0000000000..134400dbe1 --- /dev/null +++ b/server/src/region/resource-option.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common' +import { SystemDatabase } from 'src/database/system-database' +import { ObjectId } from 'mongodb' +import { ResourceOption, ResourceTemplate } from './entities/resource' + +@Injectable() +export class ResourceOptionService { + private readonly db = SystemDatabase.db + + async findAll() { + const options = await this.db + .collection('ResourceOption') + .find() + .toArray() + return options + } + + async findOne(id: ObjectId) { + const option = await this.db + .collection('ResourceOption') + .findOne({ _id: id }) + return option + } + + async findAllByRegionId(regionId: ObjectId) { + const options = await this.db + .collection('ResourceOption') + .find({ regionId }) + .toArray() + + return options + } + + async findAllTemplates() { + const options = await this.db + .collection('ResourceTemplate') + .find() + .toArray() + + return options + } +} diff --git a/server/src/storage/bucket.controller.ts b/server/src/storage/bucket.controller.ts index 7cd5a5ae75..d174a98b34 100644 --- a/server/src/storage/bucket.controller.ts +++ b/server/src/storage/bucket.controller.ts @@ -25,7 +25,7 @@ import { ResponseUtil } from '../utils/response' import { CreateBucketDto } from './dto/create-bucket.dto' import { UpdateBucketDto } from './dto/update-bucket.dto' import { BucketService } from './bucket.service' -import { BundleService } from 'src/region/bundle.service' +import { BundleService } from 'src/application/bundle.service' @ApiTags('Storage') @ApiBearerAuth('Authorization') @@ -56,7 +56,7 @@ export class BucketController { const app = req.application // check bucket count limit - const bundle = await this.bundleService.findApplicationBundle(appid) + const bundle = await this.bundleService.findOne(appid) const LIMIT_COUNT = bundle?.resource?.limitCountOfBucket || 0 const count = await this.bucketService.count(appid) if (count >= LIMIT_COUNT) { diff --git a/server/src/storage/storage.module.ts b/server/src/storage/storage.module.ts index 934fd530b1..65e591ee32 100644 --- a/server/src/storage/storage.module.ts +++ b/server/src/storage/storage.module.ts @@ -6,6 +6,7 @@ import { ApplicationService } from 'src/application/application.service' import { BucketService } from './bucket.service' import { GatewayModule } from 'src/gateway/gateway.module' import { BucketTaskService } from './bucket-task.service' +import { BundleService } from 'src/application/bundle.service' @Module({ imports: [GatewayModule], @@ -16,6 +17,7 @@ import { BucketTaskService } from './bucket-task.service' ApplicationService, BucketService, BucketTaskService, + BundleService, ], exports: [StorageService, MinioService, BucketService], }) diff --git a/server/src/trigger/trigger.controller.ts b/server/src/trigger/trigger.controller.ts index f6a8813806..240e0df948 100644 --- a/server/src/trigger/trigger.controller.ts +++ b/server/src/trigger/trigger.controller.ts @@ -19,7 +19,7 @@ import { } from '@nestjs/swagger' import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' -import { BundleService } from 'src/region/bundle.service' +import { BundleService } from 'src/application/bundle.service' import { ObjectId } from 'mongodb' @ApiTags('Trigger') @@ -44,7 +44,7 @@ export class TriggerController { @Post() async create(@Param('appid') appid: string, @Body() dto: CreateTriggerDto) { // check trigger count limit - const bundle = await this.bundleService.findApplicationBundle(appid) + const bundle = await this.bundleService.findOne(appid) const LIMIT_COUNT = bundle?.resource?.limitCountOfTrigger || 0 const count = await this.triggerService.count(appid) if (count >= LIMIT_COUNT) { diff --git a/server/src/trigger/trigger.module.ts b/server/src/trigger/trigger.module.ts index cc76d01d05..9d024eb77f 100644 --- a/server/src/trigger/trigger.module.ts +++ b/server/src/trigger/trigger.module.ts @@ -10,6 +10,7 @@ import { TriggerTaskService } from './trigger-task.service' import { FunctionService } from 'src/function/function.service' import { DatabaseService } from 'src/database/database.service' import { MongoService } from 'src/database/mongo.service' +import { BundleService } from 'src/application/bundle.service' @Module({ imports: [StorageModule, HttpModule], @@ -23,6 +24,7 @@ import { MongoService } from 'src/database/mongo.service' FunctionService, DatabaseService, MongoService, + BundleService, ], exports: [TriggerService, CronJobService], }) diff --git a/server/src/website/website.controller.ts b/server/src/website/website.controller.ts index 24522519f9..e91eb24237 100644 --- a/server/src/website/website.controller.ts +++ b/server/src/website/website.controller.ts @@ -20,7 +20,7 @@ import { import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' import { ResponseUtil } from 'src/utils/response' -import { BundleService } from 'src/region/bundle.service' +import { BundleService } from 'src/application/bundle.service' import { BucketService } from 'src/storage/bucket.service' import { ObjectId } from 'mongodb' import { DomainState } from 'src/gateway/entities/runtime-domain' @@ -48,7 +48,7 @@ export class WebsiteController { @Post() async create(@Param('appid') appid: string, @Body() dto: CreateWebsiteDto) { // check if website hosting limit reached - const bundle = await this.bundleService.findApplicationBundle(appid) + const bundle = await this.bundleService.findOne(appid) const LIMIT_COUNT = bundle?.resource?.limitCountOfWebsiteHosting || 0 const count = await this.websiteService.count(appid) if (count >= LIMIT_COUNT) { diff --git a/server/src/website/website.module.ts b/server/src/website/website.module.ts index cfa5aae2e4..badbc27edf 100644 --- a/server/src/website/website.module.ts +++ b/server/src/website/website.module.ts @@ -3,10 +3,11 @@ import { WebsiteService } from './website.service' import { WebsiteController } from './website.controller' import { ApplicationService } from 'src/application/application.service' import { StorageModule } from 'src/storage/storage.module' +import { BundleService } from 'src/application/bundle.service' @Module({ imports: [StorageModule], controllers: [WebsiteController], - providers: [WebsiteService, ApplicationService], + providers: [WebsiteService, ApplicationService, BundleService], }) export class WebsiteModule {} diff --git a/web/package.json b/web/package.json index 577368016b..ddf7079cf5 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ "lint": "eslint src --fix", "prettier": "prettier --write ./src", "prepare": "cd .. && husky install web/.husky", + "intercept": "telepresence intercept laf-web -n laf-system -p 3001:80", "lint-staged": "lint-staged" }, "dependencies": { @@ -74,4 +75,4 @@ "prettier --write" ] } -} +} \ No newline at end of file From a2dc9db2304fc745cfd4b17badef8bebdaf81879 Mon Sep 17 00:00:00 2001 From: maslow Date: Fri, 19 May 2023 22:03:18 +0800 Subject: [PATCH 12/48] rename resource template to bundle --- server/src/application/entities/application.ts | 1 + server/src/initializer/initializer.service.ts | 10 +++++----- server/src/region/entities/resource.ts | 2 +- server/src/region/region.controller.ts | 10 +++++----- server/src/region/region.module.ts | 4 ++-- server/src/region/region.service.ts | 6 +++--- ...{resource-option.service.ts => resource.service.ts} | 8 ++++---- 7 files changed, 21 insertions(+), 20 deletions(-) rename server/src/region/{resource-option.service.ts => resource.service.ts} (80%) diff --git a/server/src/application/entities/application.ts b/server/src/application/entities/application.ts index c673f64c62..c7cacf9b32 100644 --- a/server/src/application/entities/application.ts +++ b/server/src/application/entities/application.ts @@ -32,6 +32,7 @@ export class Application { tags: string[] state: ApplicationState phase: ApplicationPhase + isTrialTier?: boolean createdAt: Date updatedAt: Date lockedAt: Date diff --git a/server/src/initializer/initializer.service.ts b/server/src/initializer/initializer.service.ts index 1021551d60..e5825cd02c 100644 --- a/server/src/initializer/initializer.service.ts +++ b/server/src/initializer/initializer.service.ts @@ -9,7 +9,7 @@ import { } from 'src/auth/entities/auth-provider' import { ResourceOption, - ResourceTemplate, + ResourceBundle, ResourceType, } from 'src/region/entities/resource' @@ -23,7 +23,7 @@ export class InitializerService { await this.createDefaultRuntime() await this.createDefaultAuthProvider() await this.createDefaultResourceOptions() - await this.createDefaultResourceTemplates() + await this.createDefaultResourceBundles() } async createDefaultRegion() { @@ -233,10 +233,10 @@ export class InitializerService { this.logger.verbose('Created default resource options') } - async createDefaultResourceTemplates() { + async createDefaultResourceBundles() { // check if exists const existed = await this.db - .collection('ResourceTemplate') + .collection('ResourceBundle') .countDocuments() if (existed) { @@ -248,7 +248,7 @@ export class InitializerService { const region = await this.db.collection('Region').findOne({}) // create default resource templates - await this.db.collection('ResourceTemplate').insertMany([ + await this.db.collection('ResourceBundle').insertMany([ { regionId: region._id, name: 'trial', diff --git a/server/src/region/entities/resource.ts b/server/src/region/entities/resource.ts index e690622214..fc92604ec6 100644 --- a/server/src/region/entities/resource.ts +++ b/server/src/region/entities/resource.ts @@ -23,7 +23,7 @@ export class ResourceOption { updatedAt: Date } -export class ResourceTemplate { +export class ResourceBundle { _id?: ObjectId regionId: ObjectId name: string diff --git a/server/src/region/region.controller.ts b/server/src/region/region.controller.ts index 075d207047..737f1b6109 100644 --- a/server/src/region/region.controller.ts +++ b/server/src/region/region.controller.ts @@ -2,7 +2,7 @@ import { Controller, Get, Logger, Param } from '@nestjs/common' import { ApiOperation, ApiTags } from '@nestjs/swagger' import { ResponseUtil } from '../utils/response' import { RegionService } from './region.service' -import { ResourceOptionService } from './resource-option.service' +import { ResourceService } from './resource.service' import { ObjectId } from 'mongodb' @ApiTags('Public') @@ -11,7 +11,7 @@ export class RegionController { private readonly logger = new Logger(RegionController.name) constructor( private readonly regionService: RegionService, - private readonly resourceService: ResourceOptionService, + private readonly resourceService: ResourceService, ) {} /** @@ -52,9 +52,9 @@ export class RegionController { * @returns */ @ApiOperation({ summary: 'Get resource template list' }) - @Get('resource-templates') - async getResourceTemplates() { - const data = await this.resourceService.findAllTemplates() + @Get('resource-bundles') + async getResourceBundles() { + const data = await this.resourceService.findAllBundles() return ResponseUtil.ok(data) } } diff --git a/server/src/region/region.module.ts b/server/src/region/region.module.ts index d2cdc0781b..692e801e35 100644 --- a/server/src/region/region.module.ts +++ b/server/src/region/region.module.ts @@ -2,11 +2,11 @@ import { Global, Module } from '@nestjs/common' import { RegionService } from './region.service' import { RegionController } from './region.controller' import { ClusterService } from './cluster/cluster.service' -import { ResourceOptionService } from './resource-option.service' +import { ResourceService } from './resource.service' @Global() @Module({ - providers: [RegionService, ClusterService, ResourceOptionService], + providers: [RegionService, ClusterService, ResourceService], controllers: [RegionController], exports: [RegionService, ClusterService], }) diff --git a/server/src/region/region.service.ts b/server/src/region/region.service.ts index 4043fda30e..2c07a2fc5d 100644 --- a/server/src/region/region.service.ts +++ b/server/src/region/region.service.ts @@ -53,7 +53,7 @@ export class RegionService { name: 1, displayName: 1, state: 1, - resourceTemplates: 1, + bundles: 1, } const regions = await this.db @@ -61,10 +61,10 @@ export class RegionService { .aggregate() .match({}) .lookup({ - from: 'ResourceTemplate', + from: 'ResourceBundle', localField: '_id', foreignField: 'regionId', - as: 'resourceTemplates', + as: 'bundles', }) .project(projection) .toArray() diff --git a/server/src/region/resource-option.service.ts b/server/src/region/resource.service.ts similarity index 80% rename from server/src/region/resource-option.service.ts rename to server/src/region/resource.service.ts index 134400dbe1..28aa51774b 100644 --- a/server/src/region/resource-option.service.ts +++ b/server/src/region/resource.service.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common' import { SystemDatabase } from 'src/database/system-database' import { ObjectId } from 'mongodb' -import { ResourceOption, ResourceTemplate } from './entities/resource' +import { ResourceOption, ResourceBundle } from './entities/resource' @Injectable() -export class ResourceOptionService { +export class ResourceService { private readonly db = SystemDatabase.db async findAll() { @@ -31,9 +31,9 @@ export class ResourceOptionService { return options } - async findAllTemplates() { + async findAllBundles() { const options = await this.db - .collection('ResourceTemplate') + .collection('ResourceBundle') .find() .toArray() From fec775785789f19fb35cfbfc2fbf947a03ee809a Mon Sep 17 00:00:00 2001 From: maslow Date: Tue, 9 May 2023 16:37:49 +0800 Subject: [PATCH 13/48] design metering schema --- server/prisma/schema.prisma | 67 +++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 32178dcaeb..7e4601d306 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -54,6 +54,13 @@ type Note { level NoteLevel @default(Info) } +enum BaseState { + Active + Inactive + + @@map("CommonState") +} + // user schemas model User { @@ -190,7 +197,7 @@ model Bundle { displayName String regionId String @db.ObjectId priority Int @default(0) - state String @default("Active") // Active, Inactive + state BaseState @default(Active) limitCountPerUser Int // limit count of application per user could create subscriptionOptions BundleSubscriptionOption[] maxRenewalTime Int // in seconds @@ -334,14 +341,13 @@ model SubscriptionUpgrade { } // accounts schemas - model Account { - id String @id @default(auto()) @map("_id") @db.ObjectId - balance Int @default(0) - state String @default("Active") // Active, Inactive - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String @unique @db.ObjectId + id String @id @default(auto()) @map("_id") @db.ObjectId + balance Int @default(0) + state BaseState @default(Active) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy String @unique @db.ObjectId } model AccountTransaction { @@ -394,13 +400,54 @@ model PaymentChannel { type PaymentChannelType name String spec Json - state String @default("Active") // Active, Inactive + state BaseState @default(Active) notes Note[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } -// application schemas +// Ponit account schemas + +model PointAccount { + id String @id @default(auto()) @map("_id") @db.ObjectId + balance Int @default(0) + state BaseState @default(Active) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy String @unique @db.ObjectId +} + +model PointAccountTransaction { + id String @id @default(auto()) @map("_id") @db.ObjectId + accountId String @db.ObjectId + amount Int + balance Int + message String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum PointAccountChargePhase { + Pending + Paid + Failed +} + +model PointAccountChargeOrder { + id String @id @default(auto()) @map("_id") @db.ObjectId + accountId String @db.ObjectId + amount Int + phase PointAccountChargePhase @default(Pending) + channel PaymentChannelType + result Json? + message String? + createdAt DateTime @default(now()) + lockedAt DateTime + updatedAt DateTime @updatedAt + createdBy String @db.ObjectId +} + +// Application schemas // desired state of application enum ApplicationState { From 1f51402e623b7f8d071768f5a376a05a4d611068 Mon Sep 17 00:00:00 2001 From: maslow Date: Sun, 14 May 2023 17:19:56 +0000 Subject: [PATCH 14/48] add region bundle schema --- server/prisma/schema.prisma | 32 +++++++++++++++---- .../application/dto/create-application.dto.ts | 24 ++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 server/src/application/dto/create-application.dto.ts diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 7e4601d306..180d82219b 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -57,8 +57,6 @@ type Note { enum BaseState { Active Inactive - - @@map("CommonState") } // user schemas @@ -162,6 +160,28 @@ model Region { bundles Bundle[] } +enum RegionBundleType { + CPU + Memory + Database + Storage + Network +} + +type RegionBundleOption { + value Int +} + +model RegionBundle { + id String @id @default(auto()) @map("_id") @db.ObjectId + regionId String @db.ObjectId + type RegionBundleType @unique + price Int @default(0) + options RegionBundleOption[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + type BundleResource { limitCPU Int // 1000 = 1 core limitMemory Int // in MB @@ -170,7 +190,7 @@ type BundleResource { databaseCapacity Int // in MB storageCapacity Int // in MB - networkTrafficOutbound Int // in MB + networkTrafficOutbound Int? // in MB limitCountOfCloudFunction Int // limit count of cloud function per application limitCountOfBucket Int // limit count of bucket per application @@ -216,9 +236,9 @@ model Bundle { model ApplicationBundle { id String @id @default(auto()) @map("_id") @db.ObjectId appid String @unique - bundleId String @db.ObjectId - name String - displayName String + bundleId String? @db.ObjectId + name String? + displayName String? resource BundleResource createdAt DateTime @default(now()) diff --git a/server/src/application/dto/create-application.dto.ts b/server/src/application/dto/create-application.dto.ts new file mode 100644 index 0000000000..7a3237df17 --- /dev/null +++ b/server/src/application/dto/create-application.dto.ts @@ -0,0 +1,24 @@ +import { ApiPropertyOptional } from '@nestjs/swagger' +import { ApplicationState } from '@prisma/client' +import { IsIn, IsString, Length } from 'class-validator' + +const STATES = [ApplicationState.Running, ApplicationState.Stopped] +export class CreateApplicationDto { + /** + * Application name + */ + @ApiPropertyOptional() + @IsString() + @Length(1, 64) + name?: string + + @ApiPropertyOptional({ + enum: ApplicationState, + }) + @IsIn(STATES) + state?: ApplicationState + + validate() { + return null + } +} From 393eda007762324758ad156fa272f5bad2799b3b Mon Sep 17 00:00:00 2001 From: maslow Date: Tue, 16 May 2023 13:25:14 +0000 Subject: [PATCH 15/48] remove prisma in some modules --- server/prisma/schema.prisma | 141 +------ server/src/app.module.ts | 2 - .../application/application-task.service.ts | 21 +- .../src/application/application.controller.ts | 72 +++- server/src/application/application.module.ts | 3 +- server/src/application/application.service.ts | 390 ++++++++++++------ .../src/application/configuration.service.ts | 19 +- .../application/dto/create-application.dto.ts | 43 +- .../application/dto/update-application.dto.ts | 5 +- .../entities/application-bundle.ts | 34 ++ .../entities/application-configuration.ts | 15 + .../src/application/entities/application.ts | 49 +++ server/src/application/entities/runtime.ts | 21 + server/src/application/environment.service.ts | 54 +-- .../collection/collection.controller.ts | 2 +- server/src/database/database.service.ts | 41 +- .../{collection.entity.ts => collection.ts} | 0 .../src/database/entities/database-policy.ts | 32 ++ server/src/database/entities/database.ts | 31 ++ server/src/database/entities/policy.entity.ts | 1 - server/src/database/mongo.service.ts | 2 +- .../database/policy/policy-rule.controller.ts | 8 +- .../database/policy/policy-rule.service.ts | 109 ++--- .../src/database/policy/policy.controller.ts | 4 +- server/src/database/policy/policy.service.ts | 193 +++++---- .../src/gateway/apisix-custom-cert.service.ts | 3 +- server/src/gateway/apisix.service.ts | 3 +- server/src/instance/instance.service.ts | 2 +- server/src/region/cluster/cluster.service.ts | 2 +- server/src/region/entities/region.ts | 50 +++ server/src/region/region.service.ts | 97 ++--- server/src/storage/bucket.service.ts | 8 +- server/src/storage/entities/storage-bucket.ts | 33 ++ server/src/storage/entities/storage-user.ts | 30 ++ server/src/storage/minio/minio.service.ts | 3 +- server/src/storage/storage.service.ts | 5 +- .../dto/create-subscription.dto.ts | 47 --- .../dto/renew-subscription.dto.ts | 9 - .../dto/upgrade-subscription.dto.ts | 11 - .../src/subscription/renewal-task.service.ts | 170 -------- .../subscription/subscription-task.service.ts | 287 ------------- .../subscription/subscription.controller.ts | 275 ------------ .../src/subscription/subscription.module.ts | 19 - .../src/subscription/subscription.service.ts | 124 ------ server/src/utils/interface.ts | 3 +- 45 files changed, 933 insertions(+), 1540 deletions(-) create mode 100644 server/src/application/entities/application-bundle.ts create mode 100644 server/src/application/entities/application-configuration.ts create mode 100644 server/src/application/entities/application.ts create mode 100644 server/src/application/entities/runtime.ts rename server/src/database/entities/{collection.entity.ts => collection.ts} (100%) create mode 100644 server/src/database/entities/database-policy.ts create mode 100644 server/src/database/entities/database.ts delete mode 100644 server/src/database/entities/policy.entity.ts create mode 100644 server/src/region/entities/region.ts create mode 100644 server/src/storage/entities/storage-bucket.ts create mode 100644 server/src/storage/entities/storage-user.ts delete mode 100644 server/src/subscription/dto/create-subscription.dto.ts delete mode 100644 server/src/subscription/dto/renew-subscription.dto.ts delete mode 100644 server/src/subscription/dto/upgrade-subscription.dto.ts delete mode 100644 server/src/subscription/renewal-task.service.ts delete mode 100644 server/src/subscription/subscription-task.service.ts delete mode 100644 server/src/subscription/subscription.controller.ts delete mode 100644 server/src/subscription/subscription.module.ts delete mode 100644 server/src/subscription/subscription.service.ts diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 180d82219b..53e57b43dd 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -236,8 +236,11 @@ model Bundle { model ApplicationBundle { id String @id @default(auto()) @map("_id") @db.ObjectId appid String @unique + // @decrecapted bundleId String? @db.ObjectId + // @decrecapted name String? + // @decrecapted displayName String? resource BundleResource @@ -264,102 +267,6 @@ model Runtime { Application Application[] } -// subscriptions schemas - -// Subscription section mainly consists of two models: Subscription and SubscriptionRenewal. -// -// 1. Subscription: Represents the state, phase, and renewal plan of a subscription. It includes -// the created, updated, and deleted states (SubscriptionState enum); the pending, valid, expired, -// expired and stopped, and deleted phases (SubscriptionPhase enum); and manual, monthly, or -// yearly renewal plans (SubscriptionRenewalPlan enum). This model also contains the associated -// application (Application). -// -// 2. SubscriptionRenewal: Represents the state, duration, and amount of a subscription renewal. -// It includes the pending, paid, and failed renewal phases (SubscriptionRenewalPhase enum). - -enum SubscriptionState { - Created - Deleted -} - -enum SubscriptionPhase { - Pending - Valid - Expired - ExpiredAndStopped - Deleted -} - -enum SubscriptionRenewalPlan { - Manual - Monthly - Yearly -} - -type SubscriptionApplicationCreateInput { - name String - state String - runtimeId String - regionId String -} - -model Subscription { - id String @id @default(auto()) @map("_id") @db.ObjectId - input SubscriptionApplicationCreateInput - bundleId String @db.ObjectId - appid String @unique - state SubscriptionState @default(Created) - phase SubscriptionPhase @default(Pending) - renewalPlan SubscriptionRenewalPlan @default(Manual) - expiredAt DateTime - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String @db.ObjectId - - application Application? -} - -enum SubscriptionRenewalPhase { - Pending - Paid - Failed -} - -model SubscriptionRenewal { - id String @id @default(auto()) @map("_id") @db.ObjectId - subscriptionId String @db.ObjectId - duration Int // in seconds - amount Int - phase SubscriptionRenewalPhase @default(Pending) - message String? - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String @db.ObjectId -} - -// desired state of resource -enum SubscriptionUpgradePhase { - Pending - Completed - Failed -} - -model SubscriptionUpgrade { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - subscriptionId String - originalBundleId String @db.ObjectId - targetBundleId String @db.ObjectId - phase SubscriptionUpgradePhase @default(Pending) - restart Boolean @default(false) - message String? - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - // accounts schemas model Account { id String @id @default(auto()) @map("_id") @db.ObjectId @@ -426,47 +333,6 @@ model PaymentChannel { updatedAt DateTime @updatedAt } -// Ponit account schemas - -model PointAccount { - id String @id @default(auto()) @map("_id") @db.ObjectId - balance Int @default(0) - state BaseState @default(Active) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String @unique @db.ObjectId -} - -model PointAccountTransaction { - id String @id @default(auto()) @map("_id") @db.ObjectId - accountId String @db.ObjectId - amount Int - balance Int - message String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -enum PointAccountChargePhase { - Pending - Paid - Failed -} - -model PointAccountChargeOrder { - id String @id @default(auto()) @map("_id") @db.ObjectId - accountId String @db.ObjectId - amount Int - phase PointAccountChargePhase @default(Pending) - channel PaymentChannelType - result Json? - message String? - createdAt DateTime @default(now()) - lockedAt DateTime - updatedAt DateTime @updatedAt - createdBy String @db.ObjectId -} - // Application schemas // desired state of application @@ -511,7 +377,6 @@ model Application { database Database? domain RuntimeDomain? bundle ApplicationBundle? - subscription Subscription @relation(fields: [appid], references: [appid]) } type EnvironmentVariable { diff --git a/server/src/app.module.ts b/server/src/app.module.ts index db4f56f8fc..1faaf9893a 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -18,7 +18,6 @@ import { TriggerModule } from './trigger/trigger.module' import { RegionModule } from './region/region.module' import { GatewayModule } from './gateway/gateway.module' import { PrismaModule } from './prisma/prisma.module' -import { SubscriptionModule } from './subscription/subscription.module' import { AccountModule } from './account/account.module' import { SettingModule } from './setting/setting.module' import * as path from 'path' @@ -46,7 +45,6 @@ import { AcceptLanguageResolver, I18nModule, QueryResolver } from 'nestjs-i18n' RegionModule, GatewayModule, PrismaModule, - SubscriptionModule, AccountModule, SettingModule, I18nModule.forRoot({ diff --git a/server/src/application/application-task.service.ts b/server/src/application/application-task.service.ts index d17cc33fec..7639e5a3ea 100644 --- a/server/src/application/application-task.service.ts +++ b/server/src/application/application-task.service.ts @@ -1,13 +1,6 @@ import { Injectable, Logger } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' -import { - Application, - ApplicationPhase, - ApplicationState, - DatabasePhase, - DomainPhase, - StoragePhase, -} from '@prisma/client' +import { DomainPhase, StoragePhase } from '@prisma/client' import * as assert from 'node:assert' import { StorageService } from '../storage/storage.service' import { DatabaseService } from '../database/database.service' @@ -23,6 +16,12 @@ import { BundleService } from 'src/region/bundle.service' import { WebsiteService } from 'src/website/website.service' import { PolicyService } from 'src/database/policy/policy.service' import { BucketDomainService } from 'src/gateway/bucket-domain.service' +import { + Application, + ApplicationPhase, + ApplicationState, +} from './entities/application' +import { DatabasePhase } from 'src/database/entities/database' @Injectable() export class ApplicationTaskService { @@ -103,7 +102,11 @@ export class ApplicationTaskService { const namespace = await this.clusterService.getAppNamespace(region, appid) if (!namespace) { this.logger.debug(`Creating namespace for application ${appid}`) - await this.clusterService.createAppNamespace(region, appid, app.createdBy) + await this.clusterService.createAppNamespace( + region, + appid, + app.createdBy.toString(), + ) return await this.unlock(appid) } diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index cea4492c9b..ec288e5e54 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -7,6 +7,7 @@ import { UseGuards, Req, Logger, + Post, } from '@nestjs/common' import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' import { IRequest } from '../utils/interface' @@ -18,11 +19,12 @@ import { ApplicationService } from './application.service' import { FunctionService } from '../function/function.service' import { StorageService } from 'src/storage/storage.service' import { RegionService } from 'src/region/region.service' -import { - ApplicationPhase, - ApplicationState, - SubscriptionPhase, -} from '@prisma/client' +import { CreateApplicationDto } from './dto/create-application.dto' +import { AccountService } from 'src/account/account.service' +import { ApplicationPhase, ApplicationState } from './entities/application' +import { SystemDatabase } from 'src/database/system-database' +import { Runtime } from './entities/runtime' +import { ObjectId } from 'mongodb' @ApiTags('Application') @Controller('applications') @@ -35,8 +37,49 @@ export class ApplicationController { private readonly funcService: FunctionService, private readonly regionService: RegionService, private readonly storageService: StorageService, + private readonly accountService: AccountService, ) {} + /** + * Create application + */ + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Create application' }) + @Post() + async create(@Req() req: IRequest, @Body() dto: CreateApplicationDto) { + const user = req.user + + // check regionId exists + const region = await this.regionService.findOneDesensitized( + new ObjectId(dto.regionId), + ) + if (!region) { + return ResponseUtil.error(`region ${dto.regionId} not found`) + } + + // check runtimeId exists + const runtime = await SystemDatabase.db + .collection('Runtime') + .findOne({ _id: new ObjectId(dto.runtimeId) }) + if (!runtime) { + return ResponseUtil.error(`runtime ${dto.runtimeId} not found`) + } + + // check account balance + const account = await this.accountService.findOne(user.id) + const balance = account?.balance || 0 + if (balance <= 0) { + return ResponseUtil.error(`account balance is not enough`) + } + + // create application + const appid = await this.appService.tryGenerateUniqueAppid() + await this.appService.create(user.id, appid, dto) + + const app = await this.appService.findOne(appid) + return ResponseUtil.ok(app) + } + /** * Get user application list * @param req @@ -60,11 +103,7 @@ export class ApplicationController { @UseGuards(JwtAuthGuard, ApplicationAuthGuard) @Get(':appid') async findOne(@Param('appid') appid: string) { - const data = await this.appService.findOne(appid, { - configuration: true, - domain: true, - subscription: true, - }) + const data = await this.appService.findOne(appid) // SECURITY ALERT!!! // DO NOT response this region object to client since it contains sensitive information @@ -136,12 +175,7 @@ export class ApplicationController { } // check if the corresponding subscription status has expired - const app = await this.appService.findOne(appid, { - subscription: true, - }) - if (app.subscription.phase !== SubscriptionPhase.Valid) { - return ResponseUtil.error('subscription has expired, you can not update') - } + const app = await this.appService.findOne(appid) // check: only running application can restart if ( @@ -177,10 +211,10 @@ export class ApplicationController { } // update app - const res = await this.appService.update(appid, dto) - if (res === null) { + const doc = await this.appService.update(appid, dto) + if (!doc) { return ResponseUtil.error('update application error') } - return ResponseUtil.ok(res) + return ResponseUtil.ok(doc) } } diff --git a/server/src/application/application.module.ts b/server/src/application/application.module.ts index 292227ce85..4fb1a96d72 100644 --- a/server/src/application/application.module.ts +++ b/server/src/application/application.module.ts @@ -13,9 +13,10 @@ import { GatewayModule } from 'src/gateway/gateway.module' import { ApplicationConfigurationService } from './configuration.service' import { TriggerService } from 'src/trigger/trigger.service' import { WebsiteService } from 'src/website/website.service' +import { AccountModule } from 'src/account/account.module' @Module({ - imports: [StorageModule, DatabaseModule, GatewayModule], + imports: [StorageModule, DatabaseModule, GatewayModule, AccountModule], controllers: [ApplicationController, EnvironmentVariableController], providers: [ ApplicationService, diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index 0ac0c946dd..c3ead41e16 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -1,7 +1,5 @@ import { Injectable, Logger } from '@nestjs/common' import * as nanoid from 'nanoid' -import { ApplicationPhase, ApplicationState, Prisma } from '@prisma/client' -import { PrismaService } from '../prisma/prisma.service' import { UpdateApplicationDto } from './dto/update-application.dto' import { APPLICATION_SECRET_KEY, @@ -9,158 +7,264 @@ import { TASK_LOCK_INIT_TIME, } from '../constants' import { GenerateAlphaNumericPassword } from '../utils/random' -import { CreateSubscriptionDto } from 'src/subscription/dto/create-subscription.dto' +import { CreateApplicationDto } from './dto/create-application.dto' +import { SystemDatabase } from 'src/database/system-database' +import { + Application, + ApplicationPhase, + ApplicationState, + ApplicationWithRelations, +} from './entities/application' +import { ObjectId } from 'mongodb' +import { ApplicationConfiguration } from './entities/application-configuration' +import { + ApplicationBundle, + ApplicationBundleResource, +} from './entities/application-bundle' @Injectable() export class ApplicationService { private readonly logger = new Logger(ApplicationService.name) - constructor(private readonly prisma: PrismaService) {} - async create(userid: string, appid: string, dto: CreateSubscriptionDto) { - try { - // get bundle - const bundle = await this.prisma.bundle.findFirstOrThrow({ - where: { - id: dto.bundleId, - region: { - id: dto.regionId, - }, - }, - }) + /** + * Create application + * - create configuration + * - create bundle + * - create application + */ + async create(userid: string, appid: string, dto: CreateApplicationDto) { + const client = SystemDatabase.client + const db = client.db() + const session = client.startSession() - console.log(bundle, dto.bundleId) + try { + // start transaction + session.startTransaction() - // create app in db + // create application configuration const appSecret = { name: APPLICATION_SECRET_KEY, value: GenerateAlphaNumericPassword(64), } - - const data: Prisma.ApplicationCreateInput = { - name: dto.name, - state: dto.state || ApplicationState.Running, - phase: ApplicationPhase.Creating, - tags: [], - createdBy: userid, - lockedAt: TASK_LOCK_INIT_TIME, - region: { - connect: { - id: dto.regionId, - }, - }, - bundle: { - create: { - bundleId: bundle.id, - name: bundle.name, - displayName: bundle.displayName, - resource: { ...bundle.resource }, - }, - }, - runtime: { - connect: { - id: dto.runtimeId, - }, - }, - configuration: { - create: { + await db + .collection('ApplicationConfiguration') + .insertOne( + { + appid, environments: [appSecret], dependencies: [], + createdAt: new Date(), + updatedAt: new Date(), }, + { session }, + ) + + // create application bundle + await db.collection('ApplicationBundle').insertOne( + { + appid, + resource: this.buildBundleResource(dto), + createdAt: new Date(), + updatedAt: new Date(), }, - subscription: { - connect: { - appid, - }, - }, - } + { session }, + ) - const application = await this.prisma.application.create({ data }) - if (!application) { - throw new Error('create application failed') - } + // create application + await db.collection('Application').insertOne( + { + appid, + name: dto.name, + state: dto.state || ApplicationState.Running, + phase: ApplicationPhase.Creating, + tags: [], + createdBy: new ObjectId(userid), + lockedAt: TASK_LOCK_INIT_TIME, + regionId: new ObjectId(dto.regionId), + runtimeId: new ObjectId(dto.runtimeId), + createdAt: new Date(), + updatedAt: new Date(), + }, + { session }, + ) - return application + // commit transaction + await session.commitTransaction() } catch (error) { - this.logger.error(error, error.response?.body) - return null + await session.abortTransaction() + throw Error(error) + } finally { + if (session) await session.endSession() } } async findAllByUser(userid: string) { - return this.prisma.application.findMany({ - where: { - createdBy: userid, - phase: { - not: ApplicationPhase.Deleted, - }, - }, - include: { - region: false, - bundle: true, - runtime: true, - subscription: true, - }, - }) + const db = SystemDatabase.db + + const doc = await db + .collection('Application') + .aggregate() + .match({ + createdBy: new ObjectId(userid), + phase: { $ne: ApplicationPhase.Deleted }, + }) + .lookup({ + from: 'ApplicationBundle', + localField: 'appid', + foreignField: 'appid', + as: 'bundle', + }) + .unwind('$bundle') + .lookup({ + from: 'Runtime', + localField: 'runtimeId', + foreignField: '_id', + as: 'runtime', + }) + .unwind('$runtime') + .project({ + 'bundle.resource.requestCPU': 0, + 'bundle.resource.requestMemory': 0, + }) + .toArray() + + return doc } - async findOne(appid: string, include?: Prisma.ApplicationInclude) { - const application = await this.prisma.application.findUnique({ - where: { appid }, - include: { - region: false, - bundle: include?.bundle, - runtime: include?.runtime, - configuration: include?.configuration, - domain: include?.domain, - subscription: include?.subscription, - }, - }) + async findOne(appid: string) { + const db = SystemDatabase.db - return application + const doc = await db + .collection('Application') + .aggregate() + .match({ appid }) + .lookup({ + from: 'ApplicationBundle', + localField: 'appid', + foreignField: 'appid', + as: 'bundle', + }) + .unwind('$bundle') + .lookup({ + from: 'Runtime', + localField: 'runtimeId', + foreignField: '_id', + as: 'runtime', + }) + .unwind('$runtime') + .lookup({ + from: 'ApplicationConfiguration', + localField: 'appid', + foreignField: 'appid', + as: 'configuration', + }) + .unwind('$configuration') + .lookup({ + from: 'RuntimeDomain', + localField: 'appid', + foreignField: 'appid', + as: 'domain', + }) + .unwind({ path: '$domain', preserveNullAndEmptyArrays: true }) + .project({ + 'bundle.resource.requestCPU': 0, + 'bundle.resource.requestMemory': 0, + }) + .next() + + return doc } - async update(appid: string, dto: UpdateApplicationDto) { - try { - // update app in db - const data: Prisma.ApplicationUpdateInput = { - updatedAt: new Date(), - } - if (dto.name) { - data.name = dto.name - } - if (dto.state) { - data.state = dto.state - } + async findOneUnsafe(appid: string) { + const db = SystemDatabase.db - const application = await this.prisma.application.updateMany({ - where: { - appid, - phase: { - notIn: [ApplicationPhase.Deleting, ApplicationPhase.Deleted], - }, - }, - data, + const doc = await db + .collection('Application') + .aggregate() + .match({ appid }) + .lookup({ + from: 'Region', + localField: 'regionId', + foreignField: '_id', + as: 'region', + }) + .unwind('$region') + .lookup({ + from: 'ApplicationBundle', + localField: 'appid', + foreignField: 'appid', + as: 'bundle', + }) + .unwind('$bundle') + .lookup({ + from: 'Runtime', + localField: 'runtimeId', + foreignField: '_id', + as: 'runtime', + }) + .unwind('$runtime') + .lookup({ + from: 'ApplicationConfiguration', + localField: 'appid', + foreignField: 'appid', + as: 'configuration', }) + .unwind('$configuration') + .lookup({ + from: 'RuntimeDomain', + localField: 'appid', + foreignField: 'appid', + as: 'domain', + }) + .unwind({ path: '$domain', preserveNullAndEmptyArrays: true }) + .next() - return application - } catch (error) { - this.logger.error(error, error.response?.body) - return null - } + return doc + } + + async update(appid: string, dto: UpdateApplicationDto) { + const db = SystemDatabase.db + const data: Partial = { updatedAt: new Date() } + + if (dto.name) data.name = dto.name + if (dto.state) data.state = dto.state + + const doc = await db + .collection('Application') + .findOneAndUpdate({ appid }, { $set: data }) + + return doc } async remove(appid: string) { - try { - const res = await this.prisma.application.updateMany({ - where: { appid }, - data: { state: ApplicationState.Deleted }, - }) + const db = SystemDatabase.db + const doc = await db + .collection('Application') + .findOneAndUpdate( + { appid }, + { $set: { phase: ApplicationPhase.Deleted } }, + ) - return res - } catch (error) { - this.logger.error(error, error.response?.body) - return null + return doc.value + } + + /** + * Generate unique application id + * @returns + */ + async tryGenerateUniqueAppid() { + const db = SystemDatabase.db + + for (let i = 0; i < 10; i++) { + const appid = this.generateAppID(ServerConfig.APPID_LENGTH) + const existed = await db + .collection('Application') + .findOne({ appid }) + + if (!existed) return appid } + + throw new Error('Generate appid failed') } private generateAppID(len: number) { @@ -174,22 +278,38 @@ export class ApplicationService { return prefix + nano() } - /** - * Generate unique application id - * @returns - */ - async tryGenerateUniqueAppid() { - for (let i = 0; i < 10; i++) { - const appid = this.generateAppID(ServerConfig.APPID_LENGTH) - const existed = await this.prisma.application.findUnique({ - where: { appid }, - select: { appid: true }, - }) - if (!existed) { - return appid - } - } + private buildBundleResource(dto: CreateApplicationDto) { + const requestCPU = Math.floor(dto.cpu * 0.1) + const requestMemory = Math.floor(dto.memory * 0.5) + const limitCountOfCloudFunction = Math.floor(dto.cpu * 1) - throw new Error('Generate appid failed') + const magicNumber = Math.floor(dto.cpu * 0.01) + const limitCountOfBucket = Math.max(3, magicNumber) + const limitCountOfDatabasePolicy = Math.max(3, magicNumber) + const limitCountOfTrigger = Math.max(1, magicNumber) + const limitCountOfWebsiteHosting = Math.max(3, magicNumber) + const limitDatabaseTPS = Math.floor(dto.cpu * 0.1) + const limitStorageTPS = Math.floor(dto.cpu * 1) + const reservedTimeAfterExpired = 60 * 60 * 24 * 31 // 31 days + + const resource = new ApplicationBundleResource({ + limitCPU: dto.cpu, + limitMemory: dto.memory, + requestCPU, + requestMemory, + databaseCapacity: dto.databaseCapacity, + storageCapacity: dto.storageCapacity, + + limitCountOfCloudFunction, + limitCountOfBucket, + limitCountOfDatabasePolicy, + limitCountOfTrigger, + limitCountOfWebsiteHosting, + limitDatabaseTPS, + limitStorageTPS, + reservedTimeAfterExpired, + }) + + return resource } } diff --git a/server/src/application/configuration.service.ts b/server/src/application/configuration.service.ts index cc7974b3de..5d35dc53e3 100644 --- a/server/src/application/configuration.service.ts +++ b/server/src/application/configuration.service.ts @@ -1,24 +1,26 @@ import { Injectable, Logger } from '@nestjs/common' -import { ApplicationConfiguration } from '@prisma/client' import { CN_PUBLISHED_CONF } from 'src/constants' import { DatabaseService } from 'src/database/database.service' -import { PrismaService } from 'src/prisma/prisma.service' +import { SystemDatabase } from 'src/database/system-database' +import { ApplicationConfiguration } from './entities/application-configuration' @Injectable() export class ApplicationConfigurationService { + private readonly db = SystemDatabase.db private readonly logger = new Logger(ApplicationConfigurationService.name) - constructor( - private readonly prisma: PrismaService, - private readonly databaseService: DatabaseService, - ) {} + constructor(private readonly databaseService: DatabaseService) {} async count(appid: string) { - return this.prisma.applicationConfiguration.count({ where: { appid } }) + return this.db + .collection('ApplicationConfiguration') + .countDocuments({ appid }) } async remove(appid: string) { - return this.prisma.applicationConfiguration.delete({ where: { appid } }) + return this.db + .collection('ApplicationConfiguration') + .deleteOne({ appid }) } async publish(conf: ApplicationConfiguration) { @@ -31,6 +33,7 @@ export class ApplicationConfigurationService { await coll.insertOne(conf, { session }) }) } finally { + await session.endSession() await client.close() } } diff --git a/server/src/application/dto/create-application.dto.ts b/server/src/application/dto/create-application.dto.ts index 7a3237df17..e1bbb73677 100644 --- a/server/src/application/dto/create-application.dto.ts +++ b/server/src/application/dto/create-application.dto.ts @@ -1,8 +1,9 @@ -import { ApiPropertyOptional } from '@nestjs/swagger' -import { ApplicationState } from '@prisma/client' -import { IsIn, IsString, Length } from 'class-validator' +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { IsIn, IsInt, IsNotEmpty, IsString, Length } from 'class-validator' +import { ApplicationState } from '../entities/application' + +const STATES = [ApplicationState.Running] -const STATES = [ApplicationState.Running, ApplicationState.Stopped] export class CreateApplicationDto { /** * Application name @@ -13,11 +14,43 @@ export class CreateApplicationDto { name?: string @ApiPropertyOptional({ - enum: ApplicationState, + default: ApplicationState.Running, + enum: STATES, }) @IsIn(STATES) state?: ApplicationState + @ApiProperty() + @IsNotEmpty() + @IsString() + regionId: string + + @ApiProperty() + @IsNotEmpty() + @IsString() + runtimeId: string + + // build resources + @ApiProperty({ example: 200 }) + @IsNotEmpty() + @IsInt() + cpu: number + + @ApiProperty({ example: 256 }) + @IsNotEmpty() + @IsInt() + memory: number + + @ApiProperty({ example: 2048 }) + @IsNotEmpty() + @IsInt() + databaseCapacity: number + + @ApiProperty({ example: 4096 }) + @IsNotEmpty() + @IsInt() + storageCapacity: number + validate() { return null } diff --git a/server/src/application/dto/update-application.dto.ts b/server/src/application/dto/update-application.dto.ts index 8fd34c1821..8136ac3c68 100644 --- a/server/src/application/dto/update-application.dto.ts +++ b/server/src/application/dto/update-application.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger' -import { ApplicationState } from '@prisma/client' import { IsIn, IsString, Length } from 'class-validator' +import { ApplicationState } from '../entities/application' const STATES = [ ApplicationState.Running, @@ -8,9 +8,6 @@ const STATES = [ ApplicationState.Restarting, ] export class UpdateApplicationDto { - /** - * Application name - */ @ApiPropertyOptional() @IsString() @Length(1, 64) diff --git a/server/src/application/entities/application-bundle.ts b/server/src/application/entities/application-bundle.ts new file mode 100644 index 0000000000..ac4770ddaa --- /dev/null +++ b/server/src/application/entities/application-bundle.ts @@ -0,0 +1,34 @@ +import { ObjectId } from 'mongodb' + +export class ApplicationBundle { + _id?: ObjectId + appid: string + resource: ApplicationBundleResource + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} + +export class ApplicationBundleResource { + limitCPU: number + limitMemory: number + requestCPU: number + requestMemory: number + databaseCapacity: number + storageCapacity: number + limitCountOfCloudFunction: number + limitCountOfBucket: number + limitCountOfDatabasePolicy: number + limitCountOfTrigger: number + limitCountOfWebsiteHosting: number + reservedTimeAfterExpired: number + limitDatabaseTPS: number + limitStorageTPS: number + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/application/entities/application-configuration.ts b/server/src/application/entities/application-configuration.ts new file mode 100644 index 0000000000..d8dfd31857 --- /dev/null +++ b/server/src/application/entities/application-configuration.ts @@ -0,0 +1,15 @@ +import { ObjectId } from 'mongodb' + +export type EnvironmentVariable = { + name: string + value: string +} + +export class ApplicationConfiguration { + _id?: ObjectId + appid: string + environments: EnvironmentVariable[] + dependencies: string[] + createdAt: Date + updatedAt: Date +} diff --git a/server/src/application/entities/application.ts b/server/src/application/entities/application.ts new file mode 100644 index 0000000000..55078d5c71 --- /dev/null +++ b/server/src/application/entities/application.ts @@ -0,0 +1,49 @@ +import { ObjectId } from 'mongodb' +import { Region } from 'src/region/entities/region' +import { ApplicationBundle } from './application-bundle' +import { Runtime } from './runtime' +import { ApplicationConfiguration } from './application-configuration' + +export enum ApplicationPhase { + Creating = 'Creating', + Created = 'Created', + Starting = 'Starting', + Started = 'Started', + Stopping = 'Stopping', + Stopped = 'Stopped', + Deleting = 'Deleting', + Deleted = 'Deleted', +} + +export enum ApplicationState { + Running = 'Running', + Stopped = 'Stopped', + Restarting = 'Restarting', + Deleted = 'Deleted', +} + +export class Application { + _id?: ObjectId + name: string + appid: string + regionId: ObjectId + runtimeId: ObjectId + tags: string[] + state: ApplicationState + phase: ApplicationPhase + createdAt: Date + updatedAt: Date + lockedAt: Date + createdBy: ObjectId + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} + +export interface ApplicationWithRelations extends Application { + region?: Region + bundle?: ApplicationBundle + runtime?: Runtime + configuration?: ApplicationConfiguration +} diff --git a/server/src/application/entities/runtime.ts b/server/src/application/entities/runtime.ts new file mode 100644 index 0000000000..c160b3deb6 --- /dev/null +++ b/server/src/application/entities/runtime.ts @@ -0,0 +1,21 @@ +import { ObjectId } from 'mongodb' + +export type RuntimeImageGroup = { + main: string + init: string | null + sidecar: string | null +} + +export class Runtime { + _id?: ObjectId + name: string + type: string + image: RuntimeImageGroup + state: string + version: string + latest: boolean + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/application/environment.service.ts b/server/src/application/environment.service.ts index d837e0cf21..5f0f29738a 100644 --- a/server/src/application/environment.service.ts +++ b/server/src/application/environment.service.ts @@ -1,25 +1,25 @@ import { Injectable, Logger } from '@nestjs/common' -import { PrismaService } from 'src/prisma/prisma.service' import { CreateEnvironmentDto } from './dto/create-env.dto' import { ApplicationConfigurationService } from './configuration.service' +import { SystemDatabase } from 'src/database/system-database' +import { ApplicationConfiguration } from './entities/application-configuration' +import * as assert from 'node:assert' @Injectable() export class EnvironmentVariableService { + private readonly db = SystemDatabase.db private readonly logger = new Logger(EnvironmentVariableService.name) - constructor( - private readonly prisma: PrismaService, - private readonly confService: ApplicationConfigurationService, - ) {} + constructor(private readonly confService: ApplicationConfigurationService) {} async updateAll(appid: string, dto: CreateEnvironmentDto[]) { - const res = await this.prisma.applicationConfiguration.update({ - where: { appid }, - data: { environments: { set: dto } }, - }) + const res = await this.db + .collection('ApplicationConfiguration') + .findOneAndUpdate({ appid }, { $set: { environments: dto } }) - await this.confService.publish(res) - return res.environments + assert(res?.value, 'application configuration not found') + await this.confService.publish(res.value) + return res.value.environments } /** @@ -37,30 +37,30 @@ export class EnvironmentVariableService { origin.push(dto) } - const res = await this.prisma.applicationConfiguration.update({ - where: { appid }, - data: { environments: { set: origin } }, - }) + const res = await this.db + .collection('ApplicationConfiguration') + .findOneAndUpdate({ appid }, { $set: { environments: origin } }) - await this.confService.publish(res) - return res.environments + assert(res?.value, 'application configuration not found') + await this.confService.publish(res.value) + return res.value.environments } async findAll(appid: string) { - const res = await this.prisma.applicationConfiguration.findUnique({ - where: { appid }, - }) + const doc = await this.db + .collection('ApplicationConfiguration') + .findOne({ appid }) - return res.environments + return doc.environments } async deleteOne(appid: string, name: string) { - const res = await this.prisma.applicationConfiguration.update({ - where: { appid }, - data: { environments: { deleteMany: { where: { name } } } }, - }) + const res = await this.db + .collection('ApplicationConfiguration') + .findOneAndUpdate({ appid }, { $pull: { environments: { name } } }) - await this.confService.publish(res) - return res + assert(res?.value, 'application configuration not found') + await this.confService.publish(res.value) + return res.value.environments } } diff --git a/server/src/database/collection/collection.controller.ts b/server/src/database/collection/collection.controller.ts index 6778e158d7..3191761a8c 100644 --- a/server/src/database/collection/collection.controller.ts +++ b/server/src/database/collection/collection.controller.ts @@ -21,7 +21,7 @@ import { ApiResponseUtil, ResponseUtil } from '../../utils/response' import { CollectionService } from './collection.service' import { CreateCollectionDto } from '../dto/create-collection.dto' import { UpdateCollectionDto } from '../dto/update-collection.dto' -import { Collection } from '../entities/collection.entity' +import { Collection } from '../entities/collection' @ApiTags('Database') @ApiBearerAuth('Authorization') diff --git a/server/src/database/database.service.ts b/server/src/database/database.service.ts index 72c24924e2..c86b9db168 100644 --- a/server/src/database/database.service.ts +++ b/server/src/database/database.service.ts @@ -1,21 +1,22 @@ import { Injectable, Logger } from '@nestjs/common' import * as assert from 'node:assert' import { MongoAccessor } from 'database-proxy' -import { PrismaService } from '../prisma/prisma.service' -import { Database, DatabasePhase, DatabaseState, Region } from '@prisma/client' import { GenerateAlphaNumericPassword } from 'src/utils/random' import { MongoService } from './mongo.service' import * as mongodb_uri from 'mongodb-uri' import { RegionService } from 'src/region/region.service' import { TASK_LOCK_INIT_TIME } from 'src/constants' +import { Region } from 'src/region/entities/region' +import { SystemDatabase } from './system-database' +import { Database, DatabasePhase, DatabaseState } from './entities/database' @Injectable() export class DatabaseService { + private readonly db = SystemDatabase.db private readonly logger = new Logger(DatabaseService.name) constructor( private readonly mongoService: MongoService, - private readonly prisma: PrismaService, private readonly regionService: RegionService, ) {} @@ -34,29 +35,29 @@ export class DatabaseService { password, ) - this.logger.debug('Create database result: ', res) assert.equal(res.ok, 1, 'Create app database failed: ' + appid) // create app database in database - const database = await this.prisma.database.create({ - data: { - appid: appid, - name: dbName, - user: username, - password: password, - state: DatabaseState.Active, - phase: DatabasePhase.Created, - lockedAt: TASK_LOCK_INIT_TIME, - }, + await this.db.collection('Database').insertOne({ + appid: appid, + name: dbName, + user: username, + password: password, + state: DatabaseState.Active, + phase: DatabasePhase.Created, + lockedAt: TASK_LOCK_INIT_TIME, + createdAt: new Date(), + updatedAt: new Date(), }) + const database = await this.findOne(appid) return database } async findOne(appid: string) { - const database = await this.prisma.database.findUnique({ - where: { appid }, - }) + const database = await this.db + .collection('Database') + .findOne({ appid }) return database } @@ -69,9 +70,9 @@ export class DatabaseService { if (!res) return false // delete app database in database - const doc = await this.prisma.database.delete({ - where: { appid: database.appid }, - }) + const doc = await this.db + .collection('Database') + .deleteOne({ appid: database.appid }) return doc } diff --git a/server/src/database/entities/collection.entity.ts b/server/src/database/entities/collection.ts similarity index 100% rename from server/src/database/entities/collection.entity.ts rename to server/src/database/entities/collection.ts diff --git a/server/src/database/entities/database-policy.ts b/server/src/database/entities/database-policy.ts new file mode 100644 index 0000000000..c554e9ca9b --- /dev/null +++ b/server/src/database/entities/database-policy.ts @@ -0,0 +1,32 @@ +import { ObjectId } from 'mongodb' + +export class DatabasePolicy { + _id?: ObjectId + appid: string + name: string + injector?: string + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} + +export class DatabasePolicyRule { + _id?: ObjectId + appid: string + policyName: string + collectionName: string + value: any + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} + +export type DatabasePolicyWithRules = DatabasePolicy & { + rules: DatabasePolicyRule[] +} diff --git a/server/src/database/entities/database.ts b/server/src/database/entities/database.ts new file mode 100644 index 0000000000..048b8dc608 --- /dev/null +++ b/server/src/database/entities/database.ts @@ -0,0 +1,31 @@ +import { ObjectId } from 'mongodb' + +export enum DatabasePhase { + Creating = 'Creating', + Created = 'Created', + Deleting = 'Deleting', + Deleted = 'Deleted', +} + +export enum DatabaseState { + Active = 'Active', + Inactive = 'Inactive', + Deleted = 'Deleted', +} + +export class Database { + _id?: ObjectId + appid: string + name: string + user: string + password: string + state: DatabaseState + phase: DatabasePhase + lockedAt: Date + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/database/entities/policy.entity.ts b/server/src/database/entities/policy.entity.ts deleted file mode 100644 index 4a4ebf70c2..0000000000 --- a/server/src/database/entities/policy.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export class Policy {} diff --git a/server/src/database/mongo.service.ts b/server/src/database/mongo.service.ts index ec829bff75..be658d9887 100644 --- a/server/src/database/mongo.service.ts +++ b/server/src/database/mongo.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common' -import { Region } from '@prisma/client' import { MongoClient } from 'mongodb' import * as assert from 'node:assert' +import { Region } from 'src/region/entities/region' @Injectable() export class MongoService { diff --git a/server/src/database/policy/policy-rule.controller.ts b/server/src/database/policy/policy-rule.controller.ts index b877266341..d3b9326ec5 100644 --- a/server/src/database/policy/policy-rule.controller.ts +++ b/server/src/database/policy/policy-rule.controller.ts @@ -89,7 +89,7 @@ export class PolicyRuleController { return ResponseUtil.error('rule not found') } - const res = await this.ruleService.update( + const res = await this.ruleService.updateOne( appid, policyName, collectionName, @@ -117,7 +117,11 @@ export class PolicyRuleController { return ResponseUtil.error('rule not found') } - const res = await this.ruleService.remove(appid, policyName, collectionName) + const res = await this.ruleService.removeOne( + appid, + policyName, + collectionName, + ) return ResponseUtil.ok(res) } } diff --git a/server/src/database/policy/policy-rule.service.ts b/server/src/database/policy/policy-rule.service.ts index 356fa65a90..850774f9bc 100644 --- a/server/src/database/policy/policy-rule.service.ts +++ b/server/src/database/policy/policy-rule.service.ts @@ -1,115 +1,86 @@ import { Injectable } from '@nestjs/common' import * as assert from 'node:assert' -import { PrismaService } from 'src/prisma/prisma.service' import { CreatePolicyRuleDto } from '../dto/create-rule.dto' import { UpdatePolicyRuleDto } from '../dto/update-rule.dto' import { PolicyService } from './policy.service' +import { SystemDatabase } from '../system-database' +import { DatabasePolicyRule } from '../entities/database-policy' @Injectable() export class PolicyRuleService { - constructor( - private readonly prisma: PrismaService, - private readonly policyService: PolicyService, - ) {} + private readonly db = SystemDatabase.db + constructor(private readonly policyService: PolicyService) {} async create(appid: string, policyName: string, dto: CreatePolicyRuleDto) { - const res = await this.prisma.databasePolicyRule.create({ - data: { - policy: { - connect: { - appid_name: { - appid, - name: policyName, - }, - }, - }, + await this.db + .collection('DatabasePolicyRule') + .insertOne({ + appid, + policyName, collectionName: dto.collectionName, value: JSON.parse(dto.value), - }, - }) + createdAt: new Date(), + updatedAt: new Date(), + }) const policy = await this.policyService.findOne(appid, policyName) assert(policy, 'policy not found') await this.policyService.publish(policy) - return res + return policy } async count(appid: string, policyName: string) { - const res = await this.prisma.databasePolicyRule.count({ - where: { - policy: { - appid, - name: policyName, - }, - }, - }) + const res = await this.db + .collection('DatabasePolicyRule') + .countDocuments({ appid, policyName }) + return res } async findAll(appid: string, policyName: string) { - const res = await this.prisma.databasePolicyRule.findMany({ - where: { - policy: { - appid, - name: policyName, - }, - }, - }) + const res = await this.db + .collection('DatabasePolicyRule') + .find({ appid, policyName }) + .toArray() + return res } async findOne(appid: string, policyName: string, collectionName: string) { - const res = await this.prisma.databasePolicyRule.findUnique({ - where: { - appid_policyName_collectionName: { - appid, - policyName, - collectionName, - }, - }, - }) - return res + const doc = await this.db + .collection('DatabasePolicyRule') + .findOne({ appid, policyName, collectionName }) + + return doc } - async update( + async updateOne( appid: string, policyName: string, collectionName: string, dto: UpdatePolicyRuleDto, ) { - const res = await this.prisma.databasePolicyRule.update({ - where: { - appid_policyName_collectionName: { - appid, - policyName, - collectionName: collectionName, - }, - }, - data: { - value: JSON.parse(dto.value), - }, - }) + await this.db + .collection('DatabasePolicyRule') + .findOneAndUpdate( + { appid, policyName, collectionName }, + { $set: { value: JSON.parse(dto.value), updatedAt: new Date() } }, + ) const policy = await this.policyService.findOne(appid, policyName) assert(policy, 'policy not found') await this.policyService.publish(policy) - return res + return policy } - async remove(appid: string, policyName: string, collectionName: string) { - const res = await this.prisma.databasePolicyRule.delete({ - where: { - appid_policyName_collectionName: { - appid, - policyName, - collectionName, - }, - }, - }) + async removeOne(appid: string, policyName: string, collectionName: string) { + await this.db + .collection('DatabasePolicyRule') + .deleteOne({ appid, policyName, collectionName }) const policy = await this.policyService.findOne(appid, policyName) assert(policy, 'policy not found') await this.policyService.publish(policy) - return res + return policy } } diff --git a/server/src/database/policy/policy.controller.ts b/server/src/database/policy/policy.controller.ts index 1c80e92599..2da6fd0db0 100644 --- a/server/src/database/policy/policy.controller.ts +++ b/server/src/database/policy/policy.controller.ts @@ -76,7 +76,7 @@ export class PolicyController { if (!existed) { return ResponseUtil.error('Policy not found') } - const res = await this.policiesService.update(appid, name, dto) + const res = await this.policiesService.updateOne(appid, name, dto) return ResponseUtil.ok(res) } @@ -91,7 +91,7 @@ export class PolicyController { return ResponseUtil.error('Policy not found') } - const res = await this.policiesService.remove(appid, name) + const res = await this.policiesService.removeOne(appid, name) return ResponseUtil.ok(res) } } diff --git a/server/src/database/policy/policy.service.ts b/server/src/database/policy/policy.service.ts index b35ee25f78..84d62b3c47 100644 --- a/server/src/database/policy/policy.service.ts +++ b/server/src/database/policy/policy.service.ts @@ -1,119 +1,142 @@ -import { Injectable } from '@nestjs/common' -import { DatabasePolicy, DatabasePolicyRule } from '@prisma/client' +import { Injectable, Logger } from '@nestjs/common' import { CN_PUBLISHED_POLICIES } from 'src/constants' -import { PrismaService } from 'src/prisma/prisma.service' import { DatabaseService } from '../database.service' import { CreatePolicyDto } from '../dto/create-policy.dto' import { UpdatePolicyDto } from '../dto/update-policy.dto' +import { SystemDatabase } from '../system-database' +import { + DatabasePolicy, + DatabasePolicyRule, + DatabasePolicyWithRules, +} from '../entities/database-policy' @Injectable() export class PolicyService { - constructor( - private readonly prisma: PrismaService, - private readonly databaseService: DatabaseService, - ) {} + private readonly logger = new Logger(PolicyService.name) + private readonly db = SystemDatabase.db + + constructor(private readonly databaseService: DatabaseService) {} async create(appid: string, createPolicyDto: CreatePolicyDto) { - const res = await this.prisma.databasePolicy.create({ - data: { - appid, - name: createPolicyDto.name, - }, - include: { - rules: true, - }, + await this.db.collection('DatabasePolicy').insertOne({ + appid, + name: createPolicyDto.name, + createdAt: new Date(), + updatedAt: new Date(), }) - await this.publish(res) - return res + const doc = await this.findOne(appid, createPolicyDto.name) + + await this.publish(doc) + return doc } async count(appid: string) { - const res = await this.prisma.databasePolicy.count({ - where: { - appid, - }, - }) + const res = await this.db + .collection('DatabasePolicy') + .countDocuments({ appid }) + return res } async findAll(appid: string) { - const res = await this.prisma.databasePolicy.findMany({ - where: { - appid, - }, - include: { - rules: true, - }, - }) + const res = await this.db + .collection('DatabasePolicy') + .aggregate() + .match({ appid }) + .lookup({ + from: 'DatabasePolicyRule', + let: { name: '$name', appid: '$appid' }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ['$appid', '$$appid'] }, + { $eq: ['$policyName', '$$name'] }, + ], + }, + }, + }, + ], + as: 'rules', + }) + .toArray() + return res } async findOne(appid: string, name: string) { - const res = await this.prisma.databasePolicy.findUnique({ - where: { - appid_name: { - appid, - name, - }, - }, - include: { - rules: true, - }, - }) - return res + const policy = await this.db + .collection('DatabasePolicy') + .findOne({ appid, name }) + + if (!policy) { + return null + } + + const rules = await this.db + .collection('DatabasePolicyRule') + .find({ appid, policyName: name }) + .toArray() + + return { + ...policy, + rules, + } as DatabasePolicyWithRules } - async update(appid: string, name: string, dto: UpdatePolicyDto) { - const res = await this.prisma.databasePolicy.update({ - where: { - appid_name: { - appid, - name, - }, - }, - data: { - injector: dto.injector, - }, - include: { - rules: true, - }, - }) + async updateOne(appid: string, name: string, dto: UpdatePolicyDto) { + await this.db + .collection('DatabasePolicy') + .findOneAndUpdate( + { appid, name }, + { $set: { injector: dto.injector, updatedAt: new Date() } }, + ) - await this.publish(res) - return res + const doc = await this.findOne(appid, name) + await this.publish(doc) + return doc } - async remove(appid: string, name: string) { - const res = await this.prisma.databasePolicy.delete({ - where: { - appid_name: { - appid, - name, - }, - }, - include: { - rules: true, - }, - }) - await this.unpublish(appid, name) - return res + async removeOne(appid: string, name: string) { + const client = SystemDatabase.client + const session = client.startSession() + + try { + await session.withTransaction(async () => { + await this.db + .collection('DatabasePolicy') + .deleteOne({ appid, name }, { session }) + + await this.db + .collection('DatabasePolicyRule') + .deleteMany({ appid, policyName: name }, { session }) + + await this.unpublish(appid, name) + }) + } finally { + await session.endSession() + } } async removeAll(appid: string) { - // delete rules first - await this.prisma.databasePolicyRule.deleteMany({ - where: { - appid, - }, - }) + const client = SystemDatabase.client + const session = client.startSession() - const res = await this.prisma.databasePolicy.deleteMany({ - where: { - appid, - }, - }) - return res + try { + await session.withTransaction(async () => { + await this.db + .collection('DatabasePolicy') + .deleteMany({ appid }, { session }) + + await this.db + .collection('DatabasePolicyRule') + .deleteMany({ appid }, { session }) + }) + } finally { + await session.endSession() + } } async publish(policy: DatabasePolicy & { rules: DatabasePolicyRule[] }) { diff --git a/server/src/gateway/apisix-custom-cert.service.ts b/server/src/gateway/apisix-custom-cert.service.ts index cf067f4c11..bb905d6ba9 100644 --- a/server/src/gateway/apisix-custom-cert.service.ts +++ b/server/src/gateway/apisix-custom-cert.service.ts @@ -1,7 +1,8 @@ import { Injectable, Logger } from '@nestjs/common' -import { Region, WebsiteHosting } from '@prisma/client' +import { WebsiteHosting } from '@prisma/client' import { LABEL_KEY_APP_ID, ServerConfig } from 'src/constants' import { ClusterService } from 'src/region/cluster/cluster.service' +import { Region } from 'src/region/entities/region' import { GetApplicationNamespaceByAppId } from 'src/utils/getter' // This class handles the creation and deletion of website domain certificates diff --git a/server/src/gateway/apisix.service.ts b/server/src/gateway/apisix.service.ts index 19ef5a0455..c03da643b4 100644 --- a/server/src/gateway/apisix.service.ts +++ b/server/src/gateway/apisix.service.ts @@ -1,7 +1,8 @@ import { HttpService } from '@nestjs/axios' import { Injectable, Logger } from '@nestjs/common' -import { Region, WebsiteHosting } from '@prisma/client' +import { WebsiteHosting } from '@prisma/client' import { GetApplicationNamespaceByAppId } from '../utils/getter' +import { Region } from 'src/region/entities/region' @Injectable() export class ApisixService { diff --git a/server/src/instance/instance.service.ts b/server/src/instance/instance.service.ts index e283332f02..d8adb5a28e 100644 --- a/server/src/instance/instance.service.ts +++ b/server/src/instance/instance.service.ts @@ -16,10 +16,10 @@ import { Application, ApplicationBundle, ApplicationConfiguration, - Region, Runtime, } from '@prisma/client' import { RegionService } from 'src/region/region.service' +import { Region } from 'src/region/entities/region' type ApplicationWithRegion = Application & { region: Region } diff --git a/server/src/region/cluster/cluster.service.ts b/server/src/region/cluster/cluster.service.ts index 4653e0c8d4..92f02debbb 100644 --- a/server/src/region/cluster/cluster.service.ts +++ b/server/src/region/cluster/cluster.service.ts @@ -1,7 +1,6 @@ import { Injectable, Logger } from '@nestjs/common' import { KubernetesObject } from '@kubernetes/client-node' import * as k8s from '@kubernetes/client-node' -import { Region } from '@prisma/client' import { GetApplicationNamespaceByAppId } from 'src/utils/getter' import { compare } from 'fast-json-patch' import { GroupVersionKind } from 'src/region/cluster/types' @@ -10,6 +9,7 @@ import { LABEL_KEY_NAMESPACE_TYPE, LABEL_KEY_USER_ID, } from 'src/constants' +import { Region } from '../entities/region' @Injectable() export class ClusterService { diff --git a/server/src/region/entities/region.ts b/server/src/region/entities/region.ts new file mode 100644 index 0000000000..17399db420 --- /dev/null +++ b/server/src/region/entities/region.ts @@ -0,0 +1,50 @@ +import { ObjectId } from 'mongodb' + +export type RegionClusterConf = { + driver: string + kubeconfig: string | null + npmInstallFlags: string +} + +export type RegionDatabaseConf = { + driver: string + connectionUri: string + controlConnectionUri: string +} + +export type RegionGatewayConf = { + driver: string + runtimeDomain: string + websiteDomain: string + port: number + apiUrl: string + apiKey: string +} + +export type RegionStorageConf = { + driver: string + domain: string + externalEndpoint: string + internalEndpoint: string + accessKey: string + secretKey: string + controlEndpoint: string +} + +export class Region { + _id?: ObjectId + name: string + displayName: string + clusterConf: RegionClusterConf + databaseConf: RegionDatabaseConf + gatewayConf: RegionGatewayConf + storageConf: RegionStorageConf + tls: boolean + state: string + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/region/region.service.ts b/server/src/region/region.service.ts index e027ff8bb9..fc1b6ed578 100644 --- a/server/src/region/region.service.ts +++ b/server/src/region/region.service.ts @@ -1,81 +1,66 @@ import { Injectable } from '@nestjs/common' import { PrismaService } from '../prisma/prisma.service' +import { SystemDatabase } from 'src/database/system-database' +import { Region } from './entities/region' +import { Application } from 'src/application/entities/application' +import { assert } from 'console' +import { ObjectId } from 'mongodb' @Injectable() export class RegionService { + private readonly db = SystemDatabase.db constructor(private readonly prisma: PrismaService) {} async findByAppId(appid: string) { - const app = await this.prisma.application.findUnique({ - where: { appid }, - select: { - region: true, - }, - }) + const app = await this.db + .collection('Application') + .findOne({ appid }) - return app.region - } + assert(app, `Application ${appid} not found`) + const doc = await this.db + .collection('Region') + .findOne({ _id: app.regionId }) - async findOne(id: string) { - const region = await this.prisma.region.findUnique({ - where: { id }, - }) + return doc + } - return region + async findOne(id: ObjectId) { + const doc = await this.db.collection('Region').findOne({ _id: id }) + return doc } async findAll() { - const regions = await this.prisma.region.findMany() + const regions = await this.db.collection('Region').find().toArray() return regions } - async findOneDesensitized(id: string) { - const region = await this.prisma.region.findUnique({ - where: { id }, - select: { - id: true, - name: true, - displayName: true, - state: true, - storageConf: false, - gatewayConf: false, - databaseConf: false, - clusterConf: false, - createdAt: false, - updatedAt: false, - }, - }) + async findOneDesensitized(id: ObjectId) { + const projection = { + _id: 1, + name: 1, + displayName: 1, + state: 1, + } + + const region = await this.db + .collection('Region') + .findOne({ _id: new ObjectId(id) }, { projection }) return region } async findAllDesensitized() { - const regions = await this.prisma.region.findMany({ - select: { - id: true, - name: true, - displayName: true, - state: true, - storageConf: false, - gatewayConf: false, - databaseConf: false, - clusterConf: false, - notes: true, - bundles: { - select: { - id: true, - name: true, - displayName: true, - priority: true, - state: true, - resource: true, - limitCountPerUser: true, - subscriptionOptions: true, - notes: true, - }, - }, - }, - }) + const projection = { + _id: 1, + name: 1, + displayName: 1, + state: 1, + } + + const regions = await this.db + .collection('Region') + .find({}, { projection }) + .toArray() return regions } diff --git a/server/src/storage/bucket.service.ts b/server/src/storage/bucket.service.ts index f0eccd64a3..9dd5766426 100644 --- a/server/src/storage/bucket.service.ts +++ b/server/src/storage/bucket.service.ts @@ -1,16 +1,12 @@ import { Injectable, Logger } from '@nestjs/common' -import { - Application, - StorageBucket, - StoragePhase, - StorageState, -} from '@prisma/client' +import { StorageBucket, StoragePhase, StorageState } from '@prisma/client' import { TASK_LOCK_INIT_TIME } from 'src/constants' import { PrismaService } from '../prisma/prisma.service' import { RegionService } from '../region/region.service' import { CreateBucketDto } from './dto/create-bucket.dto' import { UpdateBucketDto } from './dto/update-bucket.dto' import { MinioService } from './minio/minio.service' +import { Application } from 'src/application/entities/application' @Injectable() export class BucketService { diff --git a/server/src/storage/entities/storage-bucket.ts b/server/src/storage/entities/storage-bucket.ts new file mode 100644 index 0000000000..2584562c9f --- /dev/null +++ b/server/src/storage/entities/storage-bucket.ts @@ -0,0 +1,33 @@ +import { ObjectId } from 'mongodb' + +export enum BucketPolicy { + readwrite = 'readwrite', + readonly = 'readonly', + private = 'private', +} + +export enum StoragePhase { + Creating = 'Creating', + Created = 'Created', + Deleting = 'Deleting', + Deleted = 'Deleted', +} + +export enum StorageState { + Active = 'Active', + Inactive = 'Inactive', + Deleted = 'Deleted', +} + +export class StorageBucket { + _id?: ObjectId + appid: string + name: string + shortName: string + policy: BucketPolicy + state: StorageState + phase: StoragePhase + lockedAt: Date + createdAt: Date + updatedAt: Date +} diff --git a/server/src/storage/entities/storage-user.ts b/server/src/storage/entities/storage-user.ts new file mode 100644 index 0000000000..c5e5b0019f --- /dev/null +++ b/server/src/storage/entities/storage-user.ts @@ -0,0 +1,30 @@ +import { ObjectId } from 'mongodb' + +export enum StoragePhase { + Creating = 'Creating', + Created = 'Created', + Deleting = 'Deleting', + Deleted = 'Deleted', +} + +export enum StorageState { + Active = 'Active', + Inactive = 'Inactive', + Deleted = 'Deleted', +} + +export class StorageUser { + _id?: ObjectId + appid: string + accessKey: string + secretKey: string + state: StorageState + phase: StoragePhase + lockedAt: Date + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/storage/minio/minio.service.ts b/server/src/storage/minio/minio.service.ts index feabc667dd..95a4a2cab8 100644 --- a/server/src/storage/minio/minio.service.ts +++ b/server/src/storage/minio/minio.service.ts @@ -8,12 +8,13 @@ import { PutBucketVersioningCommand, S3, } from '@aws-sdk/client-s3' -import { BucketPolicy, Region } from '@prisma/client' +import { BucketPolicy } from '@prisma/client' import * as assert from 'node:assert' import * as cp from 'child_process' import { promisify } from 'util' import { MinioCommandExecOutput } from './types' import { MINIO_COMMON_USER_GROUP } from 'src/constants' +import { Region } from 'src/region/entities/region' const exec = promisify(cp.exec) diff --git a/server/src/storage/storage.service.ts b/server/src/storage/storage.service.ts index 43801bb638..6e6413f485 100644 --- a/server/src/storage/storage.service.ts +++ b/server/src/storage/storage.service.ts @@ -1,15 +1,18 @@ import { Injectable, Logger } from '@nestjs/common' -import { Region, StoragePhase, StorageState, StorageUser } from '@prisma/client' +import { StoragePhase, StorageState, StorageUser } from '@prisma/client' import { PrismaService } from 'src/prisma/prisma.service' import { GenerateAlphaNumericPassword } from 'src/utils/random' import { MinioService } from './minio/minio.service' import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts' import { RegionService } from 'src/region/region.service' import { TASK_LOCK_INIT_TIME } from 'src/constants' +import { Region } from 'src/region/entities/region' +import { SystemDatabase } from 'src/database/system-database' @Injectable() export class StorageService { private readonly logger = new Logger(StorageService.name) + private readonly db = SystemDatabase.db constructor( private readonly minioService: MinioService, diff --git a/server/src/subscription/dto/create-subscription.dto.ts b/server/src/subscription/dto/create-subscription.dto.ts deleted file mode 100644 index 082735a3a3..0000000000 --- a/server/src/subscription/dto/create-subscription.dto.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' -import { ApplicationState } from '@prisma/client' -import { IsEnum, IsInt, IsNotEmpty, IsString, Length } from 'class-validator' - -enum CreateApplicationState { - Running = 'Running', - Stopped = 'Stopped', -} - -export class CreateSubscriptionDto { - @ApiProperty({ required: true }) - @Length(1, 64) - @IsNotEmpty() - name: string - - @ApiPropertyOptional({ - default: CreateApplicationState.Running, - enum: CreateApplicationState, - }) - @IsNotEmpty() - @IsEnum(CreateApplicationState) - state: ApplicationState - - @ApiProperty() - @IsNotEmpty() - @IsString() - regionId: string - - @ApiProperty() - @IsNotEmpty() - @IsString() - bundleId: string - - @ApiProperty() - @IsNotEmpty() - @IsString() - runtimeId: string - - @ApiProperty() - @IsInt() - @IsNotEmpty() - duration: number - - validate(): string | null { - return null - } -} diff --git a/server/src/subscription/dto/renew-subscription.dto.ts b/server/src/subscription/dto/renew-subscription.dto.ts deleted file mode 100644 index f43bc305e5..0000000000 --- a/server/src/subscription/dto/renew-subscription.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger' -import { IsInt, IsNotEmpty } from 'class-validator' - -export class RenewSubscriptionDto { - @ApiProperty() - @IsInt() - @IsNotEmpty() - duration: number -} diff --git a/server/src/subscription/dto/upgrade-subscription.dto.ts b/server/src/subscription/dto/upgrade-subscription.dto.ts deleted file mode 100644 index 4765628cac..0000000000 --- a/server/src/subscription/dto/upgrade-subscription.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsBoolean, IsNotEmpty, IsString } from 'class-validator' - -export class UpgradeSubscriptionDto { - @IsString() - @IsNotEmpty() - targetBundleId: string - - @IsBoolean() - @IsNotEmpty() - restart: boolean -} diff --git a/server/src/subscription/renewal-task.service.ts b/server/src/subscription/renewal-task.service.ts deleted file mode 100644 index da7440c74c..0000000000 --- a/server/src/subscription/renewal-task.service.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { - Account, - SubscriptionRenewal, - SubscriptionRenewalPhase, -} from '.prisma/client' -import { Injectable, Logger } from '@nestjs/common' -import { Cron, CronExpression } from '@nestjs/schedule' -import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' -import { ObjectId } from 'mongodb' -import { AccountService } from 'src/account/account.service' -import { Subscription } from '@prisma/client' - -@Injectable() -export class SubscriptionRenewalTaskService { - readonly lockTimeout = 30 // in second - readonly concurrency = 1 // concurrency count - - private readonly logger = new Logger(SubscriptionRenewalTaskService.name) - - constructor(private readonly accountService: AccountService) {} - - @Cron(CronExpression.EVERY_SECOND) - async tick() { - if (ServerConfig.DISABLED_SUBSCRIPTION_TASK) { - return - } - - // Phase `Pending` -> `Paid` - this.handlePendingPhase() - } - - /** - * Phase `Pending`: - * 1. Pay the subscription renewal order from account balance (Transaction) - * 2. Update subscription 'expiredAt' time (Transaction) (lock document) - * 3. Update subscription renewal order phase to ‘Paid’ (Transaction) - */ - async handlePendingPhase() { - const db = SystemDatabase.db - const client = SystemDatabase.client - - const doc = await db - .collection('SubscriptionRenewal') - .findOneAndUpdate( - { - phase: SubscriptionRenewalPhase.Pending, - lockedAt: { $lte: new Date(Date.now() - this.lockTimeout * 1000) }, - }, - { $set: { lockedAt: new Date() } }, - ) - - if (!doc.value) { - return - } - - const renewal = doc.value - - // check account balance - const userid = renewal.createdBy - const session = client.startSession() - await session - .withTransaction(async () => { - const account = await db - .collection('Account') - .findOne({ createdBy: userid }, { session }) - - // if account balance is not enough, delete the subscription & renewal order - if (account?.balance < renewal.amount) { - await db - .collection('SubscriptionRenewal') - .deleteOne({ _id: renewal._id }, { session }) - - await db - .collection('Subscription') - .deleteOne( - { _id: new ObjectId(renewal.subscriptionId) }, - { session }, - ) - return - } - - // Pay the subscription renewal order from account balance - const priceAmount = renewal.amount - if (priceAmount !== 0) { - await db.collection('Account').updateOne( - { - _id: account._id, - balance: { $gte: priceAmount }, - }, - { $inc: { balance: -priceAmount } }, - { session }, - ) - - // Create account transaction - await db.collection('AccountTransaction').insertOne( - { - accountId: account._id, - amount: -priceAmount, - balance: account.balance - priceAmount, - message: `subscription renewal order ${renewal._id}`, - createdAt: new Date(), - updatedAt: new Date(), - }, - { session }, - ) - } - - // Update subscription 'expiredAt' time - await db.collection('Subscription').updateOne( - { _id: new ObjectId(renewal.subscriptionId) }, - [ - { - $set: { - expiredAt: { $add: ['$expiredAt', renewal.duration * 1000] }, - }, - }, - ], - { session }, - ) - - // Update subscription renewal order phase to ‘Paid’ - await db - .collection('SubscriptionRenewal') - .updateOne( - { _id: renewal._id }, - { - $set: { - phase: SubscriptionRenewalPhase.Paid, - lockedAt: TASK_LOCK_INIT_TIME, - }, - }, - { session }, - ) - }) - .catch((err) => { - this.logger.debug(renewal._id, err.toString()) - }) - } - - @Cron(CronExpression.EVERY_MINUTE) - async handlePendingTimeout() { - const timeout = 30 * 60 * 1000 - - const db = SystemDatabase.db - await db.collection('SubscriptionRenewal').updateMany( - { - phase: SubscriptionRenewalPhase.Pending, - lockedAt: { $lte: new Date(Date.now() - this.lockTimeout * 1000) }, - createdAt: { $lte: new Date(Date.now() - timeout) }, - }, - { - $set: { - phase: SubscriptionRenewalPhase.Failed, - message: `Timeout exceeded ${timeout / 1000} seconds`, - }, - }, - ) - } - - /** - * Unlock subscription - */ - async unlock(id: ObjectId) { - const db = SystemDatabase.db - await db - .collection('SubscriptionRenewal') - .updateOne({ _id: id }, { $set: { lockedAt: TASK_LOCK_INIT_TIME } }) - } -} diff --git a/server/src/subscription/subscription-task.service.ts b/server/src/subscription/subscription-task.service.ts deleted file mode 100644 index 5ab5b49bc3..0000000000 --- a/server/src/subscription/subscription-task.service.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { Application, Subscription, SubscriptionPhase } from '.prisma/client' -import { Injectable, Logger } from '@nestjs/common' -import { Cron, CronExpression } from '@nestjs/schedule' -import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' -import * as assert from 'node:assert' -import { ApplicationService } from 'src/application/application.service' -import { ApplicationState, SubscriptionState } from '@prisma/client' -import { ObjectId } from 'mongodb' -import { BundleService } from 'src/region/bundle.service' -import { CreateSubscriptionDto } from './dto/create-subscription.dto' - -@Injectable() -export class SubscriptionTaskService { - readonly lockTimeout = 30 // in second - - private readonly logger = new Logger(SubscriptionTaskService.name) - - constructor( - private readonly bundleService: BundleService, - private readonly applicationService: ApplicationService, - ) {} - - @Cron(CronExpression.EVERY_SECOND) - async tick() { - if (ServerConfig.DISABLED_SUBSCRIPTION_TASK) { - return - } - - // Phase `Pending` -> `Valid` - this.handlePendingPhaseAndNotExpired() - - // Phase `Valid` -> `Expired` - this.handleValidPhaseAndExpired() - - // Phase `Expired` -> `ExpiredAndStopped` - this.handleExpiredPhase() - - // Phase `ExpiredAndStopped` -> `Valid` - this.handleExpiredAndStoppedPhaseAndNotExpired() - - // Phase `ExpiredAndStopped` -> `Deleted` - this.handleExpiredAndStoppedPhase() - - // State `Deleted` - this.handleDeletedState() - } - - /** - * Phase `Pending` and not expired: - * - if appid is null, generate appid - * - if appid exists, but application is not found - * - create application - * - update subscription phase to `Valid` - */ - async handlePendingPhaseAndNotExpired() { - const db = SystemDatabase.db - - const res = await db - .collection('Subscription') - .findOneAndUpdate( - { - phase: SubscriptionPhase.Pending, - expiredAt: { $gt: new Date() }, - lockedAt: { $lt: new Date(Date.now() - this.lockTimeout * 1000) }, - }, - { $set: { lockedAt: new Date() } }, - ) - if (!res.value) return - - // get region by appid - const doc = res.value - - // if application not found, create application - const application = await this.applicationService.findOne(doc.appid) - if (!application) { - const userid = doc.createdBy.toString() - const dto = new CreateSubscriptionDto() - dto.name = doc.input.name - dto.regionId = doc.input.regionId - dto.state = doc.input.state as ApplicationState - dto.runtimeId = doc.input.runtimeId - // doc.bundleId is ObjectId, but prisma typed it as string, so we need to convert it - dto.bundleId = doc.bundleId.toString() - this.logger.debug(dto) - - await this.applicationService.create(userid, doc.appid, dto) - return await this.unlock(doc._id) - } - - // update subscription phase to `Valid` - await db.collection('Subscription').updateOne( - { _id: doc._id }, - { - $set: { phase: SubscriptionPhase.Valid, lockedAt: TASK_LOCK_INIT_TIME }, - }, - ) - } - - /** - * Phase ‘Valid’ with expiredAt < now - * - update subscription phase to ‘Expired’ - */ - async handleValidPhaseAndExpired() { - const db = SystemDatabase.db - - await db.collection('Subscription').updateMany( - { - phase: SubscriptionPhase.Valid, - expiredAt: { $lt: new Date() }, - }, - { $set: { phase: SubscriptionPhase.Expired } }, - ) - } - - /** - * Phase 'Expired': - * - update application state to 'Stopped' - * - update subscription phase to 'ExpiredAndStopped' - */ - async handleExpiredPhase() { - const db = SystemDatabase.db - - const res = await db - .collection('Subscription') - .findOneAndUpdate( - { - phase: SubscriptionPhase.Expired, - lockedAt: { $lt: new Date(Date.now() - this.lockTimeout * 1000) }, - }, - { $set: { lockedAt: new Date() } }, - ) - if (!res.value) return - - const doc = res.value - - // update application state to 'Stopped' - await db - .collection('Application') - .updateOne( - { appid: doc.appid }, - { $set: { state: ApplicationState.Stopped } }, - ) - - // update subscription phase to 'ExpiredAndStopped' - await db.collection('Subscription').updateOne( - { _id: doc._id }, - { - $set: { - phase: SubscriptionPhase.ExpiredAndStopped, - lockedAt: TASK_LOCK_INIT_TIME, - }, - }, - ) - } - - /** - * Phase 'ExpiredAndStopped' but not expired (renewal case): - * - update subscription phase to ‘Valid’ - * (TODO) update application state to ‘Running’ - */ - async handleExpiredAndStoppedPhaseAndNotExpired() { - const db = SystemDatabase.db - - await db.collection('Subscription').updateMany( - { - phase: SubscriptionPhase.ExpiredAndStopped, - expiredAt: { $gt: new Date() }, - }, - { $set: { phase: SubscriptionPhase.Valid } }, - ) - } - - /** - * Phase 'ExpiredAndStopped': - * -if ‘Bundle.reservedTimeAfterExpired’ expired - * 1. Update application state to ‘Deleted’ - * 2. Update subscription phase to ‘ExpiredAndDeleted’ - */ - async handleExpiredAndStoppedPhase() { - const db = SystemDatabase.db - - const specialLockTimeout = 60 * 60 // 1 hour - - const res = await db - .collection('Subscription') - .findOneAndUpdate( - { - phase: SubscriptionPhase.ExpiredAndStopped, - lockedAt: { $lt: new Date(Date.now() - specialLockTimeout * 1000) }, - }, - { $set: { lockedAt: new Date() } }, - ) - if (!res.value) return - - const doc = res.value - - // if ‘Bundle.reservedTimeAfterExpired’ expired - const bundle = await this.bundleService.findApplicationBundle(doc.appid) - assert(bundle, 'bundle not found') - - const reservedTimeAfterExpired = - bundle.resource.reservedTimeAfterExpired * 1000 - const expiredTime = Date.now() - doc.expiredAt.getTime() - if (expiredTime < reservedTimeAfterExpired) { - return // return directly without unlocking it! - } - - // 2. Update subscription state to 'Deleted' - await db.collection('Subscription').updateOne( - { _id: doc._id }, - { - $set: { - state: SubscriptionState.Deleted, - lockedAt: TASK_LOCK_INIT_TIME, - }, - }, - ) - } - - /** - * State `Deleted` - */ - async handleDeletedState() { - const db = SystemDatabase.db - const res = await db - .collection('Subscription') - .findOneAndUpdate( - { - state: SubscriptionState.Deleted, - phase: { $not: { $eq: SubscriptionPhase.Deleted } }, - lockedAt: { $lt: new Date(Date.now() - this.lockTimeout * 1000) }, - }, - { $set: { lockedAt: new Date() } }, - ) - if (!res.value) return - - const doc = res.value - - const app = await this.applicationService.findOne(doc.appid) - if (app && app.state !== ApplicationState.Deleted) { - // delete application, update application state to ‘Deleted’ - await this.applicationService.remove(doc.appid) - this.logger.debug(`deleting application: ${doc.appid}`) - } - - // wait for application to be deleted - if (app) { - this.logger.debug(`waiting for application to be deleted: ${doc.appid}`) - return // return directly without unlocking it - } - - // Update subscription phase to 'Deleted' - await db.collection('Subscription').updateOne( - { _id: doc._id }, - { - $set: { - phase: SubscriptionPhase.Deleted, - lockedAt: TASK_LOCK_INIT_TIME, - }, - }, - ) - this.logger.debug(`subscription phase updated to deleted: ${doc.appid}`) - } - - @Cron(CronExpression.EVERY_MINUTE) - async handlePendingTimeout() { - const timeout = 10 * 60 * 1000 - - const db = SystemDatabase.db - await db.collection('Subscription').deleteMany({ - phase: SubscriptionPhase.Pending, - lockedAt: { $lte: new Date(Date.now() - this.lockTimeout * 1000) }, - createdAt: { $lte: new Date(Date.now() - timeout) }, - }) - } - - /** - * Unlock subscription - */ - async unlock(id: ObjectId) { - const db = SystemDatabase.db - await db - .collection('Subscription') - .updateOne({ _id: id }, { $set: { lockedAt: TASK_LOCK_INIT_TIME } }) - } -} diff --git a/server/src/subscription/subscription.controller.ts b/server/src/subscription/subscription.controller.ts deleted file mode 100644 index f170b93906..0000000000 --- a/server/src/subscription/subscription.controller.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { - Controller, - Get, - Post, - Body, - Patch, - Param, - Delete, - Logger, - UseGuards, - Req, -} from '@nestjs/common' -import { SubscriptionService } from './subscription.service' -import { CreateSubscriptionDto } from './dto/create-subscription.dto' -import { UpgradeSubscriptionDto } from './dto/upgrade-subscription.dto' -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' -import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' -import { IRequest } from 'src/utils/interface' -import { ResponseUtil } from 'src/utils/response' -import { BundleService } from 'src/region/bundle.service' -import { PrismaService } from 'src/prisma/prisma.service' -import { ApplicationService } from 'src/application/application.service' -import { RegionService } from 'src/region/region.service' -import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' -import { RenewSubscriptionDto } from './dto/renew-subscription.dto' -import * as assert from 'assert' -import { SubscriptionState } from '@prisma/client' -import { AccountService } from 'src/account/account.service' - -@ApiTags('Subscription') -@Controller('subscriptions') -@ApiBearerAuth('Authorization') -export class SubscriptionController { - private readonly logger = new Logger(SubscriptionController.name) - - constructor( - private readonly subscriptService: SubscriptionService, - private readonly applicationService: ApplicationService, - private readonly bundleService: BundleService, - private readonly prisma: PrismaService, - private readonly regionService: RegionService, - private readonly accountService: AccountService, - ) {} - - /** - * Create a new subscription - */ - @ApiOperation({ summary: 'Create a new subscription' }) - @UseGuards(JwtAuthGuard) - @Post() - async create(@Body() dto: CreateSubscriptionDto, @Req() req: IRequest) { - const user = req.user - - // check regionId exists - const region = await this.regionService.findOneDesensitized(dto.regionId) - if (!region) { - return ResponseUtil.error(`region ${dto.regionId} not found`) - } - - // check runtimeId exists - const runtime = await this.prisma.runtime.findUnique({ - where: { id: dto.runtimeId }, - }) - if (!runtime) { - return ResponseUtil.error(`runtime ${dto.runtimeId} not found`) - } - - // check bundleId exists - const bundle = await this.bundleService.findOne(dto.bundleId, region.id) - if (!bundle) { - return ResponseUtil.error(`bundle ${dto.bundleId} not found`) - } - - // check app count limit - const LIMIT_COUNT = bundle.limitCountPerUser || 0 - const count = await this.prisma.subscription.count({ - where: { - createdBy: user.id, - bundleId: dto.bundleId, - state: { not: SubscriptionState.Deleted }, - }, - }) - if (count >= LIMIT_COUNT) { - return ResponseUtil.error( - `application count limit is ${LIMIT_COUNT} for bundle ${bundle.name}`, - ) - } - - // check duration supported - const option = this.bundleService.getSubscriptionOption( - bundle, - dto.duration, - ) - if (!option) { - return ResponseUtil.error(`duration not supported in bundle`) - } - - // check account balance - const account = await this.accountService.findOne(user.id) - const balance = account?.balance || 0 - const priceAmount = option.specialPrice - if (balance < priceAmount) { - return ResponseUtil.error( - `account balance is not enough, need ${priceAmount} but only ${account.balance}`, - ) - } - - // create subscription - const appid = await this.applicationService.tryGenerateUniqueAppid() - const subscription = await this.subscriptService.create( - user.id, - appid, - dto, - option, - ) - return ResponseUtil.ok(subscription) - } - - /** - * Get user's subscriptions - */ - @ApiOperation({ summary: "Get user's subscriptions" }) - @UseGuards(JwtAuthGuard) - @Get() - async findAll(@Req() req: IRequest) { - const user = req.user - const subscriptions = await this.subscriptService.findAll(user.id) - return ResponseUtil.ok(subscriptions) - } - - /** - * Get subscription by appid - */ - @ApiOperation({ summary: 'Get subscription by appid' }) - @UseGuards(JwtAuthGuard, ApplicationAuthGuard) - @Get(':appid') - async findOne(@Param('appid') appid: string) { - const subscription = await this.subscriptService.findOneByAppid(appid) - if (!subscription) { - return ResponseUtil.error(`subscription ${appid} not found`) - } - - return ResponseUtil.ok(subscription) - } - - /** - * Renew a subscription - */ - @ApiOperation({ summary: 'Renew a subscription' }) - @UseGuards(JwtAuthGuard) - @Post(':id/renewal') - async renew( - @Param('id') id: string, - @Body() dto: RenewSubscriptionDto, - @Req() req: IRequest, - ) { - const { duration } = dto - - // get subscription - const user = req.user - const subscription = await this.subscriptService.findOne(user.id, id) - if (!subscription) { - return ResponseUtil.error(`subscription ${id} not found`) - } - - const app = subscription.application - const bundle = await this.bundleService.findOne( - subscription.bundleId, - app.regionId, - ) - assert(bundle, `bundle ${subscription.bundleId} not found`) - - const option = this.bundleService.getSubscriptionOption(bundle, duration) - if (!option) { - return ResponseUtil.error(`duration not supported in bundle`) - } - const priceAmount = option.specialPrice - - // check max renewal time - const MAX_RENEWAL_AT = Date.now() + bundle.maxRenewalTime * 1000 - const newExpiredAt = subscription.expiredAt.getTime() + duration * 1000 - if (newExpiredAt > MAX_RENEWAL_AT) { - const dateStr = new Date(MAX_RENEWAL_AT).toLocaleString() - return ResponseUtil.error( - `max renewal time is ${dateStr} for bundle ${bundle.name}`, - ) - } - - // check account balance - const account = await this.accountService.findOne(user.id) - const balance = account?.balance || 0 - if (balance < priceAmount) { - return ResponseUtil.error( - `account balance is not enough, need ${priceAmount} but only ${account.balance}`, - ) - } - - // renew subscription - const res = await this.subscriptService.renew(subscription, option) - return ResponseUtil.ok(res) - } - - /** - * TODO: Upgrade a subscription - */ - @ApiOperation({ summary: 'Upgrade a subscription - TODO' }) - @UseGuards(JwtAuthGuard) - @Patch(':id/upgrade') - async upgrade( - @Param('id') id: string, - @Body() dto: UpgradeSubscriptionDto, - @Req() req: IRequest, - ) { - const { targetBundleId, restart } = dto - - // get subscription - const user = req.user - const subscription = await this.subscriptService.findOne(user.id, id) - if (!subscription) { - return ResponseUtil.error(`subscription ${id} not found`) - } - - // get target bundle - const app = subscription.application - const targetBundle = await this.bundleService.findOne( - targetBundleId, - app.regionId, - ) - if (!targetBundle) { - return ResponseUtil.error(`bundle ${targetBundleId} not found`) - } - - // check bundle is upgradeable - const bundle = await this.bundleService.findOne( - subscription.bundleId, - app.regionId, - ) - assert(bundle, `bundle ${subscription.bundleId} not found`) - - if (bundle.id === targetBundle.id) { - return ResponseUtil.error(`bundle is the same`) - } - - // check if target bundle limit count is reached - const LIMIT_COUNT = targetBundle.limitCountPerUser || 0 - const count = await this.prisma.subscription.count({ - where: { - createdBy: user.id, - bundleId: targetBundle.id, - state: { not: SubscriptionState.Deleted }, - }, - }) - if (count >= LIMIT_COUNT) { - return ResponseUtil.error( - `application count limit is ${LIMIT_COUNT} for bundle ${targetBundle.name}`, - ) - } - - return ResponseUtil.error(`not implemented`) - } - - /** - * Delete a subscription - * @param id - * @returns - */ - @ApiOperation({ summary: 'Delete a subscription' }) - @UseGuards(JwtAuthGuard) - @Delete(':id') - async remove(@Param('id') id: string, @Req() req: IRequest) { - const userid = req.user.id - const res = await this.subscriptService.remove(userid, id) - return ResponseUtil.ok(res) - } -} diff --git a/server/src/subscription/subscription.module.ts b/server/src/subscription/subscription.module.ts deleted file mode 100644 index e5a1f6301a..0000000000 --- a/server/src/subscription/subscription.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Module } from '@nestjs/common' -import { SubscriptionService } from './subscription.service' -import { SubscriptionController } from './subscription.controller' -import { SubscriptionTaskService } from './subscription-task.service' -import { ApplicationService } from 'src/application/application.service' -import { SubscriptionRenewalTaskService } from './renewal-task.service' -import { AccountModule } from 'src/account/account.module' - -@Module({ - imports: [AccountModule], - controllers: [SubscriptionController], - providers: [ - SubscriptionService, - SubscriptionTaskService, - ApplicationService, - SubscriptionRenewalTaskService, - ], -}) -export class SubscriptionModule {} diff --git a/server/src/subscription/subscription.service.ts b/server/src/subscription/subscription.service.ts deleted file mode 100644 index 729efa6622..0000000000 --- a/server/src/subscription/subscription.service.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common' -import { - BundleSubscriptionOption, - Subscription, - SubscriptionPhase, - SubscriptionRenewalPhase, - SubscriptionRenewalPlan, - SubscriptionState, -} from '@prisma/client' -import { TASK_LOCK_INIT_TIME } from 'src/constants' -import { PrismaService } from 'src/prisma/prisma.service' -import { BundleService } from 'src/region/bundle.service' -import { CreateSubscriptionDto } from './dto/create-subscription.dto' - -@Injectable() -export class SubscriptionService { - private readonly logger = new Logger(SubscriptionService.name) - - constructor( - private readonly prisma: PrismaService, - private readonly bundleService: BundleService, - ) {} - - async create( - userid: string, - appid: string, - dto: CreateSubscriptionDto, - option: BundleSubscriptionOption, - ) { - // start transaction - const res = await this.prisma.$transaction(async (tx) => { - // create subscription - const subscription = await tx.subscription.create({ - data: { - input: { - name: dto.name, - state: dto.state, - regionId: dto.regionId, - runtimeId: dto.runtimeId, - }, - appid: appid, - bundleId: dto.bundleId, - phase: SubscriptionPhase.Pending, - renewalPlan: SubscriptionRenewalPlan.Manual, - expiredAt: new Date(), - lockedAt: TASK_LOCK_INIT_TIME, - createdBy: userid, - }, - }) - - // create subscription renewal - await tx.subscriptionRenewal.create({ - data: { - subscriptionId: subscription.id, - duration: option.duration, - amount: option.specialPrice, - phase: SubscriptionRenewalPhase.Pending, - lockedAt: TASK_LOCK_INIT_TIME, - createdBy: userid, - }, - }) - - return subscription - }) - - return res - } - - async findAll(userid: string) { - const res = await this.prisma.subscription.findMany({ - where: { createdBy: userid }, - include: { application: true }, - }) - - return res - } - - async findOne(userid: string, id: string) { - const res = await this.prisma.subscription.findUnique({ - where: { id }, - include: { application: true }, - }) - - return res - } - - async findOneByAppid(appid: string) { - const res = await this.prisma.subscription.findUnique({ - where: { - appid, - }, - }) - - return res - } - - async remove(userid: string, id: string) { - const res = await this.prisma.subscription.updateMany({ - where: { id, createdBy: userid, state: SubscriptionState.Created }, - data: { state: SubscriptionState.Deleted }, - }) - - return res - } - - /** - * Renew a subscription by creating a subscription renewal - */ - async renew(subscription: Subscription, option: BundleSubscriptionOption) { - // create subscription renewal - const res = await this.prisma.subscriptionRenewal.create({ - data: { - subscriptionId: subscription.id, - duration: option.duration, - amount: option.specialPrice, - phase: SubscriptionRenewalPhase.Pending, - lockedAt: TASK_LOCK_INIT_TIME, - createdBy: subscription.createdBy, - }, - }) - - return res - } -} diff --git a/server/src/utils/interface.ts b/server/src/utils/interface.ts index a91eab7883..14ec2a4e0b 100644 --- a/server/src/utils/interface.ts +++ b/server/src/utils/interface.ts @@ -1,5 +1,6 @@ -import { Application, User } from '@prisma/client' +import { User } from '@prisma/client' import { Request, Response } from 'express' +import { Application } from 'src/application/entities/application' export interface IRequest extends Request { user?: User From 52d926db6ba82e68cbe48ecb6108169160755e8b Mon Sep 17 00:00:00 2001 From: maslow Date: Tue, 16 May 2023 15:01:30 +0000 Subject: [PATCH 16/48] remove prisma in gateway, website, storage --- .../application/application-task.service.ts | 5 +- .../src/application/entities/application.ts | 2 + .../src/gateway/apisix-custom-cert.service.ts | 24 +-- server/src/gateway/apisix.service.ts | 2 +- .../src/gateway/bucket-domain-task.service.ts | 3 +- server/src/gateway/bucket-domain.service.ts | 85 ++++------ server/src/gateway/entities/bucket-domain.ts | 18 +++ server/src/gateway/entities/runtime-domain.ts | 29 ++++ .../gateway/runtime-domain-task.service.ts | 6 +- server/src/gateway/runtime-domain.service.ts | 58 ++++--- server/src/gateway/website-task.service.ts | 9 +- server/src/storage/bucket-task.service.ts | 48 +++--- server/src/storage/bucket.controller.ts | 4 +- server/src/storage/bucket.service.ts | 138 +++++++++------- server/src/storage/dto/create-bucket.dto.ts | 2 +- server/src/storage/dto/update-bucket.dto.ts | 2 +- server/src/storage/entities/storage-bucket.ts | 21 +-- server/src/storage/minio/minio.service.ts | 2 +- server/src/storage/storage.service.ts | 89 +++++------ server/src/website/dto/create-website.dto.ts | 2 +- server/src/website/entities/website.ts | 24 +++ server/src/website/website.controller.ts | 15 +- server/src/website/website.service.ts | 149 ++++++++---------- 23 files changed, 400 insertions(+), 337 deletions(-) create mode 100644 server/src/gateway/entities/bucket-domain.ts create mode 100644 server/src/gateway/entities/runtime-domain.ts create mode 100644 server/src/website/entities/website.ts diff --git a/server/src/application/application-task.service.ts b/server/src/application/application-task.service.ts index 7639e5a3ea..cafe78c8ac 100644 --- a/server/src/application/application-task.service.ts +++ b/server/src/application/application-task.service.ts @@ -1,6 +1,5 @@ import { Injectable, Logger } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' -import { DomainPhase, StoragePhase } from '@prisma/client' import * as assert from 'node:assert' import { StorageService } from '../storage/storage.service' import { DatabaseService } from '../database/database.service' @@ -22,6 +21,8 @@ import { ApplicationState, } from './entities/application' import { DatabasePhase } from 'src/database/entities/database' +import { DomainPhase } from 'src/gateway/entities/runtime-domain' +import { StoragePhase } from 'src/storage/entities/storage-user' @Injectable() export class ApplicationTaskService { @@ -240,7 +241,7 @@ export class ApplicationTaskService { // delete runtime domain const runtimeDomain = await this.runtimeDomainService.findOne(appid) if (runtimeDomain) { - await this.runtimeDomainService.delete(appid) + await this.runtimeDomainService.deleteOne(appid) return await this.unlock(appid) } diff --git a/server/src/application/entities/application.ts b/server/src/application/entities/application.ts index 55078d5c71..c673f64c62 100644 --- a/server/src/application/entities/application.ts +++ b/server/src/application/entities/application.ts @@ -3,6 +3,7 @@ import { Region } from 'src/region/entities/region' import { ApplicationBundle } from './application-bundle' import { Runtime } from './runtime' import { ApplicationConfiguration } from './application-configuration' +import { RuntimeDomain } from 'src/gateway/entities/runtime-domain' export enum ApplicationPhase { Creating = 'Creating', @@ -46,4 +47,5 @@ export interface ApplicationWithRelations extends Application { bundle?: ApplicationBundle runtime?: Runtime configuration?: ApplicationConfiguration + domain?: RuntimeDomain } diff --git a/server/src/gateway/apisix-custom-cert.service.ts b/server/src/gateway/apisix-custom-cert.service.ts index bb905d6ba9..921b511daf 100644 --- a/server/src/gateway/apisix-custom-cert.service.ts +++ b/server/src/gateway/apisix-custom-cert.service.ts @@ -1,9 +1,9 @@ import { Injectable, Logger } from '@nestjs/common' -import { WebsiteHosting } from '@prisma/client' import { LABEL_KEY_APP_ID, ServerConfig } from 'src/constants' import { ClusterService } from 'src/region/cluster/cluster.service' import { Region } from 'src/region/entities/region' import { GetApplicationNamespaceByAppId } from 'src/utils/getter' +import { WebsiteHosting } from 'src/website/entities/website' // This class handles the creation and deletion of website domain certificates // and ApisixTls resources using Kubernetes Custom Resource Definitions (CRDs). @@ -26,7 +26,7 @@ export class ApisixCustomCertService { 'v1', namespace, 'certificates', - website.id, + website._id.toString(), ) return res.body @@ -51,17 +51,17 @@ export class ApisixCustomCertService { kind: 'Certificate', // Set the metadata for the Certificate resource metadata: { - name: website.id, + name: website._id.toString(), namespace, labels: { - 'laf.dev/website': website.id, + 'laf.dev/website': website._id.toString(), 'laf.dev/website-domain': website.domain, [LABEL_KEY_APP_ID]: website.appid, }, }, // Define the specification for the Certificate resource spec: { - secretName: website.id, + secretName: website._id.toString(), dnsNames: [website.domain], issuerRef: { name: ServerConfig.CertManagerIssuerName, @@ -84,7 +84,7 @@ export class ApisixCustomCertService { apiVersion: 'cert-manager.io/v1', kind: 'Certificate', metadata: { - name: website.id, + name: website._id.toString(), namespace, }, }) @@ -95,7 +95,7 @@ export class ApisixCustomCertService { apiVersion: 'v1', kind: 'Secret', metadata: { - name: website.id, + name: website._id.toString(), namespace, }, }) @@ -122,7 +122,7 @@ export class ApisixCustomCertService { 'v2', namespace, 'apisixtlses', - website.id, + website._id.toString(), ) return res.body } catch (err) { @@ -146,10 +146,10 @@ export class ApisixCustomCertService { kind: 'ApisixTls', // Set the metadata for the ApisixTls resource metadata: { - name: website.id, + name: website._id.toString(), namespace, labels: { - 'laf.dev/website': website.id, + 'laf.dev/website': website._id.toString(), 'laf.dev/website-domain': website.domain, [LABEL_KEY_APP_ID]: website.appid, }, @@ -158,7 +158,7 @@ export class ApisixCustomCertService { spec: { hosts: [website.domain], secret: { - name: website.id, + name: website._id.toString(), namespace, }, }, @@ -179,7 +179,7 @@ export class ApisixCustomCertService { apiVersion: 'apisix.apache.org/v2', kind: 'ApisixTls', metadata: { - name: website.id, + name: website._id.toString(), namespace, }, }) diff --git a/server/src/gateway/apisix.service.ts b/server/src/gateway/apisix.service.ts index c03da643b4..957a0217e5 100644 --- a/server/src/gateway/apisix.service.ts +++ b/server/src/gateway/apisix.service.ts @@ -1,8 +1,8 @@ import { HttpService } from '@nestjs/axios' import { Injectable, Logger } from '@nestjs/common' -import { WebsiteHosting } from '@prisma/client' import { GetApplicationNamespaceByAppId } from '../utils/getter' import { Region } from 'src/region/entities/region' +import { WebsiteHosting } from 'src/website/entities/website' @Injectable() export class ApisixService { diff --git a/server/src/gateway/bucket-domain-task.service.ts b/server/src/gateway/bucket-domain-task.service.ts index 6be9e5f20e..cbd50416ce 100644 --- a/server/src/gateway/bucket-domain-task.service.ts +++ b/server/src/gateway/bucket-domain-task.service.ts @@ -1,11 +1,12 @@ import { Injectable, Logger } from '@nestjs/common' -import { BucketDomain, DomainPhase, DomainState } from '@prisma/client' import { RegionService } from 'src/region/region.service' import { ApisixService } from './apisix.service' import * as assert from 'node:assert' import { Cron, CronExpression } from '@nestjs/schedule' import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' import { SystemDatabase } from 'src/database/system-database' +import { BucketDomain } from './entities/bucket-domain' +import { DomainPhase, DomainState } from './entities/runtime-domain' @Injectable() export class BucketDomainTaskService { diff --git a/server/src/gateway/bucket-domain.service.ts b/server/src/gateway/bucket-domain.service.ts index 45e274ec9a..e61d8d95e6 100644 --- a/server/src/gateway/bucket-domain.service.ts +++ b/server/src/gateway/bucket-domain.service.ts @@ -1,18 +1,18 @@ import { Injectable, Logger } from '@nestjs/common' -import { DomainPhase, DomainState, StorageBucket } from '@prisma/client' -import { PrismaService } from '../prisma/prisma.service' import { RegionService } from '../region/region.service' import * as assert from 'node:assert' import { TASK_LOCK_INIT_TIME } from 'src/constants' +import { StorageBucket } from 'src/storage/entities/storage-bucket' +import { SystemDatabase } from 'src/database/system-database' +import { BucketDomain } from './entities/bucket-domain' +import { DomainPhase, DomainState } from './entities/runtime-domain' @Injectable() export class BucketDomainService { private readonly logger = new Logger(BucketDomainService.name) + private readonly db = SystemDatabase.db - constructor( - private readonly prisma: PrismaService, - private readonly regionService: RegionService, - ) {} + constructor(private readonly regionService: RegionService) {} /** * Create app domain in database @@ -23,45 +23,36 @@ export class BucketDomainService { // create domain in db const bucket_domain = `${bucket.name}.${region.storageConf.domain}` - const doc = await this.prisma.bucketDomain.create({ - data: { - appid: bucket.appid, - domain: bucket_domain, - bucket: { - connect: { - name: bucket.name, - }, - }, - state: DomainState.Active, - phase: DomainPhase.Creating, - lockedAt: TASK_LOCK_INIT_TIME, - }, + await this.db.collection('BucketDomain').insertOne({ + appid: bucket.appid, + domain: bucket_domain, + bucketName: bucket.name, + state: DomainState.Active, + phase: DomainPhase.Creating, + lockedAt: TASK_LOCK_INIT_TIME, + createdAt: new Date(), + updatedAt: new Date(), }) - return doc + return this.findOne(bucket) } /** * Find an app domain in database */ async findOne(bucket: StorageBucket) { - const doc = await this.prisma.bucketDomain.findFirst({ - where: { - bucket: { - name: bucket.name, - }, - }, + const doc = await this.db.collection('BucketDomain').findOne({ + appid: bucket.appid, + bucketName: bucket.name, }) return doc } async count(appid: string) { - const count = await this.prisma.bucketDomain.count({ - where: { - appid, - }, - }) + const count = await this.db + .collection('BucketDomain') + .countDocuments({ appid }) return count } @@ -70,30 +61,22 @@ export class BucketDomainService { * Delete app domain in database: * - turn to `Deleted` state */ - async delete(bucket: StorageBucket) { - const doc = await this.prisma.bucketDomain.update({ - where: { - id: bucket.id, - bucketName: bucket.name, - }, - data: { - state: DomainState.Deleted, - }, - }) + async deleteOne(bucket: StorageBucket) { + await this.db + .collection('BucketDomain') + .findOneAndUpdate( + { _id: bucket._id }, + { $set: { state: DomainState.Deleted } }, + ) - return doc + return await this.findOne(bucket) } async deleteAll(appid: string) { - const docs = await this.prisma.bucketDomain.updateMany({ - where: { - appid, - }, - data: { - state: DomainState.Deleted, - }, - }) + const res = await this.db + .collection('BucketDomain') + .updateMany({ appid }, { $set: { state: DomainState.Deleted } }) - return docs + return res } } diff --git a/server/src/gateway/entities/bucket-domain.ts b/server/src/gateway/entities/bucket-domain.ts new file mode 100644 index 0000000000..05a4880d78 --- /dev/null +++ b/server/src/gateway/entities/bucket-domain.ts @@ -0,0 +1,18 @@ +import { ObjectId } from 'mongodb' +import { DomainPhase, DomainState } from './runtime-domain' + +export class BucketDomain { + _id?: ObjectId + appid: string + bucketName: string + domain: string + state: DomainState + phase: DomainPhase + lockedAt: Date + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/gateway/entities/runtime-domain.ts b/server/src/gateway/entities/runtime-domain.ts new file mode 100644 index 0000000000..a5f7191e01 --- /dev/null +++ b/server/src/gateway/entities/runtime-domain.ts @@ -0,0 +1,29 @@ +import { ObjectId } from 'mongodb' + +export enum DomainPhase { + Creating = 'Creating', + Created = 'Created', + Deleting = 'Deleting', + Deleted = 'Deleted', +} + +export enum DomainState { + Active = 'Active', + Inactive = 'Inactive', + Deleted = 'Deleted', +} + +export class RuntimeDomain { + _id?: ObjectId + appid: string + domain: string + state: DomainState + phase: DomainPhase + lockedAt: Date + createdAt: Date + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/gateway/runtime-domain-task.service.ts b/server/src/gateway/runtime-domain-task.service.ts index 426b3d04b8..88b9076436 100644 --- a/server/src/gateway/runtime-domain-task.service.ts +++ b/server/src/gateway/runtime-domain-task.service.ts @@ -1,11 +1,15 @@ import { Injectable, Logger } from '@nestjs/common' -import { RuntimeDomain, DomainPhase, DomainState } from '@prisma/client' import { RegionService } from '../region/region.service' import { ApisixService } from './apisix.service' import * as assert from 'node:assert' import { Cron, CronExpression } from '@nestjs/schedule' import { ServerConfig, TASK_LOCK_INIT_TIME } from '../constants' import { SystemDatabase } from '../database/system-database' +import { + DomainPhase, + DomainState, + RuntimeDomain, +} from './entities/runtime-domain' @Injectable() export class RuntimeDomainTaskService { diff --git a/server/src/gateway/runtime-domain.service.ts b/server/src/gateway/runtime-domain.service.ts index 50f235c05c..789868322c 100644 --- a/server/src/gateway/runtime-domain.service.ts +++ b/server/src/gateway/runtime-domain.service.ts @@ -1,20 +1,20 @@ import { Injectable, Logger } from '@nestjs/common' -import { PrismaService } from '../prisma/prisma.service' import * as assert from 'assert' import { RegionService } from '../region/region.service' -import { ApisixService } from './apisix.service' -import { DomainPhase, DomainState } from '@prisma/client' import { TASK_LOCK_INIT_TIME } from 'src/constants' +import { SystemDatabase } from 'src/database/system-database' +import { + DomainPhase, + DomainState, + RuntimeDomain, +} from './entities/runtime-domain' @Injectable() export class RuntimeDomainService { private readonly logger = new Logger(RuntimeDomainService.name) + private readonly db = SystemDatabase.db - constructor( - private readonly prisma: PrismaService, - private readonly regionService: RegionService, - private readonly apisixService: ApisixService, - ) {} + constructor(private readonly regionService: RegionService) {} /** * Create app domain in database @@ -25,28 +25,26 @@ export class RuntimeDomainService { // create domain in db const app_domain = `${appid}.${region.gatewayConf.runtimeDomain}` - const doc = await this.prisma.runtimeDomain.create({ - data: { - appid: appid, - domain: app_domain, - state: DomainState.Active, - phase: DomainPhase.Creating, - lockedAt: TASK_LOCK_INIT_TIME, - }, + await this.db.collection('RuntimeDomain').insertOne({ + appid: appid, + domain: app_domain, + state: DomainState.Active, + phase: DomainPhase.Creating, + lockedAt: TASK_LOCK_INIT_TIME, + createdAt: new Date(), + updatedAt: new Date(), }) - return doc + return await this.findOne(appid) } /** * Find an app domain in database */ async findOne(appid: string) { - const doc = await this.prisma.runtimeDomain.findFirst({ - where: { - appid: appid, - }, - }) + const doc = await this.db + .collection('RuntimeDomain') + .findOne({ appid }) return doc } @@ -55,15 +53,13 @@ export class RuntimeDomainService { * Delete app domain in database: * - turn to `Deleted` state */ - async delete(appid: string) { - const doc = await this.prisma.runtimeDomain.update({ - where: { - appid: appid, - }, - data: { - state: DomainState.Deleted, - }, - }) + async deleteOne(appid: string) { + const doc = await this.db + .collection('RuntimeDomain') + .findOneAndUpdate( + { appid: appid }, + { $set: { state: DomainState.Deleted } }, + ) return doc } diff --git a/server/src/gateway/website-task.service.ts b/server/src/gateway/website-task.service.ts index a8634158a6..0b7ee4b9db 100644 --- a/server/src/gateway/website-task.service.ts +++ b/server/src/gateway/website-task.service.ts @@ -1,11 +1,5 @@ import { Injectable, Logger } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' -import { - BucketDomain, - DomainPhase, - DomainState, - WebsiteHosting, -} from '@prisma/client' import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' import { SystemDatabase } from 'src/database/system-database' import { RegionService } from 'src/region/region.service' @@ -14,6 +8,9 @@ import { ApisixService } from './apisix.service' import { ApisixCustomCertService } from './apisix-custom-cert.service' import { ObjectId } from 'mongodb' import { isConditionTrue } from 'src/utils/getter' +import { WebsiteHosting } from 'src/website/entities/website' +import { DomainPhase, DomainState } from './entities/runtime-domain' +import { BucketDomain } from './entities/bucket-domain' @Injectable() export class WebsiteTaskService { diff --git a/server/src/storage/bucket-task.service.ts b/server/src/storage/bucket-task.service.ts index bf54a56b74..052a54226a 100644 --- a/server/src/storage/bucket-task.service.ts +++ b/server/src/storage/bucket-task.service.ts @@ -1,10 +1,4 @@ import { Injectable, Logger } from '@nestjs/common' -import { - DomainPhase, - DomainState, - StorageBucket, - WebsiteHosting, -} from '@prisma/client' import { RegionService } from 'src/region/region.service' import * as assert from 'node:assert' import { Cron, CronExpression } from '@nestjs/schedule' @@ -12,6 +6,10 @@ import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' import { SystemDatabase } from 'src/database/system-database' import { MinioService } from './minio/minio.service' import { BucketDomainService } from 'src/gateway/bucket-domain.service' +import { StorageBucket } from './entities/storage-bucket' +import { StoragePhase, StorageState } from './entities/storage-user' +import { DomainState } from 'src/gateway/entities/runtime-domain' +import { WebsiteHosting } from 'src/website/entities/website' @Injectable() export class BucketTaskService { @@ -68,7 +66,7 @@ export class BucketTaskService { .collection('StorageBucket') .findOneAndUpdate( { - phase: DomainPhase.Creating, + phase: StoragePhase.Creating, lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, @@ -109,8 +107,10 @@ export class BucketTaskService { const updated = await db .collection('StorageBucket') .updateOne( - { _id: doc._id, phase: DomainPhase.Creating }, - { $set: { phase: DomainPhase.Created, lockedAt: TASK_LOCK_INIT_TIME } }, + { _id: doc._id, phase: StoragePhase.Creating }, + { + $set: { phase: StoragePhase.Created, lockedAt: TASK_LOCK_INIT_TIME }, + }, ) if (updated.modifiedCount > 0) @@ -129,7 +129,7 @@ export class BucketTaskService { .collection('StorageBucket') .findOneAndUpdate( { - phase: DomainPhase.Deleting, + phase: StoragePhase.Deleting, lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, @@ -156,7 +156,7 @@ export class BucketTaskService { // delete bucket domain const domain = await this.bucketDomainService.findOne(doc) if (domain) { - await this.bucketDomainService.delete(doc) + await this.bucketDomainService.deleteOne(doc) this.logger.debug('bucket domain deleted:', domain) } @@ -176,8 +176,10 @@ export class BucketTaskService { const updated = await db .collection('StorageBucket') .updateOne( - { _id: doc._id, phase: DomainPhase.Deleting }, - { $set: { phase: DomainPhase.Deleted, lockedAt: TASK_LOCK_INIT_TIME } }, + { _id: doc._id, phase: StoragePhase.Deleting }, + { + $set: { phase: StoragePhase.Deleted, lockedAt: TASK_LOCK_INIT_TIME }, + }, ) if (updated.modifiedCount > 0) @@ -193,12 +195,12 @@ export class BucketTaskService { await db.collection('StorageBucket').updateMany( { - state: DomainState.Active, - phase: DomainPhase.Deleted, + state: StorageState.Active, + phase: StoragePhase.Deleted, lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { - $set: { phase: DomainPhase.Creating, lockedAt: TASK_LOCK_INIT_TIME }, + $set: { phase: StoragePhase.Creating, lockedAt: TASK_LOCK_INIT_TIME }, }, ) } @@ -212,12 +214,12 @@ export class BucketTaskService { await db.collection('StorageBucket').updateMany( { - state: DomainState.Inactive, - phase: DomainPhase.Created, + state: StorageState.Inactive, + phase: StoragePhase.Created, lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { - $set: { phase: DomainPhase.Deleting, lockedAt: TASK_LOCK_INIT_TIME }, + $set: { phase: StoragePhase.Deleting, lockedAt: TASK_LOCK_INIT_TIME }, }, ) } @@ -232,17 +234,17 @@ export class BucketTaskService { await db.collection('StorageBucket').updateMany( { - state: DomainState.Deleted, - phase: { $in: [DomainPhase.Created, DomainPhase.Creating] }, + state: StorageState.Deleted, + phase: { $in: [StoragePhase.Created, StoragePhase.Creating] }, lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { - $set: { phase: DomainPhase.Deleting, lockedAt: TASK_LOCK_INIT_TIME }, + $set: { phase: StoragePhase.Deleting, lockedAt: TASK_LOCK_INIT_TIME }, }, ) await db .collection('StorageBucket') - .deleteMany({ state: DomainState.Deleted, phase: DomainPhase.Deleted }) + .deleteMany({ state: StorageState.Deleted, phase: StoragePhase.Deleted }) } } diff --git a/server/src/storage/bucket.controller.ts b/server/src/storage/bucket.controller.ts index 812fb13a6d..7cd5a5ae75 100644 --- a/server/src/storage/bucket.controller.ts +++ b/server/src/storage/bucket.controller.ts @@ -133,7 +133,7 @@ export class BucketController { throw new HttpException('bucket not found', HttpStatus.NOT_FOUND) } - const res = await this.bucketService.update(bucket, dto) + const res = await this.bucketService.updateOne(bucket, dto) if (!res) { return ResponseUtil.error('update bucket failed') } @@ -162,7 +162,7 @@ export class BucketController { ) } - const res = await this.bucketService.delete(bucket) + const res = await this.bucketService.deleteOne(bucket) if (!res) { return ResponseUtil.error('delete bucket failed') } diff --git a/server/src/storage/bucket.service.ts b/server/src/storage/bucket.service.ts index 9dd5766426..99d77a9acc 100644 --- a/server/src/storage/bucket.service.ts +++ b/server/src/storage/bucket.service.ts @@ -1,82 +1,112 @@ import { Injectable, Logger } from '@nestjs/common' -import { StorageBucket, StoragePhase, StorageState } from '@prisma/client' import { TASK_LOCK_INIT_TIME } from 'src/constants' -import { PrismaService } from '../prisma/prisma.service' import { RegionService } from '../region/region.service' import { CreateBucketDto } from './dto/create-bucket.dto' import { UpdateBucketDto } from './dto/update-bucket.dto' import { MinioService } from './minio/minio.service' import { Application } from 'src/application/entities/application' +import { SystemDatabase } from 'src/database/system-database' +import { StorageBucket, StorageWithRelations } from './entities/storage-bucket' +import { StoragePhase, StorageState } from './entities/storage-user' @Injectable() export class BucketService { private readonly logger = new Logger(BucketService.name) + private readonly db = SystemDatabase.db constructor( private readonly minioService: MinioService, private readonly regionService: RegionService, - private readonly prisma: PrismaService, ) {} async create(app: Application, dto: CreateBucketDto) { const bucketName = dto.fullname(app.appid) // create bucket in db - const bucket = await this.prisma.storageBucket.create({ - data: { - appid: app.appid, - name: bucketName, - policy: dto.policy, - shortName: dto.shortName, - state: StorageState.Active, - phase: StoragePhase.Creating, - lockedAt: TASK_LOCK_INIT_TIME, - }, + await this.db.collection('StorageBucket').insertOne({ + appid: app.appid, + name: bucketName, + policy: dto.policy, + shortName: dto.shortName, + state: StorageState.Active, + phase: StoragePhase.Creating, + lockedAt: TASK_LOCK_INIT_TIME, + updatedAt: new Date(), + createdAt: new Date(), }) - return bucket + return this.findOne(app.appid, bucketName) } async count(appid: string) { - const count = await this.prisma.storageBucket.count({ - where: { - appid, - }, - }) + const count = await this.db + .collection('StorageBucket') + .countDocuments({ appid }) return count } async findOne(appid: string, name: string) { - const bucket = await this.prisma.storageBucket.findFirst({ - where: { - appid, - name, - }, - include: { - domain: true, - websiteHosting: true, - }, - }) + const bucket = await this.db + .collection('StorageBucket') + .aggregate() + .match({ appid, name }) + .lookup({ + from: 'BucketDomain', + localField: 'name', + foreignField: 'bucketName', + as: 'domain', + }) + .unwind({ + path: '$domain', + preserveNullAndEmptyArrays: true, + }) + .lookup({ + from: 'WebsiteHosting', + localField: 'name', + foreignField: 'bucketName', + as: 'websiteHosting', + }) + .unwind({ + path: '$websiteHosting', + preserveNullAndEmptyArrays: true, + }) + .next() return bucket } async findAll(appid: string) { - const buckets = await this.prisma.storageBucket.findMany({ - where: { - appid, - }, - include: { - domain: true, - websiteHosting: true, - }, - }) + const buckets = await this.db + .collection('StorageBucket') + .aggregate() + .match({ appid }) + .lookup({ + from: 'BucketDomain', + localField: 'name', + foreignField: 'bucketName', + as: 'domain', + }) + .unwind({ + path: '$domain', + preserveNullAndEmptyArrays: true, + }) + .lookup({ + from: 'WebsiteHosting', + localField: 'name', + foreignField: 'bucketName', + as: 'websiteHosting', + }) + .unwind({ + path: '$websiteHosting', + preserveNullAndEmptyArrays: true, + }) + .toArray() return buckets } - async update(bucket: StorageBucket, dto: UpdateBucketDto) { + async updateOne(bucket: StorageBucket, dto: UpdateBucketDto) { // update bucket in minio const region = await this.regionService.findByAppId(bucket.appid) const out = await this.minioService.updateBucketPolicy( @@ -90,27 +120,23 @@ export class BucketService { } // update bucket in db - const res = await this.prisma.storageBucket.update({ - where: { - name: bucket.name, - }, - data: { - policy: dto.policy, - }, - }) + const res = await this.db + .collection('StorageBucket') + .findOneAndUpdate( + { appid: bucket.appid, name: bucket.name }, + { $set: { policy: dto.policy, updatedAt: new Date() } }, + ) return res } - async delete(bucket: StorageBucket) { - const res = await this.prisma.storageBucket.update({ - where: { - name: bucket.name, - }, - data: { - state: StorageState.Deleted, - }, - }) + async deleteOne(bucket: StorageBucket) { + const res = await this.db + .collection('StorageBucket') + .findOneAndUpdate( + { appid: bucket.appid, name: bucket.name }, + { $set: { state: StorageState.Deleted } }, + ) return res } diff --git a/server/src/storage/dto/create-bucket.dto.ts b/server/src/storage/dto/create-bucket.dto.ts index f3d7404104..ebddff3cd2 100644 --- a/server/src/storage/dto/create-bucket.dto.ts +++ b/server/src/storage/dto/create-bucket.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger' -import { BucketPolicy } from '@prisma/client' import { IsEnum, IsNotEmpty, Matches } from 'class-validator' +import { BucketPolicy } from '../entities/storage-bucket' export class CreateBucketDto { @ApiProperty({ diff --git a/server/src/storage/dto/update-bucket.dto.ts b/server/src/storage/dto/update-bucket.dto.ts index 7c3c2569c4..14e13bb1bd 100644 --- a/server/src/storage/dto/update-bucket.dto.ts +++ b/server/src/storage/dto/update-bucket.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger' -import { BucketPolicy } from '@prisma/client' import { IsEnum } from 'class-validator' +import { BucketPolicy } from '../entities/storage-bucket' export class UpdateBucketDto { @ApiProperty({ enum: BucketPolicy }) diff --git a/server/src/storage/entities/storage-bucket.ts b/server/src/storage/entities/storage-bucket.ts index 2584562c9f..f54a9cfd59 100644 --- a/server/src/storage/entities/storage-bucket.ts +++ b/server/src/storage/entities/storage-bucket.ts @@ -1,4 +1,7 @@ import { ObjectId } from 'mongodb' +import { StoragePhase, StorageState } from './storage-user' +import { WebsiteHosting } from 'src/website/entities/website' +import { BucketDomain } from 'src/gateway/entities/bucket-domain' export enum BucketPolicy { readwrite = 'readwrite', @@ -6,19 +9,6 @@ export enum BucketPolicy { private = 'private', } -export enum StoragePhase { - Creating = 'Creating', - Created = 'Created', - Deleting = 'Deleting', - Deleted = 'Deleted', -} - -export enum StorageState { - Active = 'Active', - Inactive = 'Inactive', - Deleted = 'Deleted', -} - export class StorageBucket { _id?: ObjectId appid: string @@ -31,3 +21,8 @@ export class StorageBucket { createdAt: Date updatedAt: Date } + +export type StorageWithRelations = StorageBucket & { + domain: BucketDomain + websiteHosting: WebsiteHosting +} diff --git a/server/src/storage/minio/minio.service.ts b/server/src/storage/minio/minio.service.ts index 95a4a2cab8..b9eba9b2de 100644 --- a/server/src/storage/minio/minio.service.ts +++ b/server/src/storage/minio/minio.service.ts @@ -8,13 +8,13 @@ import { PutBucketVersioningCommand, S3, } from '@aws-sdk/client-s3' -import { BucketPolicy } from '@prisma/client' import * as assert from 'node:assert' import * as cp from 'child_process' import { promisify } from 'util' import { MinioCommandExecOutput } from './types' import { MINIO_COMMON_USER_GROUP } from 'src/constants' import { Region } from 'src/region/entities/region' +import { BucketPolicy } from '../entities/storage-bucket' const exec = promisify(cp.exec) diff --git a/server/src/storage/storage.service.ts b/server/src/storage/storage.service.ts index 6e6413f485..f03cb15683 100644 --- a/server/src/storage/storage.service.ts +++ b/server/src/storage/storage.service.ts @@ -1,6 +1,4 @@ import { Injectable, Logger } from '@nestjs/common' -import { StoragePhase, StorageState, StorageUser } from '@prisma/client' -import { PrismaService } from 'src/prisma/prisma.service' import { GenerateAlphaNumericPassword } from 'src/utils/random' import { MinioService } from './minio/minio.service' import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts' @@ -8,6 +6,12 @@ import { RegionService } from 'src/region/region.service' import { TASK_LOCK_INIT_TIME } from 'src/constants' import { Region } from 'src/region/entities/region' import { SystemDatabase } from 'src/database/system-database' +import { + StoragePhase, + StorageState, + StorageUser, +} from './entities/storage-user' +import { StorageBucket } from './entities/storage-bucket' @Injectable() export class StorageService { @@ -17,7 +21,6 @@ export class StorageService { constructor( private readonly minioService: MinioService, private readonly regionService: RegionService, - private readonly prisma: PrismaService, ) {} async create(appid: string) { @@ -28,49 +31,43 @@ export class StorageService { // create storage user in minio if not exists const minioUser = await this.minioService.getUser(region, accessKey) if (!minioUser) { - const r0 = await this.minioService.createUser( + const res = await this.minioService.createUser( region, accessKey, secretKey, ) - if (r0.error) { - this.logger.error(r0.error) + if (res.error) { + this.logger.error(res.error) return null } } // add storage user to common user group in minio - const r1 = await this.minioService.addUserToGroup(region, accessKey) - if (r1.error) { - this.logger.error(r1.error) + const res = await this.minioService.addUserToGroup(region, accessKey) + if (res.error) { + this.logger.error(res.error) return null } // create storage user in database - const user = await this.prisma.storageUser.create({ - data: { - accessKey, - secretKey, - state: StorageState.Active, - phase: StoragePhase.Created, - lockedAt: TASK_LOCK_INIT_TIME, - application: { - connect: { - appid: appid, - }, - }, - }, + await this.db.collection('StorageUser').insertOne({ + appid, + accessKey, + secretKey, + state: StorageState.Active, + phase: StoragePhase.Created, + lockedAt: TASK_LOCK_INIT_TIME, + updatedAt: new Date(), + createdAt: new Date(), }) - return user + return await this.findOne(appid) } async findOne(appid: string) { - const user = await this.prisma.storageUser.findUnique({ - where: { - appid, - }, - }) + const user = await this.db + .collection('StorageUser') + .findOne({ appid }) return user } @@ -79,31 +76,31 @@ export class StorageService { // delete user in minio const region = await this.regionService.findByAppId(appid) - // delete buckets & files in minio - const count = await this.prisma.storageBucket.count({ - where: { appid }, - }) + // delete buckets & files + const count = await this.db + .collection('StorageBucket') + .countDocuments({ appid }) if (count > 0) { - await this.prisma.storageBucket.updateMany({ - where: { - appid, - state: { not: StorageState.Deleted }, - }, - data: { state: StorageState.Deleted }, - }) - + await this.db + .collection('StorageBucket') + .updateMany( + { appid, state: { $ne: StorageState.Deleted } }, + { $set: { state: StorageState.Deleted } }, + ) + + // just return to wait for buckets deletion return } + // delete user in minio await this.minioService.deleteUser(region, appid) - const user = await this.prisma.storageUser.delete({ - where: { - appid, - }, - }) + // delete user in database + const res = await this.db + .collection('StorageUser') + .findOneAndDelete({ appid }) - return user + return res.value } /** diff --git a/server/src/website/dto/create-website.dto.ts b/server/src/website/dto/create-website.dto.ts index 8d32e9a206..d18673395b 100644 --- a/server/src/website/dto/create-website.dto.ts +++ b/server/src/website/dto/create-website.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger' -import { DomainState } from '@prisma/client' import { IsEnum, IsNotEmpty, IsString } from 'class-validator' +import { DomainState } from 'src/gateway/entities/runtime-domain' export class CreateWebsiteDto { @ApiProperty() diff --git a/server/src/website/entities/website.ts b/server/src/website/entities/website.ts new file mode 100644 index 0000000000..93b05cc48a --- /dev/null +++ b/server/src/website/entities/website.ts @@ -0,0 +1,24 @@ +import { ObjectId } from 'mongodb' +import { DomainPhase, DomainState } from 'src/gateway/entities/runtime-domain' +import { StorageBucket } from 'src/storage/entities/storage-bucket' + +export class WebsiteHosting { + _id?: ObjectId + appid: string + bucketName: string + domain: string + isCustom: boolean + state: DomainState + phase: DomainPhase + createdAt: Date + updatedAt: Date + lockedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} + +export type WebsiteHostingWithBucket = WebsiteHosting & { + bucket: StorageBucket +} diff --git a/server/src/website/website.controller.ts b/server/src/website/website.controller.ts index 27acba0b13..24522519f9 100644 --- a/server/src/website/website.controller.ts +++ b/server/src/website/website.controller.ts @@ -22,7 +22,8 @@ import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' import { ResponseUtil } from 'src/utils/response' import { BundleService } from 'src/region/bundle.service' import { BucketService } from 'src/storage/bucket.service' -import { DomainState } from '@prisma/client' +import { ObjectId } from 'mongodb' +import { DomainState } from 'src/gateway/entities/runtime-domain' @ApiTags('WebsiteHosting') @ApiBearerAuth('Authorization') @@ -104,7 +105,7 @@ export class WebsiteController { @UseGuards(JwtAuthGuard, ApplicationAuthGuard) @Get(':id') async findOne(@Param('appid') _appid: string, @Param('id') id: string) { - const site = await this.websiteService.findOne(id) + const site = await this.websiteService.findOne(new ObjectId(id)) if (!site) { return ResponseUtil.error('website hosting not found') } @@ -129,7 +130,7 @@ export class WebsiteController { @Body() dto: BindCustomDomainDto, ) { // get website - const site = await this.websiteService.findOne(id) + const site = await this.websiteService.findOne(new ObjectId(id)) if (!site) { return ResponseUtil.error('website hosting not found') } @@ -144,7 +145,7 @@ export class WebsiteController { // bind domain const binded = await this.websiteService.bindCustomDomain( - site.id, + site._id, dto.domain, ) if (!binded) { @@ -170,7 +171,7 @@ export class WebsiteController { @Body() dto: BindCustomDomainDto, ) { // get website - const site = await this.websiteService.findOne(id) + const site = await this.websiteService.findOne(new ObjectId(id)) if (!site) { return ResponseUtil.error('website hosting not found') } @@ -194,12 +195,12 @@ export class WebsiteController { @UseGuards(JwtAuthGuard, ApplicationAuthGuard) @Delete(':id') async remove(@Param('appid') _appid: string, @Param('id') id: string) { - const site = await this.websiteService.findOne(id) + const site = await this.websiteService.findOne(new ObjectId(id)) if (!site) { return ResponseUtil.error('website hosting not found') } - const deleted = await this.websiteService.remove(site.id) + const deleted = await this.websiteService.removeOne(site._id) if (!deleted) { return ResponseUtil.error('failed to delete website hosting') } diff --git a/server/src/website/website.service.ts b/server/src/website/website.service.ts index b876bbbda0..5cd77d665e 100644 --- a/server/src/website/website.service.ts +++ b/server/src/website/website.service.ts @@ -1,20 +1,21 @@ import { Injectable, Logger } from '@nestjs/common' -import { DomainPhase, DomainState, WebsiteHosting } from '@prisma/client' import { TASK_LOCK_INIT_TIME } from 'src/constants' -import { PrismaService } from 'src/prisma/prisma.service' import { RegionService } from 'src/region/region.service' import { CreateWebsiteDto } from './dto/create-website.dto' import * as assert from 'node:assert' import * as dns from 'node:dns' +import { SystemDatabase } from 'src/database/system-database' +import { WebsiteHosting, WebsiteHostingWithBucket } from './entities/website' +import { DomainPhase, DomainState } from 'src/gateway/entities/runtime-domain' +import { ObjectId } from 'mongodb' +import { BucketDomain } from 'src/gateway/entities/bucket-domain' @Injectable() export class WebsiteService { private readonly logger = new Logger(WebsiteService.name) + private readonly db = SystemDatabase.db - constructor( - private readonly prisma: PrismaService, - private readonly regionService: RegionService, - ) {} + constructor(private readonly regionService: RegionService) {} async create(appid: string, dto: CreateWebsiteDto) { const region = await this.regionService.findByAppId(appid) @@ -23,69 +24,73 @@ export class WebsiteService { // generate default website domain const domain = `${dto.bucketName}.${region.gatewayConf.websiteDomain}` - const website = await this.prisma.websiteHosting.create({ - data: { + const res = await this.db + .collection('WebsiteHosting') + .insertOne({ appid: appid, + bucketName: dto.bucketName, domain: domain, isCustom: false, state: DomainState.Active, phase: DomainPhase.Creating, lockedAt: TASK_LOCK_INIT_TIME, - bucket: { - connect: { - name: dto.bucketName, - }, - }, - }, - }) + createdAt: new Date(), + updatedAt: new Date(), + }) - return website + return await this.findOne(res.insertedId) } async count(appid: string) { - const count = await this.prisma.websiteHosting.count({ - where: { - appid: appid, - }, - }) + const count = await this.db + .collection('WebsiteHosting') + .countDocuments({ appid }) return count } async findAll(appid: string) { - const websites = await this.prisma.websiteHosting.findMany({ - where: { - appid: appid, - }, - include: { - bucket: true, - }, - }) + const websites = await this.db + .collection('WebsiteHosting') + .aggregate() + .match({ appid }) + .lookup({ + from: 'Bucket', + localField: 'bucketName', + foreignField: 'name', + as: 'bucket', + }) + .unwind('$bucket') + .toArray() return websites } - async findOne(id: string) { - const website = await this.prisma.websiteHosting.findFirst({ - where: { - id, - }, - include: { - bucket: true, - }, - }) + async findOne(id: ObjectId) { + const website = await this.db + .collection('WebsiteHosting') + .aggregate() + .match({ _id: id }) + .lookup({ + from: 'Bucket', + localField: 'bucketName', + foreignField: 'name', + as: 'bucket', + }) + .unwind('$bucket') + .next() return website } async checkResolved(website: WebsiteHosting, customDomain: string) { // get bucket domain - const bucketDomain = await this.prisma.bucketDomain.findFirst({ - where: { + const bucketDomain = await this.db + .collection('BucketDomain') + .findOne({ appid: website.appid, bucketName: website.bucketName, - }, - }) + }) const cnameTarget = bucketDomain.domain @@ -97,55 +102,37 @@ export class WebsiteService { return }) - if (!result) { - return false - } - - if (false === (result || []).includes(cnameTarget)) { - return false - } - + if (!result) return false + if (false === (result || []).includes(cnameTarget)) return false return true } - async bindCustomDomain(id: string, domain: string) { - const website = await this.prisma.websiteHosting.update({ - where: { - id, - }, - data: { - domain: domain, - isCustom: true, - phase: DomainPhase.Deleting, - }, - }) + async bindCustomDomain(id: ObjectId, domain: string) { + const res = await this.db + .collection('WebsiteHosting') + .findOneAndUpdate( + { _id: id }, + { + $set: { domain: domain, isCustom: true, phase: DomainPhase.Deleting }, + }, + ) - return website + return res.value } - async remove(id: string) { - const website = await this.prisma.websiteHosting.update({ - where: { - id, - }, - data: { - state: DomainState.Deleted, - }, - }) + async removeOne(id: ObjectId) { + const res = await this.db + .collection('WebsiteHosting') + .findOneAndUpdate({ _id: id }, { $set: { state: DomainState.Deleted } }) - return website + return res.value } async removeAll(appid: string) { - const websites = await this.prisma.websiteHosting.updateMany({ - where: { - appid, - }, - data: { - state: DomainState.Deleted, - }, - }) + const res = await this.db + .collection('WebsiteHosting') + .updateMany({ appid }, { $set: { state: DomainState.Deleted } }) - return websites + return res } } From 1f51c57d50fec800971a20f4a6b64b68889a4bed Mon Sep 17 00:00:00 2001 From: maslow Date: Tue, 16 May 2023 15:26:52 +0000 Subject: [PATCH 17/48] remove prisma in dependency module --- server/src/application/application.service.ts | 2 +- server/src/application/environment.service.ts | 10 +++- .../src/dependency/dependency.controller.ts | 2 +- server/src/dependency/dependency.service.ts | 47 +++++++++++-------- server/src/gateway/bucket-domain.service.ts | 7 ++- server/src/gateway/runtime-domain.service.ts | 2 +- server/src/storage/bucket.service.ts | 2 +- server/src/storage/storage.service.ts | 2 +- server/src/website/website.service.ts | 12 ++++- 9 files changed, 56 insertions(+), 30 deletions(-) diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index c3ead41e16..10cd6edb77 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -242,7 +242,7 @@ export class ApplicationService { .collection('Application') .findOneAndUpdate( { appid }, - { $set: { phase: ApplicationPhase.Deleted } }, + { $set: { phase: ApplicationPhase.Deleted, updatedAt: new Date() } }, ) return doc.value diff --git a/server/src/application/environment.service.ts b/server/src/application/environment.service.ts index 5f0f29738a..87ff151f33 100644 --- a/server/src/application/environment.service.ts +++ b/server/src/application/environment.service.ts @@ -15,7 +15,10 @@ export class EnvironmentVariableService { async updateAll(appid: string, dto: CreateEnvironmentDto[]) { const res = await this.db .collection('ApplicationConfiguration') - .findOneAndUpdate({ appid }, { $set: { environments: dto } }) + .findOneAndUpdate( + { appid }, + { $set: { environments: dto, updatedAt: new Date() } }, + ) assert(res?.value, 'application configuration not found') await this.confService.publish(res.value) @@ -39,7 +42,10 @@ export class EnvironmentVariableService { const res = await this.db .collection('ApplicationConfiguration') - .findOneAndUpdate({ appid }, { $set: { environments: origin } }) + .findOneAndUpdate( + { appid }, + { $set: { environments: origin, updatedAt: new Date() } }, + ) assert(res?.value, 'application configuration not found') await this.confService.publish(res.value) diff --git a/server/src/dependency/dependency.controller.ts b/server/src/dependency/dependency.controller.ts index dda782f779..d705b2a51a 100644 --- a/server/src/dependency/dependency.controller.ts +++ b/server/src/dependency/dependency.controller.ts @@ -96,7 +96,7 @@ export class DependencyController { @Param('appid') appid: string, @Body() dto: DeleteDependencyDto, ) { - const res = await this.depsService.remove(appid, dto.name) + const res = await this.depsService.removeOne(appid, dto.name) return ResponseUtil.ok(res) } } diff --git a/server/src/dependency/dependency.service.ts b/server/src/dependency/dependency.service.ts index 8802ee3f9b..5edbe03c96 100644 --- a/server/src/dependency/dependency.service.ts +++ b/server/src/dependency/dependency.service.ts @@ -1,9 +1,10 @@ import { Injectable, Logger } from '@nestjs/common' -import { PrismaService } from 'src/prisma/prisma.service' import { RUNTIME_BUILTIN_DEPENDENCIES } from 'src/runtime-builtin-deps' import * as npa from 'npm-package-arg' import { CreateDependencyDto } from './dto/create-dependency.dto' import { UpdateDependencyDto } from './dto/update-dependency.dto' +import { SystemDatabase } from 'src/database/system-database' +import { ApplicationConfiguration } from 'src/application/entities/application-configuration' export class Dependency { name: string @@ -15,8 +16,7 @@ export class Dependency { @Injectable() export class DependencyService { private readonly logger = new Logger(DependencyService.name) - - constructor(private readonly prisma: PrismaService) {} + private readonly db = SystemDatabase.db /** * Get app merged dependencies in `Dependency` array @@ -59,10 +59,13 @@ export class DependencyService { // add const new_deps = dto.map((dep) => `${dep.name}@${dep.spec}`) const deps = extras.concat(new_deps) - await this.prisma.applicationConfiguration.update({ - where: { appid }, - data: { dependencies: deps }, - }) + + await this.db + .collection('ApplicationConfiguration') + .updateOne( + { appid }, + { $set: { dependencies: deps, updatedAt: new Date() } }, + ) return true } @@ -87,15 +90,18 @@ export class DependencyService { }) const deps = filtered.concat(new_deps) - await this.prisma.applicationConfiguration.update({ - where: { appid }, - data: { dependencies: deps }, - }) + + await this.db + .collection('ApplicationConfiguration') + .updateOne( + { appid }, + { $set: { dependencies: deps, updatedAt: new Date() } }, + ) return true } - async remove(appid: string, name: string) { + async removeOne(appid: string, name: string) { const deps = await this.getExtras(appid) const filtered = deps.filter((dep) => { const r = npa(dep) @@ -104,10 +110,13 @@ export class DependencyService { if (filtered.length === deps.length) return false - await this.prisma.applicationConfiguration.update({ - where: { appid }, - data: { dependencies: filtered }, - }) + await this.db + .collection('ApplicationConfiguration') + .updateOne( + { appid }, + { $set: { dependencies: filtered, updatedAt: new Date() } }, + ) + return true } @@ -117,9 +126,9 @@ export class DependencyService { * @returns */ private async getExtras(appid: string) { - const conf = await this.prisma.applicationConfiguration.findUnique({ - where: { appid }, - }) + const conf = await this.db + .collection('ApplicationConfiguration') + .findOne({ appid }) const deps = conf?.dependencies ?? [] return deps diff --git a/server/src/gateway/bucket-domain.service.ts b/server/src/gateway/bucket-domain.service.ts index e61d8d95e6..f23097eb84 100644 --- a/server/src/gateway/bucket-domain.service.ts +++ b/server/src/gateway/bucket-domain.service.ts @@ -66,7 +66,7 @@ export class BucketDomainService { .collection('BucketDomain') .findOneAndUpdate( { _id: bucket._id }, - { $set: { state: DomainState.Deleted } }, + { $set: { state: DomainState.Deleted, updatedAt: new Date() } }, ) return await this.findOne(bucket) @@ -75,7 +75,10 @@ export class BucketDomainService { async deleteAll(appid: string) { const res = await this.db .collection('BucketDomain') - .updateMany({ appid }, { $set: { state: DomainState.Deleted } }) + .updateMany( + { appid }, + { $set: { state: DomainState.Deleted, updatedAt: new Date() } }, + ) return res } diff --git a/server/src/gateway/runtime-domain.service.ts b/server/src/gateway/runtime-domain.service.ts index 789868322c..b9e6a152d4 100644 --- a/server/src/gateway/runtime-domain.service.ts +++ b/server/src/gateway/runtime-domain.service.ts @@ -58,7 +58,7 @@ export class RuntimeDomainService { .collection('RuntimeDomain') .findOneAndUpdate( { appid: appid }, - { $set: { state: DomainState.Deleted } }, + { $set: { state: DomainState.Deleted, updatedAt: new Date() } }, ) return doc diff --git a/server/src/storage/bucket.service.ts b/server/src/storage/bucket.service.ts index 99d77a9acc..22caf0b88e 100644 --- a/server/src/storage/bucket.service.ts +++ b/server/src/storage/bucket.service.ts @@ -135,7 +135,7 @@ export class BucketService { .collection('StorageBucket') .findOneAndUpdate( { appid: bucket.appid, name: bucket.name }, - { $set: { state: StorageState.Deleted } }, + { $set: { state: StorageState.Deleted, updatedAt: new Date() } }, ) return res diff --git a/server/src/storage/storage.service.ts b/server/src/storage/storage.service.ts index f03cb15683..5f580f47ce 100644 --- a/server/src/storage/storage.service.ts +++ b/server/src/storage/storage.service.ts @@ -85,7 +85,7 @@ export class StorageService { .collection('StorageBucket') .updateMany( { appid, state: { $ne: StorageState.Deleted } }, - { $set: { state: StorageState.Deleted } }, + { $set: { state: StorageState.Deleted, updatedAt: new Date() } }, ) // just return to wait for buckets deletion diff --git a/server/src/website/website.service.ts b/server/src/website/website.service.ts index 5cd77d665e..b2995d5ecb 100644 --- a/server/src/website/website.service.ts +++ b/server/src/website/website.service.ts @@ -113,7 +113,12 @@ export class WebsiteService { .findOneAndUpdate( { _id: id }, { - $set: { domain: domain, isCustom: true, phase: DomainPhase.Deleting }, + $set: { + domain: domain, + isCustom: true, + phase: DomainPhase.Deleting, + updatedAt: new Date(), + }, }, ) @@ -131,7 +136,10 @@ export class WebsiteService { async removeAll(appid: string) { const res = await this.db .collection('WebsiteHosting') - .updateMany({ appid }, { $set: { state: DomainState.Deleted } }) + .updateMany( + { appid }, + { $set: { state: DomainState.Deleted, updatedAt: new Date() } }, + ) return res } From 5938807b1701687a839075bf14a7840b343acfe3 Mon Sep 17 00:00:00 2001 From: maslow Date: Wed, 17 May 2023 07:59:58 +0000 Subject: [PATCH 18/48] remove prisma in account module --- server/src/account/account.controller.ts | 103 +++++++++++------- server/src/account/account.service.ts | 69 +++++++----- .../account/dto/create-charge-order.dto.ts | 2 +- .../account/entities/account-charge-order.ts | 40 +++++++ .../account/entities/account-transaction.ts | 16 +++ server/src/account/entities/account.ts | 19 ++++ .../src/account/entities/payment-channel.ts | 17 +++ .../payment/payment-channel.service.ts | 49 ++++----- server/src/account/payment/types.ts | 4 + 9 files changed, 224 insertions(+), 95 deletions(-) create mode 100644 server/src/account/entities/account-charge-order.ts create mode 100644 server/src/account/entities/account-transaction.ts create mode 100644 server/src/account/entities/account.ts create mode 100644 server/src/account/entities/payment-channel.ts diff --git a/server/src/account/account.controller.ts b/server/src/account/account.controller.ts index 920de193b9..d64172896c 100644 --- a/server/src/account/account.controller.ts +++ b/server/src/account/account.controller.ts @@ -10,19 +10,26 @@ import { UseGuards, } from '@nestjs/common' import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' -import { AccountChargePhase } from '@prisma/client' import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' -import { PrismaService } from 'src/prisma/prisma.service' import { IRequest } from 'src/utils/interface' import { ResponseUtil } from 'src/utils/response' import { AccountService } from './account.service' import { CreateChargeOrderDto } from './dto/create-charge-order.dto' import { PaymentChannelService } from './payment/payment-channel.service' -import { WeChatPayOrderResponse, WeChatPayTradeState } from './payment/types' +import { + WeChatPayChargeOrder, + WeChatPayOrderResponse, + WeChatPayTradeState, +} from './payment/types' import { WeChatPayService } from './payment/wechat-pay.service' import { Response } from 'express' import * as assert from 'assert' import { ServerConfig } from 'src/constants' +import { AccountChargePhase } from './entities/account-charge-order' +import { ObjectId } from 'mongodb' +import { SystemDatabase } from 'src/database/system-database' +import { Account } from './entities/account' +import { AccountTransaction } from './entities/account-transaction' @ApiTags('Account') @Controller('accounts') @@ -34,7 +41,6 @@ export class AccountController { private readonly accountService: AccountService, private readonly paymentService: PaymentChannelService, private readonly wechatPayService: WeChatPayService, - private readonly prisma: PrismaService, ) {} /** @@ -57,7 +63,10 @@ export class AccountController { @Get('charge-order/:id') async getChargeOrder(@Req() req: IRequest, @Param('id') id: string) { const user = req.user - const data = await this.accountService.findOneChargeOrder(user.id, id) + const data = await this.accountService.findOneChargeOrder( + user.id, + new ObjectId(id), + ) return data } @@ -82,7 +91,7 @@ export class AccountController { // invoke payment const result = await this.accountService.pay( channel, - order.id, + order._id, amount, currency, `${ServerConfig.SITE_NAME} recharge`, @@ -124,15 +133,17 @@ export class AccountController { this.logger.debug(result) - const tradeOrderId = result.out_trade_no + const db = SystemDatabase.db + + const tradeOrderId = new ObjectId(result.out_trade_no) if (result.trade_state !== WeChatPayTradeState.SUCCESS) { - await this.prisma.accountChargeOrder.update({ - where: { id: tradeOrderId }, - data: { - phase: AccountChargePhase.Failed, - result: result as any, - }, - }) + await db + .collection('AccountChargeOrder') + .updateOne( + { _id: tradeOrderId }, + { $set: { phase: AccountChargePhase.Failed, result: result } }, + ) + this.logger.log( `wechatpay order failed: ${tradeOrderId} ${result.trade_state}`, ) @@ -140,48 +151,62 @@ export class AccountController { } // start transaction - await this.prisma.$transaction(async (tx) => { + const client = SystemDatabase.client + const session = client.startSession() + await session.withTransaction(async () => { // get order - const order = await tx.accountChargeOrder.findFirst({ - where: { id: tradeOrderId, phase: AccountChargePhase.Pending }, - }) + const order = await db + .collection('AccountChargeOrder') + .findOne( + { _id: tradeOrderId, phase: AccountChargePhase.Pending }, + { session }, + ) if (!order) { this.logger.error(`wechatpay order not found: ${tradeOrderId}`) return } // update order to success - const res = await tx.accountChargeOrder.updateMany({ - where: { id: tradeOrderId, phase: AccountChargePhase.Pending }, - data: { phase: AccountChargePhase.Paid, result: result as any }, - }) - - if (res.count === 0) { + const res = await db + .collection('AccountChargeOrder') + .updateOne( + { _id: tradeOrderId, phase: AccountChargePhase.Pending }, + { $set: { phase: AccountChargePhase.Paid, result: result } }, + { session }, + ) + + if (res.modifiedCount === 0) { this.logger.error(`wechatpay order not found: ${tradeOrderId}`) return } // get account - const account = await tx.account.findFirst({ - where: { id: order.accountId }, - }) - assert(account, `account not found ${order.accountId}`) + const account = await db + .collection('Account') + .findOne({ _id: order.accountId }, { session }) + assert(account, `account not found: ${order.accountId}`) // update account balance - await tx.account.update({ - where: { id: order.accountId }, - data: { balance: { increment: order.amount } }, - }) - - // create account transaction - await tx.accountTransaction.create({ - data: { + await db + .collection('Account') + .updateOne( + { _id: order.accountId }, + { $inc: { balance: order.amount } }, + { session }, + ) + + // create transaction + await db.collection('AccountTransaction').insertOne( + { accountId: order.accountId, amount: order.amount, - balance: order.amount + account.balance, - message: 'account charge', + balance: account.balance + order.amount, + message: 'Recharge by WeChat Pay', + orderId: order._id, + createdAt: new Date(), }, - }) + { session }, + ) this.logger.log(`wechatpay order success: ${tradeOrderId}`) }) diff --git a/server/src/account/account.service.ts b/server/src/account/account.service.ts index affb080c52..49bec830d9 100644 --- a/server/src/account/account.service.ts +++ b/server/src/account/account.service.ts @@ -1,46 +1,50 @@ import { Injectable, Logger } from '@nestjs/common' -import { PrismaService } from 'src/prisma/prisma.service' import * as assert from 'assert' +import { WeChatPayService } from './payment/wechat-pay.service' +import { PaymentChannelService } from './payment/payment-channel.service' +import { TASK_LOCK_INIT_TIME } from 'src/constants' +import { SystemDatabase } from 'src/database/system-database' +import { Account, BaseState } from './entities/account' +import { ObjectId } from 'mongodb' import { + AccountChargeOrder, AccountChargePhase, Currency, PaymentChannelType, -} from '@prisma/client' -import { WeChatPayService } from './payment/wechat-pay.service' -import { PaymentChannelService } from './payment/payment-channel.service' -import { TASK_LOCK_INIT_TIME } from 'src/constants' +} from './entities/account-charge-order' @Injectable() export class AccountService { private readonly logger = new Logger(AccountService.name) + private readonly db = SystemDatabase.db constructor( - private readonly prisma: PrismaService, private readonly wechatPayService: WeChatPayService, private readonly chanelService: PaymentChannelService, ) {} - async create(userid: string) { - const account = await this.prisma.account.create({ - data: { - balance: 0, - createdBy: userid, - }, + async create(userid: string): Promise { + await this.db.collection('Account').insertOne({ + balance: 0, + state: BaseState.Active, + createdBy: new ObjectId(userid), + createdAt: new Date(), + updatedAt: new Date(), }) - return account + return await this.findOne(userid) } async findOne(userid: string) { - const account = await this.prisma.account.findUnique({ - where: { createdBy: userid }, - }) + const account = await this.db + .collection('Account') + .findOne({ createdBy: new ObjectId(userid) }) if (account) { return account } - return this.create(userid) + return await this.create(userid) } async createChargeOrder( @@ -53,32 +57,37 @@ export class AccountService { assert(account, 'Account not found') // create charge order - const order = await this.prisma.accountChargeOrder.create({ - data: { - accountId: account.id, + await this.db + .collection('AccountChargeOrder') + .insertOne({ + accountId: account._id, amount, currency: currency, phase: AccountChargePhase.Pending, channel: channel, - createdBy: userid, + createdBy: new ObjectId(userid), lockedAt: TASK_LOCK_INIT_TIME, - }, - }) + createdAt: new Date(), + updatedAt: new Date(), + }) - return order + return await this.findOneChargeOrder(userid, account._id) } - async findOneChargeOrder(userid: string, id: string) { - const order = await this.prisma.accountChargeOrder.findFirst({ - where: { id, createdBy: userid }, - }) + async findOneChargeOrder(userid: string, id: ObjectId) { + const order = await this.db + .collection('AccountChargeOrder') + .findOne({ + _id: id, + createdBy: new ObjectId(userid), + }) return order } async pay( channel: PaymentChannelType, - orderNumber: string, + orderNumber: ObjectId, amount: number, currency: Currency, description = 'laf account charge', @@ -90,7 +99,7 @@ export class AccountService { mchid: spec.mchid, appid: spec.appid, description, - out_trade_no: orderNumber, + out_trade_no: orderNumber.toString(), notify_url: this.wechatPayService.getNotifyUrl(), amount: { total: amount, diff --git a/server/src/account/dto/create-charge-order.dto.ts b/server/src/account/dto/create-charge-order.dto.ts index 91be6e7c3f..1aa048e774 100644 --- a/server/src/account/dto/create-charge-order.dto.ts +++ b/server/src/account/dto/create-charge-order.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger' -import { Currency, PaymentChannelType } from '@prisma/client' import { IsEnum, IsInt, IsPositive, IsString, Max, Min } from 'class-validator' +import { Currency, PaymentChannelType } from '../entities/account-charge-order' export class CreateChargeOrderDto { @ApiProperty({ example: 1000 }) diff --git a/server/src/account/entities/account-charge-order.ts b/server/src/account/entities/account-charge-order.ts new file mode 100644 index 0000000000..5d636065ac --- /dev/null +++ b/server/src/account/entities/account-charge-order.ts @@ -0,0 +1,40 @@ +import { ObjectId } from 'mongodb' + +export enum Currency { + CNY = 'CNY', + USD = 'USD', +} + +export enum AccountChargePhase { + Pending = 'Pending', + Paid = 'Paid', + Failed = 'Failed', +} + +export enum PaymentChannelType { + Manual = 'Manual', + Alipay = 'Alipay', + WeChat = 'WeChat', + Stripe = 'Stripe', + Paypal = 'Paypal', + Google = 'Google', +} + +export class AccountChargeOrder { + _id?: ObjectId + accountId: ObjectId + amount: number + currency: Currency + phase: AccountChargePhase + channel: PaymentChannelType + result?: R + message?: string + createdAt: Date + lockedAt: Date + updatedAt: Date + createdBy: ObjectId + + constructor(partial: Partial>) { + Object.assign(this, partial) + } +} diff --git a/server/src/account/entities/account-transaction.ts b/server/src/account/entities/account-transaction.ts new file mode 100644 index 0000000000..d819999e1c --- /dev/null +++ b/server/src/account/entities/account-transaction.ts @@ -0,0 +1,16 @@ +import { ObjectId } from 'mongodb' + +export class AccountTransaction { + _id?: ObjectId + accountId: ObjectId + amount: number + balance: number + message: string + orderId?: ObjectId + createdAt: Date + updatedAt?: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/account/entities/account.ts b/server/src/account/entities/account.ts new file mode 100644 index 0000000000..6ef9d1ed05 --- /dev/null +++ b/server/src/account/entities/account.ts @@ -0,0 +1,19 @@ +import { ObjectId } from 'mongodb' + +export enum BaseState { + Active = 'Active', + Inactive = 'Inactive', +} + +export class Account { + _id?: ObjectId + balance: number + state: BaseState + createdAt: Date + updatedAt: Date + createdBy: ObjectId + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/account/entities/payment-channel.ts b/server/src/account/entities/payment-channel.ts new file mode 100644 index 0000000000..30e7264c17 --- /dev/null +++ b/server/src/account/entities/payment-channel.ts @@ -0,0 +1,17 @@ +import { ObjectId } from 'mongodb' +import { PaymentChannelType } from './account-charge-order' +import { BaseState } from './account' + +export class PaymentChannel { + _id?: ObjectId + type: PaymentChannelType + name: string + spec: S + state: BaseState + createdAt: Date + updatedAt: Date + + constructor(partial: Partial>) { + Object.assign(this, partial) + } +} diff --git a/server/src/account/payment/payment-channel.service.ts b/server/src/account/payment/payment-channel.service.ts index d94eafd0f5..66b772b5e0 100644 --- a/server/src/account/payment/payment-channel.service.ts +++ b/server/src/account/payment/payment-channel.service.ts @@ -1,47 +1,46 @@ import { Injectable, Logger } from '@nestjs/common' -import { PaymentChannelType } from '@prisma/client' -import { PrismaService } from 'src/prisma/prisma.service' import { WeChatPaySpec } from './types' +import { SystemDatabase } from 'src/database/system-database' +import { PaymentChannel } from '../entities/payment-channel' +import { BaseState } from '../entities/account' +import { PaymentChannelType } from '../entities/account-charge-order' @Injectable() export class PaymentChannelService { private readonly logger = new Logger(PaymentChannelService.name) - - constructor(private readonly prisma: PrismaService) {} + private readonly db = SystemDatabase.db /** * Get all payment channels * @returns */ async findAll() { - const res = await this.prisma.paymentChannel.findMany({ - where: { - state: 'Inactive', - }, - select: { - id: true, - type: true, - name: true, - state: true, - /** - * Security Warning: DO NOT response sensitive information to client. - * KEEP IT false! - */ - spec: false, - }, - }) + const res = await this.db + .collection('PaymentChannel') + .find( + { state: BaseState.Active }, + { + projection: { + // Security Warning: DO NOT response sensitive information to client. + // KEEP IT false! + spec: false, + }, + }, + ) + .toArray() + return res } - async getWeChatPaySpec(): Promise { - const res = await this.prisma.paymentChannel.findFirst({ - where: { type: PaymentChannelType.WeChat }, - }) + async getWeChatPaySpec() { + const res = await this.db + .collection>('PaymentChannel') + .findOne({ type: PaymentChannelType.WeChat }) if (!res) { throw new Error('No WeChat Pay channel found') } - return res.spec as any + return res.spec } } diff --git a/server/src/account/payment/types.ts b/server/src/account/payment/types.ts index d3bc9aa6b8..82e633e048 100644 --- a/server/src/account/payment/types.ts +++ b/server/src/account/payment/types.ts @@ -1,3 +1,5 @@ +import { AccountChargeOrder } from '../entities/account-charge-order' + export interface WeChatPaySpec { mchid: string appid: string @@ -63,3 +65,5 @@ export interface WeChatPayDecryptedResult { payer_currency: string } } + +export type WeChatPayChargeOrder = AccountChargeOrder From 7e3fbf6478c61e12d2f66b2842c9b20dbd92df84 Mon Sep 17 00:00:00 2001 From: maslow Date: Wed, 17 May 2023 08:46:28 +0000 Subject: [PATCH 19/48] remove prisma in instance module --- server/src/constants.ts | 1 - server/src/instance/instance-task.service.ts | 16 +- server/src/instance/instance.module.ts | 3 +- server/src/instance/instance.service.ts | 231 ++++++++----------- 4 files changed, 105 insertions(+), 146 deletions(-) diff --git a/server/src/constants.ts b/server/src/constants.ts index 6636a2b1e2..f257be04bb 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -133,7 +133,6 @@ export class ServerConfig { export const LABEL_KEY_USER_ID = 'laf.dev/user.id' export const LABEL_KEY_APP_ID = 'laf.dev/appid' export const LABEL_KEY_NAMESPACE_TYPE = 'laf.dev/namespace.type' -export const LABEL_KEY_BUNDLE = 'laf.dev/bundle' export const LABEL_KEY_NODE_TYPE = 'laf.dev/node.type' export enum NodeType { Runtime = 'runtime', diff --git a/server/src/instance/instance-task.service.ts b/server/src/instance/instance-task.service.ts index 93bf805d50..bf68ca2ecc 100644 --- a/server/src/instance/instance-task.service.ts +++ b/server/src/instance/instance-task.service.ts @@ -1,11 +1,15 @@ import { Injectable, Logger } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' -import { Application, ApplicationPhase, ApplicationState } from '@prisma/client' import { isConditionTrue } from '../utils/getter' import { InstanceService } from './instance.service' import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' import { SystemDatabase } from 'src/database/system-database' import { CronJobService } from 'src/trigger/cron-job.service' +import { + Application, + ApplicationPhase, + ApplicationState, +} from 'src/application/entities/application' @Injectable() export class InstanceTaskService { @@ -100,7 +104,7 @@ export class InstanceTaskService { const app = res.value // create instance - await this.instanceService.create(app) + await this.instanceService.create(app.appid) // if waiting time is more than 5 minutes, stop the application const waitingTime = Date.now() - app.updatedAt.getTime() @@ -122,7 +126,7 @@ export class InstanceTaskService { } const appid = app.appid - const instance = await this.instanceService.get(app) + const instance = await this.instanceService.get(appid) const unavailable = instance.deployment?.status?.unavailableReplicas || false if (unavailable) { @@ -218,16 +222,16 @@ export class InstanceTaskService { const waitingTime = Date.now() - app.updatedAt.getTime() // check if the instance is removed - const instance = await this.instanceService.get(app) + const instance = await this.instanceService.get(app.appid) if (instance.deployment) { - await this.instanceService.remove(app) + await this.instanceService.remove(app.appid) await this.relock(appid, waitingTime) return } // check if the service is removed if (instance.service) { - await this.instanceService.remove(app) + await this.instanceService.remove(app.appid) await this.relock(appid, waitingTime) return } diff --git a/server/src/instance/instance.module.ts b/server/src/instance/instance.module.ts index 87287d8741..ac61be1768 100644 --- a/server/src/instance/instance.module.ts +++ b/server/src/instance/instance.module.ts @@ -4,9 +4,10 @@ import { InstanceTaskService } from './instance-task.service' import { StorageModule } from '../storage/storage.module' import { DatabaseModule } from '../database/database.module' import { TriggerModule } from 'src/trigger/trigger.module' +import { ApplicationModule } from 'src/application/application.module' @Module({ - imports: [StorageModule, DatabaseModule, TriggerModule], + imports: [StorageModule, DatabaseModule, TriggerModule, ApplicationModule], providers: [InstanceService, InstanceTaskService], }) export class InstanceModule {} diff --git a/server/src/instance/instance.service.ts b/server/src/instance/instance.service.ts index d8adb5a28e..42db6cec41 100644 --- a/server/src/instance/instance.service.ts +++ b/server/src/instance/instance.service.ts @@ -3,82 +3,129 @@ import { Injectable, Logger } from '@nestjs/common' import { GetApplicationNamespaceByAppId } from '../utils/getter' import { LABEL_KEY_APP_ID, - LABEL_KEY_BUNDLE, LABEL_KEY_NODE_TYPE, MB, NodeType, } from '../constants' -import { PrismaService } from '../prisma/prisma.service' import { StorageService } from '../storage/storage.service' import { DatabaseService } from 'src/database/database.service' import { ClusterService } from 'src/region/cluster/cluster.service' -import { - Application, - ApplicationBundle, - ApplicationConfiguration, - Runtime, -} from '@prisma/client' -import { RegionService } from 'src/region/region.service' -import { Region } from 'src/region/entities/region' - -type ApplicationWithRegion = Application & { region: Region } +import { SystemDatabase } from 'src/database/system-database' +import { ApplicationWithRelations } from 'src/application/entities/application' +import { ApplicationService } from 'src/application/application.service' @Injectable() export class InstanceService { - private logger = new Logger('InstanceService') + private readonly logger = new Logger('InstanceService') + private readonly db = SystemDatabase.db + constructor( - private readonly clusterService: ClusterService, - private readonly regionService: RegionService, + private readonly cluster: ClusterService, private readonly storageService: StorageService, private readonly databaseService: DatabaseService, - private readonly prisma: PrismaService, + private readonly applicationService: ApplicationService, ) {} - async create(app: Application) { - const appid = app.appid + public async create(appid: string) { + const app = await this.applicationService.findOneUnsafe(appid) const labels = { [LABEL_KEY_APP_ID]: appid } - const region = await this.regionService.findByAppId(appid) - const appWithRegion = { ...app, region } as ApplicationWithRegion + const region = app.region // Although a namespace has already been created during application creation, // we still need to check it again here in order to handle situations where the cluster is rebuilt. - const namespace = await this.clusterService.getAppNamespace(region, appid) + const namespace = await this.cluster.getAppNamespace(region, appid) if (!namespace) { this.logger.debug(`Creating namespace for application ${appid}`) - await this.clusterService.createAppNamespace(region, appid, app.createdBy) + await this.cluster.createAppNamespace( + region, + appid, + app.createdBy.toString(), + ) } - const res = await this.get(appWithRegion) + // ensure deployment created + const res = await this.get(app.appid) if (!res.deployment) { - await this.createDeployment(appid, labels) + await this.createDeployment(app, labels) } + // ensure service created if (!res.service) { - await this.createService(appWithRegion, labels) + await this.createService(app, labels) } } - async createDeployment(appid: string, labels: any) { + public async remove(appid: string) { + const app = await this.applicationService.findOneUnsafe(appid) + const region = app.region + const { deployment, service } = await this.get(appid) + + const namespace = await this.cluster.getAppNamespace(region, app.appid) + if (!namespace) return // namespace not found, nothing to do + + const appsV1Api = this.cluster.makeAppsV1Api(region) + const coreV1Api = this.cluster.makeCoreV1Api(region) + + // ensure deployment deleted + if (deployment) { + await appsV1Api.deleteNamespacedDeployment(appid, namespace.metadata.name) + } + + // ensure service deleted + if (service) { + const name = appid + await coreV1Api.deleteNamespacedService(name, namespace.metadata.name) + } + this.logger.log(`remove k8s deployment ${deployment?.metadata?.name}`) + } + + public async get(appid: string) { + const app = await this.applicationService.findOneUnsafe(appid) + const region = app.region + const namespace = await this.cluster.getAppNamespace(region, app.appid) + if (!namespace) { + return { deployment: null, service: null } + } + + const deployment = await this.getDeployment(app) + const service = await this.getService(app) + return { deployment, service } + } + + public async restart(appid: string) { + const app = await this.applicationService.findOneUnsafe(appid) + const region = app.region + const { deployment } = await this.get(appid) + if (!deployment) { + await this.create(appid) + return + } + + deployment.spec = await this.makeDeploymentSpec( + app, + deployment.spec.template.metadata.labels, + ) + const appsV1Api = this.cluster.makeAppsV1Api(region) const namespace = GetApplicationNamespaceByAppId(appid) - const app = await this.prisma.application.findUnique({ - where: { appid }, - include: { - configuration: true, - bundle: true, - runtime: true, - region: true, - }, - }) + const res = await appsV1Api.replaceNamespacedDeployment( + app.appid, + namespace, + deployment, + ) + + this.logger.log(`restart k8s deployment ${res.body?.metadata?.name}`) + } - // add bundle label - labels[LABEL_KEY_BUNDLE] = app.bundle.name + private async createDeployment(app: ApplicationWithRelations, labels: any) { + const appid = app.appid + const namespace = GetApplicationNamespaceByAppId(appid) // create deployment const data = new V1Deployment() data.metadata = { name: app.appid, labels } data.spec = await this.makeDeploymentSpec(app, labels) - const appsV1Api = this.clusterService.makeAppsV1Api(app.region) + const appsV1Api = this.cluster.makeAppsV1Api(app.region) const res = await appsV1Api.createNamespacedDeployment(namespace, data) this.logger.log(`create k8s deployment ${res.body?.metadata?.name}`) @@ -86,10 +133,10 @@ export class InstanceService { return res.body } - async createService(app: ApplicationWithRegion, labels: any) { + private async createService(app: ApplicationWithRelations, labels: any) { const namespace = GetApplicationNamespaceByAppId(app.appid) const serviceName = app.appid - const coreV1Api = this.clusterService.makeCoreV1Api(app.region) + const coreV1Api = this.cluster.makeCoreV1Api(app.region) const res = await coreV1Api.createNamespacedService(namespace, { metadata: { name: serviceName, labels }, spec: { @@ -102,49 +149,9 @@ export class InstanceService { return res.body } - async remove(app: Application) { + private async getDeployment(app: ApplicationWithRelations) { const appid = app.appid - const region = await this.regionService.findByAppId(appid) - const { deployment, service } = await this.get(app) - - const namespace = await this.clusterService.getAppNamespace( - region, - app.appid, - ) - if (!namespace) return - - const appsV1Api = this.clusterService.makeAppsV1Api(region) - const coreV1Api = this.clusterService.makeCoreV1Api(region) - - if (deployment) { - await appsV1Api.deleteNamespacedDeployment(appid, namespace.metadata.name) - } - if (service) { - const name = appid - await coreV1Api.deleteNamespacedService(name, namespace.metadata.name) - } - this.logger.log(`remove k8s deployment ${deployment?.metadata?.name}`) - } - - async get(app: Application) { - const region = await this.regionService.findByAppId(app.appid) - const namespace = await this.clusterService.getAppNamespace( - region, - app.appid, - ) - if (!namespace) { - return { deployment: null, service: null } - } - - const appWithRegion = { ...app, region } - const deployment = await this.getDeployment(appWithRegion) - const service = await this.getService(appWithRegion) - return { deployment, service } - } - - async getDeployment(app: ApplicationWithRegion) { - const appid = app.appid - const appsV1Api = this.clusterService.makeAppsV1Api(app.region) + const appsV1Api = this.cluster.makeAppsV1Api(app.region) try { const namespace = GetApplicationNamespaceByAppId(appid) const res = await appsV1Api.readNamespacedDeployment(appid, namespace) @@ -155,9 +162,9 @@ export class InstanceService { } } - async getService(app: ApplicationWithRegion) { + private async getService(app: ApplicationWithRelations) { const appid = app.appid - const coreV1Api = this.clusterService.makeCoreV1Api(app.region) + const coreV1Api = this.cluster.makeCoreV1Api(app.region) try { const serviceName = appid @@ -170,45 +177,8 @@ export class InstanceService { } } - async restart(appid: string) { - const app = await this.prisma.application.findUnique({ - where: { appid }, - include: { - configuration: true, - bundle: true, - runtime: true, - region: true, - }, - }) - const { deployment } = await this.get(app) - if (!deployment) { - await this.create(app) - return - } - - deployment.spec = await this.makeDeploymentSpec( - app, - deployment.spec.template.metadata.labels, - ) - const region = await this.regionService.findByAppId(app.appid) - const appsV1Api = this.clusterService.makeAppsV1Api(region) - const namespace = GetApplicationNamespaceByAppId(app.appid) - const res = await appsV1Api.replaceNamespacedDeployment( - app.appid, - namespace, - deployment, - ) - - this.logger.log(`restart k8s deployment ${res.body?.metadata?.name}`) - } - - async makeDeploymentSpec( - app: Application & { - region: Region - bundle: ApplicationBundle - configuration: ApplicationConfiguration - runtime: Runtime - }, + private async makeDeploymentSpec( + app: ApplicationWithRelations, labels: any, ): Promise { // prepare params @@ -388,21 +358,6 @@ export class InstanceService { }, ], }, - // preferred to schedule on bundle matched node - preferredDuringSchedulingIgnoredDuringExecution: [ - { - weight: 10, - preference: { - matchExpressions: [ - { - key: LABEL_KEY_BUNDLE, - operator: 'In', - values: [app.bundle.name], - }, - ], - }, - }, - ], }, // end of nodeAffinity {} }, // end of affinity {} }, // end of spec {} From e65b2113ca66754d599e2d06f656b614d29cd0f5 Mon Sep 17 00:00:00 2001 From: maslow Date: Wed, 17 May 2023 14:12:17 +0000 Subject: [PATCH 20/48] remove prisma in auth and user module --- .vscode/settings.json | 21 ++- server/src/auth/application.auth.guard.ts | 5 +- server/src/auth/auth.service.ts | 9 +- server/src/auth/authentication.controller.ts | 31 ++-- server/src/auth/authentication.service.ts | 41 +++--- server/src/auth/dto/passwd-reset.dto.ts | 2 +- server/src/auth/dto/passwd-signup.dto.ts | 2 +- server/src/auth/dto/send-phone-code.dto.ts | 2 +- server/src/auth/entities/auth-provider.ts | 18 +++ server/src/auth/entities/sms-verify-code.ts | 26 ++++ server/src/auth/jwt.strategy.ts | 2 +- server/src/auth/phone/phone.controller.ts | 6 +- server/src/auth/phone/phone.service.ts | 121 +++++++++------- server/src/auth/phone/sms.service.ts | 111 ++++++++------- server/src/auth/types.ts | 10 -- .../user-passwd/user-password.controller.ts | 33 +++-- .../auth/user-passwd/user-password.service.ts | 134 ++++++++++++------ server/src/setting/entities/setting.ts | 9 ++ server/src/setting/setting.service.ts | 12 +- server/src/user/dto/user.response.ts | 15 +- server/src/user/entities/pat.ts | 15 ++ server/src/user/entities/user-password.ts | 15 ++ server/src/user/entities/user-profile.ts | 11 ++ server/src/user/entities/user.ts | 10 ++ server/src/user/pat.controller.ts | 10 +- server/src/user/pat.service.ts | 94 +++++++----- server/src/user/user.service.ts | 110 +++++--------- 27 files changed, 497 insertions(+), 378 deletions(-) create mode 100644 server/src/auth/entities/auth-provider.ts create mode 100644 server/src/auth/entities/sms-verify-code.ts create mode 100644 server/src/setting/entities/setting.ts create mode 100644 server/src/user/entities/pat.ts create mode 100644 server/src/user/entities/user-password.ts create mode 100644 server/src/user/entities/user-profile.ts create mode 100644 server/src/user/entities/user.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 32ad6dfda4..57cc8f9be7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,6 +22,7 @@ }, "cSpell.words": [ "aarch", + "alicloud", "alipay", "alisms", "apiextensions", @@ -49,6 +50,8 @@ "datepicker", "dockerode", "doctag", + "dysmsapi", + "Dysmsapi", "EJSON", "entrypoint", "finalizers", @@ -75,7 +78,9 @@ "MONOG", "nestjs", "objs", + "openapi", "openebs", + "OVERLIMIT", "passw", "pgdb", "presigner", @@ -115,12 +120,20 @@ "zustand" ], "i18n-ally.localesPaths": "web/public/locales", - "i18n-ally.enabledParsers": ["json"], - "i18n-ally.enabledFrameworks": ["react", "i18next", "general"], + "i18n-ally.enabledParsers": [ + "json" + ], + "i18n-ally.enabledFrameworks": [ + "react", + "i18next", + "general" + ], "i18n-ally.sourceLanguage": "zh-CN", "i18n-ally.displayLanguage": "en,zh", "i18n-ally.namespace": false, "i18n-ally.pathMatcher": "{locale}/translation.json", "i18n-ally.keystyle": "nested", - "i18n-ally.keysInUse": ["description.part2_whatever"] -} + "i18n-ally.keysInUse": [ + "description.part2_whatever" + ] +} \ No newline at end of file diff --git a/server/src/auth/application.auth.guard.ts b/server/src/auth/application.auth.guard.ts index a184862158..0252421392 100644 --- a/server/src/auth/application.auth.guard.ts +++ b/server/src/auth/application.auth.guard.ts @@ -4,9 +4,9 @@ import { Injectable, Logger, } from '@nestjs/common' -import { User } from '@prisma/client' import { ApplicationService } from '../application/application.service' import { IRequest } from '../utils/interface' +import { User } from 'src/user/entities/user' @Injectable() export class ApplicationAuthGuard implements CanActivate { @@ -22,9 +22,8 @@ export class ApplicationAuthGuard implements CanActivate { return false } - // Call toString() to convert to string in case it is ObjectID const author_id = app.createdBy?.toString() - if (author_id !== user.id) { + if (author_id !== user._id.toString()) { return false } diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index 11f5e6cb99..cf27f9ae7f 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -1,12 +1,11 @@ import { Injectable, Logger } from '@nestjs/common' import { JwtService } from '@nestjs/jwt' -import { User } from '@prisma/client' -import * as assert from 'node:assert' +import { User } from 'src/user/entities/user' import { PatService } from 'src/user/pat.service' @Injectable() export class AuthService { - logger: Logger = new Logger(AuthService.name) + private readonly logger = new Logger(AuthService.name) constructor( private readonly jwtService: JwtService, private readonly patService: PatService, @@ -19,7 +18,7 @@ export class AuthService { * @returns */ async pat2token(token: string): Promise { - const pat = await this.patService.findOne(token) + const pat = await this.patService.findOneByToken(token) if (!pat) return null // check pat expired @@ -34,7 +33,7 @@ export class AuthService { * @returns */ getAccessTokenByUser(user: User): string { - const payload = { sub: user.id } + const payload = { sub: user._id.toString() } const token = this.jwtService.sign(payload) return token } diff --git a/server/src/auth/authentication.controller.ts b/server/src/auth/authentication.controller.ts index 50d8ee6dd7..32818df8e4 100644 --- a/server/src/auth/authentication.controller.ts +++ b/server/src/auth/authentication.controller.ts @@ -7,8 +7,9 @@ import { BindUsernameDto } from './dto/bind-username.dto' import { IRequest } from 'src/utils/interface' import { BindPhoneDto } from './dto/bind-phone.dto' import { SmsService } from './phone/sms.service' -import { SmsVerifyCodeType } from '@prisma/client' import { UserService } from 'src/user/user.service' +import { ObjectId } from 'mongodb' +import { SmsVerifyCodeType } from './entities/sms-verify-code' @ApiTags('Authentication - New') @Controller('auth') @@ -40,7 +41,7 @@ export class AuthenticationController { async bindPhone(@Body() dto: BindPhoneDto, @Req() req: IRequest) { const { phone, code } = dto // check code valid - const err = await this.smsService.validCode( + const err = await this.smsService.validateCode( phone, code, SmsVerifyCodeType.Bind, @@ -50,20 +51,13 @@ export class AuthenticationController { } // check phone if have already been bound - const user = await this.userService.find(phone) + const user = await this.userService.findOneByUsernameOrPhoneOrEmail(phone) if (user) { return ResponseUtil.error('phone already been bound') } // bind phone - await this.userService.updateUser({ - where: { - id: req.user.id, - }, - data: { - phone, - }, - }) + await this.userService.updateUser(new ObjectId(req.user.id), { phone }) } /** @@ -77,7 +71,7 @@ export class AuthenticationController { const { username, phone, code } = dto // check code valid - const err = await this.smsService.validCode( + const err = await this.smsService.validateCode( phone, code, SmsVerifyCodeType.Bind, @@ -87,19 +81,14 @@ export class AuthenticationController { } // check username if have already been bound - const user = await this.userService.find(username) + const user = await this.userService.findOneByUsernameOrPhoneOrEmail( + username, + ) if (user) { return ResponseUtil.error('username already been bound') } // bind username - await this.userService.updateUser({ - where: { - id: req.user.id, - }, - data: { - username, - }, - }) + await this.userService.updateUser(new ObjectId(req.user.id), { username }) } } diff --git a/server/src/auth/authentication.service.ts b/server/src/auth/authentication.service.ts index 67d058a661..0886ca404c 100644 --- a/server/src/auth/authentication.service.ts +++ b/server/src/auth/authentication.service.ts @@ -1,37 +1,32 @@ import { JwtService } from '@nestjs/jwt' -import { PrismaService } from 'src/prisma/prisma.service' import { Injectable, Logger } from '@nestjs/common' -import { AuthProviderState, User } from '@prisma/client' import { PASSWORD_AUTH_PROVIDER_NAME, PHONE_AUTH_PROVIDER_NAME, } from 'src/constants' +import { SystemDatabase } from 'src/database/system-database' +import { AuthProvider, AuthProviderState } from './entities/auth-provider' +import { User } from 'src/user/entities/user' @Injectable() export class AuthenticationService { - logger: Logger = new Logger(AuthenticationService.name) - constructor( - private readonly prismaService: PrismaService, - private readonly jwtService: JwtService, - ) {} + private readonly logger = new Logger(AuthenticationService.name) + private readonly db = SystemDatabase.db + + constructor(private readonly jwtService: JwtService) {} /** * Get all auth provides * @returns */ async getProviders() { - return await this.prismaService.authProvider.findMany({ - where: { state: AuthProviderState.Enabled }, - select: { - id: false, - name: true, - bind: true, - register: true, - default: true, - state: true, - config: false, - }, - }) + return await this.db + .collection('AuthProvider') + .find( + { state: AuthProviderState.Enabled }, + { projection: { _id: 0, config: 0 } }, + ) + .toArray() } async getPhoneProvider() { @@ -44,9 +39,9 @@ export class AuthenticationService { // Get auth provider by name async getProvider(name: string) { - return await this.prismaService.authProvider.findUnique({ - where: { name }, - }) + return await this.db + .collection('AuthProvider') + .findOne({ name }) } /** @@ -56,7 +51,7 @@ export class AuthenticationService { */ getAccessTokenByUser(user: User): string { const payload = { - sub: user.id, + sub: user._id.toString(), } const token = this.jwtService.sign(payload) return token diff --git a/server/src/auth/dto/passwd-reset.dto.ts b/server/src/auth/dto/passwd-reset.dto.ts index 9ea7407548..3890265f48 100644 --- a/server/src/auth/dto/passwd-reset.dto.ts +++ b/server/src/auth/dto/passwd-reset.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger' -import { SmsVerifyCodeType } from '@prisma/client' import { IsEnum, IsNotEmpty, IsString, Length, Matches } from 'class-validator' +import { SmsVerifyCodeType } from '../entities/sms-verify-code' export class PasswdResetDto { @ApiProperty({ diff --git a/server/src/auth/dto/passwd-signup.dto.ts b/server/src/auth/dto/passwd-signup.dto.ts index 41df857b02..12973e5c5b 100644 --- a/server/src/auth/dto/passwd-signup.dto.ts +++ b/server/src/auth/dto/passwd-signup.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' -import { SmsVerifyCodeType } from '@prisma/client' import { IsEnum, IsNotEmpty, @@ -8,6 +7,7 @@ import { Length, Matches, } from 'class-validator' +import { SmsVerifyCodeType } from '../entities/sms-verify-code' export class PasswdSignupDto { @ApiProperty({ diff --git a/server/src/auth/dto/send-phone-code.dto.ts b/server/src/auth/dto/send-phone-code.dto.ts index 1d6dda8e06..a36e28795e 100644 --- a/server/src/auth/dto/send-phone-code.dto.ts +++ b/server/src/auth/dto/send-phone-code.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger' -import { SmsVerifyCodeType } from '@prisma/client' import { IsEnum, IsNotEmpty, IsString, Matches } from 'class-validator' +import { SmsVerifyCodeType } from '../entities/sms-verify-code' export class SendPhoneCodeDto { @ApiProperty({ diff --git a/server/src/auth/entities/auth-provider.ts b/server/src/auth/entities/auth-provider.ts new file mode 100644 index 0000000000..604f688b03 --- /dev/null +++ b/server/src/auth/entities/auth-provider.ts @@ -0,0 +1,18 @@ +import { ObjectId } from 'mongodb' + +export enum AuthProviderState { + Enabled = 'Enabled', + Disabled = 'Disabled', +} + +export class AuthProvider { + _id?: ObjectId + name: string + bind: any + register: boolean + default: boolean + state: AuthProviderState + config: any + createdAt: Date + updatedAt: Date +} diff --git a/server/src/auth/entities/sms-verify-code.ts b/server/src/auth/entities/sms-verify-code.ts new file mode 100644 index 0000000000..9fcfd6ae10 --- /dev/null +++ b/server/src/auth/entities/sms-verify-code.ts @@ -0,0 +1,26 @@ +import { ObjectId } from 'mongodb' + +export enum SmsVerifyCodeType { + Signin = 'Signin', + Signup = 'Signup', + ResetPassword = 'ResetPassword', + Bind = 'Bind', + Unbind = 'Unbind', + ChangePhone = 'ChangePhone', +} + +export enum SmsVerifyCodeState { + Unused = 0, + Used = 1, +} + +export class SmsVerifyCode { + _id?: ObjectId + phone: string + code: string + ip: string + type: SmsVerifyCodeType + state: SmsVerifyCodeState + createdAt: Date + updatedAt: Date +} diff --git a/server/src/auth/jwt.strategy.ts b/server/src/auth/jwt.strategy.ts index fc87ced624..88ca43572a 100644 --- a/server/src/auth/jwt.strategy.ts +++ b/server/src/auth/jwt.strategy.ts @@ -21,7 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { */ async validate(payload: any) { const id = payload.sub - const user = await this.userService.user({ id }, true) + const user = await this.userService.findOneById(id) return user } } diff --git a/server/src/auth/phone/phone.controller.ts b/server/src/auth/phone/phone.controller.ts index b13b2d75c8..aec59eb857 100644 --- a/server/src/auth/phone/phone.controller.ts +++ b/server/src/auth/phone/phone.controller.ts @@ -1,5 +1,4 @@ import { SmsService } from 'src/auth/phone/sms.service' -import { SmsVerifyCodeType } from '@prisma/client' import { IRequest } from 'src/utils/interface' import { Body, Controller, Logger, Post, Req } from '@nestjs/common' import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' @@ -10,6 +9,7 @@ import { PhoneSigninDto } from '../dto/phone-signin.dto' import { AuthenticationService } from '../authentication.service' import { UserService } from 'src/user/user.service' import { AuthBindingType, AuthProviderBinding } from '../types' +import { SmsVerifyCodeType } from '../entities/sms-verify-code' @ApiTags('Authentication - New') @Controller('auth') @@ -49,7 +49,7 @@ export class PhoneController { async signin(@Body() dto: PhoneSigninDto) { const { phone, code } = dto // check if code valid - const err = await this.smsService.validCode( + const err = await this.smsService.validateCode( phone, code, SmsVerifyCodeType.Signin, @@ -57,7 +57,7 @@ export class PhoneController { if (err) return ResponseUtil.error(err) // check if user exists - const user = await this.userService.findByPhone(phone) + const user = await this.userService.findOneByPhone(phone) if (user) { const token = this.phoneService.signin(user) return ResponseUtil.ok(token) diff --git a/server/src/auth/phone/phone.service.ts b/server/src/auth/phone/phone.service.ts index a5093a14b0..babd0d333d 100644 --- a/server/src/auth/phone/phone.service.ts +++ b/server/src/auth/phone/phone.service.ts @@ -1,21 +1,27 @@ import { Injectable, Logger } from '@nestjs/common' -import { SmsVerifyCodeType, User } from '@prisma/client' -import { PrismaService } from 'src/prisma/prisma.service' import { SmsService } from 'src/auth/phone/sms.service' -import { UserService } from 'src/user/user.service' import { AuthenticationService } from '../authentication.service' import { PhoneSigninDto } from '../dto/phone-signin.dto' import { hashPassword } from 'src/utils/crypto' -import { SmsVerifyCodeState } from '../types' +import { SmsVerifyCodeType } from '../entities/sms-verify-code' +import { User } from 'src/user/entities/user' +import { SystemDatabase } from 'src/database/system-database' +import { UserService } from 'src/user/user.service' +import { + UserPassword, + UserPasswordState, +} from 'src/user/entities/user-password' +import { UserProfile } from 'src/user/entities/user-profile' @Injectable() export class PhoneService { private readonly logger = new Logger(PhoneService.name) + private readonly db = SystemDatabase.db + constructor( - private readonly prisma: PrismaService, private readonly smsService: SmsService, - private readonly userService: UserService, private readonly authService: AuthenticationService, + private readonly userService: UserService, ) {} /** @@ -40,26 +46,10 @@ export class PhoneService { } // disable previous sms code - await this.prisma.smsVerifyCode.updateMany({ - where: { - phone, - type, - state: SmsVerifyCodeState.Active, - }, - data: { - state: SmsVerifyCodeState.Used, - }, - }) + await this.smsService.disableSameTypeCode(phone, type) // Save new sms code to database - await this.prisma.smsVerifyCode.create({ - data: { - phone, - code, - type, - ip, - }, - }) + await this.smsService.saveCode(phone, code, type, ip) return null } @@ -72,30 +62,58 @@ export class PhoneService { async signup(dto: PhoneSigninDto, withUsername = false) { const { phone, username, password } = dto - // start transaction - const user = await this.prisma.$transaction(async (tx) => { + const client = SystemDatabase.client + const session = client.startSession() + + try { + session.startTransaction() + // create user - const user = await tx.user.create({ - data: { + const res = await this.db.collection('User').insertOne( + { phone, username: username || phone, - profile: { create: { name: username || phone } }, + createdAt: new Date(), + updatedAt: new Date(), }, - }) - if (!withUsername) { - return user - } - // create password if need - await tx.userPassword.create({ - data: { - uid: user.id, - password: hashPassword(password), - state: 'Active', + { session }, + ) + + const user = await this.userService.findOneById(res.insertedId) + + // create profile + await this.db.collection('UserProfile').insertOne( + { + uid: user._id, + name: username, + createdAt: new Date(), + updatedAt: new Date(), }, - }) + { session }, + ) + + if (withUsername) { + // create password + await this.db.collection('UserPassword').insertOne( + { + uid: user._id, + password: hashPassword(password), + state: UserPasswordState.Active, + createdAt: new Date(), + updatedAt: new Date(), + }, + { session }, + ) + } + + await session.commitTransaction() return user - }) - return user + } catch (err) { + await session.abortTransaction() + throw err + } finally { + await session.endSession() + } } /** @@ -110,16 +128,13 @@ export class PhoneService { // check if current user has bind password async ifBindPassword(user: User) { - const count = await this.prisma.userPassword.count({ - where: { - uid: user.id, - state: 'Active', - }, - }) - - if (count === 0) { - return false - } - return true + const count = await this.db + .collection('UserPassword') + .countDocuments({ + uid: user._id, + state: UserPasswordState.Active, + }) + + return count > 0 } } diff --git a/server/src/auth/phone/sms.service.ts b/server/src/auth/phone/sms.service.ts index 5cf4ccda3e..81eeb8037d 100644 --- a/server/src/auth/phone/sms.service.ts +++ b/server/src/auth/phone/sms.service.ts @@ -1,6 +1,5 @@ import { AuthenticationService } from '../authentication.service' import { Injectable, Logger } from '@nestjs/common' -import { Prisma, SmsVerifyCodeType } from '@prisma/client' import Dysmsapi, * as dysmsapi from '@alicloud/dysmsapi20170525' import * as OpenApi from '@alicloud/openapi-client' import * as Util from '@alicloud/tea-util' @@ -11,16 +10,19 @@ import { MILLISECONDS_PER_MINUTE, CODE_VALIDITY, } from 'src/constants' -import { PrismaService } from 'src/prisma/prisma.service' -import { SmsVerifyCodeState } from '../types' +import { SystemDatabase } from 'src/database/system-database' +import { + SmsVerifyCode, + SmsVerifyCodeState, + SmsVerifyCodeType, +} from '../entities/sms-verify-code' @Injectable() export class SmsService { - private logger = new Logger(SmsService.name) - constructor( - private readonly prisma: PrismaService, - private readonly authService: AuthenticationService, - ) {} + private readonly logger = new Logger(SmsService.name) + private readonly db = SystemDatabase.db + + constructor(private readonly authService: AuthenticationService) {} /** * send sms login code to given phone number @@ -50,27 +52,25 @@ export class SmsService { } // Check if phone number has been send sms code in 1 minute - const count = await this.prisma.smsVerifyCode.count({ - where: { + const count = await this.db + .collection('SmsVerifyCode') + .countDocuments({ phone: phone, - createdAt: { - gt: new Date(Date.now() - MILLISECONDS_PER_MINUTE), - }, - }, - }) + createdAt: { $gt: new Date(Date.now() - MILLISECONDS_PER_MINUTE) }, + }) + if (count > 0) { return 'REQUEST_OVERLIMIT: phone number has been send sms code in 1 minute' } // Check if ip has been send sms code beyond 30 times in 24 hours - const countIps = await this.prisma.smsVerifyCode.count({ - where: { + const countIps = await this.db + .collection('SmsVerifyCode') + .countDocuments({ ip: ip, - createdAt: { - gt: new Date(Date.now() - MILLISECONDS_PER_DAY), - }, - }, - }) + createdAt: { $gt: new Date(Date.now() - MILLISECONDS_PER_DAY) }, + }) + if (countIps > LIMIT_CODE_PER_IP_PER_DAY) { return `REQUEST_OVERLIMIT: ip has been send sms code beyond ${LIMIT_CODE_PER_IP_PER_DAY} times in 24 hours` } @@ -78,26 +78,36 @@ export class SmsService { return null } - // save sended code to database - async saveSmsCode(data: Prisma.SmsVerifyCodeCreateInput) { - await this.prisma.smsVerifyCode.create({ - data, + async saveCode( + phone: string, + code: string, + type: SmsVerifyCodeType, + ip: string, + ) { + await this.db.collection('SmsVerifyCode').insertOne({ + phone, + code, + type, + ip, + createdAt: new Date(), + updatedAt: new Date(), + state: SmsVerifyCodeState.Unused, }) } - // Valid given phone and code with code type - async validCode(phone: string, code: string, type: SmsVerifyCodeType) { - const total = await this.prisma.smsVerifyCode.count({ - where: { + async validateCode(phone: string, code: string, type: SmsVerifyCodeType) { + const total = await this.db + .collection('SmsVerifyCode') + .countDocuments({ phone, code, type, - state: SmsVerifyCodeState.Active, - createdAt: { gte: new Date(Date.now() - CODE_VALIDITY) }, - }, - }) + state: SmsVerifyCodeState.Unused, + createdAt: { $gt: new Date(Date.now() - CODE_VALIDITY) }, + }) if (total === 0) return 'invalid code' + // Disable verify code after valid await this.disableCode(phone, code, type) return null @@ -105,31 +115,22 @@ export class SmsService { // Disable verify code async disableCode(phone: string, code: string, type: SmsVerifyCodeType) { - await this.prisma.smsVerifyCode.updateMany({ - where: { - phone, - code, - type, - state: SmsVerifyCodeState.Active, - }, - data: { - state: SmsVerifyCodeState.Used, - }, - }) + await this.db + .collection('SmsVerifyCode') + .updateMany( + { phone, code, type, state: SmsVerifyCodeState.Unused }, + { $set: { state: SmsVerifyCodeState.Used } }, + ) } // Disable same type verify code async disableSameTypeCode(phone: string, type: SmsVerifyCodeType) { - await this.prisma.smsVerifyCode.updateMany({ - where: { - phone, - type, - state: SmsVerifyCodeState.Active, - }, - data: { - state: SmsVerifyCodeState.Used, - }, - }) + await this.db + .collection('SmsVerifyCode') + .updateMany( + { phone, type, state: SmsVerifyCodeState.Unused }, + { $set: { state: SmsVerifyCodeState.Used } }, + ) } // send sms code to phone using alisms diff --git a/server/src/auth/types.ts b/server/src/auth/types.ts index fc7772f9df..022ad47be7 100644 --- a/server/src/auth/types.ts +++ b/server/src/auth/types.ts @@ -4,16 +4,6 @@ export enum AuthBindingType { None = 'none', } -export enum SmsVerifyCodeState { - Active = 0, - Used = 1, -} - -export enum UserPasswordState { - Active = 'Active', - Inactive = 'Inactive', -} - export interface AuthProviderBinding { username: AuthBindingType phone: AuthBindingType diff --git a/server/src/auth/user-passwd/user-password.controller.ts b/server/src/auth/user-passwd/user-password.controller.ts index bcbf1090b7..a318074443 100644 --- a/server/src/auth/user-passwd/user-password.controller.ts +++ b/server/src/auth/user-passwd/user-password.controller.ts @@ -1,6 +1,6 @@ import { AuthenticationService } from '../authentication.service' import { UserPasswordService } from './user-password.service' -import { Body, Controller, Logger, Post, Req } from '@nestjs/common' +import { Body, Controller, Logger, Post } from '@nestjs/common' import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' import { ResponseUtil } from 'src/utils/response' import { UserService } from '../../user/user.service' @@ -31,7 +31,7 @@ export class UserPasswordController { async signup(@Body() dto: PasswdSignupDto) { const { username, password, phone } = dto // check if user exists - const doc = await this.userService.user({ username }) + const doc = await this.userService.findOneByUsername(username) if (doc) { return ResponseUtil.error('user already exists') } @@ -47,11 +47,11 @@ export class UserPasswordController { if (bind.phone === AuthBindingType.Required) { const { phone, code, type } = dto // valid phone has been binded - const user = await this.userService.findByPhone(phone) + const user = await this.userService.findOneByPhone(phone) if (user) { return ResponseUtil.error('phone has been binded') } - const err = await this.smsService.validCode(phone, code, type) + const err = await this.smsService.validateCode(phone, code, type) if (err) { return ResponseUtil.error(err) } @@ -76,13 +76,18 @@ export class UserPasswordController { @Post('passwd/signin') async signin(@Body() dto: PasswdSigninDto) { // check if user exists - const user = await this.userService.find(dto.username) + const user = await this.userService.findOneByUsernameOrPhoneOrEmail( + dto.username, + ) if (!user) { return ResponseUtil.error('user not found') } // check if password is correct - const err = await this.passwdService.validPasswd(user.id, dto.password) + const err = await this.passwdService.validatePassword( + user._id, + dto.password, + ) if (err) { return ResponseUtil.error(err) } @@ -104,23 +109,19 @@ export class UserPasswordController { async reset(@Body() dto: PasswdResetDto) { // valid phone code const { phone, code, type } = dto - let err = await this.smsService.validCode(phone, code, type) + const err = await this.smsService.validateCode(phone, code, type) if (err) { return ResponseUtil.error(err) } // find user by phone - const user = await this.userService.findByPhone(phone) + const user = await this.userService.findOneByPhone(phone) if (!user) { return ResponseUtil.error('user not found') } // reset password - err = await this.passwdService.resetPasswd(user.id, dto.password) - if (err) { - return ResponseUtil.error(err) - } - + await this.passwdService.resetPassword(user._id, dto.password) return ResponseUtil.ok('success') } @@ -133,12 +134,14 @@ export class UserPasswordController { async check(@Body() dto: PasswdCheckDto) { const { username } = dto // check if user exists - const user = await this.userService.find(username) + const user = await this.userService.findOneByUsernameOrPhoneOrEmail( + username, + ) if (!user) { return ResponseUtil.error('user not found') } // find if set password - const hasPasswd = await this.passwdService.hasPasswd(user.id) + const hasPasswd = await this.passwdService.hasPassword(user._id) return ResponseUtil.ok(hasPasswd) } diff --git a/server/src/auth/user-passwd/user-password.service.ts b/server/src/auth/user-passwd/user-password.service.ts index 0e43bb606b..44d68db1db 100644 --- a/server/src/auth/user-passwd/user-password.service.ts +++ b/server/src/auth/user-passwd/user-password.service.ts @@ -1,44 +1,76 @@ import { Injectable, Logger } from '@nestjs/common' -import { PrismaService } from 'src/prisma/prisma.service' -import { User } from '@prisma/client' import { hashPassword } from 'src/utils/crypto' import { AuthenticationService } from '../authentication.service' -import { UserPasswordState } from '../types' +import { SystemDatabase } from 'src/database/system-database' +import { User } from 'src/user/entities/user' +import { + UserPassword, + UserPasswordState, +} from 'src/user/entities/user-password' +import { UserProfile } from 'src/user/entities/user-profile' +import { UserService } from 'src/user/user.service' +import { ObjectId } from 'mongodb' @Injectable() export class UserPasswordService { private readonly logger = new Logger(UserPasswordService.name) + private readonly db = SystemDatabase.db + constructor( - private readonly prisma: PrismaService, private readonly authService: AuthenticationService, + private readonly userService: UserService, ) {} // Singup by username and password async signup(username: string, password: string, phone: string) { - // start transaction - const user = await this.prisma.$transaction(async (tx) => { + const client = SystemDatabase.client + const session = client.startSession() + + try { + session.startTransaction() // create user - const user = await tx.user.create({ - data: { + const res = await this.db.collection('User').insertOne( + { username, phone, - profile: { create: { name: username } }, + email: null, + createdAt: new Date(), + updatedAt: new Date(), }, - }) + { session }, + ) // create password - await tx.userPassword.create({ - data: { - uid: user.id, + await this.db.collection('UserPassword').insertOne( + { + uid: res.insertedId, password: hashPassword(password), state: UserPasswordState.Active, + createdAt: new Date(), + updatedAt: new Date(), }, - }) + { session }, + ) - return user - }) + // create profile + await this.db.collection('UserProfile').insertOne( + { + uid: res.insertedId, + name: username, + createdAt: new Date(), + updatedAt: new Date(), + }, + { session }, + ) - return user + await session.commitTransaction() + return this.userService.findOneById(res.insertedId) + } catch (error) { + await session.abortTransaction() + throw error + } finally { + await session.endSession() + } } // Signin for user, means get access token @@ -46,16 +78,17 @@ export class UserPasswordService { return this.authService.getAccessTokenByUser(user) } - // valid if password is correct - async validPasswd(uid: string, passwd: string) { - const userPasswd = await this.prisma.userPassword.findFirst({ - where: { uid, state: UserPasswordState.Active }, - }) + // validate if password is correct + async validatePassword(uid: ObjectId, password: string) { + const userPasswd = await this.db + .collection('UserPassword') + .findOne({ uid, state: UserPasswordState.Active }) + if (!userPasswd) { return 'password not exists' } - if (userPasswd.password !== hashPassword(passwd)) { + if (userPasswd.password !== hashPassword(password)) { return 'password incorrect' } @@ -63,38 +96,47 @@ export class UserPasswordService { } // reset password - async resetPasswd(uid: string, passwd: string) { - // start transaction - const update = await this.prisma.$transaction(async (tx) => { + async resetPassword(uid: ObjectId, password: string) { + const client = SystemDatabase.client + const session = client.startSession() + + try { + session.startTransaction() // disable old password - await tx.userPassword.updateMany({ - where: { uid }, - data: { state: UserPasswordState.Inactive }, - }) + await this.db + .collection('UserPassword') + .updateMany( + { uid }, + { $set: { state: UserPasswordState.Inactive } }, + { session }, + ) // create new password - const np = await tx.userPassword.create({ - data: { + await this.db.collection('UserPassword').insertOne( + { uid, - password: hashPassword(passwd), + password: hashPassword(password), state: UserPasswordState.Active, + createdAt: new Date(), + updatedAt: new Date(), }, - }) - - return np - }) - if (!update) { - return 'reset password failed' + { session }, + ) + await session.commitTransaction() + } catch (error) { + await session.abortTransaction() + throw error + } finally { + await session.endSession() } - - return null } // check if set password - async hasPasswd(uid: string) { - const userPasswd = await this.prisma.userPassword.findFirst({ - where: { uid, state: UserPasswordState.Active }, - }) - return userPasswd ? true : false // true means has password + async hasPassword(uid: ObjectId) { + const res = await this.db + .collection('UserPassword') + .findOne({ uid, state: UserPasswordState.Active }) + + return res ? true : false } } diff --git a/server/src/setting/entities/setting.ts b/server/src/setting/entities/setting.ts new file mode 100644 index 0000000000..afc6eef4c2 --- /dev/null +++ b/server/src/setting/entities/setting.ts @@ -0,0 +1,9 @@ +import { ObjectId } from 'mongodb' + +export class Setting { + _id?: ObjectId + key: string + value: string + desc?: string + metadata?: any +} diff --git a/server/src/setting/setting.service.ts b/server/src/setting/setting.service.ts index a906cb7a31..c1e5351431 100644 --- a/server/src/setting/setting.service.ts +++ b/server/src/setting/setting.service.ts @@ -1,19 +1,17 @@ import { Injectable, Logger } from '@nestjs/common' -import { PrismaService } from 'src/prisma/prisma.service' +import { SystemDatabase } from 'src/database/system-database' +import { Setting } from './entities/setting' @Injectable() export class SettingService { private readonly logger = new Logger(SettingService.name) - - constructor(private readonly prisma: PrismaService) {} + private readonly db = SystemDatabase.db async findAll() { - return await this.prisma.setting.findMany() + return await this.db.collection('Setting').find().toArray() } async findOne(key: string) { - return await this.prisma.setting.findUnique({ - where: { key }, - }) + return await this.db.collection('Setting').findOne({ key }) } } diff --git a/server/src/user/dto/user.response.ts b/server/src/user/dto/user.response.ts index 8c5e465269..e5be356afc 100644 --- a/server/src/user/dto/user.response.ts +++ b/server/src/user/dto/user.response.ts @@ -1,14 +1,13 @@ import { ApiProperty } from '@nestjs/swagger' -import { User, UserProfile } from '@prisma/client' +import { User } from '../entities/user' +import { UserProfile } from '../entities/user-profile' +import { ObjectId } from 'mongodb' export class UserProfileDto implements UserProfile { - id: string + _id?: ObjectId @ApiProperty() - uid: string - - @ApiProperty() - openid: string + uid: ObjectId @ApiProperty() avatar: string @@ -16,8 +15,6 @@ export class UserProfileDto implements UserProfile { @ApiProperty() name: string - from: string - @ApiProperty() openData: any @@ -30,7 +27,7 @@ export class UserProfileDto implements UserProfile { export class UserDto implements User { @ApiProperty() - id: string + _id?: ObjectId @ApiProperty() email: string diff --git a/server/src/user/entities/pat.ts b/server/src/user/entities/pat.ts new file mode 100644 index 0000000000..6934c6bfaa --- /dev/null +++ b/server/src/user/entities/pat.ts @@ -0,0 +1,15 @@ +import { ObjectId } from 'mongodb' +import { User } from './user' + +export class PersonalAccessToken { + _id?: ObjectId + uid: ObjectId + name: string + token: string + expiredAt: Date + createdAt: Date +} + +export type PersonalAccessTokenWithUser = PersonalAccessToken & { + user: User +} diff --git a/server/src/user/entities/user-password.ts b/server/src/user/entities/user-password.ts new file mode 100644 index 0000000000..cc1da3cc3d --- /dev/null +++ b/server/src/user/entities/user-password.ts @@ -0,0 +1,15 @@ +import { ObjectId } from 'mongodb' + +export enum UserPasswordState { + Active = 'Active', + Inactive = 'Inactive', +} + +export class UserPassword { + _id?: ObjectId + uid: ObjectId + password: string + state: UserPasswordState + createdAt: Date + updatedAt: Date +} diff --git a/server/src/user/entities/user-profile.ts b/server/src/user/entities/user-profile.ts new file mode 100644 index 0000000000..8daaa9c6e5 --- /dev/null +++ b/server/src/user/entities/user-profile.ts @@ -0,0 +1,11 @@ +import { ObjectId } from 'mongodb' + +export class UserProfile { + _id?: ObjectId + uid: ObjectId + openData?: any + avatar?: string + name?: string + createdAt: Date + updatedAt: Date +} diff --git a/server/src/user/entities/user.ts b/server/src/user/entities/user.ts new file mode 100644 index 0000000000..e070ea0c30 --- /dev/null +++ b/server/src/user/entities/user.ts @@ -0,0 +1,10 @@ +import { ObjectId } from 'mongodb' + +export class User { + _id?: ObjectId + username: string + email?: string + phone?: string + createdAt: Date + updatedAt: Date +} diff --git a/server/src/user/pat.controller.ts b/server/src/user/pat.controller.ts index 929e79decc..35cfb85e65 100644 --- a/server/src/user/pat.controller.ts +++ b/server/src/user/pat.controller.ts @@ -20,6 +20,7 @@ import { ResponseUtil } from 'src/utils/response' import { IRequest } from 'src/utils/interface' import { CreatePATDto } from './dto/create-pat.dto' import { PatService } from './pat.service' +import { ObjectId } from 'mongodb' @ApiTags('Authentication') @ApiBearerAuth('Authorization') @@ -40,7 +41,7 @@ export class PatController { @UseGuards(JwtAuthGuard) @Post() async create(@Req() req: IRequest, @Body() dto: CreatePATDto) { - const uid = req.user.id + const uid = new ObjectId(req.user.id) // check max count, 10 const count = await this.patService.count(uid) if (count >= 10) { @@ -61,7 +62,7 @@ export class PatController { @UseGuards(JwtAuthGuard) @Get() async findAll(@Req() req: IRequest) { - const uid = req.user.id + const uid = new ObjectId(req.user.id) const pats = await this.patService.findAll(uid) return ResponseUtil.ok(pats) } @@ -78,7 +79,10 @@ export class PatController { @Delete(':id') async remove(@Req() req: IRequest, @Param('id') id: string) { const uid = req.user.id - const pat = await this.patService.remove(uid, id) + const pat = await this.patService.removeOne( + new ObjectId(uid), + new ObjectId(id), + ) return ResponseUtil.ok(pat) } } diff --git a/server/src/user/pat.service.ts b/server/src/user/pat.service.ts index e3b515776d..5d30b5071c 100644 --- a/server/src/user/pat.service.ts +++ b/server/src/user/pat.service.ts @@ -1,66 +1,82 @@ import { Injectable, Logger } from '@nestjs/common' import { GenerateAlphaNumericPassword } from 'src/utils/random' -import { PrismaService } from '../prisma/prisma.service' import { CreatePATDto } from './dto/create-pat.dto' +import { SystemDatabase } from 'src/database/system-database' +import { + PersonalAccessToken, + PersonalAccessTokenWithUser, +} from './entities/pat' +import { ObjectId } from 'mongodb' @Injectable() export class PatService { private readonly logger = new Logger(PatService.name) + private readonly db = SystemDatabase.db - constructor(private readonly prisma: PrismaService) {} - - async create(userid: string, dto: CreatePATDto) { + async create(userid: ObjectId, dto: CreatePATDto) { const { name, expiresIn } = dto const token = 'laf_' + GenerateAlphaNumericPassword(60) - const pat = await this.prisma.personalAccessToken.create({ - data: { + const res = await this.db + .collection('PersonalAccessToken') + .insertOne({ + uid: userid, name, token, expiredAt: new Date(Date.now() + expiresIn * 1000), - user: { - connect: { id: userid }, - }, - }, - }) - return pat + createdAt: new Date(), + }) + + return this.findOne(res.insertedId) } - async findAll(userid: string) { - const pats = await this.prisma.personalAccessToken.findMany({ - where: { uid: userid }, - select: { - id: true, - uid: true, - name: true, - expiredAt: true, - createdAt: true, - }, - }) + async findAll(userid: ObjectId) { + const pats = await this.db + .collection('PersonalAccessToken') + .find({ uid: userid }, { projection: { token: 0 } }) + .toArray() + return pats } - async findOne(token: string) { - const pat = await this.prisma.personalAccessToken.findFirst({ - where: { token }, - include: { - user: true, - }, - }) + async findOneByToken(token: string) { + const pat = await this.db + .collection('PersonalAccessToken') + .aggregate() + .match({ token }) + .lookup({ + from: 'User', + localField: 'uid', + foreignField: '_id', + as: 'user', + }) + .unwind('$user') + .next() + + return pat + } + + async findOne(id: ObjectId) { + const pat = await this.db + .collection('PersonalAccessToken') + .findOne({ _id: id }) + return pat } - async count(userid: string) { - const count = await this.prisma.personalAccessToken.count({ - where: { uid: userid }, - }) + async count(userid: ObjectId) { + const count = await this.db + .collection('PersonalAccessToken') + .countDocuments({ uid: userid }) + return count } - async remove(userid: string, id: string) { - const pat = await this.prisma.personalAccessToken.deleteMany({ - where: { id, uid: userid }, - }) - return pat + async removeOne(userid: ObjectId, id: ObjectId) { + const doc = await this.db + .collection('PersonalAccessToken') + .findOneAndDelete({ _id: id, uid: userid }) + + return doc.value } } diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 7bd7a3715b..2dc0485f84 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -1,102 +1,56 @@ import { Injectable } from '@nestjs/common' -import { Prisma, User } from '@prisma/client' -import { PrismaService } from '../prisma/prisma.service' -import * as nanoid from 'nanoid' +import { SystemDatabase } from 'src/database/system-database' +import { User } from './entities/user' +import { ObjectId } from 'mongodb' @Injectable() export class UserService { - constructor(private prisma: PrismaService) {} + private readonly db = SystemDatabase.db - /** - * @deprecated - * @returns - */ - generateUserId() { - const nano = nanoid.customAlphabet( - '1234567890abcdefghijklmnopqrstuvwxyz', - 12, - ) - return nano() - } - - async create(data: Prisma.UserCreateInput): Promise { - return this.prisma.user.create({ - data, - }) - } - - async user(input: Prisma.UserWhereUniqueInput, withProfile = false) { - return this.prisma.user.findUnique({ - where: input, - include: { - profile: withProfile, - }, + async create(data: Partial) { + const res = await this.db.collection('User').insertOne({ + username: data.username, + email: data.email, + phone: data.phone, + createdAt: new Date(), + updatedAt: new Date(), }) - } - async profile(input: Prisma.UserProfileWhereInput, withUser = true) { - return this.prisma.userProfile.findFirst({ - where: input, - include: { - user: withUser, - }, - }) + return await this.findOneById(res.insertedId) } - async getProfileByOpenid(openid: string) { - return this.profile({ openid }, true) + async findOneById(id: ObjectId) { + return this.db.collection('User').findOne({ _id: id }) } - async users(params: { - skip?: number - take?: number - cursor?: Prisma.UserWhereUniqueInput - where?: Prisma.UserWhereInput - orderBy?: Prisma.UserOrderByWithRelationInput - }): Promise { - const { skip, take, cursor, where, orderBy } = params - return this.prisma.user.findMany({ - skip, - take, - cursor, - where, - orderBy, - }) + async findOneByUsername(username: string) { + return this.db.collection('User').findOne({ username }) } - async updateUser(params: { - where: Prisma.UserWhereUniqueInput - data: Prisma.UserUpdateInput - }) { - const { where, data } = params - return this.prisma.user.update({ - data, - where, + // find user by phone + async findOneByPhone(phone: string) { + const user = await this.db.collection('User').findOne({ + phone, }) - } - async deleteUser(where: Prisma.UserWhereUniqueInput) { - return this.prisma.user.delete({ - where, - }) + return user } // find user by username | phone | email - async find(username: string) { + async findOneByUsernameOrPhoneOrEmail(key: string) { // match either username or phone or email - return await this.prisma.user.findFirst({ - where: { - OR: [{ username }, { phone: username }, { email: username }], - }, + const user = await this.db.collection('User').findOne({ + $or: [{ username: key }, { phone: key }, { email: key }], }) + + return user } - // find user by phone - async findByPhone(phone: string) { - return await this.prisma.user.findFirst({ - where: { - phone, - }, - }) + async updateUser(id: ObjectId, data: Partial) { + await this.db + .collection('User') + .updateOne({ _id: id }, { $set: data }) + + return await this.findOneById(id) } } From cba015c62727afaf676fb474ffa14503fdc9af56 Mon Sep 17 00:00:00 2001 From: maslow Date: Thu, 18 May 2023 01:34:07 +0800 Subject: [PATCH 21/48] remove prisma in function and trigger module --- server/src/account/account.controller.ts | 6 +- server/src/account/account.service.ts | 8 +- server/src/app.controller.ts | 7 +- .../src/application/application.controller.ts | 6 +- server/src/application/application.service.ts | 4 +- server/src/application/entities/runtime.ts | 2 +- server/src/auth/authentication.controller.ts | 4 +- server/src/auth/jwt.strategy.ts | 3 +- .../src/function/dto/create-function.dto.ts | 2 +- .../src/function/dto/update-function.dto.ts | 2 +- .../src/function/entities/cloud-function.ts | 33 +++++ server/src/function/function.controller.ts | 6 +- server/src/function/function.service.ts | 114 ++++++++++-------- server/src/region/bundle.service.ts | 44 ++----- server/src/region/region.service.ts | 2 - server/src/trigger/cron-job.service.ts | 16 +-- server/src/trigger/entities/cron-trigger.ts | 27 +++++ server/src/trigger/trigger-task.service.ts | 18 ++- server/src/trigger/trigger.controller.ts | 5 +- server/src/trigger/trigger.service.ts | 91 +++++++------- server/src/user/pat.controller.ts | 11 +- server/src/utils/interface.ts | 2 +- 22 files changed, 229 insertions(+), 184 deletions(-) create mode 100644 server/src/function/entities/cloud-function.ts create mode 100644 server/src/trigger/entities/cron-trigger.ts diff --git a/server/src/account/account.controller.ts b/server/src/account/account.controller.ts index d64172896c..cb7202b0b3 100644 --- a/server/src/account/account.controller.ts +++ b/server/src/account/account.controller.ts @@ -51,7 +51,7 @@ export class AccountController { @Get() async findOne(@Req() req: IRequest) { const user = req.user - const data = await this.accountService.findOne(user.id) + const data = await this.accountService.findOne(user._id) return data } @@ -64,7 +64,7 @@ export class AccountController { async getChargeOrder(@Req() req: IRequest, @Param('id') id: string) { const user = req.user const data = await this.accountService.findOneChargeOrder( - user.id, + user._id, new ObjectId(id), ) return data @@ -82,7 +82,7 @@ export class AccountController { // create charge order const order = await this.accountService.createChargeOrder( - user.id, + user._id, amount, currency, channel, diff --git a/server/src/account/account.service.ts b/server/src/account/account.service.ts index 49bec830d9..b8d6fbe2c6 100644 --- a/server/src/account/account.service.ts +++ b/server/src/account/account.service.ts @@ -23,7 +23,7 @@ export class AccountService { private readonly chanelService: PaymentChannelService, ) {} - async create(userid: string): Promise { + async create(userid: ObjectId): Promise { await this.db.collection('Account').insertOne({ balance: 0, state: BaseState.Active, @@ -35,7 +35,7 @@ export class AccountService { return await this.findOne(userid) } - async findOne(userid: string) { + async findOne(userid: ObjectId) { const account = await this.db .collection('Account') .findOne({ createdBy: new ObjectId(userid) }) @@ -48,7 +48,7 @@ export class AccountService { } async createChargeOrder( - userid: string, + userid: ObjectId, amount: number, currency: Currency, channel: PaymentChannelType, @@ -74,7 +74,7 @@ export class AccountService { return await this.findOneChargeOrder(userid, account._id) } - async findOneChargeOrder(userid: string, id: ObjectId) { + async findOneChargeOrder(userid: ObjectId, id: ObjectId) { const order = await this.db .collection('AccountChargeOrder') .findOne({ diff --git a/server/src/app.controller.ts b/server/src/app.controller.ts index d5a57e0537..244feeb8d7 100644 --- a/server/src/app.controller.ts +++ b/server/src/app.controller.ts @@ -1,13 +1,14 @@ import { Controller, Get, Logger } from '@nestjs/common' import { ApiOperation, ApiTags } from '@nestjs/swagger' import { ResponseUtil } from './utils/response' -import { PrismaService } from './prisma/prisma.service' +import { SystemDatabase } from './database/system-database' +import { Runtime } from './application/entities/runtime' @ApiTags('Public') @Controller() export class AppController { private readonly logger = new Logger(AppController.name) - constructor(private readonly prisma: PrismaService) {} + private readonly db = SystemDatabase.db /** * Get runtime list @@ -16,7 +17,7 @@ export class AppController { @ApiOperation({ summary: 'Get application runtime list' }) @Get('runtimes') async getRuntimes() { - const data = await this.prisma.runtime.findMany({}) + const data = await this.db.collection('Runtime').find({}).toArray() return ResponseUtil.ok(data) } } diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index ec288e5e54..71d84a0d38 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -66,7 +66,7 @@ export class ApplicationController { } // check account balance - const account = await this.accountService.findOne(user.id) + const account = await this.accountService.findOne(user._id) const balance = account?.balance || 0 if (balance <= 0) { return ResponseUtil.error(`account balance is not enough`) @@ -74,7 +74,7 @@ export class ApplicationController { // create application const appid = await this.appService.tryGenerateUniqueAppid() - await this.appService.create(user.id, appid, dto) + await this.appService.create(user._id, appid, dto) const app = await this.appService.findOne(appid) return ResponseUtil.ok(app) @@ -90,7 +90,7 @@ export class ApplicationController { @ApiOperation({ summary: 'Get user application list' }) async findAll(@Req() req: IRequest) { const user = req.user - const data = await this.appService.findAllByUser(user.id) + const data = await this.appService.findAllByUser(user._id) return ResponseUtil.ok(data) } diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index 10cd6edb77..bcac21a66c 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -32,7 +32,7 @@ export class ApplicationService { * - create bundle * - create application */ - async create(userid: string, appid: string, dto: CreateApplicationDto) { + async create(userid: ObjectId, appid: string, dto: CreateApplicationDto) { const client = SystemDatabase.client const db = client.db() const session = client.startSession() @@ -98,7 +98,7 @@ export class ApplicationService { } } - async findAllByUser(userid: string) { + async findAllByUser(userid: ObjectId) { const db = SystemDatabase.db const doc = await db diff --git a/server/src/application/entities/runtime.ts b/server/src/application/entities/runtime.ts index c160b3deb6..170d3266a6 100644 --- a/server/src/application/entities/runtime.ts +++ b/server/src/application/entities/runtime.ts @@ -11,7 +11,7 @@ export class Runtime { name: string type: string image: RuntimeImageGroup - state: string + state: 'Active' | 'Inactive' version: string latest: boolean diff --git a/server/src/auth/authentication.controller.ts b/server/src/auth/authentication.controller.ts index 32818df8e4..d8e4e20165 100644 --- a/server/src/auth/authentication.controller.ts +++ b/server/src/auth/authentication.controller.ts @@ -57,7 +57,7 @@ export class AuthenticationController { } // bind phone - await this.userService.updateUser(new ObjectId(req.user.id), { phone }) + await this.userService.updateUser(new ObjectId(req.user._id), { phone }) } /** @@ -89,6 +89,6 @@ export class AuthenticationController { } // bind username - await this.userService.updateUser(new ObjectId(req.user.id), { username }) + await this.userService.updateUser(new ObjectId(req.user._id), { username }) } } diff --git a/server/src/auth/jwt.strategy.ts b/server/src/auth/jwt.strategy.ts index 88ca43572a..e0265e79bc 100644 --- a/server/src/auth/jwt.strategy.ts +++ b/server/src/auth/jwt.strategy.ts @@ -3,6 +3,7 @@ import { PassportStrategy } from '@nestjs/passport' import { ExtractJwt, Strategy } from 'passport-jwt' import { ServerConfig } from '../constants' import { UserService } from '../user/user.service' +import { ObjectId } from 'mongodb' @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -20,7 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { * @returns */ async validate(payload: any) { - const id = payload.sub + const id = new ObjectId(payload.sub as string) const user = await this.userService.findOneById(id) return user } diff --git a/server/src/function/dto/create-function.dto.ts b/server/src/function/dto/create-function.dto.ts index 0a86d79314..5c51b850bd 100644 --- a/server/src/function/dto/create-function.dto.ts +++ b/server/src/function/dto/create-function.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' -import { HttpMethod } from '@prisma/client' import { IsArray, IsIn, @@ -9,6 +8,7 @@ import { MaxLength, } from 'class-validator' import { HTTP_METHODS } from '../../constants' +import { HttpMethod } from '../entities/cloud-function' export class CreateFunctionDto { @ApiProperty({ diff --git a/server/src/function/dto/update-function.dto.ts b/server/src/function/dto/update-function.dto.ts index f68798a463..9d2c77925a 100644 --- a/server/src/function/dto/update-function.dto.ts +++ b/server/src/function/dto/update-function.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' -import { HttpMethod } from '@prisma/client' import { IsArray, IsIn, @@ -9,6 +8,7 @@ import { MaxLength, } from 'class-validator' import { HTTP_METHODS } from '../../constants' +import { HttpMethod } from '../entities/cloud-function' export class UpdateFunctionDto { @ApiPropertyOptional() diff --git a/server/src/function/entities/cloud-function.ts b/server/src/function/entities/cloud-function.ts new file mode 100644 index 0000000000..ee00976c58 --- /dev/null +++ b/server/src/function/entities/cloud-function.ts @@ -0,0 +1,33 @@ +import { ObjectId } from 'mongodb' + +export type CloudFunctionSource = { + code: string + compiled: string + uri?: string + version: number + hash?: string + lang?: string +} + +export enum HttpMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', + PATCH = 'PATCH', + HEAD = 'HEAD', +} + +export class CloudFunction { + _id?: ObjectId + appid: string + name: string + source: CloudFunctionSource + desc: string + tags: string[] + methods: HttpMethod[] + params?: any + createdAt: Date + updatedAt: Date + createdBy: ObjectId +} diff --git a/server/src/function/function.controller.ts b/server/src/function/function.controller.ts index 9da6fd2e70..be83fc62d0 100644 --- a/server/src/function/function.controller.ts +++ b/server/src/function/function.controller.ts @@ -79,7 +79,7 @@ export class FunctionController { ) } - const res = await this.functionsService.create(appid, req.user.id, dto) + const res = await this.functionsService.create(appid, req.user._id, dto) if (!res) { return ResponseUtil.error(i18n.t('function.create.error')) } @@ -148,7 +148,7 @@ export class FunctionController { ) } - const res = await this.functionsService.update(func, dto) + const res = await this.functionsService.updateOne(func, dto) if (!res) { return ResponseUtil.error(i18n.t('function.update.error')) } @@ -178,7 +178,7 @@ export class FunctionController { ) } - const res = await this.functionsService.remove(func) + const res = await this.functionsService.removeOne(func) if (!res) { return ResponseUtil.error(i18n.t('function.delete.error')) } diff --git a/server/src/function/function.service.ts b/server/src/function/function.service.ts index 2ad96fb315..5e5e5ba5d1 100644 --- a/server/src/function/function.service.ts +++ b/server/src/function/function.service.ts @@ -1,12 +1,10 @@ import { Injectable, Logger } from '@nestjs/common' -import { CloudFunction, Prisma } from '@prisma/client' import { compileTs2js } from '../utils/lang' import { APPLICATION_SECRET_KEY, CN_FUNCTION_LOGS, CN_PUBLISHED_FUNCTIONS, } from '../constants' -import { PrismaService } from '../prisma/prisma.service' import { CreateFunctionDto } from './dto/create-function.dto' import { UpdateFunctionDto } from './dto/update-function.dto' import * as assert from 'node:assert' @@ -14,17 +12,22 @@ import { JwtService } from '@nestjs/jwt' import { CompileFunctionDto } from './dto/compile-function.dto' import { DatabaseService } from 'src/database/database.service' import { GetApplicationNamespaceByAppId } from 'src/utils/getter' +import { SystemDatabase } from 'src/database/system-database' +import { ObjectId } from 'mongodb' +import { CloudFunction } from './entities/cloud-function' +import { ApplicationConfiguration } from 'src/application/entities/application-configuration' @Injectable() export class FunctionService { private readonly logger = new Logger(FunctionService.name) + private readonly db = SystemDatabase.db + constructor( private readonly databaseService: DatabaseService, - private readonly prisma: PrismaService, private readonly jwtService: JwtService, ) {} - async create(appid: string, userid: string, dto: CreateFunctionDto) { - const data: Prisma.CloudFunctionCreateInput = { + async create(appid: string, userid: ObjectId, dto: CreateFunctionDto) { + await this.db.collection('CloudFunction').insertOne({ appid, name: dto.name, source: { @@ -36,66 +39,79 @@ export class FunctionService { createdBy: userid, methods: dto.methods, tags: dto.tags || [], - } - const res = await this.prisma.cloudFunction.create({ data }) - await this.publish(res) - return res + createdAt: new Date(), + updatedAt: new Date(), + }) + + const fn = await this.findOne(appid, dto.name) + await this.publish(fn) + return fn } async findAll(appid: string) { - const res = await this.prisma.cloudFunction.findMany({ - where: { appid }, - }) + const res = await this.db + .collection('CloudFunction') + .find({ appid }) + .toArray() return res } async count(appid: string) { - const res = await this.prisma.cloudFunction.count({ where: { appid } }) + const res = await this.db + .collection('CloudFunction') + .countDocuments({ appid }) + return res } async findOne(appid: string, name: string) { - const res = await this.prisma.cloudFunction.findUnique({ - where: { appid_name: { appid, name } }, - }) + const res = await this.db + .collection('CloudFunction') + .findOne({ appid, name }) + return res } - async update(func: CloudFunction, dto: UpdateFunctionDto) { - const data: Prisma.CloudFunctionUpdateInput = { - source: { - code: dto.code, - compiled: compileTs2js(dto.code), - version: func.source.version + 1, + async updateOne(func: CloudFunction, dto: UpdateFunctionDto) { + await this.db.collection('CloudFunction').updateOne( + { appid: func.appid, name: func.name }, + { + $set: { + source: { + code: dto.code, + compiled: compileTs2js(dto.code), + version: func.source.version + 1, + }, + desc: dto.description, + methods: dto.methods, + tags: dto.tags || [], + params: dto.params, + updatedAt: new Date(), + }, }, - desc: dto.description, - methods: dto.methods, - tags: dto.tags || [], - params: dto.params, - } - const res = await this.prisma.cloudFunction.update({ - where: { appid_name: { appid: func.appid, name: func.name } }, - data, - }) + ) - await this.publish(res) - return res + const fn = await this.findOne(func.appid, func.name) + await this.publish(fn) + return fn } - async remove(func: CloudFunction) { + async removeOne(func: CloudFunction) { const { appid, name } = func - const res = await this.prisma.cloudFunction.delete({ - where: { appid_name: { appid, name } }, - }) + const res = await this.db + .collection('CloudFunction') + .findOneAndDelete({ appid, name }) + await this.unpublish(appid, name) - return res + return res.value } async removeAll(appid: string) { - const res = await this.prisma.cloudFunction.deleteMany({ - where: { appid }, - }) + const res = await this.db + .collection('CloudFunction') + .deleteMany({ appid }) + return res } @@ -109,6 +125,7 @@ export class FunctionService { await coll.insertOne(func, { session }) }) } finally { + await session.endSession() await client.close() } } @@ -145,12 +162,14 @@ export class FunctionService { assert(appid, 'appid is required') assert(type, 'type is required') - const conf = await this.prisma.applicationConfiguration.findUnique({ - where: { appid }, - }) + const conf = await this.db + .collection('ApplicationConfiguration') + .findOne({ appid }) + + assert(conf, 'ApplicationConfiguration not found') // get secret from envs - const secret = conf?.environments.find( + const secret = conf?.environments?.find( (env) => env.name === APPLICATION_SECRET_KEY, ) assert(secret?.value, 'application secret not found') @@ -209,10 +228,7 @@ export class FunctionService { .toArray() const total = await coll.countDocuments(query) - return { - data, - total, - } + return { data, total } } finally { await client.close() } diff --git a/server/src/region/bundle.service.ts b/server/src/region/bundle.service.ts index 0616a880a3..462a088dba 100644 --- a/server/src/region/bundle.service.ts +++ b/server/src/region/bundle.service.ts @@ -1,45 +1,25 @@ import { Injectable, Logger } from '@nestjs/common' -import { Bundle } from '@prisma/client' -import { PrismaService } from 'src/prisma/prisma.service' +import { ApplicationBundle } from 'src/application/entities/application-bundle' +import { SystemDatabase } from 'src/database/system-database' @Injectable() export class BundleService { private readonly logger = new Logger(BundleService.name) - - constructor(private readonly prisma: PrismaService) {} - - async findOne(id: string, regionId: string) { - return this.prisma.bundle.findFirst({ - where: { id, regionId }, - }) - } - - async findOneByName(name: string, regionName: string) { - return this.prisma.bundle.findFirst({ - where: { - name: name, - region: { - name: regionName, - }, - }, - }) - } + private readonly db = SystemDatabase.db async findApplicationBundle(appid: string) { - return this.prisma.applicationBundle.findUnique({ - where: { appid }, - }) + const bundle = await this.db + .collection('ApplicationBundle') + .findOne({ appid }) + + return bundle } async deleteApplicationBundle(appid: string) { - return this.prisma.applicationBundle.delete({ - where: { appid }, - }) - } + const res = await this.db + .collection('ApplicationBundle') + .findOneAndDelete({ appid }) - getSubscriptionOption(bundle: Bundle, duration: number) { - const options = bundle.subscriptionOptions - const found = options.find((option) => option.duration === duration) - return found ? found : null + return res.value } } diff --git a/server/src/region/region.service.ts b/server/src/region/region.service.ts index fc1b6ed578..50a035e062 100644 --- a/server/src/region/region.service.ts +++ b/server/src/region/region.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common' -import { PrismaService } from '../prisma/prisma.service' import { SystemDatabase } from 'src/database/system-database' import { Region } from './entities/region' import { Application } from 'src/application/entities/application' @@ -9,7 +8,6 @@ import { ObjectId } from 'mongodb' @Injectable() export class RegionService { private readonly db = SystemDatabase.db - constructor(private readonly prisma: PrismaService) {} async findByAppId(appid: string) { const app = await this.db diff --git a/server/src/trigger/cron-job.service.ts b/server/src/trigger/cron-job.service.ts index 2d56082dca..8bf59a045c 100644 --- a/server/src/trigger/cron-job.service.ts +++ b/server/src/trigger/cron-job.service.ts @@ -1,5 +1,4 @@ import { Injectable, Logger } from '@nestjs/common' -import { CronTrigger, TriggerPhase } from '@prisma/client' import { ClusterService } from 'src/region/cluster/cluster.service' import * as assert from 'node:assert' import { RegionService } from 'src/region/region.service' @@ -8,6 +7,7 @@ import { FunctionService } from 'src/function/function.service' import { FOREVER_IN_SECONDS, X_LAF_TRIGGER_TOKEN_KEY } from 'src/constants' import { TriggerService } from './trigger.service' import * as k8s from '@kubernetes/client-node' +import { CronTrigger, TriggerPhase } from './entities/cron-trigger' @Injectable() export class CronJobService { @@ -31,14 +31,14 @@ export class CronJobService { // create cronjob const ns = GetApplicationNamespaceByAppId(appid) const batchApi = this.clusterService.makeBatchV1Api(region) - const name = `cron-${trigger.id}` + const name = `cron-${trigger._id}` const command = await this.getTriggerCommand(trigger) const res = await batchApi.createNamespacedCronJob(ns, { metadata: { name, labels: { appid, - id: trigger.id, + id: trigger._id.toString(), }, }, spec: { @@ -81,7 +81,7 @@ export class CronJobService { const region = await this.regionService.findByAppId(appid) try { const batchApi = this.clusterService.makeBatchV1Api(region) - const name = `cron-${trigger.id}` + const name = `cron-${trigger._id}` const res = await batchApi.readNamespacedCronJob(name, ns) return res.body } catch (err) { @@ -105,7 +105,7 @@ export class CronJobService { for (const trigger of triggers) { if (trigger.phase !== TriggerPhase.Created) continue await this.suspend(trigger) - this.logger.log(`suspend cronjob ${trigger.id} success of ${appid}`) + this.logger.log(`suspend cronjob ${trigger._id} success of ${appid}`) } } @@ -114,7 +114,7 @@ export class CronJobService { for (const trigger of triggers) { if (trigger.phase !== TriggerPhase.Created) continue await this.resume(trigger) - this.logger.log(`resume cronjob ${trigger.id} success of ${appid}`) + this.logger.log(`resume cronjob ${trigger._id} success of ${appid}`) } } @@ -123,7 +123,7 @@ export class CronJobService { const ns = GetApplicationNamespaceByAppId(appid) const region = await this.regionService.findByAppId(appid) const batchApi = this.clusterService.makeBatchV1Api(region) - const name = `cron-${trigger.id}` + const name = `cron-${trigger._id}` const res = await batchApi.deleteNamespacedCronJob(name, ns) return res.body } @@ -150,7 +150,7 @@ export class CronJobService { const ns = GetApplicationNamespaceByAppId(appid) const region = await this.regionService.findByAppId(appid) const batchApi = this.clusterService.makeBatchV1Api(region) - const name = `cron-${trigger.id}` + const name = `cron-${trigger._id}` const body = [{ op: 'replace', path: '/spec/suspend', value: suspend }] try { const res = await batchApi.patchNamespacedCronJob( diff --git a/server/src/trigger/entities/cron-trigger.ts b/server/src/trigger/entities/cron-trigger.ts new file mode 100644 index 0000000000..da8de20b7f --- /dev/null +++ b/server/src/trigger/entities/cron-trigger.ts @@ -0,0 +1,27 @@ +import { ObjectId } from 'mongodb' + +export enum TriggerState { + Active = 'Active', + Inactive = 'Inactive', + Deleted = 'Deleted', +} + +export enum TriggerPhase { + Creating = 'Creating', + Created = 'Created', + Deleting = 'Deleting', + Deleted = 'Deleted', +} + +export class CronTrigger { + _id?: ObjectId + appid: string + desc: string + cron: string + target: string + state: TriggerState + phase: TriggerPhase + lockedAt: Date + createdAt: Date + updatedAt: Date +} diff --git a/server/src/trigger/trigger-task.service.ts b/server/src/trigger/trigger-task.service.ts index 5222b896f4..a2316ac594 100644 --- a/server/src/trigger/trigger-task.service.ts +++ b/server/src/trigger/trigger-task.service.ts @@ -3,7 +3,11 @@ import { Cron, CronExpression } from '@nestjs/schedule' import { TASK_LOCK_INIT_TIME } from 'src/constants' import { SystemDatabase } from 'src/database/system-database' import { CronJobService } from './cron-job.service' -import { CronTrigger, TriggerPhase, TriggerState } from '@prisma/client' +import { + CronTrigger, + TriggerPhase, + TriggerState, +} from './entities/cron-trigger' @Injectable() export class TriggerTaskService { @@ -60,11 +64,7 @@ export class TriggerTaskService { ) if (!res.value) return - // fix id for prisma type - const doc = { - ...res.value, - id: res.value._id.toString(), - } + const doc = res.value // create cron job if not exists const job = await this.cronService.findOne(doc) @@ -103,11 +103,7 @@ export class TriggerTaskService { ) if (!res.value) return - // fix id for prisma type - const doc = { - ...res.value, - id: res.value._id.toString(), - } + const doc = res.value // delete cron job if exists const job = await this.cronService.findOne(doc) diff --git a/server/src/trigger/trigger.controller.ts b/server/src/trigger/trigger.controller.ts index 3c3781dc16..f6a8813806 100644 --- a/server/src/trigger/trigger.controller.ts +++ b/server/src/trigger/trigger.controller.ts @@ -20,6 +20,7 @@ import { import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' import { BundleService } from 'src/region/bundle.service' +import { ObjectId } from 'mongodb' @ApiTags('Trigger') @Controller('apps/:appid/triggers') @@ -87,12 +88,12 @@ export class TriggerController { @Delete(':id') async remove(@Param('id') id: string, @Param('appid') appid: string) { // check if trigger exists - const trigger = await this.triggerService.findOne(appid, id) + const trigger = await this.triggerService.findOne(appid, new ObjectId(id)) if (!trigger) { return ResponseUtil.error('Trigger not found') } - const res = await this.triggerService.remove(appid, id) + const res = await this.triggerService.removeOne(appid, new ObjectId(id)) return ResponseUtil.ok(res) } } diff --git a/server/src/trigger/trigger.service.ts b/server/src/trigger/trigger.service.ts index 6060ed394d..b3f207139a 100644 --- a/server/src/trigger/trigger.service.ts +++ b/server/src/trigger/trigger.service.ts @@ -1,80 +1,75 @@ import { Injectable, Logger } from '@nestjs/common' -import { TriggerPhase, TriggerState } from '@prisma/client' import { TASK_LOCK_INIT_TIME } from 'src/constants' -import { PrismaService } from 'src/prisma/prisma.service' import { CreateTriggerDto } from './dto/create-trigger.dto' import CronValidate from 'cron-validate' +import { SystemDatabase } from 'src/database/system-database' +import { + CronTrigger, + TriggerPhase, + TriggerState, +} from './entities/cron-trigger' +import { ObjectId } from 'mongodb' @Injectable() export class TriggerService { private readonly logger = new Logger(TriggerService.name) - - constructor(private readonly prisma: PrismaService) {} + private readonly db = SystemDatabase.db async create(appid: string, dto: CreateTriggerDto) { const { desc, cron, target } = dto - const trigger = await this.prisma.cronTrigger.create({ - data: { - desc, - cron, - state: TriggerState.Active, - phase: TriggerPhase.Creating, - lockedAt: TASK_LOCK_INIT_TIME, - cloudFunction: { - connect: { - appid_name: { - appid, - name: target, - }, - }, - }, - }, + const res = await this.db.collection('CronTrigger').insertOne({ + appid, + desc, + cron, + target, + state: TriggerState.Active, + phase: TriggerPhase.Creating, + lockedAt: TASK_LOCK_INIT_TIME, + createdAt: new Date(), + updatedAt: new Date(), }) - return trigger + return this.findOne(appid, res.insertedId) } async count(appid: string) { - const res = await this.prisma.cronTrigger.count({ - where: { appid }, - }) + const count = await this.db + .collection('CronTrigger') + .countDocuments({ appid }) - return res + return count } - async findOne(appid: string, id: string) { - const res = await this.prisma.cronTrigger.findFirst({ - where: { id, appid }, - }) + async findOne(appid: string, id: ObjectId) { + const doc = await this.db + .collection('CronTrigger') + .findOne({ appid, _id: id }) - return res + return doc } async findAll(appid: string) { - const res = await this.prisma.cronTrigger.findMany({ - where: { appid, state: TriggerState.Active }, - }) + const docs = await this.db + .collection('CronTrigger') + .find({ appid, state: TriggerState.Active }) + .toArray() - return res + return docs } - async remove(appid: string, id: string) { - const res = await this.prisma.cronTrigger.updateMany({ - where: { id, appid }, - data: { - state: TriggerState.Deleted, - }, - }) - return res + async removeOne(appid: string, id: ObjectId) { + await this.db + .collection('CronTrigger') + .updateOne({ appid, _id: id }, { $set: { state: TriggerState.Deleted } }) + + return this.findOne(appid, id) } async removeAll(appid: string) { - const res = await this.prisma.cronTrigger.updateMany({ - where: { appid }, - data: { - state: TriggerState.Deleted, - }, - }) + const res = await this.db + .collection('CronTrigger') + .updateMany({ appid }, { $set: { state: TriggerState.Deleted } }) + return res } diff --git a/server/src/user/pat.controller.ts b/server/src/user/pat.controller.ts index 35cfb85e65..78e3ca8f06 100644 --- a/server/src/user/pat.controller.ts +++ b/server/src/user/pat.controller.ts @@ -41,7 +41,7 @@ export class PatController { @UseGuards(JwtAuthGuard) @Post() async create(@Req() req: IRequest, @Body() dto: CreatePATDto) { - const uid = new ObjectId(req.user.id) + const uid = req.user._id // check max count, 10 const count = await this.patService.count(uid) if (count >= 10) { @@ -62,7 +62,7 @@ export class PatController { @UseGuards(JwtAuthGuard) @Get() async findAll(@Req() req: IRequest) { - const uid = new ObjectId(req.user.id) + const uid = req.user._id const pats = await this.patService.findAll(uid) return ResponseUtil.ok(pats) } @@ -78,11 +78,8 @@ export class PatController { @UseGuards(JwtAuthGuard) @Delete(':id') async remove(@Req() req: IRequest, @Param('id') id: string) { - const uid = req.user.id - const pat = await this.patService.removeOne( - new ObjectId(uid), - new ObjectId(id), - ) + const uid = req.user._id + const pat = await this.patService.removeOne(uid, new ObjectId(id)) return ResponseUtil.ok(pat) } } diff --git a/server/src/utils/interface.ts b/server/src/utils/interface.ts index 14ec2a4e0b..71459b6a96 100644 --- a/server/src/utils/interface.ts +++ b/server/src/utils/interface.ts @@ -1,6 +1,6 @@ -import { User } from '@prisma/client' import { Request, Response } from 'express' import { Application } from 'src/application/entities/application' +import { User } from 'src/user/entities/user' export interface IRequest extends Request { user?: User From 2d2a116627dcd276760326881ce724e39e63d388 Mon Sep 17 00:00:00 2001 From: maslow Date: Thu, 18 May 2023 01:59:54 +0800 Subject: [PATCH 22/48] remove prisma totally --- server/Dockerfile | 7 +- server/README.md | 4 - server/package-lock.json | 79 -- server/package.json | 8 +- server/prisma/schema.prisma | 678 ------------------ server/src/app.module.ts | 2 - server/src/application/entities/runtime.ts | 4 +- server/src/initializer/initializer.service.ts | 254 +++---- server/src/main.ts | 1 - server/src/prisma/prisma.module.ts | 9 - server/src/prisma/prisma.service.ts | 22 - server/src/region/entities/region.ts | 4 +- 12 files changed, 95 insertions(+), 977 deletions(-) delete mode 100644 server/prisma/schema.prisma delete mode 100644 server/src/prisma/prisma.module.ts delete mode 100644 server/src/prisma/prisma.service.ts diff --git a/server/Dockerfile b/server/Dockerfile index b14442e4b7..8d384ec2e1 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -15,11 +15,6 @@ EXPOSE 3000 COPY . /app # All commands in one line will reduce the size of the image -RUN npm install @nestjs/cli@9.0.0 prisma@4.9.0 -g && npm install --omit=dev && npm run build && npm remove @nestjs/cli prisma -g && npm cache clean --force && rm -rf /app/src/* - -# RUN npm install --omit=dev -# RUN npm run build -# RUN npm remove @nestjs/cli prisma -g -# RUN npm cache clean --force +RUN npm install --omit=dev && npm run build && npm cache clean --force && rm -rf /app/src/* CMD [ "node", "dist/main" ] \ No newline at end of file diff --git a/server/README.md b/server/README.md index 435088dd3f..174a9feb66 100644 --- a/server/README.md +++ b/server/README.md @@ -20,7 +20,6 @@ - [Kubernetes](https://kubernetes.io) basic use - [Telepresence](https://www.telepresence.io) for local development - [MongoDb](https://docs.mongodb.com) basic use -- [Prisma](https://www.prisma.io) - [MinIO](https://min.io) object storage - [APISIX](https://apisix.apache.org) gateway - [nestjs-i18n](https://nestjs-i18n.com/) i18n @@ -46,9 +45,6 @@ telepresence list -n laf-system telepresence intercept laf-server -n laf-system -p 3000:3000 -e $(pwd)/.env npm install -npx prisma generate -npx prisma db push - npm run watch ``` diff --git a/server/package-lock.json b/server/package-lock.json index bd670318c9..a06e7f6ad5 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -26,7 +26,6 @@ "@nestjs/schedule": "^2.1.0", "@nestjs/swagger": "^6.1.3", "@nestjs/throttler": "^3.1.0", - "@prisma/client": "^4.9.0", "class-validator": "^0.14.0", "compression": "^1.7.4", "cron-validate": "^1.4.5", @@ -70,7 +69,6 @@ "eslint-plugin-prettier": "^4.0.0", "jest": "28.1.3", "prettier": "^2.3.2", - "prisma": "^4.9.0", "source-map-support": "^0.5.20", "supertest": "^6.1.3", "ts-jest": "28.0.8", @@ -7090,38 +7088,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@prisma/client": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.9.0.tgz", - "integrity": "sha512-bz6QARw54sWcbyR1lLnF2QHvRW5R/Jxnbbmwh3u+969vUKXtBkXgSgjDA85nji31ZBlf7+FrHDy5x+5ydGyQDg==", - "hasInstallScript": true, - "dependencies": { - "@prisma/engines-version": "4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5" - }, - "engines": { - "node": ">=14.17" - }, - "peerDependencies": { - "prisma": "*" - }, - "peerDependenciesMeta": { - "prisma": { - "optional": true - } - } - }, - "node_modules/@prisma/engines": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.9.0.tgz", - "integrity": "sha512-t1pt0Gsp+HcgPJrHFc+d/ZSAaKKWar2G/iakrE07yeKPNavDP3iVKPpfXP22OTCHZUWf7OelwKJxQgKAm5hkgw==", - "devOptional": true, - "hasInstallScript": true - }, - "node_modules/@prisma/engines-version": { - "version": "4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5.tgz", - "integrity": "sha512-M16aibbxi/FhW7z1sJCX8u+0DriyQYY5AyeTH7plQm9MLnURoiyn3CZBqAyIoQ+Z1pS77usCIibYJWSgleBMBA==" - }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -14444,23 +14410,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/prisma": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.9.0.tgz", - "integrity": "sha512-bS96oZ5oDFXYgoF2l7PJ3Mp1wWWfLOo8B/jAfbA2Pn0Wm5Z/owBHzaMQKS3i1CzVBDWWPVnOohmbJmjvkcHS5w==", - "devOptional": true, - "hasInstallScript": true, - "dependencies": { - "@prisma/engines": "4.9.0" - }, - "bin": { - "prisma": "build/index.js", - "prisma2": "build/index.js" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/proc-log": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", @@ -22629,25 +22578,6 @@ } } }, - "@prisma/client": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-4.9.0.tgz", - "integrity": "sha512-bz6QARw54sWcbyR1lLnF2QHvRW5R/Jxnbbmwh3u+969vUKXtBkXgSgjDA85nji31ZBlf7+FrHDy5x+5ydGyQDg==", - "requires": { - "@prisma/engines-version": "4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5" - } - }, - "@prisma/engines": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-4.9.0.tgz", - "integrity": "sha512-t1pt0Gsp+HcgPJrHFc+d/ZSAaKKWar2G/iakrE07yeKPNavDP3iVKPpfXP22OTCHZUWf7OelwKJxQgKAm5hkgw==", - "devOptional": true - }, - "@prisma/engines-version": { - "version": "4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5.tgz", - "integrity": "sha512-M16aibbxi/FhW7z1sJCX8u+0DriyQYY5AyeTH7plQm9MLnURoiyn3CZBqAyIoQ+Z1pS77usCIibYJWSgleBMBA==" - }, "@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -28297,15 +28227,6 @@ } } }, - "prisma": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-4.9.0.tgz", - "integrity": "sha512-bS96oZ5oDFXYgoF2l7PJ3Mp1wWWfLOo8B/jAfbA2Pn0Wm5Z/owBHzaMQKS3i1CzVBDWWPVnOohmbJmjvkcHS5w==", - "devOptional": true, - "requires": { - "@prisma/engines": "4.9.0" - } - }, "proc-log": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", diff --git a/server/package.json b/server/package.json index 311324bb15..fce43be3a0 100644 --- a/server/package.json +++ b/server/package.json @@ -8,11 +8,9 @@ "scripts": { "intercept": "telepresence intercept laf-server -n laf-system -p 3000:3000 -e $(pwd)/.env", "leave": "telepresence leave laf-server-laf-system", - "prebuild": "npm run generate && rimraf dist", - "generate": "prisma generate", + "prebuild": "rimraf dist", "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "push-db": "prisma db push --skip-generate", "start": "nest start", "watch": "nest start --watch", "start:dev": "nest start --watch", @@ -43,7 +41,6 @@ "@nestjs/schedule": "^2.1.0", "@nestjs/swagger": "^6.1.3", "@nestjs/throttler": "^3.1.0", - "@prisma/client": "^4.9.0", "class-validator": "^0.14.0", "compression": "^1.7.4", "cron-validate": "^1.4.5", @@ -87,7 +84,6 @@ "eslint-plugin-prettier": "^4.0.0", "jest": "28.1.3", "prettier": "^2.3.2", - "prisma": "^4.9.0", "source-map-support": "^0.5.20", "supertest": "^6.1.3", "ts-jest": "28.0.8", @@ -112,4 +108,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} +} \ No newline at end of file diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma deleted file mode 100644 index 53e57b43dd..0000000000 --- a/server/prisma/schema.prisma +++ /dev/null @@ -1,678 +0,0 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -// These models cover multiple aspects, such as subscriptions, accounts, applications, -// storage, databases, cloud functions, gateways, and SMS verification codes. -// Here's a brief description: -// -// 1. Subscription models (Subscription and SubscriptionRenewal): Represent the state -// and plans of subscriptions and their renewals. -// 2. Account models (Account and AccountChargeOrder): Track account balances and -// recharge records. -// 3. Application models (Application and ApplicationConfiguration): Represent -// application configurations and states. -// 4. Storage models (StorageUser and StorageBucket): Represent the state and policies -// of storage users and buckets. -// 5. Database models (Database, DatabasePolicy, and DatabasePolicyRule): Represent the -// state, policies, and rules of databases. -// 6. Cloud Function models (CloudFunction and CronTrigger): Represent the configuration -// and state of cloud functions and scheduled triggers. -// 7. Gateway models (RuntimeDomain, BucketDomain, and WebsiteHosting): Represent the -// state and configuration of runtime domains, bucket domains, and website hosting. -// 8. Authentication provider models (AuthProvider): Represent the configuration and state -// of authentication providers. -// 9. SMS verification code models (SmsVerifyCode): Represent the type, state, and -// related information of SMS verification codes. -// -// These models together form a complete cloud service system, covering subscription -// management, account management, application deployment, storage management, database -// management, cloud function deployment and execution, gateway configuration, and SMS -// verification, among other functionalities. - -generator client { - provider = "prisma-client-js" - binaryTargets = ["native"] -} - -datasource db { - provider = "mongodb" - url = env("DATABASE_URL") -} - -enum NoteLevel { - Info - Warning - Danger - Error -} - -type Note { - title String? - content String? - link String? - lang String? - level NoteLevel @default(Info) -} - -enum BaseState { - Active - Inactive -} - -// user schemas - -model User { - id String @id @default(auto()) @map("_id") @db.ObjectId - username String @unique - email String? - phone String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - profile UserProfile? - personalAccessTokens PersonalAccessToken[] -} - -model UserPassword { - id String @id @default(auto()) @map("_id") @db.ObjectId - uid String @db.ObjectId - password String - state String @default("Active") // Active, Inactive - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -model UserProfile { - id String @id @default(auto()) @map("_id") @db.ObjectId - uid String @unique @db.ObjectId - openid String? - from String? - openData Json? - avatar String? - name String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [uid], references: [id]) -} - -model PersonalAccessToken { - id String @id @default(auto()) @map("_id") @db.ObjectId - uid String @db.ObjectId - name String - token String @unique - expiredAt DateTime - createdAt DateTime @default(now()) - - user User @relation(fields: [uid], references: [id]) -} - -// region schemas - -type RegionClusterConf { - driver String // kubernetes - kubeconfig String? - npmInstallFlags String @default("") -} - -type RegionDatabaseConf { - driver String // mongodb - connectionUri String - controlConnectionUri String -} - -type RegionGatewayConf { - driver String // apisix - runtimeDomain String // runtime domain (cloud function) - websiteDomain String // website domain - port Int @default(80) - apiUrl String - apiKey String -} - -type RegionStorageConf { - driver String // minio - domain String - externalEndpoint String - internalEndpoint String - accessKey String - secretKey String - controlEndpoint String -} - -model Region { - id String @id @default(auto()) @map("_id") @db.ObjectId - name String @unique - displayName String - clusterConf RegionClusterConf - databaseConf RegionDatabaseConf - gatewayConf RegionGatewayConf - storageConf RegionStorageConf - tls Boolean @default(false) - state String @default("Active") // Active, Inactive - - notes Note[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - applications Application[] - bundles Bundle[] -} - -enum RegionBundleType { - CPU - Memory - Database - Storage - Network -} - -type RegionBundleOption { - value Int -} - -model RegionBundle { - id String @id @default(auto()) @map("_id") @db.ObjectId - regionId String @db.ObjectId - type RegionBundleType @unique - price Int @default(0) - options RegionBundleOption[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -type BundleResource { - limitCPU Int // 1000 = 1 core - limitMemory Int // in MB - requestCPU Int // 1000 = 1 core - requestMemory Int // in MB - - databaseCapacity Int // in MB - storageCapacity Int // in MB - networkTrafficOutbound Int? // in MB - - limitCountOfCloudFunction Int // limit count of cloud function per application - limitCountOfBucket Int // limit count of bucket per application - limitCountOfDatabasePolicy Int // limit count of database policy per application - limitCountOfTrigger Int // limit count of trigger per application - limitCountOfWebsiteHosting Int // limit count of website hosting per application - reservedTimeAfterExpired Int // in seconds - - limitDatabaseTPS Int // limit count of database TPS per application - limitStorageTPS Int // limit count of storage TPS per application -} - -type BundleSubscriptionOption { - name String - displayName String - duration Int // in seconds - price Int - specialPrice Int -} - -model Bundle { - id String @id @default(auto()) @map("_id") @db.ObjectId - name String - displayName String - regionId String @db.ObjectId - priority Int @default(0) - state BaseState @default(Active) - limitCountPerUser Int // limit count of application per user could create - subscriptionOptions BundleSubscriptionOption[] - maxRenewalTime Int // in seconds - - resource BundleResource - notes Note[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - region Region @relation(fields: [regionId], references: [id]) - - @@unique([regionId, name]) -} - -model ApplicationBundle { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String @unique - // @decrecapted - bundleId String? @db.ObjectId - // @decrecapted - name String? - // @decrecapted - displayName String? - resource BundleResource - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - application Application @relation(fields: [appid], references: [appid]) -} - -type RuntimeImageGroup { - main String - init String? - sidecar String? -} - -model Runtime { - id String @id @default(auto()) @map("_id") @db.ObjectId - name String @unique - type String - image RuntimeImageGroup - state String @default("Active") // Active, Inactive - version String - latest Boolean - Application Application[] -} - -// accounts schemas -model Account { - id String @id @default(auto()) @map("_id") @db.ObjectId - balance Int @default(0) - state BaseState @default(Active) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String @unique @db.ObjectId -} - -model AccountTransaction { - id String @id @default(auto()) @map("_id") @db.ObjectId - accountId String @db.ObjectId - amount Int - balance Int - message String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -enum AccountChargePhase { - Pending - Paid - Failed -} - -enum Currency { - CNY - USD -} - -model AccountChargeOrder { - id String @id @default(auto()) @map("_id") @db.ObjectId - accountId String @db.ObjectId - amount Int - currency Currency - phase AccountChargePhase @default(Pending) - channel PaymentChannelType - result Json? - message String? - createdAt DateTime @default(now()) - lockedAt DateTime - updatedAt DateTime @updatedAt - createdBy String @db.ObjectId -} - -enum PaymentChannelType { - Manual - Alipay - WeChat - Stripe - Paypal - Google -} - -model PaymentChannel { - id String @id @default(auto()) @map("_id") @db.ObjectId - type PaymentChannelType - name String - spec Json - state BaseState @default(Active) - notes Note[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -// Application schemas - -// desired state of application -enum ApplicationState { - Running - Stopped - Restarting - Deleted -} - -// actual state of application -enum ApplicationPhase { - Creating // app resources creating - Created // app resources created - Starting // instance starting - Started // instance started (Running, Ready) - Stopping // instance stopping - Stopped // instance stopped - Deleting // app resources deleting - Deleted // app resources deleted -} - -model Application { - id String @id @default(auto()) @map("_id") @db.ObjectId - name String - appid String @unique - regionId String @db.ObjectId - runtimeId String @db.ObjectId - tags String[] - state ApplicationState @default(Running) - phase ApplicationPhase @default(Creating) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lockedAt DateTime - createdBy String @db.ObjectId - - region Region @relation(fields: [regionId], references: [id]) - runtime Runtime @relation(fields: [runtimeId], references: [id]) - - configuration ApplicationConfiguration? - storageUser StorageUser? - database Database? - domain RuntimeDomain? - bundle ApplicationBundle? -} - -type EnvironmentVariable { - name String - value String -} - -model ApplicationConfiguration { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String @unique - environments EnvironmentVariable[] - dependencies String[] @default([]) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - application Application @relation(fields: [appid], references: [appid]) -} - -// storage schemas - -enum StorageState { - Active - Inactive - Deleted -} - -enum StoragePhase { - Creating - Created - Deleting - Deleted -} - -model StorageUser { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String @unique - accessKey String - secretKey String - state StorageState @default(Active) - phase StoragePhase @default(Creating) - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - application Application @relation(fields: [appid], references: [appid]) -} - -enum BucketPolicy { - readwrite - readonly - private -} - -model StorageBucket { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - name String @unique - shortName String - policy BucketPolicy - state StorageState @default(Active) - phase StoragePhase @default(Creating) - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - domain BucketDomain? - websiteHosting WebsiteHosting? -} - -// database schemas - -enum DatabaseState { - Active - Inactive - Deleted -} - -enum DatabasePhase { - Creating - Created - Deleting - Deleted -} - -model Database { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String @unique - name String - user String - password String - state DatabaseState @default(Active) - phase DatabasePhase @default(Creating) - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - application Application @relation(fields: [appid], references: [appid]) -} - -model DatabasePolicy { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - name String - injector String? - rules DatabasePolicyRule[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([appid, name]) -} - -model DatabasePolicyRule { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - policyName String - collectionName String - value Json - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - policy DatabasePolicy @relation(fields: [appid, policyName], references: [appid, name], onDelete: Cascade) - - @@unique([appid, policyName, collectionName]) -} - -// cloud function schemas - -enum HttpMethod { - GET - POST - PUT - DELETE - PATCH - HEAD -} - -type CloudFunctionSource { - code String - compiled String? - uri String? - version Int @default(0) - hash String? - lang String? -} - -model CloudFunction { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - name String - source CloudFunctionSource - desc String - tags String[] - methods HttpMethod[] - params Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - createdBy String @db.ObjectId - - cronTriggers CronTrigger[] - - @@unique([appid, name]) -} - -// diresired state of resource -enum TriggerState { - Active - Inactive - Deleted -} - -// actual state of resource -enum TriggerPhase { - Creating - Created - Deleting - Deleted -} - -model CronTrigger { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - desc String - cron String - target String - state TriggerState @default(Active) - phase TriggerPhase @default(Creating) - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - cloudFunction CloudFunction @relation(fields: [appid, target], references: [appid, name]) -} - -// gateway schemas - -// diresired state of resource -enum DomainState { - Active - Inactive - Deleted -} - -// actual state of resource -enum DomainPhase { - Creating - Created - Deleting - Deleted -} - -model RuntimeDomain { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String @unique - domain String @unique - state DomainState @default(Active) - phase DomainPhase @default(Creating) - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - application Application @relation(fields: [appid], references: [appid]) -} - -model BucketDomain { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - bucketName String @unique - domain String @unique - state DomainState @default(Active) - phase DomainPhase @default(Creating) - lockedAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - bucket StorageBucket @relation(fields: [bucketName], references: [name]) -} - -model WebsiteHosting { - id String @id @default(auto()) @map("_id") @db.ObjectId - appid String - bucketName String @unique - domain String @unique // auto-generated domain by default, custom domain if set - isCustom Boolean @default(false) // if true, domain is custom domain - state DomainState @default(Active) - phase DomainPhase @default(Creating) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lockedAt DateTime - - bucket StorageBucket @relation(fields: [bucketName], references: [name]) -} - -enum AuthProviderState { - Enabled - Disabled -} - -model AuthProvider { - id String @id @default(auto()) @map("_id") @db.ObjectId - name String @unique - bind Json - register Boolean - default Boolean - state AuthProviderState - config Json - notes Note[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -// Sms schemas -enum SmsVerifyCodeType { - Signin - Signup - ResetPassword - Bind - Unbind - ChangePhone -} - -model SmsVerifyCode { - id String @id @default(auto()) @map("_id") @db.ObjectId - phone String - code String - ip String - type SmsVerifyCodeType - state Int @default(0) // 0: created, 1: used - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -model Setting { - id String @id @default(auto()) @map("_id") @db.ObjectId - key String @unique - value String - desc String - metadata Json? // extra meta data -} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 1faaf9893a..c5b3d64cf7 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -17,7 +17,6 @@ import { DependencyModule } from './dependency/dependency.module' import { TriggerModule } from './trigger/trigger.module' import { RegionModule } from './region/region.module' import { GatewayModule } from './gateway/gateway.module' -import { PrismaModule } from './prisma/prisma.module' import { AccountModule } from './account/account.module' import { SettingModule } from './setting/setting.module' import * as path from 'path' @@ -44,7 +43,6 @@ import { AcceptLanguageResolver, I18nModule, QueryResolver } from 'nestjs-i18n' TriggerModule, RegionModule, GatewayModule, - PrismaModule, AccountModule, SettingModule, I18nModule.forRoot({ diff --git a/server/src/application/entities/runtime.ts b/server/src/application/entities/runtime.ts index 170d3266a6..4f2a2c3ddb 100644 --- a/server/src/application/entities/runtime.ts +++ b/server/src/application/entities/runtime.ts @@ -2,8 +2,8 @@ import { ObjectId } from 'mongodb' export type RuntimeImageGroup = { main: string - init: string | null - sidecar: string | null + init: string + sidecar?: string } export class Runtime { diff --git a/server/src/initializer/initializer.service.ts b/server/src/initializer/initializer.service.ts index a2547da013..d9e2fee973 100644 --- a/server/src/initializer/initializer.service.ts +++ b/server/src/initializer/initializer.service.ts @@ -1,216 +1,138 @@ import { Injectable, Logger } from '@nestjs/common' -import { AuthProviderState } from '@prisma/client' -import { RegionService } from 'src/region/region.service' -import { MinioService } from 'src/storage/minio/minio.service' -import { CPU_UNIT, ServerConfig } from '../constants' -import { PrismaService } from '../prisma/prisma.service' +import { ServerConfig } from '../constants' +import { SystemDatabase } from 'src/database/system-database' +import { Region } from 'src/region/entities/region' +import { Runtime } from 'src/application/entities/runtime' +import { + AuthProvider, + AuthProviderState, +} from 'src/auth/entities/auth-provider' @Injectable() export class InitializerService { private readonly logger = new Logger(InitializerService.name) - constructor( - private readonly prisma: PrismaService, - private readonly minioService: MinioService, - private readonly regionService: RegionService, - ) {} + private readonly db = SystemDatabase.db async createDefaultRegion() { // check if exists - const existed = await this.prisma.region.count() + const existed = await this.db.collection('Region').countDocuments() if (existed) { this.logger.debug('region already exists') return } // create default region - const res = await this.prisma.region.create({ - data: { - name: 'default', - displayName: 'Default', - tls: ServerConfig.DEFAULT_REGION_TLS, - clusterConf: { - driver: 'kubernetes', - }, - databaseConf: { - set: { - driver: 'mongodb', - connectionUri: ServerConfig.DEFAULT_REGION_DATABASE_URL, - controlConnectionUri: ServerConfig.DEFAULT_REGION_DATABASE_URL, - }, - }, - storageConf: { - set: { - driver: 'minio', - domain: ServerConfig.DEFAULT_REGION_MINIO_DOMAIN, - externalEndpoint: - ServerConfig.DEFAULT_REGION_MINIO_EXTERNAL_ENDPOINT, - internalEndpoint: - ServerConfig.DEFAULT_REGION_MINIO_INTERNAL_ENDPOINT, - accessKey: ServerConfig.DEFAULT_REGION_MINIO_ROOT_ACCESS_KEY, - secretKey: ServerConfig.DEFAULT_REGION_MINIO_ROOT_SECRET_KEY, - controlEndpoint: - ServerConfig.DEFAULT_REGION_MINIO_INTERNAL_ENDPOINT, - }, - }, - gatewayConf: { - set: { - driver: 'apisix', - runtimeDomain: ServerConfig.DEFAULT_REGION_RUNTIME_DOMAIN, - websiteDomain: ServerConfig.DEFAULT_REGION_WEBSITE_DOMAIN, - port: ServerConfig.DEFAULT_REGION_APISIX_PUBLIC_PORT, - apiUrl: ServerConfig.DEFAULT_REGION_APISIX_API_URL, - apiKey: ServerConfig.DEFAULT_REGION_APISIX_API_KEY, - }, - }, + const res = await this.db.collection('Region').insertOne({ + name: 'default', + displayName: 'Default', + tls: ServerConfig.DEFAULT_REGION_TLS, + clusterConf: { + driver: 'kubernetes', + kubeconfig: null, + npmInstallFlags: '', }, - }) - this.logger.verbose(`Created default region: ${res.name}`) - return res - } - - async createDefaultBundle() { - // check if exists - const existed = await this.prisma.bundle.count() - if (existed) { - this.logger.debug('default bundle already exists') - return - } - - // create default bundle - const res = await this.prisma.bundle.create({ - data: { - name: 'standard', - displayName: 'Standard', - limitCountPerUser: 10, - priority: 0, - maxRenewalTime: 3600 * 24 * 365 * 10, - resource: { - limitCPU: 1 * CPU_UNIT, - limitMemory: 512, - requestCPU: 0.05 * CPU_UNIT, - requestMemory: 128, - - databaseCapacity: 1024, - storageCapacity: 1024 * 5, - networkTrafficOutbound: 1024 * 5, - - limitCountOfCloudFunction: 500, - limitCountOfBucket: 10, - limitCountOfDatabasePolicy: 10, - limitCountOfTrigger: 10, - limitCountOfWebsiteHosting: 10, - reservedTimeAfterExpired: 3600 * 24 * 7, - - limitDatabaseTPS: 100, - limitStorageTPS: 1000, - }, - subscriptionOptions: [ - { - name: 'monthly', - displayName: '1 Month', - duration: 31 * 24 * 3600, - price: 0, - specialPrice: 0, - }, - { - name: 'half-yearly', - displayName: '6 Months', - duration: 6 * 31 * 24 * 3600, - price: 0, - specialPrice: 0, - }, - { - name: 'yearly', - displayName: '12 Months', - duration: 12 * 31 * 24 * 3600, - price: 0, - specialPrice: 0, - }, - ], - region: { - connect: { - name: 'default', - }, - }, + databaseConf: { + driver: 'mongodb', + connectionUri: ServerConfig.DEFAULT_REGION_DATABASE_URL, + controlConnectionUri: ServerConfig.DEFAULT_REGION_DATABASE_URL, + }, + storageConf: { + driver: 'minio', + domain: ServerConfig.DEFAULT_REGION_MINIO_DOMAIN, + externalEndpoint: ServerConfig.DEFAULT_REGION_MINIO_EXTERNAL_ENDPOINT, + internalEndpoint: ServerConfig.DEFAULT_REGION_MINIO_INTERNAL_ENDPOINT, + accessKey: ServerConfig.DEFAULT_REGION_MINIO_ROOT_ACCESS_KEY, + secretKey: ServerConfig.DEFAULT_REGION_MINIO_ROOT_SECRET_KEY, + controlEndpoint: ServerConfig.DEFAULT_REGION_MINIO_INTERNAL_ENDPOINT, }, + gatewayConf: { + driver: 'apisix', + runtimeDomain: ServerConfig.DEFAULT_REGION_RUNTIME_DOMAIN, + websiteDomain: ServerConfig.DEFAULT_REGION_WEBSITE_DOMAIN, + port: ServerConfig.DEFAULT_REGION_APISIX_PUBLIC_PORT, + apiUrl: ServerConfig.DEFAULT_REGION_APISIX_API_URL, + apiKey: ServerConfig.DEFAULT_REGION_APISIX_API_KEY, + }, + updatedAt: new Date(), + createdAt: new Date(), + state: 'Active', }) - this.logger.verbose('Created default bundle: ' + res.name) + + this.logger.verbose(`Created default region`) return res } async createDefaultRuntime() { // check if exists - const existed = await this.prisma.runtime.count() + const existed = await this.db + .collection('Runtime') + .countDocuments() if (existed) { this.logger.debug('default runtime already exists') return } // create default runtime - const res = await this.prisma.runtime.create({ - data: { - name: 'node', - type: 'node:laf', - image: { - main: ServerConfig.DEFAULT_RUNTIME_IMAGE.image.main, - init: ServerConfig.DEFAULT_RUNTIME_IMAGE.image.init, - }, - version: ServerConfig.DEFAULT_RUNTIME_IMAGE.version, - latest: true, + const res = await this.db.collection('Runtime').insertOne({ + name: 'node', + type: 'node:laf', + image: { + main: ServerConfig.DEFAULT_RUNTIME_IMAGE.image.main, + init: ServerConfig.DEFAULT_RUNTIME_IMAGE.image.init, }, + version: ServerConfig.DEFAULT_RUNTIME_IMAGE.version, + latest: true, + state: 'Active', }) - this.logger.verbose('Created default runtime: ' + res.name) + + this.logger.verbose('Created default runtime') return res } async createDefaultAuthProvider() { // check if exists - const existed = await this.prisma.authProvider.count() + const existed = await this.db + .collection('AuthProvider') + .countDocuments() if (existed) { this.logger.debug('default auth provider already exists') return } // create default auth provider - user-password - const resPassword = await this.prisma.authProvider.create({ - data: { - name: 'user-password', - bind: { - password: 'optional', - phone: 'optional', - email: 'optional', - }, - register: true, - default: true, - state: AuthProviderState.Enabled, - config: { usernameField: 'username', passwordField: 'password' }, + await this.db.collection('AuthProvider').insertOne({ + name: 'user-password', + bind: { + password: 'optional', + phone: 'optional', + email: 'optional', }, + register: true, + default: true, + state: AuthProviderState.Enabled, + config: { usernameField: 'username', passwordField: 'password' }, + createdAt: new Date(), + updatedAt: new Date(), }) // create auth provider - phone code - const resPhone = await this.prisma.authProvider.create({ - data: { - name: 'phone', - bind: { - password: 'optional', - phone: 'optional', - email: 'optional', - }, - register: true, - default: false, - state: AuthProviderState.Disabled, - config: { - alisms: {}, - }, + await this.db.collection('AuthProvider').insertOne({ + name: 'phone', + bind: { + password: 'optional', + phone: 'optional', + email: 'optional', + }, + register: true, + default: false, + state: AuthProviderState.Disabled, + config: { + alisms: {}, }, + createdAt: new Date(), + updatedAt: new Date(), }) - this.logger.verbose( - 'Created default auth providers: ' + - resPassword.name + - ' ' + - resPhone.name, - ) - return resPhone + this.logger.verbose('Created default auth providers') } } diff --git a/server/src/main.ts b/server/src/main.ts index 0271bf2952..3b204d511e 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -48,7 +48,6 @@ async function bootstrap() { try { const initService = app.get(InitializerService) await initService.createDefaultRegion() - await initService.createDefaultBundle() await initService.createDefaultRuntime() await initService.createDefaultAuthProvider() } catch (error) { diff --git a/server/src/prisma/prisma.module.ts b/server/src/prisma/prisma.module.ts deleted file mode 100644 index 4501415d70..0000000000 --- a/server/src/prisma/prisma.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Global, Module } from '@nestjs/common' -import { PrismaService } from './prisma.service' - -@Global() -@Module({ - providers: [PrismaService], - exports: [PrismaService], -}) -export class PrismaModule {} diff --git a/server/src/prisma/prisma.service.ts b/server/src/prisma/prisma.service.ts deleted file mode 100644 index 976fc249b3..0000000000 --- a/server/src/prisma/prisma.service.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - INestApplication, - Injectable, - Logger, - OnModuleInit, -} from '@nestjs/common' -import { PrismaClient } from '@prisma/client' - -@Injectable() -export class PrismaService extends PrismaClient implements OnModuleInit { - private readonly logger = new Logger(PrismaService.name) - async onModuleInit() { - await this.$connect() - this.logger.debug('PrismaService connected') - } - - async enableShutdownHooks(app: INestApplication) { - this.$on('beforeExit', async () => { - await app.close() - }) - } -} diff --git a/server/src/region/entities/region.ts b/server/src/region/entities/region.ts index 17399db420..73cbb7928c 100644 --- a/server/src/region/entities/region.ts +++ b/server/src/region/entities/region.ts @@ -2,7 +2,7 @@ import { ObjectId } from 'mongodb' export type RegionClusterConf = { driver: string - kubeconfig: string | null + kubeconfig: string npmInstallFlags: string } @@ -40,7 +40,7 @@ export class Region { gatewayConf: RegionGatewayConf storageConf: RegionStorageConf tls: boolean - state: string + state: 'Active' | 'Inactive' createdAt: Date updatedAt: Date From 2093afb5375a909cf36ee63a50a9e119e5baf75d Mon Sep 17 00:00:00 2001 From: maslow Date: Fri, 19 May 2023 21:51:01 +0800 Subject: [PATCH 23/48] add resource option & template api --- server/README.md | 4 +- .../application/application-task.service.ts | 6 +- server/src/application/application.module.ts | 4 +- .../{region => application}/bundle.service.ts | 4 +- server/src/database/database.module.ts | 2 + .../src/database/policy/policy.controller.ts | 4 +- server/src/function/function.controller.ts | 4 +- server/src/initializer/initializer.service.ts | 179 ++++++++++++++++++ server/src/main.ts | 4 +- server/src/region/entities/resource.ts | 43 +++++ server/src/region/region.controller.ts | 42 +++- server/src/region/region.module.ts | 6 +- server/src/region/region.service.ts | 11 +- server/src/region/resource-option.service.ts | 42 ++++ server/src/storage/bucket.controller.ts | 4 +- server/src/storage/storage.module.ts | 2 + server/src/trigger/trigger.controller.ts | 4 +- server/src/trigger/trigger.module.ts | 2 + server/src/website/website.controller.ts | 4 +- server/src/website/website.module.ts | 3 +- web/package.json | 3 +- 21 files changed, 347 insertions(+), 30 deletions(-) rename server/src/{region => application}/bundle.service.ts (86%) create mode 100644 server/src/region/entities/resource.ts create mode 100644 server/src/region/resource-option.service.ts diff --git a/server/README.md b/server/README.md index 174a9feb66..18972d6a87 100644 --- a/server/README.md +++ b/server/README.md @@ -35,12 +35,10 @@ ```bash cd server/ -# Install telepresence traffic manager +# Install telepresence traffic manager (only telepresence helm install # Connect your computer to laf-dev cluster telepresence connect -# view the available services, service status needs to be Ready, `ready to intercept` -telepresence list -n laf-system # Connect local server to laf server cluster telepresence intercept laf-server -n laf-system -p 3000:3000 -e $(pwd)/.env diff --git a/server/src/application/application-task.service.ts b/server/src/application/application-task.service.ts index cafe78c8ac..253f2e9bb6 100644 --- a/server/src/application/application-task.service.ts +++ b/server/src/application/application-task.service.ts @@ -11,7 +11,7 @@ import { SystemDatabase } from 'src/database/system-database' import { TriggerService } from 'src/trigger/trigger.service' import { FunctionService } from 'src/function/function.service' import { ApplicationConfigurationService } from './configuration.service' -import { BundleService } from 'src/region/bundle.service' +import { BundleService } from 'src/application/bundle.service' import { WebsiteService } from 'src/website/website.service' import { PolicyService } from 'src/database/policy/policy.service' import { BucketDomainService } from 'src/gateway/bucket-domain.service' @@ -225,9 +225,9 @@ export class ApplicationTaskService { } // delete application bundle - const bundle = await this.bundleService.findApplicationBundle(appid) + const bundle = await this.bundleService.findOne(appid) if (bundle) { - await this.bundleService.deleteApplicationBundle(appid) + await this.bundleService.deleteOne(appid) return await this.unlock(appid) } diff --git a/server/src/application/application.module.ts b/server/src/application/application.module.ts index 4fb1a96d72..ec00c8b210 100644 --- a/server/src/application/application.module.ts +++ b/server/src/application/application.module.ts @@ -14,6 +14,7 @@ import { ApplicationConfigurationService } from './configuration.service' import { TriggerService } from 'src/trigger/trigger.service' import { WebsiteService } from 'src/website/website.service' import { AccountModule } from 'src/account/account.module' +import { BundleService } from './bundle.service' @Module({ imports: [StorageModule, DatabaseModule, GatewayModule, AccountModule], @@ -28,7 +29,8 @@ import { AccountModule } from 'src/account/account.module' ApplicationConfigurationService, TriggerService, WebsiteService, + BundleService, ], - exports: [ApplicationService], + exports: [ApplicationService, BundleService], }) export class ApplicationModule {} diff --git a/server/src/region/bundle.service.ts b/server/src/application/bundle.service.ts similarity index 86% rename from server/src/region/bundle.service.ts rename to server/src/application/bundle.service.ts index 462a088dba..7994fded29 100644 --- a/server/src/region/bundle.service.ts +++ b/server/src/application/bundle.service.ts @@ -7,7 +7,7 @@ export class BundleService { private readonly logger = new Logger(BundleService.name) private readonly db = SystemDatabase.db - async findApplicationBundle(appid: string) { + async findOne(appid: string) { const bundle = await this.db .collection('ApplicationBundle') .findOne({ appid }) @@ -15,7 +15,7 @@ export class BundleService { return bundle } - async deleteApplicationBundle(appid: string) { + async deleteOne(appid: string) { const res = await this.db .collection('ApplicationBundle') .findOneAndDelete({ appid }) diff --git a/server/src/database/database.module.ts b/server/src/database/database.module.ts index d73814cd4d..4faad95a54 100644 --- a/server/src/database/database.module.ts +++ b/server/src/database/database.module.ts @@ -9,6 +9,7 @@ import { PolicyRuleService } from './policy/policy-rule.service' import { PolicyRuleController } from './policy/policy-rule.controller' import { MongoService } from './mongo.service' import { ApplicationService } from 'src/application/application.service' +import { BundleService } from 'src/application/bundle.service' @Module({ imports: [], @@ -25,6 +26,7 @@ import { ApplicationService } from 'src/application/application.service' PolicyRuleService, MongoService, ApplicationService, + BundleService, ], exports: [ CollectionService, diff --git a/server/src/database/policy/policy.controller.ts b/server/src/database/policy/policy.controller.ts index 2da6fd0db0..504a4941cf 100644 --- a/server/src/database/policy/policy.controller.ts +++ b/server/src/database/policy/policy.controller.ts @@ -20,7 +20,7 @@ import { UpdatePolicyDto } from '../dto/update-policy.dto' import { ResponseUtil } from 'src/utils/response' import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' -import { BundleService } from 'src/region/bundle.service' +import { BundleService } from 'src/application/bundle.service' @ApiTags('Database') @ApiBearerAuth('Authorization') @@ -37,7 +37,7 @@ export class PolicyController { @UseGuards(JwtAuthGuard, ApplicationAuthGuard) async create(@Param('appid') appid: string, @Body() dto: CreatePolicyDto) { // check policy count limit - const bundle = await this.bundleService.findApplicationBundle(appid) + const bundle = await this.bundleService.findOne(appid) const LIMIT_COUNT = bundle?.resource?.limitCountOfDatabasePolicy || 0 const count = await this.policiesService.count(appid) if (count >= LIMIT_COUNT) { diff --git a/server/src/function/function.controller.ts b/server/src/function/function.controller.ts index be83fc62d0..7880293565 100644 --- a/server/src/function/function.controller.ts +++ b/server/src/function/function.controller.ts @@ -25,7 +25,7 @@ import { ApplicationAuthGuard } from '../auth/application.auth.guard' import { FunctionService } from './function.service' import { IRequest } from '../utils/interface' import { CompileFunctionDto } from './dto/compile-function.dto' -import { BundleService } from 'src/region/bundle.service' +import { BundleService } from 'src/application/bundle.service' import { I18n, I18nContext, I18nService } from 'nestjs-i18n' import { I18nTranslations } from '../generated/i18n.generated' @@ -68,7 +68,7 @@ export class FunctionController { } // check if meet the count limit - const bundle = await this.bundleService.findApplicationBundle(appid) + const bundle = await this.bundleService.findOne(appid) const MAX_FUNCTION_COUNT = bundle?.resource?.limitCountOfCloudFunction || 0 const count = await this.functionsService.count(appid) if (count >= MAX_FUNCTION_COUNT) { diff --git a/server/src/initializer/initializer.service.ts b/server/src/initializer/initializer.service.ts index d9e2fee973..1021551d60 100644 --- a/server/src/initializer/initializer.service.ts +++ b/server/src/initializer/initializer.service.ts @@ -7,12 +7,25 @@ import { AuthProvider, AuthProviderState, } from 'src/auth/entities/auth-provider' +import { + ResourceOption, + ResourceTemplate, + ResourceType, +} from 'src/region/entities/resource' @Injectable() export class InitializerService { private readonly logger = new Logger(InitializerService.name) private readonly db = SystemDatabase.db + async init() { + await this.createDefaultRegion() + await this.createDefaultRuntime() + await this.createDefaultAuthProvider() + await this.createDefaultResourceOptions() + await this.createDefaultResourceTemplates() + } + async createDefaultRegion() { // check if exists const existed = await this.db.collection('Region').countDocuments() @@ -135,4 +148,170 @@ export class InitializerService { this.logger.verbose('Created default auth providers') } + + async createDefaultResourceOptions() { + // check if exists + const existed = await this.db + .collection('ResourceOption') + .countDocuments() + if (existed) { + this.logger.debug('default resource options already exists') + return + } + + // get default region + const region = await this.db.collection('Region').findOne({}) + + // create default resource options + await this.db.collection('ResourceOption').insertMany([ + { + regionId: region._id, + type: ResourceType.CPU, + price: 0.072, + specs: [ + { label: '0.2 Core', value: 200 }, + { label: '0.5 Core', value: 500 }, + { label: '1 Core', value: 1000 }, + { label: '2 Core', value: 2000 }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + type: ResourceType.Memory, + price: 0.036, + specs: [ + { label: '256 MB', value: 256 }, + { label: '512 MB', value: 512 }, + { label: '1 GB', value: 1024 }, + { label: '2 GB', value: 2048 }, + { label: '4 GB', value: 4096 }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + type: ResourceType.DatabaseCapacity, + price: 0.0072, + specs: [ + { label: '1 GB', value: 1024 }, + { label: '4 GB', value: 4096 }, + { label: '16 GB', value: 16384 }, + { label: '64 GB', value: 65536 }, + { label: '256 GB', value: 262144 }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + type: ResourceType.StorageCapacity, + price: 0.002, + specs: [ + { label: '1 GB', value: 1024 }, + { label: '4 GB', value: 4096 }, + { label: '16 GB', value: 16384 }, + { label: '64 GB', value: 65536 }, + { label: '256 GB', value: 262144 }, + { label: '1 TB', value: 1048576 }, + ], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + type: ResourceType.NetworkTraffic, + price: 0.8, + specs: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + this.logger.verbose('Created default resource options') + } + + async createDefaultResourceTemplates() { + // check if exists + const existed = await this.db + .collection('ResourceTemplate') + .countDocuments() + + if (existed) { + this.logger.debug('default resource templates already exists') + return + } + + // get default region + const region = await this.db.collection('Region').findOne({}) + + // create default resource templates + await this.db.collection('ResourceTemplate').insertMany([ + { + regionId: region._id, + name: 'trial', + displayName: 'Trial', + spec: { + [ResourceType.CPU]: { value: 200 }, + [ResourceType.Memory]: { value: 256 }, + [ResourceType.DatabaseCapacity]: { value: 1024 }, + [ResourceType.StorageCapacity]: { value: 1024 }, + [ResourceType.NetworkTraffic]: { value: 0 }, + }, + enableFreeTier: true, + limitCountOfFreeTierPerUser: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + name: 'lite', + displayName: 'Lite', + spec: { + [ResourceType.CPU]: { value: 500 }, + [ResourceType.Memory]: { value: 512 }, + [ResourceType.DatabaseCapacity]: { value: 4096 }, + [ResourceType.StorageCapacity]: { value: 4096 }, + [ResourceType.NetworkTraffic]: { value: 0 }, + }, + enableFreeTier: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + name: 'standard', + displayName: 'Standard', + spec: { + [ResourceType.CPU]: { value: 1000 }, + [ResourceType.Memory]: { value: 2048 }, + [ResourceType.DatabaseCapacity]: { value: 16384 }, + [ResourceType.StorageCapacity]: { value: 65536 }, + [ResourceType.NetworkTraffic]: { value: 0 }, + }, + enableFreeTier: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + regionId: region._id, + name: 'pro', + displayName: 'Pro', + spec: { + [ResourceType.CPU]: { value: 2000 }, + [ResourceType.Memory]: { value: 4096 }, + [ResourceType.DatabaseCapacity]: { value: 65536 }, + [ResourceType.StorageCapacity]: { value: 262144 }, + [ResourceType.NetworkTraffic]: { value: 0 }, + }, + enableFreeTier: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + this.logger.verbose('Created default resource templates') + } } diff --git a/server/src/main.ts b/server/src/main.ts index 3b204d511e..6d083b2d6f 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -47,9 +47,7 @@ async function bootstrap() { try { const initService = app.get(InitializerService) - await initService.createDefaultRegion() - await initService.createDefaultRuntime() - await initService.createDefaultAuthProvider() + await initService.init() } catch (error) { console.error(error) process.exit(1) diff --git a/server/src/region/entities/resource.ts b/server/src/region/entities/resource.ts new file mode 100644 index 0000000000..e690622214 --- /dev/null +++ b/server/src/region/entities/resource.ts @@ -0,0 +1,43 @@ +import { ObjectId } from 'mongodb' + +export enum ResourceType { + CPU = 'cpu', + Memory = 'memory', + DatabaseCapacity = 'databaseCapacity', + StorageCapacity = 'storageCapacity', + NetworkTraffic = 'networkTraffic', +} + +export interface ResourceSpec { + value: number + label?: string +} + +export class ResourceOption { + _id?: ObjectId + regionId: ObjectId + type: ResourceType + price: number + specs: ResourceSpec[] + createdAt: Date + updatedAt: Date +} + +export class ResourceTemplate { + _id?: ObjectId + regionId: ObjectId + name: string + displayName: string + spec: { + [ResourceType.CPU]: ResourceSpec + [ResourceType.Memory]: ResourceSpec + [ResourceType.DatabaseCapacity]: ResourceSpec + [ResourceType.StorageCapacity]: ResourceSpec + [ResourceType.NetworkTraffic]?: ResourceSpec + } + enableFreeTier?: boolean + limitCountOfFreeTierPerUser?: number + message?: string + createdAt: Date + updatedAt: Date +} diff --git a/server/src/region/region.controller.ts b/server/src/region/region.controller.ts index d108126ba4..075d207047 100644 --- a/server/src/region/region.controller.ts +++ b/server/src/region/region.controller.ts @@ -1,13 +1,18 @@ -import { Controller, Get, Logger } from '@nestjs/common' +import { Controller, Get, Logger, Param } from '@nestjs/common' import { ApiOperation, ApiTags } from '@nestjs/swagger' import { ResponseUtil } from '../utils/response' import { RegionService } from './region.service' +import { ResourceOptionService } from './resource-option.service' +import { ObjectId } from 'mongodb' @ApiTags('Public') @Controller('regions') export class RegionController { private readonly logger = new Logger(RegionController.name) - constructor(private readonly regionService: RegionService) {} + constructor( + private readonly regionService: RegionService, + private readonly resourceService: ResourceOptionService, + ) {} /** * Get region list @@ -19,4 +24,37 @@ export class RegionController { const data = await this.regionService.findAllDesensitized() return ResponseUtil.ok(data) } + + /** + * Get resource option list + */ + @ApiOperation({ summary: 'Get resource option list' }) + @Get('resource-options') + async getResourceOptions() { + const data = await this.resourceService.findAll() + return ResponseUtil.ok(data) + } + + /** + * Get resource option list by region id + */ + @ApiOperation({ summary: 'Get resource option list by region id' }) + @Get('resource-options/:regionId') + async getResourceOptionsByRegionId(@Param('regionId') regionId: string) { + const data = await this.resourceService.findAllByRegionId( + new ObjectId(regionId), + ) + return ResponseUtil.ok(data) + } + + /** + * Get resource template list + * @returns + */ + @ApiOperation({ summary: 'Get resource template list' }) + @Get('resource-templates') + async getResourceTemplates() { + const data = await this.resourceService.findAllTemplates() + return ResponseUtil.ok(data) + } } diff --git a/server/src/region/region.module.ts b/server/src/region/region.module.ts index 18294f5f99..d2cdc0781b 100644 --- a/server/src/region/region.module.ts +++ b/server/src/region/region.module.ts @@ -2,12 +2,12 @@ import { Global, Module } from '@nestjs/common' import { RegionService } from './region.service' import { RegionController } from './region.controller' import { ClusterService } from './cluster/cluster.service' -import { BundleService } from './bundle.service' +import { ResourceOptionService } from './resource-option.service' @Global() @Module({ - providers: [RegionService, ClusterService, BundleService], + providers: [RegionService, ClusterService, ResourceOptionService], controllers: [RegionController], - exports: [RegionService, ClusterService, BundleService], + exports: [RegionService, ClusterService], }) export class RegionModule {} diff --git a/server/src/region/region.service.ts b/server/src/region/region.service.ts index 50a035e062..4043fda30e 100644 --- a/server/src/region/region.service.ts +++ b/server/src/region/region.service.ts @@ -53,11 +53,20 @@ export class RegionService { name: 1, displayName: 1, state: 1, + resourceTemplates: 1, } const regions = await this.db .collection('Region') - .find({}, { projection }) + .aggregate() + .match({}) + .lookup({ + from: 'ResourceTemplate', + localField: '_id', + foreignField: 'regionId', + as: 'resourceTemplates', + }) + .project(projection) .toArray() return regions diff --git a/server/src/region/resource-option.service.ts b/server/src/region/resource-option.service.ts new file mode 100644 index 0000000000..134400dbe1 --- /dev/null +++ b/server/src/region/resource-option.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common' +import { SystemDatabase } from 'src/database/system-database' +import { ObjectId } from 'mongodb' +import { ResourceOption, ResourceTemplate } from './entities/resource' + +@Injectable() +export class ResourceOptionService { + private readonly db = SystemDatabase.db + + async findAll() { + const options = await this.db + .collection('ResourceOption') + .find() + .toArray() + return options + } + + async findOne(id: ObjectId) { + const option = await this.db + .collection('ResourceOption') + .findOne({ _id: id }) + return option + } + + async findAllByRegionId(regionId: ObjectId) { + const options = await this.db + .collection('ResourceOption') + .find({ regionId }) + .toArray() + + return options + } + + async findAllTemplates() { + const options = await this.db + .collection('ResourceTemplate') + .find() + .toArray() + + return options + } +} diff --git a/server/src/storage/bucket.controller.ts b/server/src/storage/bucket.controller.ts index 7cd5a5ae75..d174a98b34 100644 --- a/server/src/storage/bucket.controller.ts +++ b/server/src/storage/bucket.controller.ts @@ -25,7 +25,7 @@ import { ResponseUtil } from '../utils/response' import { CreateBucketDto } from './dto/create-bucket.dto' import { UpdateBucketDto } from './dto/update-bucket.dto' import { BucketService } from './bucket.service' -import { BundleService } from 'src/region/bundle.service' +import { BundleService } from 'src/application/bundle.service' @ApiTags('Storage') @ApiBearerAuth('Authorization') @@ -56,7 +56,7 @@ export class BucketController { const app = req.application // check bucket count limit - const bundle = await this.bundleService.findApplicationBundle(appid) + const bundle = await this.bundleService.findOne(appid) const LIMIT_COUNT = bundle?.resource?.limitCountOfBucket || 0 const count = await this.bucketService.count(appid) if (count >= LIMIT_COUNT) { diff --git a/server/src/storage/storage.module.ts b/server/src/storage/storage.module.ts index 934fd530b1..65e591ee32 100644 --- a/server/src/storage/storage.module.ts +++ b/server/src/storage/storage.module.ts @@ -6,6 +6,7 @@ import { ApplicationService } from 'src/application/application.service' import { BucketService } from './bucket.service' import { GatewayModule } from 'src/gateway/gateway.module' import { BucketTaskService } from './bucket-task.service' +import { BundleService } from 'src/application/bundle.service' @Module({ imports: [GatewayModule], @@ -16,6 +17,7 @@ import { BucketTaskService } from './bucket-task.service' ApplicationService, BucketService, BucketTaskService, + BundleService, ], exports: [StorageService, MinioService, BucketService], }) diff --git a/server/src/trigger/trigger.controller.ts b/server/src/trigger/trigger.controller.ts index f6a8813806..240e0df948 100644 --- a/server/src/trigger/trigger.controller.ts +++ b/server/src/trigger/trigger.controller.ts @@ -19,7 +19,7 @@ import { } from '@nestjs/swagger' import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' -import { BundleService } from 'src/region/bundle.service' +import { BundleService } from 'src/application/bundle.service' import { ObjectId } from 'mongodb' @ApiTags('Trigger') @@ -44,7 +44,7 @@ export class TriggerController { @Post() async create(@Param('appid') appid: string, @Body() dto: CreateTriggerDto) { // check trigger count limit - const bundle = await this.bundleService.findApplicationBundle(appid) + const bundle = await this.bundleService.findOne(appid) const LIMIT_COUNT = bundle?.resource?.limitCountOfTrigger || 0 const count = await this.triggerService.count(appid) if (count >= LIMIT_COUNT) { diff --git a/server/src/trigger/trigger.module.ts b/server/src/trigger/trigger.module.ts index cc76d01d05..9d024eb77f 100644 --- a/server/src/trigger/trigger.module.ts +++ b/server/src/trigger/trigger.module.ts @@ -10,6 +10,7 @@ import { TriggerTaskService } from './trigger-task.service' import { FunctionService } from 'src/function/function.service' import { DatabaseService } from 'src/database/database.service' import { MongoService } from 'src/database/mongo.service' +import { BundleService } from 'src/application/bundle.service' @Module({ imports: [StorageModule, HttpModule], @@ -23,6 +24,7 @@ import { MongoService } from 'src/database/mongo.service' FunctionService, DatabaseService, MongoService, + BundleService, ], exports: [TriggerService, CronJobService], }) diff --git a/server/src/website/website.controller.ts b/server/src/website/website.controller.ts index 24522519f9..e91eb24237 100644 --- a/server/src/website/website.controller.ts +++ b/server/src/website/website.controller.ts @@ -20,7 +20,7 @@ import { import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' import { ResponseUtil } from 'src/utils/response' -import { BundleService } from 'src/region/bundle.service' +import { BundleService } from 'src/application/bundle.service' import { BucketService } from 'src/storage/bucket.service' import { ObjectId } from 'mongodb' import { DomainState } from 'src/gateway/entities/runtime-domain' @@ -48,7 +48,7 @@ export class WebsiteController { @Post() async create(@Param('appid') appid: string, @Body() dto: CreateWebsiteDto) { // check if website hosting limit reached - const bundle = await this.bundleService.findApplicationBundle(appid) + const bundle = await this.bundleService.findOne(appid) const LIMIT_COUNT = bundle?.resource?.limitCountOfWebsiteHosting || 0 const count = await this.websiteService.count(appid) if (count >= LIMIT_COUNT) { diff --git a/server/src/website/website.module.ts b/server/src/website/website.module.ts index cfa5aae2e4..badbc27edf 100644 --- a/server/src/website/website.module.ts +++ b/server/src/website/website.module.ts @@ -3,10 +3,11 @@ import { WebsiteService } from './website.service' import { WebsiteController } from './website.controller' import { ApplicationService } from 'src/application/application.service' import { StorageModule } from 'src/storage/storage.module' +import { BundleService } from 'src/application/bundle.service' @Module({ imports: [StorageModule], controllers: [WebsiteController], - providers: [WebsiteService, ApplicationService], + providers: [WebsiteService, ApplicationService, BundleService], }) export class WebsiteModule {} diff --git a/web/package.json b/web/package.json index 577368016b..ddf7079cf5 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ "lint": "eslint src --fix", "prettier": "prettier --write ./src", "prepare": "cd .. && husky install web/.husky", + "intercept": "telepresence intercept laf-web -n laf-system -p 3001:80", "lint-staged": "lint-staged" }, "dependencies": { @@ -74,4 +75,4 @@ "prettier --write" ] } -} +} \ No newline at end of file From b3a7d9561e47361522f4de192cac56a2a5265c2f Mon Sep 17 00:00:00 2001 From: maslow Date: Fri, 19 May 2023 22:03:18 +0800 Subject: [PATCH 24/48] rename resource template to bundle --- server/src/application/entities/application.ts | 1 + server/src/initializer/initializer.service.ts | 10 +++++----- server/src/region/entities/resource.ts | 2 +- server/src/region/region.controller.ts | 10 +++++----- server/src/region/region.module.ts | 4 ++-- server/src/region/region.service.ts | 6 +++--- ...{resource-option.service.ts => resource.service.ts} | 8 ++++---- 7 files changed, 21 insertions(+), 20 deletions(-) rename server/src/region/{resource-option.service.ts => resource.service.ts} (80%) diff --git a/server/src/application/entities/application.ts b/server/src/application/entities/application.ts index c673f64c62..c7cacf9b32 100644 --- a/server/src/application/entities/application.ts +++ b/server/src/application/entities/application.ts @@ -32,6 +32,7 @@ export class Application { tags: string[] state: ApplicationState phase: ApplicationPhase + isTrialTier?: boolean createdAt: Date updatedAt: Date lockedAt: Date diff --git a/server/src/initializer/initializer.service.ts b/server/src/initializer/initializer.service.ts index 1021551d60..e5825cd02c 100644 --- a/server/src/initializer/initializer.service.ts +++ b/server/src/initializer/initializer.service.ts @@ -9,7 +9,7 @@ import { } from 'src/auth/entities/auth-provider' import { ResourceOption, - ResourceTemplate, + ResourceBundle, ResourceType, } from 'src/region/entities/resource' @@ -23,7 +23,7 @@ export class InitializerService { await this.createDefaultRuntime() await this.createDefaultAuthProvider() await this.createDefaultResourceOptions() - await this.createDefaultResourceTemplates() + await this.createDefaultResourceBundles() } async createDefaultRegion() { @@ -233,10 +233,10 @@ export class InitializerService { this.logger.verbose('Created default resource options') } - async createDefaultResourceTemplates() { + async createDefaultResourceBundles() { // check if exists const existed = await this.db - .collection('ResourceTemplate') + .collection('ResourceBundle') .countDocuments() if (existed) { @@ -248,7 +248,7 @@ export class InitializerService { const region = await this.db.collection('Region').findOne({}) // create default resource templates - await this.db.collection('ResourceTemplate').insertMany([ + await this.db.collection('ResourceBundle').insertMany([ { regionId: region._id, name: 'trial', diff --git a/server/src/region/entities/resource.ts b/server/src/region/entities/resource.ts index e690622214..fc92604ec6 100644 --- a/server/src/region/entities/resource.ts +++ b/server/src/region/entities/resource.ts @@ -23,7 +23,7 @@ export class ResourceOption { updatedAt: Date } -export class ResourceTemplate { +export class ResourceBundle { _id?: ObjectId regionId: ObjectId name: string diff --git a/server/src/region/region.controller.ts b/server/src/region/region.controller.ts index 075d207047..737f1b6109 100644 --- a/server/src/region/region.controller.ts +++ b/server/src/region/region.controller.ts @@ -2,7 +2,7 @@ import { Controller, Get, Logger, Param } from '@nestjs/common' import { ApiOperation, ApiTags } from '@nestjs/swagger' import { ResponseUtil } from '../utils/response' import { RegionService } from './region.service' -import { ResourceOptionService } from './resource-option.service' +import { ResourceService } from './resource.service' import { ObjectId } from 'mongodb' @ApiTags('Public') @@ -11,7 +11,7 @@ export class RegionController { private readonly logger = new Logger(RegionController.name) constructor( private readonly regionService: RegionService, - private readonly resourceService: ResourceOptionService, + private readonly resourceService: ResourceService, ) {} /** @@ -52,9 +52,9 @@ export class RegionController { * @returns */ @ApiOperation({ summary: 'Get resource template list' }) - @Get('resource-templates') - async getResourceTemplates() { - const data = await this.resourceService.findAllTemplates() + @Get('resource-bundles') + async getResourceBundles() { + const data = await this.resourceService.findAllBundles() return ResponseUtil.ok(data) } } diff --git a/server/src/region/region.module.ts b/server/src/region/region.module.ts index d2cdc0781b..692e801e35 100644 --- a/server/src/region/region.module.ts +++ b/server/src/region/region.module.ts @@ -2,11 +2,11 @@ import { Global, Module } from '@nestjs/common' import { RegionService } from './region.service' import { RegionController } from './region.controller' import { ClusterService } from './cluster/cluster.service' -import { ResourceOptionService } from './resource-option.service' +import { ResourceService } from './resource.service' @Global() @Module({ - providers: [RegionService, ClusterService, ResourceOptionService], + providers: [RegionService, ClusterService, ResourceService], controllers: [RegionController], exports: [RegionService, ClusterService], }) diff --git a/server/src/region/region.service.ts b/server/src/region/region.service.ts index 4043fda30e..2c07a2fc5d 100644 --- a/server/src/region/region.service.ts +++ b/server/src/region/region.service.ts @@ -53,7 +53,7 @@ export class RegionService { name: 1, displayName: 1, state: 1, - resourceTemplates: 1, + bundles: 1, } const regions = await this.db @@ -61,10 +61,10 @@ export class RegionService { .aggregate() .match({}) .lookup({ - from: 'ResourceTemplate', + from: 'ResourceBundle', localField: '_id', foreignField: 'regionId', - as: 'resourceTemplates', + as: 'bundles', }) .project(projection) .toArray() diff --git a/server/src/region/resource-option.service.ts b/server/src/region/resource.service.ts similarity index 80% rename from server/src/region/resource-option.service.ts rename to server/src/region/resource.service.ts index 134400dbe1..28aa51774b 100644 --- a/server/src/region/resource-option.service.ts +++ b/server/src/region/resource.service.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common' import { SystemDatabase } from 'src/database/system-database' import { ObjectId } from 'mongodb' -import { ResourceOption, ResourceTemplate } from './entities/resource' +import { ResourceOption, ResourceBundle } from './entities/resource' @Injectable() -export class ResourceOptionService { +export class ResourceService { private readonly db = SystemDatabase.db async findAll() { @@ -31,9 +31,9 @@ export class ResourceOptionService { return options } - async findAllTemplates() { + async findAllBundles() { const options = await this.db - .collection('ResourceTemplate') + .collection('ResourceBundle') .find() .toArray() From 935c7b231be4e7b5205ca06e1432b1e140be2622 Mon Sep 17 00:00:00 2001 From: maslow Date: Tue, 23 May 2023 15:09:27 +0800 Subject: [PATCH 25/48] add billing module & price calculation api --- server/package-lock.json | 11 +++ server/package.json | 3 +- server/src/app.module.ts | 2 + server/src/billing/billing.controller.ts | 71 +++++++++++++++++++ server/src/billing/billing.module.ts | 11 +++ server/src/billing/billing.service.ts | 68 ++++++++++++++++++ .../{region => billing}/entities/resource.ts | 0 .../{region => billing}/resource.service.ts | 26 ++++++- server/src/initializer/initializer.service.ts | 10 +-- server/src/region/region.controller.ts | 42 +---------- server/src/region/region.module.ts | 3 +- 11 files changed, 198 insertions(+), 49 deletions(-) create mode 100644 server/src/billing/billing.controller.ts create mode 100644 server/src/billing/billing.module.ts create mode 100644 server/src/billing/billing.service.ts rename server/src/{region => billing}/entities/resource.ts (100%) rename server/src/{region => billing}/resource.service.ts (62%) diff --git a/server/package-lock.json b/server/package-lock.json index a06e7f6ad5..ff4ee3c906 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -31,6 +31,7 @@ "cron-validate": "^1.4.5", "database-proxy": "^1.0.0-beta.2", "dayjs": "^1.11.7", + "decimal.js": "^10.4.3", "dotenv": "^16.0.3", "fast-json-patch": "^3.1.1", "lodash": "^4.17.21", @@ -9516,6 +9517,11 @@ "callsite": "^1.0.0" } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -24545,6 +24551,11 @@ "callsite": "^1.0.0" } }, + "decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, "dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", diff --git a/server/package.json b/server/package.json index fce43be3a0..1d2e2af4e9 100644 --- a/server/package.json +++ b/server/package.json @@ -46,6 +46,7 @@ "cron-validate": "^1.4.5", "database-proxy": "^1.0.0-beta.2", "dayjs": "^1.11.7", + "decimal.js": "^10.4.3", "dotenv": "^16.0.3", "fast-json-patch": "^3.1.1", "lodash": "^4.17.21", @@ -108,4 +109,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} \ No newline at end of file +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index c5b3d64cf7..a5ef690196 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -21,6 +21,7 @@ import { AccountModule } from './account/account.module' import { SettingModule } from './setting/setting.module' import * as path from 'path' import { AcceptLanguageResolver, I18nModule, QueryResolver } from 'nestjs-i18n' +import { BillingModule } from './billing/billing.module' @Module({ imports: [ @@ -60,6 +61,7 @@ import { AcceptLanguageResolver, I18nModule, QueryResolver } from 'nestjs-i18n' '../src/generated/i18n.generated.ts', ), }), + BillingModule, ], controllers: [AppController], providers: [AppService], diff --git a/server/src/billing/billing.controller.ts b/server/src/billing/billing.controller.ts new file mode 100644 index 0000000000..bd120adb35 --- /dev/null +++ b/server/src/billing/billing.controller.ts @@ -0,0 +1,71 @@ +import { Body, Controller, Get, Logger, Param, Post } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { CreateApplicationDto } from 'src/application/dto/create-application.dto' +import { ResourceService } from './resource.service' +import { ResponseUtil } from 'src/utils/response' +import { ObjectId } from 'mongodb' +import { BillingService } from './billing.service' +import { RegionService } from 'src/region/region.service' + +@ApiTags('Billing') +@Controller('billing') +export class BillingController { + private readonly logger = new Logger(BillingController.name) + + constructor( + private readonly resource: ResourceService, + private readonly billing: BillingService, + private readonly region: RegionService, + ) {} + + /** + * Calculate pricing + * @param dto + */ + @ApiOperation({ summary: 'Calculate pricing' }) + @Post('price') + async calculatePrice(@Body() dto: CreateApplicationDto) { + // check regionId exists + const region = await this.region.findOneDesensitized( + new ObjectId(dto.regionId), + ) + if (!region) { + return ResponseUtil.error(`region ${dto.regionId} not found`) + } + + const result = await this.billing.calculatePrice(dto) + return ResponseUtil.ok(result) + } + + /** + * Get resource option list + */ + @ApiOperation({ summary: 'Get resource option list' }) + @Get('resource-options') + async getResourceOptions() { + const options = await this.resource.findAll() + const grouped = this.resource.groupByType(options) + return ResponseUtil.ok(grouped) + } + + /** + * Get resource option list by region id + */ + @ApiOperation({ summary: 'Get resource option list by region id' }) + @Get('resource-options/:regionId') + async getResourceOptionsByRegionId(@Param('regionId') regionId: string) { + const data = await this.resource.findAllByRegionId(new ObjectId(regionId)) + return ResponseUtil.ok(data) + } + + /** + * Get resource template list + * @returns + */ + @ApiOperation({ summary: 'Get resource template list' }) + @Get('resource-bundles') + async getResourceBundles() { + const data = await this.resource.findAllBundles() + return ResponseUtil.ok(data) + } +} diff --git a/server/src/billing/billing.module.ts b/server/src/billing/billing.module.ts new file mode 100644 index 0000000000..a080c74db1 --- /dev/null +++ b/server/src/billing/billing.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { BillingService } from './billing.service' +import { ResourceService } from './resource.service' +import { BillingController } from './billing.controller' + +@Module({ + controllers: [BillingController], + providers: [BillingService, ResourceService], + exports: [BillingService, ResourceService], +}) +export class BillingModule {} diff --git a/server/src/billing/billing.service.ts b/server/src/billing/billing.service.ts new file mode 100644 index 0000000000..dca3d730f3 --- /dev/null +++ b/server/src/billing/billing.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common' +import { CreateApplicationDto } from 'src/application/dto/create-application.dto' +import { SystemDatabase } from 'src/database/system-database' +import { ResourceService } from './resource.service' +import { ObjectId } from 'mongodb' +import { ResourceType } from './entities/resource' +import { Decimal } from 'decimal.js' +import * as assert from 'assert' + +@Injectable() +export class BillingService { + private readonly db = SystemDatabase.db + + constructor(private readonly resource: ResourceService) {} + + async calculatePrice(dto: CreateApplicationDto) { + // get options by region id + const options = await this.resource.findAllByRegionId( + new ObjectId(dto.regionId), + ) + + const groupedOptions = this.resource.groupByType(options) + assert(groupedOptions[ResourceType.CPU], 'cpu option not found') + assert(groupedOptions[ResourceType.Memory], 'memory option not found') + assert( + groupedOptions[ResourceType.StorageCapacity], + 'storage capacity option not found', + ) + assert( + groupedOptions[ResourceType.DatabaseCapacity], + 'database capacity option not found', + ) + + // calculate cpu price + const cpuOption = groupedOptions[ResourceType.CPU] + const cpuPrice = new Decimal(cpuOption.price).mul(dto.cpu) + + // calculate memory price + const memoryOption = groupedOptions[ResourceType.Memory] + const memoryPrice = new Decimal(memoryOption.price).mul(dto.memory) + + // calculate storage capacity price + const storageOption = groupedOptions[ResourceType.StorageCapacity] + const storagePrice = new Decimal(storageOption.price).mul( + dto.storageCapacity, + ) + + // calculate database capacity price + const databaseOption = groupedOptions[ResourceType.DatabaseCapacity] + const databasePrice = new Decimal(databaseOption.price).mul( + dto.databaseCapacity, + ) + + // calculate total price + const totalPrice = cpuPrice + .add(memoryPrice) + .add(storagePrice) + .add(databasePrice) + + return { + cpu: cpuPrice.toNumber(), + memory: memoryPrice.toNumber(), + storageCapacity: storagePrice.toNumber(), + databaseCapacity: databasePrice.toNumber(), + total: totalPrice.toNumber(), + } + } +} diff --git a/server/src/region/entities/resource.ts b/server/src/billing/entities/resource.ts similarity index 100% rename from server/src/region/entities/resource.ts rename to server/src/billing/entities/resource.ts diff --git a/server/src/region/resource.service.ts b/server/src/billing/resource.service.ts similarity index 62% rename from server/src/region/resource.service.ts rename to server/src/billing/resource.service.ts index 28aa51774b..d8416629bc 100644 --- a/server/src/region/resource.service.ts +++ b/server/src/billing/resource.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common' import { SystemDatabase } from 'src/database/system-database' import { ObjectId } from 'mongodb' -import { ResourceOption, ResourceBundle } from './entities/resource' +import { + ResourceOption, + ResourceBundle, + ResourceType, +} from './entities/resource' @Injectable() export class ResourceService { @@ -22,6 +26,13 @@ export class ResourceService { return option } + async findOneByType(type: ResourceType) { + const option = await this.db + .collection('ResourceOption') + .findOne({ type: type }) + return option + } + async findAllByRegionId(regionId: ObjectId) { const options = await this.db .collection('ResourceOption') @@ -39,4 +50,17 @@ export class ResourceService { return options } + + groupByType(options: ResourceOption[]) { + type GroupedOptions = { + [key in ResourceType]: ResourceOption + } + + const groupedOptions = options.reduce((acc, cur) => { + acc[cur.type] = cur + return acc + }, {} as GroupedOptions) + + return groupedOptions + } } diff --git a/server/src/initializer/initializer.service.ts b/server/src/initializer/initializer.service.ts index e5825cd02c..f1db7fe1ae 100644 --- a/server/src/initializer/initializer.service.ts +++ b/server/src/initializer/initializer.service.ts @@ -11,7 +11,7 @@ import { ResourceOption, ResourceBundle, ResourceType, -} from 'src/region/entities/resource' +} from 'src/billing/entities/resource' @Injectable() export class InitializerService { @@ -167,7 +167,7 @@ export class InitializerService { { regionId: region._id, type: ResourceType.CPU, - price: 0.072, + price: 0.000072, specs: [ { label: '0.2 Core', value: 200 }, { label: '0.5 Core', value: 500 }, @@ -180,7 +180,7 @@ export class InitializerService { { regionId: region._id, type: ResourceType.Memory, - price: 0.036, + price: 0.000036, specs: [ { label: '256 MB', value: 256 }, { label: '512 MB', value: 512 }, @@ -194,7 +194,7 @@ export class InitializerService { { regionId: region._id, type: ResourceType.DatabaseCapacity, - price: 0.0072, + price: 0.0000072, specs: [ { label: '1 GB', value: 1024 }, { label: '4 GB', value: 4096 }, @@ -208,7 +208,7 @@ export class InitializerService { { regionId: region._id, type: ResourceType.StorageCapacity, - price: 0.002, + price: 0.000002, specs: [ { label: '1 GB', value: 1024 }, { label: '4 GB', value: 4096 }, diff --git a/server/src/region/region.controller.ts b/server/src/region/region.controller.ts index 737f1b6109..d108126ba4 100644 --- a/server/src/region/region.controller.ts +++ b/server/src/region/region.controller.ts @@ -1,18 +1,13 @@ -import { Controller, Get, Logger, Param } from '@nestjs/common' +import { Controller, Get, Logger } from '@nestjs/common' import { ApiOperation, ApiTags } from '@nestjs/swagger' import { ResponseUtil } from '../utils/response' import { RegionService } from './region.service' -import { ResourceService } from './resource.service' -import { ObjectId } from 'mongodb' @ApiTags('Public') @Controller('regions') export class RegionController { private readonly logger = new Logger(RegionController.name) - constructor( - private readonly regionService: RegionService, - private readonly resourceService: ResourceService, - ) {} + constructor(private readonly regionService: RegionService) {} /** * Get region list @@ -24,37 +19,4 @@ export class RegionController { const data = await this.regionService.findAllDesensitized() return ResponseUtil.ok(data) } - - /** - * Get resource option list - */ - @ApiOperation({ summary: 'Get resource option list' }) - @Get('resource-options') - async getResourceOptions() { - const data = await this.resourceService.findAll() - return ResponseUtil.ok(data) - } - - /** - * Get resource option list by region id - */ - @ApiOperation({ summary: 'Get resource option list by region id' }) - @Get('resource-options/:regionId') - async getResourceOptionsByRegionId(@Param('regionId') regionId: string) { - const data = await this.resourceService.findAllByRegionId( - new ObjectId(regionId), - ) - return ResponseUtil.ok(data) - } - - /** - * Get resource template list - * @returns - */ - @ApiOperation({ summary: 'Get resource template list' }) - @Get('resource-bundles') - async getResourceBundles() { - const data = await this.resourceService.findAllBundles() - return ResponseUtil.ok(data) - } } diff --git a/server/src/region/region.module.ts b/server/src/region/region.module.ts index 692e801e35..eaa90738db 100644 --- a/server/src/region/region.module.ts +++ b/server/src/region/region.module.ts @@ -2,11 +2,10 @@ import { Global, Module } from '@nestjs/common' import { RegionService } from './region.service' import { RegionController } from './region.controller' import { ClusterService } from './cluster/cluster.service' -import { ResourceService } from './resource.service' @Global() @Module({ - providers: [RegionService, ClusterService, ResourceService], + providers: [RegionService, ClusterService], controllers: [RegionController], exports: [RegionService, ClusterService], }) From 1f23d4323bfc6a64a657c42e2208913b2302ef47 Mon Sep 17 00:00:00 2001 From: maslow Date: Tue, 23 May 2023 19:50:37 +0800 Subject: [PATCH 26/48] add response api typings --- server/src/auth/auth.controller.ts | 4 +- server/src/billing/billing.controller.ts | 21 +++++-- server/src/billing/billing.service.ts | 4 +- server/src/billing/dto/calculate-price.dto.ts | 51 +++++++++++++++++ server/src/billing/entities/resource.ts | 55 ++++++++++++++++++- .../collection/collection.controller.ts | 6 +- server/src/utils/response.ts | 24 +++++++- 7 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 server/src/billing/dto/calculate-price.dto.ts diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index d1b4d03cbb..caf57da153 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -5,7 +5,7 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger' -import { ApiResponseUtil, ResponseUtil } from '../utils/response' +import { ApiResponseObject, ResponseUtil } from '../utils/response' import { IRequest } from '../utils/interface' import { UserDto } from '../user/dto/user.response' import { AuthService } from './auth.service' @@ -41,7 +41,7 @@ export class AuthController { */ @UseGuards(JwtAuthGuard) @Get('profile') - @ApiResponseUtil(UserDto) + @ApiResponseObject(UserDto) @ApiOperation({ summary: 'Get current user profile' }) @ApiBearerAuth('Authorization') async getProfile(@Req() request: IRequest) { diff --git a/server/src/billing/billing.controller.ts b/server/src/billing/billing.controller.ts index bd120adb35..0fa6246239 100644 --- a/server/src/billing/billing.controller.ts +++ b/server/src/billing/billing.controller.ts @@ -1,11 +1,16 @@ import { Body, Controller, Get, Logger, Param, Post } from '@nestjs/common' -import { ApiOperation, ApiTags } from '@nestjs/swagger' -import { CreateApplicationDto } from 'src/application/dto/create-application.dto' +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' import { ResourceService } from './resource.service' -import { ResponseUtil } from 'src/utils/response' +import { ApiResponseArray, ResponseUtil } from 'src/utils/response' import { ObjectId } from 'mongodb' import { BillingService } from './billing.service' import { RegionService } from 'src/region/region.service' +import { + CalculatePriceDto, + CalculatePriceResultDto, +} from './dto/calculate-price.dto' +import { ResourceBundle, ResourceOption } from './entities/resource' +import { ApiResponseObject } from 'src/utils/response' @ApiTags('Billing') @Controller('billing') @@ -24,11 +29,13 @@ export class BillingController { */ @ApiOperation({ summary: 'Calculate pricing' }) @Post('price') - async calculatePrice(@Body() dto: CreateApplicationDto) { + @ApiResponseObject(CalculatePriceResultDto) + async calculatePrice(@Body() dto: CalculatePriceDto) { // check regionId exists const region = await this.region.findOneDesensitized( new ObjectId(dto.regionId), ) + if (!region) { return ResponseUtil.error(`region ${dto.regionId} not found`) } @@ -41,17 +48,18 @@ export class BillingController { * Get resource option list */ @ApiOperation({ summary: 'Get resource option list' }) + @ApiResponseArray(ResourceOption) @Get('resource-options') async getResourceOptions() { const options = await this.resource.findAll() - const grouped = this.resource.groupByType(options) - return ResponseUtil.ok(grouped) + return ResponseUtil.ok(options) } /** * Get resource option list by region id */ @ApiOperation({ summary: 'Get resource option list by region id' }) + @ApiResponseArray(ResourceOption) @Get('resource-options/:regionId') async getResourceOptionsByRegionId(@Param('regionId') regionId: string) { const data = await this.resource.findAllByRegionId(new ObjectId(regionId)) @@ -63,6 +71,7 @@ export class BillingController { * @returns */ @ApiOperation({ summary: 'Get resource template list' }) + @ApiResponseArray(ResourceBundle) @Get('resource-bundles') async getResourceBundles() { const data = await this.resource.findAllBundles() diff --git a/server/src/billing/billing.service.ts b/server/src/billing/billing.service.ts index dca3d730f3..af7dd46cb1 100644 --- a/server/src/billing/billing.service.ts +++ b/server/src/billing/billing.service.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common' -import { CreateApplicationDto } from 'src/application/dto/create-application.dto' import { SystemDatabase } from 'src/database/system-database' import { ResourceService } from './resource.service' import { ObjectId } from 'mongodb' import { ResourceType } from './entities/resource' import { Decimal } from 'decimal.js' import * as assert from 'assert' +import { CalculatePriceDto } from './dto/calculate-price.dto' @Injectable() export class BillingService { @@ -13,7 +13,7 @@ export class BillingService { constructor(private readonly resource: ResourceService) {} - async calculatePrice(dto: CreateApplicationDto) { + async calculatePrice(dto: CalculatePriceDto) { // get options by region id const options = await this.resource.findAllByRegionId( new ObjectId(dto.regionId), diff --git a/server/src/billing/dto/calculate-price.dto.ts b/server/src/billing/dto/calculate-price.dto.ts new file mode 100644 index 0000000000..7f5e4d8422 --- /dev/null +++ b/server/src/billing/dto/calculate-price.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsInt, IsNotEmpty, IsString } from 'class-validator' + +export class CalculatePriceDto { + @ApiProperty() + @IsNotEmpty() + @IsString() + regionId: string + + // build resources + @ApiProperty({ example: 200 }) + @IsNotEmpty() + @IsInt() + cpu: number + + @ApiProperty({ example: 256 }) + @IsNotEmpty() + @IsInt() + memory: number + + @ApiProperty({ example: 2048 }) + @IsNotEmpty() + @IsInt() + databaseCapacity: number + + @ApiProperty({ example: 4096 }) + @IsNotEmpty() + @IsInt() + storageCapacity: number + + validate() { + return null + } +} + +export class CalculatePriceResultDto { + @ApiProperty({ example: 0.072 }) + cpu: number + + @ApiProperty({ example: 0.036 }) + memory: number + + @ApiProperty({ example: 0.036 }) + storageCapacity: number + + @ApiProperty({ example: 0.036 }) + databaseCapacity: number + + @ApiProperty({ example: 0.18 }) + total: number +} diff --git a/server/src/billing/entities/resource.ts b/server/src/billing/entities/resource.ts index fc92604ec6..511a7142f4 100644 --- a/server/src/billing/entities/resource.ts +++ b/server/src/billing/entities/resource.ts @@ -1,3 +1,4 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' import { ObjectId } from 'mongodb' export enum ResourceType { @@ -8,26 +9,68 @@ export enum ResourceType { NetworkTraffic = 'networkTraffic', } -export interface ResourceSpec { +export class ResourceSpec { + @ApiProperty() value: number + + @ApiPropertyOptional() label?: string } export class ResourceOption { + @ApiProperty({ type: String }) _id?: ObjectId + + @ApiProperty({ type: String }) regionId: ObjectId + + @ApiProperty({ enum: ResourceType }) type: ResourceType + + @ApiProperty() price: number + + @ApiProperty({ type: [ResourceSpec] }) specs: ResourceSpec[] + + @ApiProperty() createdAt: Date + + @ApiProperty() updatedAt: Date } +export class ResourceBundleSpecMap { + @ApiProperty({ type: ResourceSpec }) + [ResourceType.CPU]: ResourceSpec; + + @ApiProperty({ type: ResourceSpec }) + [ResourceType.Memory]: ResourceSpec; + + @ApiProperty({ type: ResourceSpec }) + [ResourceType.DatabaseCapacity]: ResourceSpec; + + @ApiProperty({ type: ResourceSpec }) + [ResourceType.StorageCapacity]: ResourceSpec; + + @ApiPropertyOptional({ type: ResourceSpec }) + [ResourceType.NetworkTraffic]?: ResourceSpec +} + export class ResourceBundle { + @ApiProperty({ type: String }) _id?: ObjectId + + @ApiProperty({ type: String }) regionId: ObjectId + + @ApiProperty() name: string + + @ApiProperty() displayName: string + + @ApiProperty({ type: ResourceBundleSpecMap }) spec: { [ResourceType.CPU]: ResourceSpec [ResourceType.Memory]: ResourceSpec @@ -35,9 +78,19 @@ export class ResourceBundle { [ResourceType.StorageCapacity]: ResourceSpec [ResourceType.NetworkTraffic]?: ResourceSpec } + + @ApiPropertyOptional() enableFreeTier?: boolean + + @ApiPropertyOptional() limitCountOfFreeTierPerUser?: number + + @ApiPropertyOptional() message?: string + + @ApiProperty() createdAt: Date + + @ApiProperty() updatedAt: Date } diff --git a/server/src/database/collection/collection.controller.ts b/server/src/database/collection/collection.controller.ts index 3191761a8c..dfc7681861 100644 --- a/server/src/database/collection/collection.controller.ts +++ b/server/src/database/collection/collection.controller.ts @@ -17,7 +17,7 @@ import { } from '@nestjs/swagger' import { ApplicationAuthGuard } from '../../auth/application.auth.guard' import { JwtAuthGuard } from '../../auth/jwt.auth.guard' -import { ApiResponseUtil, ResponseUtil } from '../../utils/response' +import { ApiResponseObject, ResponseUtil } from '../../utils/response' import { CollectionService } from './collection.service' import { CreateCollectionDto } from '../dto/create-collection.dto' import { UpdateCollectionDto } from '../dto/update-collection.dto' @@ -61,7 +61,7 @@ export class CollectionController { * @param appid * @returns */ - @ApiResponseUtil(Collection) // QUIRKS: should be array but swagger doesn't support it + @ApiResponseObject(Collection) // QUIRKS: should be array but swagger doesn't support it @ApiOperation({ summary: 'Get collection list of an application' }) @UseGuards(JwtAuthGuard, ApplicationAuthGuard) @Get() @@ -79,7 +79,7 @@ export class CollectionController { * @param name * @returns */ - @ApiResponseUtil(Collection) + @ApiResponseObject(Collection) @ApiOperation({ summary: 'Get collection by name' }) @UseGuards(JwtAuthGuard, ApplicationAuthGuard) @Get(':name') diff --git a/server/src/utils/response.ts b/server/src/utils/response.ts index 8e68f86833..8639a41f81 100644 --- a/server/src/utils/response.ts +++ b/server/src/utils/response.ts @@ -47,7 +47,7 @@ export class ResponseUtil { } } -export const ApiResponseUtil = >( +export const ApiResponseObject = >( dataDto: DataDto, ) => applyDecorators( @@ -65,3 +65,25 @@ export const ApiResponseUtil = >( }, }), ) + +export const ApiResponseArray = >( + dataDto: DataDto, +) => + applyDecorators( + ApiExtraModels(ResponseUtil, dataDto), + ApiResponse({ + schema: { + allOf: [ + { $ref: getSchemaPath(ResponseUtil) }, + { + properties: { + data: { + type: 'array', + items: { $ref: getSchemaPath(dataDto) }, + }, + }, + }, + ], + }, + }), + ) From 9130c529416ae8996530cd49bc7976b41450c161 Mon Sep 17 00:00:00 2001 From: maslow Date: Tue, 23 May 2023 19:56:03 +0800 Subject: [PATCH 27/48] Design metering (#1169) * design metering schema * add region bundle schema * remove prisma in some modules * remove prisma in gateway, website, storage * remove prisma in dependency module * remove prisma in account module * remove prisma in instance module * remove prisma in auth and user module * remove prisma in function and trigger module * remove prisma totally * add resource option & template api * rename resource template to bundle * add billing module & price calculation api * add response api typings --- server/package-lock.json | 11 +++ server/package.json | 3 +- server/src/app.module.ts | 2 + server/src/auth/auth.controller.ts | 4 +- server/src/billing/billing.controller.ts | 80 ++++++++++++++++ server/src/billing/billing.module.ts | 11 +++ server/src/billing/billing.service.ts | 68 +++++++++++++ server/src/billing/dto/calculate-price.dto.ts | 51 ++++++++++ server/src/billing/entities/resource.ts | 96 +++++++++++++++++++ .../{region => billing}/resource.service.ts | 26 ++++- .../collection/collection.controller.ts | 6 +- server/src/initializer/initializer.service.ts | 10 +- server/src/region/entities/resource.ts | 43 --------- server/src/region/region.controller.ts | 42 +------- server/src/region/region.module.ts | 3 +- server/src/utils/response.ts | 24 ++++- 16 files changed, 382 insertions(+), 98 deletions(-) create mode 100644 server/src/billing/billing.controller.ts create mode 100644 server/src/billing/billing.module.ts create mode 100644 server/src/billing/billing.service.ts create mode 100644 server/src/billing/dto/calculate-price.dto.ts create mode 100644 server/src/billing/entities/resource.ts rename server/src/{region => billing}/resource.service.ts (62%) delete mode 100644 server/src/region/entities/resource.ts diff --git a/server/package-lock.json b/server/package-lock.json index a06e7f6ad5..ff4ee3c906 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -31,6 +31,7 @@ "cron-validate": "^1.4.5", "database-proxy": "^1.0.0-beta.2", "dayjs": "^1.11.7", + "decimal.js": "^10.4.3", "dotenv": "^16.0.3", "fast-json-patch": "^3.1.1", "lodash": "^4.17.21", @@ -9516,6 +9517,11 @@ "callsite": "^1.0.0" } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -24545,6 +24551,11 @@ "callsite": "^1.0.0" } }, + "decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, "dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", diff --git a/server/package.json b/server/package.json index fce43be3a0..1d2e2af4e9 100644 --- a/server/package.json +++ b/server/package.json @@ -46,6 +46,7 @@ "cron-validate": "^1.4.5", "database-proxy": "^1.0.0-beta.2", "dayjs": "^1.11.7", + "decimal.js": "^10.4.3", "dotenv": "^16.0.3", "fast-json-patch": "^3.1.1", "lodash": "^4.17.21", @@ -108,4 +109,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} \ No newline at end of file +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index c5b3d64cf7..a5ef690196 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -21,6 +21,7 @@ import { AccountModule } from './account/account.module' import { SettingModule } from './setting/setting.module' import * as path from 'path' import { AcceptLanguageResolver, I18nModule, QueryResolver } from 'nestjs-i18n' +import { BillingModule } from './billing/billing.module' @Module({ imports: [ @@ -60,6 +61,7 @@ import { AcceptLanguageResolver, I18nModule, QueryResolver } from 'nestjs-i18n' '../src/generated/i18n.generated.ts', ), }), + BillingModule, ], controllers: [AppController], providers: [AppService], diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index d1b4d03cbb..caf57da153 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -5,7 +5,7 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger' -import { ApiResponseUtil, ResponseUtil } from '../utils/response' +import { ApiResponseObject, ResponseUtil } from '../utils/response' import { IRequest } from '../utils/interface' import { UserDto } from '../user/dto/user.response' import { AuthService } from './auth.service' @@ -41,7 +41,7 @@ export class AuthController { */ @UseGuards(JwtAuthGuard) @Get('profile') - @ApiResponseUtil(UserDto) + @ApiResponseObject(UserDto) @ApiOperation({ summary: 'Get current user profile' }) @ApiBearerAuth('Authorization') async getProfile(@Req() request: IRequest) { diff --git a/server/src/billing/billing.controller.ts b/server/src/billing/billing.controller.ts new file mode 100644 index 0000000000..0fa6246239 --- /dev/null +++ b/server/src/billing/billing.controller.ts @@ -0,0 +1,80 @@ +import { Body, Controller, Get, Logger, Param, Post } from '@nestjs/common' +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' +import { ResourceService } from './resource.service' +import { ApiResponseArray, ResponseUtil } from 'src/utils/response' +import { ObjectId } from 'mongodb' +import { BillingService } from './billing.service' +import { RegionService } from 'src/region/region.service' +import { + CalculatePriceDto, + CalculatePriceResultDto, +} from './dto/calculate-price.dto' +import { ResourceBundle, ResourceOption } from './entities/resource' +import { ApiResponseObject } from 'src/utils/response' + +@ApiTags('Billing') +@Controller('billing') +export class BillingController { + private readonly logger = new Logger(BillingController.name) + + constructor( + private readonly resource: ResourceService, + private readonly billing: BillingService, + private readonly region: RegionService, + ) {} + + /** + * Calculate pricing + * @param dto + */ + @ApiOperation({ summary: 'Calculate pricing' }) + @Post('price') + @ApiResponseObject(CalculatePriceResultDto) + async calculatePrice(@Body() dto: CalculatePriceDto) { + // check regionId exists + const region = await this.region.findOneDesensitized( + new ObjectId(dto.regionId), + ) + + if (!region) { + return ResponseUtil.error(`region ${dto.regionId} not found`) + } + + const result = await this.billing.calculatePrice(dto) + return ResponseUtil.ok(result) + } + + /** + * Get resource option list + */ + @ApiOperation({ summary: 'Get resource option list' }) + @ApiResponseArray(ResourceOption) + @Get('resource-options') + async getResourceOptions() { + const options = await this.resource.findAll() + return ResponseUtil.ok(options) + } + + /** + * Get resource option list by region id + */ + @ApiOperation({ summary: 'Get resource option list by region id' }) + @ApiResponseArray(ResourceOption) + @Get('resource-options/:regionId') + async getResourceOptionsByRegionId(@Param('regionId') regionId: string) { + const data = await this.resource.findAllByRegionId(new ObjectId(regionId)) + return ResponseUtil.ok(data) + } + + /** + * Get resource template list + * @returns + */ + @ApiOperation({ summary: 'Get resource template list' }) + @ApiResponseArray(ResourceBundle) + @Get('resource-bundles') + async getResourceBundles() { + const data = await this.resource.findAllBundles() + return ResponseUtil.ok(data) + } +} diff --git a/server/src/billing/billing.module.ts b/server/src/billing/billing.module.ts new file mode 100644 index 0000000000..a080c74db1 --- /dev/null +++ b/server/src/billing/billing.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { BillingService } from './billing.service' +import { ResourceService } from './resource.service' +import { BillingController } from './billing.controller' + +@Module({ + controllers: [BillingController], + providers: [BillingService, ResourceService], + exports: [BillingService, ResourceService], +}) +export class BillingModule {} diff --git a/server/src/billing/billing.service.ts b/server/src/billing/billing.service.ts new file mode 100644 index 0000000000..af7dd46cb1 --- /dev/null +++ b/server/src/billing/billing.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common' +import { SystemDatabase } from 'src/database/system-database' +import { ResourceService } from './resource.service' +import { ObjectId } from 'mongodb' +import { ResourceType } from './entities/resource' +import { Decimal } from 'decimal.js' +import * as assert from 'assert' +import { CalculatePriceDto } from './dto/calculate-price.dto' + +@Injectable() +export class BillingService { + private readonly db = SystemDatabase.db + + constructor(private readonly resource: ResourceService) {} + + async calculatePrice(dto: CalculatePriceDto) { + // get options by region id + const options = await this.resource.findAllByRegionId( + new ObjectId(dto.regionId), + ) + + const groupedOptions = this.resource.groupByType(options) + assert(groupedOptions[ResourceType.CPU], 'cpu option not found') + assert(groupedOptions[ResourceType.Memory], 'memory option not found') + assert( + groupedOptions[ResourceType.StorageCapacity], + 'storage capacity option not found', + ) + assert( + groupedOptions[ResourceType.DatabaseCapacity], + 'database capacity option not found', + ) + + // calculate cpu price + const cpuOption = groupedOptions[ResourceType.CPU] + const cpuPrice = new Decimal(cpuOption.price).mul(dto.cpu) + + // calculate memory price + const memoryOption = groupedOptions[ResourceType.Memory] + const memoryPrice = new Decimal(memoryOption.price).mul(dto.memory) + + // calculate storage capacity price + const storageOption = groupedOptions[ResourceType.StorageCapacity] + const storagePrice = new Decimal(storageOption.price).mul( + dto.storageCapacity, + ) + + // calculate database capacity price + const databaseOption = groupedOptions[ResourceType.DatabaseCapacity] + const databasePrice = new Decimal(databaseOption.price).mul( + dto.databaseCapacity, + ) + + // calculate total price + const totalPrice = cpuPrice + .add(memoryPrice) + .add(storagePrice) + .add(databasePrice) + + return { + cpu: cpuPrice.toNumber(), + memory: memoryPrice.toNumber(), + storageCapacity: storagePrice.toNumber(), + databaseCapacity: databasePrice.toNumber(), + total: totalPrice.toNumber(), + } + } +} diff --git a/server/src/billing/dto/calculate-price.dto.ts b/server/src/billing/dto/calculate-price.dto.ts new file mode 100644 index 0000000000..7f5e4d8422 --- /dev/null +++ b/server/src/billing/dto/calculate-price.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsInt, IsNotEmpty, IsString } from 'class-validator' + +export class CalculatePriceDto { + @ApiProperty() + @IsNotEmpty() + @IsString() + regionId: string + + // build resources + @ApiProperty({ example: 200 }) + @IsNotEmpty() + @IsInt() + cpu: number + + @ApiProperty({ example: 256 }) + @IsNotEmpty() + @IsInt() + memory: number + + @ApiProperty({ example: 2048 }) + @IsNotEmpty() + @IsInt() + databaseCapacity: number + + @ApiProperty({ example: 4096 }) + @IsNotEmpty() + @IsInt() + storageCapacity: number + + validate() { + return null + } +} + +export class CalculatePriceResultDto { + @ApiProperty({ example: 0.072 }) + cpu: number + + @ApiProperty({ example: 0.036 }) + memory: number + + @ApiProperty({ example: 0.036 }) + storageCapacity: number + + @ApiProperty({ example: 0.036 }) + databaseCapacity: number + + @ApiProperty({ example: 0.18 }) + total: number +} diff --git a/server/src/billing/entities/resource.ts b/server/src/billing/entities/resource.ts new file mode 100644 index 0000000000..511a7142f4 --- /dev/null +++ b/server/src/billing/entities/resource.ts @@ -0,0 +1,96 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { ObjectId } from 'mongodb' + +export enum ResourceType { + CPU = 'cpu', + Memory = 'memory', + DatabaseCapacity = 'databaseCapacity', + StorageCapacity = 'storageCapacity', + NetworkTraffic = 'networkTraffic', +} + +export class ResourceSpec { + @ApiProperty() + value: number + + @ApiPropertyOptional() + label?: string +} + +export class ResourceOption { + @ApiProperty({ type: String }) + _id?: ObjectId + + @ApiProperty({ type: String }) + regionId: ObjectId + + @ApiProperty({ enum: ResourceType }) + type: ResourceType + + @ApiProperty() + price: number + + @ApiProperty({ type: [ResourceSpec] }) + specs: ResourceSpec[] + + @ApiProperty() + createdAt: Date + + @ApiProperty() + updatedAt: Date +} + +export class ResourceBundleSpecMap { + @ApiProperty({ type: ResourceSpec }) + [ResourceType.CPU]: ResourceSpec; + + @ApiProperty({ type: ResourceSpec }) + [ResourceType.Memory]: ResourceSpec; + + @ApiProperty({ type: ResourceSpec }) + [ResourceType.DatabaseCapacity]: ResourceSpec; + + @ApiProperty({ type: ResourceSpec }) + [ResourceType.StorageCapacity]: ResourceSpec; + + @ApiPropertyOptional({ type: ResourceSpec }) + [ResourceType.NetworkTraffic]?: ResourceSpec +} + +export class ResourceBundle { + @ApiProperty({ type: String }) + _id?: ObjectId + + @ApiProperty({ type: String }) + regionId: ObjectId + + @ApiProperty() + name: string + + @ApiProperty() + displayName: string + + @ApiProperty({ type: ResourceBundleSpecMap }) + spec: { + [ResourceType.CPU]: ResourceSpec + [ResourceType.Memory]: ResourceSpec + [ResourceType.DatabaseCapacity]: ResourceSpec + [ResourceType.StorageCapacity]: ResourceSpec + [ResourceType.NetworkTraffic]?: ResourceSpec + } + + @ApiPropertyOptional() + enableFreeTier?: boolean + + @ApiPropertyOptional() + limitCountOfFreeTierPerUser?: number + + @ApiPropertyOptional() + message?: string + + @ApiProperty() + createdAt: Date + + @ApiProperty() + updatedAt: Date +} diff --git a/server/src/region/resource.service.ts b/server/src/billing/resource.service.ts similarity index 62% rename from server/src/region/resource.service.ts rename to server/src/billing/resource.service.ts index 28aa51774b..d8416629bc 100644 --- a/server/src/region/resource.service.ts +++ b/server/src/billing/resource.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common' import { SystemDatabase } from 'src/database/system-database' import { ObjectId } from 'mongodb' -import { ResourceOption, ResourceBundle } from './entities/resource' +import { + ResourceOption, + ResourceBundle, + ResourceType, +} from './entities/resource' @Injectable() export class ResourceService { @@ -22,6 +26,13 @@ export class ResourceService { return option } + async findOneByType(type: ResourceType) { + const option = await this.db + .collection('ResourceOption') + .findOne({ type: type }) + return option + } + async findAllByRegionId(regionId: ObjectId) { const options = await this.db .collection('ResourceOption') @@ -39,4 +50,17 @@ export class ResourceService { return options } + + groupByType(options: ResourceOption[]) { + type GroupedOptions = { + [key in ResourceType]: ResourceOption + } + + const groupedOptions = options.reduce((acc, cur) => { + acc[cur.type] = cur + return acc + }, {} as GroupedOptions) + + return groupedOptions + } } diff --git a/server/src/database/collection/collection.controller.ts b/server/src/database/collection/collection.controller.ts index 3191761a8c..dfc7681861 100644 --- a/server/src/database/collection/collection.controller.ts +++ b/server/src/database/collection/collection.controller.ts @@ -17,7 +17,7 @@ import { } from '@nestjs/swagger' import { ApplicationAuthGuard } from '../../auth/application.auth.guard' import { JwtAuthGuard } from '../../auth/jwt.auth.guard' -import { ApiResponseUtil, ResponseUtil } from '../../utils/response' +import { ApiResponseObject, ResponseUtil } from '../../utils/response' import { CollectionService } from './collection.service' import { CreateCollectionDto } from '../dto/create-collection.dto' import { UpdateCollectionDto } from '../dto/update-collection.dto' @@ -61,7 +61,7 @@ export class CollectionController { * @param appid * @returns */ - @ApiResponseUtil(Collection) // QUIRKS: should be array but swagger doesn't support it + @ApiResponseObject(Collection) // QUIRKS: should be array but swagger doesn't support it @ApiOperation({ summary: 'Get collection list of an application' }) @UseGuards(JwtAuthGuard, ApplicationAuthGuard) @Get() @@ -79,7 +79,7 @@ export class CollectionController { * @param name * @returns */ - @ApiResponseUtil(Collection) + @ApiResponseObject(Collection) @ApiOperation({ summary: 'Get collection by name' }) @UseGuards(JwtAuthGuard, ApplicationAuthGuard) @Get(':name') diff --git a/server/src/initializer/initializer.service.ts b/server/src/initializer/initializer.service.ts index e5825cd02c..f1db7fe1ae 100644 --- a/server/src/initializer/initializer.service.ts +++ b/server/src/initializer/initializer.service.ts @@ -11,7 +11,7 @@ import { ResourceOption, ResourceBundle, ResourceType, -} from 'src/region/entities/resource' +} from 'src/billing/entities/resource' @Injectable() export class InitializerService { @@ -167,7 +167,7 @@ export class InitializerService { { regionId: region._id, type: ResourceType.CPU, - price: 0.072, + price: 0.000072, specs: [ { label: '0.2 Core', value: 200 }, { label: '0.5 Core', value: 500 }, @@ -180,7 +180,7 @@ export class InitializerService { { regionId: region._id, type: ResourceType.Memory, - price: 0.036, + price: 0.000036, specs: [ { label: '256 MB', value: 256 }, { label: '512 MB', value: 512 }, @@ -194,7 +194,7 @@ export class InitializerService { { regionId: region._id, type: ResourceType.DatabaseCapacity, - price: 0.0072, + price: 0.0000072, specs: [ { label: '1 GB', value: 1024 }, { label: '4 GB', value: 4096 }, @@ -208,7 +208,7 @@ export class InitializerService { { regionId: region._id, type: ResourceType.StorageCapacity, - price: 0.002, + price: 0.000002, specs: [ { label: '1 GB', value: 1024 }, { label: '4 GB', value: 4096 }, diff --git a/server/src/region/entities/resource.ts b/server/src/region/entities/resource.ts deleted file mode 100644 index fc92604ec6..0000000000 --- a/server/src/region/entities/resource.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ObjectId } from 'mongodb' - -export enum ResourceType { - CPU = 'cpu', - Memory = 'memory', - DatabaseCapacity = 'databaseCapacity', - StorageCapacity = 'storageCapacity', - NetworkTraffic = 'networkTraffic', -} - -export interface ResourceSpec { - value: number - label?: string -} - -export class ResourceOption { - _id?: ObjectId - regionId: ObjectId - type: ResourceType - price: number - specs: ResourceSpec[] - createdAt: Date - updatedAt: Date -} - -export class ResourceBundle { - _id?: ObjectId - regionId: ObjectId - name: string - displayName: string - spec: { - [ResourceType.CPU]: ResourceSpec - [ResourceType.Memory]: ResourceSpec - [ResourceType.DatabaseCapacity]: ResourceSpec - [ResourceType.StorageCapacity]: ResourceSpec - [ResourceType.NetworkTraffic]?: ResourceSpec - } - enableFreeTier?: boolean - limitCountOfFreeTierPerUser?: number - message?: string - createdAt: Date - updatedAt: Date -} diff --git a/server/src/region/region.controller.ts b/server/src/region/region.controller.ts index 737f1b6109..d108126ba4 100644 --- a/server/src/region/region.controller.ts +++ b/server/src/region/region.controller.ts @@ -1,18 +1,13 @@ -import { Controller, Get, Logger, Param } from '@nestjs/common' +import { Controller, Get, Logger } from '@nestjs/common' import { ApiOperation, ApiTags } from '@nestjs/swagger' import { ResponseUtil } from '../utils/response' import { RegionService } from './region.service' -import { ResourceService } from './resource.service' -import { ObjectId } from 'mongodb' @ApiTags('Public') @Controller('regions') export class RegionController { private readonly logger = new Logger(RegionController.name) - constructor( - private readonly regionService: RegionService, - private readonly resourceService: ResourceService, - ) {} + constructor(private readonly regionService: RegionService) {} /** * Get region list @@ -24,37 +19,4 @@ export class RegionController { const data = await this.regionService.findAllDesensitized() return ResponseUtil.ok(data) } - - /** - * Get resource option list - */ - @ApiOperation({ summary: 'Get resource option list' }) - @Get('resource-options') - async getResourceOptions() { - const data = await this.resourceService.findAll() - return ResponseUtil.ok(data) - } - - /** - * Get resource option list by region id - */ - @ApiOperation({ summary: 'Get resource option list by region id' }) - @Get('resource-options/:regionId') - async getResourceOptionsByRegionId(@Param('regionId') regionId: string) { - const data = await this.resourceService.findAllByRegionId( - new ObjectId(regionId), - ) - return ResponseUtil.ok(data) - } - - /** - * Get resource template list - * @returns - */ - @ApiOperation({ summary: 'Get resource template list' }) - @Get('resource-bundles') - async getResourceBundles() { - const data = await this.resourceService.findAllBundles() - return ResponseUtil.ok(data) - } } diff --git a/server/src/region/region.module.ts b/server/src/region/region.module.ts index 692e801e35..eaa90738db 100644 --- a/server/src/region/region.module.ts +++ b/server/src/region/region.module.ts @@ -2,11 +2,10 @@ import { Global, Module } from '@nestjs/common' import { RegionService } from './region.service' import { RegionController } from './region.controller' import { ClusterService } from './cluster/cluster.service' -import { ResourceService } from './resource.service' @Global() @Module({ - providers: [RegionService, ClusterService, ResourceService], + providers: [RegionService, ClusterService], controllers: [RegionController], exports: [RegionService, ClusterService], }) diff --git a/server/src/utils/response.ts b/server/src/utils/response.ts index 8e68f86833..8639a41f81 100644 --- a/server/src/utils/response.ts +++ b/server/src/utils/response.ts @@ -47,7 +47,7 @@ export class ResponseUtil { } } -export const ApiResponseUtil = >( +export const ApiResponseObject = >( dataDto: DataDto, ) => applyDecorators( @@ -65,3 +65,25 @@ export const ApiResponseUtil = >( }, }), ) + +export const ApiResponseArray = >( + dataDto: DataDto, +) => + applyDecorators( + ApiExtraModels(ResponseUtil, dataDto), + ApiResponse({ + schema: { + allOf: [ + { $ref: getSchemaPath(ResponseUtil) }, + { + properties: { + data: { + type: 'array', + items: { $ref: getSchemaPath(dataDto) }, + }, + }, + }, + ], + }, + }), + ) From cdfeee481c77d0b0b3327b21087630a1d0e54595 Mon Sep 17 00:00:00 2001 From: maslow Date: Wed, 24 May 2023 16:49:22 +0800 Subject: [PATCH 28/48] update app bundle api --- server/build-image.sh | 2 - server/fix-local-envs.sh | 12 -- server/src/account/account.controller.ts | 2 +- server/src/account/account.service.ts | 2 +- .../payment/payment-channel.service.ts | 2 +- server/src/app.controller.ts | 2 +- .../application/application-task.service.ts | 2 +- .../src/application/application.controller.ts | 149 ++++++++++++++---- server/src/application/application.service.ts | 46 +++++- server/src/application/bundle.service.ts | 2 +- .../src/application/configuration.service.ts | 2 +- .../application/dto/create-application.dto.ts | 39 +---- .../application/dto/update-application.dto.ts | 46 +++++- .../entities/application-bundle.ts | 55 +++++-- .../entities/application-configuration.ts | 17 +- .../src/application/entities/application.ts | 36 ++++- server/src/application/entities/runtime.ts | 21 ++- server/src/application/environment.service.ts | 2 +- server/src/auth/authentication.service.ts | 2 +- server/src/auth/phone/phone.service.ts | 2 +- server/src/auth/phone/sms.service.ts | 2 +- .../auth/user-passwd/user-password.service.ts | 2 +- server/src/billing/billing.service.ts | 2 +- server/src/billing/resource.service.ts | 2 +- server/src/constants.ts | 7 + server/src/database/database.service.ts | 2 +- .../database/policy/policy-rule.service.ts | 2 +- server/src/database/policy/policy.service.ts | 2 +- server/src/dependency/dependency.service.ts | 2 +- server/src/function/function.service.ts | 2 +- .../src/gateway/bucket-domain-task.service.ts | 2 +- server/src/gateway/bucket-domain.service.ts | 2 +- server/src/gateway/entities/runtime-domain.ts | 15 ++ .../gateway/runtime-domain-task.service.ts | 2 +- server/src/gateway/runtime-domain.service.ts | 2 +- server/src/gateway/website-task.service.ts | 2 +- server/src/initializer/initializer.service.ts | 2 +- server/src/instance/instance-task.service.ts | 2 +- server/src/instance/instance.service.ts | 2 +- server/src/main.ts | 2 +- server/src/region/entities/region.ts | 15 ++ server/src/region/region.service.ts | 2 +- server/src/setting/setting.service.ts | 2 +- server/src/storage/bucket-task.service.ts | 2 +- server/src/storage/bucket.service.ts | 2 +- server/src/storage/storage.service.ts | 2 +- server/src/{database => }/system-database.ts | 0 server/src/trigger/trigger-task.service.ts | 2 +- server/src/trigger/trigger.service.ts | 2 +- server/src/user/pat.service.ts | 2 +- server/src/user/user.service.ts | 2 +- server/src/website/website.service.ts | 2 +- 52 files changed, 406 insertions(+), 130 deletions(-) delete mode 100644 server/fix-local-envs.sh rename server/src/{database => }/system-database.ts (100%) diff --git a/server/build-image.sh b/server/build-image.sh index dd0807ec86..a319bd6a0a 100644 --- a/server/build-image.sh +++ b/server/build-image.sh @@ -3,5 +3,3 @@ docker buildx build --platform linux/amd64,linux/arm64 --push -t docker.io/lafyun/laf-server:dev -f Dockerfile . - -# docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/labring/laf-server:dev -f Dockerfile . \ No newline at end of file diff --git a/server/fix-local-envs.sh b/server/fix-local-envs.sh deleted file mode 100644 index 8734c09115..0000000000 --- a/server/fix-local-envs.sh +++ /dev/null @@ -1,12 +0,0 @@ - -# remove MINIO_CLIENT_PATH line -sed -i '' '/MINIO_CLIENT_PATH/d' .env - -# replace 'mongo.laf-system.svc.cluster.local' with '127.0.0.1' -sed -i '' 's/mongodb-0.mongo.laf-system.svc.cluster.local/127.0.0.1/g' .env - -# replace 'w=majority' with 'w=majority&directConnection=true' -sed -i '' 's/w=majority/w=majority\&directConnection=true/g' .env - -# port forward mongo -kubectl port-forward mongodb-0 27017:27017 -n laf-system \ No newline at end of file diff --git a/server/src/account/account.controller.ts b/server/src/account/account.controller.ts index cb7202b0b3..dd69b2aaa3 100644 --- a/server/src/account/account.controller.ts +++ b/server/src/account/account.controller.ts @@ -27,7 +27,7 @@ import * as assert from 'assert' import { ServerConfig } from 'src/constants' import { AccountChargePhase } from './entities/account-charge-order' import { ObjectId } from 'mongodb' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { Account } from './entities/account' import { AccountTransaction } from './entities/account-transaction' diff --git a/server/src/account/account.service.ts b/server/src/account/account.service.ts index b8d6fbe2c6..f9700d2454 100644 --- a/server/src/account/account.service.ts +++ b/server/src/account/account.service.ts @@ -3,7 +3,7 @@ import * as assert from 'assert' import { WeChatPayService } from './payment/wechat-pay.service' import { PaymentChannelService } from './payment/payment-channel.service' import { TASK_LOCK_INIT_TIME } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { Account, BaseState } from './entities/account' import { ObjectId } from 'mongodb' import { diff --git a/server/src/account/payment/payment-channel.service.ts b/server/src/account/payment/payment-channel.service.ts index 66b772b5e0..4012ae0a5a 100644 --- a/server/src/account/payment/payment-channel.service.ts +++ b/server/src/account/payment/payment-channel.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common' import { WeChatPaySpec } from './types' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { PaymentChannel } from '../entities/payment-channel' import { BaseState } from '../entities/account' import { PaymentChannelType } from '../entities/account-charge-order' diff --git a/server/src/app.controller.ts b/server/src/app.controller.ts index 244feeb8d7..cad5b23535 100644 --- a/server/src/app.controller.ts +++ b/server/src/app.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get, Logger } from '@nestjs/common' import { ApiOperation, ApiTags } from '@nestjs/swagger' import { ResponseUtil } from './utils/response' -import { SystemDatabase } from './database/system-database' +import { SystemDatabase } from './system-database' import { Runtime } from './application/entities/runtime' @ApiTags('Public') diff --git a/server/src/application/application-task.service.ts b/server/src/application/application-task.service.ts index 253f2e9bb6..54b9f89110 100644 --- a/server/src/application/application-task.service.ts +++ b/server/src/application/application-task.service.ts @@ -7,7 +7,7 @@ import { ClusterService } from 'src/region/cluster/cluster.service' import { RegionService } from 'src/region/region.service' import { RuntimeDomainService } from 'src/gateway/runtime-domain.service' import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { TriggerService } from 'src/trigger/trigger.service' import { FunctionService } from 'src/function/function.service' import { ApplicationConfigurationService } from './configuration.service' diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index 71d84a0d38..3df5f39e22 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -12,19 +12,34 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' import { IRequest } from '../utils/interface' import { JwtAuthGuard } from '../auth/jwt.auth.guard' -import { ResponseUtil } from '../utils/response' +import { + ApiResponseArray, + ApiResponseObject, + ResponseUtil, +} from '../utils/response' import { ApplicationAuthGuard } from '../auth/application.auth.guard' -import { UpdateApplicationDto } from './dto/update-application.dto' +import { + UpdateApplicationBundleDto, + UpdateApplicationDto, + UpdateApplicationNameDto, + UpdateApplicationStateDto, +} from './dto/update-application.dto' import { ApplicationService } from './application.service' import { FunctionService } from '../function/function.service' import { StorageService } from 'src/storage/storage.service' import { RegionService } from 'src/region/region.service' import { CreateApplicationDto } from './dto/create-application.dto' import { AccountService } from 'src/account/account.service' -import { ApplicationPhase, ApplicationState } from './entities/application' -import { SystemDatabase } from 'src/database/system-database' +import { + Application, + ApplicationPhase, + ApplicationState, + ApplicationWithRelations, +} from './entities/application' +import { SystemDatabase } from 'src/system-database' import { Runtime } from './entities/runtime' import { ObjectId } from 'mongodb' +import { ApplicationBundle } from './entities/application-bundle' @ApiTags('Application') @Controller('applications') @@ -33,11 +48,11 @@ export class ApplicationController { private logger = new Logger(ApplicationController.name) constructor( - private readonly appService: ApplicationService, - private readonly funcService: FunctionService, - private readonly regionService: RegionService, - private readonly storageService: StorageService, - private readonly accountService: AccountService, + private readonly application: ApplicationService, + private readonly fn: FunctionService, + private readonly region: RegionService, + private readonly storage: StorageService, + private readonly account: AccountService, ) {} /** @@ -45,12 +60,13 @@ export class ApplicationController { */ @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Create application' }) + @ApiResponseObject(ApplicationWithRelations) @Post() async create(@Req() req: IRequest, @Body() dto: CreateApplicationDto) { const user = req.user // check regionId exists - const region = await this.regionService.findOneDesensitized( + const region = await this.region.findOneDesensitized( new ObjectId(dto.regionId), ) if (!region) { @@ -66,17 +82,17 @@ export class ApplicationController { } // check account balance - const account = await this.accountService.findOne(user._id) + const account = await this.account.findOne(user._id) const balance = account?.balance || 0 if (balance <= 0) { return ResponseUtil.error(`account balance is not enough`) } // create application - const appid = await this.appService.tryGenerateUniqueAppid() - await this.appService.create(user._id, appid, dto) + const appid = await this.application.tryGenerateUniqueAppid() + await this.application.create(user._id, appid, dto) - const app = await this.appService.findOne(appid) + const app = await this.application.findOne(appid) return ResponseUtil.ok(app) } @@ -88,9 +104,10 @@ export class ApplicationController { @UseGuards(JwtAuthGuard) @Get() @ApiOperation({ summary: 'Get user application list' }) + @ApiResponseArray(ApplicationWithRelations) async findAll(@Req() req: IRequest) { const user = req.user - const data = await this.appService.findAllByUser(user._id) + const data = await this.application.findAllByUser(user._id) return ResponseUtil.ok(data) } @@ -103,21 +120,17 @@ export class ApplicationController { @UseGuards(JwtAuthGuard, ApplicationAuthGuard) @Get(':appid') async findOne(@Param('appid') appid: string) { - const data = await this.appService.findOne(appid) + const data = await this.application.findOne(appid) // SECURITY ALERT!!! // DO NOT response this region object to client since it contains sensitive information - const region = await this.regionService.findOne(data.regionId) + const region = await this.region.findOne(data.regionId) // TODO: remove these storage related code to standalone api let storage = {} - const storageUser = await this.storageService.findOne(appid) + const storageUser = await this.storage.findOne(appid) if (storageUser) { - const sts = await this.storageService.getOssSTS( - region, - appid, - storageUser, - ) + const sts = await this.storage.getOssSTS(region, appid, storageUser) const credentials = { endpoint: region.storageConf.externalEndpoint, accessKeyId: sts.Credentials?.AccessKeyId, @@ -134,7 +147,7 @@ export class ApplicationController { // Generate the develop token, it's provided to the client when debugging function const expires = 60 * 60 * 24 * 7 - const develop_token = await this.funcService.generateRuntimeToken( + const develop_token = await this.fn.generateRuntimeToken( appid, 'develop', expires, @@ -156,12 +169,94 @@ export class ApplicationController { return ResponseUtil.ok(res) } + /** + * Update application name + */ + @ApiOperation({ summary: 'Update application name' }) + @ApiResponseObject(Application) + @UseGuards(JwtAuthGuard, ApplicationAuthGuard) + @Patch(':appid/name') + async updateName( + @Param('appid') appid: string, + @Body() dto: UpdateApplicationNameDto, + ) { + const doc = await this.application.updateName(appid, dto.name) + return ResponseUtil.ok(doc) + } + + /** + * Update application state + */ + @ApiOperation({ summary: 'Update application state' }) + @ApiResponseObject(Application) + @UseGuards(JwtAuthGuard, ApplicationAuthGuard) + @Patch(':appid/state') + async updateState( + @Param('appid') appid: string, + @Body() dto: UpdateApplicationStateDto, + ) { + // check if the corresponding subscription status has expired + const app = await this.application.findOne(appid) + + // check: only running application can restart + if ( + dto.state === ApplicationState.Restarting && + app.state !== ApplicationState.Running && + app.phase !== ApplicationPhase.Started + ) { + return ResponseUtil.error( + 'The application is not running, can not restart it', + ) + } + + // check: only running application can stop + if ( + dto.state === ApplicationState.Stopped && + app.state !== ApplicationState.Running && + app.phase !== ApplicationPhase.Started + ) { + return ResponseUtil.error( + 'The application is not running, can not stop it', + ) + } + + // check: only stopped application can start + if ( + dto.state === ApplicationState.Running && + app.state !== ApplicationState.Stopped && + app.phase !== ApplicationPhase.Stopped + ) { + return ResponseUtil.error( + 'The application is not stopped, can not start it', + ) + } + + const doc = await this.application.updateState(appid, dto.state) + return ResponseUtil.ok(doc) + } + + /** + * Update application bundle + */ + @ApiOperation({ summary: 'Update application bundle' }) + @ApiResponseObject(ApplicationBundle) + @UseGuards(JwtAuthGuard, ApplicationAuthGuard) + @Patch(':appid/bundle') + async updateBundle( + @Param('appid') appid: string, + @Body() dto: UpdateApplicationBundleDto, + ) { + const doc = await this.application.updateBundle(appid, dto) + return ResponseUtil.ok(doc) + } + /** * Update an application + * @deprecated use updateName and updateState instead * @param dto * @returns */ - @ApiOperation({ summary: 'Update an application' }) + @ApiOperation({ summary: 'Update an application', deprecated: true }) @UseGuards(JwtAuthGuard, ApplicationAuthGuard) @Patch(':appid') async update( @@ -175,7 +270,7 @@ export class ApplicationController { } // check if the corresponding subscription status has expired - const app = await this.appService.findOne(appid) + const app = await this.application.findOne(appid) // check: only running application can restart if ( @@ -211,7 +306,7 @@ export class ApplicationController { } // update app - const doc = await this.appService.update(appid, dto) + const doc = await this.application.update(appid, dto) if (!doc) { return ResponseUtil.error('update application error') } diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index bcac21a66c..25da275072 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -1,6 +1,9 @@ import { Injectable, Logger } from '@nestjs/common' import * as nanoid from 'nanoid' -import { UpdateApplicationDto } from './dto/update-application.dto' +import { + UpdateApplicationBundleDto, + UpdateApplicationDto, +} from './dto/update-application.dto' import { APPLICATION_SECRET_KEY, ServerConfig, @@ -8,7 +11,7 @@ import { } from '../constants' import { GenerateAlphaNumericPassword } from '../utils/random' import { CreateApplicationDto } from './dto/create-application.dto' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { Application, ApplicationPhase, @@ -222,6 +225,43 @@ export class ApplicationService { return doc } + async updateName(appid: string, name: string) { + const db = SystemDatabase.db + const res = await db + .collection('Application') + .findOneAndUpdate({ appid }, { $set: { name, updatedAt: new Date() } }) + + return res.value + } + + async updateState(appid: string, state: ApplicationState) { + const db = SystemDatabase.db + const res = await db + .collection('Application') + .findOneAndUpdate({ appid }, { $set: { state, updatedAt: new Date() } }) + + return res.value + } + + async updateBundle(appid: string, dto: UpdateApplicationBundleDto) { + const db = SystemDatabase.db + const resource = this.buildBundleResource(dto) + const res = await db + .collection('ApplicationBundle') + .findOneAndUpdate( + { appid }, + { $set: { resource, updatedAt: new Date() } }, + { + projection: { + 'bundle.resource.requestCPU': 0, + 'bundle.resource.requestMemory': 0, + }, + }, + ) + + return res.value + } + async update(appid: string, dto: UpdateApplicationDto) { const db = SystemDatabase.db const data: Partial = { updatedAt: new Date() } @@ -278,7 +318,7 @@ export class ApplicationService { return prefix + nano() } - private buildBundleResource(dto: CreateApplicationDto) { + private buildBundleResource(dto: UpdateApplicationBundleDto) { const requestCPU = Math.floor(dto.cpu * 0.1) const requestMemory = Math.floor(dto.memory * 0.5) const limitCountOfCloudFunction = Math.floor(dto.cpu * 1) diff --git a/server/src/application/bundle.service.ts b/server/src/application/bundle.service.ts index 7994fded29..b6fe6af437 100644 --- a/server/src/application/bundle.service.ts +++ b/server/src/application/bundle.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common' import { ApplicationBundle } from 'src/application/entities/application-bundle' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' @Injectable() export class BundleService { diff --git a/server/src/application/configuration.service.ts b/server/src/application/configuration.service.ts index 5d35dc53e3..1440736e41 100644 --- a/server/src/application/configuration.service.ts +++ b/server/src/application/configuration.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common' import { CN_PUBLISHED_CONF } from 'src/constants' import { DatabaseService } from 'src/database/database.service' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { ApplicationConfiguration } from './entities/application-configuration' @Injectable() diff --git a/server/src/application/dto/create-application.dto.ts b/server/src/application/dto/create-application.dto.ts index e1bbb73677..6cc11e2a1b 100644 --- a/server/src/application/dto/create-application.dto.ts +++ b/server/src/application/dto/create-application.dto.ts @@ -1,24 +1,22 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' -import { IsIn, IsInt, IsNotEmpty, IsString, Length } from 'class-validator' +import { ApiProperty } from '@nestjs/swagger' +import { IsIn, IsNotEmpty, IsString, Length } from 'class-validator' import { ApplicationState } from '../entities/application' +import { UpdateApplicationBundleDto } from './update-application.dto' const STATES = [ApplicationState.Running] -export class CreateApplicationDto { - /** - * Application name - */ - @ApiPropertyOptional() +export class CreateApplicationDto extends UpdateApplicationBundleDto { + @ApiProperty() @IsString() @Length(1, 64) - name?: string + name: string - @ApiPropertyOptional({ + @ApiProperty({ default: ApplicationState.Running, enum: STATES, }) @IsIn(STATES) - state?: ApplicationState + state: ApplicationState @ApiProperty() @IsNotEmpty() @@ -30,27 +28,6 @@ export class CreateApplicationDto { @IsString() runtimeId: string - // build resources - @ApiProperty({ example: 200 }) - @IsNotEmpty() - @IsInt() - cpu: number - - @ApiProperty({ example: 256 }) - @IsNotEmpty() - @IsInt() - memory: number - - @ApiProperty({ example: 2048 }) - @IsNotEmpty() - @IsInt() - databaseCapacity: number - - @ApiProperty({ example: 4096 }) - @IsNotEmpty() - @IsInt() - storageCapacity: number - validate() { return null } diff --git a/server/src/application/dto/update-application.dto.ts b/server/src/application/dto/update-application.dto.ts index 8136ac3c68..451d5bfa6a 100644 --- a/server/src/application/dto/update-application.dto.ts +++ b/server/src/application/dto/update-application.dto.ts @@ -1,5 +1,5 @@ -import { ApiPropertyOptional } from '@nestjs/swagger' -import { IsIn, IsString, Length } from 'class-validator' +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { IsIn, IsInt, IsNotEmpty, IsString, Length } from 'class-validator' import { ApplicationState } from '../entities/application' const STATES = [ @@ -7,6 +7,10 @@ const STATES = [ ApplicationState.Stopped, ApplicationState.Restarting, ] + +/** + * @deprecated use UpdateApplicationNameDto or UpdateApplicationStateDto instead + */ export class UpdateApplicationDto { @ApiPropertyOptional() @IsString() @@ -23,3 +27,41 @@ export class UpdateApplicationDto { return null } } + +export class UpdateApplicationNameDto { + @ApiProperty() + @IsString() + @Length(1, 64) + @IsNotEmpty() + name: string +} + +export class UpdateApplicationStateDto { + @ApiProperty({ enum: ApplicationState }) + @IsIn(STATES) + @IsNotEmpty() + state: ApplicationState +} + +export class UpdateApplicationBundleDto { + // build resources + @ApiProperty({ example: 200 }) + @IsNotEmpty() + @IsInt() + cpu: number + + @ApiProperty({ example: 256 }) + @IsNotEmpty() + @IsInt() + memory: number + + @ApiProperty({ example: 2048 }) + @IsNotEmpty() + @IsInt() + databaseCapacity: number + + @ApiProperty({ example: 4096 }) + @IsNotEmpty() + @IsInt() + storageCapacity: number +} diff --git a/server/src/application/entities/application-bundle.ts b/server/src/application/entities/application-bundle.ts index ac4770ddaa..49b0d3bbf6 100644 --- a/server/src/application/entities/application-bundle.ts +++ b/server/src/application/entities/application-bundle.ts @@ -1,30 +1,40 @@ +import { ApiProperty } from '@nestjs/swagger' import { ObjectId } from 'mongodb' -export class ApplicationBundle { - _id?: ObjectId - appid: string - resource: ApplicationBundleResource - createdAt: Date - updatedAt: Date - - constructor(partial: Partial) { - Object.assign(this, partial) - } -} - export class ApplicationBundleResource { + @ApiProperty({ example: 500 }) limitCPU: number + + @ApiProperty({ example: 1024 }) limitMemory: number + requestCPU: number requestMemory: number + + @ApiProperty({ example: 1024 }) databaseCapacity: number + + @ApiProperty({ example: 1024 }) storageCapacity: number + + @ApiProperty({ example: 100 }) limitCountOfCloudFunction: number + + @ApiProperty({ example: 3 }) limitCountOfBucket: number + + @ApiProperty({ example: 3 }) limitCountOfDatabasePolicy: number + + @ApiProperty({ example: 1 }) limitCountOfTrigger: number + + @ApiProperty({ example: 3 }) limitCountOfWebsiteHosting: number + + @ApiProperty() reservedTimeAfterExpired: number + limitDatabaseTPS: number limitStorageTPS: number @@ -32,3 +42,24 @@ export class ApplicationBundleResource { Object.assign(this, partial) } } + +export class ApplicationBundle { + @ApiProperty({ type: String }) + _id?: ObjectId + + @ApiProperty() + appid: string + + @ApiProperty() + resource: ApplicationBundleResource + + @ApiProperty() + createdAt: Date + + @ApiProperty() + updatedAt: Date + + constructor(partial: Partial) { + Object.assign(this, partial) + } +} diff --git a/server/src/application/entities/application-configuration.ts b/server/src/application/entities/application-configuration.ts index d8dfd31857..4a5652a166 100644 --- a/server/src/application/entities/application-configuration.ts +++ b/server/src/application/entities/application-configuration.ts @@ -1,15 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger' import { ObjectId } from 'mongodb' -export type EnvironmentVariable = { +export class EnvironmentVariable { + @ApiProperty() name: string + + @ApiProperty() value: string } export class ApplicationConfiguration { + @ApiProperty({ type: String }) _id?: ObjectId + + @ApiProperty() appid: string + + @ApiProperty({ isArray: true, type: EnvironmentVariable }) environments: EnvironmentVariable[] + + @ApiProperty() dependencies: string[] + + @ApiProperty() createdAt: Date + + @ApiProperty() updatedAt: Date } diff --git a/server/src/application/entities/application.ts b/server/src/application/entities/application.ts index c7cacf9b32..7ca535e192 100644 --- a/server/src/application/entities/application.ts +++ b/server/src/application/entities/application.ts @@ -4,6 +4,7 @@ import { ApplicationBundle } from './application-bundle' import { Runtime } from './runtime' import { ApplicationConfiguration } from './application-configuration' import { RuntimeDomain } from 'src/gateway/entities/runtime-domain' +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' export enum ApplicationPhase { Creating = 'Creating', @@ -24,18 +25,42 @@ export enum ApplicationState { } export class Application { + @ApiProperty({ type: String }) _id?: ObjectId + + @ApiProperty() name: string + + @ApiProperty() appid: string + + @ApiProperty({ type: String }) regionId: ObjectId + + @ApiProperty({ type: String }) runtimeId: ObjectId + + @ApiProperty({ isArray: true, type: String }) tags: string[] + + @ApiProperty({ enum: ApplicationState }) state: ApplicationState + + @ApiProperty({ enum: ApplicationPhase }) phase: ApplicationPhase + + @ApiPropertyOptional() isTrialTier?: boolean + + @ApiProperty() createdAt: Date + + @ApiProperty() updatedAt: Date + lockedAt: Date + + @ApiProperty({ type: String }) createdBy: ObjectId constructor(partial: Partial) { @@ -43,10 +68,19 @@ export class Application { } } -export interface ApplicationWithRelations extends Application { +export class ApplicationWithRelations extends Application { + @ApiPropertyOptional() region?: Region + + @ApiPropertyOptional() bundle?: ApplicationBundle + + @ApiPropertyOptional() runtime?: Runtime + + @ApiPropertyOptional() configuration?: ApplicationConfiguration + + @ApiPropertyOptional() domain?: RuntimeDomain } diff --git a/server/src/application/entities/runtime.ts b/server/src/application/entities/runtime.ts index 4f2a2c3ddb..14678d36fd 100644 --- a/server/src/application/entities/runtime.ts +++ b/server/src/application/entities/runtime.ts @@ -1,18 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' import { ObjectId } from 'mongodb' -export type RuntimeImageGroup = { +export class RuntimeImageGroup { + @ApiProperty() main: string + + @ApiProperty() init: string + + @ApiPropertyOptional() sidecar?: string } export class Runtime { + @ApiProperty({ type: String }) _id?: ObjectId + + @ApiProperty() name: string + + @ApiProperty() type: string + + @ApiProperty() image: RuntimeImageGroup + + @ApiProperty() state: 'Active' | 'Inactive' + + @ApiProperty() version: string + + @ApiProperty() latest: boolean constructor(partial: Partial) { diff --git a/server/src/application/environment.service.ts b/server/src/application/environment.service.ts index 87ff151f33..c762e86cd8 100644 --- a/server/src/application/environment.service.ts +++ b/server/src/application/environment.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common' import { CreateEnvironmentDto } from './dto/create-env.dto' import { ApplicationConfigurationService } from './configuration.service' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { ApplicationConfiguration } from './entities/application-configuration' import * as assert from 'node:assert' diff --git a/server/src/auth/authentication.service.ts b/server/src/auth/authentication.service.ts index 0886ca404c..8d72f41738 100644 --- a/server/src/auth/authentication.service.ts +++ b/server/src/auth/authentication.service.ts @@ -4,7 +4,7 @@ import { PASSWORD_AUTH_PROVIDER_NAME, PHONE_AUTH_PROVIDER_NAME, } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { AuthProvider, AuthProviderState } from './entities/auth-provider' import { User } from 'src/user/entities/user' diff --git a/server/src/auth/phone/phone.service.ts b/server/src/auth/phone/phone.service.ts index babd0d333d..b079f330fd 100644 --- a/server/src/auth/phone/phone.service.ts +++ b/server/src/auth/phone/phone.service.ts @@ -5,7 +5,7 @@ import { PhoneSigninDto } from '../dto/phone-signin.dto' import { hashPassword } from 'src/utils/crypto' import { SmsVerifyCodeType } from '../entities/sms-verify-code' import { User } from 'src/user/entities/user' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { UserService } from 'src/user/user.service' import { UserPassword, diff --git a/server/src/auth/phone/sms.service.ts b/server/src/auth/phone/sms.service.ts index 81eeb8037d..fce705b7e9 100644 --- a/server/src/auth/phone/sms.service.ts +++ b/server/src/auth/phone/sms.service.ts @@ -10,7 +10,7 @@ import { MILLISECONDS_PER_MINUTE, CODE_VALIDITY, } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { SmsVerifyCode, SmsVerifyCodeState, diff --git a/server/src/auth/user-passwd/user-password.service.ts b/server/src/auth/user-passwd/user-password.service.ts index 44d68db1db..232b780f73 100644 --- a/server/src/auth/user-passwd/user-password.service.ts +++ b/server/src/auth/user-passwd/user-password.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common' import { hashPassword } from 'src/utils/crypto' import { AuthenticationService } from '../authentication.service' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { User } from 'src/user/entities/user' import { UserPassword, diff --git a/server/src/billing/billing.service.ts b/server/src/billing/billing.service.ts index af7dd46cb1..769ff9f29b 100644 --- a/server/src/billing/billing.service.ts +++ b/server/src/billing/billing.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { ResourceService } from './resource.service' import { ObjectId } from 'mongodb' import { ResourceType } from './entities/resource' diff --git a/server/src/billing/resource.service.ts b/server/src/billing/resource.service.ts index d8416629bc..a0756a5747 100644 --- a/server/src/billing/resource.service.ts +++ b/server/src/billing/resource.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { ObjectId } from 'mongodb' import { ResourceOption, diff --git a/server/src/constants.ts b/server/src/constants.ts index f257be04bb..73783f19ab 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -10,6 +10,13 @@ export class ServerConfig { return process.env.DATABASE_URL } + static get METERING_DATABASE_URL() { + if (!process.env.METERING_DATABASE_URL) { + throw new Error('METERING_DATABASE_URL is not defined') + } + return process.env.METERING_DATABASE_URL + } + static get JWT_SECRET() { if (!process.env.JWT_SECRET) { throw new Error('JWT_SECRET is not defined') diff --git a/server/src/database/database.service.ts b/server/src/database/database.service.ts index c86b9db168..5d085749ef 100644 --- a/server/src/database/database.service.ts +++ b/server/src/database/database.service.ts @@ -7,7 +7,7 @@ import * as mongodb_uri from 'mongodb-uri' import { RegionService } from 'src/region/region.service' import { TASK_LOCK_INIT_TIME } from 'src/constants' import { Region } from 'src/region/entities/region' -import { SystemDatabase } from './system-database' +import { SystemDatabase } from '../system-database' import { Database, DatabasePhase, DatabaseState } from './entities/database' @Injectable() diff --git a/server/src/database/policy/policy-rule.service.ts b/server/src/database/policy/policy-rule.service.ts index 850774f9bc..ff63fdf005 100644 --- a/server/src/database/policy/policy-rule.service.ts +++ b/server/src/database/policy/policy-rule.service.ts @@ -3,7 +3,7 @@ import * as assert from 'node:assert' import { CreatePolicyRuleDto } from '../dto/create-rule.dto' import { UpdatePolicyRuleDto } from '../dto/update-rule.dto' import { PolicyService } from './policy.service' -import { SystemDatabase } from '../system-database' +import { SystemDatabase } from '../../system-database' import { DatabasePolicyRule } from '../entities/database-policy' @Injectable() diff --git a/server/src/database/policy/policy.service.ts b/server/src/database/policy/policy.service.ts index 84d62b3c47..dacb7deb64 100644 --- a/server/src/database/policy/policy.service.ts +++ b/server/src/database/policy/policy.service.ts @@ -3,7 +3,7 @@ import { CN_PUBLISHED_POLICIES } from 'src/constants' import { DatabaseService } from '../database.service' import { CreatePolicyDto } from '../dto/create-policy.dto' import { UpdatePolicyDto } from '../dto/update-policy.dto' -import { SystemDatabase } from '../system-database' +import { SystemDatabase } from '../../system-database' import { DatabasePolicy, DatabasePolicyRule, diff --git a/server/src/dependency/dependency.service.ts b/server/src/dependency/dependency.service.ts index 5edbe03c96..d47eeb5064 100644 --- a/server/src/dependency/dependency.service.ts +++ b/server/src/dependency/dependency.service.ts @@ -3,7 +3,7 @@ import { RUNTIME_BUILTIN_DEPENDENCIES } from 'src/runtime-builtin-deps' import * as npa from 'npm-package-arg' import { CreateDependencyDto } from './dto/create-dependency.dto' import { UpdateDependencyDto } from './dto/update-dependency.dto' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { ApplicationConfiguration } from 'src/application/entities/application-configuration' export class Dependency { diff --git a/server/src/function/function.service.ts b/server/src/function/function.service.ts index 5e5e5ba5d1..d8841a059b 100644 --- a/server/src/function/function.service.ts +++ b/server/src/function/function.service.ts @@ -12,7 +12,7 @@ import { JwtService } from '@nestjs/jwt' import { CompileFunctionDto } from './dto/compile-function.dto' import { DatabaseService } from 'src/database/database.service' import { GetApplicationNamespaceByAppId } from 'src/utils/getter' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { ObjectId } from 'mongodb' import { CloudFunction } from './entities/cloud-function' import { ApplicationConfiguration } from 'src/application/entities/application-configuration' diff --git a/server/src/gateway/bucket-domain-task.service.ts b/server/src/gateway/bucket-domain-task.service.ts index cbd50416ce..3174816384 100644 --- a/server/src/gateway/bucket-domain-task.service.ts +++ b/server/src/gateway/bucket-domain-task.service.ts @@ -4,7 +4,7 @@ import { ApisixService } from './apisix.service' import * as assert from 'node:assert' import { Cron, CronExpression } from '@nestjs/schedule' import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { BucketDomain } from './entities/bucket-domain' import { DomainPhase, DomainState } from './entities/runtime-domain' diff --git a/server/src/gateway/bucket-domain.service.ts b/server/src/gateway/bucket-domain.service.ts index f23097eb84..89c0d243c0 100644 --- a/server/src/gateway/bucket-domain.service.ts +++ b/server/src/gateway/bucket-domain.service.ts @@ -3,7 +3,7 @@ import { RegionService } from '../region/region.service' import * as assert from 'node:assert' import { TASK_LOCK_INIT_TIME } from 'src/constants' import { StorageBucket } from 'src/storage/entities/storage-bucket' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { BucketDomain } from './entities/bucket-domain' import { DomainPhase, DomainState } from './entities/runtime-domain' diff --git a/server/src/gateway/entities/runtime-domain.ts b/server/src/gateway/entities/runtime-domain.ts index a5f7191e01..3a876f763a 100644 --- a/server/src/gateway/entities/runtime-domain.ts +++ b/server/src/gateway/entities/runtime-domain.ts @@ -1,3 +1,4 @@ +import { ApiProperty } from '@nestjs/swagger' import { ObjectId } from 'mongodb' export enum DomainPhase { @@ -14,13 +15,27 @@ export enum DomainState { } export class RuntimeDomain { + @ApiProperty({ type: String }) _id?: ObjectId + + @ApiProperty() appid: string + + @ApiProperty() domain: string + + @ApiProperty({ enum: DomainState }) state: DomainState + + @ApiProperty({ enum: DomainPhase }) phase: DomainPhase + lockedAt: Date + + @ApiProperty() createdAt: Date + + @ApiProperty() updatedAt: Date constructor(partial: Partial) { diff --git a/server/src/gateway/runtime-domain-task.service.ts b/server/src/gateway/runtime-domain-task.service.ts index 88b9076436..6c301a56cc 100644 --- a/server/src/gateway/runtime-domain-task.service.ts +++ b/server/src/gateway/runtime-domain-task.service.ts @@ -4,7 +4,7 @@ import { ApisixService } from './apisix.service' import * as assert from 'node:assert' import { Cron, CronExpression } from '@nestjs/schedule' import { ServerConfig, TASK_LOCK_INIT_TIME } from '../constants' -import { SystemDatabase } from '../database/system-database' +import { SystemDatabase } from '../system-database' import { DomainPhase, DomainState, diff --git a/server/src/gateway/runtime-domain.service.ts b/server/src/gateway/runtime-domain.service.ts index b9e6a152d4..dcd4a5fcbb 100644 --- a/server/src/gateway/runtime-domain.service.ts +++ b/server/src/gateway/runtime-domain.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common' import * as assert from 'assert' import { RegionService } from '../region/region.service' import { TASK_LOCK_INIT_TIME } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { DomainPhase, DomainState, diff --git a/server/src/gateway/website-task.service.ts b/server/src/gateway/website-task.service.ts index 0b7ee4b9db..ffadb9b92e 100644 --- a/server/src/gateway/website-task.service.ts +++ b/server/src/gateway/website-task.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { RegionService } from 'src/region/region.service' import * as assert from 'node:assert' import { ApisixService } from './apisix.service' diff --git a/server/src/initializer/initializer.service.ts b/server/src/initializer/initializer.service.ts index f1db7fe1ae..04fb8ab344 100644 --- a/server/src/initializer/initializer.service.ts +++ b/server/src/initializer/initializer.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common' import { ServerConfig } from '../constants' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { Region } from 'src/region/entities/region' import { Runtime } from 'src/application/entities/runtime' import { diff --git a/server/src/instance/instance-task.service.ts b/server/src/instance/instance-task.service.ts index bf68ca2ecc..f09f7b881c 100644 --- a/server/src/instance/instance-task.service.ts +++ b/server/src/instance/instance-task.service.ts @@ -3,7 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule' import { isConditionTrue } from '../utils/getter' import { InstanceService } from './instance.service' import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { CronJobService } from 'src/trigger/cron-job.service' import { Application, diff --git a/server/src/instance/instance.service.ts b/server/src/instance/instance.service.ts index 42db6cec41..c128f182b6 100644 --- a/server/src/instance/instance.service.ts +++ b/server/src/instance/instance.service.ts @@ -10,7 +10,7 @@ import { import { StorageService } from '../storage/storage.service' import { DatabaseService } from 'src/database/database.service' import { ClusterService } from 'src/region/cluster/cluster.service' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { ApplicationWithRelations } from 'src/application/entities/application' import { ApplicationService } from 'src/application/application.service' diff --git a/server/src/main.ts b/server/src/main.ts index 6d083b2d6f..e1ecd0006d 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -5,7 +5,7 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger' import { ValidationPipe, VersioningType } from '@nestjs/common' import { ServerConfig } from './constants' import { InitializerService } from './initializer/initializer.service' -import { SystemDatabase } from './database/system-database' +import { SystemDatabase } from './system-database' async function bootstrap() { await SystemDatabase.ready diff --git a/server/src/region/entities/region.ts b/server/src/region/entities/region.ts index 73cbb7928c..ec38932456 100644 --- a/server/src/region/entities/region.ts +++ b/server/src/region/entities/region.ts @@ -1,3 +1,4 @@ +import { ApiProperty } from '@nestjs/swagger' import { ObjectId } from 'mongodb' export type RegionClusterConf = { @@ -32,16 +33,30 @@ export type RegionStorageConf = { } export class Region { + @ApiProperty({ type: String }) _id?: ObjectId + + @ApiProperty() name: string + + @ApiProperty() displayName: string + clusterConf: RegionClusterConf databaseConf: RegionDatabaseConf gatewayConf: RegionGatewayConf storageConf: RegionStorageConf + + @ApiProperty() tls: boolean + + @ApiProperty() state: 'Active' | 'Inactive' + + @ApiProperty() createdAt: Date + + @ApiProperty() updatedAt: Date constructor(partial: Partial) { diff --git a/server/src/region/region.service.ts b/server/src/region/region.service.ts index 2c07a2fc5d..7dc1d9f5fd 100644 --- a/server/src/region/region.service.ts +++ b/server/src/region/region.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { Region } from './entities/region' import { Application } from 'src/application/entities/application' import { assert } from 'console' diff --git a/server/src/setting/setting.service.ts b/server/src/setting/setting.service.ts index c1e5351431..33a87583e6 100644 --- a/server/src/setting/setting.service.ts +++ b/server/src/setting/setting.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { Setting } from './entities/setting' @Injectable() diff --git a/server/src/storage/bucket-task.service.ts b/server/src/storage/bucket-task.service.ts index 052a54226a..eb35831765 100644 --- a/server/src/storage/bucket-task.service.ts +++ b/server/src/storage/bucket-task.service.ts @@ -3,7 +3,7 @@ import { RegionService } from 'src/region/region.service' import * as assert from 'node:assert' import { Cron, CronExpression } from '@nestjs/schedule' import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { MinioService } from './minio/minio.service' import { BucketDomainService } from 'src/gateway/bucket-domain.service' import { StorageBucket } from './entities/storage-bucket' diff --git a/server/src/storage/bucket.service.ts b/server/src/storage/bucket.service.ts index 22caf0b88e..65d9d1d0c1 100644 --- a/server/src/storage/bucket.service.ts +++ b/server/src/storage/bucket.service.ts @@ -5,7 +5,7 @@ import { CreateBucketDto } from './dto/create-bucket.dto' import { UpdateBucketDto } from './dto/update-bucket.dto' import { MinioService } from './minio/minio.service' import { Application } from 'src/application/entities/application' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { StorageBucket, StorageWithRelations } from './entities/storage-bucket' import { StoragePhase, StorageState } from './entities/storage-user' diff --git a/server/src/storage/storage.service.ts b/server/src/storage/storage.service.ts index 5f580f47ce..b3897b2c8e 100644 --- a/server/src/storage/storage.service.ts +++ b/server/src/storage/storage.service.ts @@ -5,7 +5,7 @@ import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts' import { RegionService } from 'src/region/region.service' import { TASK_LOCK_INIT_TIME } from 'src/constants' import { Region } from 'src/region/entities/region' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { StoragePhase, StorageState, diff --git a/server/src/database/system-database.ts b/server/src/system-database.ts similarity index 100% rename from server/src/database/system-database.ts rename to server/src/system-database.ts diff --git a/server/src/trigger/trigger-task.service.ts b/server/src/trigger/trigger-task.service.ts index a2316ac594..9654f84ecc 100644 --- a/server/src/trigger/trigger-task.service.ts +++ b/server/src/trigger/trigger-task.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' import { TASK_LOCK_INIT_TIME } from 'src/constants' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { CronJobService } from './cron-job.service' import { CronTrigger, diff --git a/server/src/trigger/trigger.service.ts b/server/src/trigger/trigger.service.ts index b3f207139a..b08181a9fd 100644 --- a/server/src/trigger/trigger.service.ts +++ b/server/src/trigger/trigger.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common' import { TASK_LOCK_INIT_TIME } from 'src/constants' import { CreateTriggerDto } from './dto/create-trigger.dto' import CronValidate from 'cron-validate' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { CronTrigger, TriggerPhase, diff --git a/server/src/user/pat.service.ts b/server/src/user/pat.service.ts index 5d30b5071c..12d21b7b38 100644 --- a/server/src/user/pat.service.ts +++ b/server/src/user/pat.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common' import { GenerateAlphaNumericPassword } from 'src/utils/random' import { CreatePATDto } from './dto/create-pat.dto' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { PersonalAccessToken, PersonalAccessTokenWithUser, diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 2dc0485f84..66764a6e3d 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { User } from './entities/user' import { ObjectId } from 'mongodb' diff --git a/server/src/website/website.service.ts b/server/src/website/website.service.ts index b2995d5ecb..00faadf159 100644 --- a/server/src/website/website.service.ts +++ b/server/src/website/website.service.ts @@ -4,7 +4,7 @@ import { RegionService } from 'src/region/region.service' import { CreateWebsiteDto } from './dto/create-website.dto' import * as assert from 'node:assert' import * as dns from 'node:dns' -import { SystemDatabase } from 'src/database/system-database' +import { SystemDatabase } from 'src/system-database' import { WebsiteHosting, WebsiteHostingWithBucket } from './entities/website' import { DomainPhase, DomainState } from 'src/gateway/entities/runtime-domain' import { ObjectId } from 'mongodb' From 12389981bab84fcaf3c219d20870542a7691e87c Mon Sep 17 00:00:00 2001 From: maslow Date: Wed, 24 May 2023 21:34:30 +0800 Subject: [PATCH 29/48] impl billing task --- server/src/application/application.service.ts | 1 + .../src/application/entities/application.ts | 2 + server/src/billing/billing-task.service.ts | 226 ++++++++++++++++++ server/src/billing/billing.module.ts | 3 +- .../billing/entities/application-billing.ts | 64 +++++ server/src/billing/metering-database.ts | 46 ++++ server/src/constants.ts | 4 + 7 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 server/src/billing/billing-task.service.ts create mode 100644 server/src/billing/entities/application-billing.ts create mode 100644 server/src/billing/metering-database.ts diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index 25da275072..e72f685515 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -85,6 +85,7 @@ export class ApplicationService { lockedAt: TASK_LOCK_INIT_TIME, regionId: new ObjectId(dto.regionId), runtimeId: new ObjectId(dto.runtimeId), + billingLockedAt: TASK_LOCK_INIT_TIME, createdAt: new Date(), updatedAt: new Date(), }, diff --git a/server/src/application/entities/application.ts b/server/src/application/entities/application.ts index 7ca535e192..911deeb30e 100644 --- a/server/src/application/entities/application.ts +++ b/server/src/application/entities/application.ts @@ -63,6 +63,8 @@ export class Application { @ApiProperty({ type: String }) createdBy: ObjectId + billingLockedAt: Date + constructor(partial: Partial) { Object.assign(this, partial) } diff --git a/server/src/billing/billing-task.service.ts b/server/src/billing/billing-task.service.ts new file mode 100644 index 0000000000..ddba92cc1d --- /dev/null +++ b/server/src/billing/billing-task.service.ts @@ -0,0 +1,226 @@ +import { Injectable, Logger } from '@nestjs/common' +import { Cron, CronExpression } from '@nestjs/schedule' +import { Application } from 'src/application/entities/application' +import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' +import { SystemDatabase } from 'src/system-database' +import { + ApplicationBilling, + ApplicationBillingState, +} from './entities/application-billing' +import { MeteringDatabase } from './metering-database' +import { CalculatePriceDto } from './dto/calculate-price.dto' +import { BillingService } from './billing.service' +import { times } from 'lodash' + +@Injectable() +export class BillingTaskService { + private readonly logger = new Logger(BillingTaskService.name) + private readonly lockTimeout = 60 * 60 + 60 // in second + + constructor(private readonly billing: BillingService) {} + + @Cron(CronExpression.EVERY_5_MINUTES) + async tick() { + const db = SystemDatabase.db + if (ServerConfig.DISABLED_BILLING_TASK) { + return + } + + const total = await db + .collection('Application') + .countDocuments({ + billingLockedAt: { + $lt: new Date(Date.now() - 1000 * this.lockTimeout), + }, + }) + + if (total === 0) { + return + } + + let concurrency = total + if (total > 30) { + concurrency = 30 + setTimeout(() => { + this.tick() + }, 3000) + } + + times(concurrency, () => { + this.handleApplicationBilling().catch((err) => { + this.logger.error('processApplicationBilling error', err) + }) + }) + } + + private async handleApplicationBilling() { + const db = SystemDatabase.db + + const res = await db + .collection('Application') + .findOneAndUpdate( + { + billingLockedAt: { + $lt: new Date(Date.now() - 1000 * this.lockTimeout), + }, + }, + { $set: { billingLockedAt: this.getHourTime() } }, + { sort: { billingLockedAt: 1, updatedAt: 1 } }, + ) + + if (!res.value) { + return + } + + const app = res.value + const billingTime = await this.processApplicationBilling(app) + if (!billingTime) return + + if (Date.now() - billingTime.getTime() > 1000 * this.lockTimeout) { + await db + .collection('Application') + .updateOne( + { appid: app.appid }, + { $set: { billingLockedAt: TASK_LOCK_INIT_TIME } }, + ) + return + } + } + + private async processApplicationBilling(app: Application) { + this.logger.debug(`processApplicationBilling ${app.appid}`) + + const appid = app.appid + const db = SystemDatabase.db + + // determine latest billing time & next metering time + const latestBillingTime = await this.getLatestBillingTime(appid) + const nextMeteringTime = await this.determineNextMeteringTime( + appid, + latestBillingTime, + ) + + if (!nextMeteringTime) { + return + } + + // lookup metering data + const meteringCollection = MeteringDatabase.db.collection('metering') + const meteringData = await meteringCollection + .find({ category: appid, time: nextMeteringTime }, { sort: { time: 1 } }) + .toArray() + + if (meteringData.length === 0) { + return + } + + // calculate billing + const price = await this.calculatePrice(app, meteringData) + + // create billing + const cpuMetering = meteringData.find((it) => it.property === 'cpu') + const memoryMetering = meteringData.find((it) => it.property === 'memory') + const databaseMetering = meteringData.find( + (it) => it.property === 'storageCapacity', + ) + const storageMetering = meteringData.find( + (it) => it.property === 'databaseCapacity', + ) + + await db.collection('ApplicationBilling').insertOne({ + appid, + state: ApplicationBillingState.Pending, + amount: price.total, + detail: { + cpu: { + usage: cpuMetering?.value || 0, + amount: price.cpu, + }, + memory: { + usage: memoryMetering?.value || 0, + amount: price.memory, + }, + databaseCapacity: { + usage: databaseMetering?.value || 0, + amount: price.databaseCapacity, + }, + storageCapacity: { + usage: storageMetering?.value || 0, + amount: price.storageCapacity, + }, + }, + startAt: new Date(nextMeteringTime.getTime() - 1000 * 60 * 60), + endAt: nextMeteringTime, + lockedAt: TASK_LOCK_INIT_TIME, + createdAt: new Date(), + updatedAt: new Date(), + }) + + return nextMeteringTime + } + + private async calculatePrice(app: Application, meteringData: any[]) { + const dto = new CalculatePriceDto() + dto.regionId = app.regionId.toString() + dto.cpu = 0 + dto.memory = 0 + dto.storageCapacity = 0 + dto.databaseCapacity = 0 + + for (const item of meteringData) { + if (item.property === 'cpu') dto.cpu = item.value + if (item.property === 'memory') dto.memory = item.value + if (item.property === 'storageCapacity') dto.storageCapacity = item.value + if (item.property === 'databaseCapacity') + dto.databaseCapacity = item.value + } + + const result = await this.billing.calculatePrice(dto) + return result + } + + private async determineNextMeteringTime( + appid: string, + latestBillingTime: Date, + ) { + const db = MeteringDatabase.db + const nextMeteringData = await db + .collection('metering') + .findOne( + { category: appid, time: { $gt: latestBillingTime } }, + { sort: { time: 1 } }, + ) + + if (!nextMeteringData) { + return null + } + + return nextMeteringData.time as Date + } + + private async getLatestBillingTime(appid: string) { + const db = SystemDatabase.db + + // get latest billing + const latestBilling = await db + .collection('ApplicationBilling') + .findOne({ appid }, { sort: { endAt: -1 } }) + + if (latestBilling) { + return latestBilling.endAt + } + + const latestTime = this.getHourTime() + latestTime.setHours(latestTime.getHours() - 1) + + return latestTime + } + + private getHourTime() { + const latestTime = new Date() + latestTime.setMinutes(0) + latestTime.setSeconds(0) + latestTime.setMilliseconds(0) + return latestTime + } +} diff --git a/server/src/billing/billing.module.ts b/server/src/billing/billing.module.ts index a080c74db1..82d521d380 100644 --- a/server/src/billing/billing.module.ts +++ b/server/src/billing/billing.module.ts @@ -2,10 +2,11 @@ import { Module } from '@nestjs/common' import { BillingService } from './billing.service' import { ResourceService } from './resource.service' import { BillingController } from './billing.controller' +import { BillingTaskService } from './billing-task.service' @Module({ controllers: [BillingController], - providers: [BillingService, ResourceService], + providers: [BillingService, ResourceService, BillingTaskService], exports: [BillingService, ResourceService], }) export class BillingModule {} diff --git a/server/src/billing/entities/application-billing.ts b/server/src/billing/entities/application-billing.ts new file mode 100644 index 0000000000..2eba40d1cf --- /dev/null +++ b/server/src/billing/entities/application-billing.ts @@ -0,0 +1,64 @@ +import { ApiProperty } from '@nestjs/swagger' +import { ObjectId } from 'mongodb' +import { ResourceType } from './resource' + +export enum ApplicationBillingState { + Pending = 'Pending', + Done = 'Done', +} + +export class ApplicationBillingDetailItem { + @ApiProperty() + usage: number + + @ApiProperty() + amount: number +} + +export class ApplicationBillingDetail { + @ApiProperty() + [ResourceType.CPU]: ApplicationBillingDetailItem; + + @ApiProperty() + [ResourceType.Memory]: ApplicationBillingDetailItem; + + @ApiProperty() + [ResourceType.DatabaseCapacity]: ApplicationBillingDetailItem; + + @ApiProperty() + [ResourceType.StorageCapacity]: ApplicationBillingDetailItem; + + @ApiProperty() + [ResourceType.NetworkTraffic]?: ApplicationBillingDetailItem +} + +export class ApplicationBilling { + @ApiProperty({ type: String }) + _id?: ObjectId + + @ApiProperty() + appid: string + + @ApiProperty({ enum: ApplicationBillingState }) + state: ApplicationBillingState + + @ApiProperty() + amount: number + + @ApiProperty() + detail: ApplicationBillingDetail + + @ApiProperty() + startAt: Date + + @ApiProperty() + endAt: Date + + lockedAt: Date + + @ApiProperty() + createdAt: Date + + @ApiProperty() + updatedAt: Date +} diff --git a/server/src/billing/metering-database.ts b/server/src/billing/metering-database.ts new file mode 100644 index 0000000000..810b618aa0 --- /dev/null +++ b/server/src/billing/metering-database.ts @@ -0,0 +1,46 @@ +import { Logger } from '@nestjs/common' +import { MongoClient } from 'mongodb' +import { ServerConfig } from 'src/constants' +import * as assert from 'node:assert' + +export class MeteringDatabase { + private static readonly logger = new Logger(MeteringDatabase.name) + + private static _conn: MongoClient = this.connect() + + private static _ready: Promise + + static get client() { + if (!this._conn) { + this._conn = this.connect() + } + return this._conn + } + + static get db() { + return this.client.db() + } + + static get ready() { + assert(this.client, 'metering database client can not be empty') + return this._ready + } + + private static connect() { + const connectionUri = ServerConfig.METERING_DATABASE_URL + const client = new MongoClient(connectionUri) + this._ready = client.connect() + + this._ready + .then(() => { + this.logger.log('Connected to metering database') + }) + .catch((err) => { + this.logger.error('Failed to connect to metering database') + this.logger.error(err) + process.exit(1) + }) + + return client + } +} diff --git a/server/src/constants.ts b/server/src/constants.ts index 73783f19ab..8e5d804d30 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -54,6 +54,10 @@ export class ServerConfig { return process.env.DISABLED_STORAGE_TASK === 'true' } + static get DISABLED_BILLING_TASK() { + return process.env.DISABLED_BILLING_TASK === 'true' + } + static get APPID_LENGTH(): number { return parseInt(process.env.APPID_LENGTH || '6') } From e3f588622dc920b2a51cec6ce36283e425540584 Mon Sep 17 00:00:00 2001 From: maslow Date: Thu, 25 May 2023 15:35:51 +0800 Subject: [PATCH 30/48] impl billing payment task --- server/src/account/account.controller.ts | 37 ++-- server/src/account/account.service.ts | 4 +- .../account/entities/account-transaction.ts | 2 +- .../src/application/application.controller.ts | 27 ++- server/src/application/application.service.ts | 2 +- server/src/billing/billing-task.service.ts | 175 ++++++++++++++---- 6 files changed, 179 insertions(+), 68 deletions(-) diff --git a/server/src/account/account.controller.ts b/server/src/account/account.controller.ts index dd69b2aaa3..9f9a4802d1 100644 --- a/server/src/account/account.controller.ts +++ b/server/src/account/account.controller.ts @@ -154,53 +154,38 @@ export class AccountController { const client = SystemDatabase.client const session = client.startSession() await session.withTransaction(async () => { - // get order - const order = await db - .collection('AccountChargeOrder') - .findOne( - { _id: tradeOrderId, phase: AccountChargePhase.Pending }, - { session }, - ) - if (!order) { - this.logger.error(`wechatpay order not found: ${tradeOrderId}`) - return - } - // update order to success const res = await db .collection('AccountChargeOrder') - .updateOne( + .findOneAndUpdate( { _id: tradeOrderId, phase: AccountChargePhase.Pending }, { $set: { phase: AccountChargePhase.Paid, result: result } }, - { session }, + { session, returnDocument: 'after' }, ) - if (res.modifiedCount === 0) { + const order = res.value + if (!order) { this.logger.error(`wechatpay order not found: ${tradeOrderId}`) return } - // get account - const account = await db + // get & update account balance + const ret = await db .collection('Account') - .findOne({ _id: order.accountId }, { session }) - assert(account, `account not found: ${order.accountId}`) - - // update account balance - await db - .collection('Account') - .updateOne( + .findOneAndUpdate( { _id: order.accountId }, { $inc: { balance: order.amount } }, - { session }, + { session, returnDocument: 'after' }, ) + assert(ret.value, `account not found: ${order.accountId}`) + // create transaction await db.collection('AccountTransaction').insertOne( { accountId: order.accountId, amount: order.amount, - balance: account.balance + order.amount, + balance: ret.value.balance, message: 'Recharge by WeChat Pay', orderId: order._id, createdAt: new Date(), diff --git a/server/src/account/account.service.ts b/server/src/account/account.service.ts index f9700d2454..2dcbf1aa89 100644 --- a/server/src/account/account.service.ts +++ b/server/src/account/account.service.ts @@ -57,7 +57,7 @@ export class AccountService { assert(account, 'Account not found') // create charge order - await this.db + const res = await this.db .collection('AccountChargeOrder') .insertOne({ accountId: account._id, @@ -71,7 +71,7 @@ export class AccountService { updatedAt: new Date(), }) - return await this.findOneChargeOrder(userid, account._id) + return await this.findOneChargeOrder(userid, res.insertedId) } async findOneChargeOrder(userid: ObjectId, id: ObjectId) { diff --git a/server/src/account/entities/account-transaction.ts b/server/src/account/entities/account-transaction.ts index d819999e1c..8e57eb4ed1 100644 --- a/server/src/account/entities/account-transaction.ts +++ b/server/src/account/entities/account-transaction.ts @@ -7,8 +7,8 @@ export class AccountTransaction { balance: number message: string orderId?: ObjectId + billingId?: ObjectId createdAt: Date - updatedAt?: Date constructor(partial: Partial) { Object.assign(this, partial) diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index 3df5f39e22..36aa4ebf9e 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -8,6 +8,7 @@ import { Req, Logger, Post, + Delete, } from '@nestjs/common' import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' import { IRequest } from '../utils/interface' @@ -194,9 +195,9 @@ export class ApplicationController { async updateState( @Param('appid') appid: string, @Body() dto: UpdateApplicationStateDto, + @Req() req: IRequest, ) { - // check if the corresponding subscription status has expired - const app = await this.application.findOne(appid) + const app = req.application // check: only running application can restart if ( @@ -250,6 +251,28 @@ export class ApplicationController { return ResponseUtil.ok(doc) } + /** + * Delete an application + */ + @ApiOperation({ summary: 'Delete an application' }) + @ApiResponseObject(Application) + @UseGuards(JwtAuthGuard, ApplicationAuthGuard) + @Delete(':appid') + async delete(@Param('appid') appid: string, @Req() req: IRequest) { + const app = req.application + + // check: only stopped application can be deleted + if ( + app.state !== ApplicationState.Stopped && + app.phase !== ApplicationPhase.Stopped + ) { + return ResponseUtil.error('The app is not stopped, can not delete it') + } + + const doc = await this.application.remove(appid) + return ResponseUtil.ok(doc) + } + /** * Update an application * @deprecated use updateName and updateState instead diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index e72f685515..d2fe489362 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -283,7 +283,7 @@ export class ApplicationService { .collection('Application') .findOneAndUpdate( { appid }, - { $set: { phase: ApplicationPhase.Deleted, updatedAt: new Date() } }, + { $set: { state: ApplicationState.Deleted, updatedAt: new Date() } }, ) return doc.value diff --git a/server/src/billing/billing-task.service.ts b/server/src/billing/billing-task.service.ts index ddba92cc1d..6bb42ddb0a 100644 --- a/server/src/billing/billing-task.service.ts +++ b/server/src/billing/billing-task.service.ts @@ -1,6 +1,9 @@ import { Injectable, Logger } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' -import { Application } from 'src/application/entities/application' +import { + Application, + ApplicationState, +} from 'src/application/entities/application' import { ServerConfig, TASK_LOCK_INIT_TIME } from 'src/constants' import { SystemDatabase } from 'src/system-database' import { @@ -11,6 +14,10 @@ import { MeteringDatabase } from './metering-database' import { CalculatePriceDto } from './dto/calculate-price.dto' import { BillingService } from './billing.service' import { times } from 'lodash' +import { ApplicationBundle } from 'src/application/entities/application-bundle' +import * as assert from 'assert' +import { Account } from 'src/account/entities/account' +import { AccountTransaction } from 'src/account/entities/account-transaction' @Injectable() export class BillingTaskService { @@ -38,22 +45,115 @@ export class BillingTaskService { return } - let concurrency = total + const concurrency = total > 30 ? 30 : total if (total > 30) { - concurrency = 30 setTimeout(() => { this.tick() }, 3000) } times(concurrency, () => { - this.handleApplicationBilling().catch((err) => { - this.logger.error('processApplicationBilling error', err) + this.handleApplicationBillingCreating().catch((err) => { + this.logger.error('handleApplicationBillingCreating error', err) + }) + + this.handlePendingApplicationBilling().catch((err) => { + this.logger.error('handlePendingApplicationBilling error', err) }) }) } - private async handleApplicationBilling() { + private async handlePendingApplicationBilling() { + const db = SystemDatabase.db + + const res = await db + .collection('ApplicationBilling') + .findOneAndUpdate( + { + state: ApplicationBillingState.Pending, + lockedAt: { + $lt: new Date(Date.now() - 1000 * this.lockTimeout), + }, + }, + { $set: { lockedAt: new Date() } }, + { sort: { lockedAt: 1, createdAt: 1 } }, + ) + + if (!res.value) { + return + } + + const billing = res.value + + // get application + const app = await db + .collection('Application') + .findOne({ appid: billing.appid }) + + assert(app, `Application ${billing.appid} not found`) + + // get account + const account = await db + .collection('Account') + .findOne({ createdBy: app.createdBy }) + + assert(account, `Account ${app.createdBy} not found`) + + // pay billing + const session = SystemDatabase.client.startSession() + + try { + await session.withTransaction(async () => { + // update the account balance + const res = await db + .collection('Account') + .findOneAndUpdate( + { _id: account._id }, + { $inc: { balance: -billing.amount } }, + { session, returnDocument: 'after' }, + ) + + assert(res.value, `Account ${account._id} not found`) + + // create transaction + await db.collection('AccountTransaction').insertOne( + { + accountId: account._id, + amount: -billing.amount, + balance: res.value.balance, + message: `Application ${app.appid} billing`, + billingId: billing._id, + createdAt: new Date(), + }, + { session }, + ) + + // update billing state + await db + .collection('ApplicationBilling') + .updateOne( + { _id: billing._id }, + { $set: { state: ApplicationBillingState.Done } }, + { session }, + ) + + // stop application if balance is not enough + if (res.value.balance < 0) { + await db + .collection('Application') + .updateOne( + { appid: app.appid, state: ApplicationState.Running }, + { $set: { state: ApplicationState.Stopped } }, + { session }, + ) + } + }) + } finally { + session.endSession() + } + } + + private async handleApplicationBillingCreating() { const db = SystemDatabase.db const res = await db @@ -73,9 +173,10 @@ export class BillingTaskService { } const app = res.value - const billingTime = await this.processApplicationBilling(app) + const billingTime = await this.createApplicationBilling(app) if (!billingTime) return + // unlock billing if billing time is not the latest if (Date.now() - billingTime.getTime() > 1000 * this.lockTimeout) { await db .collection('Application') @@ -87,7 +188,7 @@ export class BillingTaskService { } } - private async processApplicationBilling(app: Application) { + private async createApplicationBilling(app: Application) { this.logger.debug(`processApplicationBilling ${app.appid}`) const appid = app.appid @@ -105,8 +206,8 @@ export class BillingTaskService { } // lookup metering data - const meteringCollection = MeteringDatabase.db.collection('metering') - const meteringData = await meteringCollection + const meteringData = await MeteringDatabase.db + .collection('metering') .find({ category: appid, time: nextMeteringTime }, { sort: { time: 1 } }) .toArray() @@ -114,39 +215,31 @@ export class BillingTaskService { return } - // calculate billing - const price = await this.calculatePrice(app, meteringData) + // calculate billing price + const priceInput = await this.buildCalculatePriceInput(app, meteringData) + const priceResult = await this.billing.calculatePrice(priceInput) // create billing - const cpuMetering = meteringData.find((it) => it.property === 'cpu') - const memoryMetering = meteringData.find((it) => it.property === 'memory') - const databaseMetering = meteringData.find( - (it) => it.property === 'storageCapacity', - ) - const storageMetering = meteringData.find( - (it) => it.property === 'databaseCapacity', - ) - await db.collection('ApplicationBilling').insertOne({ appid, state: ApplicationBillingState.Pending, - amount: price.total, + amount: priceResult.total, detail: { cpu: { - usage: cpuMetering?.value || 0, - amount: price.cpu, + usage: priceInput.cpu, + amount: priceResult.cpu, }, memory: { - usage: memoryMetering?.value || 0, - amount: price.memory, + usage: priceInput.memory, + amount: priceResult.memory, }, databaseCapacity: { - usage: databaseMetering?.value || 0, - amount: price.databaseCapacity, + usage: priceInput.databaseCapacity, + amount: priceResult.databaseCapacity, }, storageCapacity: { - usage: storageMetering?.value || 0, - amount: price.storageCapacity, + usage: priceInput.storageCapacity, + amount: priceResult.storageCapacity, }, }, startAt: new Date(nextMeteringTime.getTime() - 1000 * 60 * 60), @@ -159,7 +252,10 @@ export class BillingTaskService { return nextMeteringTime } - private async calculatePrice(app: Application, meteringData: any[]) { + private async buildCalculatePriceInput( + app: Application, + meteringData: any[], + ) { const dto = new CalculatePriceDto() dto.regionId = app.regionId.toString() dto.cpu = 0 @@ -170,13 +266,20 @@ export class BillingTaskService { for (const item of meteringData) { if (item.property === 'cpu') dto.cpu = item.value if (item.property === 'memory') dto.memory = item.value - if (item.property === 'storageCapacity') dto.storageCapacity = item.value - if (item.property === 'databaseCapacity') - dto.databaseCapacity = item.value } - const result = await this.billing.calculatePrice(dto) - return result + // get application bundle + const db = SystemDatabase.db + const bundle = await db + .collection('ApplicationBundle') + .findOne({ appid: app.appid }) + + assert(bundle, `bundle not found ${app.appid}`) + + dto.storageCapacity = bundle.resource.storageCapacity + dto.databaseCapacity = bundle.resource.databaseCapacity + + return dto } private async determineNextMeteringTime( From c415158fe7aa4de9f75e9c49e4394f07a859eb3f Mon Sep 17 00:00:00 2001 From: maslow Date: Thu, 25 May 2023 16:32:57 +0800 Subject: [PATCH 31/48] restart app while updating bundle --- .../charts/laf-server/templates/deployment.yaml | 2 ++ deploy/build/charts/laf-server/values.yaml | 1 + deploy/build/images/shim/ImageList | 4 +++- deploy/build/start.sh | 2 ++ server/src/application/application.controller.ts | 13 +++++++++++++ server/src/application/application.service.ts | 1 + 6 files changed, 22 insertions(+), 1 deletion(-) diff --git a/deploy/build/charts/laf-server/templates/deployment.yaml b/deploy/build/charts/laf-server/templates/deployment.yaml index 963ac7fc85..f23cdbed21 100644 --- a/deploy/build/charts/laf-server/templates/deployment.yaml +++ b/deploy/build/charts/laf-server/templates/deployment.yaml @@ -51,6 +51,8 @@ spec: env: - name: DATABASE_URL value: {{ .Values.databaseUrl | quote}} + - name: METERING_DATABASE_URL + value: {{ .Values.meteringDatabaseUrl | quote}} - name: JWT_SECRET value: {{ .Values.jwt.secret | quote}} - name: API_SERVER_URL diff --git a/deploy/build/charts/laf-server/values.yaml b/deploy/build/charts/laf-server/values.yaml index a06aa06fba..57e1e7ed4a 100644 --- a/deploy/build/charts/laf-server/values.yaml +++ b/deploy/build/charts/laf-server/values.yaml @@ -4,6 +4,7 @@ apiServerHost: "" databaseUrl: "" +meteringDatabaseUrl: "" apiServerUrl: "" siteName: "laf" # init default region conf diff --git a/deploy/build/images/shim/ImageList b/deploy/build/images/shim/ImageList index ebb3e2ea89..cf89945f70 100644 --- a/deploy/build/images/shim/ImageList +++ b/deploy/build/images/shim/ImageList @@ -1,4 +1,6 @@ docker.io/apache/apisix-dashboard:2.13-alpine docker.io/apache/apisix-ingress-controller:1.5.0 +docker.io/apache/apisix:2.15.0-alpine quay.io/minio/minio:RELEASE.2023-03-22T06-36-24Z -quay.io/minio/mc:RELEASE.2022-11-07T23-47-39Z \ No newline at end of file +quay.io/minio/mc:RELEASE.2022-11-07T23-47-39Z +quay.io/coreos/etcd:v3.5.4 \ No newline at end of file diff --git a/deploy/build/start.sh b/deploy/build/start.sh index 6a6e3b9d85..d386ae1b8c 100644 --- a/deploy/build/start.sh +++ b/deploy/build/start.sh @@ -24,6 +24,7 @@ set -e set -x DATABASE_URL="mongodb://${DB_USERNAME:-admin}:${PASSWD_OR_SECRET}@mongodb-0.mongo.${NAMESPACE}.svc.cluster.local:27017/sys_db?authSource=admin&replicaSet=rs0&w=majority" +METERING_DATABASE_URL="mongodb://${DB_USERNAME:-admin}:${PASSWD_OR_SECRET}@mongodb-0.mongo.${NAMESPACE}.svc.cluster.local:27017/sealos-resources?authSource=admin&replicaSet=rs0&w=majority" helm install mongodb -n ${NAMESPACE} \ --set db.username=${DB_USERNAME:-admin} \ --set db.password=${PASSWD_OR_SECRET} \ @@ -66,6 +67,7 @@ helm install minio -n ${NAMESPACE} \ SERVER_JWT_SECRET=$PASSWD_OR_SECRET helm install server -n ${NAMESPACE} \ --set databaseUrl=${DATABASE_URL} \ + --set meteringDatabaseUrl=${METERING_DATABASE_URL} \ --set jwt.secret=${SERVER_JWT_SECRET} \ --set apiServerHost=api.${DOMAIN} \ --set apiServerUrl=${HTTP_SCHEMA}://api.${DOMAIN} \ diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index 36aa4ebf9e..2195b7628b 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -247,7 +247,20 @@ export class ApplicationController { @Param('appid') appid: string, @Body() dto: UpdateApplicationBundleDto, ) { + const app = await this.application.findOne(appid) + const origin = app.bundle const doc = await this.application.updateBundle(appid, dto) + + // restart running application if cpu or memory changed + const isRunning = app.phase === ApplicationPhase.Started + const isCpuChanged = origin.resource.limitCPU !== doc.resource.limitCPU + const isMemoryChanged = + origin.resource.limitMemory !== doc.resource.limitMemory + + if (isRunning && (isCpuChanged || isMemoryChanged)) { + await this.application.updateState(appid, ApplicationState.Restarting) + } + return ResponseUtil.ok(doc) } diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index d2fe489362..02f37e42c4 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -257,6 +257,7 @@ export class ApplicationService { 'bundle.resource.requestCPU': 0, 'bundle.resource.requestMemory': 0, }, + returnDocument: 'after', }, ) From 5e045ff5d7d708069e593a105b77295a932de9db Mon Sep 17 00:00:00 2001 From: maslow Date: Thu, 25 May 2023 18:14:44 +0800 Subject: [PATCH 32/48] update user profile api --- server/src/auth/auth.controller.ts | 17 +++---- server/src/auth/authentication.controller.ts | 2 +- server/src/auth/phone/phone.controller.ts | 2 +- .../user-passwd/user-password.controller.ts | 2 +- server/src/user/dto/user.response.ts | 49 ------------------- server/src/user/entities/pat.ts | 14 +++++- server/src/user/entities/user-profile.ts | 14 ++++++ server/src/user/entities/user.ts | 18 +++++++ server/src/user/user.service.ts | 15 +++++- server/src/utils/interface.ts | 4 +- server/src/utils/response.ts | 17 +++++++ 11 files changed, 88 insertions(+), 66 deletions(-) delete mode 100644 server/src/user/dto/user.response.ts diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index caf57da153..c8d235c850 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -1,16 +1,15 @@ import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common' +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' import { - ApiBearerAuth, - ApiOperation, - ApiResponse, - ApiTags, -} from '@nestjs/swagger' -import { ApiResponseObject, ResponseUtil } from '../utils/response' + ApiResponseObject, + ApiResponseString, + ResponseUtil, +} from '../utils/response' import { IRequest } from '../utils/interface' -import { UserDto } from '../user/dto/user.response' import { AuthService } from './auth.service' import { JwtAuthGuard } from './jwt.auth.guard' import { Pat2TokenDto } from './dto/pat2token.dto' +import { UserWithProfile } from 'src/user/entities/user' @ApiTags('Authentication') @Controller() @@ -23,7 +22,7 @@ export class AuthController { * @returns */ @ApiOperation({ summary: 'Get user token by PAT' }) - @ApiResponse({ type: ResponseUtil }) + @ApiResponseString() @Post('pat2token') async pat2token(@Body() dto: Pat2TokenDto) { const token = await this.authService.pat2token(dto.pat) @@ -41,7 +40,7 @@ export class AuthController { */ @UseGuards(JwtAuthGuard) @Get('profile') - @ApiResponseObject(UserDto) + @ApiResponseObject(UserWithProfile) @ApiOperation({ summary: 'Get current user profile' }) @ApiBearerAuth('Authorization') async getProfile(@Req() request: IRequest) { diff --git a/server/src/auth/authentication.controller.ts b/server/src/auth/authentication.controller.ts index d8e4e20165..4909b6d5b9 100644 --- a/server/src/auth/authentication.controller.ts +++ b/server/src/auth/authentication.controller.ts @@ -11,7 +11,7 @@ import { UserService } from 'src/user/user.service' import { ObjectId } from 'mongodb' import { SmsVerifyCodeType } from './entities/sms-verify-code' -@ApiTags('Authentication - New') +@ApiTags('Authentication') @Controller('auth') export class AuthenticationController { constructor( diff --git a/server/src/auth/phone/phone.controller.ts b/server/src/auth/phone/phone.controller.ts index aec59eb857..a17f7bff6a 100644 --- a/server/src/auth/phone/phone.controller.ts +++ b/server/src/auth/phone/phone.controller.ts @@ -11,7 +11,7 @@ import { UserService } from 'src/user/user.service' import { AuthBindingType, AuthProviderBinding } from '../types' import { SmsVerifyCodeType } from '../entities/sms-verify-code' -@ApiTags('Authentication - New') +@ApiTags('Authentication') @Controller('auth') export class PhoneController { private readonly logger = new Logger(PhoneController.name) diff --git a/server/src/auth/user-passwd/user-password.controller.ts b/server/src/auth/user-passwd/user-password.controller.ts index a318074443..ecff138434 100644 --- a/server/src/auth/user-passwd/user-password.controller.ts +++ b/server/src/auth/user-passwd/user-password.controller.ts @@ -11,7 +11,7 @@ import { SmsService } from '../phone/sms.service' import { PasswdResetDto } from '../dto/passwd-reset.dto' import { PasswdCheckDto } from '../dto/passwd-check.dto' -@ApiTags('Authentication - New') +@ApiTags('Authentication') @Controller('auth') export class UserPasswordController { private readonly logger = new Logger(UserPasswordService.name) diff --git a/server/src/user/dto/user.response.ts b/server/src/user/dto/user.response.ts deleted file mode 100644 index e5be356afc..0000000000 --- a/server/src/user/dto/user.response.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger' -import { User } from '../entities/user' -import { UserProfile } from '../entities/user-profile' -import { ObjectId } from 'mongodb' - -export class UserProfileDto implements UserProfile { - _id?: ObjectId - - @ApiProperty() - uid: ObjectId - - @ApiProperty() - avatar: string - - @ApiProperty() - name: string - - @ApiProperty() - openData: any - - @ApiProperty() - createdAt: Date - - @ApiProperty() - updatedAt: Date -} - -export class UserDto implements User { - @ApiProperty() - _id?: ObjectId - - @ApiProperty() - email: string - - @ApiProperty() - username: string - - @ApiProperty() - phone: string - - @ApiProperty() - profile: UserProfileDto - - @ApiProperty() - createdAt: Date - - @ApiProperty() - updatedAt: Date -} diff --git a/server/src/user/entities/pat.ts b/server/src/user/entities/pat.ts index 6934c6bfaa..d07ec6b43a 100644 --- a/server/src/user/entities/pat.ts +++ b/server/src/user/entities/pat.ts @@ -1,15 +1,27 @@ import { ObjectId } from 'mongodb' import { User } from './user' +import { ApiProperty } from '@nestjs/swagger' export class PersonalAccessToken { + @ApiProperty({ type: String }) _id?: ObjectId + + @ApiProperty({ type: String }) uid: ObjectId + + @ApiProperty() name: string + token: string + + @ApiProperty() expiredAt: Date + + @ApiProperty() createdAt: Date } -export type PersonalAccessTokenWithUser = PersonalAccessToken & { +export class PersonalAccessTokenWithUser extends PersonalAccessToken { + @ApiProperty({ type: User }) user: User } diff --git a/server/src/user/entities/user-profile.ts b/server/src/user/entities/user-profile.ts index 8daaa9c6e5..6887a4cb32 100644 --- a/server/src/user/entities/user-profile.ts +++ b/server/src/user/entities/user-profile.ts @@ -1,11 +1,25 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' import { ObjectId } from 'mongodb' export class UserProfile { + @ApiProperty({ type: String }) _id?: ObjectId + + @ApiProperty({ type: String }) uid: ObjectId + + @ApiPropertyOptional() openData?: any + + @ApiPropertyOptional() avatar?: string + + @ApiPropertyOptional() name?: string + + @ApiProperty() createdAt: Date + + @ApiProperty() updatedAt: Date } diff --git a/server/src/user/entities/user.ts b/server/src/user/entities/user.ts index e070ea0c30..355b1e1737 100644 --- a/server/src/user/entities/user.ts +++ b/server/src/user/entities/user.ts @@ -1,10 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' import { ObjectId } from 'mongodb' +import { UserProfile } from './user-profile' export class User { + @ApiProperty({ type: String }) _id?: ObjectId + + @ApiProperty() username: string + + @ApiPropertyOptional() email?: string + + @ApiPropertyOptional() phone?: string + + @ApiProperty() createdAt: Date + + @ApiProperty() updatedAt: Date } + +export class UserWithProfile extends User { + @ApiPropertyOptional() + profile?: UserProfile +} diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 66764a6e3d..fb97f8b03a 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common' import { SystemDatabase } from 'src/system-database' -import { User } from './entities/user' +import { User, UserWithProfile } from './entities/user' import { ObjectId } from 'mongodb' @Injectable() @@ -20,7 +20,18 @@ export class UserService { } async findOneById(id: ObjectId) { - return this.db.collection('User').findOne({ _id: id }) + return this.db + .collection('User') + .aggregate() + .match({ _id: id }) + .lookup({ + from: 'UserProfile', + localField: '_id', + foreignField: 'uid', + as: 'profile', + }) + .unwind({ path: '$profile', preserveNullAndEmptyArrays: true }) + .next() } async findOneByUsername(username: string) { diff --git a/server/src/utils/interface.ts b/server/src/utils/interface.ts index 71459b6a96..c4fe95a4f3 100644 --- a/server/src/utils/interface.ts +++ b/server/src/utils/interface.ts @@ -1,9 +1,9 @@ import { Request, Response } from 'express' import { Application } from 'src/application/entities/application' -import { User } from 'src/user/entities/user' +import { UserWithProfile } from 'src/user/entities/user' export interface IRequest extends Request { - user?: User + user?: UserWithProfile application?: Application [key: string]: any } diff --git a/server/src/utils/response.ts b/server/src/utils/response.ts index 8639a41f81..b163e71aa6 100644 --- a/server/src/utils/response.ts +++ b/server/src/utils/response.ts @@ -47,6 +47,23 @@ export class ResponseUtil { } } +export const ApiResponseString = () => + applyDecorators( + ApiExtraModels(ResponseUtil), + ApiResponse({ + schema: { + allOf: [ + { $ref: getSchemaPath(ResponseUtil) }, + { + properties: { + data: { type: 'string' }, + }, + }, + ], + }, + }), + ) + export const ApiResponseObject = >( dataDto: DataDto, ) => From 2f1cb160b270bc6a2250712980a05be399036568 Mon Sep 17 00:00:00 2001 From: maslow Date: Fri, 26 May 2023 10:49:19 +0800 Subject: [PATCH 33/48] fix account api response body --- server/src/account/account.controller.ts | 31 +++++++++++++------ .../account/dto/create-charge-order.dto.ts | 14 ++++++++- .../account/entities/account-charge-order.ts | 25 ++++++++++++++- server/src/account/entities/account.ts | 12 +++++++ .../application/application-task.service.ts | 4 +-- .../src/application/application.controller.ts | 3 -- server/src/application/application.service.ts | 15 +++++++-- server/src/application/environment.service.ts | 8 ++++- server/src/billing/billing-task.service.ts | 4 +-- .../database/policy/policy-rule.service.ts | 1 + server/src/database/policy/policy.service.ts | 1 + .../src/gateway/bucket-domain-task.service.ts | 2 ++ server/src/gateway/bucket-domain.service.ts | 1 + .../gateway/runtime-domain-task.service.ts | 2 ++ server/src/gateway/runtime-domain.service.ts | 1 + server/src/gateway/website-task.service.ts | 2 ++ server/src/instance/instance-task.service.ts | 6 ++-- server/src/storage/bucket-task.service.ts | 2 ++ server/src/storage/bucket.service.ts | 2 ++ server/src/trigger/trigger-task.service.ts | 2 ++ server/src/website/website.service.ts | 7 ++++- 21 files changed, 119 insertions(+), 26 deletions(-) diff --git a/server/src/account/account.controller.ts b/server/src/account/account.controller.ts index 9f9a4802d1..1ec8528139 100644 --- a/server/src/account/account.controller.ts +++ b/server/src/account/account.controller.ts @@ -9,12 +9,20 @@ import { Res, UseGuards, } from '@nestjs/common' -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' +import { + ApiBearerAuth, + ApiExcludeEndpoint, + ApiOperation, + ApiTags, +} from '@nestjs/swagger' import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' -import { IRequest } from 'src/utils/interface' -import { ResponseUtil } from 'src/utils/response' +import { IRequest, IResponse } from 'src/utils/interface' +import { ApiResponseObject, ResponseUtil } from 'src/utils/response' import { AccountService } from './account.service' -import { CreateChargeOrderDto } from './dto/create-charge-order.dto' +import { + CreateChargeOrderDto, + CreateChargeOrderOutDto, +} from './dto/create-charge-order.dto' import { PaymentChannelService } from './payment/payment-channel.service' import { WeChatPayChargeOrder, @@ -22,10 +30,12 @@ import { WeChatPayTradeState, } from './payment/types' import { WeChatPayService } from './payment/wechat-pay.service' -import { Response } from 'express' import * as assert from 'assert' import { ServerConfig } from 'src/constants' -import { AccountChargePhase } from './entities/account-charge-order' +import { + AccountChargeOrder, + AccountChargePhase, +} from './entities/account-charge-order' import { ObjectId } from 'mongodb' import { SystemDatabase } from 'src/system-database' import { Account } from './entities/account' @@ -47,18 +57,20 @@ export class AccountController { * Get account info */ @ApiOperation({ summary: 'Get account info' }) + @ApiResponseObject(Account) @UseGuards(JwtAuthGuard) @Get() async findOne(@Req() req: IRequest) { const user = req.user const data = await this.accountService.findOne(user._id) - return data + return ResponseUtil.ok(data) } /** * Get charge order */ @ApiOperation({ summary: 'Get charge order' }) + @ApiResponseObject(AccountChargeOrder) @UseGuards(JwtAuthGuard) @Get('charge-order/:id') async getChargeOrder(@Req() req: IRequest, @Param('id') id: string) { @@ -67,13 +79,14 @@ export class AccountController { user._id, new ObjectId(id), ) - return data + return ResponseUtil.ok(data) } /** * Create charge order */ @ApiOperation({ summary: 'Create charge order' }) + @ApiResponseObject(CreateChargeOrderOutDto) @UseGuards(JwtAuthGuard) @Post('charge-order') async charge(@Req() req: IRequest, @Body() dto: CreateChargeOrderDto) { @@ -107,7 +120,7 @@ export class AccountController { * WeChat payment notify */ @Post('payment/wechat-notify') - async wechatNotify(@Req() req: IRequest, @Res() res: Response) { + async wechatNotify(@Req() req: IRequest, @Res() res: IResponse) { try { // get headers const headers = req.headers diff --git a/server/src/account/dto/create-charge-order.dto.ts b/server/src/account/dto/create-charge-order.dto.ts index 1aa048e774..bff80932c1 100644 --- a/server/src/account/dto/create-charge-order.dto.ts +++ b/server/src/account/dto/create-charge-order.dto.ts @@ -1,6 +1,10 @@ import { ApiProperty } from '@nestjs/swagger' import { IsEnum, IsInt, IsPositive, IsString, Max, Min } from 'class-validator' -import { Currency, PaymentChannelType } from '../entities/account-charge-order' +import { + AccountChargeOrder, + Currency, + PaymentChannelType, +} from '../entities/account-charge-order' export class CreateChargeOrderDto { @ApiProperty({ example: 1000 }) @@ -20,3 +24,11 @@ export class CreateChargeOrderDto { @IsEnum(Currency) currency: Currency } + +export class CreateChargeOrderOutDto { + @ApiProperty({ type: AccountChargeOrder }) + order: AccountChargeOrder + + @ApiProperty({ type: Object }) + result: any +} diff --git a/server/src/account/entities/account-charge-order.ts b/server/src/account/entities/account-charge-order.ts index 5d636065ac..5e7a87ee5f 100644 --- a/server/src/account/entities/account-charge-order.ts +++ b/server/src/account/entities/account-charge-order.ts @@ -1,3 +1,4 @@ +import { ApiProperty } from '@nestjs/swagger' import { ObjectId } from 'mongodb' export enum Currency { @@ -21,17 +22,39 @@ export enum PaymentChannelType { } export class AccountChargeOrder { + @ApiProperty({ type: String }) _id?: ObjectId + + @ApiProperty({ type: String }) accountId: ObjectId + + @ApiProperty() amount: number + + @ApiProperty({ enum: Currency }) currency: Currency + + @ApiProperty({ enum: AccountChargePhase }) phase: AccountChargePhase + + @ApiProperty({ enum: PaymentChannelType }) channel: PaymentChannelType + + @ApiProperty() result?: R + + @ApiProperty() message?: string - createdAt: Date + lockedAt: Date + + @ApiProperty() + createdAt: Date + + @ApiProperty() updatedAt: Date + + @ApiProperty({ type: String }) createdBy: ObjectId constructor(partial: Partial>) { diff --git a/server/src/account/entities/account.ts b/server/src/account/entities/account.ts index 6ef9d1ed05..e90ff918df 100644 --- a/server/src/account/entities/account.ts +++ b/server/src/account/entities/account.ts @@ -1,3 +1,4 @@ +import { ApiProperty } from '@nestjs/swagger' import { ObjectId } from 'mongodb' export enum BaseState { @@ -6,11 +7,22 @@ export enum BaseState { } export class Account { + @ApiProperty({ type: String }) _id?: ObjectId + + @ApiProperty() balance: number + + @ApiProperty({ enum: BaseState }) state: BaseState + + @ApiProperty() createdAt: Date + + @ApiProperty() updatedAt: Date + + @ApiProperty({ type: String }) createdBy: ObjectId constructor(partial: Partial) { diff --git a/server/src/application/application-task.service.ts b/server/src/application/application-task.service.ts index 54b9f89110..293c83761f 100644 --- a/server/src/application/application-task.service.ts +++ b/server/src/application/application-task.service.ts @@ -85,7 +85,7 @@ export class ApplicationTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, - { sort: { lockedAt: 1, updatedAt: 1 } }, + { sort: { lockedAt: 1, updatedAt: 1 }, returnDocument: 'after' }, ) if (!res.value) return @@ -185,7 +185,7 @@ export class ApplicationTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, - { sort: { lockedAt: 1, updatedAt: 1 } }, + { sort: { lockedAt: 1, updatedAt: 1 }, returnDocument: 'after' }, ) if (!res.value) return diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index 2195b7628b..522e1b9fbf 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -162,9 +162,6 @@ export class ApplicationController { /** This is the redundant field of Region */ tls: region.tls, - - /** @deprecated alias of develop token, will be remove in future */ - function_debug_token: develop_token, } return ResponseUtil.ok(res) diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index 02f37e42c4..e46bf37353 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -230,7 +230,11 @@ export class ApplicationService { const db = SystemDatabase.db const res = await db .collection('Application') - .findOneAndUpdate({ appid }, { $set: { name, updatedAt: new Date() } }) + .findOneAndUpdate( + { appid }, + { $set: { name, updatedAt: new Date() } }, + { returnDocument: 'after' }, + ) return res.value } @@ -239,7 +243,11 @@ export class ApplicationService { const db = SystemDatabase.db const res = await db .collection('Application') - .findOneAndUpdate({ appid }, { $set: { state, updatedAt: new Date() } }) + .findOneAndUpdate( + { appid }, + { $set: { state, updatedAt: new Date() } }, + { returnDocument: 'after' }, + ) return res.value } @@ -273,7 +281,7 @@ export class ApplicationService { const doc = await db .collection('Application') - .findOneAndUpdate({ appid }, { $set: data }) + .findOneAndUpdate({ appid }, { $set: data }, { returnDocument: 'after' }) return doc } @@ -285,6 +293,7 @@ export class ApplicationService { .findOneAndUpdate( { appid }, { $set: { state: ApplicationState.Deleted, updatedAt: new Date() } }, + { returnDocument: 'after' }, ) return doc.value diff --git a/server/src/application/environment.service.ts b/server/src/application/environment.service.ts index c762e86cd8..031a5eaff3 100644 --- a/server/src/application/environment.service.ts +++ b/server/src/application/environment.service.ts @@ -18,6 +18,7 @@ export class EnvironmentVariableService { .findOneAndUpdate( { appid }, { $set: { environments: dto, updatedAt: new Date() } }, + { returnDocument: 'after' }, ) assert(res?.value, 'application configuration not found') @@ -45,6 +46,7 @@ export class EnvironmentVariableService { .findOneAndUpdate( { appid }, { $set: { environments: origin, updatedAt: new Date() } }, + { returnDocument: 'after' }, ) assert(res?.value, 'application configuration not found') @@ -63,7 +65,11 @@ export class EnvironmentVariableService { async deleteOne(appid: string, name: string) { const res = await this.db .collection('ApplicationConfiguration') - .findOneAndUpdate({ appid }, { $pull: { environments: { name } } }) + .findOneAndUpdate( + { appid }, + { $pull: { environments: { name } } }, + { returnDocument: 'after' }, + ) assert(res?.value, 'application configuration not found') await this.confService.publish(res.value) diff --git a/server/src/billing/billing-task.service.ts b/server/src/billing/billing-task.service.ts index 6bb42ddb0a..6484aa13f2 100644 --- a/server/src/billing/billing-task.service.ts +++ b/server/src/billing/billing-task.service.ts @@ -76,7 +76,7 @@ export class BillingTaskService { }, }, { $set: { lockedAt: new Date() } }, - { sort: { lockedAt: 1, createdAt: 1 } }, + { sort: { lockedAt: 1, createdAt: 1 }, returnDocument: 'after' }, ) if (!res.value) { @@ -165,7 +165,7 @@ export class BillingTaskService { }, }, { $set: { billingLockedAt: this.getHourTime() } }, - { sort: { billingLockedAt: 1, updatedAt: 1 } }, + { sort: { billingLockedAt: 1, updatedAt: 1 }, returnDocument: 'after' }, ) if (!res.value) { diff --git a/server/src/database/policy/policy-rule.service.ts b/server/src/database/policy/policy-rule.service.ts index ff63fdf005..bcd5148cf2 100644 --- a/server/src/database/policy/policy-rule.service.ts +++ b/server/src/database/policy/policy-rule.service.ts @@ -65,6 +65,7 @@ export class PolicyRuleService { .findOneAndUpdate( { appid, policyName, collectionName }, { $set: { value: JSON.parse(dto.value), updatedAt: new Date() } }, + { returnDocument: 'after' }, ) const policy = await this.policyService.findOne(appid, policyName) diff --git a/server/src/database/policy/policy.service.ts b/server/src/database/policy/policy.service.ts index dacb7deb64..d7e51481bd 100644 --- a/server/src/database/policy/policy.service.ts +++ b/server/src/database/policy/policy.service.ts @@ -92,6 +92,7 @@ export class PolicyService { .findOneAndUpdate( { appid, name }, { $set: { injector: dto.injector, updatedAt: new Date() } }, + { returnDocument: 'after' }, ) const doc = await this.findOne(appid, name) diff --git a/server/src/gateway/bucket-domain-task.service.ts b/server/src/gateway/bucket-domain-task.service.ts index 3174816384..0a44c50411 100644 --- a/server/src/gateway/bucket-domain-task.service.ts +++ b/server/src/gateway/bucket-domain-task.service.ts @@ -67,6 +67,7 @@ export class BucketDomainTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, + { returnDocument: 'after' }, ) if (!res.value) return @@ -114,6 +115,7 @@ export class BucketDomainTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, + { returnDocument: 'after' }, ) if (!res.value) return diff --git a/server/src/gateway/bucket-domain.service.ts b/server/src/gateway/bucket-domain.service.ts index 89c0d243c0..b6b4212484 100644 --- a/server/src/gateway/bucket-domain.service.ts +++ b/server/src/gateway/bucket-domain.service.ts @@ -67,6 +67,7 @@ export class BucketDomainService { .findOneAndUpdate( { _id: bucket._id }, { $set: { state: DomainState.Deleted, updatedAt: new Date() } }, + { returnDocument: 'after' }, ) return await this.findOne(bucket) diff --git a/server/src/gateway/runtime-domain-task.service.ts b/server/src/gateway/runtime-domain-task.service.ts index 6c301a56cc..8b169310ad 100644 --- a/server/src/gateway/runtime-domain-task.service.ts +++ b/server/src/gateway/runtime-domain-task.service.ts @@ -69,6 +69,7 @@ export class RuntimeDomainTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, + { returnDocument: 'after' }, ) if (!res.value) return @@ -116,6 +117,7 @@ export class RuntimeDomainTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, + { returnDocument: 'after' }, ) if (!res.value) return diff --git a/server/src/gateway/runtime-domain.service.ts b/server/src/gateway/runtime-domain.service.ts index dcd4a5fcbb..4921025b87 100644 --- a/server/src/gateway/runtime-domain.service.ts +++ b/server/src/gateway/runtime-domain.service.ts @@ -59,6 +59,7 @@ export class RuntimeDomainService { .findOneAndUpdate( { appid: appid }, { $set: { state: DomainState.Deleted, updatedAt: new Date() } }, + { returnDocument: 'after' }, ) return doc diff --git a/server/src/gateway/website-task.service.ts b/server/src/gateway/website-task.service.ts index ffadb9b92e..0f8d6224f3 100644 --- a/server/src/gateway/website-task.service.ts +++ b/server/src/gateway/website-task.service.ts @@ -72,6 +72,7 @@ export class WebsiteTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, + { returnDocument: 'after' }, ) if (!res.value) return @@ -177,6 +178,7 @@ export class WebsiteTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, + { returnDocument: 'after' }, ) if (!res.value) return diff --git a/server/src/instance/instance-task.service.ts b/server/src/instance/instance-task.service.ts index f09f7b881c..ad78c356bc 100644 --- a/server/src/instance/instance-task.service.ts +++ b/server/src/instance/instance-task.service.ts @@ -97,7 +97,7 @@ export class InstanceTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, - { sort: { lockedAt: 1, updatedAt: 1 } }, + { sort: { lockedAt: 1, updatedAt: 1 }, returnDocument: 'after' }, ) if (!res.value) return @@ -212,7 +212,7 @@ export class InstanceTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, - { sort: { lockedAt: 1, updatedAt: 1 } }, + { sort: { lockedAt: 1, updatedAt: 1 }, returnDocument: 'after' }, ) if (!res.value) return @@ -272,7 +272,7 @@ export class InstanceTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, - { sort: { lockedAt: 1, updatedAt: 1 } }, + { sort: { lockedAt: 1, updatedAt: 1 }, returnDocument: 'after' }, ) if (!res.value) return diff --git a/server/src/storage/bucket-task.service.ts b/server/src/storage/bucket-task.service.ts index eb35831765..8b554d996e 100644 --- a/server/src/storage/bucket-task.service.ts +++ b/server/src/storage/bucket-task.service.ts @@ -70,6 +70,7 @@ export class BucketTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, + { returnDocument: 'after' }, ) if (!res.value) return @@ -133,6 +134,7 @@ export class BucketTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, + { returnDocument: 'after' }, ) if (!res.value) return diff --git a/server/src/storage/bucket.service.ts b/server/src/storage/bucket.service.ts index 65d9d1d0c1..48b8f96397 100644 --- a/server/src/storage/bucket.service.ts +++ b/server/src/storage/bucket.service.ts @@ -125,6 +125,7 @@ export class BucketService { .findOneAndUpdate( { appid: bucket.appid, name: bucket.name }, { $set: { policy: dto.policy, updatedAt: new Date() } }, + { returnDocument: 'after' }, ) return res @@ -136,6 +137,7 @@ export class BucketService { .findOneAndUpdate( { appid: bucket.appid, name: bucket.name }, { $set: { state: StorageState.Deleted, updatedAt: new Date() } }, + { returnDocument: 'after' }, ) return res diff --git a/server/src/trigger/trigger-task.service.ts b/server/src/trigger/trigger-task.service.ts index 9654f84ecc..54ff71ccf8 100644 --- a/server/src/trigger/trigger-task.service.ts +++ b/server/src/trigger/trigger-task.service.ts @@ -61,6 +61,7 @@ export class TriggerTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, + { returnDocument: 'after' }, ) if (!res.value) return @@ -100,6 +101,7 @@ export class TriggerTaskService { lockedAt: { $lt: new Date(Date.now() - 1000 * this.lockTimeout) }, }, { $set: { lockedAt: new Date() } }, + { returnDocument: 'after' }, ) if (!res.value) return diff --git a/server/src/website/website.service.ts b/server/src/website/website.service.ts index 00faadf159..140b2a4405 100644 --- a/server/src/website/website.service.ts +++ b/server/src/website/website.service.ts @@ -120,6 +120,7 @@ export class WebsiteService { updatedAt: new Date(), }, }, + { returnDocument: 'after' }, ) return res.value @@ -128,7 +129,11 @@ export class WebsiteService { async removeOne(id: ObjectId) { const res = await this.db .collection('WebsiteHosting') - .findOneAndUpdate({ _id: id }, { $set: { state: DomainState.Deleted } }) + .findOneAndUpdate( + { _id: id }, + { $set: { state: DomainState.Deleted } }, + { returnDocument: 'after' }, + ) return res.value } From 1f7d2984f62569f1177214525d39ccf2b5ec0e00 Mon Sep 17 00:00:00 2001 From: allence Date: Fri, 26 May 2023 14:22:19 +0800 Subject: [PATCH 34/48] feat(web): new metering design (#1171) * doc: add wechat example * doc: add jsapi pay example * doc: add two cloud function example (#1134) * doc: add two cloud function example * doc: delete repeat word * doc: fix wrong format (#1164) * feat: bundle select * fix: upgrade payment * refactor(web): update package * refactor(web): upgrade type definitions --------- Co-authored-by: NightWhite --- docs/.vitepress/config.js | 55 +- docs/3min/index.md | 4 +- docs/3min/wechat/CorporateWeChat/index.md | 7 + docs/3min/wechat/MediaPlatform/H5.md | 338 +++ docs/3min/wechat/MediaPlatform/Menu.md | 135 ++ .../wechat/MediaPlatform/ServerDocking.md | 122 ++ docs/3min/wechat/MediaPlatform/index.md | 7 + docs/3min/wechat/MiniProgram/index.md | 7 + docs/3min/wechat/index.md | 7 + docs/doc-images/MediaPlatformBaseSetting.png | Bin 0 -> 66470 bytes docs/doc-images/MediaPlatformBaseSetting2.png | Bin 0 -> 48142 bytes docs/doc-images/bind-pay.png | Bin 0 -> 44954 bytes docs/doc-images/bind-pay2.png | Bin 0 -> 149244 bytes docs/doc-images/set-wechat-pay.png | Bin 0 -> 36024 bytes docs/guide/function/faq.md | 99 + docs/guide/laf-assistant/index.md | 4 +- web/package-lock.json | 1852 +++++++++-------- web/package.json | 59 +- web/public/locales/en/translation.json | 9 +- web/public/locales/zh-CN/translation.json | 7 +- web/public/locales/zh/translation.json | 7 +- web/src/apis/typing.d.ts | 157 +- web/src/apis/v1/accounts.ts | 20 +- web/src/apis/v1/api-auto.d.ts | 255 ++- web/src/apis/v1/applications.ts | 115 +- web/src/apis/v1/apps.ts | 267 ++- web/src/apis/v1/auth.ts | 49 +- web/src/apis/v1/code2token.ts | 28 - web/src/apis/v1/login.ts | 28 - web/src/apis/v1/pat2token.ts | 7 +- web/src/apis/v1/pats.ts | 17 +- web/src/apis/v1/profile.ts | 5 +- web/src/apis/v1/regions.ts | 5 +- web/src/apis/v1/register.ts | 28 - web/src/apis/v1/resources.ts | 91 + web/src/apis/v1/runtimes.ts | 5 +- web/src/apis/v1/settings.ts | 10 +- web/src/apis/v1/subscriptions.ts | 113 - web/src/components/ChargeButton/index.tsx | 54 +- web/src/layouts/Header/index.tsx | 5 +- web/src/pages/LoginCallback.tsx | 28 - .../app/functions/mods/AIChatPanel/index.tsx | 5 + .../app/functions/mods/DebugPanel/index.tsx | 20 +- .../app/functions/mods/DeployButton/index.tsx | 6 +- .../app/functions/mods/EditorPanel/index.tsx | 10 +- .../functions/mods/FunctionPanel/index.tsx | 2 +- web/src/pages/app/functions/store.ts | 2 +- web/src/pages/app/mods/SideBar/index.tsx | 2 +- web/src/pages/app/mods/StatusBar/index.tsx | 15 +- .../pages/app/setting/AppInfoList/index.tsx | 7 +- web/src/pages/app/setting/UserInfo/index.tsx | 2 +- .../mods/CreateWebsiteModal/index.tsx | 4 +- .../storages/mods/StorageListPanel/index.tsx | 2 +- web/src/pages/globalStore.ts | 38 +- .../mods/CreateAppModal/BundleItem/index.tsx | 84 +- .../pages/home/mods/CreateAppModal/index.tsx | 379 ++-- .../pages/home/mods/DeleteAppModal/index.tsx | 25 +- web/src/pages/home/mods/List/BundleInfo.tsx | 11 +- web/src/pages/home/mods/List/index.tsx | 73 +- web/src/pages/home/service.ts | 12 +- web/src/utils/format.ts | 9 +- web/src/utils/getRegionById.ts | 2 +- 62 files changed, 2914 insertions(+), 1802 deletions(-) create mode 100644 docs/3min/wechat/CorporateWeChat/index.md create mode 100644 docs/3min/wechat/MediaPlatform/H5.md create mode 100644 docs/3min/wechat/MediaPlatform/Menu.md create mode 100644 docs/3min/wechat/MediaPlatform/ServerDocking.md create mode 100644 docs/3min/wechat/MediaPlatform/index.md create mode 100644 docs/3min/wechat/MiniProgram/index.md create mode 100644 docs/3min/wechat/index.md create mode 100644 docs/doc-images/MediaPlatformBaseSetting.png create mode 100644 docs/doc-images/MediaPlatformBaseSetting2.png create mode 100644 docs/doc-images/bind-pay.png create mode 100644 docs/doc-images/bind-pay2.png create mode 100644 docs/doc-images/set-wechat-pay.png delete mode 100644 web/src/apis/v1/code2token.ts delete mode 100644 web/src/apis/v1/login.ts delete mode 100644 web/src/apis/v1/register.ts create mode 100644 web/src/apis/v1/resources.ts delete mode 100644 web/src/apis/v1/subscriptions.ts delete mode 100644 web/src/pages/LoginCallback.tsx create mode 100644 web/src/pages/app/functions/mods/AIChatPanel/index.tsx diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index c07ceae407..02ef012838 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -65,8 +65,12 @@ const NavConfig = [ }, { text: "三分钟实验室", - // target: "_self", - link: "/3min/", + items: [ + { + text: "接入微信", + link: "/3min/wechat/", + }, + ], }, { text: "立即使用", @@ -265,34 +269,45 @@ const guideSiderbarConfig = [ */ const examplesSideBarConfig = [ { - text: "云函数", + text: "3 分钟实验室", items: [ { - text: "阿里云短信发送函数", - link: "/examples/aliyun-sms", - }, - { - text: "实现微信支付功能", - link: "/examples/wechat-pay", + text: "介绍", + link: "/3min/", }, + ], + }, + { + text: "接入微信", + items: [ { - text: "实现支付宝支付功能", - link: "/examples/alipay-pay", + text: "微信公众号", + link: "/3min/wechat/MediaPlatform/", + items: [ + { + text: "服务器对接及文本消息", + link: "/3min/wechat/MediaPlatform/ServerDocking.md", + }, + { + text: "自定义菜单", + link: "/3min/wechat/MediaPlatform/Menu.md", + }, + { + text: "H5 开发", + link: "/3min/wechat/MediaPlatform/H5.md", + }, + ], }, { - text: "使用 WebSocket 长连接", - link: "/examples/websocket", + text: "微信小程序", + link: "/3min/wechat/MiniProgram/", }, { - text: "使用 SMTP 服务发送邮件", - link: "/examples/send-mail", + text: "企业微信", + link: "/3min/wechat/CorporateWeChat/", }, ], }, - { - text: "前端应用", - items: [{ text: "Todo List", link: "/examples/todo-list" }], - }, ]; export default defineConfig({ @@ -330,7 +345,7 @@ export default defineConfig({ ], sidebar: { "/guide/": guideSiderbarConfig, - "/examples/": examplesSideBarConfig, + "/3min/": examplesSideBarConfig, }, }, head: [ diff --git a/docs/3min/index.md b/docs/3min/index.md index ff4b4562f0..5d60a46534 100644 --- a/docs/3min/index.md +++ b/docs/3min/index.md @@ -4,4 +4,6 @@ title: 三分钟实验室 # {{ $frontmatter.title }} -> TODO +何为三分钟实验室。意为快速接入,帮助开发者实现快速接入各种应用、AI + +三分钟实验室为开发者打造了多种“开箱即用”的 AI 和应用接入示例 diff --git a/docs/3min/wechat/CorporateWeChat/index.md b/docs/3min/wechat/CorporateWeChat/index.md new file mode 100644 index 0000000000..ff4b4562f0 --- /dev/null +++ b/docs/3min/wechat/CorporateWeChat/index.md @@ -0,0 +1,7 @@ +--- +title: 三分钟实验室 +--- + +# {{ $frontmatter.title }} + +> TODO diff --git a/docs/3min/wechat/MediaPlatform/H5.md b/docs/3min/wechat/MediaPlatform/H5.md new file mode 100644 index 0000000000..f8dc6cb8d9 --- /dev/null +++ b/docs/3min/wechat/MediaPlatform/H5.md @@ -0,0 +1,338 @@ +--- +title: 公众号 H5 +--- + +# {{ $frontmatter.title }} + +公众号 H5 开发必须基于认证服务号,个人公众号和订阅号可忽略。 + +微信公众号 H5 的开发会用到 [JSAPI](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html) + +本文档会使用一个封装好的 JSAPI 依赖去实现:[weixin-js-sdk](https://www.npmjs.com/package/weixin-js-sdk) + +## 静默登录(获取 OpenID) + +### 云函数部分 + +命名为:`h5-login` + +```typescript +import cloud from '@lafjs/cloud' + +const appid = process.env.WECHAT_APPID +const appsecret = process.env.WECHAT_SECRET + +export default async function (ctx: FunctionContext) { + const { code } = ctx.body + return await login(code) +} + +// 根据 code 获取用户 openid +async function login(code) { + const userInfo = await cloud.fetch.get(`https://api.weixin.qq.com/sns/oauth2/access_token?appid=${appid}&secret=${appsecret}&code=${code}&grant_type=authorization_code`) + console.log(userInfo.data) + return userInfo.data +} +``` + +### 前端部分 + +以 uniapp 为例。 + +替换 baseUrl 为云函数 Url,注意不要带云函数名。appID 为公众号 appid。 + +>用到 vconsole 和 weixin-js-sdk 依赖。请记得 npm install 安装 + +```typescript + +``` + +## JSAPI 支付(公众号 H5 支付) + +### 获取 V3 证书和 V3 密钥 + +![set-wechat-pay](/doc-images/set-wechat-pay.png) + +- step 1 点击 `账户安全` +- step 2 点击 `API安全` +- step 3 点击 `申请证书` +- step 4 点击 `设置APIv3密钥的设置` + +### 公众号绑定支付商户号 + +![bind-pay](/doc-images/bind-pay.png) + +![bind-pay2](/doc-images/bind-pay2.png) + +- step 1 登录公众号后台 点击 `微信支付` +- step 2 点击`关联更多商户号` 并登录需要绑定的微信支付商户号 +- step 3 点击`产品中心` +- step 4 点击 `AppID账号管理` +- step 5 点击 `我关联的AppID账号` +- step 6 点击 `关联AppID` +- step 7 填入公众号的 AppID 等信息,点击提交 +- step 8 公众号微信支付页面点击确认,即完成绑定 + +### 云函数和前端范例代码 + +>laf 云函数安装依赖 `wechatpay-node-v3-laf` +> +>前端安装依赖 `weixin-js-sdk` + +支付云函数,如命名为:`h5-pay` + +```typescript +import cloud from "@lafjs/cloud"; +const baseUrl = '' // laf 云函数域名 + +export default async function (ctx: FunctionContext) { + const Pay = require('wechatpay-node-v3-laf'); + const pay = new Pay({ + appid: '', // 认证服务号 appid + mchid: '', // 绑定该认证服务号的微信支付商户号 + publicKey: savePublicKey(), // V3 公钥 apiclient_cert.pem + privateKey: savePrivateKey(), // V3 秘钥 apiclient_key.pem + }); + + const params = { + description: 'pay test', + out_trade_no: '12345678', // 订单号,随机生成即可,当前仅为测试用,实际需要自己单独管理 + notify_url: `${baseUrl}/h5-pay-notify`, + amount: { + total: 1, + }, + payer: { + openid: ctx.body.openid, + }, + scene_info: { + payer_client_ip: ctx.headers['x-real-ip'], + }, + }; + const result = await pay.transactions_jsapi(params); + console.log("pay params",result); + return result +}; + +function savePrivateKey() { + const key = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGVc+DdK1VRxFt +... +... +... +NJ0Fpw91iHlttL5IzRmQ/Y+m6zaieCaRlvkEuW9xnzGJY6JPDcz/Y3D+5bYYlWir +dDdYre8DP5exGNAJ5V+cQeVn +-----END PRIVATE KEY----- +`; + return Buffer.from(key); +} + +function savePublicKey() { + const key = `-----BEGIN CERTIFICATE----- +MIID7DCCAtSgAwIBAgIUf0yvWQ0DLN+H2GG9Cz2VC6MkLfEwDQYJKoZIhvcNAQEL +... +... +... +t1MYZCa6F/ePNugyXhxkhGGB1gUB8HLYN2OuwlxNtASzlp2CifCBKojFLAduhdnJ +-----END CERTIFICATE----- +`; + return Buffer.from(key); +} +``` + +回调通知云函数,如命名为:`h5-pay-notify` + +当用户支付成功或者失败,微信支付服务器会发一个请求到回调通知云函数,根据回调通知接收到的信息来判断用户是否支持成功 + +```typescript +import cloud from '@lafjs/cloud' +const Pay = require('wechatpay-node-v3-laf'); +const pay = new Pay({ + appid: '', // 认证服务号 appid + mchid: '', // 绑定该认证服务号的微信支付商户号 + publicKey: savePublicKey(), // V3 公钥 apiclient_cert.pem + privateKey: savePrivateKey(), // V3 秘钥 apiclient_key.pem +}); +const key = "" // 用商户平台上设置的 APIv3 密钥【微信商户平台—>账户设置—>API 安全—>设置 APIv3 密钥】 + +export default async function (ctx: FunctionContext) { + + // 支付成功回调通知范例 ctx.body 的内容 + // { + // id: '8e6ff231-f7bc-514f-bec3-e24157fa2db5', + // create_time: '2023-05-22T03:44:20+08:00', + // resource_type: 'encrypt-resource', + // event_type: 'TRANSACTION.SUCCESS', + // summary: '支付成功', + // resource: { + // original_type: 'transaction', + // algorithm: 'AEAD_AES_256_GCM', + // ciphertext: 'tm+BtFF2/mxDJ49uQp39JlOv6Ss6rVjoxyxQE8/rbNtRR+TLVniHGq9cWlycXd408wYx0OmnpUV0BADqEB/VuIk+w4DmvoXH7ingWcnHP4xjnLaO4jDfsLMMcnPdZyMHiBYhAgGyXpmftqgGZs+5AnCcuMq3A7o8dpAyV/qxRQypaqcRhwaYw+TErGecpPw3SDTi7ekwQ8J5PUVCmchBk3n1Li064NsYnkmUzDd1WDzXcVqmw/Vlt/JnoNMBKh7+AhyZGv6pwZLcmgBlaYjHeUdtHNjidBpSCtXQ1HV8mxLGi4L3hQ/ZFK2kpUbt2tRsby1ai5KtUj4rxaAyOZ79j40v3RYjTSAiwKdCiP7c5jT6Q4uwmK8Dt4CfrMGLem4W8Ah6+bp8/b26Ib7JMc42lSeKaufltNY+1o/ffL5sBI9LiJRr7T4Cn2JeYBP0Nuaw2RMfUlT3cCUaotJx9BBm8oVzr0NY0kWhdrKgyO63SeGS9WMmd3F5//pkbsCgsumva8gcvhL5Pch79SKzNYiguRUnqg8teR0gD0E8u90Jc7WfcDOZylPY', + // associated_data: 'transaction', + // nonce: 'n1NofrM384Ci' + // } + // } + + const { resource } = ctx.body + const { ciphertext, associated_data, nonce } = resource + const result = pay.decipher_gcm(ciphertext, associated_data, nonce, key); + console.log("支付解密", result) + if (result.trade_state == "SUCCESS") { + // 支付成功的逻辑 + + } + + + return ` + + +` +} + +function savePrivateKey() { + const key = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC2msJBre5qr9Z7 +... +... +... +iXTIs7zGtva7qSMNX6r5844/UnMeZv4ohK8pwMMPXOtVPc3grVx7DX5D2jY3ITid +cTg/LKJksgROdEz3E7EIQ7k= +-----END PRIVATE KEY----- +`; + return Buffer.from(key); +} + +function savePublicKey() { + const key = `-----BEGIN CERTIFICATE----- +MIIEKzCCAxOgAwIBAgIUTb+JfWjcUWhea7fP1TyqzJIUNVIwDQYJKoZIhvcNAQEL +... +... +... +0hhIiCOXNdeeo92JZUJLp8BsomSNFbHPckunxt99gaQYPYTF+LlBQRUYOP5MnS65 +cOKTpMEZD57/fDqBzbvU +-----END CERTIFICATE----- +`; + return Buffer.from(key); +} +``` + +前端 + +获取用户 openid 请看前面的代码 + +```typescript +import weixin from 'weixin-js-sdk' +// 支付 +pay() { + const token = uni.getStorageSync('token') + const openid = token.openid + uni.request({ + url: `${baseUrl}/h5-pay`, + method: 'POST', + data: { + openid: openid + }, + success: (res) => { + weixin.config({ + // debug: true, + appId: this.appID, + timestamp: res.data.timeStamp, + nonceStr: res.data.nonceStr, + signature: res.data.signature, + jsApiList: ['chooseWXPay'] + }) + weixin.ready(function () { + weixin.chooseWXPay({ + timestamp: res.data.timeStamp, + nonceStr: res.data.nonceStr, + package: res.data.package, + signType: res.data.signType, + paySign: res.data.paySign, + success: function (res) { + console.log('支付成功', res) + }, + fail: function (res) { + console.log('支付失败', res) + } + }) + }) + }, + fail: (err) => { + console.log(err); + } + }) +}, +``` diff --git a/docs/3min/wechat/MediaPlatform/Menu.md b/docs/3min/wechat/MediaPlatform/Menu.md new file mode 100644 index 0000000000..0ccf8866e2 --- /dev/null +++ b/docs/3min/wechat/MediaPlatform/Menu.md @@ -0,0 +1,135 @@ +--- +title: 自定义菜单 +--- + +# {{ $frontmatter.title }} + +由于对接了自己的服务器,公众号自带的菜单管理变得不可用,我们可以通过云函数去管理菜单 + +## 新建云函数并发布 + +从公众号后台获取 `appid` 和 `appsecret` 并设置到环境变量中 `WECHAT_APPID` 和 `WECHAT_SECRET` 中 + +```typescript +const appid = process.env.WECHAT_APPID +const appsecret = process.env.WECHAT_SECRET + +// 以 laf 开发者官方公众号菜单配置举例 +const menu = { + button: [ + { type: 'view', name: '用户论坛', url: 'https://forum.laf.run/' }, + { + type: 'media_id', + name: '马上进群', + media_id: 'EvjUO0_eaTT2pBbYYcM5trVz_aFexNcXDkACOvQLDAUZhJXSe0zTenPiOQZPHzRJ' + }, + { + name: '关于我们', + sub_button: [ + { type: 'view', name: '国内网站', url: 'https://laf.run' }, + { type: 'view', name: '国外网站', url: 'https://laf.dev' }, + { + type: 'view', + name: 'GitHub', + url: 'https://github.com/labring/laf' + }, + { + type: 'view', + name: '联系我们', + url: 'https://www.wenjuan.com/s/I36ZNbl/#' + } + ] + } + ] +} + +export default async function (ctx: FunctionContext) { + // 打印当前菜单 + console.log(await getMenu()) + // 设置菜单 + const res = await setMenu(menu) +} + +// 获取公众号菜单 +async function getMenu() { + const access_token = await getAccess_token() + const res = await cloud.fetch.get(`https://api.weixin.qq.com/cgi-bin/get_current_selfmenu_info?access_token=${access_token}`) + return res.data +} + +// 设置公众号菜单 +export async function setMenu(menu) { + const access_token = await getAccess_token() + const res = await cloud.fetch.post(`https://api.weixin.qq.com/cgi-bin/menu/create?access_token=${access_token}`, menu) + console.log("setting success", res.data) + return res.data +} + +// 获取微信公众号 ACCESS_TOKEN +async function getAccess_token() { + // 判断档期微信公众号 ACCESS_TOKEN 是否过期 + const shared_access_token = cloud.shared.get("mp_access_token") + if (shared_access_token) { + if (shared_access_token.exp > Date.now()) { + return shared_access_token.access_token + } + } + // 获取微信公众号 ACCESS_TOKEN 并保存添加过期时间后保存到缓存中 + const mp_access_token = await cloud.fetch.get(`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appid}&secret=${appsecret}`) + cloud.shared.set("mp_access_token", { + access_token: mp_access_token.data.access_token, + exp: Date.now() + 7100 * 1000 + }) + return mp_access_token.data.access_token +} +``` + +## 调试运行 + +直接在 Web IDE 中调试运行,即可实现发布公众号菜单了。也可以做成一个 Post 请求,传入 menu 的值,通过其他的前端去配置公众号菜单 + +## 获取 Media_id + +如果有点击菜单后发送的需求。需要获取文件的 Media_id + +如点击马上进群,公众号自动发送指定二维码图片,需要上传图片并获取 Media_id + +```typescript +export default async function (ctx: FunctionContext) { + + const url = 'https://xxx.xxx.xxx/pic.jpg' + const res = await updatePic(url) + console.log('Media_id',res) +} + +// 通过图片 Url 到微信临时素材 +async function updatePic(url) { + const match = /^https?:\/\/[^\/]*\/.*?[^A-Za-z0-9]*?([A-Za-z0-9]+).([A-Za-z0-9]+)[^A-Za-z0-9]*?(?:.*)$/.exec(url) + const pic_name = `${match[1]}.${match[2]}` + const res = await cloud.fetch.get(url, { + responseType: 'arraybuffer' + }) + const access_token = await getAccess_token() + const formData = { + media: { + value: Buffer.from(res.data), + options: { + filename: pic_name, + contentType: 'image/png' + } + } + }; + const request = require('request'); + const util = require('util'); + const postRequest = util.promisify(request.post); + const uploadResponse = await postRequest({ + url: `https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${access_token}&type=image`, + formData: formData + }); + return uploadResponse.body +} +``` + +## 更多菜单相关可根据微信开发文档接入 + + diff --git a/docs/3min/wechat/MediaPlatform/ServerDocking.md b/docs/3min/wechat/MediaPlatform/ServerDocking.md new file mode 100644 index 0000000000..68b5d4c740 --- /dev/null +++ b/docs/3min/wechat/MediaPlatform/ServerDocking.md @@ -0,0 +1,122 @@ +--- +title: 服务器对接 +--- + +# {{ $frontmatter.title }} + +## 新建对接云函数 + +可任意命名。本示例代码为明文接入,可不用填写`消息加解密密钥` + +:::tip +toXML 方法适用于所有的微信订阅号和服务号。 + +认证订阅号和服务号还可以使用客服消息功能去回复消息。 +::: + +```typescript +// 引入 crypto 和 cloud 模块 +import * as crypto from 'crypto'; +import cloud from '@lafjs/cloud'; + +// 处理接收到的微信公众号消息 +export async function main(event) { + const { signature, timestamp, nonce, echostr } = event.query; + const token = process.dev.WECHAT_TOKEN; + + // 验证消息是否合法,若不合法则返回错误信息 + if (!verifySignature(signature, timestamp, nonce, token)) { + return 'Invalid signature'; + } + + // 如果是首次验证,则返回 echostr 给微信服务器 + if (echostr) { + return echostr; + } + + // 处理接收到的消息 + const payload = event.body.xml; + console.log("receive message:", payload) + // 文本消息 + if (payload.msgtype[0] === 'text') { + const newMessage = { + msgid: payload.msgid[0], + question: payload.content[0].trim(), + username: payload.fromusername[0], + sessionId: payload.fromusername[0], + createdAt: Date.now() + } + // 回复文本,这里演示,发什么消息回复什么消息 + const responseText = newMessage.question + return toXML(payload, responseText); + } + + // 其他情况直接回复 'success' 或者 '' 避免出现超时问题 + return 'success' +} + + +// 校验微信服务器发送的消息是否合法 +function verifySignature(signature, timestamp, nonce, token) { + const arr = [token, timestamp, nonce].sort(); + const str = arr.join(''); + const sha1 = crypto.createHash('sha1'); + sha1.update(str); + return sha1.digest('hex') === signature; +} + +// 返回组装 xml +function toXML(payload, content) { + const timestamp = Date.now(); + const { tousername: fromUserName, fromusername: toUserName } = payload; + return ` + + + + ${timestamp} + + + + ` +} +``` + +## 获取公众号令牌 Token 并配置 + +### 登录公众号后台 + + 扫码登录你的微信公众号 + +### 配置服务器 + +![MediaPlatformBaseSetting](/doc-images/MediaPlatformBaseSetting.png) + +![MediaPlatformBaseSetting2](/doc-images/MediaPlatformBaseSetting2.png) + +- step3 填入刚刚新建并已发布的云函数 Url + +- step4 自定义一个`Token`,保持与云函数的环境变量中的`WECHAT_TOKEN`相同,这里设置后,也需要在 laf 应用中配置环境变量 + +- step5 明文模式这个用不到 + +如果配置无误,点击提交会显示`提交成功` + +## 测试对话功能 + +在你的微信公众号发送文本,如果公众号回复与你相同的文字,就代表对接成功 + +## 更多功能可查看微信开发文档 + +:::tip +接入微信公众号消息功能最容易踩的坑。 + +在公众号接收到用户发送过来的消息,需要在 5 秒内做出响应,否则会自动重试 3 次。如对接 ChatGPT 很容易造成超时问题。 + +可以查看文档:[云函数设置某请求超时时间](/guide/function/faq.html#云函数设置某请求超时时间) 解决。 + +如果是认证订阅号或认证服务号,可考虑使用客服消息功能,会有更好的体验。 +::: + +开发消息相关能力可查看: + +其他功能也都可以正常接入。 diff --git a/docs/3min/wechat/MediaPlatform/index.md b/docs/3min/wechat/MediaPlatform/index.md new file mode 100644 index 0000000000..ff4b4562f0 --- /dev/null +++ b/docs/3min/wechat/MediaPlatform/index.md @@ -0,0 +1,7 @@ +--- +title: 三分钟实验室 +--- + +# {{ $frontmatter.title }} + +> TODO diff --git a/docs/3min/wechat/MiniProgram/index.md b/docs/3min/wechat/MiniProgram/index.md new file mode 100644 index 0000000000..ff4b4562f0 --- /dev/null +++ b/docs/3min/wechat/MiniProgram/index.md @@ -0,0 +1,7 @@ +--- +title: 三分钟实验室 +--- + +# {{ $frontmatter.title }} + +> TODO diff --git a/docs/3min/wechat/index.md b/docs/3min/wechat/index.md new file mode 100644 index 0000000000..ff4b4562f0 --- /dev/null +++ b/docs/3min/wechat/index.md @@ -0,0 +1,7 @@ +--- +title: 三分钟实验室 +--- + +# {{ $frontmatter.title }} + +> TODO diff --git a/docs/doc-images/MediaPlatformBaseSetting.png b/docs/doc-images/MediaPlatformBaseSetting.png new file mode 100644 index 0000000000000000000000000000000000000000..5ffb08c11a79d9406fd057560417d9876ed0ef9b GIT binary patch literal 66470 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

242=!^W*h%|w$+;<%CV zxy#rq1P&|*dv#jtpgJ(fGDcthI8;I)&;LJPfdATBnp%OwE!q@rV7#Y20XM4D1~5cmrshZphkkSWrAn$`3~OvF%U{I!oEZu=>f{>DT$a zMfQgPv|rd-#%2;Sr)`GRMQ=26hvk*1RQ_+ox$C8jJ@KFe-S5eY;Qp6|1r0d;6|L3< z$@2e?uJ-_kGx*+yg$N;{1&L0S=+RpcHBlpaT|~4ftFwBEPIRJ2@4d52B6{!L3U+mt z)%&}W-|zo_ukZR^m+RTxd1lU>Ip^FnyEFGQQ@g{2U}-4Q@CR?Z>}AWQfvZQDa)$=m zk381G;2SQ0Uv~zYDJCW+bfkfJ;dneFJ^F7(MEl=xZ94}o)EO0!U?!olrYimM2a5~! z>i8%CcCpQt>MAx_P9ZLAqqM6gQRE;@Gf=akIqOv)^RM^mgT(bFV}wqRb<`W0fgK;3 zG0drhhHg>&&#NXo`ZYQw0;rOl`R$KrAj?&GCKwIqq5`3w1ht}F<WSzzc0K3cObiT!m<77MJ@v?sN&YtqG9k!_ zj^Z<(f=uFc_>nDAhOmrfvq-0!G>UQI!HMq!hoRss?J=>4-tT?MLuCc3t8YWjy{RN4 zVHuJ%CXTWN#DD5DTw=UM9zdI00>q^meKVP15+@cF(oiG*IJshc{y<_{Aj!tUMIZcz zc5wJvana=&j(srnd5bGKhzqU$9R8$UhLeXO`KECqM;V}}*?>A~@2DT~<+tAoZ_JrJ zTo~E50N3NNYwYUUx;7umTB#$2(ARa@#L@NeRBFoT{M>j*JP%u9)?w>t;6tXSt5}49 zGij|qEnysoo1Yd45m9`Tahq;&1`L5+gR8*!H|xvJP!QyuKe&NP0ZMC^oORDeF|a-ZupkU+OHEuLv-U;($eM=7X8F~)Z>v9Jp4g^dtQC09-QuiOgB4}kN;Ik@eS0=eO9WX=xQFeRcFNIU zF(f@BNffwR_sMU$s%MVfHHFGX!38iE}UgDx{qq=Up`92xOJJ zvwHsWNi%ZziPR_6&_`kB-Nv6(r3}9oJXoVq^?#5{-$yU)DuqGpknt!o zdQN9lx#S0Kc3K6F>sVp}7Bnabi6pop1D`F55))ewr~{xzj_q~47u;@2JWdj4gDFlU zx|RWbsL6DPg-BkhONJS~UJ4ALp7Za5!e8wIKhR$AsL2p@OTQR@(Ax}j^^2bedb><6 zG_CyHq!P8A+@|^Fd*@{zdS$Rz)_6w{e@3%xXVXZNWE-V-Cx3$1a_(xK_Qgx?U61#s z(~?2G%XI%ta?7qpdc-R=O}=)&Q*6)# zMVpP8s3~}Ep6Y=PDNBw4FU&2CySLPQ9AFbXCri%o1?wYH1y)8HYCFpWH9V_WPjZoD zb7@89wDiX(ltg@o|20_0)^%Je?^_E7qe1Z zc!&bJ3%CzO?)kWuDzYexM~Dn}Z(415Oo0_vOSKB(b~lXY;3?kgVWy^}=lNXXoX4Jg zfL&xa+oerqG_h|RuT4^(ZFF?ZgL(>Tb?2kl8enBKCOyDOWvq6NZn9aWAaJ-%m!JOJq^l3LU*Bk z5m0Kcrk3BQh{Ku65XfwYt=GB7WMKDjTc)6opf(jZSUXR4O)QF4RQPVfW{im|q%Y!Y zczZLp+}$T`Ma?{DFb6s>ZWy#qL`;H-tDtBB!6t(3Zllb?-}oo$eTuvS^i^nF^4Z4j z=bFiRbI~_hgX%0l!UgRHO(tAd-`maA^lv|LS0^)Ne4sNFu%9HXs_hT1!& zwhpH_d2VQ{4v%{aP`-dc-e4d<#V2`C9Nre1LUEtrnuW; zSU~CetfEIS64x%WNljzswdiLU0^d57ki#SqZxn(Pv$EWi_O4;$nGBEevwU# zzz5}Z1&H=BG%7*HCg)I4T8cO7Ay%jn5yDVf?`?9PvOFu3*!f&sr6y(H#?h?ZkolW4 zSjJP`gu_MBM`7d#l4Oh6F+VgaFEdD}o&~!hjdf*K?AlcM7|=mkTscJh5=YaEPY>9F zk@n*r4P7tt;!^79x^f%;fZN`DN%s*CZa)rj$X;k_qNgIHF!5eztZ=|xkAHx<=AsYu zLBu&kHaj3{9MjGCEEDX-kf$G`U@2>k z|3M^%oY2{1RGe#wwpny9V4Pmvc)E6u=}Bg>RzQhOjJ5%0VmmXigKEfme{*&B0MY$9 zenMh&Wua6t5L`L39kq~oyHk5~>0tW=BHUC$)4+b$`Q?q`Ml3O z&>j2qq4A(bcsI7XL&%@qw|$YkVitHXeo5k&_fIUhGXxORiE-DpwQ-K3b}gQBH;1;% zJ5FY7a91?4ce*O75-}Qi3yPXP&5!GjDjXISF5bLZqhqvc>Q?*Q97hmioT@>M)I#hi zPFf5u7NRC;<Gd!)_mWVIztaA+HONt16uxcPoy3 zcscjAlXNc2cJw-|IE+bsRBtf(V_n84V~<8Q!65si;U}2FTe8f_&%sG%Zx4}CVRN?w zXJSR|l{*ND*@2gZ-*aM?$J8VhkDV828pMQVy%7?>QtxEC)oznS9Jr5LiH;18b9^pe za4aHuE}olq>p1XY5*J22Z@=*tatZQ$z_a)Gv)9q_$OrJ%LILJP#E^?{9ekGbaSg0d zt(S-~L`%o?{yFi>553(!0e2Pbca2%;|4ZHaN96iT=-RT1IRoA)q9|G@9-9DH?LV?w z%718KFaCY}`-0-Iy#U=|#y&Qf_f8OzxCY(^R<I-r^9VT6xMyiGt3b zNpT|tPZ3>e?&Gxjef62-Alto;bxG>}E+y2-@chzopYv-cMUfURVd~FhttC0$zE(K< zv3Sw_i2W>vwvsxb`Uqid`@$n{j3W70VH|c>| z!N<}#&xJ_`I!)(Q=$UHU`cdbW9M|>kyb#1m8(4EqnC6Xu$F*U~R~Fb>{`^wkqNn;~ zhW}7!;iN%~T}xwX#Z^?JKETLx1&36cA?U^A*OhEmyJ1Yl+ct5pYdD&K!;xGN(u)$< z*kZ~k?4l(fSMIJdu{zvT0AEENLy4l6iE$%?hu)cp*7W{?8&Z7v#5?iMNjQq=Nw{=$ zeTH2!Jt=O9NKr{@33}AYTo6HhqCdDZ5-;NO)W>`woo<**8Yh;Lc7({UL>{XTuVlc3 z4l@S_Rm690_Y^>sa5E%5R)PvBIH1jDw92Bq({+jlaCnra({`v#|5Q28ph8yD(7BnZ z%B8G_RclW0S(r>f%=9vKl$>|WQ%VCITjZrVbxg8l*G^g?iZ$4##McA`Nf;spi5YH1+5WHj`zQe!8Cl5W8Dn}jb(j%sv2SV%dWy#CeiG+W_ z+rSKqG>kT7&n6froo!M?9RIM_aRs`W9D);^$vnndIlm|>`o*wW^SKG+oG2fBxTyvE z;rSjgc|BSNs9HFf?T~p zP{1|Ys;8Z=q?V4}cjwh>lW*heALd=GEMDDCR$O^|SltS; z_KCNa>=)m_Q5&^e#sVML(1ob;IG~L&fJbPcC=eLT{1G-ZDlTY z&BU%ZJ$qC@67rXt(HrW;8Zr_tx+AUvqIQPI-gcZ$K8H@rUEVv-Hug2NUR8RTpQVYe zzaN`?kC0u7iE8t!?P{g!DA ztu05Y#_fG^64Vyi^qK0ur)=mC{mwMu;B$BJ?0z%PDKq6-F-LxN5-!6-=3yQVDBx6b zQC6993G*~1JA?ieM=fbDuejN=FIM}emA>s%Rig$JFT6q3V*fJj1kh^^9GD*G#PIC7 zZ@8y>Y*>KS@zqUpcXkEVwoan9K5uir!)%iLsjcnFH=wOI(|)Ys;(s%H3#Xm(jOr7} zZ-{Au^!1fY`oxO$V-q*Xx_f$m2rIzD!{PbxT z7FdQRo;&}HnS6%Tu7pKwc=0J8Y_e84Q8~a?J0KQRMTuffMBUr4(F4RAtFux9nBj)i z^;7UAeJ$qg7e%djE@|kHiy79^ zOfum=+BRVMA2%<(bZe{@WhOcb{ftn^v-F`;QAX2-uceXvrHe2y;U<+|+Tu8-kA zoq=I1|3)!!0pByYe)P}O-iss4Rszk1=5>6~JT6+u2_OQ#WdzXIKlOmJ1Klg=#EJUr z^H%f6Q*n+REqr}_%_VK=^T^Ov#C$5e=qh`T2G{VuPOWW@KsXT}3e9-cUAsMxprm(} zuT>MGx-_aMO<4JHh>g%R&c0u+KH&G|SdifiT9vmFJ8A)M)fq$#N$5s(eVRXq_`AfW zDSou7O;Ioy$M7@tJQXZoFip~~bJp!A#!Ez+$I<_Pf<0ja7Y!QLeyR)SH;dB(Q4~(u zylKi~bT&N8)Xg*`H1d4KItLCD=0ck^fNl^I6Mu*b{4=8k(#{N(VZzZHL!51phqO=^ zaccr>hhXcv7=C&KX%fD-USmSDl#Trq@57JbHuD2-fszavXF70OTkasxG}@nx4BJ_1 z{()98GwM0Mn>wX|-Y(4Qetp3mb-9T*)@TRA{jFz$EH?A_J}FO`T991|prJXtR3!Ra zyzs6*8SX?bRx@2&An*g{3*Q9ITyV|L_m9S-Y!LqFF_#Le}aG5!rd+JFCkhW$)lesF%! z&KS3i{-cc+jP@SUGi6A6ig6HI<_A*i7$|-G^IhHc)#JyNO}kIg&JnvOQXb3lpbSoc zGTPH}s66N~7uvX@`-606&T&*=W^|Nd|0GP=@Q}B}gPZl^{?UmC5a~&SE;KOwa%GkM$YWw;(Seh?CQPP4ksHiuw8j%>V!Bb!EW8eND~0 z1>lR;d$5+znHTIvo~=VTZpVy&yrPIG{#5*1<4eNGlO2FlA^HqIxW!J#0MIUp8Xll) zEu?9_HRKUpb%&l9JM@CdIpgNMiKkh%Iv{Z-yWgXVZW#C4MGuL^QXY9gGJ>FFzvI5A zhwW3R`vAu198Jx?D7}eFw?*-5tw{{ViCQW=N7zRyLMz3dCE7qg4}whZZat*sIqX6= zi+N4shMMj>3H`KQ8A-Own_Gi5)fo(zvi{QYl*MlQtJTm{(w>FLwlG%UZ%0({`~cT) z4b0Ug`$GfXiy*4|7v?|Vq^+rc$k;E$FVE71Cw>w~#fB=B{J$Z2SLO6h0!14~>*-tR zY;DaksnRItaI53!s%Je>z|*#|CClX?$(Ob*R1L?@#>pccz)4Q5a2}V-DPC$C8kXSx z@J8h>*n92Q!Mo$G?{!o&~q_P%t*8S0jO=H=c@&y8uPdnMn_5!JVa9GB?3J*m2>5emL5l~)A-Ij zsWu8Fj<)6l`l>PC*k}59o{I>lTUwXa7=6b;m2if^f|iLMOA_?r`v0x)Zv(g<{uTC5 zh5HoL?f*%Uq~+?xMWtMY#Jg=d^sL-~W=`=tj68~3MBKI_-1cLjTa>-}-bnKBI_D7i z;hb;QUBy&KOviO^df;;zA9+5jwe|pVzvmpqT~aZ!xpVAV)*+`rZe4+Wo!>4WJlJIW z=~+tZyYj_X$n6zE!*d5E%msNVTgo4i8uz{?&MSwQ?k;Z$swuxt?;-9geSn36 ztU#G(vNCY*Pk5MRfL~uq6B@n0!w-8K_neiORmGZv>W`UWRHf&y_itcuDU~WTbdV&} zv~FbJD53@&T=-NY&vfEr4*cvvDY0-vS^B5Ku17d~-a$PT{v8Z2STB*n>a+E`Wic~l zHcul=;KOh9u|{f1coc3`KpFV*;#^|l2~)#AX8ayJLoZksOYM%>O<{~N?d)6HFjA(0& zX+YHP4-pnN3=yrVvuZ!QfMNnn!a++V-YSpeEm~w(7}}hH)xZaV)VE0;E*Hq7p#L~IU|GFX*S(kJ2P{(^Zroo z=ic+${dR$}BNw$=^p#dum{GksfD+@6FcNpW5xc-RPVKW+7+glE>yba07Ev85*sImz zoaGkFZkn+x5)><`M@=%jN4s!EH5^I@@2}|oarO*Clw*0a1aI+JYK7OA4Cs~81Ajs) z!Ih)6$MddEl%jNvS831k>`7nyl}JHbwR`DAJ_>(!zk$P-_�x5W~bcks`KzFk2=d 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 literal 0 HcmV?d00001 diff --git a/docs/doc-images/bind-pay.png b/docs/doc-images/bind-pay.png new file mode 100644 index 0000000000000000000000000000000000000000..194ed245304503613e35a27e521220f99c8a14cd GIT binary patch literal 44954 zcmc$_Wl&r}w=O(nl7RpdNP-UT?(QzZEx3o^?k*V!8rec=91S`l%fDs{x0002|MN(7=004mj0H6^9@byhiO!Ax8 zpSLDLvO)krRRq$b!Mj%(&Ou2+7*IZfyZ4F&D9EUYUEkilsW^s|LD<;XF0XGcuC8}> z4lb|mUS3|VZf?&mE>F(R-5YnFUtZSMH@3F6c6N5w*4Cb%pU1|=j!#Ym1O)c>_a7b} z`}+GkJG;ci#cytIZf|d&o}TXS?_FJ84~~w{udeFq>U4E=VVzye%PS2Hjk&pb^78UY z$te>P6Bn14#Kgp*VPQr_-%n1@%F8R>!1PT`O%D$bm6Vi!{tS$Wh&(zuYj1}EDvnD_ z%bJ^8($dmQBhHMPag|2YfNlhZ0`|XIfSJ4&}=-^w`I5CZ(TuQ(boCH^4a?S)j|1ML(xLP z_{rk>#;#e12@emCe1p8PYkJlCZQ|VZ;>mqw!^rXNGg|eDdvK|iO=A1%We{wCZh5D4 z>U3m(EVHC*_IO6N?Tm<69kz4dJG^xDaM{{Dqt|`Dv9WFSdVi3GvXiAw*7VCGk^2u=@zU&<9)^F2s>a{cPzlfR$r?o54 z8TMzZW~2&e^`1#)6L1r#<)*GA$0u{4<2GVyJE2CCdbIlHKkWUoz46poM&E5tncn@9 z7WrZ#ik&#mL9K9kd&~2Yjyqv4DYtF!`Y9-(j$S^7S=lXevCy7D>;=|_H@W*9;2$y? z;^bebEavnQ<{(39EKeZ(E9p;)1kZ~)lMIAPz&tQAwb7K8!PckfWp>1nCpyqIK^j`* zC#~6`Au;T%nyaS|KE6@PYJ%oWbFAa10|118FQP&!t_z3DHl{c?c)fN|?tJG#;a~f| z47h+c-)eL`jTJLW!z^|Bkns;RXZoh!7)O35v4jSoCVoU#io8}(U{;-gUxu#aSTa(T z&22zM)6H`eOz>QIsf9)$TJxFQakY=_UTK(16RkVoAH~QhvgIU zW&l9ulm-BZ1OWgO0DvF_0FZp0$7TRP2L4SQyP7I{{c#isz}TaaDK0d-eYOSnn$>%- zfgTefuw{mdB#{uGJ#HFy0|s1M*bj4{hxCdlx7L2K!1~h->(Xmer!=YwxOq9-5xM`= zRzQIBfdRV@2$;*j)_WXy{JnZ`B|oYc?DLzsJ*#H7z+xudLDObqR%xu&S_3)X7Q-B7+6BQ6v zzc<$G)Hx#uv9!LL&>yVsBtWksHL0nl4SvrQC5id{OFvcGqC`3u4N-3lp{LGP8W2b91~XMfN|K)Msi-O` zMLUj7!^T*5C4~N9zOg3h?0%rjFprPGcz0EB3wym_JUm$NKWeoJ!GN%8F+Blf z)!A9KouB!zrtEAqp6cc-G9m7$S!=W};g57QF9)WR?O1GM2S3zlai_UMof*K*x*p&$ zFp!So?F67bF_~+gKu}jt^`#u;fU2=f6(sg09w{3GID2qZzt}c3$U(!wVqf+AuptRe z10XPH;A>t<|HwQlq(AIx*T;SRlzIYBWCW0Bb}sMRvBcO5{LIkqeriG}mB>lV%pw^$ zASeWU?x`(q1ZMuLdW#`AL*Se>D4G3FJbe7y%1iVj z4b)w{E$9!f4_IO#M)sL-9+Q*`sxvDs<(*j4mD9mw^ z`P!otQYX0IA^ui7m|dwQKp3;5<^IQs54S(`yMzeGmh}%gf<}>i6}Jv#DK&ME(BC`U zEUD!uHb<#WW|DrVPTF4#7HwFvoIOa-CtVKaHQnj{yBa|aHe9!cAjiTn6*!EA$>HZT z6=K5w(8apU7H%UM8nHKo^R;tuZcG3jw6LnLT1__=tK#Ht_Qmjgt;I0?*Ix+)sRMmc zGWp4)p=CKzz$aRkt3M|y49IBkE%+}hcC9Yu8iJ?S@{&l-9EkTKFzhQh-zc>66y|ov zQ75s3&)J1Rd$FVv)Mf5RN)2bHia9#osb)^x8bvD9=nqZ0u1w{tEAD3&OQ|+LSEflj*~1-RGM6~Yg#T{?nl!sGynQ!Ylx@KiS4+>>8hwx zUlOw0?bm6fRIicWGl6z}RMV4<4y^h4BlZoTN3^ZLv^D|c&~j$v)3-vLfKfXMrjDbC z%T_6k8yptT(JuFljG`|w%T%9e_c+>bD5yYomIcmE4uzFxCw2<0#=eiIa30~Tbb9;F z7&!F~(9;pi6ta||WQ6#0k4g*rx!|B7nmteQ7xV`$)s<+?*;>FPIu}WSd7e_a`SOzo z(!-rIA1PS-Z#x+{?3r-U3}`O3nF~sRf?)X4dm=&B~W3l4&m&9jkF65HLzB?yzF-WxGqgb z0FG)He}4DssdEN7A7sFQ&)-Dv29nEQn+JD|O4N0vT*{G1)9%VzrIJHvQxar(xtrd$ zkrmc`fBd9z{oy5|Tsj1Iy*4lp*`TOX%0z44<$mpE2mtYfD(3F4R<6`zyvnR--=t6lVoG1EK<;GsAs)1LL-B~ z$A3t+Ae!?bysw)K?q&6bp?(iCeS-r;oAO(ZBj z$D6a+DA$jT>_?Al<;=!s#+y5Yr$KzqQ5xnvp~wg%->C1pE=67~A( z_lIY^;xIrc2eq07#F*%8$o4z06YX^*3-RBqtIISY8oT@n&QFjcNH2W3cJr z%pY*xIjYhjTso08eirk163Pz|wBzeWrjNU1u*y>Oe^La^)3toz_Mb= zVk;Q7$ilA2XdBtNSZ0-^Ko@I>m79s~H>`4Re-iCf{T)s}@83lA@u}^lAoBnSz`~p) z3f$M{XFXc^tIe+?Z*CWf$)RG%oFHvYYT?7VtH!>052|Afa_%G?@<0U0E91%BJS_he3=@vV`NW(vOT)XNRIecH!3F&c_cwKmD(|NxQ0AG?~LOV4r z53n^EBzsMuA-2aM6UcW3rE*!v@_5UwmvHb!yd5x%EA?6TXr2oV){{;6} z_5aJ{3%p$7$e6UIr#*;hJ)eoVm*;yJaLE`yk!fi?oVq-$wecCLJ3A2gaOKlJXyfU( z$_Vhpc%yYQzOm?(r<;jFM z{o?T_Tv)DyzypEDP1}TcW3Sz#pN;?LcU*y=f+q#S&LkxIOy5Jk=R{#u=WEpU^pO9C z1#v>N}_OTj!YgjJW3^-#rBY>(YVALg`&XsVEvIsB-=hy92qLV+k37JGE zbwzgZpA5--s@=0LSju=Gtdzq_ZjMUG5jt5|OD`jTkH0a|mTvy?&G2atA%HM|`qw=q-esWyz{gPSd12&1KzA89BSm|~9qieOy-@v}x z$igmRHJtSB3)2(;z->ObCjNmJn4DvBzto8tjEI}pH5Gs3Ff6tao)00_&dDiH%o~F5YAVtrwtLm`0Z})DhP3o$Vd?m7oqesBb6uz)OJUweBZ4KZ6sDy;$g~R5OCOIGi|p6 z0xFp@rA)N8)0BF}41Kai50+<(j;QQIvPSdSY)`Y6Mn8o% z0`AcF@e0IxKsuoo^|&0n(PbzUHi-mGyZM1KC4MY&<*ffc7zLnMvxQ2725?mxDqjKH z2GMxSFk6*$D@DaBM@(V##pAg?7BT})XAUqB{NFC z%me|(-m~?gpzJJ8ty#^S&REde`_O{&o`kekM!#k|DMH~FXnZh3xkmcFZyOwy^n$xM z+f2NGef%C84dMz;rV6h)N32 z>fWWxUVeLdkPK&KoR-BL6*U9%U=u$86T>NK` z5CGutD=^7f6nstj8SRAR=s6w2X)lruq7jmKfS4u-y*krFWhmy~ho9M=rY~2A=-*Yt zNZ{&cG6$P_8D17C_>;7EU!4YkI}~#$&s40U>wF?27pKG8S7t*!Py5;F;QxpAL({%N$4_{ns3x<2T zKD}^Sl*2URLnA`XG*dGieiiig^bp6cEAuJ?2xZzd4o~zvS$;|UmAKV*Vs*5p{SK`^tRI>c)AMIddW;JfDgwi?b zAJ-`su#)r-x!8DT%cJl#57c76E1{A7G8Y)T)Y}`lomoL;%BCM@E~Y%K8jC~`aBo-JVX_tTxdvpurPNYdsJ7b7 zWg#EcDL-!b?Q!%BLh0q@1w8QlkOIU>oU3+^0@(kU==MC+-?-C*Nf?AY6v zH-Hx6VQrjDK9fliFajU1JF(mISppZ&!8~(L?ZPyCmGDl3GSJoFkM$q3zBb&fJVw*! zes0^D+TYjRWy=!UOyoZz_2V+M((c?fd|b$(JowbY6pq6?8YoPp@i&II0(R`2I??~f z?8A#ZfP1y`F2l@=KN)32YPlISxOZuE8Z09sa{v6pj1P3*_Waz$<;=LW`9U+YK=5Wu z&cn7%HC- z+$DX5z(3#VR|Nn5=m} zsNP+C0I}?Nqe!(+jXv{td7#nvn1S*MC|;4v5&r~C3BYK^M{YhVzIjO1V;VPYJ7wEr z&WMPPiAhcTEz4VR;pW4i5i|Tt57&BrWyRI%X|t#2x=x4c-oU|uC5OI&M2~v+VdM|Q z7F@LDYNeeA6H_x~0EbYEsX|yAj;YhG6;uE(raQzRt=K;$_1AR?TTz3viE~((Z6%^r zA;?@_px5WuzbCPDlD>l1Yu7WLcn8m7owG;VaY^s_T#yLn_h>TfZVSM*#58gocWMuv znb~%k3m}Wvfh#zF==m81S{Z(d0o}83Sp*gFkyw#@lcn#oOyu~*ipoSIM!>wS^zl{@ zX|bwkLa?KJ@s9Nm1~O=e*xb&F0sNb+TOI+;rvWRauG-kAPPbBhUkIXo;qjxfwz7)}ZQ zZwuWcO*M2{jHgS7jhdlkrqs)%SxcP6P+^a5Vjl3!p?3hLGvrd>g+IX~?v5?tVI6ql z*iwf!OAn7Uk!j#Ob&v~d{g|)1*dC@h0pR?YZ1Nylz66m@XSPnO@4;~%9sE|t2u@{m zRnKBQ)%s5S(#ncOSxV$o$ohxAI(LCa#4hT<7_wy6bXXZ8qJ8lpi^iHxzn)DbvUC6XyzZJme>^Z5|0#&54_La`Js?{$w z42`C=;mE#o(s*buVf)ae9R;D11P0e{E`nwmbQ-7JBg$NgdGeS6pI7NYkj9>%b&2U<)4XS z#Ak|{yD@4B-N+wB)YE5g;VmiSk%gA6mbdae9Pf`jESH?D;+x{K-Y1+NNI!oI2eN>5 zU+#7+c)+^f?@@Sr4$vzWs?7>u+wxo&^4ky_~jTRx>|{hc2ZEH-5nA(E}D`aQR5 z$xon+jJk>T`UxWr3`yXcXwXtxdg~H9A0Fd1qH!B+344~SD21(_pHn%PxTP;pEd+$| zYwI5TZBN;Z&7(=s$q7i^uQYN(oMh(v6~(Pfhh3s)pqsQR>y z;?#1nA_>K#!h`R{2Z=9cjwib>nW}`OfB(QU8|h;8O)sxc5=!uka@XhVZ}-Iziuv7P zP=fnB(_e_(O8B4T3K{e~A?5xHwEi#)(wSTx1^h@*`};3Y3d76Kg;1edwcB#H)-RQk6VPi}M62=;FO7G!3-8cL%a@b>rHP3s>nYEZI%FVsBz%OgV)t z{0jdusmfSLF|OHFF&~g!Vq9f9i{M{!QYRG`a>&Vf&B(Y2iZ`j4Wo95vplT2qwJLu) zbfnyrNHn$|alrkvleu7=fWcOPmhs_Mo8~5s_0}_287EH3?DSMsA=%SBkLTc?qm8Wn zy%aQ_D?!U$Cx?%PzGQ_r1xyN@#7IXg;yH1_nC zjuad}h08Kd_=;^ZGuepG=dfau5vI?i-=rdBu^2;!E(;M%86)1*D7sB;RqteY`?DzH z>QIFoh=6Mnj-;ZRTl-(w8?$ z*iags>3r4v>e}G+2KMP~11DNV4OHoNRW&@H!?_x#GV0hrz#G|W@;wwwhBD|;xnBfU zJ#Lgu+MLRL=z(?H2f_g!m$6NLdvji{4NA47^L6|-g77-C;h6NAjt8nhv`+&_4(iHn z7mFTTEU%dJw7hb1oGGeD5r0i{pH+Amxr_46`m49}sjaqh>|h^S9v!!?a?OQW#(hgR zza<0+is5~w3XvFc`)KbCd!1rs>)eyI^iCUjEN7N7e~YpUkTvv=whZUy!`KuTE zh^d}+_m4Y6Xx%HP7y!YqxqkIEUQ-ZZcbYWjyqk&4ik23-IKF}z^DE*JHP?5)1B|>p z7Lr9z6-PDU0h~+gCcpWYzg3V56Iqx%PvL{v9IYyJ7{}J@N9>MT6uPiKq5IH+zsZ-m zA)F6ubpQa*>D2dD94k+&&YiPfZ`%7W&syJq{;x2Z(aV15mf#&=Vl77A94=FM4*HKx z{@*A5?1br`^~nEw;QvCX#9{)9KRuj$`vA4#0H~x{{atfySjGnw3R}CpbBXhQ5#R^& zng~2$zUIk~x>I9;8q49Y38eKRMayV|cFfwGdWR{#rh!cEWZ(+rOlZ*GX~eyI_H9I4 ze%h>-(YC~nT{Po~Z0T7^rI!kaywQu)d&Dh67z@TY{4;mE3Fp z7#0W_dBaYPvhOE+QP6zNzh*HqGPi5Lz3c#AjPIJ8u)0_9q)F{jHK5|nR%;K%t6*{t>VW#0BmCm(%MCT&pnK7RQfcT!P<3EztcdOc#BSVM5u z^TR)-3E+cIKkn`iZauzMa97~wPGj9Yd=K)V_yNmo8nUxJGr)D#Ver)fr!1ZDj^_7Wm2>`m)~ILeSXIaMkvw4cAn>B-pc5dv>n6 zr9;cA<|c#HX}75?stS?+t4w}vD(a75e!8@Y-$G6|`o%&3d8@8uI^nrA8tnLoOY)s2*5&l?oX2e82 zq>$M{)X7IiCZe98U%GCWTBt-e-GF3+V3- zrp-#f`10+LkMDBwC?_79-dr?u-lQ&hjPTv0AKY;5Oyqu1eN=utnMH3oN+8=?Jcven zNc3tn`s06s0|2zYOo4`bLxF=u1O>Y;RG|KJ;qiy>)sH$x!vg3>UXzoP{p}J*Q@xRJ zmH>b+*t;9^!yin!flP;6o6aAh-w*d}(#UYa)X|T83^cq1?bh2b-u{D~B~YOa$7eiR z>I@1$K%b4f75*k~!@W?5>gl`ynJ5?VyxUe0|Cy&S1_lJYnCm?TlYa@dt{zOVBC+b4nKRl_>(oc>8R+-rNiKd-dj{arfUrkeq& z|C1@ORnkbjTRxz|41j=koBoDqxXIV9{+9(`j5(SB#xMZRy?O|oc2Ff80Ko1G)_-+B zuYmSfT72%G{I7xkQP6({zIM?o5Y}BS^i5$A%aS_5eL?QniY?Y*`z%aKZS(M_k_(H- zU%PPywSlSYKC$$D(eQF1=~p^oX>k+3#8kwhh|Z+@{u8>@M2QpVfwC?gGe3 zQ*AKZXwRZo@Cwo>H~s`o1`j}=H%ge!L}Y66{+fdGlxun=uEo4tGk)fHh?C?z;T(oD|p!a>U2)Q7~kKE7ZK0=)BK zM0w0rTMQJvuV3kwON-sV3Z1PnsH#%83144+7u%a2$;rX9AX{k%Va|Xqy0qk*w|+!A z+7Fyfk|Asq8(xRVa>yEeGU1pabP^B52^ET%|L$6NdB^HD^lbSK-?XCd$|IU-L6EzM zyDti~`K!^@w$C0d{7Wzz!#f}gNvDBdZXmpTnzLo*4{mNTb-;Q?M8xSI_HN00S50XN zuds2$oaOF;^m2QaBtR3qj&=O|etsrt+Hf;}rTX8WLQh@FdJR;gWlXMPCY_cF*GkLE zrkgbW=Bf36lxb+(jWmhqT8*EN{Y_7E*G@Y9IoF$a<7_6C7 zO>BgNQ+U7N4BYN(03bMo+dc8g|@3p?7ys zZEFXXrX&vvc=!7g+>+Y37Fe}>b*g~%@#GP(_27DjyErmowLn8yWkjDKgw9-t;{<~S(`dqPL zhGcFnrV_T1Ynwt8InjJsjwb(w#oiX(3RIkiLl)!kVR4UKlq-ESGBdQx9G7=#p4w7y z883E4en7~He63dZSt4v{N!8@W)u`qS;&0~PO?+ss_OVHe6M~iP+H+#8 z7(ZKQKRa^E`a34iQ>1MRF&P?-?@?39!+P@;R5aiz?)SRIS_lSC? zK@q*=ZoGCd!$S4Q*tavYvl2ZI_cH$9lTA4epo>wavErDGg#!x&pHe^emnUqxFM-Sw zI@cNVYCH`jpL=q*e%Wd{P&%BU{h2iKeOh0L^8mj%xWsc;i(+lLJ;>g-&Axi1s~p?W zLvDErV^{Z7*T<*qn|ui5sX&sp(N$fgJ?(o=uBjoh!6?!7da2aZ*43REuPrW47#w`p zs4X>zn3XxXS4`!jbsEpWv1`b**$Kj&0;P{vgFzye&JvzI!Bu~HIjK)nK2VTr{H}F6 zjr&ofmuffB&X|JwQ^Ss38y=6e$&Kum1(h#syh$%N(d>1EU+kiJCAJj>jm@w)xazHxJn#MN*gDnjG)z9GkRT?Gh{=>jtM~yMGT_&V=M^7Evh3PtPRF z6spz`8ef)vQyzaCoIK$ZULi1YI9aVR z7`ED}nN1hq;_TzU^E<946SWp-(j1QekP@rAXxI)SFo&KX29Rq~Dtygq|8YNZZeZ|z z&&(i+UchVE&@-!EtziF%j`b{{^L%3V#ZdbFw3b^^<#o2ZnUz&RWnIrI@m{;aJ2EZ)TbEM_xXr9vt3xd=8&pT`-^tN07QCGO8Pfa zg}j(QgsbRP=!umEY7po!8%@=2P$*sItyl7w1lH0nQi79B*PS#@DOtW4GhaLm+t~B( zc#bl>#b(^-Dmxas`3m{17#S{cmZk&=Tz4Zs(KZlCBuD=!f)VQy{PbsP@<-j1$o?)B#9q&G z`nw$ME)rx`_jY6Ak3P;U^H?Do`a<)Cwg0dg<0m|j!;fKDajR9429;7GwOq`v-ux$S z@S8a;r1#ELQUgk6Y=1+-*!1F=$Se|>LG(nK#qZ+wo;vas@yXYIlxP;0;dB^W-h2Y< zbOy178QW)}IR0hllaF9SquSCA6&y6fU*Bgr?*07N?T1J4Y~i z&GAWicuPZ1vSDrfN^5Pc<$*7B4}28c3vC=6!?nY*p8JBctq~d>KAL8;@$sW&G=at~ zs+B%x4(}@^SZ^(HBUh@gv&`oz>RE4@%qj`zz>wFYCiPQ(eOfvec1|V=jIr7E0(@Hb zN`H!)3C7Snb+~h`nbI%24Ssonvhp<=ojw5n2Ur(!RJ#V|JURjC?$M7$S5;gAx^0n#h`#NDG|-8{I*u7&6@3WjvYo$OiS-48xv#yG4x6Yaw# z@MJALDQU$xE&K1ea5dilyhsIotOI=c8MJRG!r0z?fl6I8(K-kL6U7je07MN-Y>@%EO1N?CR)^1Eek%eT=hK3XyTJsW zI03zVh#0qII6Gy58| zGUmn4Th3OaqnL0SMZ=uH5CGRz?95TJNWwl8kP^>&y%ShZw2U$acDfDOhuE*L5r%MC_EzQLzf%se#I`M@C) zP0ix_!Mm{`4a^$k)*%Ufy=6+J#RY!6Wfq%|W7q<_5bIWOaFFMDt>K|=>t8Z+n4;t@ zj{l_GId4QmH&nW#2FD7brj7=I+Qhv*%6p{Cn50MG26CjWXnb=q<6P*&5!B zt4E}I)H7y+3Up}E}DcJIP zM;a_Z;V~5KWsY`tcX7YH&lPfs>2=wnC#Yw72qU5%eiC2>NZP)+eezc8$_U?B@X11Z z=P3$h{}tEBCj0G2KGm!#VOW^|K5M2%v~vo)sADnyM0?qkP{Q_~l@Y=B8&9@anAq3@ zf~;d+ol}c(Is#d4r$a_^I1I^pS@u-0l?fBkH z3S!4b_yb-ty01~6l69(nGVEvH#&5bLglbXkxQV^X%(vaX21!bUjC4OGq`RkW2acR~ zq=!jn7?amJpWva(mQf4ue&9-zX3E;aQhghujkRT2ql2eheYp|>Y}x^4?P<^K1u&t! z^SDlf9pwJXfI6+BF>`pcN+(^nv#mkv?+1ek67W26%SRbB8H|B*?qCM$CL{P)-&el0 zCW112(Q}(I(^hwwf}&+{;NsD4cXLc234JoZI<>BCTs40bA0Ei^X3d2W9Zup-fHS-P z@-=Q1@1tdt(O(JgQN>2v)cn;7HY(-r0k5chXPAo}&CAaM4RW_rkbQJ#9*9a4=t&crdV&NU1- zPlCLPFLMvZ>KTC{B4cFhwsm{t1&iE-8QTsaw_?<65$YSJ_dlY7!dP9oD-3EHltjhi zS8YAp6=^Y=665mWPL(1ylF)4VKa#hKL6 zSybW`ZO>v(K>n*sHr{OF^Vj>huimpwNrml31t*qR25?z`bpRB5I#}ukE|qzkn$265 zDRHe2zsI}X1aE;-ezoP-)E#rKU-w!qufgZ}QO$i}Rku~jJm5%@34Tjo+K3Yo?K5LG z_E9x{fKjH(XO(dkt%bKwB+OGpv9gkPDC9$ZMp$vF)eFA;6s=`oYcp% z%o+o?Y3rMOx8=+dv+ZID2IABA;zfiv!JF(hnL?;7Hba?0ej53S)AYXzmFLKwbqbl- zWofwAr!tW?_K`E_&GgJOg_^n^K)rfC6!=v^m}^nyYEL?LMa2t&NO)v8uz6X zX9mpl@$U#cc5Y(C>)=m=O%_v+#RFg;vGMFQP=kPMXml_F{j7tCw?0`4wCHixW}WUW zv)6T&&0oG*dY&5N)L45l7JZpK5@Gtq)Wh18AKc>~c-=3sW=!Cscb(@0;}Za3MPA1* zIoceiodA)ClFoQc5>IwKeNuBAASSrBJdZo}QN#$=4EKb8ln?3zrGWA{^fAB22Q6>i zhinpNmU ze`a;{L?6V@hYe;>&*~i;++ATd-h4Ul@1YUTh7E6i+crGEBL~)K7!fmkEhx-*ILunx7mK~L!SbIK&RZMpY=Nvzn4}8fxdhpO=0lL zW_3;XL<&%A)9Xq}N$KCzI+Abe?6q-Vb!Fz|eZ0-1T`e8U%|o6mC6EnH&Or=ne~Tj?#qq;*EMmu-PI#4#)hL4@?=O8JOo$ZS@@2vI-K070 zP@E~%TvgPxllwa7N!Z^rK6`i>)e#2?)4Ee3Iu-cwA{BSJvigoFBFm2AjWbtLf8G2{ zZjN9&VzCfz+OfsV?({dvzR%t5seHoJG9sqCgSv05S=F!2kgj4&>r+9cZ~!mNYJav| zi;v*^X73@YN*uedK#Qi{fSij&ko%z_XXPG>i@kS(MRbks$8%OwTdApww_t6W^AB(O zsJ;b)G*TigK9cha+qKON=Q6VG6SI!_vWJ_Bn}_JrV2+eXnP$F*wX^aV%l{^s*ip(y z@RUNODIv#tQZ6fk>n1Cf1MEUDV&vmk3~mq-1anPTsyyY|Q1@Bfv{ERW-$x(9J1_VA zgLZ0IfSqFm_RGhLjVzEO+T-+)yzXT&5rm_qrFHNL>@Au8)l>l={2^U6`^>xh9MRKR z;q2n3#DcFxKy9Vhm;mo=oATG=YC-RB1=fpQ+dO#U*>x=`=mV!&vr^T?@4tX})(3dk z^o61BxDd&eY)YMTPuH3uWs=H%*FUt-VHafVQU=_>G#^j>yLU`F7V7 zY&JH0r;cQ@9+|O;+{s8Do6#GmpaBVE@TJ)`f!@R5`oOA0@UbXowan9VJN?b2pCP3i zNx%x*6@8LB8d>a0kAsw*Q*nEmyHrGUSl)r=W&78zfEW{9 zD;YI@Ubd2%d1Eosn!0En%;jnn7QP`Fi|20{>jG(w4L6SBAx0Bo!k?*s{ruoiRCF=$ z=E}*n)#`WoE+e~?{f92u$mk?$bfex4PWH?Zj!9IKZ($Rki)(E)sfioQa7+wB?{kHf zlN;T5A2A|@unW3H@k}chws^U+it0*eT;3Fg<-zF)mmBb<8L7(C7bmM6PsQshT0Oq; zpXA2su{bchN8DKUdZR-Jo)}wDiAq;u*y1h}nbwlH^vPm@SQ2Y$=>X z-Z5CFhjYZOE?+{#y`q{@Ws$2>nvX8IMJk>`cT82}c=vVl+wZDBdP>p9y7p;r_Pr)~ zXXq1bTdf%q8iuNM>U-s0@2aCQ$c38~r9i2308+ow6~I-yXNo1fd$LCRD= z6}`$t|95ar9ep*gM`W%Ik|+?56pZsxXC_eaYKDF8^c~l};9PBjFRac5i87xOZ_LdoP^jM;tBAVFw zwKXJhAt=mSVpSG-l7#y*x)94P*R(zu{4RYY{ZYGXA6WDFbVGQjoNEUqptLnEZL%?QSA%P*s}vEWu{r z2duZ<@0^e_J4hH906Hmv6ZiW?l`r(U>WqL`_AhHc&gIoFLI`qKW23D&k;)C%H9_QO z*yZJOjSQCOTU<(%AFut-s$hEhEEz>9wYTv@Qc?Yo%F?5jjv|2MiHA5nhLMDxj;yid zlasV;)8DUCxN6zD4;-&y>7+_L{AqNJ@raDR1w)HAZA*(ax^%ay64e~R7zj1)t+})` zl}Hwzj-AL7^;BOMc)nj4IjUf|>m#a=aF~BEsT@K-`R&>ue`4eiLHjK?L#ySrTg^_Y z#{<%s)4QJ-Z5e@+9*c`eH;WFf84e>Y^?V!oC=@R?*oQ42GKm0yQ}Q;;Q}PH)!4uBG zB^Tp<{!ndiufe)3mZF0%@FOKEJ~Cd|xw-p%BJyYill?p3*3F!8gxb#cZ(cZ5-TCiJ zVIJ|YSK{#yPGBeQ!^&f-1be1hqVciNNJGc(ixGC5+<4R~n0yh=XroF(B1f_urAbsu zigPgl)$(j zZ0iIn18Qs90KT%PfX!O)oR`U^)`6V$-Ato2@M_U=ESoX1&Tr7XtkFA0m*a9;&}Od8 z;l*3<@5}wyi>tAy@PpOHmrSK;(%ilq%zykU;LZwq*&S|C9# zSRAv5B7~v`Q#>pBgay?s=wuyX00SE}Uy0kcSvAiKz)s1CGc=4@keC6jtBDZIC29>M zqikSDeM^)B%_ipNoeTI^PAr(bH?#&j!~eQQ{y!KBN-ax4PeMfH2s|o+X6C~V_(&K^ z4`^vh)Q|Xo&92UC3{C@#sv@DQ{7{&?~667GF zkiQ;@Kcc?_=ikNB;-R&m@lXF3Xrp@(J8-HR%4F3akfgHMd zsZW^}oLf3&^Ywb`(umjUdoV}Od zvauAS>yl|fxYaGXa7kX`pPanPw2MlPpRHLXDkP&Sg`b`N(vgPTTDT3tWd0@iKkoYW zE1+;140|q`Q>6W#iaR-*frSmcA>`shnmI2#qu~3cPKpm%jcsO3cS>hdmdp?)){KwB-&iNI zLAp!?Jbk1gJ(U-unnAG-VO9cJA*B!gCgxX3ru4>5;bMIAA)r5X)tpl&wc}&u+N1Ww zO7BZ|NWPYxlZt^2pTx7|js9{{Jej^T#iDChJxf{r+-ljOQ;yG(bye|M&&R$|45Z2! z#i~mb^SY~R#l*$s$GzP2myedOYG0XS1#@|N?lZ&{HbHJx_MI$XKfYmd7O+O!aXt*h zz()Z~bbp{hjtde^SFiT5NXQq0=H!w$FFm*_e53jJ-rBsOq%r-3L10K763h;I@{6RZ z^PCcoz(LG>f1p^^_doG7>63)i0Uri}@1v%Jd86>CX;ZV&NG7P{B^BitiY9KXP1#~Z zr0{H43@5YpviRsMt}le1AS3bOQ+Q;*@{hXzNjh8}t{7E{mXzQmBtHuB*xgNojMG>5 z3*U}jql+y7bh-A++nx;GmzC&yxO>JHHz#@C7+kF!AgD+VsTX$H*5@46UMr%>=BmWS z!X6u|tEsj!fGodB7|Clqz0|WSRB&famKi}|)OdMte0Bi|DW@Q#j|2KLzSF}1+Z@wc zpcsX&zsmt1XA^VGFVWns&>OoxR*@3Y@bSHYk2J!2^BH1hm3smD5?x3M!%@7&Uq;{KTOfDt_d?M z-y0|uqpf4Y0e(xcW*|-88;VZ@E5iI72i(E8l8%rnp@h+HH7fY;pkSa)R?H^J<+!ch zuvT6T*I$u)uB0BFPl{#64uUsWC-~CDnf;KY`V?gFRse7^EZ602QKdzBGgLA?h#KH9o_W)V((UlAWXByQB=hta6-T^K#`%@?lD~Fl1rhBbi36-`~kfPa}DO#B_OPP&X^asXV%R z&gfz)d;6$}(y%FH)UupLJ-R}N@pa?bLSP7d#7ZI;30&^_H80jEOYNKlI$|WMT!gt` zkv-*IS=&6JwK!oPf#u|19)dYo92kqv()sBxVgviDb2;;GxuLtNi;9%+ZtT6n&^@l! zmFW%APIS`RCihpKG=h&)IN1VgH{qKFw`U1aK}TmXjW)PutYI14Q=e*%uY52%w{R6j^uA{?tRzR zygrQ?OTqy*t(4htk$mW4dr#fa1FMq-zY)|bspREvnthAfqD;AuVW>&TLFuG_SHZI? z2ZUM%@U=AA7R{18*)%Ii&~gA0n5Z1c@-ad2jlC*+f3iuTNb`%}@8Im6f0WYzV79TS z#oG4%y)fR(<)abHS+&KMjp{|gyxQWt67Iu_wW2*ORywe742A6XHXjFC7w(pJi*eHl z$HfF7-S$q88=1GZ`V!HyhJK)i=YxRq0lY0&0D~UQV;rp!k0cIB;?8KYw(N* ze+mbO=nDO@jihXdSUgZ|pD6TOUsK;9BGHkYZqED=H~pn0n^=_n-x2~3t+*8T62;z> zKn!#y=kW%t#1cJJeMUx!blG!a#;XIDWdVYSr?`|0cWHg;O&2U?hgBvZ^#P}?1ItkQ zrVCZH&P;TEP}J{i^!Z{?^t+9GD#ACF=1BlL2lQ9KZ(J>lAw-E&i_{E5v61#nRCq5M z_MggaesJa`H!WWpErhfzU%U2^>PPQf>T$XKX70B2(?92LJ-KjABh3&<~MGYS1U zi@bRAb9O1{YnKAywWSd<<4%I;o577vYQ)a;D4s4_Yh4hfZ|L%sz6do1aJKpOW+&GHH~5TDlMwV?HF0zZG3F36o?BQ#F&;kgdEOW1gLm zp(JUd-OCsnj<SVIkL;o!-rW*YtEzs|x@m)Id zY^EU8;{}nBXwVAy@9Y$9wJetePqZ@Jay2!JjxO+Jy*8PrETg5R8*vs0s*#svUwPlf5; zh1ubs|7)kao!(ZZHY=<#@j=mxIQo{#%XiqVMSK@*ApEOWiQ#MfQe_mj?;>FT=O)+I zsB#c{*USukiP-4IOO?-jp+|oJk2`K(-76^dBXioQS1x?#0HAl&DXxW*qHiSF*vmuH z|2is~P3rJGz2kSP)g{u9u%><6Y}ArP^T+rND6N#_0Vm?m5N{T(aX@8~wg zxYM;o=(Qi9PCy8{1OUxX(464aGfJW`5t|2Fd9#~Xe5!>#ltRBzS6T8D5~$iHzpBU4 z)Ia0o_(l;ZKV0;IN(#3iUvYgiH0UfBSp`w|L9-(;{I%IrYdq)tDxHQ88o>jb#j!HBoEV5Sfq$mLXvR!Uha&QacO}#cGoA?T+zv#ez!HV+eq= zJMLDi#QK`KmfHM246?$T|7dE}R5RqlH%;$Q0>s;O7wj>wB#RpUL=|^epauN_6fUb_ zy{(NpVpcncs~r|e)P-oTu#czf|KR~t_yazZ@AmixVTXoIk`;h0tm1QjC0244SiYFL zlrs={vq5UyUDhUcd+W;4(Gk}=GPg3(fhEw@^ke^8<^C;hsp)uIg%IdNiEWq?PPaN+3!oGKAeHuB^YfIG_NR>G3BlTM; zN;sKHC@zhng9LvwPzK7eeLgXg^l=;I;1uhCk|C$#vE*0^C#OT`7C4bmVP#WQ*SI{% z^J}!NHb-?-XT-2F#!}TD7#rYk#})n7Ku%?fQW-PBEwe`Vk)q&|-Qi zL?BE=v{U|uwqeaB!$&RyyZ47xlM8xP-vCv3|BfFgs_VMmOV`gC$Rr!W@sRgyzv?3B zGdAn{cg5CmCs{?!ibYXg$uaHC)tnr!V~%N;pS*@jrH;EC)URYWO)q1T0K=QJ{h34fS1PKrMJG-Bg54D z6^XJyvNA+5#cn{=MeFk0zhmZ;R1VkYrAVD)FV{E2 zDzaKdr%;idoc*2fFRzpv%}DK>0M4x@Q94J0fB1YP{+*TJ&Cp=W?xCC_-@`u?pZO)B z@q$VglRnPelpqHVIGYQR<<#2!Lxj8cCvR_=B?)g{nz(Bpz|OfgIuMqYdVG|U>a{nH zBn)-aR{rfA_6ydqrA(HZ__<&LioUrGSxJ;n1V;r*2N=HPkF2t^dbz%CXp}+2;*8-f zoZjRni9#fjloC50zZi4TpD9DS_oc|nCwhQK3ZGkwG)Fb9xBgQOGEA^0LaY*Lsw1R= zkg?WuT+p5yxM7mBdzE82+4Y8X;RV~PJc~CW?4_bmuC0rx6f{!U#_5k4y()?15$Bv4 zjo_5p7xoHoImJtPD5)r7%yJPc4f&&~@$Qmu%1T6-9-x#_wPg5HON0(|{!7U^#$bn} zLE)>Z)XYnNm=YNrYTjGAW6Sj0_sawabKp?hPHQCrbdJE6Xn6An0 z@_1hP13av%M4=DoCsy~yC!Q%*F0CIcI#jU22k*q6n0U~E{0>uVTj?Ic4zsI#qTRJ) zP4~)mJf_BXarBJFGN$bxqF+Qy0R~iQC3sc8mquxtt3>gWnoqUI3Brs-!==%CmcLAu zow>dh^rt&G?{h>EhK~Dzq&}z;HKYH!nVmu`q!m4 z?#ew;)DNrL(Z|@lhe+(CTI~Dx{%*r`z4}|YvXN7-5D(C}AI-|q6+2{X`TIEe*;wID zxNqO_{vlLpAiv3N96P&6Lq>6#dBewD?|9Vd?%pOu2y|*RNe|W&&FiTCm%#SP{wt-G zg!1c`?@KF!I^>bDsO7%obvV4*(k5GCeK}&(E0ufZGhi@jN=AN-$MZKnIr+~M0BalT z6NPM2PKea*^tmg(?lDZPrx_Rl_%MW8m^>p_<+m?D zGWdnG+%PvV+1xAykX8#CCu0oON&!$f@3$ykn>{$U`F>U~NOo}6k$3?#qU5~gq)f-h z4e+ZMuCau5dBIux{)vt2He4CoN^>*4@0tQ^qEoN> zdt8UajvG?EGf+L>7!1H~*+gOyg;NbHRxZt;X96{((;Z$XP{%vqxzYd+Rpp|_b?Ehb zJhCR_)wRQixF;BI#}FlKH(_B4Ex^K?{W%<(2x|I-cy z>(%Pgs(`wC_d-&I??Sx;wQ^I~ig%TEb;lnI=>*!wS{hc-$8;%K))MzY_02b9YQN$E z6hfv@*(H&hW^eFy#oo`zI3`bbN_~#_ljt{7e8d50#<$jx>C9EKM2d~vSQgRgq%pP3 zuu1lD(3n4EYEfS2t|zp)s@~q$G1$fWk41W&Y;2RKfQwm*r;(+$3f1($UCqCA8TNd8 z$Lab#vnMjge+DSZP3_bJfHG4^r4Vxp7V#gkxw6>9E0l~)_d{tF6O?aRd*T$jUc#<^ z!=&%S+zzaU8B))}S8ZzCtWU9Zx9nP)^4jcMKSnVJG~=OHL9uu(QLHrY0^kN7Dwf!U zFSiTN;X3CLI|ABXJN1skc5^Fhca1Xgc?XAZqgx5qsWB~Ms89n%Ct01)DQ459V-vzZ zoFn4t=@lro^(0v%)DWudP?9GL3oUr~j+p^X>HNRU?KPULaKrU8t%o zmWuJWyU^{m0U=$etfq55_mfDzNQggm4pU}|a944GzSDeP)8%@rPhTg-f{*18Kwh@~ zV!qSo8u^3|jc1^s11uJFm)|DP@2Yvdam=JTe zzx3d8dj%$v{?^i&e!#iP_{qKw|l9>|sh|Dic-Jd=nu_n2^#^3R%V6?J`9JDF0-oaC=ZzY%eZ*LI}4bemwMZ*RAbb zhjMB07Qv#~=V5kHXu~*JZzv0|A@or-MzX}fN@!(*S;^GcJ?rv|sVpfacG;B7!!p;6 zkTdl+;!Hs(XfpT@B%1TkzA2jMpfURmAnc#?$h#ck<4=b^X*{^|Mr&m1Z3BOAkE z|KihR4pELpre6fi;7e5R^!u44iC_4bOr{6-_Zl=@(d@=G@KUy0ziEYIt2S%-`u}m#WM&d`nY>- zn)@n^`VOp>pOG`sdY2`C&T^xK$V#v)m_aOm+qE5hLo(L!uNu0gPg?7c^<^319RfYK z7nnlNZHi?55Qa`VLT8Sogy)wtcPFDn*~jSy3!$6jLo)i&KgGmnzR9d|Eh-<$f&aehQVRr-3$VAI3>ssk^mR8UiC_t7B$DIjc(OvEUOj)zoNw$&U6gBbR6HK_uRP4 zaUizp>o9=#vn2dA5{5nwu}fre_yd^^W+F1OA;z1dO8NowIu=1EB}`|1$BbYH0;j8s zi#9?J3Q# zfrDHs%>KY411M9Nk^8)P|ECr?P6!5&aI|Kiw8ljcMvN5ZB#+{6uEqQr#Ov8KZB%6n z1>Qf}Ev4%oTV()$ZJTxeT?WLk9a-d;$u28)B05PG96H`!EtGhDJrEW;I&WkZ)I=7S zdEOz(g1fn-qzm0P5Z+XWH|At6Xsm%bNBFxSj}5cpKfn3i^^dlEqVIIlq+y^x9N{ZlE#f4tK1)O3zI0)KX@ zY>~{Sy7?#669B6~@^Q98@s?i^+|2N;^erY;oC%B4V)@;FMnNz-$JmG>)kjQ5d32-EaUYQ5KUxb6r^jG{0`TztT#9rka zuUIZ&xM9GrfX8~GCNC1ekdO%!ufXUBATS^V5;NZq@O=TcU|{~ZRpaev5s9N@kdjOF zF)mIN-<6)eO8h(0z9Q}W{`ocM=d?hia$k&d_R|FR^h8@dR!Th|)DHn)I{Sb3pE7<6 z!pT*K!yDf-XYcXA(L1^G`**7rk+`2T3f;NcXljxFKKSKuhEbL{g)A7)T)HY&GiiEV zC4h7ihXnMC=K(a7U5KGnUm#1={>9YLxoq(P$C-O-fH8M!5HIM%V@XM9G1!7>B9b!8 zOsArwu?Hak-wq4|9$$dRF{+C{S6M)kww3n=y+f9CW)h@JCa<@!vnRhLMFH>st9&%@ zMcKTM_)F0h_r-*|j{jyq`W)#)=l+ub>2poPej_>?r*Hb7mYYv1I-Go;6C{0BEXaEC za-5X_rlGWchz>bb`!csCvFruwxBkZ}VZdVh-yF^m_W5oN0|e@&legBckw;UTRFLU_ z0mpmBIFl9xV}9c{1|J$Ddz^$S@8_3ei ziiAu}O%3w|&nuKORshYLnw0+z<9W!Y0J)fI{+LcwsAn$R*r(px}_(&>pP}$f5e=69#y5n zuc&C5c4Us}g=m=}tG(L$DElv>d<#J)ptqvW(@u;16BMNjO{~*oX-iY**Z-;}TiBn8 zfVfY(29k z(nm^!-^IF&LRr0JFVhR~{KI|F(1k8)vh)lF>!6sTh@d5|()p)kDn`Z$xmw!%gN0_< zSsea5o0V;C-mZ5X?PR4(XrFpjTgG#8 zKk(V1w-fsrm=&9!KW^K0R)y2Iv0~M_`#?i<<<_;1 zj6mnXb|Sy|O=*{M#mi?r z_G@W0HN|z3d&YM}$5;S$ zQt&mU0xA+xj$BVVrN%a8=||6EO@l1vtW8KjpLL^x%31soS3xn&xt;D|wMN zHUEB8LMv7Z7)M2spv=N%*H8bX%P8+h?*9!Rx=zq(hfWWlZY@tg?VUaZo9$2z&&8{F zfdxiV`BNgp+c|ZVEidki_m8Wobd$GulaIsg%Up4}?9WEIp_I6~+6`;}T z%1^|M>JD+T1;|Jm@lZ1?DIC4Dw|;7GxOv(R_qe=ta;O;@Wt<;CSPngQo$*=W| zS)-$k@DW^dYhxXzSi?RfL7F=%U7AJHam0MMeYo40b%27WM>nEJnBkHUKIYQ_D}ohm z4NAgBu>Sf0E1UH1PDe>RkcUOoOfor{ zzawdk!;vASgLS-uN!9VB@-_gF(x&>A*!a7nKN8oy??|G> zYg|D_^0nu|(?hk+t*ymILXEr?2+uHGTw1L|7F=t#wl+q!w!=gs3w+mxcy9jhKJ9Zy zD|7x&ME^Ir#8P6U7eQxD1D{=10Z6_VsYGll>gunMUgVdA;WQ@*pS95dYjs6Mm_)D-)^G!MT(&@Y-n%8NNn`g$4jn5bIcRK=kW0SaY(rgZiZ~eNrbn}I zGjn}?nOVuAw0be0z;_YPmq59LxSvp@*?3ZU)5tH}k`s>)@~p6H(Jas7f4x>p#myl3 zgN)*vPV%0rpTGBiEe-7}xIS0uQ27B}z3fjVno+iNZrDB|j*@V$p@8q~zzXM!{-ym< zi;~Beh!^`~8S&MzYilc}f==B?Ge!05W%?k&f9lVvK$Gzdi>p5UPb>iU43JCdAwwW4 zO20+w>rrH+!JlAmHx-!7@la3`yxo>az4vlbHP&A%@z#fJz7)oz?%sN+qK$%R4Pw#( zVvuO$$yAJsty#&r6M3rAV5+0pJk}Gpwz@GZcdlmhs%BWva)v#~HhV}7Y8>Gh8sAK} zkj?ruAPWN3$^g2m$H6&H`o~TW%^gmgW7l0q2v*xPLUB=Zo)DN&2IBzSP~xrIzBJ`l z`r|2wtT|Z?F4it7Z6eRT!0ejUjDB0hFpx52)r2G8?yNdtKi(oRS;iQt*H!dxo|}@rN`s05F`)Q|;Vc z9!j&GH|xo6)Xc89h}*P7_LfSt89@uz-jZanPwaD}6!5f;77_6o&#b6GMGN(aQnVS+ zS5K1i0|XZ4F18J|rhtp;|Df{H1+rgtesf}X?PwFLE0%SX7N;zv`T&WkWY&f*quE0v zf=_btlCE(%%1!dW-H*+R`raWzwI>wp&~7U>^=+dXG^{j< zT82fJL6?SKIwgr?wzJis_47RZA0-_>u>H2nv?@GH+QYZkpjP%WS)GJ;9f`UdYZhqe zlHu?bE1Iu4t$h_!T5bQT-6&r0l21gHcKItj7MS<%@!g@KnQ$&3^ZTPAJRQzD4EzI< zO={{rKUR$p*KZC&Hp89=?IrlCqvUZ*hNSe&AnbvpSN!XbwOE;w&0N z|>=H{~`Gk6|93-;Oa-T+#D?1Abh zT*(GpuaTR!=2f7T&W&75>vk?|Nkd6pQcg!n8mx0y$pu1;NX`%Zo>ps~Wa^@m@2j*N zT%Lagrx3Y|@1eKmk;{%{G;TCQGgwL=KJaN01obM(i?tTXV2&0DS@b<#EQ*8c(! z$NB8c zQur~Fxhh8qsDAx9FZ}f~a!_?C?0(ZRm0&Q@WEgB+VUH`aMQ#c8HQyezRmeU<-A6Oz zlkLo$6_TdeZaoVDjZ-NTBzupOgl@`tX64t3!9#*@MW^#K5wC_eFcCXZBP&9)L> zuk5d79p_yif1h|{Vz z1s+bOR{7nBvCz0~C4jM%qz^$5|089LLV<+XqD|{GPg9`R)WbDoEucng`544mG*I+{TMr@O)SjJc?v=Xf%r88W;TENJbYv&mijt=Xw^CSWe=&VJJt5GfV zr6rJ}`Lmj0Y#TCJk~6U7?V0Ux{(#ieC{d)h=}dFc^~C1ktVfodE#&1kcfBi6Kr#&3 zS}H4DVU^1hv(ue=<8{$&S+~E7)@pfW99Q_r!gs=v-|?V8s^0V?0NH4>S%|KObcWNL zfv3OlX~v^XG}(*4wPJ0sPyA@}o@oiv4`fCWQ8!LyupapWr1;lDb6AroK+T`3-JK@2 z8g-PmXpmL*!yCv4!ZHF`xVP9l=X=P?`^nD3dJO)F5Otix{ox&9x6)@`KvZQY^HquY zY34cWtS(~Ae6y6nq)!bcLXwWXAX+Wxt>U2ApY7OKYkc!9Y6Hp?Phq1xz!Q{IJD%XF ziy`DGfV8UkDGjo^7^Z-N6dwjUEcrNfZEjOSGg#1RUOsVr6ZBR^#0#QADS#1xQ>GvP zkI7tz?Ah-*H9Yh#_DzTc8+v5-cbKL3)`BVw!hZboY?wL`mqS7LQLTNTL+(l20P$oF ztyS{X{ofSRND!o(N3(WjP-3AFqvwH)br9%=`TAKJf)qak8vdJWUcNTgjj~k=K%+XQ zkk^80?Pi*ZAl?RzssJ4h9*uw&{vb4~*O>qn>t0Bpb^v<9#mArn?Y$pXhX|$Gi)Yd| zr1_0ybz1=VT*A}o5MyIVd4w{kiCJQ(a`1|Tz)muFxP=SefBp)BgcM%`(y%<|gZkHB zqZSE0Mn)zzit~jiRQ$0Ckq0cV zuy}OS^Niit7Ui-zcbOfdBa&PsgvT}9d54bhw*?X65F~^d{{Ozb;fCCRk=i-@K=(Q3 zWXMP!xcSHkH(93`)XdWau3cG9o3)quMYMbWdGUW4R-C*A_4cx$&PG%`3#UadOFnmg zCqf?V3YpZa7|g1L5QGr3MtitpF6i*{T38I301%5dK=VQg2AUI4Z6dfv*+7 zbK97+jeg6AdF`<^)yx-&cmsLY3fRf91!^#(yIQI8*JRT@uG_rZf^dEy)a~eT!rO3< z72cWmHY&hkJUmh5+dAY{j?`C1SgrjA5_r`I`Jt%DUYIV28ju#dC#uT zb16Y+28VElx%~M`f>7#{7w|P=mBTCYU|?ommezxi6-;@^<9rNXs`VM2rM}~tlRO%h zCQ1v${jYH8UrJH)6d?QT2~l*d$`7({_YQs~YsA6tyNs+gPFr6`*9daqaGKwV#64m^ z+wKPRg=zP)svn&mJpSi0X2D`Ve#A)l@bO2CX++@+OWGV9yIJqL<6wP`s6{1ng5kN& zCCGnz03s?LcOg(;HTbyeDi3uP)<8i#e$ek&6Z{ZV^) z#1h4C7K_(BdK$a5|AjGwn}`|q+k^+&%|{aZY&j+u|EOIBUyq0xn6^5B{t0_;IxtUHrMy_jM(q1q8<~#u*b?3OR)5y0Px znG@XQ!&6UsT_iMypoehF{u%~H8LFlsHdo6pGXr%Wrvh3Y|%SL z9Jpy;5brG;ZB%!rT}rQ`nI%Zo9JKk()PJs5rLB6}!M&>7^04PIqKz{Z#V|x8m(>5B zZ<747>NL(m9Xz4fh z;mRK?joanZ?}i0|1huu?tp%WIas4YA0U_+6w8A>b-bM7_4Ez^AY(mK>7x0mZy1t84 zc78AFaFCPeth#GmC#wl=+t_+6Q-*ePFjCv;x-ddhneRJKyXUn6YMx_D$dk~;ve@R{ z5J;sv6vBm^1}5Yl9E(8uY5GHqAux8H;%l`^YZe)kBbiTiyGIP2Ub~Kt&it`L zQ9YLpu^rfMsJI5Qgzv2I!84}ZOa3!&aEu2*O?!0s=L-8o+^!Ncld{Ei5743 z4NPX6RT!+n|At9m4&c!?wp;xGNW5#Ax5VTizpbbg5=rg6(9}Q#(XItM*WB906YzZ4 z)j?7Df_lxDA4HmPW3iRa+HfSDtf!M`R7n`!>v;YCs(Dpi#FF`EZLz3kUY;$%^wO%g z3CzeY$vINC@LzV)SWxfw`v}n`YEMn9`rn1jqjn19b>s{IRic{9RGl;COSQTdaGfPP zyq|ZKzg6er8y&^O2&)&02aKvL;#Z6RNX>=|MXQU9pOjOCF|z86c6M%YmM6VnJWj}@ z+-R%mpc=-L_6Hsa*|=6M9UcC3F+~T5ey4q6iUIjbQN$1y(T{(a1guze=PBpmwqv#@ zl=~7BlhS|{vlQZc6#%MNE`B$M`g4AnzaFML02>Y8eKX@!P`P3Uh9>kQq`xDFr|C~} z!)xGcl8bA!+^v6VBH3AMY^0KV>>JS6?!}y2`w+~|Z4jLM@L|z5WM36eQTL0{|THFLgLs)gES}U@BAaK8msp0DtWC{ zzwspp7@HNDT`qL7Ld&*k0&Rvb(}2mWz?#D_1g7^zCA+T5fMuX}AiK%RZj=?%v_VLM^NnFp zaWBjOsky2L5X`FDVRSZ8S8BI*`;;DwQAyo4Zb)OSqoCE~{N21y*Kp0fVv+w#9oL-= zrn!9wF$3skg-{3ENpP;JTmr>hmsENrC1=9RN`?gd?S0@kzkY~j z>Fot5&|EmmuF`?`dNbhc6xH;Z>TeITfi{dAB>eDUvduDmedy1!yq&jAKBE+I2JZv- z3RrFnlA~d))6)(KU-q>s82bFZh&3lV0iyZ&`59ZO(gePoKYp#u)%i{!SzFu+Rr*8s zZx%M{)e!-TFBaAikrsmhaOBfUMlNgm}KeeM5h1*+3dVRmvDEweqKKOKb z3`9M^qpPU(oiM9WBp+R(1zU8XwPwJ%Kb=Jh7h{U2?z`G^TzLu` z%3arED(X{@qRNFrxTF%u%C^-%Hd1b!Rh7ZEoEjWFwVC3+{c=elvM7JsDjNw59|VN- zae#R(*EPn=GL)n7Ld>d8j5s{7Qb#4r<=9PrndDAefxGytfZ zRqkW7_z5ihoS=|h*xygu2d;ffxL=a28{CY+kf_rK)pB+Xgzr3hVbi1>Kj8(= zroJ*LBd`tI!}z?Enzumt$@4nr7huom|c4qxN6IJNz|pq`sQ`V$yR zuPSQgi%SZ206+~)pdq8;;6KJypA@QR{DE|ys-jvUznwfR$VT4yr0+yjh=|!M6 z+*gcJLM2~&6?SZOp|w`FQf$dgQd0B5a_Y_IFV8a;4~OHVod{6C>6_ZFR-3+*Jpgf2 z=;@>vaGaT}Mz@lkoqwy;5MR}<%?C4^&N?B}#DxitnP!F}{XkD)iJ(V3h4(%F#Fr&> zC_>3s844-X`BmlCVzqq`lf$rdT2m;uBX+Ko``aIwYJFN6!P^iK1{AB@t(>cKU97oOgeU=;dUtDw2)h&PBQarbEhL~3k?i!U|&!+Nx?qw|m-wVvIU7>Pd z*lvpag92H8TKy`C0rEP14PwH$YS=vQ)}>k=Vw~`8W1d>q&q6J6S=6a=(f1b!buMN- zi7jvv2B+&+y&FlX%v5D@)W(2b@FO(7(L%}10Ajb~jOtGz{4#3Z@T+=F-yDl^#WbY8nYoYixrVp4*aeB^A^WWL)HS^yNn)O_m6N!c}RhPk>j&LU9n8e3KcQd8g zIkmtRFz+2@iVsW&`&TxOsm#;mLp(kc9y+?;f zzrHbzT2D2{4g20InxgCP*0zU_&3b&eC4|%885i2n8c`HV#~5jcEx^Kc^_p( zWWbMYubjSFbS90m%`hN4dLXFO=*5yHF5ldF61l|)tP~xw9}E;OTgmnV#Ent>$O4~C z6=m7Uz}Tolk-&Zcst~pTN+q%oHtK?pg&?Bv|La#|zu3uM`Q1oI0$ZzrkD}P9XX<3& zNZ>0!vVMTU^Zhs%)>Yf)tpL z3T2-N0!t#yYm4L&uTikV4n58aVpxr`gJ;j{Kh(nZGVX_}+x&7(sNITkE<(>5P&Y1po*jR-R3T{C5I6cP zHlq6oh_#W;(6ODQ;}Uuad(6FK{Uc!l}D$;>N4&mJ#mn%Job_=C%ft@o0fG6d_z^ z7Wrma=DnHjcx4Iu?YM8Dkb_9IXY;@k&JBYkWwc~mVTZgM7y0bm&sQ?vQCt=kJXc$f zX!#$#3Y*$+rmbq0npt_7%}}7VBu?OEP3X=w@I^p?f+wSTK)`6-SQLC?rpZo5+Hsj7 zWO6#=TzQ6!_?VO6(Vt{Ub0#0TxoCyq>lB-{elCOJ^jX`fsI>G!OGnOy+HUAJ?`-^p zTMm?{{Q-h)S>RSaX~-r%;a~O`0Uc6u^1bfVRia}!y0neE737V7#Q$bT~XnpSfPWUKlvJb;an#30WXB!}V>PM#9+XYdcJ z7Xt#SDc2eBQ&Jf6%lF7KZ4&bkEAq@&e64I4y#g>31R~}(!-G)>H)M}_FHsZvF|n3; z$=%FV+Ff7!i>Gjo;QxZhq!h&U+4?nhw8E9tdX~ z^OjGpXF389okoJGgB_NP5(93;q-)I=bC>5*^XYhj1z)$W7+XFXI+!K$SK%$ik;0m} zvj0c^9^NQa7y2~GE3w6ZA3W&+vrnxoMm{nnzur&-Y;Ss+s?I_n&uHpt;?ghsrImZe zV5i+j9p`KRMeeMRP_wF2#igV1#+uaKkv5E~rT=$e_y4n0*$m=z2&&S2q0O?bF$5XW z{miM?qjv=&LI+_n1on$K!|u7G&zSE2!6Fwv1oT3_Xotn)wzqw zLS71%OyR=Ud?y*tfVWjqN46BRR_l|8e4RMqHg1hH5x0HYagfP|DmklT;_b=>Nn#da z?WzR*2&z5WABsMM9m$!f22LC*a}vpD6pVy=b_;;(6z zYo9kbEhYJYK3HW2mL)e$VD@F_t~T1ZEV@R@?D3BBQN$k6`DJD3dcU$8#oHi;@Vro0 za^*gmFNHk>jqXpRDko1pI}hei(h+$SB%6f<3T+ks_m}~7g9!`O<97!iS4qy6M%hOz zNyn5cI|*-tPnMp0#8lQX9j{$BW45j>%j!gq}{te@1v?$Y#|b>x(FDjBDuqU_!3 z=yRLSK%>UN$E<~gh3H;*7$U$D@Px;usdcT z+dk)qg<+oP;mulWQNKX)vqf7$&B^#Pw4R%}E-ENRx4T<$puph*2K5aX=-%YnjCW>{ z@j-tC=9Dw75evT5zGQ(aw3>V4WO`6P`nvhS`#(Z(GaU_d9N?vB_v)q$uyiLTLSiDG znORi)03MTNu)6IW7fkw}0c-_*U6r{Iv!oCajwjU1HPO}@4N<0Kj-?;Pnt!S7c~=~; zgV^-0F9VQt!O;<$%@9Pa2V$1Wp&(}I^z+v9|B0e|cWzf9WScyh@HK%cvND?Z^vj$7 zi3LD9X6whfH$KOz*czZugaUb1s=WYpa-sF4?l#~)EryaG(gAu{w*);t<|mvM);wq6pk zS^A=hj3*bE{jr8g8^ITo*RBp!c~d{Jg+1e^ZlCmhF9IDmeESsaDg5PDmEb0}%}adl zMy@uVsV<}DX;_$V&x5`0Q|h^I$y;t4^HAQvc4ZyDVDY{ppN>mG!eC-x*8WuLpBKoM zaa!p_7q{KfDfInx zz;!O)n~v?CZ={ULeC`36XYe~*T-+(q z=RQ~0Y}5Ra8*zaqK(^I;-ud2cU0oB}#JYw1^pJ$ywo2OS@X*UB%%7x2HHV_Nkm?{-jEO!6m%zRH3p-K)?Z~_ zOyjIB+59s1>D7niH6(I>xB0Ohf567z`;3mH-OGG;$3dRkgPEcr>onM7qDHE=7L_*d zPE8UpbQcG|kpqnP-%7JB0do(+|!WBN|kM#!-GMydQ%=ze# zYXUJaPoFRlie>p$e6M!+X}A<=cQQ6$|2Ek}$RDt}+$zEe?Z(3`gR7V0c~J~nBEW+#KEX7=S6t^*3q&QE^J_Ox(htStS(CkJasB9s)DqrI z;Qv+IS4G9u1Pvl#Ab|mbXCN?Ga336k1PGAeuEAY{y9^NA3GVI=!5xCTy99T4c9L)Z zecwH2pYA=~x4WvVs;m1}S5;%JK0ZD!O+6;k+}p17K@wWGz0Q}5SKS|x^T*cqDUOos zKqdq=GhpKb3crnlMJ!^(;RsG)jIf^#-xSA;YAXQ%wx9oYR6oY3Jsg`$u*=@l||JxFBg;%vc=?R(Oi1 zXfnyQ{%|!sL=Yv@bE3T)te+;CLV4NoZ1O=4&l)aCH$=!Djv%5^3Fay9JpYRfGRJ6` zYy_={LwwA9E4;fE0pI~_7yec|^2pE^J!9Dvw+I{+yFl;Dd*&EVb~!ux!}<~>2J9Sz zQTW~(g3`uFP;RJ|(f4pDZM$@|LRT)2I^a_0QIW{dyK5M3UJ*4MI`QQPQe>tP5Q1Kz z=!Yr%#0HW@Ljy`$-8gryQ(IZ@)M{-l6}Z{=t&5jb8576>mG_1%a0?CIQ-L6-@4EgC z1UG+-Lcc~-$DF!QYq{z!Fs(?icn=b zQLDcK^u!0+B_IGDT38r#>%XAE5?DJ$_07}t&xZk9Lz=%~6@LzL8UlRu*j&>BIm6&G z@fs2^1NDMrV*nPTT-7RDY=Qv{=x0de(KJ=@BBi?Jr6nL=E@wTKiC^1ftLl;;ToXi;JVD$>3fT@qB zl#xjekVOJP0%)r$`Iz*^=)<}VPSpeKUw8l~QRzPSDQk{%PQy@!yQ=kk$w~kH65BV`?=u=7s%v875scIHug0ped`kghdEmg zvjD$7!P?bM)<_$4faAkpx#Izggp~*|@fCt9u!0_8>Ow9Ymf5)pH1`Gs_RA3RD#1nq zqMcV}q?^j_5(ALjMrk^qUX#88hO0rnn%Ozf5Crf*6paBw)Nj23D5NH17zA6N&?|K@ zPhnd!XzKAWfcJVtwD-RzN{H5Jqe%YWDGn#u34$Cf3kxu-Z+Jw136T0_blB>F|I`8z zt>>n|RhOUQxu_p8Yi>Y!|5dV>ftj6?qnaB5DTr+^0=z*tfXv9bAS6WC%yV}1eF6}YjXJ6u1tm2@}xt*{rrDF<@t=&rVE$R{igs#yO@&F4q87|Bd z+<>>?LhUOLWSh@^n7)AbaVwPT)THvXL*;ohuU}WAwm2_6LKs>pml2@uLzH_U)%ajC zJw8luD3KI`E_`2xOD4)cDmjrX&|MAuOexDZEE7`CL-s06Vr?T$oDztMee+U~lLVPV zZSfdhRS}G)_yqNK;9?de7*{m8i1-4gADaoP8HfAVi%b!nE+u2%v@?60I(d$KX+e>C z=|o6uPSO2N5$$-QpmhC|4pY2C5NBdkNtaKhDh>-_&{F1$*n(Iajh=eD7-Bj%sQDs{ z+15)K#!hre8q+Kam}{9z%rr!;IIm7#PMh}VYb7-*J(ZeDblRKZBIP=IQ0!o_^bS0* zgb3noQ)7=-lKoc&2tlpt>yk@yOwG+8xmfwMeG-b>l}Enp>Z}q>bF}jEWti8cduIGC zR(hBrXS383D!~wRbZ0@)QztU`>NPOnA6FVRG{aS*`wc|PL}DnEa#2dk+jib~@2 z4n!pVr)H9rgZU0qQ-MJA@9lidLd>cr52Jw^;F+KdcwrRKC9Oo%!6=#mfJ!?cys%#45^s^&!Bv&Df#OYhMZ9WdvWC zIP|oSn83lb+*n48XN@$wxa43qe+X z*r>(1k0@iE4r*n$fh>fP>PKty#!^Hza6(k; z1^%UYa~=Q5Og@NuyhgK}POc8zZ4BiJ8wS+cx2T8Z#hQ71nJ_}U#-7Fl?Go)0n}AKo zb}8mmI&MA7E82tqV=}P`W@#DkX47=d=P*=YEJdk0X;W~Ch(`{dH07WDBo4yvxS|xR zOrH)PSWYPvWo(Pwr&uE&ezboKvcJZe+`VUX>%`<=sBYQV1qEGThwT|MSP@;%Zr&fv z`YQmL)Mulicw`9_L^NmBjYu5K|EUPm+Mg=s$W~)*?q#)rVQ>vNGe$E2$??Bhn-ps& zy8@ICuggZjCN&xF|6`~$3UaBejYG;aA>Z}i@1EjY><%Py<~vV&+!UO6bT}7$QzPG5 z2thT0e`&$6hC%*1@OASO1&>J#xg25-LM*LNm?N8%TT%|{v~Oo)#k;WWbJIo z;aygGsK4qch7s>i`O-hHSRY!Cp`Ye!Vn`6&m~i&BT7v244&c`5w(^pi%K ziX`{ZnX(SFXC08#TBi?~Z$TKu{mPKy}f*FCzr^{CWs0j9%i124pT?~8&S%&Xd zd?0#E+L=Zree0&>3FKPhXA#uuNt9H1F7m$9Dy0G4J~ zFxgB>288J@XONnlDsJvywZ2h%KY!_**BU)R-=x$c_bzpr66>|W-L}0&4q)IC$0Oy; z-r@!#t8*x+jDY?%ee25=rv6(ZTiBkQL&rxpE;E@R&$b5t8J6~MS7Qp8BHZyRW{tpa zdc4u+5+HY!$odatJfI(5Mf&X`Dr`p*rM_7Ddh@fU`s9taqAR}~C#I?E=+R*J=GhMe zzO>dgjQ#SQUE9Rl&gQ7BnZoQ=erbxT8{CZ)0JR?;x>8q<>S|Ae#lT$pWjNLi1t9{@ zy^sMdsy0_YUOAyN-J86GSIO8iO+waiB6E)XB87e;5O9m!vQG6XQ%1}Db^jMH4WGRf zE8N7&(d)6O$$CWc2?d6^J|Tu~+a^6`6T(A2Q%LNg`FV%SK46OIjvV;Mj#f2@-ri5y!C&5XuQ zJk;DQ?t7^e9hl7-vkzp=3smU$+)`fWvNFNo{{f&eRdiH^JaZ>$7$2s%W%Zu5vglOm zCHc=9mk^?Wha?f}^>pfs_CWzG)?idRBuUD@lOk*%zzF=hmAj=@^O02?W4V|REg+Sj z#bXB~sfyn9jl7)B(X}lB=iWIddJ(#v?_*0rY0@s8MQ_oL$rMO?&Jinv>VK|xhcnur zH-@Ihe%JR0`vc$*t#>3O>%pYG?Ba2pV{oa8otnjHTR-tY7138VYNc14rPCI0;f84X zYH6I@STCw+rM*0b`Z}NPFCC$xm*$Bz5Qyv{z5Z)Z&2=t)hM52s??&^qygf@^x32A+ zl~=UKf~~7eQDnnMmBZ@eUccXRyDTbI`uG9q0I3HB3ZNecovh@@esxluIyFuRd^T^Y z)?lWWeln%MH74fLKrS~>ooqKd0)U}3!>gc1+I7rh(ii(_rZ1Nav$HkA^LS%LCrK8qBcF&9u+ zPiL?DGh)l&p%y1e^!8v_fujXEIrk(6)#?)AkT~MIw&*-XVVI)d&Johmtc1s0ypIHY zoBdi!nd<@xOvz>!ti{Rj`5AUj?{c?RB8%st}4qy@< zf|5ZYxdHi*S8A51aOs+>eSX~|ss-TVsSJJy#`XT;SNCI3ng^ufuuxJ1MPofxPfhcl z+Gn%B+GS;wP+ZCUf22GR<8v_9LVIQhJi1hm_V|GdR8eK9j$6=U*d<)EXCqH9B!6s_ zgvgGWRJ4z0;GpjmcrCm@bkFDUAz84xqtjNg6W78|Wy?Gh0n#vA3`yXgy14DmOiPjV z7p9F{m?ZnEdCRCcYG*x$-&BrnDr*z5_K2e4vGlTrTzZ%}6rt-bcJSbW=muwzD*sRO zga9f{EW{C2fN7hHAClx5feY^g8xkKcHKIOEd9d8Ah_LiJ?d+V~aGlE6u(@yM?1uHn zEoZharg{JAsY%eHHtnatrj4lW^>lRp`D`IvU2ecH4CVfWTfu*=UiM)-FxbI?ldlkA z5sJE!mg#xlGvH?dTJo45?`fVUWXUY}5UCgV%6&DI0yoZy=u+l6kJIE+?tcu+^2r7R zzuGG&nmYAtiOU@}peoiK!aIVS-s)$P1bK>20nv5NreX(lyClYuS96@dGX&#!gw2*` zD#)h{ezpEBmkHz^x636P>Sg#?ft^yJ01U9h%>e_qfzwxU0(^jNy~Cwe3&+|{n?;}N z&bh>>EwA$ZZytD9KWRw^sENEyEzY0u7HjPB=&1FTww$g2E=lqtFiBd82EN3lWs8F*kOc+HH!Ele7aUk?rn-e^(G_)8@yO2foCBLse*#>*p`ZHW;-Q{dnI( zR$fie=*riW_|vOz_uN;s0Mh++4Eykv3C zrM7cn!4@XSEw%$qtnTeQ1`d$@O=fYZZy|ubkNCu5>#Tc`=Nmepl;bE*g_n zy1kx%l=J(Z5-c5q7p=^$#I~?!(wdEAX-tz=Fv5)Qft78tynZ8QjiKpkoJ+n4*# z`X5ewdqDz2B|j(apfWPRXMNyLFC+(;1-rEc^lJ1nIXLa*&ELp7SWI0O$wzE6*g;7K z;ICJ+H$Ruz-(pz7|Me4=QQ>dPzfJ$=)hig3BK9%Sqmive4HsBg-hmWeGbEloumw#qG&r?5@SuYRR;+uoe4 z&<24}vFQh!)KtwZk8AL9IF%cN z=|th+e2P1Yi+SashsE6r66#U8l*SIV&08-O)Z>{5`rt(E}=v zo*XBTE2(^oJ(nq&iTQ1~!6(8VNx64r?mAN}0o=WFWcTn2gLxUhDh8Y=^_8S+a=S&p zjn7HBtT>2&W_&UNYAc4bR?@-F8NjOS)Cr4#X>j?+23K;4F!L(f4=d;|tLBkU5|Yil z9+OfYcA;=LA*^G@nnYoftB_ADXa*^RcO)3oBwps#k7(< z2m|ccQ!JG0%p@Voe(@NTv%D;c8vYN3B0v?bWqucxq^nb=N9lCZkcou+5{EzyN!ogg z^=ZcUd=LDVMyr1~@Nfo7WNt`WZ5lVawNaR^x&^7urg^#xU&TZa?@;Vr_940By@Lbm zu%m!VWMGm63k3Zqm9W?L7iFj@ocB8+VCz1}2F^QC5D1e1m^8!a{{Qd_Bd*Co3~x)Y zlbu4!&?>&>$nxPkWh+=$tv(=w(*9J#y1s9`(v1gP;m)26QCoajYc&Y4uu|{Y41B6W z50;~G3-KTo$S|#Wl2|n|A>QJgcr52kg(D)I8Rjl%N|lwEvk)MmjD7Lre5`J=$Z~Z8 zjc|N)$eC~Ozi&=q7w^%DG874|)YW5r&W@VTl<+qj3ltgXixNkK4QUFz%MQyTP_$q% zQwW$Ap6B^0tyl;c9v&`hY%qoSdcfi=TL9*bo+%So>?e|P9UCl**+F!o_$h>d9emgv zf=%kNII?np$4{zppd7T(l(xup26oaX2=vuP9Aw@%?W)LQr^XhY$|ya!-)50IS8ala z1-p3yd7!31tAIo9`PH@afJjLfPW_VAztHY30Ocw$lpB0+n}tY+$x5Vmmok#D6sMaC z^_~E5Q)|h0^sG#k;@=v;!(sj88(Led4Cx+xvy5M5B-C3Mnw4kfs5w#_UY{io*k4;f zo@m2oq{2zAjJc~qY>v-TrjwWKoES}#2J1dtG!zYm+|M$zGEO2$qPxgA3AlLja#Uwp z3N(vUy-XY_wFN}=JXl%Ty3bjk1eLq)y`xK~A9i5jSM|D#q+1nvWS4bc$d}i=a~30x z81i~)6&tjdv#5D4x1HRMBcB5QX6Jfy*4pXPHdMn@;?pWcKZD5`LKYrlR5Qe* zw{h-fok*j%u*urAWO&AJIIHJxSuaq*@V4HR<6ixRLE@^+wxNYkbo^4iA#@_Kz0!MP;5kt;CDpH6E}88UvZSm$-=- z2Z$WZ`0p$OmYpDylZVWc21TZ>ypx`0oGNTMClNP?7H+bux6*^t1FWM?*K= z-YWZF`M1?*uxkjg z@ei$=@I_w;Do0~he#a_zZx1gvM1;o7OS=O_T9>pbk( zKw*u^kawdl27E<>j`JVunu>nG;bhK+QT^PTOaGbJBurl{)}5uP|5dkS^INp0%B7GW zUd^0uH=#LfY299cKD*n2M}R|5PXU!}GJPaX77U#uVt8dZ>q~3ZW|4Mb^h~(BXlhd# zi>LmDfS#7ZjiK3n8_(Q7)7~6oSnq>yKd@6zs68a5VEnK&`q6cyoZQ4}{ut77@gPMc zvGFT6Dc8XbOMTBE=mNER&O3@(__q!Y(U?C!n)u@gDG*asn7NX~8!Tl+MTFt^hrxRM zk?Ib)ya{;cP|a)C$j!$&r4nV?E)wV1^ifOcDEvKf@|v=enC(JqYYP1GkmGGCY{IYKR zaT8#JqSVdh1zIO!4KdR-@BSXMSFaQ9#m}OK)OmINzBJ@$ znS!Abhm1G~#FHKMPOt?4^-)4ZNU=SjvM^5gr}Q3WuSkr=lxxe1CdsP=YOK+&p$tkS|(VP2-eF`6HW zes;i5a?yZ(xNw+qU*#UE=uq@F8(s}wL?S2I0(nAP!x8zAVVF!xAxHb#kB4R7&ax}v z_y=H8oPu?lEDCKDCyvKptE_b6W`UKAs%Xtd4n%K&a*c+1oIrDd8Kb?$tL*x&KJN{w zp(xq$b~o)U=qF6#J*6^vG(b5D0GLY|u8fX3FG zqik^*fow>EB3?-M>->%qmFVc8YJ)|eyxE@&bH6kU@TV$*N=e>s+XbOi#25Tg%L|;$ zm@qOgt{B`aGz`J3DQ!+(9BXYQWc^W`#zn)x?nRdFBV{A zPRV-Wh0hlJq#74#k>p(PUELaOa<7JQQp)Qy^xb>X+sMC6nFR)9=@P7+HVQr5p$%6g zJC@bAWOsCB+azM<3&m@gPzPiAV!o_e18z|zy}m zO_atIaOFb^YWeCchVq>!&dPcFCoL-Pp7SjTS4e=85fEA0*L8Joa(()KzUA)R4lxoI zghrQi;s;s`jIq^`7O1O9G&w`?Ypf-E_mFqeVnxGxd8KGXehzIq%Iq*-e{vxPSmVE< zX76PID&mgl4ZjD@!3l}M=1HqJU~JD`6Xd7+SQrDEw)O@vrNiQ&aYV&Q_n`FIK8XwQ zqhSs_*vEF3?03sHDL#_QVyRYm7M_&h-Fb`hwA}Nzbn%06MTb=JsF5G&?vU0Jj#Qzz(PBd%N`bcL zZdpQ6wTXJgYFJUB_z;`o-*(z&IgnrPBPEmPI(Q=(+}=k~1_z?$3?3%Z0uknaa_O7jqgwPUos5{7N8liap<+7=iBjuxoBib)_HQu?T&hbHsr;Q|_oC(MhI0 zfwH=dY9m>be}D1)!jsdO{K@#E*ZZl?wLOYSk*m5sx5M$fHxL^P_5E0abd>K$X(iA?&Mr1T-;Bwqf-EWvDlGIF{Uv` zIZ+8{h|#S5sGwizwe#3tLjm35fet4OA>N9cLARpB22Bq+NF9ivp~oDoob_}xsR#zb z1KAm{gWg=jE4Sm+39x|SvpfAVPvq}L#4?*VC9Tb1Wl4iF_KfNfUiCU>#nK@mFw1f&H@Aeqz^dr z9BjIshtUaIw;PQt%xa>WyF~?XFFcQ2sOQ&e!Rg>`&|v;x-=j8nwoWX69OD~iO(c}5 zsXxf!V>rjyBj_5%%e$CiT?Apj=(vrWFtbL?EtW_+OB0_kJtPqBp@umZpW{E3tk#a5 zJi|XVHSsPk@^)x9?bS`soYOCsBZxy!f&e{(5Ata{?0t+tT8o~*ffYQnPnoi-nUR1O zFUT!j4plWMU9Mjry6xB#Sb*`s_QqcrM1&5EHNyq$eQ8BV{M0F<9BL9;`8ivbI;Ue@ zG3twP&H^gyaA35glunbL$UcD^08HO?DT8^Oe^*HfgiE<-pCZ$6RyA7lajMye@xpsk)GH!5tk5~*m^>WTS$Mm_=OFPz!x0HOxv`fw!v`Ydy)-kiBO@;OhSl5=6d(9^-y zRn*DSQ*8Tj?{0k!oBI&I`8`UMxsVBim+)UuQ}t4aAiQoWTd5%Pn0R>KE5e?xqx0pa zw(f70vu5HbPh==EYTSFljWPdV@~dU{Mk zWH)>{}*~7qS z?YF4x$giDggy%y@m$Cfk!(J(%s(&i3!!iLS&0xkTlkHAG*GLP2+Unnj*jFjORq(Qz zC)bKLM&|tFYik#q5pYKNI5ZL}aUYP!{p*b2uji)DNQn>uf=Z%>71!6mVe*qA?1s6z zp>7|$(>X4#+M8K22i)zU)6-cIsx1H~D8OYI1$+m70tKmVQ6JGvX5;VktTl!hQe@W5Us`NdLMtvnOzx>rkO} zVg}|TzhPsKRH9-ilyR`@JUa*wP=I1j*Kle}m5q`OPN+R~6p8O=M4sx6S8D{qtVx2v z?J}APY=GpC_GC33GqXs?1y=NHOQ6|mSW=l#8+LzxR8{q>=+a)Z3P)v6x0PshN6L8( zb#1NZ_y<`2ws3ky;8ZzoUe~Hw#kH{Oj(>)w3|CAM%Q*Y5Br87iFXlQY0vvR5UNdXD zHl*i975w7OH-{X0r?arkT%vZIZc$wMTw=NFFixe$VO0gDqcXuyZ3KWOp8YqQ8k>XO^-QA&u;u755Jw*eAz)in< z&Uo$I_x`+p?qCE~lC}4;xz>E<^UO_{x~d!wCOIYo0s@YLytF0)0vZ|u0`hkZ_-}*< znr<=pA4FG8IZ1@dG0Fq@8-7cDg%2t!2+Z&@1_ENJ4FbyVD)1LM{Dpvkl#TS?pP->- zBmZ|9`TOrL3wK692ngZ`3epnVUWmsT=;_)s3y)bqW1qxTX_TkLHki6Z_%g>GvgSVT zSM}oW@8;oUd7jb*|PZE?@ul$Y0YANVOm zykBatYtlI$yQi{VsQuZr)Kd4l1ON~!9&I5CGCqCF1p3ZT82w>1?bfPRaQ5q!0G53> z<-vl%?S?IMyaSV7=9H_b_$?O;6RUGpV2N1KC1pY~Skdp)#>60@zWTY4hXqe$(i_9Y z)0G`v-A0zOJMGU1D2HtDrM&WVBqjUn90a1Ni7Qqc(NoBz5WOQw_e>g1frh*>Ij_tR z_0AQ6++#1bTSgfBZFqZt0eDME4u&VkT&fNIQ~}I6BWqoz)OH6%_r0p#9+w(&t=AVu z9g6hz34P!Wh{WPXl&DL-807eZt)PNXl~UL%`zyJ?Na6CuyAIuKl}zrOa0!~_AZ*kh z`Y~nB>jQMMFNN_ugbJXppe}2|zOOjO(39Oah9@aDQ}IJTkALNaE)uG#)#ZNCD6rQ{ zsW5@XRxK5(P#jISNNKh`VW4svPO7SxQCd}HUaR(xcUa=;N;Y-LgdMGUz74%Tu^-vj zm@Ra-D=Mz6h9tArIU2X!z!)-pW}Cp%B|+|o%@A@aJfZ1ikncr>^dkkIL*vyE->WFd zm8!!?K|)`iEcQi{$9OifLmgMAd=BgZs&vw99apxG`_9y(=KAdNo$CXcGx~+YE}O3t9ZK5ek(OF{BGg6A`>BX^%waM7TVhS-HDB zv=Q-fYZ{BXy~Ck6IcZvJ2tvoJjh?M=1)uR=Z*zg@$y`-4*=J|VVFM=K?orhJIic8% zKZ4K}MNYZ6m@5n#YPHnJsNdAufK;0%P8Z&`d1P>rc5Z;ain;_pQsRVsrjx~~L&lvK zD>3HxdW7K}&%AZ*p*6%1e@9LDoxM7m;nT3xUrz$-aum_CGo_2xhJa-k--eTIoL~15 zT=tVc&s1q=2X!k<%!|^6$4)yTInqmn{Z!_L8Jhg9-H*-Y{ro$-x=W3KG%bvPcLp|< zdI}0*SNp4P2HD3O9Vl2>9W(cA;r=1}Q{({oIxNO_{;g}2T74isMNh#s>k zMS{R3x|vG(=%%U&b0(&sO|hw~{DI>GqUdN6;|(wn~P3iXjWyc`Wrg-?tFV8Pf^Y<<}0>i7d4 zgt5%sajw$XvEf4XXwg#aXs)S=$7ywWPH$(y)ugFu&3mm0>(R!9I~h;pyqSwJ<9L;N z$>E_}#$)n^P?Gp)smL?Vr7n-6&7zpg0427&t&iz<5iioqz0LRgfXy3F{shpCHyed}&c0{cx z=PjWpa|^BRN462qy0*k1mk-bR*AbT1u`x3sM?|fSI7fWVpw17cDGp9*BCvEev4`e7 zmA=o#ks=R_M=NE?qp2{T+~x>h+_`cSJ6996Yi@E5`{FMD+!sRlpT3JSd@_5NrG|+7 zXBYRMbJysJb%T8imZdYK1JA&&sC=>7CceRg<-3qTL5Ewk!(xL+c*T3)vw{?zR>u=z zwA8l5agA6`0amLlo&}amrD5itersTh#ijzoT}8Kh@rpx}+FAcbR}ZepJkz+rVqUbE z`Cf)=Y6j<-blTzdQM|MstDTYN3y{Yu;rVP#vK$)(lO@gF1oK0p)z)av9Rbq~p8%>e zK9Ks!$&wJz|LwtQwJ>2HH8|l8ke&+&;zMcwwW&;Un!nuFstZP*)g&zcQ+cUHDBJ+sNFV6SKgeg)Lj&3k7@7 zU%aJ6y76Ma*7Ht9YDdX0T$50GpZ~<+5zH!my~?_BroHEo4LL~}<0_r@7It?Y0swHC zxnmOU*hbTLurrNed&UY16sArYX>~mr9C9J8zTkiXP(^0J*(q79b>~$Jvl?|rt58sh zD3|3YtjS!Nu#Cg@udN;jn<6-yAaOmbVt$K(b@de|OZJ$YXMu3Hg7@34Sh12m`I}pj zm7$pl-#K(t&k3x3%G6d*00WYkUgrdFKVi3CM-ck>9Z@vuHb0E8J#d|9PXiw>mc#Da z$gsf*)wMZ^XvV;pf&|$5q%316kmgBFaBCY!y5A(fY2hRHE2fEhq{Yw@XT=6|DxZ-> zK^bGPiO6wqP5)xRX*-`rfaxjRG$L9gQE%t>#v;TCB++HQBDYl9i{2GacuaqzBfx}w z?4s`4eBWfatxKjC!l`ll88xEgsay^~)x(wd^rs?y0XC{yn>yp!GNM(ce<>gKu?Ne1 znn3UVK~ctL+nj#@NuR~{Psq!PSDbBIl~BJgv}8_|t>*-7D`#^6?Q(Wsn?B`uS_#@f zs(|;Y#x4^Aop==qr(FSJY1hkG5Bg|WOtPll?v29=HFNsxt3bBY)71dyf?_A_&_|g) z&Ey%sAp}B6tU+Ws_gu_<`#sI@U5BMcXtLxJhxzIsz~Z+~4jCINxxY9zb0OCitjb#W z-ZyHsQVP#o1Y>g! z0;m)%7-KZt&Pxb;nCxb169*#{!XKd2ej}h^`Z4)JZ{IoZPGRVBL|Chy|8ee5|M@Ve z_kfmMU$9W&vMIe&|2=9%`wyCWoF#}6X-)&RP6#rVr9CY|AOMcwzIX0`X8s00xebu~ zQCqRZwur}6$tZM`Wt{{`k@F*G4|naQ!_}DTRE0P6i|h^d(%LUhp$eF+WSAk(JNYh^ z_C3q7!e~+7YIob7S--7>qPNW-YJPWEY!{fQFLz6JU|g|JTR!xzPzEpL8Fr7y6xZW< zk5;Wu zs-E7|2}18jp3!z>d4kDX)CEv7wKbuW`4!#q0m>!y^dkw%BSl|w5%Pg5m76AKg!S;ViS zzCQdre)T49VpBK0`2`n5hW2T53ai690)849S(J9_h}~X(D_7Nx@c`@)+n7cJ}z%9FfW4& zNV1Nwd9=!%)=d0Y6NF@11U7T=C5MyVc!pFZg~iyV?8;`+olD0sjJ2I!TxSQO->~YH z-;YxuqqQwFr{pJd#W05sbjv6iu?9mpb#^FGXw=c+Kz$m~f)(+va6X%f03&=?`dg}q zEE2Hf2>}mUa5{=vgHLh1BTMg=4@tjYPoJVpJ5e)9W`J&Pt7Mk^4+;{dDcvgLH0|X| z6+@xxZxafZf-$vPY+>Df)&;)&tj^(+#g{G{9?xDIZR#b<4L4Nj>|j>pCQkoArZp+5 zj4EJ^3Xgp492AL-lp5OnVu~fj7-la`o4MGqR2G@gefCA82Zre~s?z3hlAaq-qn28F zkE5YO1Q^e zU07>wAsx@cyenOHV#lvQLJ&9b6ZU|I7sNuK;4OeJliYB@506}(U!KeEU0jOT)j=zz zoA5&gom@ev!KQ1;nRMbqs@SO0WeS%0*aucI7mj_tR(4}#w96*G-Hsi? z{_uib-4|d|vNj70j6o|M*1YtD0jT@wv6oIreB2zcdmd%MScA%gV#hV9xn&@#`@PJb zT#>de#=)w)n9zPU@`~`NCwKuf3rj`-Be&*Zr{L_2g;l}G@eYe^>1FaeujYtHHnq=+ zOtt&VUQ$0YxqQ$p*C*~F`>QKt)65sX$IC5KWM3PbQ8p5@%0izD{`5Y4?b-6`np}a9 z#9fH0`;gNL6V{E7+UlNT8|i(+3=kXrA|KS z&5clALN#&{55gtn^jtytgG~Ng#p@E{7R)q)_r;-tiynv5KUTe5JC%}-B`F1V(sfnl zNMb+hn;_GLfS+sp+MG9xTgvQ?qy$XI`Zsu~zh*|e`_Qy$g!a6`_Hti;*vHbWL^K{Hm zhHVfTPptv5@^LF#WQf|1xV8&KOa#hw7&622ISd1^UW zUXAngWWH0Y*S{bDi?7HoV=ih}FUt-NC)>q}9xX0kJ7)oX3ZDs7ee%L!C&RB*nHTYl z&g~N1LXZta@%NyK;eulZ0lK{3k@;WFg>5Bq+0QrO3x3eJ&#-sN+#ZKpYD?lxl5Z84 zS37)PwJtezDfYTD?(cQmj<}ZD6v!Jk`T?Kw++lQ;x{oa@ntvu(S2XJ|@5f!UNHd(d z`$-6zUlbYIheszT!&w0TkTnnY@RluO{TB*iR>3ioDhke8WN4~ce&G~*TK>R_Zl~7< z_fbwwY!6eI=f&1MEeY|QXaic`eZAxZ4~Di}iJd4;7+TqSALyb*Hgxd=5-{lVFRDgT z9pKR_5AdnaL$6{KbMl#ZxVZuepOZm5n+$+4p6^Q>nj8)?NKi_G!AhAFu+3OhZFIcE z9QJD@rD_2lPZQ<@=V$v((bIL_Phu@&?y9_U3Pet~3hY)85rff#pKzKjKN0mV1OJ3E z6dWzE=Gd{@T3mU5OB{+B)RIG~ z0F$D-ap=R@uVN?UUi3suyS|JL-1jvw5ui;_7xE@<-qReklM+QEqx>vURU>LerONX- z^AlmNpCfU@_3MTI;i06+iVxJ7bv3~9^E)yOi+61x;7oXkR~)VOldNHb)2mtaG#gOo zx^Q8iSC9C&v=5`qdo6WJ{2kkQo@L8}pMeo4pD)JB-|tV?(II~#2$hooQ|y$%?JG35 zmkd(Ih5L?Z;7fEoesL0<+p>MCf%tcB>yR$62lx4L!i^A&S%(ZQ-%E3Hjpj^Ute@I5 za@5BmN=bcr4OlyG%hrBbo8jJPLH&^1q*cx2ai=iG|EpRnSnt*@1 zo_rb{^Zrb}ay4^+2eBu_i=8yrSqVjoMr{N12o7tVfMfd=YFQ~1pfD0AZt%=7?TB)H z=5<}-vp0(N@k)mj$#{B|9qJgj}x>=fEl^q3w46y(fA}hV^**WT^q6a?AIV27OcK|v1 zXVIe93E=bPI<*Lo6A1M3zy>O09z`_g!`kL?^nN&5Z!DI1U|sVN`*mF3eflj$B#Z=y zAzxrfZ~IAhGSO`i8r42FTN}(4R>P{$8fRrUW`RN~u=&xcy@dPIv$r6Vm5M}Yc|^FR z^BY6m6jG6qubF)GYI9Z=@Syn^NXBK1(bzEJ#k;P?|VR-AG))^ zWz3H;sKuj~b3^|@Br+*#sf-*dhW2lYui;DaU-v$oH@%Ae&Q7u^!xA4PzfltaJGfqXoJ{D6QYs)_69^j++SQrO!w|od{n64z` zBc_;fU3W#Zu8Z+t)bj$~i#;9SMGg{%=JM4i@Ox;oGuwmUh8!1(^u{ph=I3b1W}=fb zt=3EP=!(nb7*1_(dV`kW<_UHUayfli)tzyxTdg~*7%&cB>De3qrHX<|?aN_8%lUEI zRhD;YM@lWUpRJ?%BdK<9G}%Du#Nm(wUeRPTc4od&*ViTWV^%`RIaS(*O!;zc+arpy zhP#m>aU6Maf<1adR@m0NILzmV>Nf>i3@Y{LCVtCv8mdU%b12AZY$NAgA=l|LPD*`^ zv17#qSKSkx-6aQav1D7U)9kOMIzy>MS{DZiRWD86{N^$OvQ*tb-z3w4!pNgW%0D5V3ygv0lH={yT$D6946 zQB)@c?g25pS)cCad}UUR6iqUse}09p^m&dv1%@^~obqKRRQ%g9FP@!AsE(vvA#JkX z{r0EK6Ah3E)U6BC2-7sI9lajYc!;ecDM|9;iw51j08@o79AZ|<>$RUeuOZ&;)Z}?| z2>+=+#{<1OF()*7Zy1)4UDb*&c%?uj`)Wq?LSMUkDQ*U2dTVV>pNl~G5RTZ$O$j^e_h zQR$xY>zja8fg&g*TDQd1@5jPJi;i`XUw)2=RY@db$*BPW1_z+k+Z%0(GWDP6*?WEgLv+`9%IN-dmr?$J_<+U0Yhp~cpGh*PSr07(Kd;uvq8 zLg8m`CzOiUdw~&UNj*72szrPok=_#a9`cr<3LWT{cB?o8Y7RoftIx}zRWS=2@|L0$;|@4QNcY_0=i{K15-fvzpo{m}Xd^3r{_Gj2MmWn7G4G%!*~^M&Wfjl}%=$ndyt zjUQPan}n)9-Q3In=vtxIRcSah8q}O|`o%t;UecM6R=SoWB}B4ot#qmBZ)$?tx;vJU zNb4wx>McG{T|c`+#8}TA+!DD~=VhJPN-+9?&Y-5!qB$pdC# z5-;#$bV_53u#*pn*Awit(^*`?80a${F9rJi8a2l$L(efrx5B{7Df^>{NdMU$S9*L7 ztrz!CDIcJqd&iq?F1Zm* z<>vh&cg|WF^mZDV!6!Q9+SMixVlTg+UIMZoQ#hbbES?J;>hT6jM<@#BeL5T{dpT7{ zei9165YIk^OI5|byGBp{?(}KYoiI>~^ zlh^Z;T>skQ>eKJ+>fpj)#D7)nY@^{YYSCN?^sg=Y$iZE3M;ZYd%Afl2k7BV1T-URW zek1ZXW#F&e@8R>n_jM~*j_TML|5!zYKxLGhH(6n-J%as4vun5S9S_0F(-syZb}IW5 zjc53btHu*vjv+#DBJpn)&Fz6iOibM8blb-8q z%J|Qt1ObH{AJKo9FMF``L-B|u&!_7Ro81r8-BrCxTvZJi$(0lYfiJ9>dN{{pK1A01 z({TRO3tRC|+dVv=<1On-u}3i!{?Elg)_}byDPZ0fcw*Oec@o~6)T3d7VdHs!r`*VW`NIW#~-lP4p#{A>Tf1oma zYwO=?Ydx8H`@h;0IX>Jzk-fP8bt1UA;U-1C7|CJsud|Btdv_rUr~d0ikdwoADvm=R zDcir!D%$Toh(M+CuM>gu6}~_ryylT4|2C`Y$iFuPLUiH3PDF?-e1W9WIQp>uZB~mB z;l2O==7seC#=MB;vfrrCsL(n z>2d8RL^}L7rF%l^|NI_q$57J``4~b$=Ktf#`861BaI&1S zDm%Ou_&0-Te8k%xL9qzP5)vOL|0bpUVMXKOzjrka;mmaCTlpaI^c_AtXe8h~M(t;W zz*x}tqpB{)o)DbjJbINsonwC~2DZKsKi_D5G3tK)N$5l4{cz~m~mHede>sYH;o5**0uLhbk0F^OZcRI>im83 z(R?x9A6@+O@evrJ{Cg*4cD!Q0W)p@Kd3qA#ITP%K-k=QeZbcg+*OI^HNtFi4$`>)u_2J znJw6vh7^^x>)u6-vcgZTXkEAEu%A9rg4Tq=hw`rLxOOm#AP*B-{QKb+j1Yl)@cnAffyyaU~&@bw|mvVcGv=H;9`trrGA7} zmj9iN@A)9ZMeFCjt`DQB?Tfs9vYTh?L3q1L00?)EY-_d6=&u;nH= zmtN<4+ESz_mDut40M!|i3LLY%TTbJ$iaMy{ATgtI%}H!M4S!yCeBpHeeb>R@TdU;r zgw@V_i8hA?n76Ubb%^)%kLyJ528ZIWib_fjQ-v!2H+R_Zh_ggTTYJ7#r_66*cQnmm zD1oWaL2zPkA0B_$?dBp0V~LhAKy%BMxKjs4!qj=ArV;FMiB_@!V! zS%ZcYEx%r^sCo|bai94y8y`gMV9j9JT1kDvWo()vd1c*xs1#RXQrgM$tfMwI>*2CU zr!*|tq-0E|MESqSyzDT1uk}FmV|WU~b%1$9c=)~Xk=)JW1Nd=~qo2~8A^oEyK^R;f zU0@K6HN`FHdbp~ZtuSoLe;{9yfL@<;m}Tpf>3ZxW>V0tc#eCarIhiLPFP@<0bI8-O27PXHeiDA?&5V=Bk?7PFqR)%qu=#$`XKz%GmEJDuOP3u6T;@?1v_EHJ&({yQn$gqBeB9;hveA5p z^7QDavTdv{>N!PtT%$ojId8)o%{WEpJGdcDcHZ=|EvoRXSez_2l${KpUOZSH$2srj z$E%*3Pk6p#s?!Wh=+nIH|7spBd@i4uxILQ2*CYgP=c=*gy<`|$gRVl9_w1xn4~07+ ztG6nP!%qoo*sIeledxnoO809VsKSd*vei+hF7kIFv zp=?Q}2l@|K#C;rHFNrB}WZkPZZlT5|(r^kgswAwSIHfn3orV-#PS{2@?}yH;JzVn{ zR=<--r0&W&eJV$}q30C5wcqHvH}-&@;Q!5L#^%+8VtWAW$TX!?Tc8MHelO=;oIhif z1zp~|$7}TaYLmm(_#2mzQy{|l18#Sc;~nj8er+^<+>oT!lrQ@Uy*jdDj_)?8qS;G# zjneyUvs13SD;qGvBK`e*XZR$NYpfa(87=sgsN1J@l+S#Uo6^W@OiWBA@A>uiQ7ZPM z$)`P!=grQkzJz0=J8Q@TZ`y+eHO6w3mky?imZ%?og<8k1CFjV^eW?t_O%%OI0p%Oz zlz@?)I&TZVlJg8fyw7?`poyt-rU3x!#o{I(P#5&HZPjoyMhaKKyUHgZ;C9%Q7w~us zKUT7|@s#}Xt=*D6cyG7C>9H5Bao>LKvTfY-c-_PIaaX!(wd8GB#;Und zJ!8$<>B)3!ps%iKgHxBG`^@eAxx?&G`0+RiOV8aaK^LL5gMcvOMXM@34gRJHGO7>d zSq_A4&#koN9+pGKT4yU$AtG%~(@v6!vMn|3e!g)C_`ECs!GgX7}=`*^;NKwvPIuka3Fya$kFk@k!;x97T2R)^sJW8ddRM&o5#V)G(Ekso7>zjHd z_sX0^)Gf((*;__ZWT?n0&5%QE<{T5inypT;u3J7Ktay?oaUk*H6Eg0vqSH>O2h9fJaDx zZ(d*mxdhw^K7jgfalnD_nA{TU8JVrYA&nOE zR$dc>xg@h?!J#$1Td6Sq`9sXpbu3eP6Q;76kvK05a^Ob{jw}FhAiw}4+drRj=EidR zaY(OoQ)-{lUQsIsPwaj?G)#d9ud4k#oM~GQ2$np zx#!MFzmVU4>lZKoutZhSy0ERwaHkq|%sowkw#esX7vGw-KHPY+=omo)>+_%7Gv{rQ zv+PJU=4iP(oOFLh_r0n0tCFDeUTez3O*^dbW#XgPC^xj&&0O;>zn3M0A%2Oh@)9AOL>C>b@YAuLgeagh%}E)@t^K+?8qK zCB&t|e3|$86XhgI&4v+ZWInK?pa4D89s4>Ea)%rno?xD7htDXplt{IGTw&)0v?Oiv zP)}f<0_bTY)i3W#zDa}RAiZy2b8|#dtL;#Y@H;~DiN=|+T9g$pdlhT+3+Bfa zd$e=e>GdAY? zM*`=obBqiD6VvUsmE5z%*1fAB|7$Lxhnp>!?d-n5^>3%kErV3mah+=Fhkt53WKof% zW8?_H`w~uM#kpBXz|_-l$CkL?-zkaC?J)s(m8lOAr!ViL$BiqE>6E{aPHPW*P}0=$WccvvkOg3{2%cGDh!ixP6e z>nddshH}P>q1#xPbfv*V0H{Th zt6DOu`(1h=*^11?oo)3q0Qxwh-{yeyOS+kl*M_O&rze=PsPa}GP4K#3xn9@Ebk)h2JJya>mUD#pOEDXzN)Z_{Oyuv}R@KCITx9L*?w5mMC(4k|z=}>7Bh*CPHwBEo|R~Af; z3FvRq*zwcvt*W)xEtkVVo$$X35;NX>Kch6Kx|geH@qEtniEPw9e=pk*j}PliCpPko zw|>6uochqE)L{^w=&zncBylr9{ULMtw>Eeb0&9`N6XA|cS2KID=^R?XJsDoA{Gtkp z3~e)aCgv`%aN806xtscZy_oJdEWi)#j^=@N8a?e^`=n$^cEMmDhD@(qPlh>LF*26i znhdGu|8c^p1c=^|BGBVHg;m6o_L|EeAuSj227%-19$~>=T-|g>CX|;8FQ>1FDSicL zGBUr8>}0tA6)L7oI^(XS88tvNUSlqom9K4frwMqRWOk!3#VO0sqK75sz9c=y3R5yq z`XNahy!k?^eH$KbUFXmciOMLZ6f$b^Djg&+y-ey<8~hTdP;cAF8|+=4zgVC|B6PQ( z2Sa+wL^~Dce^l&<58#{LOFsAB7pm?KIDlHq3s(HnIIEVv>m+M;l@#My;f&rrM>=kd z?(lUb7z%9yyu<4sdKf0d`i!k)M#^70#oqQo?=JteX9>4DPw+WaE93sPtJC9*=+u>d zTD7U=O`7%IRvXn8o}4?StsRTULd7ukh#;a%eb!xj6~2!FQTAGl<}!=bS4qHuP^j=! zg0lV#nJ(##^GnZa!B`220CZwid4ldspn2j!3)}RXVy4_v8JZX|IA?TQmuCsjm3d;C zA^+oXP->)9YkzK<2EUJ%F1Ru|0tMCc*R$eEhhjEVFYbDE_mfQ%jETGOsuDd1`B&z_ zl?-|ErHPL-OvgbSGQu&_D3|na$Z;hah@|*fClaF!WWF&6OSh{u_Mc;pGYeNtDnh9A zC@q0}UwrV311jDnXv$(TRxnSHsKfY^i-MN*6N?pBQG80GPF27ck^q~LYpRmpPi|y) zK8-;#D1&e+_!Za}wyONV$3Hb4LWhk`mdeu8%s`%!Ra#R zFJ;*YyVIy$`|Zh0YRK-2M=PG8?t?A386-=Om~84hk@vw4TAbJe>aG`~e7@wp&rOD8MuEBnM?z+Pv|or~3@mbiFU~n}SP~j) z`R~Uj6hDYe{f=M>>k4|=&zg5N6&+Vvc{GX&g+|Ode+UtK6hTbXwzunMsF_C89fHYA zPlvFY+E0id@`QBAN2X@Lvo2y#?f#w<5Vh#)J~r}Q+Y2@p*lJv$3aG%phe^5o8T~#u zM^x*IkNzbx9_@|hSzJ>Z_VJJa%~si>41KFtj(UKUH&tHnLa4YuJnGu+|Ek*{F0OcU z2*=clTi+S{zGYz*o_r-MYdq*i1^Sn9F$VkaETEx!>K{CONA@*-t#!pxP<(h8YOB3K zO|+epWo?+DWem_5>-~m*LbnDm7K-Fs4Lzkzw2m}uFBv;Zze~&&SoSRSNl1CDA&jFs z#7frvvih+HM-(kTfzk(Dug)`UxycztLs04R1{L_e@D{*Ni(3={iao)GArV^cd{3BHjV}9ch^l_88a23 ziIo#WXSeUE^4v<-s0Cc|tB6+jhMwTNm;q;p=$=ZO1?9eo))krCtgM#AtRU+YvVHp1 z#5q>Z*63S5e{8GB!{w1<&O{$ysgc`{0n_<(6|7@|rc^OjJx>pXxln(GlAFY$OwgAIvg>(OqCkHEAw++L=pIm(YIFo#ojUf&*Jii2||?q?dT zbO+3r0u0Dy&&+{FJe&mk;IIc(+lg5%HIW^udD))(Awsz>vY@%vcGr3zQ&|+4KNc0b zqPE|hvFB}9PN4Df_wN^^1JywB75Eb#>X4C^_c$1grvVsP+_^^>X`H9BYiaE`lHRSS z7aW+vHLaZ98m*#RI}$&h!-7hJg;O?REYGqYNQ*ST6meqir~bIfKFT-vQALS-C-E(M z0LUJ1tt=6iRbq0PoRtwkSS~79=fB;K2Vfhw zj2_9wb$(Y!@gLzxJgss@yF+4t92M(Hw5z)JD~cAV>y+r8+co5_N@4O4kyMCIurR*e z+}rBEtAS*I{rVxqFb(ReehxUuH*EbYdh7R>8i5A4#jWpm;ut0_=V(lA_G%Wv1Nqy!Ge zlXSz`2Je6j(}(k9od;QFHtANdBwNR&rffSLDciC1iwpLAO4#8vVT=xrq=3^G>WWp} zktDV>3_E6|IPt~!VX4T#$CmRU7TDoOZ&YhB*3u%#r_QQvx)F2HY0aisXUzy{f9TR$ zVR;PeYdvnhACm=2DqX($H*@JjU-0?v_HS_oFuQ?pZ2aQ!B}Ia!27PuStuoaH7K;=blnAVhtl1pUECz~-dbA6~(X+WR}c(X`)> zNFq39+WQoTJH#Ku+}TB{kdrNKzwjyrI}BQXPWMw=SM08qG^(N7yf>V9)m37TJJT8$ zxq>v-uxZG$8k2=uhcBLyD3*ZpJ<))4nCtHVj5gbA8Hf)9T zlPr)amXS|wT1cfYxL7VdA==;@%~s%D+zYL>y6xvhiHCZ|?ukso;SV2dB)+`7hh3td zijjFfTx2~4OLf6|+59(}G`s@KdIDQV7yCt3e~uZZxD1!)N6Zt{{h?q7(_IRx@m5d&=gqo71|Bsm(2S!cww2Cq z3bfn|peu75U`A^~%inY`bdB;VE&rTtzFY1MzmA-{0U194k#x=8ww5D)9hnW61hK-jSXN9U6hdn+API1rD z4m{H(kx*lQsH{D|Y~s92Dedw-Z=UMr0u+tA(jzxW2e1S^m2jZn(3f4Z#$eBsiw=FH zsRqGijd`p6mXGP*_Z@rcRGBHA(hi&P(E{V!q7`P`hd7yrf?UKbfv++1PPi(Ds#L?M z-aCD89&9fipvi{Lkmi|3c|MDzpCQ996{5bIiT=Kx7<vsn}Oyxuaqi?2rG{2+Z_!JjjW4%H?4C++`BRx4OvH=)$YbhOi1wB^1(oVnJ2y; zAMPMODeQY~-v(DRR1CepE4Tt%Nk7|=Cy~=S|SXYh_dXY-uJ!?UCXHphiyzr&nPLoU|FDLk0zrDP! z>0XD!FrWa@;ZD!A<(aL~^pQ5jnq}nkd5_2ZRYN|Ov;MPly%19;K!)e1ZFiDV`BgL$ zcaZ%@W7EdLIq-fvD(fNF0tM?S^w*QNdQeJdd(LA~7wk^u4W0v8!VZ9*Ef5v!*~{+T z1FD`i65u2An>Q+?={wJ9%q6(%Yy2OTEX2pxSg)QxWR@6Fd7W!x{5`7nCQ#?6RcIZT z;KX1wm8dlPbWR!N^vbe9x@>Th1$NcXVY15tE}_=>h~Dd|k4h{;vN#=D(MP`}4fi&d z!s;E8E1`D;M~9V$)TA@Jq}ycKY}$&Fj4~4XG6ni&;Ty$luA11vUeOaZBUxf=xvcXO zt|`~V=;xi#Q>C{u7n5B90r3n{yf46ka0%!`c`B=zWS?w$FkA>6mw#f-labJy@=|A> zXl8B=qoR5yT(tK_r&(aG6@pVOLH1l%gd)zHXd!uX@tCBo@(! zvGju;p5DaERwpLOZR6}1v2eqxMom^0P&77@o++R~Pr9rU``3Fa9_|5od>*1=lUHGV zhI;#=qX8J;n97Ve5k)r#GRpi&rfEo(+g$mPi?_6GX1@KTByxp|$_`@5MR>%UXk0S< z>$NUT=VCC&d!{@I(Qe^gsW$+MQK=i8#LC%d|I0kLB7&Ved&63bFtORq;v0y@8}28v zv^wvp2={I4FAn>>MTyix;~IGm8P@us4g2;!mMl_2l(0I%Duc9fK{Fh8|l|sOP!OfEQ71~*}Mna-K#|lU!p=^N+LD9spNLNhXu8r3`B+AeXwMm`w_hg zEyA&%orEWl-Si@3Lfd#(ZVqdW!o6XDsk&k?1_3~Ks0|8N6D~Jw64^8Y%oAAFhfN=9 zAqZb~%d}QW!dxyGFEhG*P&FnusYHB&rLyo|WqR>DbM`;a?57OWc#dQP_Yv~QA)BoX zGyQ^GFNe`KXhRoFf)^! zYpe({THMdv>_faFjHEmBt#rg*NXP4oFFfVJ*Jqj&ho^nRatIDB2{@*b+kXUc^-qmV zqwoDkd4?yc$;VcBEw+T~E}?-rV4rkNQr}VPl0IYVzXEJ!`kvPO7BQ~}=%R0eD{j4# zb_5$+ORFdaG|@s6A((P>;PB%2lC(s>$!%(%-Bn-k6=i*`5fxl(!2TtbI3fPR35!Ye z5+LnIZ2ndjX*5<6(WZAu+4tQnzq*4QOyf=U(_X0Hn;P#7avKPA?WF17;6CdLTtsqo3 zxLdg`x~F)6D8Q*})&*_dCMXzN`kZ}&*&2XGr1(va3pn-Gux7YZbU&V_d$FIMm1?!| zoXcaXvf^6uKu!S$#}Izz{i*q`JRFDZ6M!fiov`qo=qO zE4twiY=NhP(`%&k%ny^vo!e3~2l_dO(033rWBj1c-bTs?o#nEHDp-v#Ys8$ zm;2pnHoio77NzcLKUl->l24nBDQC^+s3KT{#z4=NRt*_wf{}~QXV6jJ)y2u~b}&hw zKGdvZziT8z6=*=uh8!$ej1mjyb{sJ*zuE|WTo-SyiHM=rOx zf=gY2Ym*n1I-rH^MRnP< zSW(r#3chmnMi6Y;w|-=@{XhZ{6`9ySj{flW;=?Fm|AB{WQ`Kd@qNk{i_eJh5FRZw0 zv9Pv6_q2~FUrNDuss(Pt{cx^Ja!NKH=jcK^fKTjZ9l2^fI%IxoP3*Z+OI4Q~CnIBU za+FSGyAY;LnIFoe%)V1$B$~vf2;B-KR$b08v!4;?npV9HB|Lq}Qs%_u^D1o&1tQb^ zQIIm3r@2W8Vny~G9ubTqabKT6FF}s@ptLMmcPaEPXM}I;muYcUX+Yx6d(wgH)RK@q z=ipzWXEphyS(VC0E@YndS>LrG8p$u_uJ=g_7h4;30k)P#g*W$#xb!4yz%S#N0=+S_ z$)YW@b_K<8wU!r_uakZ8!S=4$13Wq`m{ z4}*2fa*c#6iDDStK4T*Rc`=KffbU{hJb81^tY?ng=ZuqU zTHlL=)>xGfDEyDfc7f^e?Ark{G925qPcAs}wWECM6kCJzP2z<|N(z;FeyAPYSPHqa zfz_KPX8`)ryjm~AfLf~iB4`|~l%Y3$u+9S~X0YDn!{Lt@I|mH+jW^jphQFFr9d5&; zAiqRrmyPlY>0lS8QQd?QjjF?Z!5#_s6M_FZRVXG?yza7yA2|>pM?;E!-HUOKLz`e* zu6e9vLxs0TNVL1C_*sL-7G=CM;Z#u1RC?=)Ip)?Re~^aw*0jiAb4~>|3MM$u!9l56 zRtqGezLb7I{XgdMWp5CEH_zSxndNmLGwna#&yF@E8SxYI138#2FmkHnL}wa4Ylvey zFfF-L^3lj|u%fmf2H%z3De65-_+Q+8^;?u(*S6BBs2~UuDvhLcqk>2`N`o{E-Hi$g z(kYDs(%o&)DM-#x(lsER!+d*q;^uyDo`2x`&BKGaVqa^oz2ZF2b+FQBrcHG`z)$zP zX?8AjKYMF6tMr{_&AXD&k$yA%Ib1z_%VM8Lp-gF3Kd~;o`xUfM6wI)VHzRb}Co>&6 zhud6=!bJ&kUh7~~GFQZ8+AqJsRH4e$oi(rFE}B16-ss}wHOw^pByD*3!~UHOc^R*O zXHk*jQL8=~aEI|xH@5n9t`M|Z@eH=hjLlCxnoddWcV_352;YsQN?Nd40Xy| zZbo}OuXS&{5&xz%c;nJ1L&eko)+rhp3KTYjCueGhG4E$A>1jWL>Aie3IvRUVLJuoy zU^4Ioo@4U>Kk>zwYnSi;#$A$=U3DPfF2S`5{t-$~O;0VZ>V<9hKS9vq_kig$(#jv_ zk)ZjeA+Jxf*Iu#2WKa2@cvJZh@Zsore@|S#(Eq{-azoJO$;_cU{gao${(v{PTS-Qi zBCYk*@<%(WcDkX|*43eh7FEOls+kYmG#@eF457FjbvTxd!CdfpiVPEDHr2x@eJnfr zdn>Ae#}CRcZWRCd2!HoDLwNr8S;}Og*NbACc2;&9Jh zUBkaocH-zXe(mDuA87+|{{27ydeQ%LH%4Oc z9O1K!alZ3aK*cp85kKql1vz>DRfMI>dW|gv{RBrW9ss5jub=`OgQ!}`%Ra}?2%Niv zO|>avjvr#-ZTvqY7DG|cb;5VRCIcU7hFppS=f*{QBE((ooJF#~^-{a=b;)CSqrO^j z5~SOpNEg~uI)s-xDH?pVwDsvh8+|>~(S|0G4^Xx9E$^=b;_r@t+s>E6s8`>9rSq04 zi2bF5IGaq_|A9${y^0}73VxbMfO<94yjtZ;U(F83$#S5lZ*B7X^+Qj5m$9^D|11Bx z;dd0CoGfhj5=}K=g-Ql!X%o$Z9O*XA{H{rpl?@eNHPg=p$mFBJvV0NPoZz|4^W=+tD`BK z_$n($>XK}cGZu0P*)D2$IwOi`g$H<_Gw`1iB>ZDQS0}UO@x99)!%tlIR>W&DHtJ1H zBgx3`tP7^uy}#Ry}kFQ^DKf zLm^AA_1&-HN|=)`~X8s=+7 zd7z=^8q>@3dG9>0{yBy_!})W}?BmI$&TTKkFJ>=+c>i}pRbdk8c&m5R58MFl#L}h- zwvV-~GTp9b!}{j!|ANfWD*?FBmmd1zFLLiZ&vW*FJQyRt|0)4mE*pP$Zizx-tX`Dm%*`(pp(lFww!E?!8@3PvF~7HziPi< zv(Pr(sSb>qhsO+?>T2H}XAe{xb~fQcGZu{Z!VE*LCpQ&ZYj5HW`J62|2G>hXca_0E z{ZKLM{b;Sz!fzX=v*4*-c+!XNC(5z#G|@4LuB^*A-*rpng=9Px-3MwtPHaPlC?Y|6 zDG?*I*3ry^x}eP=eggYas{%Lzq7byLtMgh-Un;(1DZng-=gF&(LYZTtjlT9;PsDkp z{H#}7Hs8HM**IZD^g+V+7`(y+E(~LYphlz ziD5NMWjC8ymb=3~JHmUx%_0wFr5y=Z?B6;_Nugi1-%*61%o)Y3dCerXr7~94lkkJd z{Pj_L=E(7xyxOe&9%!TCJ1*S^lP^^FQi>k#rdcKx5iX$YaGwNF-}Xt|i!NnVkl*lM zjaggiYwwSjvZ8hm=hQo|P3UilCMtfIea}+x%0p%>Rg92ouGfyWnRK`{ypDTPs*Ij3@IR)8Vm(*C-DgrBS{1&bIm$a!SVXmi^2Nl7 zS3bSHj1q|2?&1&VX-r(0-F>wK5T_|zNp*ESBkLTngglj7tX3-<_1Y}u&nIQqt+3-> zAbBO3o)Z}{(%r@bB0gFuV$kjMLAuM#%sd3ODN@se{(-9DO9J(;~< z4>86Us@1mdVgugi-1z}3BE!NXh}pO#9r6=@MH|t__$T$lv~SNYW!0y<8RAttY;#|G z=CsDILR8;9{mvi3`g|2o-i~fcBUYkqZ7VMIAVlU)7%F!#)xN}k$2KJHfOIXqyl3NY z*x<}OwIz5LWtKU(;2h%l^bAPe{nHWt?7+=bQF+!)wK zz~mTk79`%x6uERUGBH$`vgb@>xI^X@X!=X9Iyk z4t|#o`lSaa5t@B~?=F9xep2->d&MORtDXA-``%I~i~HRJ5NoA(#?D%`s_V@amhrkG zRv#K)#=XieUHvAVKD+FbH}^vI8tQw@KlM|3Eb#mD<)Wkj;rAADFG$$yDTTJHf*GOq?X(~Mn|Ka1My$mx~ZQx5*NLmco5NSRu63C3LWGd3pKlxoX z>C!z*S>>^0Ls6+;sw-HM#pBrKKLtq|cW=~!CLg#gI^Ea`3A~BdzDiq{w#uK4uY3o! zXHm=`90HfiZhQs9GXASuwm9{=9*e8m*lzDaRng~dkHU$?^xLL|iJLqGPBsHQ*IZ5V zb>F9|d#(lyB-^l0gjSv240kz zt)g~^U~6ydC3fm3{)2`{7@Zqhl4g_CONnr?lRasXC@g?BfKZj--Qq{Gf)@d`Y}A=NXPtweUq;Grj1E$nYg!*Pr-@?pQz4oO9Sj4{+;xUuAX|&lB6Wk|)z2y` z34igyCMmiQ0j~)^*9!R}mW?ysIG__aLVO;5Nn!PNi56*ecHN%YzuP;eXBe$Fp13_> zx72HQcI9B2Dq&A~!TE7aKqw{czPq<>q=I;mn4oi@2%Tv)a?QdNR<{uE4$?fh`RmSEQK z^=aprZyLp5*F1zM)>+5}R#|LQ>N-*55Sc!I9i{_SK|Q#*JjxT|ia;-`D)>uDiwCc& zMHLCFPb%%_;kT9#7p`jBTxA-{4&5{eXMw~8Make>p z0#RY5YzPTHSd>wkdde7sj65K5olHG{cMT5TIkbIzh@f2zEWZGB%IhmT)<9&G73x7RbnkK8*?1Mm*-b zJ3d0?ZIn?c3(*X^-btOUatz|k@sE#DhqwF9F)L_KIeOydM0jomwA>ivK-sbnJ=*9) zsEvM7ZL4V!TlwS-Ta3a}v0O#`lhH937b!dwpNf9BF24mfZlzt4)jaFrJ~xQt6yB${ zsX|%XhzRRVI_{4f!q`o74tQSct*jb#^@BHm{#u~qcTx0ttHl0`*Q0mY$2>akVe+f< z0G%EqO1_Y1CFmRgdx zaO7Jj9C$QJM@zNm< zUgO*C3d4h~1dehd?!JV^;V%uHR`T95VR~bUx&bN5xm=8yKZfPE!9A*H&rqEhX}}UY}1}7N#K$0 zCfw`vUOpW?iFt8yJGKbBK6&Zut_W_VGn;nMcF2YqEY$<`xDIqmd#YTcRR1BD<~%^l z*C;GI*=(LZC*yG#h*RqF9C4{;D^voGsj){;1$)8W6}C(zvn4u?&VU1xcMQT}>WtYw`lOV&U$euHLUce>jZI(pp}E<#}}?dIV1EUB(xXarthcZ8cb z&J?-U=aNc*Z}PlDqNTKh&ZJLr+ag*PIRpYUaxwqLG`~=!UhR|f@HM9$Bh_sdw|=wf zL?!Ic*sJi$ZuaJhI5u5NS-IKM45_3qx*giPpPr+9--4DyToS4OH7sGb47F2Yz&Qby@rPpiFmP{elwGSu^kS%LDMwTEC!Bi8!~Y%MX4R5r z-1wMYEX!%XNP5-^Ni2!NikOKB@W}#eUF+-U%!F8tx}RsQu3JG)gtE>aIaZHn>%)w$ zSFV`96Abs~ZS7q*GUI>WowB)JQO}ooZQ;S3ct5Xa=nc#Iz5ul2?=0H0leA$b9EY82 zuyw&D_|NZmXzQwMOyjTT$z>+z%)7+EX0UBS0-|cR#e8&f)_z_G^=EEWZ6$GM%3!0e zp<#jw2v4Ix;*Dhmb^fvfAD<`k&@$oEB_X{bc4Z|8T_#RDL0FAE}xZ+h*quyG|UUc)&*0 zUg}FJtXpzC-d)jKp|+(C+pL)jYjJl6+ik0zx*BFv-%Hx<_eoR7DHO%6H4{4-KVdA!h_fUP!8PT1$; z{sA!*a4ax=2cxcr>{Oz&v34k6>ZQ7+zH{(Fi4ig|D{Ew$?vF@@K7R3A_UY&`&FWnr z_ekCSLHd)aPY`RTYNIt+mHX%7VVDa{pLFvNpfOFJ`8HRK7oOU_LhRFGPi<)IH`kfQ zBkX^>cn;9r?4uR)Y96{>cNb%tY@`j#)!e2~C-p52HGB+GENJ_#SRmtTl10O{$CHP= zcJJdI&kHzJsA?wU`)=B;tA$PKWMMhxCUBPG1|u!byCh8HfWZqy(S5--IK!7v`JIYh zyP)elte&etcTT{jM}J%ucr|o0@)=brAqDTDPbRGhV)vISvuHG1%T)|Zaq-P+>eCtQ zAgW!jmb7;*omvyhFDRMnDpf?uur}(}*rM-?rsQl?j_O&>wTCONu${aUJH2Nyj07;C z7}Zep=U<+1$fd5?8fTk5Ul(2I8XI<(XBS*2mRCUENjgO zxGh7Q=fR|_X8;1wmM!Ye2|HH5zJz+OX-~`S=Jv<4>DG<)`<^*QbM7oA+3q*^oX$~e z+uN5eF&3lWQ3B0D{Uev=(*<@E1AHF<+=ujKr9Lg6G6FtX-M53&SMs$<;~lRqMWQ zwK8(W#;F#2QA6-<3wsKGQ}>;#HeL?Vx$^90$!^~QXtZvv@F{cl_EGlX{|;N?)qduB zHs;dXBXE68%WTYTJb;)v#(lkym{rQF+9h~(- zXsw_;s>{i4!oFSorEY`uIF~0NcIFSstCu(~DXb0Us^+Pxck$XxEQg{A+MgXQdD1Nb zGK(=FVrQqgKrMU%9Ac%wM!kZO>am)esaN5kZ|Jldtz+Lx!s{?EEk=9i)Y!=ru{R27 z3%R4G%ofnB(ViWZb~?{6bxT6(wcLFZ#Ax%GbFo+aV1s&o-bO=Rj_jPY) zrd{moRO>&kdLvJ!4}OQvi_mO-NwTTyqg=X{E)T;)|CaC2y-?Q z>4DPkWcM!aHuH6i%rU-vy#zME+xsCAqbMrlJ}os8C@eYW;Us(Qc7Sm4lSe48U-=y5 z_K?A>&1BpV3ckMGD8{z^hq9`X5nn(wcrGtx;OOzvSLt- zYUAk=$rBxK^95g>3Y8c4l&Y-9jR3Scs&gjYZ6=t9>T#Nm-8W3Vf$SG6cv}uVobARC z^Y{AUuW&LSq{psAp=i@Z&1W9<0ExevVrbCv{>B-Cx*7XUe}$l)p*y`ovN>o?hiY=( zS$2UB9)BA8tj>KLatN>~OKF;|h|OQwimP3oG7GqWfP8|$E6O`;DnY5XcbKN!<_L=q zGrQr%hBYPxqYi8qDWxUS?(_9Q%Nw113+!*oO}L&vOg z=Dhs#o0Ayeco$PT4=IZlNLO4tA?k?to9*u|m%?@H=bs}081J*ixdB=Chp)8McF(kH z9kM#p_t(q&JzDpJNm$kaW2p*6CEu5@aP~+>7Qt>V?~I_gO-6+_VsBIiSssRMtK+d6a(V3PU1;j9X5D0QR%=)wdXz=DKbI&T3V~Idjs2tO~O|P3!(8_ z3;QtJaK7gdbz zKEO~4__Ur0EG54R&M#hi(jT_%Bt0KVq%I5s3&0w3JpokzmEt9q;TbUz3Xc&~h@#29 zeJ#RJdbFUz&7`zAALjGuqLTkXb|ks@U}rQ>p0 z-jURkzPUiA6o&S^gY!=YHRAnU^i&+=#f*bS2yISh@YTN>et9Se^ChLh8?G_ z_oowB;q{Vew3)4%4}6xy4Zkna4U}Wb-FFu0cm|f<3E$(jozf|pZ;-e{%|<&_J>l>{ zbN*eKoOS@st&@lhnj!U%G~O_l0{;%M_hS8;9G{Ukm?yJNTA6uM+KEub z*R)D{oeMrYO8^h1tF%a7sW5}0eRg&K!B%5QQ{?}RLB0Qs3P($wbs(NJU>*mrX8G%o z)D#XN9!BWr0_O5Ino`F#479xfKaDfl-Lq>{tie{r5rVRD54V25o^4|q29)VC@8e%- zBSoZ@$dT%%1^wVqYCeMHRogu=DBp0LERa# zRlXa>tt>7fSwf+`UP_Ts+F~Bk*s>qaT9s3J3~vGlKI8ThMOmfXMu+++9@Q=w+l^=M=O)JW6R7m? zcaMw*F6)UpKe}arFn`(2Or!%H4PC+n_OR5vhO|%`9g53hio-;+Q1$flAqE$*akRu6 ztX4NH@+UGVmys}$y$&*n@kCgDaTB?BwP#j6$Do1{X)Q+Q@HEUW0F*g|k*7PQwG*av za|VoI0R-rR@)5lp5gMmf5#SJJs)G-%T*3S$&%8vU_<+^nCG8J!k9eOtUFU7VqnW`b zZ%;+jq7_Q%ya>Z5_9TF0zhRyeaYNDH$2m`e^9HRAM$EudNBLml&BXc%+Tlf!jDz>h zvfM=q@-_Ks^WIS_(F%f0b$C%9(kp}>_$qj?cXHSP0UFa;isd7h^)b#zF-A!p3rZk> zo|~AQjF?izxtOKI@m*!gk47q{_GNEYd%nZk|2l>@nzSOgU5oo(Tl6^v;oy;%cP^^s zphV&iwJ^vhkM+PAiu$^{@~gZ~B6q=Vc;id-Rh7Qycb2*a;8ifTW9$CgKh@E@LxWr_ z>vNhz&_O8P-@tgwmPS<^4Xs;ToIz_>AX>UJS%W)YXAi2&hTMIwEEihASmftY-kqLK zxh&{RGZ5ySQtE`&OsErc&vfmUop^ett;duR++`BCkRb z;0rl6PQo?;B`yR?;q^@P+9LE{wyA!p|u6NV<6oOYx;%oQ3mjUf)CL(B4peCLYrB<+j9SU1gGGOgKb7Kqt|E{| zAeg*9C*cs^&b9~Gdo0&dWG#vWI+QeHr8$vcbG<;GAs|VRs?KqpHuO#k+lCvy4!k-# zpQ6PYJ@&d_a#Dn~FY;uBY24M}YV2Cickk8FdPE(?j**!;Y2^; zcsydA*!~S2zMl#f)141n6F;COH66}=;LLYY0n!x^+h&Bzghevz&ytC9=_3ZQ-_YEK zoj#%Vjc9s#Ez9;&7h2gt8Fjd@LLEj>=pm**`OSzl&)Tn%I!*(U1Lm<*kGHUiDa7Fl z{J-BW7f->wj#si}?-T;sR?<-eeW=dr1IfCHeOi;5ul}4#y^N98n(A6@AHX@v&)a?5 zT&GPC-qyd^$0$-2a`iZjncqS5hd8+Lddn?EIahsVKO4gpSmW?vR z!!n5+{rp;FQ~mmkS-$v9V5iiY207a*tK>(c$x6{;f;N%CI4F+3;Pu(n3&WT%d)8Is zJn}MIBg2xk@Ps*jc1kkXoju;cd8)y^bQP$+hx*GcI7s3lb&8&3_C(Ka0rjTPv&O84 z!(r<>Mbc{V#G$~9(fm#BrKi_JOWB+E1v{hU=g(w5yLg}MhjL;xOak6J8Of4}$L@Pc zd{VXwkYUq(A;Eqk`bT}y;g>w$_^_saihUs+^#RDf^b7h-pYHe^6_M-jL}}mcEp^?T z+Nxjjz9zI@LPn;;=1Xf;P#P$*)AI<0`hK`4*3VGu47OLIRG#^rxyEou%(x)(n|L-QEndz;O=CgHNM3@5Ki}~O z)_AL%tk}(wrEsNV{g_? zDfH8p5C_v@8-k;Kj+mq_k;Nw{83C*xai7*R!bO7_tc`b7l%^}pmrg<0N9BFcjYI$A z<4Je?{U*WBUmG^a<+9yQpN1b>x*|a+px;Tu@E|b+TO~qvKD8S?h=s#e<@7o()9?YX zPII!Fnb-JoY<{OA5!U*X74?5|!|G`pg^Vk5WCk5IJzJgl!8FEKnx$g^^(r4o>D!(; zE$eVAOI7)-D1S9|=puH;!2MPP8Vo5m(XJ3z`&t_^xKP{09d@89DALr0ryjMlFF@Zd z4A-pOswhfTh&$o0?TXsVM781`vYJTsBAF@f~XNUs%eT?$b@C@gMoc|WsFwP(8$g%s^9Z%C^e>E&(Zi& z-4H-1t$K+1X>jxXNPqFRmYgmz6Nr6Dhc z0Sze>c&#LB9xmJ(QT-f8onePGwc zls)Em6@5K7J2jZWJToW$vsRm51+_PPWZJ%+GQ)5tD_$>H#;x_<&!DT7#bIR)Rd1ch zB^-xyU$a(Ea4{d5I4KFEUH@|eYNF$mt`j$O-B(1p)^iowqNGB}C8tk=u^oV-pISHM zCs1p5 z{?$9eny5A7q5B#H@B|qsJc0jx`jXYW{6wF;2V5ALinr9V^&wF6(FT`BYWMj_Wu{Gr z8x<61+uiKp4A@RLr8ivK!l`Y8pQX>tJbxANnoDe}<(4d_d>;>WE8s$yD-n25h0Is2 zd`W7fJVmK?6tN{Z)UrhzKHj>S7qyBZq7XKat(ex+`QF&QXt!>yC@DYK;jhmKS_bd^ zVtU7kxAjt+=&=t3W8DSa)oa@{UJ6p;-yZdZ!rYsF7L6~)KkSa{(}>fFAI&cAl|Kr7c(@%UDBP!6} z+Je2?rB`FoRc_S1wn#7jAxoSSFI{8&k-F8E({`fVlI7gp21r@Ts67g;phSU|OqN5) zbBwY-#+zt(4XkOqoyedbKIh6kgUKUk;d{XlG?v%QuP&Zc>H8SFu5Vf3A}O1BLb={$ z@?{T2qUXm}>WsRrdfC*o)Hfk{b(V6rb90WUo0JIS*clnPD;X19T%Z3~yb?A}P1#g@ zRF7v}oo487$W9hQ&qscLtL$;@qUs>SS{_&wbcTTaJX9abhb;98US zK@?{rqNss>VNd_}pK(EcF~da#qvD-jTyVZEE?v6u7-VgiV`V5hNhiC$t3zF9*=lB^ zZBbR8;)uKFd3a=rn(+YX96$*{fW-v<^FJ$x>PtOAQ&FmaJWYtBM}K#vqN|~CFv_?& zIBFJ|qA{`5&9Ej6ADw0R26{IBMl|9f{zh&xJ^xe)2NybR!O(HvtnQ6h99fWAB5&_r z`_7Z(AofxtrGlRpX3{;1sZjvtiM*)A;O8uHIC6Bo_!R$nASGgq%mM{wMD&r97QPBB z^EK>*zIzb=fOlp4ao18(bN{1BdVNv-l<;=cx_{lK2b*_6K4$Cxywv&S#3}DZ$SbF@ z7*;aL(VeEARuaidW1@p1As1K!uQ{uMaPM7>Sz^4uZKo zqqL5(xqUGDSg78jw{ziIlumOok9NXpS*#h$j{SKw_d)|e3F2aD5SM{Y#3>nQ=$ut* zix=u<>jW33c!QWG*%4 zcs+fi=5c+VhizKJ;%c_^Ex4i?UZ!Ri0blFv|;;7x9=~~n2 z@+v-1sD-*W7OjYO))^^iy6O%?GrwzMID%rdZ_HVSI_jRkOP9O}OW`)tNL&Isx#r;X~p zqh^tPBd&~+cvlPm(0S&$C0Z_1{!%o93lFujP?Wxhr^_x1G5q(P zhO(y)37Ryn1~ziS?Lt26`@yI$T!2#H)*$z=xm3M=mEmg4^4?Buxh1eLI~;DtcVPmBTlx--D zVNB4>jDAmLE&t~ah;LsdzzEnA{@B|Ea!rGU?WGOWqUO=g1YXgNUSSp*{EvD35x4;B(JJ{06QB29cdMjUAGa^lUmOS1lB)eV@?pa$8^xpbY) z_n5+Y8rZ9)n9#xr%tzbG;A*993Nuha5RuU;;p%Kg2s_DPVJ9S~)@CH_loTI?!jg-_#0efI?t3SqYrZ;(v#S`Ov#*MmYzXVthV zMLmc+As|Sq5JRjeR)JDCY$ZF*#vy<9KKgfj_67q`Jt!l6336{Zh{eFzVG$nHI61jA z(Me-r>0knaa4=|-5@_Fc8d*&>)Pj2XCzWxag=^@z*psWam~fj_YYe37zh4#+<#77X zvtfZk6d5(;KMgk)XW7;Yg6BMUr4)<#_dQjVNJNg=^@fP}{PTO01_?F8-x`A)S6#{ ziVz%Rg(abUQ)6nIgXt3JYtTxo9#N3ixh$o)dZ=gR6W~3M#vstpPf_9oo8EAm@x{;Z@)j z$MihmJXXVb>JPj3^MQKxmQSZ(FZ!k6wsU`_^d3V7h)a`_%(i?~MLeLv^pbZS*9Q$u zdRNhUcvaFQ5L~ujIs=e{ySj&rIUzn#udY$>_MlX}Lktao#_(3Azj-%2oB$i>DeJ#w z=X)B=W4HY2U0Z_!xts1ooMtEe6l-aUYxAGYPY<_hZaOIGSV?}>#bpAuLxaDS=l*RK z-BRMEOj&aSxmOjgJ*1HJAmsz@hWKD7U$s2`wZ2#sHnkTA^Fr34Yvj$d?R{U_`3--72R2KUx58h6GaUGU~AmChKFR1G)pC4YP9?{&;Pq z5DujKy0~9LDu=n;k3Pv#VD*V1y?`??{Nl|O8R8r9kl|wL^Ox5_xkCdJ&G1c52^{(kq~V!9gMFRf7`0MpY@=cR_6h;t8+Rq|5NbG6xQAcdvL1 z{U-d67<~)XPcjGgU7-I1upeEY@6qt$G+R;(?SZ7>QLB(0>Em1Ua_se;ay591sV)y;@P2(Ye! z@MZ!G2i8o=Gewa-wK6L?lzek8@hkCw6d_mM+7snue|g?F6*RPUNIF-4f<~^t? zvx~@l#1pP3r?aHZ@7u1DFxot>sx>8#K94=lzY*rs^r|F!Jab%GdCci=viCmy?c4zS z^E%j{FTD4L;W4TTr9jOZ2K1e8xz2ZU%zXaJ5%o(!h_#L&N+=D;6BxU|J?nSX?eXXJ zq;1~be)vL(iOpYuzMyt7afNOEzcG|}3Y`*ZEb@p-qiN5GyW?4y;9ow%zYe91uj^AM zSycsu=MsEq!J*LGqxa1lg%+vb8_~}AKvnxElJSe5%R2ISv^nhPy6EB8Ktc-BH&}$h zNAtzE6!`C%J_FT7puy9lwnF@!+^Qh5^TG=|hl-1~y6I|L$&W^xzWJ2?1;gS3`&Ov{ z72>Q#=g;k=g5rbDrx*lE)2OftYPtXC$MtqyBygjdkec6tyar#d2m!D7h<2z7cn@As zRbTdCRE?C*32%!+`Tg@cAqCAqCbYAi%CiUMjbMt2+)Tf0K{XMj<=)m{bnRc{`G4&Z zB59V!e4%&H{rrtXF_Zd4=3{R-5Bxy2D9H0-#Ae`*5(5!t1F zqGG2H-=Fr}Z0yy=?W{frTLg)uCjotC&bFH`%u<}K!Kt&wAi#9z7QJHX-8oW5yOMdJ zgkQb~)R%K<+B$uX26gLQDl12{ta9l=t-3F!KjBe3NUfXIDE@+qeGLYIB0R6H*rq6sN3PRdPKLlQIDv>c9$azL71hk#?!fTFC-7Ft>54%Z!W)BaS-Nvzzo%z}c4Q!LZo4Zg>DV;EdlYP9c%0wszQ{x2JI zS3|)8WhIE9d4PrniUkoWkHvz+f2Y9`!z9kTIYc>P7&-|ldRyR>~pyWvV}X7pwsAEfxs zeK-){+A%@Rb|1gPg@%eLNdS@HE8e%Rbku8tj)0Dozb3=Ok|)Rgr3v`s_-ey%Y$tPC zsHXD*;I)=>1@g*wja&aibxU7Hr6qM>RJ10M|GKu3#?M|dR7v2k^?K2S;cy2tu%Eg7 zhiDt4k}i}hwd4JLn{iswC2I)N#UN1p6Dqh}R(yYS(K2YT{jIw!V^^MSy!jGquR?=e zSJ29J?S0ZCIlitop~=91zvI7lc(6YR{{E3eGNFyf40V=izNWaBxrh-p5hd)MX>Fo+ zp5*=aL0eIDic(>wMcfN+-oF*2j4At<2_ARIP_lTUc4EF%kA}vM(7@jq4=kubOtI~$ zLKmx#>22VrS-(uW`ir+ZoQSU4gZWhQ43qS_im2r0zqbuQ`G$L9l#5swZ_A96NiwwJ zMKYP&CqdK8nbJQHYV?;w0ZbrF;8`WNK7ORV7?9>ob#8Q7HGeN~!AMTi_m0Wio(f~R z_y>3?;u(+AXm&2HI_Pik;$d5?2ZAnX9NQ3^pY;cbE=y%)jQ3i-~z! z{kZP_g~uk!kp(^lObzJNy5Rd)3H>v#e*NX6t4suCqjEbAu9F~a7b%jxbH*sc8D$b%!Y|)vvC#RWg_qcHA$y6DZLfgYk4|*+ z?t9O_lrQXr%2NLALMXF)?1tC#x2F~WPmNepPHOtj4p6l^eO^L5I!2qo`mh)oN zU$z#D3aj6~A^6*)$NRCI@jOw?F5c*2 zxa#3+?kl2|#+9w9Y5itQN$NF+iS!(8wxhq=;@f8(?dMfkBG@?r9D_T#!C zEIIaSI?J<(M(T;2TFNO^&r=rJ=$UAwrWJSPk~UA-lGGwE*TWH{cK?j&@;A8l%YNQi zWP>?z+jzl~2le)AalCLu!v1bWa5RC*_Z)!0*9j3IgG zgIG%NP&Xlq5HQ#+)+lxPfY_Zz!i7E8+A*sv>ZM$c1AFCUygR8+`YOWib!=`mCP`-1 zccoHtPGc$PnZJ4c8QD7Hvb>j8U7=ftG5uKLOJK@LxR==U7i<%Rj}>oaC{!JT_Tx<* zV#uFW?j*9^(_8zNR+P8Xh?LR{4MSYpSxc_{7WH(pO19VDo!kwmn$mtWOq(A9Z47lL z47?IVW*+aWwQ=fEgwW?NRl1c>?;240y*+<*^_8bk|9QRj{7m|T(~}}JO3rOgglzWr zt;gRy;>bt~6JKBz5u5a`y>-z>X(HbFv79*aY#_~l!UD*=6VB_ zTlK;<%uxlfD&-Ipj!bUT_~T)=kd@D3>&^}aQs(s@U}$XQOv_#(Y@H*;F;HWge?|(c z`B>y{J=F9f6xCfbm3Hl~ZAp|OsP&_-d8CU5@So(=MsmZWbFuMS7*W0?5PwfYA+>x#{@6N<61zD~LqFAwF@>f-oQ z%)Vp_x=^l?9Q1dG%X9beq^@>qK*(Vc&t;!I&=a{EWIkZtS^K`eIl;=iDZy;+d(hjo zfHCv~+es+zwS(wWpTaLZTm&r&LHYobQrmS)_xZ)*NjeQE-Z6Hxf6u(&{%InT{3x(b zJrE_B`^WPWO^4;7nTVYOUs5yzTcsPX4EEv(*K4MFe}pHvoHbKS;7#5nhWGd8yvL7# z5X3^NWERAj*v1Pai@uBqCEu0qo>`F%M|#&}Q($>i6MC1CVHsbkark-HisTxL9FA02 zGxnR;AB@meXI|{TgQ$cD;^Ue6lgKE22l+*}g)fQaxJc*zeLCwOuTY32&b)kMv&5c8W7`mjY2GKH#&F}pm#axYp`U3+pj1c9UiTW+*V+r)z-`;eaH7c!P;t{iu78) zcbhZGZCdC_*Y_6}4mQ~xxt ztW3#Is_T_BKmY*US!&UL9(5<6rDW8FnB>T?TI z>;!fPLg)gqZ8O&14mzw+>0s=c5C6QjJkWAYFKb&ENi~bt5fEYQv2x$D3NK!|)NoZZ z%9$(0QtEFZs^l|+PUHaT$ae?qh|0=i;-@VF2x~rF zX^bC~cU$KlEROXEyZEo(CG_!J)2NzTl_g&G(QghTltGG2rBcQ2#21XWuuV36cMcyF zqwqSVCPusBYFpExH}RE+!2ZKPA zhV075*q2ym>r~H&wS33A%`dl}J}s)v>NeX{-`Xr9KtIdiwPBQwqwD@9UrVF@s$vxjt!@NBbTr_3R=-Z?la^q(`LkQBr=JW{&2>0Or5*`$=e4krgq5{yAR|raTwXNa*|)Zn z;1(ofr4=1{E3mL1ZXj%zAzmPNDp)uzcXIDWiJJ`$`RP;*rpxNPM?2)0+%3M3PyG>7 zsfN^H`UX3Xn=|DIcD!tibKx*osn8HK@*hj7cf+~=W@$G;jqBgBw7;OFxC}1B&L~|$ zMB_H9Nk_x(U-1~a)46t}-&bz+ZsAYyEh&95>s7Hr&nH5A-4j>el*=2<&4}da;_w$d z!8vkSsV3Z`;AO|P$9%ncX7GS4QKiFby)Y^6drVIS%*S_T&UdOJCL4ga2 z~`l+xv@RQe4_ctAPlEkMb4x^L{?i6&O z_D}&a6@%8reA6K7MnoC1#@`X%-YT>O<6$ekHDYe;OTd7kfzRfqwkgH}iQ=i*idXHn z-iuq{WpAN>ffX*sQ}f{OQ%C)2L5B96{2Wg*^a|~fX-~;`xtSRbxB1Vnf=t(WEyhaLMe%Z^+det6g9|Edk+Sv*z z)@P?js3H*zz|yV25GeIWz0X3)O+djehSH(q-bkg@=nGN6ukhR*cm@Z5*Iyv=>l4mR z6jd+bk(oxK#fRIha%tsYDBOc0XL#jkky=_00kx>`woDw|FVtkUjmdh3536z>yA9NE zL}%Jqb@?+WU86#&V^c#HuWfxQq`a%&+F^womN!W+Tj*?f^w`t52fxZWXKm6lMGn!f ziama7POr3lL=;-by_U>`wAr`QexWxWAR8ShUnu|M_I_KY&;ChKa~LFz{GcqBmAU#! z-Foy&e#>-o{Unsz(jvRi+4#$8@$}T)F0JtdE9z(_mbj~SmUn_84dCYWbOQvCoOnPBzg*|(yiHCWl@nnd`2o!GfwHdsK7v)UeWG(&k`OvxDl+^qBk^pXUD=N2rIh#-d4 zk4!r)8rl$(>PeR*v$?(Ue$jG(%Y{O#ZEd;kW}3Vq>ht?Dj?ERUj#T$Jw9NR)VM`v}d7of6cRwIm4c0lWL|J6FfH@qtpgO0>{oWcpR_O*9 zL>eXo|D`DAom&N_5$I-dnVioAMDe6R#kmnRKye=cprD|nO}?4JO<*1dCeFPFcpdu$ z&MEh$6!V82`@&qIkBtD}pVxicIQp|r;QIwuE*kkXQ4w-p09i8y<>#Et65FAiNau|S z0p`=_gAube@LtllB_Dhk$ys9f5lnzijsz3@${zLcKXBN(hN6VdmhS^FMSmaxgR|sR z^4RDGhr$l`Z_{hu|Oa9_vvF&^Fh%JLC`5t;A4Y+`W= zWzkZ?#uOnI5NccRVxEV&97DRt2;KetiBcDf;w~Au9(vz)QNr5sH^x8j`s&98Ug?*F z!gi~sSVzl#H8=N<*g~744AX_l@Ys-B>OwZLyk`^1;GFI)=KFf@rH+fP!8UYA5uTtl+F)4z;`lhT$>q<1E1OLuVtZbY z0V<*Eqn^tZALTo<%jIc*`q zRZww4Vd7ek=U2ZqpxTxA4 zpVsfQ*DZg3F%3qe=KKju_#^xOn0w2(sM@yeTR=dNPy{7JRB}W~=@z9!hDJK1L276Q zL_m;|Zjh83I){|*?k;JDju~Ly#d%%l^>{w_b-y3q-}{Z2wPqd1I(px>{|;ZEzsW+{ z&X$G{Ix*e(f=? z)YUNFEf88=(j^WrLbT&J36OSA5>=!(eLje{&(hv;5z*(5=c{&9F$h{IzZX~G2%iH5x!T=f7 z9Y}SvBIuO&14xBh+4k_mTLzBtPV(#xIXS7UIAxs=Hb(!{h8#0b_8a7n1x6cS!VoPHuHomkz50&o zW-fYr_QZaDsr&8j<7i7_&`zdR*VmB(D$?KBAC7)Bb)2qLjdlBd(_NYt9HZr6MdSb7R7}mu!ba zxRWi7`V<_z-QhfnK5ImW;Csy}w)-I((ZWPPPOt&wetf=mj};((wbKPZksWdX;_dBx zn{Zy^&U+}p&}`(M>@rxJ**os6>JVulipaPz@Ro#3=V>GE1}d;h8NUvf+N_`YnAnz# zT&8z^v&CsUFZcln-DQZ0tw?VcRs@CwzXZ&RHaq#K&C$=}c{KH5$JfJ~pKpvU&T$HM zQpSQ~=N!*FyquhFZrg-D?FGUz(X*&YtrU9NKs5=XpYkM@yXBdO2P z1=#MqwiMFZ3sz68H`<{Tq|#0$)t)FbvenNY@I@I)}C)l$szzkm+gyAH$fq`)@L-|pl`rPP+JxB9_3mX?N=1H0y>Zp;wd+xR z{qR{bZ7iX&vrjW=nvAG{-(;ydAzEj_P9pwZ4?ep;mqsyI?|6TT5S4+>A%d5P+YWpD z=ZxTrbF=-r>%4Gu!1@jnL^JK*?0uldMl#Fq6@BWbZhXBlu* z?EQBK7!k{M#C10|GIkuFU_25E!cmDF8e{KpAF&(^I*wZ#)C}J-RQ%s32ZcY^v5bB8 z_Re{aSnDV#9SaQ+1f_>6xrshcu9-8U4=aCjmzg-ZLz}Wu^_CboXz& zfVzhS!JM%nhD%L~T0>RQV9%RRm8dc#N8%Yi+w!o0NGqZBHs8?_JP8-#cEQ{gL|CCF|k#gZDa zrQO4!@@B~NMvi0UFCGpg#{(Cd@P|4}W`NVGhMdVJy6Uk}z=9BNTv1d?I!WJmYGZokz z)n3lrCGKtUPU{hz=_d2knUDEaq=|yUr;QNn{dHFC>%yh$1(qZeDjwZ7n8rGB<%gVI zqr_wp;UR7f!C1x|;=mlxsb$#@owDXsU-VV+u0X)CMMFaJ=+#;LH1taxf#s=CFAJfA zNZ!o#I5+w2ya-bUsF^8bvK^+4cxg#Oz$2}h(?$saf5lLGGS16gEcI<`^y9l8%{i*c z$dzuZhm}4S88&3X>FWim2PuUizZ$tc^Mv>)|2$gN=eyCBJT~AMExkV&s#UWeE_Fe_ zR(okIyXv5oK9|lxYIe3$ZMU+_qP;sCySxd=#scB;S=!*IirBlez=}G9Wba47lOP${ ziz6?Y!n5E})+H2x_@(Lvm|;S8k7c{NL%64z_OxP{pHgtH z&cDHz1ufasti?>K;1Pk`H>zI72B+XwE&z^{a1@G*!PTLx6 zGtQpV&A1@_2w5vJI2(fD>oeRIMz}axb*m@RG#$!!QqE4NVENc?H;*)y zPz$J#?+jlxBytTieBtY3*3(x%yKE$Xu;lw`TC`MYd!CyvDZvHVfsdsiZW+F(3xb{z zFGVSz^Og7LR*DA{z8D*$%Ru<0-jm9KR~3Is`0<09i+n}YDXyDoACbycx%cX`UO`)g z2ZC-y_b-9`e}d_Mthja^WZM3@lRE(p6na_&c&DFups8dyT{RMj8ktS~eTO z!`L+(57oGkiJc(=C1svREz7}eDzIFVoyG%OWCKC!KyP@is&ly@zb;kVD8#n5r`Bub#GTucGt>P-~KU0>^6)rGoS>R@6leK!qn6 zh%_pkqS^|Z5Q&#-(`iH4o^L`Wze znUoJ!+izz$`$R2r+}jYyKMM+`!2eW0z2hbGoDUZ?I@S?#w~PPe2=SBZ>Fdz94vswU zx&Z|rYCZNK9ncuP2(?o@_97SdaCs~XpV4`Ku#$nHY*`p+(3INp^**h~WEPAA zlPTM;Rfo=%wx$T&WZ2hoanW_Rk2Q9q?875K<(a5bt(Q=m_UmgV_0Gsc1s+f~kL}pZ zlavE_M^?w?Gw121;aqMLc+d#6;y0eC{?9kUg-D5tk5E(lyd87x!h(*p?8sBDS)3-6_(ug`rJnvQ{d&MS4xXvBjExBrLoCOzjtC zpZY*~?u#M5+o~~_Y>mI_H7ZZ5g1s1wzD}ZMs9tcn^0#`EKO%T=hSTzeu3hKO#7F#w za7k{Pk+NxjpUh>Ef$;BTjkFMA8#|=+NE3`&>0^;1OI6^}9jPSa94G~;vgn>~|K8A` zTe;QyO@{<4S;{{{T~2|3Qla+v^*4`K;QejM^t1+h8anLcZ;%KJo}Z-G(>^cGo=i6< zJOqViM#-Tr)I|azXvXc?tFHvOwM7uTo|CTxWE=!in?>Zf;MH}K8ukc#Fg_SmtNlIq zFf*81fV-9x32cp@kfFh~pw9en5JPue{JwGn(V$^%fuORIeY=`BY5cd3U85tkf~*&Y z_O3EBqdBzvK@>xms5I&Wuv*XiMS&fsQpxMfwqT(VWi7Jii-gA2 zD$oyl{7L3BPJU{-@6FE=%}n8M&&CB;0ak_>sON-bACW%2aXajCvM25-R@3Q>di8Uq zTLu*L$GSu27!z`q>brQm+NivpyiSC>oA2Og0T3l~qdVg-3kQo@IO5 z@_9BAbp82l-HCAc_cdT+9fV*LVP9+~rU23NQ>US-G1ZzsNBU#T4PaGo*Z&wH&bYb)CC)JC!BsXS4*8(gf;=yfmFY4prl^4$?kOxmkIflT7ip`l?M8Ld!)^6hz| zo+ZN=S8enjZS;+?M#v#hlqz8cLigYtq5klMtk-DJTEgC2|cy= zn!9hcKLgd6p!Yw(;pgc+`($p*IQ!gd@<*X}81J#1A3#rC z?|AQ=>CC=Hn5b#Ct;0Q6xZ@#Z;@YYMt=;>JUC?K1%ZPDjom_G;4&yUZo@9o#=amjJ)wzy3tG(T}% zK@$3k*>!<%cpjgRp)!wjb%WIl7wA7|ndt6KchH`JIJ9cFS0Ii<2iu9BH zi37kjNtnGyuZZEI2YSH83cAQ-8>LPkK(W8+^9d0}g_3Cu>l6b+IjXTTn;{{w&$bVmc(h)!l>e8jcPR z4kKw3a6Rl|TtSK2BzmOSu)W{G*u9*PrsGNK)CXmR{07-bw|LKUaomzxW$Dw&1{FDf z(H5#<7i|w%<@`h&rl3*1aa#ghS|L4dDhvPibdD9o(QU@UA>8$IN#aBD?4W?E;97PU zV$;)!|6TgorDc)oj^P=if>DJOV71Uy`o_0;mrf@4`_6xY)I; zDQV>sHpq+zUYCfu^CHOlax%)|NPd#9aWn1iGRz5dR#D~!Uj%n%4H>?xUq{(^XtqZW zBE&s>R>lQ8C9qBEA!u2ajXL)`{U}M>Cq>hn=+=)D#)bqxi^Zg|;I!L?$S#!6lJ@U&qq!E`J7=5c2DuCIk{9jry2QgX7Q%p!yuQ(Ta2`Rd+WpL zN^QA-4s(phIM;Sz5l3p6_P)r{wkEBiW&lHetK~q9;}8=4Pg%N;;SE)5GDSlut|;oA$o09=abJ5@E=xCN-cKo;laRUSEdDmhV=J~H2Wiv&Dl}>VphfYSdGx6x?qc)7 zLObWee#0J+?D@+bMat6EKWb&W*ozbsZO2Z24Pc~5cQ`O>@>83AaXUV2w?=$xcNlV) zV`rf2AKA9oapHI@p%yqlK~f)kQWFl=JCdsv44R8r+Mv2f!H>&b6>+xLkfL`*h*5Kj?*YGKk)^s5ezOnfQ zqzzoJVc&OWp%i{)`RfBjcVNYrDubcq^`mXsD9^qPy!&2uQFAG#*!-rlkE(bG}%A&H#u?T!z)+F->>xy`m z7Bn+;B8AIQ;=^h8m=7;&O-CP`irv|$Q;1s_n6V~V=I#yKM69bP8E(zjS@!bi2toSSl4<8R?m&HF*gSR||e{dy4seE0Kxri5n6Su)ekb zEYj)45oEJ`-6{IK5sR#5`64;RnFC?osAihnRVj^M(;aCo^$#8Hy&I)cYYtd(J#%gk zJSzQz?mMP5+-O-L=_CL ziU%hEa`bBuD48@|N}8T#;u<%F6gqwIl0Fx{^_B1uIs$!c9E?F`>0AP#&rgl|2L5TT z{fI(nG@Cn}J?nKO)BC({+8&*}Pl4Pf`&6!kE66pC*&>Tg9!W!6=f6gJibSv-dU8MSI8^h z0PUnM>(GW+4mzpVBA|Q|tApxiTRFlUpEr!XF<$}<450?+RhS%t?-2!n9zHn7(uEyV zNCYDGCj?}TWRCksyD%S7uo{zxEBy}U?B8EL*Nqf=hoeeC=*Ta=H??XZ{oMDnHs|J5 zy4%5!z|cZiyblRpo8ll!(`VE~YLN0o^E(x9phi&OyfRX-paHqX{?MC%k!AVkKnQ`j z+X6jdzF}snRr*<7YGtRIUa-ktqh8RbyZ#t~Q48xhXJYtG+Z?ZV%+PM9p9Cy>9V&}g zlqoWr&hJ$_FAVhGZFbL@ewPg4z^3yuyE?LhvjRL|+$3`zn0wIftlc(m@a#h!q2&ek z`XhC9v!VJq@@#;VMIou= zbcnrzi5#2m6(u)Uq4d#orjn%q-A;mHH~TYK^8#ao_1TxFt$V}k(_L3qV?UhZA=`Q! z%S)4&U&l`6AZ1q~0u%juM{Fn;FOC?uj<_SS_)9CFPQ z+{VlWoF8@7T5W3Itec{1MQv`BsDJxqe8>QEQ4_v*cX$QuQ~K|a78%zZopLZ(rfY3k zK&-+@6Mnv?n%DN!xe=9_=m+joRhk%s~8rc6ewvZe@O34g!`#GF}u^ z#)~6o(lVBTq#ZXzGoiyyS|xK2z;0<`NY!~Shx{=(j+w2rH3UP(eNZoiu?9H$TtSI3 zLX97X-$wvpi%=PdKAjR#M=0oY4J;=xd~&7Qi^0RI?QzgHB$dvQtj!PPgXM&yvyRmZ zFhLz_UQ*_BAMl-bRbw#t%oAIuTCdv1P9Qqiw0=}hGAUCMD zEr(6zQmN*OK2+Ico90A5b_?MDgL*qZ5jK@9OUBK@ug1UP@3MaM zGM&Jsub3r}zNXYct5jn|EI_e4g*H)e>sb9;@9RD@yI&7g;tQ;<<4y?TR%vxq+QLgh z2uiSFsh2-a@K}5y7smutRc1cP6DwvP>NoR-IzL^sIv1$FAB3}d&aJPdg|jMFvw`E^ z`eP@*adEas$05k0mWYGBShB;koYr0BuaTay^)CppfVhdnqtx)h>3*s+aQ4*ZF4QIf zhewjd-lUw(gW;6M&EX~ZdNYJ2MBuy!>~QEDKKt5Jr2eyYN5s8R-lRLcBCQsa<|QR> zuUM{sOSp3;=~jDG=uH!@EEL!K{#a`%d~@qS|1EjNr%>xrgw#y=yNOQu5*!Fojv%)+ zyg@O4*y*Dgt2qfKlL*&obeyipzOHV%)2929ddT+Xs_I%7B+uc%9-u3%NlTwf$2^kZ zpSg@MnHrwzj@Ujx9&=4em=`I=M*f+$Nc7naI`FGvR!YBg(DN%P9BJ8gDK*u)xIuU%j_=`4AZL!o=W7+{h}wADA1 z@zX!`^^O|7Vbn6$h!307NB1O0M4>75+V9wnhJ7QT1M6G91kDK**9MaSWMpeWpS0xT zJ&5F9955-Nugd+}Bi`J^6g@vW*O6|xb!^11lC%jPR5RMAGVr|R7ouMYYnQFPcgXH| zDD&9(i#(-j)?!iU#&I-nPh0pb{^nfnBnh%t%27*B``F5)Jp6~4e|$L# zRH0gv;&d3*1Snm8=JC@`hs;|xrE+Xf6&=A0axRQ#glsIC;ocKu&@C`-i1Zsa0P&cFuxHu@YF*XS+Q$lj%Gl{;_sLcHmt z+v@M?KNMzVEA8F2`Pxr`dLt)`fu8w$msvxi|G~Pei(K{K&Cu2(Wkv zp-n9KV$nLwf&LIG_sVv4P@2y7RJB>lV#j*sf=JH@llczDh{q!{t~w>!WqmU}mkHX= zswQovHvhC%ZILkHLABNAaa86~%6*&g>C%$Brd?msCy^e4wWaZx=wqU!z)irSOs8(o zOMDG%)_vlq{mCo`?J%IaBQuJJ1xn6jisArm_DlL>fL>Rus0nDMrS~iH{YQsTq?g4X8so5?#77T6j#u!>}Ae{s!S+1W0 zE@YY&^>e{;_Xf$y&B6!l_8l1rNibfKt5oiezaVHlQpRJfr>ed|(MIaMCU|vE5(nQC zdXjvgEN&0YxG;VI>O4svcNUzJSP-Lln3*(g<4MCR6iWIVSh+uY&q#6XIWRVllGS|m z=#BIv8C^zok>xC9!UT2xVwi$~6h*3voa@4|YP0*=Zz%~0k(zLuNaiAEzu!w@;Ul}p z+GH7`j4)$5&6ITSaej*5IhDtP$5Z3E4=C`CgFUa7Gr79G!(|%)E47Q;n}K(hhi68x z(>9HH=+@$5Dzwsiv{t=8?$+HF1t>PIF5INeygabkg-v82u02enPbfnLF#rW?Rl<-?oY7W2F_bmTO7Bo$XLgLC3;e<&zb znq*(7&SQypqrh2xzFDK2GwqektPM7K@rW^@(OI8BSkP<+cSN-(D%Rk4U!bS;nW}SV zFsE@l&(PrE!Km1{P_^dPtd;cTEed+lfTyuvTRtcYm)119442rU7C)PPYie+X{XUpW zEs%S$1wg4PjvQf@mE2t%S@iRb&((RnQ10*Yb6*s?*(#553F%hWb^pg?jUVQ{+`EbR zy4rZ<$0Pgkwhx&iunY<^?WG1V;lg!m3gY4)Oc3KkPwC_9v7^r^w{p^f?Wn%D(uBbt@L0A zwvT#x6e2t18DFhR;DNj_!A%>uEuse^vbh`-qkppi)c!IbF#z7o#Yhv?tgG=$^}OC(Z>9c+DWnKbialAapRvax0zzNa zepJ4geMBUvUNiBihpR>{)uFV2e=`r6E6f*K^`9rciH-TZ0qnkMgQ@raVf++*hzXX0 zA2daeNN0J$%Kc@=F9tVD>gpzo`r9YIv!)gtmFv}u{6#VQ`v7$m8xU%2{Zck2{QED! zPQfYEn{*gfaMo56G2K(PRyO7;5R!p37FLg6C}Ytc*&a)XFd381K^gy1)|>r>DEo~S zia;@t?~myBs4ON}1D-B|_f+TR9DWA1)@@nmn7Aq=xN9w__fuWZwBaIaX0>NMzsH$) z>u-a@jhE_wvCMdrj!s1#ZQ83FyU%F5`p8vDx16{|qj)#D{Za;a{-QOt^=1vGQvF#s z=jJ!|LUUe+NB?Il1hA|C{O5$UNj>o@{;si3?aO3!J=P<;h^w+Ocer7F_QdwKRBX;s zjfS!1x{>`2SL{E2ng3c+cTBKE4%s;o{fA`cqWZA)`mk8$RM-)>(?NoD;Z(Q1UDsG8 zOR1s!pH&@u6TLxMY2GmP{=H2TrKVk!*~|nl#Kz_K_l&Vjv1;xaWzNv`d}B|x3~7kb zKH++GcJNOVng&ojrWnRH!9SZP z+Ft=q|JfJ!76!^bX8rkq!QRaIBRIG8vL~2R3*}4aQFKL9NV@I2>^fr!rF}Jd z!m4AYRFNEiRx2sT&6siN7pvdg>RQ8PJMU2wHsJl& z1HTu+(`V8+oMpfL(m)LVUqQO}dVjkj^^xdX*LrCUf==V{A-5QDN|k;P{3DPTCE6h3 zy`4Vvbir+VgZ885AC++LX9Q3SFYXWhy^a4@t@_@QIYu5NY5pV8Bc=C0KJ@2ZZhF-% zAEY>-V!U!4FZi!HGyiT7`s6t0_x}Fn@22@j7XXIDe{+$_RQ`PV|Kk#W4B`JJE7iXq z&ByI6@H|3plP_-&ZU6X&|8xo9uiggU1OMwkZ=Nsh$qj%j>wS||4$SB|6}!-sf3MpgYPCa0m*Iddicu`V^%N@ zX354*J7+Wg`Q(4s1n@FqXo0TWbDu%{6%__)PEUK-{Qv%nfCRGYZ^nMSrTTws4gh2i zn7Kj)rD8DtJ>30|H*{00KZ$_v{r~u0gFizHd`YYEXS zN-bZKmsoyRt#SW=4FFe>$)+6xO7dh^ z4W|jGA2#or00T%9{>?ho?}v&0f4_X~T~qXoYKZzAL^VXY^Q2~6BYJrOr8))+pXi?j z-ozR(IyN0Hbu#_mZ~I?0!0heKTs9&emZWSmLBw1!!4Ngw>iW&ft5!^?pNG&AFHD<)Gs#tRhDYoD6**6!y#Up#wDK{RMdk^5qa|xg_iCX>761{(&iwMb8;exB$hi`! z#J(%TxpKL_nPUYYjN~L+ivjzeXe9e3Cby#mW5^HA(m!`O=;apI;vW^7#GIPqf z=i85Olbc6c$*IO7h}1e@qsMc)(LzlB%|1JT@T&jG#hI_>HjtFAvHt4!w&r$B*8W(m zUTJeGVZrNGovH;3fM;I-zy-yya5WYh5bl-3C%ZfgY-6%96K6lU2a1L>ADTXat}dKj z!x6^$c`88ir#tRX>ky?~1)7|@*X{`2Jd5SlKyFXuaaOl#n!DP1gQmbl+J4S?(k99k zRmj6y0S6-t=g=2uK_kG%nEQI$C)LXkDf}uwDkf&yvgnf4D>k1}7Ttc{$SAXYm3j^M z92Mpb+QZO8HVp?z8FY;Cb{f?77@?(gJWtJG{Hd1LQg@q@=c8P~NTaixtvbC0v`3z2 za<=ATLTQYbx%7HBOmc>qCb(eiRrfE+kF%Lm+>751PM2p|c6`Z)Vmzc?kl*oRn@%Ho<|dl{cLjMa=0|hE5f>6F3zM%NVpUJ$*)O&v2HeGag0gBicImW!k~r$)ImUY4js%_;B^|_OHkRdu1?LN9 zV>pmBu2Nsdm=CGWzh`hWdUk5{xXb(rV=fJ}$sU8g_VmP->y`hUO>sNKVoWW8(P_s z;x=9j*?qX3Nm_Th#k@x*SR>8G3nNJZfV>}fg{TGcFN1BA+1VFx>vi? zme+t~wLvngQqN1c^>uq7fKXIs!5-*gy_&d2jE-=cOp$3ay+7Zio$tH~^+EXERO&$E;OQLNTMlI+AAHx+10l^p60V__7dn!P5;|J~Gc#z~w6vN@Xg5 zsD<+sot!l6Q^)hVf_KnL3&aKD0iok%&WK(Y*2{HGfB+a*INY!d_iVauEYaOAq}fK? z4LXqLxr(>0KdEF#?R#!-m z{=P7QD@N{n*3yy-RxTVoiiTik&&<0|P@lTK-;kMl#dp3aUeNaa8q;z6=BzZ(CPZq}02aUdx9+TkB?KAx@3$+o@~RHb$nPnVDuBx|SrLB)h6- z%>aqJW>~fzO0GoQO@Smo7bgd0$;B_{bikjzf2KVr*Y_!8`uf%9dHPTCFXe$gtTG$U8YQN9`()%l$FU^C$*TmAZyON5WREp4k_zN0}aS=L8|0k zCh_l$R#RZD(#ed`LajndA+~b^O5VkO?YZ3n0ZN0>Vr(40vw6>%I^wPId{Z8!k?g)) zU0Ug6EEJ7sJBYd{Xj*^{nrgR|G+sFoW|fi% zGrahPP$5~#_$gl|lWjB8FaG-2Z_3Ksz(lB#gQHAdm~&k~YV(U(o@&MGv0OF()6!uP zK2W>B1o9PXQ-E905uvg$>Uli3;HdR2kCg1bfG-E^SXS+AUY<2g&Gp&+dmh$7-h?{Wzq)k-^;4V^xL}~c7T`sxDV>k z{T5tterGy1MgH#T$Dw0iqHP1=evHQ@Mxw(W1Yh|6FRTb{!F!qOK?X#sGdW~GSsIUtML+`o6>ZJWXxowy7ZXIdj*HTPL9K0VN(1veL*W59 z%BCp@Qnl77X7y0y+;}WYL%Bfx1=7;-6%}1XL`2<6iI(SSLHuY^bgYIdB@LBn|H{Nv zS+3^M?=O&|Vs{31u+$v9?vVU3toqP`l6S)^SNO$dwmentx|90I$bg_NsX$m5wR~gW z#h9%PIWDaBw^l83`iU2QX*03Vo1EmK`B6g8bFm54dRl*>H-UkHDh(dkjeJUR=fiXs z{rGx01a&>8f_9Z_IRr-Co@cZ21A^$!&gAo)G|+S5B_TUPJ;lx8EO{!m>E&8h`0@*U zV&WX2)T06n?{WN-8z&bXR^)k<_5|ng_ncIhU~a2@o;N_6*)ej4S>E|+go4FzW-bMh zupaaTyZ-#pIDRiK>xAlcX18iy!i74Ep3PxjV#WRw;?x!Y5K8wDNyEz)zJ609DqUrl z4%{t9MNi*_GIgb@o}&FwbWn(mo!tSDI>XduJCrVR0To~*){F+gv+l%+_DpSp0k9n} z_IDB&DFN!G%ji5y80S^75gr0QgV<1Hj_Vm1fvyfrIelF3gz3P|n5jNKEvd%Ee^?&-}T3FSmCt74s>kG)c#_#jLfzj7tqvVL51pg5$!efX;>Iw9eW z4r^T;!Y+v&N3~EZx?Sj?{GwobA~sGVNH^iN9}aPCfCLeZVM?r_{+mX0$&me{fvNSF zncm`3K7S_n(92c!>)E4z9)F)$y<`jF4{W51p7_3>J16wK4E746z6)QR^BTp;$sCgV zyboxUoYX1znBpL<2R9hLGa`3ECs*AO*kV~~ zlE-Y&(-$QC%x8hN!PL*W)Z=6E3?WYVySknn<#h-1rBtY!sYX3g4;kxBt>Zp-(lN)z z=5Q^cm6-8qz<8~p&;#l=O{0mJde`}4eJdE%Tp#}X&ZeRZUBH2Xn1&7^ygW>{I z0oE9j;Tjx7!en(j2_Fi#S`S-u?cp&!yZY2|*y*$WJC)~HyUb-vmpQ@k`Q;-?Yk??v z;(igSBE&KIDb@fH0?s1Ewo#`dB7)yxH{E*puU8|3AQ@ro!)>$^%(0s4`dvquWeoNWV2jK#0I z4DANF?`SmeWPVEya^gL9@xC|4aINeslfnpigefNa=M|!M=C#Rz!tKrl=9rPY4^lW? ztKGc~jhd3HsLmd|_Y%$Z<+#lpPIVYG`jhEetVw`q(HNt)4E}YWwbn7 z2fxd{HwCLdv5Fh%6wN)gbgx!?Yo^bJ8hntK*!bB?v>kCYv?$j$5p9T^r!~h_jlQrD z-!)&**L@9NLqo$pt``0K6r1P@93y>WDgCw4;`_qfy0l4~v^I z@wevr;Ix^n;F*oryA`0{o_=Ge4^pTV0Y7dze7HNBnJGlwx~1BeY;vw>f9LD|HmTtc z!#0_6|LCvb4Dtz-Yo`!y{jb`CSl98dtbrH^LP*%~=_us2#m-8$C0$wA^1yNZ z<%Q2@9)U#l%fsrObF13*d6YdU$mIGm8@mQ75Vv4nXMr5<@xZyeZl`DBJBr~25e2f$ zik=bG^=RvZnSH(GV^nX7F2bSJ)br2)N10IdhmlVP&Ze-fzIZxr(6--tA4d3u%@N>s z+d5sn@X|G^xNJ=_<%MOe@Fn-c?ynwPrtCvm*%v|>!b+LcUhH!|OAH}$$%c2m@w(>u zo&2JI&SXO%mXMc??sKEm#5WG=^d?kb#7bS-kf;&>4O zGr;y@^GQrgbU(|KR?dz$<$;V5)~>VUsnz>Wh1@b)+r2_6D|_h~_P(at<{TkaxIMYRZjn9(DOJ6km4Nj_iWnc+9%V;s2P#hAT zG{U0em#0}mHtRSol#N4w-GlkE(431T@UCj-Db71>(|E9t#?~odRb^GU8E(>md;6TByVC@;-YL7zQ|Qa)PO{bJ zm=IJHZ7PLNTeIP4Pw)l_Yo>^uRfJV0Y9ARGEZtrsj`Eo(c8?DxIHSRQpDB!*MaMVv z2QJ#TLzK&`XSm&B)zx$zH?vgoRLnn9MY8Cy9@fhI?8uL$fm+lpynetaXxptQcvYZR zuPj!cKt``&QlA`#VZLyEGqO~48BH{qsp*doXx87&s_sm_LU1z+H(nuSk((Vf8o4KR zO4*{h&oSxc2DXd7r%RW-poY_=crNMG9w03@AF16+9*|MF6E*CMfXA{Ju8s2SM3~Lh zR*ZU+=~m6U2_(;Mk&c+|d!CVJ+}754saqaSCNcICWfJQ?Hy@`EivH9`=3>HeJIKDe z*xNm5#%5ET6B@7ZqdLZi#`8e(D|Q6Xu)`r9O<|y(xUYz+5VhSl=vVF6wq-y~m6tqsb<> zJTUaVv(yASgs=*)$wj<^jl8yAni4ABZ7jU44ia*kHzQrAIpS+F+s7&T?61Er=J7@_ zw!-rRo9dYXi%grl~Ch4R?$_wN94RT>koE zYCd>HJlyr_!|^QIumIy;$;2zFf*OjooR`S{}d_eQ7OA@4JrIt?bo?h|F~elbmDzcRc) z_z8Y6AkeP#h_Bj7?)=_-bPV&V8m2i8?#W!ai44=bk_L9 zcpK~aEFw_uBi0+*6qL(d(i7Vfd*B5fZBNK$-*Ort_+juUfuaACI-KbEE z87#By%TWtq9uApHQ1V;lO<=dh8Z-2(@0<>CXNp%ppMXW6D9&b_W{IF`GjFoJ%olBhDfg&v&ja2@Z(_VlsWCG#rx zPpj%rSnA>>-N96N*QgUy$0;cFbOXI)Xb(8NcKHrjAHV z-eKt09WQQTGZM)n8@ZfU7yB=7r?@;3P}Z&<#k*~)xy7Xlj1wCV-nWPjH3qn018Wo} zA47Tlq0pDLh1Pqj9G6@%399%x!zDF@Shwu$*FL#f8<;UKrya*3+h=~u?)wrGbLCR0 zc)0qD7#};~t4030b*mYFMuL%x{Q~F`rI;=`c4GFrp&ZnN8V5vGw3RzERTI_sx_bi9 ze03aTHcWes6J>Xw#n0VY!ft*sG+nX%zJii#C)Z)^hkoaH0+b!Deg}OYdBHaS-#sqcWU$#zY0hy zEMmeR7e{tjR1rd=cqPzF3zw>DP&`wS>efa(meP zVp0^-`LT-uqkkg&hv-Vu$tR=NPSZ2x@#>X^&bU#ozAn++QH7I!Lt*MWkX^U?p1s|p zK)R8>{4bHNX?0MSjSO(+H+a%^A8ro;wQ$7rzKnCHp)Q{AbuJ@TEy*ubv+mW)qhBr< zK2*dxqW5PLoW(`=>fw!3*h@POzc+<}c4+Hl+TTyXB)TL(eLZzQ3=5O~1UQGfikhCB z!mMI&QKTWvRmlpn%Q!hVGSbQMJ=pxnZ4s!luG;-x@cLxJ&<%2_?6~wUc0W%$tn!vu zzn+IlkYko3H-}C?i~Ih{g_)GsUaDsu_WwiISI0%6ZEe%t-6XoDlOGIh_Fn5*y`Gk0W=;hvyaUy*Mr`VK zGiD}d+}%^Fqt{2RUWI1cpk8j%(cR&7K4i0`emBX7HKU#w(_y?}@?q|0X6Ju=1P-c5 z!CRSEV*Zz!~Uq_V#?^>A7i_K?be-;EN&e-mmVyH5`AlbpnHH*YM*J`i%bR_ zOdD^)?!2M*8#RwHi(_^ehV%BlGjYQhdEfq6?%Og;h0@GKC&MxJ|m<4vqxH(1h8&o9dWHQf688-9qk^HqoGIi_OQV>CHw01{^IVAX({}kyv7b z@7zL_{1hTC9`pHAmK(8{`hqotGwS6xSv@_S z!wJ1C$D#<-5t!2bDNvN|L;>X{f8v#JbQ^Jn8qo*tDV0zL&}nI!^$~b&{@Crgv_e=D7CsTjPA`p4DzlIJNuq5;8;Unrw<5BI)11~Gn zbuDD@HZI{H7ol|DkJKc6?G2H$qfCpqKLGMuC3+fa^@fhVz#@p9Mp9#bb|Ks?qL+U-oGW+?>Gp>YPz{yM}9td@3oaKJ{A zRkFm1@HPeedZruIZ1i#adt~tkIk9~))kQEo12h?J7$`=bjo7^2?G+{Nzzl@o%)B~b zH~E=hX_05;aPKD27a&B?1()9?KAW(ffrgSF$#p2_bL)*)LxdoEJINN09uT&^P8c=T z&41#@ZH(l+$@q}Q)*B9W;(4n`?ju{owvJOl$yShCF^0A_{Q))HHyryDts_=6c}wbg zB}*Sfr8*v5fOvP!hoNQVzyDP=eUuLa&`=maCnNxDX?dJ(?nPtriPf2}G-LU+aKvvs z^+20fP_QYYMpkbeX3Z#|?tL;~x!m>sC9iS010J||j!P0)4k+?6g~F%2^U>j}UUZ}I zU>USzZokHz=n6*=)N6NM^ap(H9_936lDumb8Y4wS6t(RRC;5oeP!^UM`F;B= zSL^8;a+Cv}en$VJNUqo1Q`c3IOgvb_X{V8MovR02n<)1sKaC2?9IcT^o$If;F**uw zSG|ta>$+|(N5OuSW-qXi>V`8VN6Dg0=>kuYI7fY zLv@hJ!tPz9cqDvUUr$+}GN?=$cX^Fx{0BA0UAmX%?lMb3>XF_hE@Ob!AWNTS=8zNn zx!Zy8D@58%RofJuwPX6#8rLv24OVA!N4j;p^u1&TEX0EP6K~uvK$S$mJ7!bLSteRm z>`jpjYcd(Zm*IKAteKs>>5`0+XM+$?Qkq=jsrNf$T4JHb$@b>^NEC|b7t4G6dzG{2 zf4jPPgxHYgvOU_vgaZfC5iqpR8h(zBbdzu&X)9|=g#(BuY9y#D37L#6C}?) z`_-Bd)usKs*KQyk+~A04tupgM>4IRhB6LZE>bg*JKPXq5?TCS z=uIn&J5v;i^-Kx9hA+bxrohCAG^^5+UO2|C@ME_->DwKD$7 zyz~C{>&yEJgI>1q5cs@QgVZHb_L%bHxyWVDARk}*`HgMqjm^(c`PoZp@Z2hnfGD0@Vj*&K`kWkz`gX1I(I6my*rcjgV_oON zUPoEBRFmKVd4pEpqa4-Zalh@ht>R6dXB*u~c@pU90#M?aPuXzfst|6ujV-Slq7Y zM10&>1))@ZT$T0=bxT>9?~9>$m!Vb|=7`w~zpeCvtl^}w*YX(b_n0k@0#+M6*)GK{ zKarqtY5`m7&H2(&3RY^ZIK`L+CUJ1rtUi1-5e{=x6PDC!aJwlHhf2q0O&EC(uXv0pqg!h_;bNyL<#Nk;9arqAPrVBSJ5KO~$ z+vvRg{Y3^c-pRvCj+#mfF2sHf%eSucC2bk)D6vN!l~6^d{HAfEHM3|hG*QU``U9VH z=&E$xejezLB7R%_SysLI6G5%Hpp2@1vj>+7)F)_$8*WDS*t<56q-;@cmt0@F+>?CI zNsijn^qvd0g(&XRsThMYbV-U5x?qom7={~0J|jKac8V$vtvgPRZA)L6$`viMW$WU9 zgCHB~PlhfnylOSFw&MwGTu<0J;ociOMUqgC%2k`m6Haa2q; z>t&CX=i9Iyg87yimnA#FXYL1PYMO{T<`K^}O1P8WKz`VCMi?QXg~FV+m*Nt3QH~2^ zn?EAW3-yblr}FB&L2Vl>m69J>TURH9zrV+QytUPpu6hn_tq1c<*$p{l4HaZ~EN3il zG@AfJOrqem890AE;7Ys?Y?dZRG($$XlRgQ8Z~K+~8XAK*5Rr9{UXIg;;f-J5gjFVh zL$kp_D;!wGF(zzrg7HS63LX_hN%R|HZ<+a~Ytgep zW{PZczPo%JIS!H;#^+R>uh@Uq0{t<26Oy=qi+ec|L`?+tU5BLg-`wZRJ3&`|kxFrr~e?i$HXKQp@>pLm{M7w>XF%3N)x5(Z~;ePU?9wDe@FBQ9X1 z)QSMc@pb;e-N$fZOBk!-R4|}-w7Qe%jJ=sH&>;6Gl?;5QzPH@-f1&6a=NESgLp7|(Q8rS1NJuj$~7y(QH6LJH3M9vg^p)Yrhn`0&%j+oMVluEwN` zhtss@WYn+3O&lZPrmkeJCici{&r-=;_tneii9fInt|{H5fwUXxqG-Tll{}du^2x78 zIJO_Uei_X`*6P#TJgG&ROYP<1-s9cZeT+;d@ayYa^2vAhyz__T=H6x+l&QYWMv5T} z07Uo}x#eePkUXXseJ6ZPW|se$t~8Feb54ErO+kZ43#A^EXVMuS*n=!m3(4`q+0 z3h)~7*4&ojp^-11WRvyIM1*PCnrL(rBDNkN6n>V}1eL9KsNsL-;(B%~9ZEaIiqTx~q`r1G{;Wsmu5wNc|gVMGb~s|w64 zl1+w>Onu~qY3H&_Z7V{LZB*+Jkd5Hj;GzCxi?_dAdxmha$y{qFnzu_=9^NAI=Z>kU zglyLMsYp@xL`ekF$PQVhMw|@V)hbN_43`Eg|M$&Gi9eSsc~RJ7!QMf-_0^gM=zS~= z_A78eBj>7_%P$wzS`CbWsidq&#g(ZPH(+Ss0|)rJBZxoR=%Rxo@@AvHuW@sA`JH$( z>3b;N0S-gZ=+yb;3RecAD4B5ve~*3HrZSXQigR`5t>NkmgQf)H&uwsD)B!VPMhjjI zOp&A$Zw^9b&2P^vm@)I4uMWQL`Lp>(wFp>LSxL6~c*X0iz1ed>M>K=i`KD81ILF$Q zwL`SqU#wSocRTIt=I&;wK-r5+=3Y+pFur@h;=>0nW+Y|>AGefpHR}|~<_LYRz>n{VW|n8-mB<+NycS~c(ubY{Lm+&l$V6W>I`Kf= zwXXDdGz17Jna?0Xo~iXL#oCIiZ?K_uG#W{AWp)XHF`yMeayFpjgHWa*aBd}o);0kC zXoyP!Q{=F?g~F?Qr_vaa$k0T}*!_`QB+LyXoISg?1ds?RnX(n|{3k0v|Gl&LD zi@yM_c91qjZ+}#`~_gJrR7bW6$W>~hwl@JRCiMK~yM~xoNcBaD8(5vnv?ZpT$z4mp> zACp#oSuL)s-LmzxQkP%_Rxt{o8um&J#J0;*i=o zJ62$lB9;J|nq!TbdgSH~??@y+lH996+^9xHIkj}EL1&JUaIgp)bu$ihryQ`!eLN_( zZ#6ol>Ts6A+FM2eb=U=9 zXK`~JH6dhsEj%uU@Nm`KTTocP?PYpD3XFr=S*iz7SbV&pw*1Xb`y#(#R-vh-`dBpB z#kk26_TG_D&TR#Q7;^t4B-(GDxoWMCqU{G1Kjdl$Q!s2Mt*7bg;cvpg)6Q6dgw@Tz zps#Clkms>oCALa zqw~WU9nW)iVH@JtJ7SC;e7RR*-(_(T*-5^z@xD1lkkw{&Dp}GBcEXvLM<=pCu$&6d zs{dyGTfdg!7PF-N#}~gyLSt3M)#E*TF13P%x1Kt3A`3x@_%d)(@3OnGpQ-Nv_kD%a zeNVQa9Tb>nhYJFX%9{Kc-cKUr+QNFa6UM@T91YQ-4VkEWW9W9hoF60f{FVx?8ocPXAHtc$U^ zbARi1;+*oZQ2m1(V*tE$uk05s4M0gz zhb=g<{=Ec~v!(V@M?u4gvMfjY*unutdCRK!+jO(4B&;iZbJS$ZZaKXYN7NBKZi}AP z^@hLCU+g?|ak4DBiQ%3x&X)3i8aCU4jN_Kr(72D%5i)iQ%il&M?)Y4B`R&_2v~(Ih zHD)x~uBBtBGpl)TzA2YN##Ud%G0WriUOrx^o`JPo5B5l8uAfT8uXmObE*4IiGkz|q zOA=7TcYfbV>-Xj6L0((}@d2{*-f(&2(g+Vl3d!EyzR^6saY^&w@fRmaH&w@jldpa2 zK}0bcSAQO_E<>J2F{#8&^8Z`~a&0yX_+? zX1AKdQG^WTE5k+)r8cM|wv3QzlU4;~DKFGEV0h#DruZNf7sbd672^p1SzI(#o!F2l z?FZ1!oVNJGB4+Pzi+b8HAz3M~{Zu6v>Rl3kHrsxh-C-*LvU9 zUSec{g#?%{e$vWd&#}T;8HhxDJ2){x3iPa_yc_rc6rPNYCNMcULTnJASm2K-D zORs9xU6uCFrbMofBM6nr5yYasMX$bM@&2ahp)TKJ7v{ZOvGE7`}FoYYT zq#TS{A*<4866y90mG$~Y9t05IjK1Q!@}}wPPc)*K74x+4o|LC8W*&KkB}DBZ#P)i` zo(m(byYu^HEG01^5CT@3{mQjggXwG;W<#aC8Hx~vXb)ybo{cJzx#q0xgaXEbFrc*Std)M=7L>Y-dWY*e{U+aT%> zDL9sH{8FDSK%x+^NjGYq*J*Yn2-@`@PzATVwg|Q5x+w=6YYpquX~N8U0>$ z)osz~=A%Z?C^7R}mdjof6# zp!+Z*LT*kvp)wAAL);t;<7^5P2espteLF7-dm87C9#mf!YLN}+=8eM+@)DgS;(cK2 z2EM`!8+TI&*o`hOtCO2RR?btW19>m<+J`mJD}eghaV6N7tng^n|7Y~AkNDx`%ua+I zDLzp$ni94kn;mB`0`HtO;DkQ z^+d&{rpi*I)pXam5odxoZP!Q6&Sj&;KB^!wAk8YDA3J4H-ByKM8b1C_wn?*REn8c! zC>x!-YQ}O~&}Um-hmLePN3op@c>a@R2)dFVtJOO^yns%)K~s%ok_5~x(Trn0D%?zB z$MvbncXYB4DPI$SPGzRASeWc|$tiaiU#@ud-O++Qnw%xs>-y7bKNd=fxUh~{by%2Y ze*#QKEFv>z_~l(JrvVN~ykt;iq%ahjbA%fo*R*2vQ%i)~$RkuvUV0-`K;K1^Xw_on znXe}7>YG&7qP&J+pPJsuSqiyHPEF7b{jK&)bRs7=iC0r>y+U-L?vT;_+G*J;w5j!8 z!k`yL1!6BXwKvrYf24a34jOf@$9*)uI+#i=Yq@*t<#`vmlqhwOuIPTufOo}`=_kwW z2o&&&#dZ-clUt7I#c7-ovhyFCQtOINH`Df0Nvgtrw46ie%x{AQi0$fKIP ziQk28{0x((6|>cEw&LpPyUW<5oDl3v=z|`B%;@DWP=l-Cks$NcCFnBpT1=c?N7vh)1=)6Q zMr(_9{ue-vY3NWNQ5S$wXD2@L-AX$V7;H8?wLkN~FU~>(50o%$-NAe4>8*lU0 zZo@)c`6%he6J|iY_XdUutZlGFhC|P_$mL)JT0ql5`_)<`mw+W^-UCPd;@UPlsp}K- zjbo}tu?|Oj@f~byCXC)f>@i%oL{Ez2Z4I~N>ukeRek%fO3jS~!-lz!w1}uLpsp#sTNG7qF0&&;7sc(Q z!6Xh}e~yaG=ADR7)$<^U7KY`E_&QUz1e|MJijRR%<5Bj{_HCOw<-FAE^Xb4)N66WR znOT$*Ua`}v9|TwDB1us-f1-hU(557ph!n@izBS#>6d8rcJjdhB>6b9nR3K94%f}?) zS;u0nx2cIuwsW#!4?R@Q^!=OUmM+`@YUKyMpJ&o>W9S2U)LC&4z_Dd5dlY+}+c*Jh zt_Or_$sNtycmS)QH|N)qHx)cDRy^M|)a1F1($qv|Iy=ZBAdBgEn7#Q(2Wx>S4NhXs z@-%cq#P&VGKYtwz_T-W4cR#T4KSh)>97<5Ra^BY}*0F6j6MvlK<9%U@aZQd!hFvc* ziw>8^NMvi@$}`eH{LA%)Iqjmlx>+A0ki~nr%o#;etlJe7O(}R{=MR}Jl_}xj&CybB zldS952?YFvk)uqNaXQc(W8W0f_Si}2I67F80i-oj7z_9Hu1cvpj|mOWRebI|N8#a1 zW=ai|&A!)is7o;TB5x!YWMq{)A<(1FlOyWVvy~8qG*cnr;5_3_z;4;cA(YQzYp!kz z)MayPJz+SGIP1%xQbRMn7jMT+X3b*w&n^J@XfnHH6WkNX##g%~L=~$ei*|#%??6K^ zERKR)ijPCMy{56MoqO%a#V{xZ?RnYP*mVjO;%9GyuQ3ge!lNjJ(?~#>mUjt5tA=0x zcvv7NGIP%NUFN<>0hX01t-wQ(UhXfRtRZfdVx}3SdbZfIHZr~+jDGd@&$saHJ=f{~ z)#3i!FGJp85$j;3(;4;prig_mr!q$#N$mdCh$}byV5p7GP_-L!KrSHS+jZnaQE|E* zl4Lw^`MRt6rY*pY+_%Ag)|DP1qd~`zFQXJ@%SJ!p4`fc}T9v)IxL#h$%tVKNAH_7B z`b;TGPWi&9(uQ1I0GLpZdBn9i^f_&*GQKgEn{1Xd_@sW{-dFO)+Y6TC$Dhng&PMh8 zru7JqI;Mi8kG(66;qA8Vqv=DvN4?`Cx$Y8b>0$%rI4B;{v_-8_T7 z^8f`qHq#HMahg$=O}orUD0?1_8-Lxm)@EjMKdvT+S!Z?~>)(q@q*OsoDVBsb_PteZ z@4lDWP@S-{TC4_zx0lYF>XhHfW9X?~%#OlwG*aRfohsU_*vH+&XZ|NTp!m zsiLFiMgCK92Xa-AEUa{%RLEzbdtGNGhfXAbVAq~~C?ZOGu!wq}E^Uo#KK`cGtWYIH z;TI<@n$DwJ8Hq~4o2XnGKC$8x#F6##43_!svH+W+Z|g@ymj)AQtsd8D$2kYoAADL6 z_b{iaPcoIG5SA5%s!c6JXSf{*`aWR3>EL=17LuNHYUethUj)m;_Cx~6{Kl?KoYY&& z)XMYG2!$RlCaeY%*h&;c2fT<2y%;_cdQKN#b`i7Bd$9dCtVX{lD>(T4an$Oj}_!&=i zNBWyl9NJ@mJ5gbx8Ff5ru<%NwtBR_Hzpn+B>Sq@kpKT_m78L zCzn(c9ONvqbHzA9yf-&vdX_=$@DA=8r7( zaOg81Jx;q;(+Te?Z*6@$Gly#74f=L=sk?3Dx`u<%Z2kByr$5=!?Se8;I2$Wenn)CP zqmNyLdcenDV^jST#!Jig&47)tKu7}O!#_>C*~qMN7F3m1hG zf00iN@l<~Z1ky;`u-R8)P5h`+N}4cq>YK?id>d8!(XnbDLMs}15{12P=@O!p*us7m z(i4~KFjAA`Mz=C&6ziru7lk+y*^W9Z?!T?eimh2$`MsDI z>X!M5%|ewUqwLAGJy~mI&dNu$j93S=kPwNw6`#5fBcIF^N`?>D?D~(Y!`5ep?{6&w zHHyi>@(|(o*P+La9&Kh#?$$sh}$=x}WIA&OFLaV#m3)?AyoZwdyG zBy{jF_#Zbhe05Vk6xkSphKs{zV=f-MJg1Z*)rrjJq=M61L{!mFmac!X-O+tWuv7AU z?8C3-t%K`cvKQcwK5pp;-|?QMp0#_)_IrHMRiRgQCs z2TH#)eo=ZK6{yOHbdV=^G-YXSGWW-EbKqp=4pF4c>BDr}6`_x+r#F)W16tv`l~?LB zW}&a>BtrwHT1FmV3^#)3YhkyP_AOE{g0&p|+GAivaoO38(c$mxApOJGB?Rl4vdu&w z=~(w?LRgch-v#9dN|X~BB)gC_ggn}Al_E68zzN-2=N1^3O*~xN$r^Gt{~nb9>1o8+hF(vTtseORAw?{0XN3=MAT5l-L!DNxHs z`m^3UPJgYR0Nl1fy^QHkHS{wQAH~TipDvhS!o;~FN6C2m zRXPZI$vTJc?Y`b)!5WI~q+gQN?hKMjsae_v9`^KBPBwnxKepU50%2JhMXV=`|;~+$J!02JL5CRanW?Xc*TqpnV`p-2jS#U=xzaQG zE78OvvpuT=R}jpr>2$uM)NlzxT=sIywpGC8cF6|!d&_8G?> z(7`2%`8liz7{JiCYvEFG{teaSfo^pQc49%A6GnO2i@rAf{S?x@WnYM`@#pV0hB(NO zE~YlSRZGeTyov^za=Z{zw+`4^C(E-NTpmmZEHol%ZYnx%<~5RVvGZX*7Z^FBM)ij_ zx^xbMy5ZViig(s9j}-8T-hFCP+N>JMTW%DFGclMR#`d+HUGO=nYF=ye`4sCHzba(f zfT^+dOfUK!q%OIe#kWX@di|7@gxFk(gvwcki(x;UGWsBP&y&_w3}XvmPA&m!3(~9I z%uGHk*I^)Ve?o|*gKHdD-%rBY0ENk!dkqt|$FhHs3i{Yws@E;9=P|mpb3U@$TMo5} zk^lX20g zG@6|p{BXb$Jl6}g?6^|#W+Sd`Kv%o->j23cZ}wBWV9u$WR=I%I1GEj%40cI`n9d6( zJ7;3^s0FdNu+suo=~uiMh%=nW`Pj0N>YTXiU_3>AhT=N$Qu%6`8OrGAmop|_MpD|A zq!epIqz~B#VbXrrM@t8(3*bE5d0LMRd|1ghfnJEyJC$=2?&HGTb?qcQ9`7QY)Z6oK zp9HUEW5ut?j9>&{`M8UOQWX%mv(+$KiWwPD>1e(NR0~0AB^*5=dYfzdUQ^?#`1+>{ z9CnFMO&A{u9iHE(Z{8a(B|)mKw!UGBR7Y59^jT0@!f_c0ICFz>>)jXcUo0j{Eq(IB zLU|*se!8TxUdYUTS#Yiz)P^a9Bjru-QZXjW-8j(&n-VAO>~W9(?jx?f1rVPM^?4s8 z3V)}H{gV3<*Lb3ySwP#?OW`ilnVi@ zM;!wj9?GJWEzBVv_f-L|?Ezm4j)4c!*khAelJvO)7yqZpLPI6b)t5<#8J#A4=EL84 zn2q7i9JqqvI;$ArTp7>+Vs?u{SDQ^qc#C?5@b6eU)7_TYmJGETaGg+25@H+#vwu9v z(Vt@U|G5tUI+Zv=HU)Omh&Uj&vN)4{mJTix`eS2xl>NZcR;2(!LVH#=>lbK*F|`Kx zMLH9nf9AN_Yx^bbw5f!9kH?Z0+nzwpRyf@8G8paHaAEe%?+rgqE7s;aN5XD)_bd;w zseW|Lw67lDw`@mqNrjm^%Go@u&qdE(c0By1X6eynsN>n_?~Jl}`-}6d+za7XU||Ge zM3n5eBGpAv@1zPg?HqCwy#UwjEu*U<$Zg`4gt=2AM+jtO z^EFoMr2TsSS?vuBd$W~{mpfxJDQB3=w0%SZsL_^7UqlpL4jIXTcg7A`4rfMC!RlR1=AwLpD8j zJRtVOQTIO&L47X|M2mwco9G+W@6%mQrRGcoeqim!th>gj?dXy@~onOuc@^ zdR3#%xbeTjMwSoy0m?>-V1|E*MKl7@IT-bvtFoAc&AxdXu^`+tz@1uV`pD%eCH`Pi z{`MQTt^HjTJZzHyFZ|~vW^b6cy1V2CeyP%_a~n$|o&p=u{x-<&mIU7>^UUhtUsPQ9*~9OpnQedx&RjQzvwl;+}W_3ac;S*-S-Ps+!`dLd(& zRnqqh@c&umNN~`0&-e?W!Rd$CAZRlj`-wgqqgt zs6P1>P`aqq&B_dMN`oLQ`|hd!SJnK_bNpLKVIczSZwF3<@)sf6luqj?WB|D8_;9XF242AJidNhefsvbx69>U|&e7 zBV?l0%WXYhVe4@{R+5!>{9FT&2Ctx}M`pX+l8h$<$Ab!EQ1pko5{PkT;;x@Pf5pMHzz0XVcoG}J&9%eeI&7J{_fh4fr@KM$q9$GB; zFCwmc-@Ug%=_=&V&J*=^U!N2~`wVn{@0AYt3DiF4_@!pIiQm~WY&<-1PjtY?jSZJm zBL93|SbIC5!}QNT1JIzu`<4M(oqdx4pr`jwynSgsof{sGHp3^q2*NL}XEmGh75j9*Uv65`92!)it%yhH0)cg4ueJ(xy*iG_y=P$`I@n*pm{qd> za=U<4I)AVT1E?k8kjTU14U{c<+`J;6p3t9gGdlf!R1%WA(ic)@fU)L)@*J#b6xce6 ztUPl7%4a5Cx)kl~rQR3VamhlhDu?RPYHjNxgv7xF7GvFL%vtjZ`nln6$BRSZeMJ8Y z^I%`7FQSEy)6Q%TE6=IMC7lgaYg)y>MLB!;R@nP3K$iObh>%7^WEEJIDK;kHESt&@ z8!=mbTtaE38~=A+21`sUjsE{NEB{w^nKJxKy-~0%=g-(}r^&03|OWT}IP-hWnmF3iW|MeKl-i{{_uD+rEgZaX15D|10kqx>g668ILa zq#ZvxIA93gxf<-?LE4Er<&F+8?}VWC}E##48-QinVs(3*I9rECA% zT+8N}MiJRdoSUPRt-mTATB1J^p`4$k@gQ(-c)=pMOWzU{f=C*v(426{ua~+s3c|f- ziCPBR%`|Mc+r_D{uxvr=<5PLT<)HC4SWla1EZSL`6CaUCMvGSFr+*m0^GuzdJ_?vm#q7O0k&f7N zg0j!_N8|tJm;rAF=QEuY`cps%+OKkUrEvcP> zw0pmI3PTlXn?b3g+qkKp7TYn{AL`BBEgeRv#9q}%y!;)T4|nixj%`{$ugxlACvk9 z8q$N)n~kzw=U>^rw`@O>+`0>*xjbJE?Ig7_q7)JS_kjZl6gqAxH3zeEfwZEyQJOg4 z>Kzvz4m~X;)Z2|)^!83&LCbAUuV>$V(*Mi|*Q~(WF1&O%>uTpwBtyX(J5jl?IOsGu zt~U5bf-lfvKK~Ct+)Ipv4o4<`F6jW{zROoJ6P*Y~)>&M^eY-Um+<()N00uR>FID<` z=|w)B{Km zoqeJr=lp;>;r-eYn6!*B!}R%w5A@<7v4e+YMLUZ29FZg&ku{aUrTRkqU;V%TixI46 zNQ*?XAd!T0*9M28Bmv9x8c}-1R=!%uR0L+umBj%C)LWP|Nd$D8YZIFwqL?>Q8YR$7 z<59$yoCbvbu+>wPF~FEr7Ob=^KEtkqK#$M56LAw5gSnd<-Vm9rkPxH{vtR?Qel0)v-p~<`$#2^@`$qfe#s&)5o66FS)H_M_Jm% zB@u8@Qw8-5vYe~>HWKBVF9z7LUBn=s{N=C%oYG~9qD1uhOctvn$VbDq?aZ105ma70 zq*-M>%2qCW*lL3I-%1}Q&4|sH{RQ)%@U0VlHsVPAe20Z=nA4+vQo^(1>WH|Djb5zc z9bgMxKt9XavABN!Y%s^@&&r9FXRyDP_Nt}?Nr@GnwR*{XDZA@pV-M>NXn zL%>YaZF<{3VpxN|6Z0Q2(4aTPV2vWIDoONk=v2`*z9k?xpXRLC znl?jj@vBha__>>s@G=h_!N(Z&{iQ7_BjfeF*r=q*HWX3X!uF^MZP;Yp4>bTbqAZkV zkC-(8fzUxqCkKbC0e3z=PcGoTBg7GIF*w_~gwo!UmV^C&rSyNUL=s*@PdQ@R)Wb^V zIajjAAV_=5Wjcqda73S$hDLR!`GBIr4NIu6|1@x_xmI}pfcFef_!{8RyoB`|3KMu% z4WvnK^&fWi#w7$N_zqf{3$9>fhsgC*DQ7z*8#@8D-F+RFKd<*6{rLA^R(cL!@Kr>e z{BoRx6*qKz>%e+OIkmd|BF>B6LJ@H)=OOeH{>B3B%C0^=OE189J!m9SfSjwS8YH}I z=QgD5XN9R#x9}fD2@P0tq`iC&TL_BQz*dm%ipF2^)ek|{PPOd&SScq|ejNYrGO(rs zhv8swP{qe(k$d*FQWKc}(QP{W#hMMKwtZ|7IjO286J)D{%#n#`&pRL{=uUmB0y5{^ zxS3b}Z-AhOVbWLU@=K*efBKdD1mJXbR7cbETNXGwJF#>=VTjPe6L$S2CSrp~-ml`5RytKOI*Ppj8~NCVsQ(-qK#+rF?3X z<5hgsMdeVyUYZXK9{vE+|J=6%Qrh4yz{1i;A>NvW3x1WQh=1eFH{U01{p)-4!fS*t zU?EJ+UH-tT-{%f9IvyV4NxQtF0vcpJYR2+Q@J5vt9?ZS`(v1?>?-08N&XC`7EAFBK ziVxy7tDa?$j6yZTv|sJzz9?E^(fc<_pE8=xaYcfAc8Oum1D*)H##?2IyZ*JQYJ^l} zYiCo|xTWM*#k{!6JR3w*r?Sf9Ur;~E|Mg_BCpvk|sL}}MUAS$YDmC6aI0ZR=biS&F zG}JauHI7UadlIQEecxQ-FwG2e#;HFY2bbDcJR(z*?g;T_=EEe5Nb)I`^1aqBS3qwd zsAylz3W6ziSx~H|g(gVMD|Xzw?|q7(s$v$b_tmSwxQadgEok~9AHI6m_SYuL5x9)fkRT$NiVh5JMn@@O@8_YYvs+UC*?@ehpI?V099dNcHHMr?k?5 z=kUDnuxcZHcT9rkMvv(8b;H|)_t^^j=eO9x!jFaNnC@$s-}!prb5(J+UMROd-SK~W zH}Fg=1{|?qRl7j2aUIg0<+WpsRBDuSUE`E8b+0fa2WLFeEvH;r(b*Q4pSk@qgYote zmw$PtD;~2p(>d*r4oN)ekoQrZ@n-^|Y$On@>K}SP|JC#Rhr`hyM#A8k_qQb!6_y#Z zXkgOXjU9(iAlC)n@H&yUnQUl?BEEKt?wi-(bgBI+@AVCY{WC*!{4h2q(cv3tdAHF# zvz&U~tV2pGOOVb<%WWc$%37gxnx$9APoHG%_L}X}{=J8L)x3wlIxaBlv_gi5XHi$0 z;3gV%;asd3nZsUuqiql385eB7BqRM8HB8RRvC{xJw|W|c zkWKPG-qm&~ml`#M*#&RBiuMu(33ZCWn$ee0*{KxWn$iiQ+p3;d@9rG6M3x#)=q`Vf z$i!BGeGTO5RI}m|0LP`OIW&>D$@xH@cl*{TXA=&}G`r8`VJ_?vI>?jyuznl4?3&Wod4LVc5 z)dA}!*yu{3U%b0y4qWf6urQ4o3Z4u7uxAl#-gEBX{Muptq`0W*Wd z$lUFDrr*RC-j8A3|C(E2c7OU_C&n#_*w&mDVG-f6X}|K|*>1EA@$p08rQeX&E;ym2 zC3JHxgEq!PHJ|#gzz=&{I#PJn=0n{woZCV)0ky0;z-0gqWuae|=P+Rj$8X29b?&e~ zF-eCaoqcJtbMPNA)kH#IE6iI?4)t{G-Na>w%0Bhz3xa0V){}DabnawQnB;%w%joml zJW9Rw>nN~+n%kxnr4EXs&~c>`9J?-6iym@6fUC4Anqhq?JyEU5H^c{rO zu(tePr;;W~szv%4W=ZdhySlX=+@60Qpn_uC`$8 zvEq@J>LM_G@n=z0#^pDg<^Zm1uX1pQ_68STk3BPPgsOw=;OZ-ZOZp`Tv4iNZ zqb{Ct;*RQf#`>PN7yxo*=lTsp1JH*wTjby`DEoSYjW5j#=_!%vY?bB5zEZc{Am#IJ zw^ve400J4>Z|y03Iz@3~Lj21YlIj7MeQQ)Ms(&*v{sayzrch(d3y9ubL#=Wu!n2bRZDD^_#YW=goU7Vf`p|S2uz{=3#1hFB7$^!`jVHlINXa!sv+S&Pxc@ZEq85d zj0`3BnWrWw58y%VI~NlN3$J3{$Jp$52gj0M_}9`ARrOm0ukWE}JU!gMSwHtRkfETQ z;)G5(0iWAQd{^lb);k>|a&YnWa4r^2>6>i)2L)Q`N9#Xs0JKM+e;OSfvnT_YoqfXP zSKa@EquF>TGyNgF(y(NM*(=lYt&zTuTVKUXA1vg~Kt#a@B5G~Y87nCN-3);-I+yY} z5sLQA`)!ZH9?v@9cjC=lOpZ;}d;F}Tw=od(oQ8!d{mp$2=di z+Fba@tZltd(Fk(9;{}v33g!w}##MozUD%o_Fl0}Rdr5_E3Bqb%+h~@vg;=c4bw0`c z&4T@@O5Bc+>)VoC+UtUrc*d7=n`#AB_%82i$@VnG2OCh8!Z8Z$(xC4>j@~@* z9{&(;TYoX1tXMF-$fZJ`H|uzE2oI%02;{=wpAoe=tuKkBg^ z#cnQ!WR_%P!*rJ=0$|6n@L7aVybSi0d^$iXVJtgl4rJ#;v0XAOe@E{HPOjw$`s#7{ zfP5gQU~U)V2VIgdfWI`|6nD!R6)c!=xjL3iUB`D>x|ua*d!cA{IEw(%C4 zNnsaXFnJeirX7P@U@yWv2KM6n+@3$So9G=|+YYoC$GC3jn%uqaunPde5*E9p2|P&^ zHfkg)qnljIOypRnmJVuC-!OMK5;@$`FHWxDL3PmhvX%~r>c+b?D+99Maylfc- z$TTVnD+1G^E;eQU7AZIb>q}&_6rk$5MfB@W*zD&Ly>-cZhRc^zyhn5Pqu4W$ww5}f2+Oo zGtwcu{9YX^@s9KrC9Y=zly|01W{&?`H0lh8#74wcFl!-V`uMeg0q|Lq|!gYB`XP1)?$)wA!!2mF&hNip7Ipn-7>){ z02#62DYxlH5SZpVNTbhUe1fG`71BKt;{BXV(rI@BQ_ce)Hp00B<)Nk+x6kbC2g^Li z-S1j$81*(!wqtJ>dV)2uQaP@j4gzE!9Ye-mFEl;VXw-`}sy?%9wh|o;;(60xC(ukM zefw(gNELsXE368m`|-?j+c9IjFFDC8K0RH<(b19R?CZtsKe$w|JzdBF2?<27b(hzV zc3dQ+d8FA$7yy6~Ix$=d4lIL1Cm6sa_y5TI&akGotzBB^O{t0$K?H*oks>{yh@f;r zkgkZ*d+$X=r72Pb1VV=d5P{xYR9aYMjMF(CuZ{J&)NUY^ z-1-0>WOBt@obY@N9Vj6Epw#TWF0@CRCdu#2jM{J|1^r<=3UiWjQtYfx>27Rn7-75? zBeZk#fcpXl@A7b29s-Kw>ATeSNW#a2Ti)wsjnk+a6k()4197h_31Ikh0^XCvl5w*$ z8b?CT1;{r0-Sl67e84+ZJ1WWxn}M9e_y!;Zfm{+!VgFrovC8h!D^5zdq35?Amz4&{3MsYMFL(!7D2rr*N|mPQ54W-Kb&B#x z`>Cejs9CRV#Xl!cI9%Y$@5QEBja!k7DsL&KPwe|&DUvGH05?%RW5VEmRr+Ua)D6Mi z{)B~2LfvRfRD#{6nv97JEo&uLZc!dUKhW?|)vEo=GYjbxa(mA>KpJ;VFW)u4{kd>| zt*&kp1+(5K2uLo||6FMc#i-=s(ByFNJ9O&N;nL z&9C^;9Z<54v@pYmtJf8D=O;W4{M;mI3jX1lkxR0%K?h;c1~1!7pyva0}LGbln1z#K@y4tp2XBurU2bhK%zjvX*WkvI0bdiJe&m zJnMKzz(=`r`P;;Jt!okBO!VtHhxY({l(-PnKL`2epPn9JSun)N1rPs5nNy`tJ(ki& zdgfUjr`=#Hwn{Sw8p_>GEzqn+qdj?d|QWTnO^7U%krt{P_-mJ3^c^ z@M*MVqkJ+L+Ig2vib?%AMbwPi9zc#*Ndk{V=t+=k#iLIzp01UaULWA?;E?lLU@kMM zh{(;$i_77cluQIe5m&k=S#8{0Q$@}00#(!nw&||Wafofp2nq>h*tFgXTV`u{Caj>4 z0o2aaknkuqt?qQ>YniZq`}XZ4O8`eCze!u*LK@S!=r3E-=G~5>WfQ)9xyAc|D!4^~ zpFaX@Txu74siuYO$nE=neKbgpirX`YhMr3%7Qi31XCK^O)X!Um9!pEx_dh@LFk@!o z6&hYtw#RW{vp8d&Z$~p0ycN($@2PVXDg$aI{>fTEE(=POem9`A$))!<|qb^V>HEY7VQp?SHiQYYVTsU2V+p{6=c4OVmm8z7| zf9Ox><{ug{l(bpwtGBu&qUBF?=Azb#@Y$~A^!DiNzcde0mP+q^h|n%HnI`I zUvqk+D+7S!sw$x5Quzpl822f`Be zs>eZ`d~QK2=bPnJ-57T`N5jKpgx6bafo$m)>OfAHzIN#8>VD4e`Rvg2%A@G5moZy= zWv$OHVCfz3DADfry@;8@{QO=`q0C=pD*g0(Eq0e*ka8;Mu?tYqQ(4mp-dKy%>JyT1 zom6xfD2($Lw*r#Ip7&hrAN}~u5RkXHa1xl@v+tKYE3ASxK-%e2Uju*aDXvX6X1FvG zjd8no01WR|oQzNZi%SG@`HCB%U%v`YFv3*NNlQK2;;53^;v+zdisS~OPlLa%$7Vk~ z6{{8-m$RhOgeMPKz~vB4s*n>KtSnN87moeuk46=_+xm~v$l zJJN4+6|WtU){iCRKUN#i5Ef8~o6H>Vc(7$Y5bdGu3*h_&A!Z@5z{ZY>>)ctuiSXnZ z>D91~w#+%4TluPJ*(PmHJBdH48QfRbkIsMvDpU>M{no^+1?saNYha~EM+jX?h7?R+BK z4lIddQfH2(cv8|?stRpK=;<+DxOG;E!B&pqF7=9Vb45if_k;)x1}7E)L&ER0U>|qkaWDa?pz0AUM}8_{J@m7U4!J0e70CKWU1Lu*zD@5+m2Z)=pzD5#Ym{;<1v zrGcvVo4Y_5w~*e>?UDp;AUjBNZUjh=`QV=bL}SOH>gO)nNO4Npz1eNBwqBmX;c8=h zI%C9}?!l~rrCXSV`iu1izW$LQa|WYl3iixh>!4~EBk3A4i>}v)))67T!%Zz-uV@qa zEGeJTQxv~s+%4{Jay||%Jg%1>SP1XV9_XMlHUy(<5@KE!ym(c;7tP-nUu7YcyVgCJ zZ$!RzIFojj$>2(Y?#l=8%+*4@Y~0=6A}V~E|M^d?B~Swvt*=@I@*C0%-`;OFI9f;B zD;S@OPMtC-ecW+=O_xxJafdD*(LU?7|0=qOH`dkr1lr|6ep+fc1A_Bs2jvx6HQn1Z zAMTOqfnRS*s`xL)cnP`E`8_hcr9FA+?zPc<*f+ej)v*M{hprc4H~nFdpt=FhX@Tc6 z+@drxsf4){CLIm##!hUJ@5> z&M}F8DS2t@atdiIsor;isB<|`q%p;;66?B2qj-dS;LgeoRDAw-9YHY?*6kfe5^RDQ z4{dnhJ z?k=^D_VguqZ-@CpG=X*@1_M+u;R=y3oet86H3j*9x;Mv^k}LlBZW!+xc{>s)BBi&G zaN%bA2gM1hSN9P>z|#dmm5a(Jdo~f_5Q_D(mPo4mefgWC2fyCw9?_fH1uYh&9!+9H z$gBT6fzOuS1_Hky#puLoU7G{M?kkVWMb{JshPZyQr+0J{LKpQEM#CPw0lzz`+ZD%o zJh&re@vs1S<0|~eIJc*rpvWnUz|2BAf}>AMhVR>0b#v^)N7qg?V2w%qt^CD3iO~zI zDG37t)q(n{$uo_f;`092xdd}doQy6Ym_Nn5k!0+Znv9dMp6Mv;$r>o^Gcew+eKMnF z?nm#eJ*9fvxCw$@vslUels1}Nk$QD?qh7T@`!vtLTVvyU^iwY-tX_cQl-4}+nQu@p z76`qDa}pjjvI@QdxJpv>jfG*nOOJ^7>S1N;LKMRtaaY>B$bAXw&(FCFZ~BoUR%nZP zQ^%RZY<>ymY=3oRwD-;ELV0#!hrR~gD1c$MWLNb))jY>*>+AI7BLw&crP^iV zTZh)ODn9h)em|nT4p(ap0kH;J^Qhzl^@2tlvZq2%gE8Cv_i02<&*Er3&q-Wuv7?ez z9pNUqJ(zUO@Bxo^Wgtl5*;U;vr_QLYC1UA673T0no#|)s*8uxkK#qE2Css+yMWKU| znqQ&c$FOb6weTAwZ@Oh(t+uQ^es8H=Lc+J_@e+2e%>1UgMVUxTTQ9bBg4f-eA=B{0 zi~qj3m5h8EqQh7QdtP~RQxv_nZas1Fxcby8z;>M<%$;`cS&~vzP2n1xclSh#!&ouH z(t1}FcRblq2kG=~CSlte=_UOH88;9rdBjH|P-aZl76rt0z3$2!Tt2fabs@xWqy4}PCLG*(Zb>O zs?agPpjIwl#~>C6*$H2oh}Z95D(~dhns1;8R@1+FTP{AejxIv-V&~lz>hkqy;kle! z(T_;l$=&D4Wz{6enu|#ac1}u=o60=q8`8c{FpOK2j@{3= zn&30;JV{r-*(uWK|HRs@HLwU!>#r(P#*%SZc%37z5i;h!XWnNK(#fY`xyotNnxJyR zDHT>b_4%*rdhwF*X zMumn=;Nu4mq-t32Z@xFl9pR;;WnbZg^YihmQ{?YXvX3;K?tc#u^IV?{^tG=HT%Z4K zx&}0j@q9LUvo>?;gXfWCi6tJQWwl$^fv)QLZF*jq!Cw9g`>&nNqHGK0FohE$5Tg%tD%oz0pz?`V^GWL=k%-w{~C+M<6jH!8<%}GN-KW}gh9I5 z`6To{_V5NKH1p6EZyF^RzD;Ohz}R+r4vaVQHHW68j^=e4IC;7sbsjxHE5J!7^6f3awD|8-+2 zV9;W&CFF}hmyrxw%eVTf+gA{y6nZ)w!{iZp;$YfB3N1wbobXoqH5?Z-*yI>X2D6{6 zaW>0P@I~QzP#MY7^0c>#)u0^(3I$Mqw8BwxkxDnR(gGg=?ql=n^)WKuy69&zaB-j+ z#L46H!z{NW)KcAhXC7&c>dB9?x->)=42SmATu7wH|sBM~Kb1QM^ zzX7(l(v*flROP)N72u;yY0f}t=V+_vkSgE(%Agj6?bzjrPPBI$=;bnBD|JCRs4kSD zVe7j>p-P`DVj4?2tgp8UmjD$>DYua<2aqqih*ZSsqYGA%? z!B9;CK6H)uQwP^o*sYXdOIf9hB)3cvGte~+v*Csm{G}#%Q0z?*vsXuByP%$Yl?#>O zu9}OotU$-bCxg4G-kr)Lp$#S7>L)f-XvvTu{P=BKaizTv<;{=~%^Y4bk1j%9r+q-6eu+|j?l&G&)45>Pmlpn0Uzg9{Bd2sM4GSS^HHt&PT-cz}W49ix zY}ZT~mwDk#OmajQLXz82;aA2KAr8!{0N9hsx0A;(kxC}^+||}e5AX@~;G2=YIBcg& zS?=p#5XqniM3Wq`#5ivqLmH}@bXtX4n}qp;IFkk+t0qs;iWQG1PZQ78)2v1#-C*hs z2ir_MMW$rJdT>eF_~P@>Fw!OH)^wk>&R5MVj#&MV6n;lA`ayZ(vlK)Pp z^3kVGs%cLR?;$8`|6Kq9(U2I}eZ1cjS_9~#LrfIrA^Wq#wXRmUq2@>?<%L=Uec3x# zD3~E6q`<>;2eY65xMHCHn&N3m)9OB4rMr3ZFDxxOgpTx?xC@(Sk2qx|CN1fzGX(<0>0-Mjp8TCHvcf&oND!W;( z&nsU0EnsjQ`U<^1?4a(3k!9XcP-r*_Y&6otI66snyNZ6@>R{=u>MzW7o@JRl9cX9{ zuSrX(41|U@dN2}eZ>YGs+nGJ!3xhnSU75t*etgl*i26!d7?{!m^yv2uYJ=&xnS;U2 z^M@UYlCHmVrxQ09ozT!`_xfuge2|tfRVKrqTdyyr8+Iu1iw8&9u z@mw743$a(37$Ko0sSDPd#ep~3tb6%7QI%F*(64B*m zP#D!e)H84pFisjHr;BlhKHB3ZT2wZ;lU*zrv0g_f;K?Kfq(^9T8cLCG7>tvQYtYHb zakYw*m7Q6#9{yF%>T~t}yT#6Ri(fjvtO{*4fN{_ZXh;~Qs4sK&V8FTw;b){iv$p*V ztrckDi1;-uGnB0N+r=MW9sdn__p7&Hey0mTlh^46u=7dkw0xkikr7s!Y^QJ8))0Cy zI%>iOy5$A59eRL#Y%zc^hq*NqRM*#f@iJ_}`g(k~{K_{i_*%H{|0d?M(;=SYn@VQG z?=NND>tU%+K)e}}E;-h7QyZYAF3#W-T!pzQj6H+?#zG>7X()vw@U=T#-e&nhAlR>S z^d@!3GZjy1>QZjFpuNkOxa9|NJ5oAfDAhO?w^kaVkvQ4Oh1dJZr%Un+1G*~2$h)-X zKe)GF2qF2g)uU2tkUDJpXDxuq#=ygHQG7MjPW|ftAO}e}ud4OuP1!R27rq*1Y>D7W ztJOUbg`)khA?o|9p1c*Xt)^NAhQP5Kaq-(7=jz-i1tv$xmXFKquSZf^wly|!Ak~0L zS&8u}Eywwf>kA%RHHA)ysqC36)Gv}?2e}}7X=NW*C0#8S*C(4m11uRu(90>~*NO?EO)ugC9-Jz*S87)J=q*z=ne_n)1l? zDPybS>~`sp$%%6eP7~h(0$X0vyJm=&`E?Da`jgWoGDG-s01>*fy*N9~EX>i%!2+u& z{_tfVZurVKlISU-wbBG?tNG^2koJ=y>4{7)m&o+5_L2|?yp2g#Al20qh9r0HP)f+M z(KT%Fo)Puzfh9|#oJu?F#;NC^wqwo;#t%iA#5`wNj`e-NP*Jrq@SxY4VYb20cVwir z!o?mI;iOQ@CDao^Xpo58^x9xTwKm}gN?{4Aio#~HvAQK6(q0#K%V`O)Xbbt}V?={; zr((U-n|Va*VfTRo#8ZXZ>4N@7me!!d&55Rc`@V-<6R2UsWeR6q8zy0sAMH1KJ?*pE zdZ{NDL-&3}Q^}r_As}Jmp_Uyb^IkTh#r!Ij1pHWYs1EiP{QeEDsCHZsEU`L#Vn(? zIeWI{(>t<7$XYQD7v+v;5)8pZ$-qUUTcm z@Gzg%qNxcN0nBM1ujBMU9&ha{uEG(9t<%|cq7Irr^hbPwWgDQ{wT2dVZ({`I73Y~1 zn%u3>)y-EA$6tuA93Ca_w4XaIKlK|o*(Jsem$)dH)p#a%3r`L)yb<8Uh9qWAuJ|3^ z@A`Or9duDXaF%z?2YVX$6YBcrCw4gG=WDYje8F2&sRdo~b5rv3t}*wuX`Lc__FXuHC4cn62;dOa2*% z`Q|*X)IJ_B!m@tDg=>->7+@v z8NEVpe`e9$BaqscJA;14?_w?(6f~I*!~#?a@AJcSM^pvCjEt*uSp4!;$zOZHdc|#q zHTIHR|GL__k?ldHxmd=gIye%Rn2q0Tk1M?7gfo8V=%z3jK;exdG@ir*Ko?rp{8{B7ix zTc&i^KgN$bJDa&@W!@sD_tug5%Br-5;1^*#Cu1}@Vo|Ne@sb})cWqKKm#W`nDNcOv zUb7ac75^RDfz2%-zdJUc(Nviox(i#jEUJ$iYx*oI4^Ob|XmW>StBk$F4lf@xa_(~} znfh-s-!md5BYzd}6j*V73Vx9VLIdT40B`84V@LP5+~z`UI`)ZAh5SXy(`}Pno0DUL z1`Sd<nBdLT+= z^c8I^FIHSg@*#r8RH!o7Qk0pW+~(~#VZfh^JP_Ow@W;_kZ6qE}&c^ws)s>4_8|wPu ziNEb6JkZ@h7VX`_Alg$UlG0$5GdP}!uDvimXBwa-8aTTA{aL!Kox22K)mwi%TAKF- zbd4%iLkHUo`6MnLXa1`P+uR&Bx_ieW1TINKe@WOsizB=f097NGJ2VvM@A8?vMMs)e z=6$~ED)uo2uqhk4^7kS+KwZE=t5LSR$zPP$ErPCOeQ3{f9aq#9F<$H>Kj~TLEztEj zBcb;C@Q!&?U{b{iHT|t~Jm(;M2G*e}Y58iJ!|CZiPa?@{ zpLpzIOI=J0y)#4Z4`z-4As;&xcw+B99Zv%02x5Dl^OoMGqBvPHokTli_HGh{?-9uK zS)u7NZ$Pm-+cBcoXJD<_A`7`P?2%d|*>W`9U>54jL#W}$ze-;3gw{+XBbRI%R@KGE z3I40Mf5}4$;+_B0oc=M#SF)S?Zgx>g#e4sXYihVY%)faKLK0p~MnRcd`Q{bGgSyyb z3`Cl%t_OT|^wYKUo^wGX6`hsH@t?@XJCBd4)>-hB9SJaPm zevzf+{D5QX<2OdHR#UL{yT$ydLJ;B`cmEdeiOMe;wcTHiI)~$GEzh*5)y0Fp z;g}Q-$5Wf+xCxax;J=?49sm9?Vg<~`PU`h>f=GBnVrGqGXtFe#@Oh~SKaO-(N0ri^ z2r2+4^lHs-k)Nsa=o)T81+}(xTdA54ro`J@E16sD99wJL!0#cM39~lTs zl%w48GH))2u0)C9YGu-}l=droL}-d@1vxoW^{pOHC4{`)jS+(=&a#(++Ul6d4~!||J!=~ z&+;`lG4n~3i?fclD|6I^{$TMreeufYVdJNjDZ<)B(RlVcv~V{)I67|Sx1&PdS3Yde zcY;ifHLMMrRqC#Vu^3(8T-Q17`n2{lw60A~CV!EyORLQ0_A`P@!8uV9+D7=N{f}$q z=C1xo`EHSw-RMl0%5Y0i$dijwtN6DMcQX8$4*WlH5vy^f+19wuMSE^Zvr$ixsTytD zww=SPk*mIrD&?M)V-HUc2-iF4;DcJ4A8kA_<9lT{SG%~oR_EqaDk2?uf4ReDII3)>vG7g%GlW0rG~&2F5qy@2~$ouzi})>qbnF4`^W5s`No*P9wLTh z%m?B^HKh$9-XefSTiis}poUzp*Bnz4*}yFOJIq{hT=X8;nsWZQ;@UPYs-P5z3)n^_ z+lYG~zT3TIThnBn7u-lR!v+r7an*X)1bcnXOPPb#iP-wHEyF^5(H5&9>V89mW5wO6 zLD8G5saq#&$tBL>-ZopvrIll#M}1w_d5ZhFvhr$Hg=gI_s~%x3g&x3;oJ&7CYka-2%CZ@ z`kv4K2cl5T4~f_j!%0_XgKpMa*bsbr{mhCqnI=C#wrJ{jY0H1RvD`5pfUXR5NX4!c zyPjI3OGBn!)XL*kD7+6n=e~_Qo4lJ@Z6junDT>?m5|(%7dz@d4)5IMf+JFSbzSwoF(@)Gp-|-#$R)K)8t6^r z#Quige2iLVSHy0LDm1p7^Lf^RY*5%0X{tII+q~4Z1>U0*XusBbfS2K3@h10rKAvRN zf@^kc`#k_U-Clbm%%oKpm5v)l?#Uh>zj1kZ9N###c$e!}wb;vKsgUq-^tO@fsg^Ip zrBZJrf9}#=-{es-@!^JHZ(FIdU!5sE(34QxSrN0ftQ^Fw`My$c>C%=_^+eWMN4TZ9 zg2MRb=H}5>_PckUaNp>84U+IH?7j1EBFM~YoGKz2!KaG4j~N-c{{Xx2=jZC`j|*fR z4$4BfRa-9^C0=@IN;ffa!lCI=b?GXZv?2WQs%^I-M z)t0%fZ&gEi-0F|m@sNfgq`}q61E@t!QP0PsWvDj}abFHZnN;IC<$^RWt88s;U8;0k zp(4{1fMaK1DDyO_4z6Vgu}Opu1X`H8o^pQwA0j-c7=F8fYv<}t!_*1J?Rn#3^$K45 zdH#|pJS@zVR#>=qR3Y>q06{^C005#>$!wMI15FxYwR5loyEwVxrQq9VBQw4nQe;NZ zx%_U~GoglxvHolJsr^U8vQL9Qc79i;uDn8pZf^znC2yjALYukZvO{9_?uE`Lb62sx zL*rOB&fXV;nSEgKUS<*PiS4*!&n=@+_hmGiQN+hFkJJ*T%rPorNk=URV!9eg&cd6L z;w(2wIIQS@Xc^Viewj0A;FD{c+oy<*2C=aIGQ-tEHscG)yNyckvh1G?seDU+BDI~G zy-=-qk8o72O*Dmln3Izxl3mrB?`axXJ}GV>w(NflnXfi~c>+|h5^2#2En5a-BKO5~ zyCV0~(GIjO0>kpqYb^Bu@y${nr`|GviKh~x;S&uiOqJ4y%e`n$R%O=X9HA(D# z7RO1hfsnNyg}ABfA;ZdBl)Fj?8Clbpa-x$Hy5oq5o2nSZ1o&=+ z_HR9Moc0bidWad|x-_b5X$=qc5jyqf$EVfVyvio_=F~>9Tg+A`-XoHcAcx~VE{~r~ z^qitoi>eBQd#<7{+NwA!HoNxexqq?2nGB|ooBjkh36PVD;eu9`PD6cbJ}TJm1||GN zKjhxIe8N-7`oGl|K%ILHC@|pkH=bb>Quo$amX)jJQyz=Erk@|9>vn7=1cp+dw^PN) zO1Mhc!zWiKn;XQ240sR1lYV#$M^+!m9oBSxcu#R)9Yt`79iBGOvdy=?e#I6Uk81i6k9&EL2iV`OyxH{jkSm05B{QL{!8b)!$OT#iZhN- zqzJhZPQz6+a(>aIeK>Hi-o;H< zW_Wj|_b9*2Q*9vqedVlKs3l>kG;TopjhSZhVyO9Eol2nbN0y@w;|p<4MYW-{>{5VM z$heb*wsc4_YYT0F?_cw2{eWFn1>8Ji=VoZ^+-9&DnU{Y1E6iAd-TK-L(qhX z6tCP5aQh7O1S|P=dF|^_)5vhsNPE_b!t%fV`(K{vu}}cy)%gL z>NQ?)yW=J-Lr&fyCuu;GA?pmv6PLHFm*0B6R9f}EDV2AtXYJNrZg0TFqUoGAft86l zq489J<@%IybsXvr#=!zC=!5HAa+3p+Pq-SxL$V)kHN}aVIQq)SwE+6>M(yFf)YFPu zd>pZZyUzVYWhG%1RgUsj7~6|`ce`gC86xXA2ckHs*t>u&I~KpapUyd0j6UT^+5hy- z$dx{-z1kyJbaTtt&xU1is-m=Ylh`)CQoTyIvLrKW0TSJapo4V>H(&Lep}X`U#a_d| z<@tTHEL)ggB!h@O^UR6oiBmRrK(zEp&d}```EE5)5`^hKFWNjRGxlADEZbh-N0$#4 zkI(%Zru=EE(1-)7#SdNg?pw{ymTf07<8x`0E7}_;pm%fPUsqSBh)tW(YhK^KxXd$Y zt#lrLUB=9pKE-0zFRTzUPs3pKp)ni~J$Z3#lU<97in65&5+5O@KnRzA0ZJ#nxga~5FXn5?;oAJeTb-*Ex;pnL z_*d;d;Pi;Y+xB=nhdk|S&~o=aWEm~fWy=;ba?0eOUXWPD?_}KV7_-PkRpeGa=GrDo z`*;}#q7zPE>|m>TN8XeU`@F+DsEYEq?5!z}nwzksf)8kD?wbN8)ShU&lDfDk@^kk8tLJy7N5 zr2Fll%9VBR=C^@1brE3@ZMHAl;7+rm>X-G0CD%%ZuYR=sHjDiTe+An=&63>-U!E&Y zf#s-Fjw=}piGJF=PrJJERLE2a%@%oo*9X%=+t_(zQsU_o7+5*udcDW74)ZzXOUV8RzPY=I@@6Gnw$}y} z{|Wp_DR#mElRG%<%heFYaCu9KX-?PPse|gJj-dXgw6ap~r(5s4cD=&sXzPg5Zz>Fe z*P*`(36&4fl~$n}234eydC9!}+n?=5Lo89UVl4%tT^&p>AKHm?+%cY5w}%7VU|_vj z>7VY|zkzU|Fa)L+NZsK8yx)An4j0Ke7X7yKH_1xW4nsXpT#fZ?sGu;g5#{6k&wt|& zv;4enSTp2jreQqwf4wODJ#gAR?l1q_0{-P?_-7VAi8p@pNdjtrdH3IM$X5tV+Iy+% zf8zrF@{(@@z|<>~aAdL_r zd$C!q3vp6)hw0DfpyqpXCamYT8a#~t*GK>S&K)*B3FhRe2mhDlHw1RY<1AP_?SCKcpOoHzVt4*{ z5Aa_zbKd*5)v5otDg28b!3+g{$cSC?Z#(!e;(hPInLPVj%7Op;aC6V(xl`@xe>{5s zn1@$q`{qWEh(^?ZAMP2qt^e@Lo&UatZ=AWy=KN=Y@^t_0qL*Ochro1c|9$qaoB_dS zMf4@M|2|xO;D_$~zb7o=$D(>hMo;=qw6wIqir)72C1n=aVW7pwthiZIP`&nhi5xXK z$$v~vaXeX{@_JP^9sBiipj_T(f^9J6-0W=Iv0>Vmr0i_h7!GkpTOeg<{OR`(7t*tV zAfmHu4=J1ii->V)IZ%Oc7)PF9CYN};y@AXi@1o$&0wLJBg2!U{jIXP zx?5G5;BfG#1JdV27lSu}Ad@YQvjTKL_5w$lN%B^W^Y~LBz7c2(k}hfcTGYI* z7qHPSXNlKd0s?d;5fZ@9P2Ul5CQN!tAm!5|&r>S+4$5bBY`EGH7B~|po4e*@YMOq0 zG_SH7dOhf1v8cv>>#4}~>)Tz!NLbHzg>}VKs@$#^%sD{z`4C~2vWS;`j+*E;peTZW zmHoiguCHG!OsgICdsu=phs(_HOM@jJ#j_KgFYDP*So-|{8AdrsSOjgf71H`Z4S@2K zpRDq=#G#cy)$l;uo2D3{F>kTaC)veo$McFPu95+K3%-VWoq+$-pGpYGz1$el9 zaR#SA7Ql^Tj5KZ zm?#Yc0x*l7QARf@ym-9JsvU+pr+xrYjRYq7`dzu;=-%CE&+l?Yt)wMnI3QETXCWv0 zU_D_iphT}ck>0|LR_hukmw)Est_C1x+Up4cIDs=D`g+FlJ^U`!@d{YIOAu1R!<3AS zW!US5=u9$lEe=b+N5wyHZ((8{u<>gh^rLZrL>!8Oj5KpQ9~`^&GMNR3^GZ7;Sq&fi z0u|Qx_Fb|x0PiP<-WwgZ8{HYQwb@6%gnrQNdg!o05Di8J`at&5G3_0~?4<_?pt@{L z8F77T3N1w|W^*z84o9z}4r%?jZw~^2NNVsU6j&YlcB;arqrB%X>^BE)PdzFK>JrC! zqm_*&(5V+5jw_S%oHL@nxUe=3G~Ib98OCdlhM@xpJwWj%7{oaBAJ_I`M`43LoL+=J&dF8v6dG63PepDy&}l>srFC(;G$ zHniut{eQjP0h(Vt>`D<81!zAjtMJm=$$%~Gu zJE==@tVS;%VDijDTa0ia$M1X2UGMNBvfWy%n^6lVgN|UQZQT-vTGeGQESP$d3KE_$ zi<%%_2V2Bc^lR`2|Dv+V6U85aJtPtB3Xs4>m%^?2GnQRlOhP&JS@H2OKTxhITs2DD&QDc>qxj zmz@e47&PE}KbQ!yII5#H2H|rBq0^KqH=$Q6*&@tz!=tCUD}eL}^I|nCpsc!4I8sYC z*awkl!dAIdsY|LH&9fhwtYMJ};AYtB1!37b|p6}Ya1qq|&&&X68Vza#5a+;1t$Vjjb4p&_PM zx@vFc@aQbOE!0fRM8auAB9wPKduIbEOS)wSDz*(X#S4c)M8cW%AD ze{&z`Gw>0J_nydx>Zy_56a)zw8Oz>!0|0#Z6Tp4*hW=Ta&Dn8@C<$}qW}Z-jZNPcY zV+{KW&f@S+b4nDzK=iL44ipjc91)@J%UrVW{v_&^St_t&BycJg(53~{>o>zh*wTd0!KVS;W{4m!_$#V`2sZv8Kg~{>esth_Hx6>tA;c}}PF?mQEClFJ_gheP8us1`*Q%z~ zck8!69-wm91r8?R(n>F0?&K<3!GHVqPhdf}L$4IO z+62Fa6%?eDjGu>^+hv%lSPnee4N~NP^NkE+XyxmUP*YFdUefvuq8xl~>AO|Z^%gjW z4pmyE%iNU*m7arxy*h9qRgM4ye4$Bw3gbqo~y|^R}_e4zRf%k@N(HfS*Jfp zJc(&GtPf)hI9}2?`tnxbSRs?(IP6?hsd3DVw<4(Fd_Z1OnzZjO`Pmd1xMS@MCxx z)$;Kk@<-Q?BIbupDLQET4LbY3ve)zpqu<@rXyjuW2QHAG9A|M1MOQOn+z%3&8|Vt zm#kFJbZ*t1QVe?B5sgURJ}IINWB`}ud(MLwrP1pb1T^Ks`V~fn)n)jv$6bu}4q|Fnl_3Z3my=ooYmrzjiso_h-HB1`qM5g?D?S zvywji&N(+xq+*#G)Q@=n>Tv9(s3v7>LNv)K^{M4!X8CGhKN1`v|1%3~asW%>%yRs!~nv?Zq74OtZBaw zk*9dt@X70HohNc~0ynWzTVq(q62FJJwp^LQb0711t%esGhi-+tCgzC!R|; zwWombH8D8^qEz;GR>z~8V0Tk&Pdd)^`qDSnE?ngy1i`mfFvZ*sU9WkskY zH{yi#3U+U%dS!yh?}=F@(B4tM9}utk?kdzyl-V`YZu}3$t@tazKsaoU+Dx6T%SAA} z^VM{!FX8fH2+0D~^QMVqF>zwl+Cjz8<|jslZ-AD7O^VdqT4ER6BfnYq!5#o2>k52Z zWC#Qq!v}L0W+Kd%V1Eq`MRM^OBqv~I`UQ`znI`S)1Bec_UqX#$6B13=D9kqZ(r%m> z+9n#5)4k$U|9Y425rxqrTA|oNuq1-~t*<@uyrE6t9Xk4A4;epSFXp@9?)dJQO{hMX z+*yfH*)9fWvKXPjiOVqUJshI(t!wNPEw1M}u)e}EZC!Mp3_KYf6dgyPdMdVPnD zrig`)m;JQw@Vr8C9os8eM1#?eTKKTdZU3LjR#L}bg|bIfsknblZRZ#ZYtafX-em@X zyn>hHp~z}$yb5NARZ-6x(n12NuQG4BKD+!;OZLrz=asz=EyqJ1yYhy}3;p42y@9DG z6P#@F>j&TA%c^BGUZ`zCD3El0!g(z~#UQOSk*v%m1B~1?@%CmHT~yM=UE{}nT2O*% z;teE9N{4~k>wu}lr?;=*A}Q<^`v)CyXaVtkacvRBcU%`!I^nDEyPeB*Pep3zS!o0* zib+otpP(D8k7w$%wt_EhHXq+}0?d_hRzQ!QrYX)I}iRV-wxCqPug7=l?{)?Y^E>a!&xruCWCk_`fmvDVh*iUUCC zXftpmt-zYWy@2gXcvSYf)VhCJ3;ouPEo&)W+_>LjyoDqSybsjZ=K`Z<1Dt`X$0QM! zTL%+H1ujnv?q_|$Z$B2VD}Igr62wuLFhRLB1cM}(ET!ltS0LQ*(#2Z{_RAOrE4DhZ zlJn51YOij^-3iZ-8n72}vTWBIL_0``;gPm3ZI9{SVZGPyTLIMTpTvz%IqQn*CYeiU z;<1=p{aee7?t&mjejM*oy3rfe!#ouzAF|<~IN`{AOZMln2BcZj2&VypOBpw3xHc_j zs|3sT?)TS2%OPcn+l2hee3E1?7EN0V3E|;@iwSByCCfvCzMhd8_`FFKXbOOvx=!XT42RB?f-0C|Mpw6=!$?$q&yqg>6B z`%?W<(f;SjsFUc5)ils(ljEpm?>sFP29n}@<-nnXc0tfW1R4sZK$uZMCBci|zX2Vf zF+bUOhT7gN_uo<3hWeM#lEQ8nJpxHG-_s*){LXrB0N#q|HBw)ZoBuFF)jinO4)lm! z52Zx7`o^{8RZt9q9@uKK)zUVo8$VxS3!qWlA$TbHKIx*WjqO+xfV-G_%{R4E^?Dbiq>D)ugoUA~sxRriF|n~) z2rvL4z_ZCkormr-?fdxGC>V&$tIUCG>852F6{}mXbeSV~ul_Pf3BAUMpKe+6mu3vJ zWLfgKhsUC9gP#gp&w^i6XJL2@K;@`Kz?|3<)ggXZGQOYu6@$$%ItH0XHB6?mUg_T@ z1toatBuk|{|6W3=gCEh@Ff9j?jM!GbGf*C!qFVFgQ*_6bvm|=l1GZ=_gp)pRH-g&8_Q8{=Y|9LUltGxT%9qIQW41jy<d$XRjk{XDweeDltnZ|1ynX8$rKi~C;dT34@&>sQa`h;mlXT%voE!M4-5cHr_M2u8ZM@ygjh#3WlJ zRGhZEvprXxYK#zE8{98C5@TvJhjL;HUz(^DoT|hsf|u=m&ILz$GgT8(1D%|ep}=;JYvmddmvG2X)2aNc_c9E6Fc#dT!&xV?|>%Ib?84HYgITm+3 zCknDwX=mswUa9i2#7Z|jPSX7A%Dr5F5&Y>4nT1LK@!OJ+cU0gk5T(z#c$JfUMNe6z z8)-1=b0M7fi)lY&)v-sC?^Wq{T;=LWDq0`(wD!rqTj^yumH|D^v+)u)VOgzm&ATj@ z=+!e(KtGdnV^@f~D|u?_yj18_mjg1B#imF;u7ZwD}-#;^Tj5rb$d;qvL> z9AP{~5E&C6YP=qiL9}^|?(Fvbp5TMjU$m->?J?5CWm$-qCn|@MUp=-nZnYdaUQj($4SoG2nKvBU*Gw2B}Jn&vGS*HTL@S&R@--6g@Av1Rt5{4K=ff0$o{Ae z`jOdR9Qug;H0|tuil&doD<*M4qS`C=lshyX`VUv}-<|0~6|_JYZf)H8m#6%^Jjx2} zJPNYyp!#3C`R~5|_@sG@1wUmdVgG^M-*&qN+^~&9mNquk{ay{c%$lrNN0Cy=#>%wgj6n z%}^TU+4dlE%M)ACFoh80S+XVAv;ooxHOg$;y=DCN{}!U8iQ>N8`eP$x(DO^i0|I+I z^HY?H20!6cTHKFbVBdqgn%6;I*v0ULPlmKL8kO51vmsu$lK1?6F}ynQ>i zf;T8YJ_|0k^`!o^$kuDf=Wf^4sb8}{vL&!~bhCk5QTV1>7`OGBX-=?)eD~Rtzn$%; z{~E~C5_`fIKG%c!~Am7FrNb|D){L6p;Tc^Gk zq=5e~0uEv^xM<&>hpMCFGOP4*X>sxxEWg>}P*o+QrncGOXlQ5{qZpsCUqf0WWMlQk zfY`BP4=Y-vzJs1PiQ(%nVq*3wXvh`kj2*_=2_Tvnk6wPUr|c6gMB=+flzgck9MJMr zEpR!!!cCv&c}&dAWR9_ojEG2jlf#d=(tqJ}j`JZzelKj-{LcjbH-b#92Vu{w!psQ% zElE|=b_MW+=~_PMe;@t#PX=~iNq6s+hKMcMhEM?+P^-^yxWd-k$tt9uRPbr{2KrtUgZ^XrolkhU zxOzeNtUQowesP>0Oh{Q8kg)`aFIQQAMJMcc7O|Lm?Yq zaK>Mb!*b-Cc5_~8!3RB+{26yaXKj~wWM9GiSX5(i4767#g_Krf@^nzK}7PJ!{RA{;)NbX$JR`rZ;uUaY;7;zE3y}kts9Mp zx(zJ9E4UNj^!2cetbrR^zVqEY!4X%aj&HW4N7-QBy9fKd-nTEtqSmyuyu5p7*rmK) zR<>h3?QI1iJjQ&Y$28TMV}5dD^+_}&fnC8K=%3D;J$#9BM; zZaJP()%Qa-8lUgCRt_9pp`ak+&(0sogCYZbE_Kav%b1qb)@Ar3?gqG+`Cw|K4!02VX~5JLM6Z{YrX+=A zL53)Cu_m>(i4#8(ol{)_dFPCu6Z=8Cu9AC5(hZ;wjH!S75mG)|zTa}6lcl!y$BenR za^$J}DMeU6qmt#dN)YhfHy_H!dGzv*Hn}G>#%{2S(Pz5%8ZB_{O&Y>upc>^mQ-{BU z5u8fs9nQ1}=0zz#>FU`tn5tK_pkjB@QytLru?RGjt1|C$0uxO6&nzT#tYfGY5^VI- zvr^DAy1>Cb1NfL|5#lOy0nV$b#*VCkaJ-X`Wu}Ckz?aua%@T~vM_~!>N^ODN5GPOV z+E%f?YZOfNAVEBskdHRB0OR(MPR_=0#)n~B~1GjIP`32O7 z1lR;S(OzZeB}osbXK3e_4jz#Zd(8fRJoB>qK5VG%R2g)J4^^!bX6n9B8y469+&mXH zHQggU0I&YueHNyP;=bnJ@`D{;rRm4Fj$HbcHwTKOVy)O!XK&X4vj|-3&6|ao*6!}B zflEE6Fj`m4g%s*wd*X4s`_?xqtEvGOJ0TfZy1py3U1hf0p8<589K3Q5b|?M=msvcf-W@`*8LZPRIYPpxR@LAL&kD}t+A44< z)Pm;s*VdLgi{Fo9S|LvQ`9aNCKVZIjvhSaMBGZ9OQ70x2%zRvqGjCtF-dKCd#{6jI z5XUUWUq`V|{>?KKDws@&(=Bk5y$J6{rb zrGVm5MN>qIY=6yC>Xo6V1q39P_c7iOQws+6D##LONDd?_gZaEAB`2=j+Z=6-rfjQ& zqG}ukgJuy9_V%kt9GB3;%llP?EL?|cYou%{F-nYo^#Z8w&99si2i^|WJm8^NUi}t1 z01(sz!}`jZ@HFlPy%L;k+rp4oT63M}ip$XLw@c7_rmG%eZN>TKNT~+Gg=03SR91Eb zrZ#)PI-Wz;K3a6UIIdr5T3{9>nxLY+vvHqOCNLlNWhKU3a~VplQ?lu7uGh76kE~Jb z(lEoXp}d;Ua#9*B(4Es6*R=ZWQ6ic%;a(_mDpzxxI2=A$jRyW*{9ZAk8y{Xhq0E2Y zw443nEdIk%n`80`=6nK)NdH3xySnF7y9|ak?_H?o1u(>u-t)7lQ)|M$;J54*Z!bMB zXELWHw*U#Hz;#Sqya=4N4~+ce_at>c=@{NzR!Tg1^YyNSCp$chQ>CZ5xD1ref#$9l zdD+UT2h&3SI9TIjgGbN-S;RDlQ-Q# zlsHYJ{x;^!hz8TKZdc80kXEG#Ch^u89pyA0Nl#ueUF(?FS(sr5O|#O6@D%BpV-jd% zhx4Vj1FDH5#v8c8SrhTh6MTDivRz)Pl<6-BDv=WJn?$L*e>-7$wE&Cd_E?!n!OQlY za(W(_dGjtVeGh^z^JQ=3QnCDN0o#(dcjAD`0eKeey!+O z4&H}%uL%JAChIjYAjQuZvi)M~^jag{y*m>Kx-3vmhbRc$sP+P>F}<)d{P^%3%7l6U z_7PtUISCIsGi_?Vlc}B!?a8+upw&D%3~ej8#KN zYFBn_;kB&v$`&**7~<4d{eqf^U3gfxF{4v`XP$`1h+WepOEI* zyCn6XHwGrnW-0KJZzxOO%=C=Dpv{oQc=8&xRHZt0$F3T>#|`|}0xx&wH+X&B$rTsd zrxhJ{L9H(IwKd(beP-oBVb+1eCv)SBn=O=3PCBL@3(PV$HrMb^EX6%)tgeatk==F% z$jdWaphpjlUY_#OHwXl}4Kf(sGc-1;u8PbpQg`6cN_A+CB`?~F=N?2(MZ@qGLsI9f z=PNxE?$#l=3|Mc|l(pF}?L8sGxz(%#V|bLS=DDisW{mpf^K47xBA~f9~f0>_Z|A4+SI@Sq>CF zi74GmINEzws(rb1DMEKp_9L_3fRM-oO_;|)1VM(#w`7Zgk8~Am@T-I&ic}jiib2+rnVwz*Y_R;eTg`SsNkL5$ zYZjNfufUC|+8y&$BC9U6lvAb#L?3-X^4+==>(>}N^Z6ZI&$-vFnb57Sdwn`w&&xWm zOJP49upEg)$`nj5UOo^Tm=@<-Rnx!3t&;I2U%IHaZyu@JXlW~7Gsh@kMUBTKA<&6t zo*pA#*s*#g4mP+map=wL8Og@7+zB<;l$Fg$Oih8P{n0OsCu9pOn5;r@TE`=$PWsJW zOWNP3;V!@{PVRY$&cHZlU#sc=T-5l(=am&oqah)~AKCS(<2&;#%krC4AXnB$@~8DS zb4qt)$+0ozrfY_Af7z$xF4}{RiqQZ0HK0U*f`b7qI8DIzqqJ-Se4uw~W-qN<_N>97HvX>M) z_ri?6b?U@2`}f9llo{#e_d6xA{tWb((y6oX!P}+g+sR`*Bs2F&$$+;41FPx$&z?a^ z0&^<{BAbw^AvYvF8?fN_s`q^B6=r9jGRTBq+2M z#SzKc>Eip{%5O4wEIxj?k`bb|yC@8P%lY1tQ;Ve&3Vs4}MxF16P%{Rp_XY~0mwq@V zr?n6us3=Lq-r2`@leOp%j|{?9@@Yy6$(KDXmM&)&Ga9Ai1NG2~6o7#?E*G5nz2&_K z$g=z69_3${;nfHbEwKRYq|UC5I!+%idR;&{O30_fx%V`|tbhTNfMBi0d>0;(K>o zUwo^6Nwd>1Y^JsnTkl(gCrM{P-XF0#jtW)kDfMUAoigIBwg~kYL}+-`)iJdc2nxi=O%T6XpqWC5Jg#4i&%xUs?M>*KSW5tEHL}HCp!vyhYe1$U)H>L`_-! zE)~|tQh4YS%^%t1z48by581qV!`x-JVC)}X>C^AOY5-49Pw(-u)il1b!JA!Fr#WhU z{%vq)(sS}*Z=zKpr0dg6zFFa6IVh$@Fu?hvN=B%D3Ce=)Dpu~$>dVWff(Sn;W^PHd zJ-%8i#>3Q`DIYA$QOQmo4ZO~SzZC_2Ydq?V9caLwaO2gq5ZGBED z{GNQQIS=Z}hC>MKyf2fIU^4?{D6dSfBshS)&*En-QwtNO#TpR$47Ixx1X>nn%z)Y3 z7&v?Yhr1c$6MjZPPxCFped&~O<*+ME)(6j{n5B&=7(ti>!FqUAgsW;`bCukW6_e`@ zl9W%`m!a8Yj-5Uulu*Y8c-aXQ)9JE^T@sQu#n&FRz9<`FUV&O)3Dy@L&(Gxdn_zfQ}W=C1{vZz zI-WuHJw5h+aH+kfc^#k2H{Sf!O87&y`{O$UCg4Re)_nbca5p}prN0`s2{$1A>xui@ zC(V6~Mmt$qn{x~z4*z(%Mv3&XE}GE%LjOMGXAu5xf(c^L zUhPK20orxhk{E$!pv3opE$rvrf@{9Z{i(H8_xrbRBYS?$4q-S}$5YMlu>HzExE=!! z(Mr7#4LLs}_^;09?h^nx6lTH;FC}h*?Ntw z49yB>63E>;Vqnk(mUNfM*KcmUrhtcjGO$qk&!_A;D(lqtvS7_wesZ!JWy_9|QFtRlXCjcL&1?*s(hy4(p4P zU-%5inb~;vh{Q?$j1d>PX@a0Re*EN?amhWi;Gj47wn7nLES#goy}sLfW^IW_;8I5R z|2-!9U;Zfc2)si0bK|(d&$~!n-|;*))?yT5Zf@Sq|MG-Olp4r-F^oIkEGZ^d2+B7= z;SC=LeGE%WpW^V44MqFjb$zK|l9nNUj9=vL^0bl`ll%eqXeVT$l7_jne$DZLK*1-QwG~?Wb(d&3$H9^&`nmAd zPe-ddRNy($_9A^afPj3_+@?X@c+ninN=p(Pach~3;8&@)s;6gk4Xq=fFz&$1<>73~ zuCZ~0Od-KNCX#$@j2$X?Ton`hmP%hi$W=koPYai1TpDR7zhOPx;mxZ<9EYPTVOK-JvBU**7UiKS>5wBMIS%fbYy3Jcnse`-~D$#b~b0Jl;aO(AP(ZE1{Hfn{>vG5r`DC<{3af} zyy#VnHFSySb6u8!&AdjAiivR`RWIl{W0L*H`NG&fNkM7{*$-zK zfLad95`HIei!(myyi+Hg~#C+oE1G1-nh!whTq++HwI%oW|$CHcnWF=!?#Go4n8 zoN>w&+|L{n%e^VUQ~H#TMya4wcO&%ipOvi${J!s<={n9aAgt2ib>lj)jNY2ls?ZT> z91oX?3ux*vG3V9uLwBGroq z+^bskRAx>Ll;3;<>nr+5a|xneOX3BB7Y%{43k_TVEtTE`ZVS;{*MaAHA3cNDA+GG_ zy$eV=%U&`;y?Ef1HQ$0J&y1DLAz4|MF0ZPaLvJ=uCF;z>^cr^)sAFR$)84)c>CVv# zdfF=!#v@Cz_h>4-aicJQ(`*K0A2*1zS5BhK0NWGhX z@xvXKMN7Y+^^f}xTe=8DD8qJ>$z$^i3vXhZ8()1D7iWt%<}g^X%xBHdx30R6Tv)+S z@iswo=dZAaHcMAYmRvraOmhp8#4nyk0ADONWVtwHtr^UD@^HPDI-*(94Olvcd`Rj3 zX?s3z-R`WtzEG`7;jf_4xq=UQYUL|4^J{)z5;K0L#wPNvRHcuef1H`1kN-(Yj7@+a zkLmC@s#w&t)&W|5JG8T6`>s;X11n3Jk>qioz(R@xwm4*Mmxwao5+!I;M-}m>$J*~3 zU0&(g{u2d(D3$tte`~7hdZ$b~a z+t`e3Jzb^l%Og2(-PN$8^kcrN=<=FUZXObcG{|-Iw&woBUu{f_iZcm6{vjWPlYxo< znIxXmWc}P&?9FDf_Kt(c5TNGIc)eRE_8dv>aQStRo_?qJ@)4S-E-z%LU7wNwUN-BP z955&51Tb7dPutafOYUrR#@{7)W$o(O0ypLwWG8V|0Qtca+ z-@Dt;=hLAFy;D88u%_o?A$_3fSb5+^qv#kzlK?3E@&UzS4N}eT5@c>Hl(E;VK6Dx2 zxoQ?08|C;v^l3{-6atIPJT8Er;=ie5g{U;=i;YdV{y%hS8()3w;1pwO3LCPpwW}dM zMM)>&Er%iApk9@lwCJuP>U{ zS4e`uy8MHCt{!JRp?k#w=G&6vsIi(MPtUR^(Pe+!+ zARF&I)P7XVfe{xjz`0bNLak$9Hv>V~88FIhrK|!}aVBcOYr&ucm1*U}PJs8GwH4wJ zp__=~$&d8EstX-6i_24dHMZYs^-0ed&hS`Q;tRAV+93m58fQ|yuVp#2-6iDlaLiLj zM|X%@yEMED%LQAAdt#>8smKjm%3MIU*jUY#23LnfX}~WOBd}ssNgA#2M5^7_Mt`xuaF&M`ygU)0Oqvvhch#C zj>SQi8x9%p8Qo&7269)NVKi=hK(P8I-KCl;vUR_qiED1tW4E`Z{)%sNS5bCfe4SG| z?KO|d%JvXyo!`%YF3qyf#H^P;>2foCYhh_wjZcC5ytgO3{9Ko!{*GYuuUe8NQxfw;)~qfS7amNRjf>*HUN z6=Bb5R^e=6(V2zP*Qx7 z^>Qqp9ITNNU*Sot$PCi2SW$1c3=mb2+CHD)4pMbJ6NnFo=3~peMDLHpQ{DzJ4T)4zhd~+a?9851R zZxvw7e`QQiPYjxOD$}j-^Zv2N1ozxKJP>pT)T7dS{y2g=;WHyW^Zsq{Y~?i z2ZHK_dtD5l&fiz6zINS$W0yb7!$?Tpeh`5yORf%!WwQ$UuvhD{WCH{J-d@mJFjn84 z+H~vt`{aUcpS)KF4Td~hS;lk|Rnw=d+K6L~Jk`Yq3x<*%9`ux%+tv~_CL_K_tS`*1 zSI(FWyjHzdQ_}>;->_Jl(2dM%UUyv^dg2rtOptp%x5um^;>c{y1nJadTkTX$rnD@j zt%@i!fP4FRn9|e`7b)OrW9~qlo_~3^6k?ma;kkKUDHfINGkK;bq0Sl^2$En)C^N^dxfYO5!9LReicIYq#nK5&}nAnOAYIM@vPB`>NgW z_5XNJP+FsR0g^bTD_M7E%t(lVA=9`nrIzwRYz3v_H`_LVG~Jv;ll5Bym)qgi^YZ5A zzE5IA6W#XH@S#N$UT{LdHT(v&xIM=PJRoYk8cmk*qD8wA<1Xu2Mh@JWGFnze>~oy zf#27Sn)G>CSX^#)?*0MylW*2qOWau9PWn zFz}J#YW7w^i>gW*{YN{!DAl1vyU8}^yS8ys%=Cz zQF}hT^@Pu_-x?%RtxR5^-xWP^BZ%&Mp@e#V0P3r8ARptphJmjJ6Ed}(@;T*uXV;;) z&k~MOCqq6CMGlp#QfKdxJSVLE&aSJAJEiqX%%m${CgEOV6L80>_KiCBUq!1qwcD)DES3(qLz$nvymf8q5l{69QG(J5*ce5*>Ax20QX2C6=J=*FU?i`& zP29iz()BM4f~$3hm6k1s*G<+&MK#RFTjH-sgQDpoU^DVeG#&1X4dYYHQCNmxHr2?e8-ETT`l{?OcIti|QDa3)^EfYDTOb2*nWdTJk3)MLg3?4bh#G@WPm3v#f{iL5f+GK!88L?+9{$ zdVXP0T%t`%wEOK-Uk$QMvnXog`dXJ%NE}dD-_v&E2gO(8?6dX4c5)s@U-CU`l z+#%(Z47ZjiYWi9T412dz9DHC;QfcI07sb+&qhRK`+w=#<8*;ud&Fvi(MKhV2JH!|c zRJ)WpRdO@nYyxLWP}T|hyXB8RrO=A$Gw}S65H9|zeNKo;<{iX|oCnIQv#*KiVN;i! zxm-r{NdEi6$~Uww%0Lnc%>`(GzMp8 z_hh_=3M82a2V>0y5oGB=z^K0jM=ONzC!D!c(s_5cs6BlVAb4G+y~92!?9>ELg@L>z zj$V8BbVor9g0T!ry@80)(>AMXP>hX_+02(QY(Kig{u|3ib-rC zPukrxwsMGtsgv{)SZ+oYSQYV%J_P?MdqrT zLelezSYNtFh%w5J0S$nJ=HT;#knh6G0+p{sBryQHSp^5qGii%3b1TZ<`%ARs0ahys z)oNK@*3r}op+uoUrtx1~_m>Z{q2LKe{0!VU{t!w(*v-!?zQ^7Epoy`Uiu?1?e_s%u zdiz7&4gs#jbAQRTzpYpov7P-A(r5t^rWplZ*A=O*&Ym6gmJ$7RS@2%j)f*5qDK9>* zj_bC3s?Lh>Tf9&*8@w>8$)A6V4Vy7D2{XsOz2g}Dv#|Z?ZFNyQ0dTTk;`aVm7XS3~ zHiUx#V8#D#;r{{@9A)kdxc#^XTSi9r+O_e$y*m?shBY1i8}Wj+Q-tuTj6P|4I~k$l zvdfvXk0OjAS z(_{6wz*y#EU>lllxrcw<>3=rzp8F{;@ABS)kgaG+6VNE#b<2HQU?^`Xz~F+&+e@5V zARrw;P_{h-IsZtpfOQst1Ktm2%uCsVMA&Foce~f*{1ykiF9i-beZ-dXpCkSH!S*(Q z5W|O3@vRQ%1`c>lkdE*-BL2@)7`On0gi4LKqOfbM;DF>EW*S>Th{ja56(S#RbwEpS z!2h44K#RagC5UbO1|iKOH>i}AI5M#c)sm>>k$Sk;u~i(j;}$D+YL}E;?jh)ld0?im zjJ1&6`M>)Rm7S3D%Sg!8-NQGid`MsOxtE{YUOd`{sAY9N-r;*);&&AMzK{2Ukdodn`Ayz6r*>OUCz%LiaCaxmDzcW(O~ z7(Y19O%8z0nQvf_f95#0(|{i9H@UX`^va(B@=X|!o1WG{Eyr)qXS+I5V5}%5(Uzdl z;cdYOE}L6K=y<`gL+!uwhVYkl$g&{|>Dtw=&j#{|%oNIn2Qetp+_U-B7%V|gAie=8 zI!+a{?^v5b#-f_sfc;qzMG3E)ew$Zs@egp7hfJ@~-#Z2*O#vO}Vn%7e` z@?$Uk#K4nCzN)4;l-kWnsgg(?*JuV)Yj0!!5ybZO z@}prOba)k@%Z0Ivy}N%y7laE_r;#lZjdcylNctQdCiQewGm0Mc)1q9*U|DzUF7T-6 zLXC)$gBN81*^>n7_Q#QoOSBPEWrIavWT(^Gka?ii?Yg^wh8XW{{{^*wpk#BXW)#B# zSczeyZADWQ>!ANclCBQ8d@!W6YLs>U5>P9Z>8x3#JRh_$|BiQ(I(POAYj_M@6eut@ zwMK(RX1kA6z!ZT8Z4=0;MI5g4H&8LwUmfW&g;$Jf0p-3fiv(*H@BRS^gdr!OKlG97fxqL~k8d;u0RgT*gns`0=s&)(?WWmO~J0V}eCf_m zdtb;q>G56AR3@}kI#~=AE(yk|J$QQX)2ZRwCBarN`NUugZdIr1vyQ@=sVMqlBJd~p z2V38{M}v7C2MZ7-sL=_~B~3Cnuc(Pb>V4SXV2v0Cvxsok4m86g%IN*Yw2crQkY#BF zQ1(8-W9IiA{Q>z@nkF$;Dz^pT!QVeZd|9_t`6sjhPvFf0IIUTM4BrCoY^DIN<(pW& zE#S^94p^^&yTp+#Fz@|7fLZw%^j4UMplM>y2b%r{jK6kUcLN~azsLXg_Wyxr&@$)$ zMgD(j|6kVkkIeI5&i}9A`=81EFd11WHt!?+nd#rTEGx@uM9rQ@EOs<0pRs+&9i;oM z)2|hDhS<`KfeY|EutR@|>OT|+-fM{ED(@BY@(z!D-6I{QK>Mhjw>SwF2~=vT7YxFw z6!zfLv47JR>dl0PkpnyZ>{@JbEGGa{*P{jX!V5IZxZi^evn&1myN*;hDn9vDNeO?h zS*tUl;(ci)aQPxE?a3ioTZB1C?;=&xp}MAML}7cJalDm`hn<2~Me^9~brkvay>?<9 zrF*^~W$bscW4WHm(!%pXH)(zKKhmN|3hGl+5ngI3%GUugTKFixB$#XTdv5*u&rFp!NBwMoip#_@1CO{ z??4#R9IYSUTuTCc~ex#((?uX>#B^7?a9^wa7U2(HrY0@ z5wcP>q%i3zAOB*3sBg3Q`g~hJ2WN^|@_Kis^a+p!ICFKAh$q=NSF$kP7kf$=`v&qs z`T}y1jwO8X<<7X+Ox;@{B=kh`STM=IJ+1ju;766=xrm~soLt5B35)MGnUQ_LEA#!h zYR67o?JPUBp!$0x)$i5BEh+uwWOPUPQAs6w&;ZIolUh+}b3)9Nq_Jry57Qo&QMYa{G$u?WUo|ib zoIxiFe_&u&HK<))Ld8uS^uDTn?_ud61QmC7w@TjHij2+NyBjFhYz9p!;XjW=fpNqsJHcq3!Y*Jc;-U;%Y=EMmV^O(wH5DPZ>Pe5l2N6I9a5 zUC|Q@!rD)5JGW2qY}lzU#NV=q9eRpOS6$$1cc#~;+CbTk7S8sdf{`vapTbg%F`qK+ z*N^xN7Wz-)=0$FRb}(_6>Mtq}3Yd~$j$ zT-GTkl%k}U-I#0XUXlJ1B<{BQ$F{3Bq}({OwqntDs2%Olyw(`DEdQA`nYvN*<^F+E zee%zGEP@3nzbrf1FPs)a7hDO`5J7*L_Z*DF40gZJX|31}omHo1Zggo-J(Vk%Lye3E zlp)nsB8Y_TQyVp11O8Ym&(`_z{pGs;4!brz3O3g{@uqGCZ<$*cyVYC4T-4>VxS7&{ z{A_gGwy7oszZfh%%T6mJXztOIMlOwwXOSn~W(XG<9ay9Yu4=B0m(?oFB@DRA z%A=zP>RiYl1hI_gLa*ZMoXCUL=%ZetW*%8|tuX|J=ksV(2)cSUax+WyW{7SlV?rmt zp=EPPFcQPaEH&KSSmg{L!41l@PA77wD7IL zFkgd^*Ku*U8lrdWbZ*Z|*gk0Ui#I9c!Ex$>Yjd$ociOxKnjFXP)B9>eUw1Kta$wk* zqsB#S;q7|JQ~N<>g)dJ{R2#ZunQ48wn$4Y3@WSlLMwUa>udyH8QJG$cqGL5Hu$|}* zK`c~a;MRs|ZD+}09aedVHI7dc;a4zZ{QTIr2nnhYX`li=d{u*e5Ol8LR7o(ogUNYx zC)hPCud(TCHN?%v^2ju$yg^~&W^Bx3wly}duNwrB7+#z8sJIg;k(-lu1g&hn`>-k9 z3rb^aOQN`Urcf~VB2+u>JS3Op-g8M5GU)T=*|7iGMTkOL5Iy(3+PVrE-+!+uQbpl( z@yAJm#+ZUyu==nSv`y`KRPAfdgEO)0OFFRE|NQWxEDoRWMGk4clEpXpz(D60W#2lRFQi^mW_4<3sB5UG23pKG>}-F?j>;Tzu)_xIslqG39f=Ukfo6}D9d!uV%15_VKM zhJ^Zoz8mfwBe~QM^9JK)*~VRM2J|DTZBoLXD&Lr6`a6sGP*(LKi&} zVnIK8a3D81*PnWE-miFl=yU7}*1e!}tfOISz0w+sgh-sK_6@=LD1$k}PFU%p&H$mt zA}1{4($LcW)q#Ga3qN^%Y=eODT))yAIhrhb@%jEWeAKoeSIV$omoC1MGqNj%nt~J3 z^=OQnEIE1cX=zzdTF4{4+Zq~TB&33Vm+$Q7B^^&o4q3_sdvMldBBkIgyHqAt2wKm3 z@ywyq+1({q>P=JmRfHG}GM6q|b}Do@xxz*lS%S~Q)NUf8y%9dvaJAt;*?j zS(|_$6Z}*9U!~-HV@kVM$K>tyq~yLfW`e~eBXW{c1-YZ&dyM3(r+V}2Fm*G9x@JuX zeI$l~KeZjp1fx)Ravmi~%NT_zkfSYnb|S9r6#gDh_EEalW z>+D4B#+Lw@_HsU$b|_M*F*eYrGD&9}RSeBqupL^kxWOvzY(Z}y9&Z)xG8P?0SgB*! zeJnDh`%P2x=0>|{{SoK56ogY(k>mmdh-6IeBkMaK{ZUPdNUO|Dhxnq69m0Kyr*jv~ zN3|5LJ$Fwl&F<=K?S8c+|E?W$G@R#IuvA?pE`6LkC&LX zUY>D#pKw_eFCjK}Do|gg)X*XF@K&QF99O{yIP3T{Z3Q$g7dvL!vj<-f67f zxmPNNUR*e@$iTJ|tB=$Cen}xUdi1o1wyc6iv|3z9V5T^qfgD}B*WPEEbwI`K?q<_n z;pQ6_VrU4-#�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 c$CI5a3{i#)!OV literal 0 HcmV?d00001 diff --git a/docs/doc-images/set-wechat-pay.png b/docs/doc-images/set-wechat-pay.png new file mode 100644 index 0000000000000000000000000000000000000000..2de73ca956e87ab3e534e8d26712b57cc2998b82 GIT binary patch literal 36024 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 literal 0 HcmV?d00001 diff --git a/docs/guide/function/faq.md b/docs/guide/function/faq.md index 7bb752e83b..22f6160fcf 100644 --- a/docs/guide/function/faq.md +++ b/docs/guide/function/faq.md @@ -146,6 +146,62 @@ export async function main(ctx: FunctionContext) { } ``` +## 云函数合成带中文字体的图片 + +先把支持中文字体的字体文件上传到云存储,获得云存储的字体下载地址,然后保存到临时文件中,再去通过 canvas 合成。 + +:::tip +Laf 应用重启后,临时文件会清空哦~ +::: + +```typescript +import { createCanvas, registerFont } from 'canvas' +import fs from 'fs' +import cloud from '@lafjs/cloud' + +export async function main(ctx: FunctionContext) { + + const url = '' // 字体文件网址 + const dest = '/tmp/fangzheng.ttf' // 本地保存路径 + + if (fs.existsSync(dest)) { + console.log('File exists!') + } else { + console.log('File does not exist') + const res = await cloud.fetch({ + method: 'get', + url, + responseType: 'arraybuffer' + }) + fs.writeFileSync(dest, Buffer.from(res.data)) + } + + registerFont('/tmp/fangzheng.ttf', { family: 'fangzheng' }) + const canvas = createCanvas(200, 200) + const context = canvas.getContext('2d') + + // Write "你好!" + context.font = '30px fangzheng' + context.rotate(0.1) + context.fillText('你好!', 50, 100) + + // Draw line under text + var text = context.measureText('hello!') + context.strokeStyle = 'rgba(0,0,0,0.5)' + context.beginPath() + context.lineTo(50, 102) + context.lineTo(30 + text.width, 102) + context.stroke() + + // Write "Laf!" + context.font = '30px Impact' + context.rotate(0.1) + context.fillText('Laf!', 50, 150) + console.log(canvas.toDataURL()) + return `` +} +``` + ## 云函数防抖 通过 Laf 云函数的全局缓存可以很方便的设置防抖 @@ -391,6 +447,48 @@ export default async function (ctx: FunctionContext) { }; ``` +## 云函数上传文件到微信公众号临时素材 + +公众号回复图片或者文件,需要先把文件上传到临时素材才可以回复。 + +以下例子为,通过文件 URL 上传到临时素材 + +```typescript +import fs from 'fs' +const request = require('request'); +const util = require('util'); +const postRequest = util.promisify(request.post); + +export default async function (ctx: FunctionContext) { + const res = await UploadToWinxin(url) + console.log(res) +} + +async function UploadToWinxin(url) { + const res = await cloud.fetch.get(url, { + responseType: 'arraybuffer' + }) + fs.writeFileSync('/tmp/tmp.jpg', Buffer.from(res.data)) + // 这里 getAccess_token 不做演示 + const access_token = await getAccess_token() + const formData = { + media: { + value: fs.createReadStream('/tmp/tmp.jpg'), + options: { + filename: 'tmp.png', + contentType: 'image/png' + } + } + }; + // 由于 axios 上传微信素材有 BUG,所以这里使用 request 封装 post 请求来上传 + const uploadResponse = await postRequest({ + url: `https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${access_token}&type=image`, + formData: formData + }); + return uploadResponse.body + } +``` + ## Laf 云函数的鉴权,获取 token,token 过期处理 原帖: @@ -466,5 +564,6 @@ export async function main(ctx: FunctionContext) { console.log(ctx.user) // 如果前端传了 token 并且没过期: { uid: 1, exp: 1683861234, iat: 1683861224 } // 如果前端没传 token 或者 token 不在有效期:null + } ``` diff --git a/docs/guide/laf-assistant/index.md b/docs/guide/laf-assistant/index.md index c88551187d..0d246ad26a 100644 --- a/docs/guide/laf-assistant/index.md +++ b/docs/guide/laf-assistant/index.md @@ -13,9 +13,9 @@ title: laf assistant `VSCode` 应用中直接搜索 `laf assistant` 或点击链接[在线安装](https://marketplace.visualstudio.com/items?itemName=NightWhite.laf-assistant) :::tip -使用`laf assistant`需 node 版本大于等于 16 +使用`laf assistant`需 node 版本大于等于 18 -node version >= 16 +node version >= 18 ::: ## VSCode 设置修改 diff --git a/web/package-lock.json b/web/package-lock.json index 6221087e27..9568f8b46e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,61 +9,69 @@ "version": "1.0.0-beta.8", "dependencies": { "@chakra-ui/anatomy": "^2.1.1", - "@chakra-ui/icons": "^2.0.18", - "@chakra-ui/react": "^2.5.5", - "@emotion/react": "^11.10.6", - "@emotion/styled": "^11.10.6", - "@tanstack/react-query": "^4.28.0", - "axios": "^1.3.4", + "@chakra-ui/icons": "^2.0.19", + "@chakra-ui/react": "^2.6.1", + "@emotion/react": "^11.11.0", + "@emotion/styled": "^11.11.0", + "@tanstack/react-query": "^4.29.7", + "axios": "^1.4.0", "clsx": "^1.2.1", "dayjs": "^1.11.7", "dotenv": "^16.0.3", - "framer-motion": "^10.10.0", - "i18next": "^22.4.13", + "framer-motion": "^10.12.16", + "i18next": "^22.5.0", "i18next-browser-languagedetector": "7.0.1", - "i18next-http-backend": "2.2.0", - "immer": "^9.0.21", + "i18next-http-backend": "2.2.1", + "immer": "^10.0.2", "laf-client-sdk": "^1.0.0-beta.8", "lodash": "^4.17.21", - "make-plural": "^7.2.0", - "monaco-editor": "^0.36.1", + "monaco-editor": "^0.38.0", "qrcode.react": "^3.1.0", "react": "18.2.0", "react-datepicker": "^4.11.0", "react-dom": "18.2.0", "react-hook-form": "^7.43.9", - "react-i18next": "^12.2.0", + "react-i18next": "^12.3.1", "react-icons": "^4.8.0", - "react-router-dom": "^6.10.0", + "react-router-dom": "^6.11.2", "react-syntax-highlighter": "^15.5.0", - "sass": "^1.60.0", - "zustand": "^4.3.6" + "sass": "^1.62.1", + "zustand": "^4.3.8" }, "devDependencies": { - "@types/lodash": "^4.14.192", - "@types/node": "18.15.11", - "@types/react": "^18.0.31", - "@types/react-datepicker": "^4.10.0", - "@types/react-dom": "^18.0.11", + "@types/lodash": "^4.14.194", + "@types/node": "20.2.3", + "@types/react": "^18.2.7", + "@types/react-datepicker": "^4.11.2", + "@types/react-dom": "^18.2.4", "@types/react-syntax-highlighter": "^15.5.6", - "@vitejs/plugin-react-swc": "^3.2.0", + "@vitejs/plugin-react-swc": "^3.3.1", "autoprefixer": "^10.4.14", "click-to-react-component": "^1.0.8", - "eslint": "^8.37.0", + "eslint": "^8.41.0", "eslint-config-react-app": "^7.0.1", "eslint-plugin-simple-import-sort": "^10.0.0", "husky": "^8.0.3", "install": "^0.13.0", - "lint-staged": "^13.2.0", - "postcss": "^8.4.21", - "prettier": "^2.8.7", - "prettier-plugin-tailwindcss": "^0.2.6", - "tailwindcss": "^3.3.1", - "typescript": "5.0.3", - "vite": "^4.2.1", + "lint-staged": "^13.2.2", + "postcss": "^8.4.23", + "prettier": "^2.8.8", + "prettier-plugin-tailwindcss": "^0.3.0", + "tailwindcss": "^3.3.2", + "typescript": "5.0.4", + "vite": "^4.3.8", "vite-plugin-rewrite-all": "^1.0.1" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -1970,11 +1978,11 @@ "integrity": "sha512-pKfOS/mztc4sUXHNc8ypJ1gPWSolWT770jrgVRfolVbYlki8y5Y+As996zMF6k5lewTu6j9DQequ7Cc9a69IVQ==" }, "node_modules/@chakra-ui/avatar": { - "version": "2.2.8", - "resolved": "https://registry.npmmirror.com/@chakra-ui/avatar/-/avatar-2.2.8.tgz", - "integrity": "sha512-uBs9PMrqyK111tPIYIKnOM4n3mwgKqGpvYmtwBnnbQLTNLg4gtiWWVbpTuNMpyu1av0xQYomjUt8Doed8w6p8g==", + "version": "2.2.10", + "resolved": "https://registry.npmmirror.com/@chakra-ui/avatar/-/avatar-2.2.10.tgz", + "integrity": "sha512-Scc0qJtJcxoGOaSS4TkoC2PhVLMacrBcfaNfLqV6wES56BcsjegHvpxREFunZkgVNph/XRHW6J1xOclnsZiPBQ==", "dependencies": { - "@chakra-ui/image": "2.0.15", + "@chakra-ui/image": "2.0.16", "@chakra-ui/react-children-utils": "2.0.6", "@chakra-ui/react-context": "2.0.8", "@chakra-ui/shared-utils": "2.0.5" @@ -2034,9 +2042,9 @@ } }, "node_modules/@chakra-ui/checkbox": { - "version": "2.2.14", - "resolved": "https://registry.npmmirror.com/@chakra-ui/checkbox/-/checkbox-2.2.14.tgz", - "integrity": "sha512-uqo6lFWLqYBujPglrvRhTAErtuIXpmdpc5w0W4bjK7kyvLhxOpUh1hlDb2WoqlNpfRn/OaNeF6VinPnf9BJL8w==", + "version": "2.2.15", + "resolved": "https://registry.npmmirror.com/@chakra-ui/checkbox/-/checkbox-2.2.15.tgz", + "integrity": "sha512-Ju2yQjX8azgFa5f6VLPuwdGYobZ+rdbcYqjiks848JvPc75UsPhpS05cb4XlrKT7M16I8txDA5rPJdqqFicHCA==", "dependencies": { "@chakra-ui/form-control": "2.0.18", "@chakra-ui/react-context": "2.0.8", @@ -2139,9 +2147,9 @@ "integrity": "sha512-PVtDkPrDD5b8aoL6Atg7SLjkwhWb7BwMcLOF1L449L3nZN+DAO3nyAh6iUhZVJyunELj9d0r65CDlnMREyJZmA==" }, "node_modules/@chakra-ui/editable": { - "version": "2.0.21", - "resolved": "https://registry.npmmirror.com/@chakra-ui/editable/-/editable-2.0.21.tgz", - "integrity": "sha512-oYuXbHnggxSYJN7P9Pn0Scs9tPC91no4z1y58Oe+ILoJKZ+bFAEHtL7FEISDNJxw++MEukeFu7GU1hVqmdLsKQ==", + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/@chakra-ui/editable/-/editable-3.0.0.tgz", + "integrity": "sha512-q/7C/TM3iLaoQKlEiM8AY565i9NoaXtS6N6N4HWIEL5mZJPbMeHKxrCHUZlHxYuQJqFOGc09ZPD9fAFx1GkYwQ==", "dependencies": { "@chakra-ui/react-context": "2.0.8", "@chakra-ui/react-types": "2.0.7", @@ -2192,9 +2200,9 @@ } }, "node_modules/@chakra-ui/hooks": { - "version": "2.1.6", - "resolved": "https://registry.npmmirror.com/@chakra-ui/hooks/-/hooks-2.1.6.tgz", - "integrity": "sha512-oMSOeoOF6/UpwTVlDFHSROAA4hPY8WgJ0erdHs1ZkuwAwHv7UzjDkvrb6xYzAAH9qHoFzc5RIBm6jVoh3LCc+Q==", + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@chakra-ui/hooks/-/hooks-2.2.0.tgz", + "integrity": "sha512-GZE64mcr20w+3KbCUPqQJHHmiFnX5Rcp8jS3YntGA4D5X2qU85jka7QkjfBwv/iduZ5Ei0YpCMYGCpi91dhD1Q==", "dependencies": { "@chakra-ui/react-utils": "2.0.12", "@chakra-ui/utils": "2.0.15", @@ -2218,9 +2226,9 @@ } }, "node_modules/@chakra-ui/icons": { - "version": "2.0.18", - "resolved": "https://registry.npmmirror.com/@chakra-ui/icons/-/icons-2.0.18.tgz", - "integrity": "sha512-E/+DF/jw7kdN4/XxCZRnr4FdMXhkl50Q34MVwN9rADWMwPK9uSZPGyC7HOx6rilo7q4bFjYDH3yRj9g+VfbVkg==", + "version": "2.0.19", + "resolved": "https://registry.npmmirror.com/@chakra-ui/icons/-/icons-2.0.19.tgz", + "integrity": "sha512-0A6U1ZBZhLIxh3QgdjuvIEhAZi3B9v8g6Qvlfa3mu6vSnXQn2CHBZXmJwxpXxO40NK/2gj/gKXrLeUaFR6H/Qw==", "dependencies": { "@chakra-ui/icon": "3.0.16" }, @@ -2230,9 +2238,9 @@ } }, "node_modules/@chakra-ui/image": { - "version": "2.0.15", - "resolved": "https://registry.npmmirror.com/@chakra-ui/image/-/image-2.0.15.tgz", - "integrity": "sha512-w2rElXtI3FHXuGpMCsSklus+pO1Pl2LWDwsCGdpBQUvGFbnHfl7MftQgTlaGHeD5OS95Pxva39hKrA2VklKHiQ==", + "version": "2.0.16", + "resolved": "https://registry.npmmirror.com/@chakra-ui/image/-/image-2.0.16.tgz", + "integrity": "sha512-iFypk1slgP3OK7VIPOtkB0UuiqVxNalgA59yoRM43xLIeZAEZpKngUVno4A2kFS61yKN0eIY4hXD3Xjm+25EJA==", "dependencies": { "@chakra-ui/react-use-safe-layout-effect": "2.0.5", "@chakra-ui/shared-utils": "2.0.5" @@ -2243,12 +2251,12 @@ } }, "node_modules/@chakra-ui/input": { - "version": "2.0.21", - "resolved": "https://registry.npmmirror.com/@chakra-ui/input/-/input-2.0.21.tgz", - "integrity": "sha512-AIWjjg6MgcOtlvKmVoZfPPfgF+sBSWL3Zq2HSCAMvS6h7jfxz/Xv0UTFGPk5F4Wt0YHT7qMySg0Jsm0b78HZJg==", + "version": "2.0.22", + "resolved": "https://registry.npmmirror.com/@chakra-ui/input/-/input-2.0.22.tgz", + "integrity": "sha512-dCIC0/Q7mjZf17YqgoQsnXn0bus6vgriTRn8VmxOc+WcVl+KBSTBWujGrS5yu85WIFQ0aeqQvziDnDQybPqAbA==", "dependencies": { "@chakra-ui/form-control": "2.0.18", - "@chakra-ui/object-utils": "2.0.8", + "@chakra-ui/object-utils": "2.1.0", "@chakra-ui/react-children-utils": "2.0.6", "@chakra-ui/react-context": "2.0.8", "@chakra-ui/shared-utils": "2.0.5" @@ -2259,13 +2267,13 @@ } }, "node_modules/@chakra-ui/layout": { - "version": "2.1.18", - "resolved": "https://registry.npmmirror.com/@chakra-ui/layout/-/layout-2.1.18.tgz", - "integrity": "sha512-F4Gh2e+DGdaWdWT5NZduIFD9NM7Bnuh8sXARFHWPvIu7yvAwZ3ddqC9GK4F3qUngdmkJxDLWQqRSwSh96Lxbhw==", + "version": "2.1.19", + "resolved": "https://registry.npmmirror.com/@chakra-ui/layout/-/layout-2.1.19.tgz", + "integrity": "sha512-g7xMVKbQFCODwKCkEF4/OmdPsr/fAavWUV+DGc1ZWVPdroUlg1FGTpK9bOTwkC/gnko7cMClILA+BIPR3Ylu9Q==", "dependencies": { "@chakra-ui/breakpoint-utils": "2.0.8", "@chakra-ui/icon": "3.0.16", - "@chakra-ui/object-utils": "2.0.8", + "@chakra-ui/object-utils": "2.1.0", "@chakra-ui/react-children-utils": "2.0.6", "@chakra-ui/react-context": "2.0.8", "@chakra-ui/shared-utils": "2.0.5" @@ -2303,22 +2311,22 @@ } }, "node_modules/@chakra-ui/menu": { - "version": "2.1.12", - "resolved": "https://registry.npmmirror.com/@chakra-ui/menu/-/menu-2.1.12.tgz", - "integrity": "sha512-ylNK1VJlr/3/EGg9dLPZ87cBJJjeiYXeU/gOAphsKXMnByrXWhbp4YVnyyyha2KZ0zEw0aPU4nCZ+A69aT9wrg==", + "version": "2.1.14", + "resolved": "https://registry.npmmirror.com/@chakra-ui/menu/-/menu-2.1.14.tgz", + "integrity": "sha512-z4YzlY/ub1hr4Ee2zCnZDs4t43048yLTf5GhEVYDO+SI92WlOfHlP9gYEzR+uj/CiRZglVFwUDKb3UmFtmKPyg==", "dependencies": { "@chakra-ui/clickable": "2.0.14", "@chakra-ui/descendant": "3.0.14", "@chakra-ui/lazy-utils": "2.0.5", - "@chakra-ui/popper": "3.0.13", + "@chakra-ui/popper": "3.0.14", "@chakra-ui/react-children-utils": "2.0.6", "@chakra-ui/react-context": "2.0.8", "@chakra-ui/react-use-animation-state": "2.0.8", "@chakra-ui/react-use-controllable-state": "2.0.8", "@chakra-ui/react-use-disclosure": "2.0.8", - "@chakra-ui/react-use-focus-effect": "2.0.9", + "@chakra-ui/react-use-focus-effect": "2.0.10", "@chakra-ui/react-use-merge-refs": "2.0.7", - "@chakra-ui/react-use-outside-click": "2.0.7", + "@chakra-ui/react-use-outside-click": "2.1.0", "@chakra-ui/react-use-update-effect": "2.0.7", "@chakra-ui/shared-utils": "2.0.5", "@chakra-ui/transition": "2.0.16" @@ -2381,9 +2389,9 @@ "integrity": "sha512-yOGxBjXNvLTBvQyhMDqGU0Oj26s91mbAlqKHiuw737AXHt0aPllOthVUqQMeaYLwLCjGMg0jtI7JReRzyi94Dg==" }, "node_modules/@chakra-ui/object-utils": { - "version": "2.0.8", - "resolved": "https://registry.npmmirror.com/@chakra-ui/object-utils/-/object-utils-2.0.8.tgz", - "integrity": "sha512-2upjT2JgRuiupdrtBWklKBS6tqeGMA77Nh6Q0JaoQuH/8yq+15CGckqn3IUWkWoGI0Fg3bK9LDlbbD+9DLw95Q==" + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@chakra-ui/object-utils/-/object-utils-2.1.0.tgz", + "integrity": "sha512-tgIZOgLHaoti5PYGPTwK3t/cqtcycW0owaiOXoZOcpwwX/vlVb+H1jFsQyWiiwQVPt9RkoSLtxzXamx+aHH+bQ==" }, "node_modules/@chakra-ui/pin-input": { "version": "2.0.20", @@ -2403,18 +2411,18 @@ } }, "node_modules/@chakra-ui/popover": { - "version": "2.1.9", - "resolved": "https://registry.npmmirror.com/@chakra-ui/popover/-/popover-2.1.9.tgz", - "integrity": "sha512-OMJ12VVs9N32tFaZSOqikkKPtwAVwXYsES/D1pff/amBrE3ngCrpxJSIp4uvTdORfIYDojJqrR52ZplDKS9hRQ==", + "version": "2.1.11", + "resolved": "https://registry.npmmirror.com/@chakra-ui/popover/-/popover-2.1.11.tgz", + "integrity": "sha512-ntFMKojU+ZIofwSw5IJ+Ur8pN5o+5kf/Fx5r5tCjFZd0DSkrEeJw9i00/UWJ6kYZb+zlpswxriv0FmxBlAF66w==", "dependencies": { "@chakra-ui/close-button": "2.0.17", "@chakra-ui/lazy-utils": "2.0.5", - "@chakra-ui/popper": "3.0.13", + "@chakra-ui/popper": "3.0.14", "@chakra-ui/react-context": "2.0.8", "@chakra-ui/react-types": "2.0.7", "@chakra-ui/react-use-animation-state": "2.0.8", "@chakra-ui/react-use-disclosure": "2.0.8", - "@chakra-ui/react-use-focus-effect": "2.0.9", + "@chakra-ui/react-use-focus-effect": "2.0.10", "@chakra-ui/react-use-focus-on-pointer-down": "2.0.6", "@chakra-ui/react-use-merge-refs": "2.0.7", "@chakra-ui/shared-utils": "2.0.5" @@ -2426,9 +2434,9 @@ } }, "node_modules/@chakra-ui/popper": { - "version": "3.0.13", - "resolved": "https://registry.npmmirror.com/@chakra-ui/popper/-/popper-3.0.13.tgz", - "integrity": "sha512-FwtmYz80Ju8oK3Z1HQfisUE7JIMmDsCQsRBu6XuJ3TFQnBHit73yjZmxKjuRJ4JgyT4WBnZoTF3ATbRKSagBeg==", + "version": "3.0.14", + "resolved": "https://registry.npmmirror.com/@chakra-ui/popper/-/popper-3.0.14.tgz", + "integrity": "sha512-RDMmmSfjsmHJbVn2agDyoJpTbQK33fxx//njwJdeyM0zTG/3/4xjI/Cxru3acJ2Y+1jFGmPqhO81stFjnbtfIw==", "dependencies": { "@chakra-ui/react-types": "2.0.7", "@chakra-ui/react-use-merge-refs": "2.0.7", @@ -2464,14 +2472,14 @@ } }, "node_modules/@chakra-ui/provider": { - "version": "2.2.2", - "resolved": "https://registry.npmmirror.com/@chakra-ui/provider/-/provider-2.2.2.tgz", - "integrity": "sha512-UVwnIDnAWq1aKroN5AF+OpNpUqLVeIUk7tKvX3z4CY9FsPFFi6LTEhRHdhpwaU1Tau3Tf9agEu5URegpY7S8BA==", + "version": "2.2.4", + "resolved": "https://registry.npmmirror.com/@chakra-ui/provider/-/provider-2.2.4.tgz", + "integrity": "sha512-vz/WMEWhwoITCAkennRNYCeQHsJ6YwB/UjVaAK+61jWY42J7uCsRZ+3nB5rDjQ4m+aqPfTUPof8KLJBrtYrJbw==", "dependencies": { "@chakra-ui/css-reset": "2.1.1", "@chakra-ui/portal": "2.0.16", "@chakra-ui/react-env": "3.0.0", - "@chakra-ui/system": "2.5.5", + "@chakra-ui/system": "2.5.7", "@chakra-ui/utils": "2.0.15" }, "peerDependencies": { @@ -2499,58 +2507,59 @@ } }, "node_modules/@chakra-ui/react": { - "version": "2.5.5", - "resolved": "https://registry.npmmirror.com/@chakra-ui/react/-/react-2.5.5.tgz", - "integrity": "sha512-aBVMUtdWv2MrptD/tKSqICPsuJ+I+jvauegffO1qPUDlK3RrXIDeOHkLGWohgXNcjY5bGVWguFEzJm97//0ooQ==", + "version": "2.6.1", + "resolved": "https://registry.npmmirror.com/@chakra-ui/react/-/react-2.6.1.tgz", + "integrity": "sha512-Lt8c8pLPTz59xxdSuL2FlE7le9MXx4zgjr60UnEc3yjAMjXNTqUAoWHyT4Zn1elCGUPWOedS3rMvp4KTshT+5w==", "dependencies": { "@chakra-ui/accordion": "2.1.11", "@chakra-ui/alert": "2.1.0", - "@chakra-ui/avatar": "2.2.8", + "@chakra-ui/avatar": "2.2.10", "@chakra-ui/breadcrumb": "2.1.5", "@chakra-ui/button": "2.0.18", "@chakra-ui/card": "2.1.6", - "@chakra-ui/checkbox": "2.2.14", + "@chakra-ui/checkbox": "2.2.15", "@chakra-ui/close-button": "2.0.17", "@chakra-ui/control-box": "2.0.13", "@chakra-ui/counter": "2.0.14", "@chakra-ui/css-reset": "2.1.1", - "@chakra-ui/editable": "2.0.21", + "@chakra-ui/editable": "3.0.0", "@chakra-ui/focus-lock": "2.0.16", "@chakra-ui/form-control": "2.0.18", - "@chakra-ui/hooks": "2.1.6", + "@chakra-ui/hooks": "2.2.0", "@chakra-ui/icon": "3.0.16", - "@chakra-ui/image": "2.0.15", - "@chakra-ui/input": "2.0.21", - "@chakra-ui/layout": "2.1.18", + "@chakra-ui/image": "2.0.16", + "@chakra-ui/input": "2.0.22", + "@chakra-ui/layout": "2.1.19", "@chakra-ui/live-region": "2.0.13", "@chakra-ui/media-query": "3.2.12", - "@chakra-ui/menu": "2.1.12", + "@chakra-ui/menu": "2.1.14", "@chakra-ui/modal": "2.2.11", "@chakra-ui/number-input": "2.0.19", "@chakra-ui/pin-input": "2.0.20", - "@chakra-ui/popover": "2.1.9", - "@chakra-ui/popper": "3.0.13", + "@chakra-ui/popover": "2.1.11", + "@chakra-ui/popper": "3.0.14", "@chakra-ui/portal": "2.0.16", "@chakra-ui/progress": "2.1.6", - "@chakra-ui/provider": "2.2.2", + "@chakra-ui/provider": "2.2.4", "@chakra-ui/radio": "2.0.22", "@chakra-ui/react-env": "3.0.0", "@chakra-ui/select": "2.0.19", "@chakra-ui/skeleton": "2.0.24", - "@chakra-ui/slider": "2.0.23", + "@chakra-ui/slider": "2.0.24", "@chakra-ui/spinner": "2.0.13", "@chakra-ui/stat": "2.0.18", - "@chakra-ui/styled-system": "2.8.0", - "@chakra-ui/switch": "2.0.26", - "@chakra-ui/system": "2.5.5", + "@chakra-ui/stepper": "2.2.0", + "@chakra-ui/styled-system": "2.9.0", + "@chakra-ui/switch": "2.0.27", + "@chakra-ui/system": "2.5.7", "@chakra-ui/table": "2.0.17", "@chakra-ui/tabs": "2.1.9", "@chakra-ui/tag": "3.0.0", "@chakra-ui/textarea": "2.0.19", - "@chakra-ui/theme": "3.0.1", - "@chakra-ui/theme-utils": "2.0.15", - "@chakra-ui/toast": "6.1.1", - "@chakra-ui/tooltip": "2.2.7", + "@chakra-ui/theme": "3.1.1", + "@chakra-ui/theme-utils": "2.0.17", + "@chakra-ui/toast": "6.1.3", + "@chakra-ui/tooltip": "2.2.8", "@chakra-ui/transition": "2.0.16", "@chakra-ui/utils": "2.0.15", "@chakra-ui/visually-hidden": "2.0.15" @@ -2652,9 +2661,9 @@ } }, "node_modules/@chakra-ui/react-use-focus-effect": { - "version": "2.0.9", - "resolved": "https://registry.npmmirror.com/@chakra-ui/react-use-focus-effect/-/react-use-focus-effect-2.0.9.tgz", - "integrity": "sha512-20nfNkpbVwyb41q9wxp8c4jmVp6TUGAPE3uFTDpiGcIOyPW5aecQtPmTXPMJH+2aa8Nu1wyoT1btxO+UYiQM3g==", + "version": "2.0.10", + "resolved": "https://registry.npmmirror.com/@chakra-ui/react-use-focus-effect/-/react-use-focus-effect-2.0.10.tgz", + "integrity": "sha512-HswfpzjP8gCQM3/fbeR+8wrYqt0B3ChnVTqssnYXqp9Fa/5Y1Kx1ZADUWW93zMs5SF7hIEuNt8uKeh1/3HTcqQ==", "dependencies": { "@chakra-ui/dom-utils": "2.0.6", "@chakra-ui/react-use-event-listener": "2.0.7", @@ -2704,9 +2713,9 @@ } }, "node_modules/@chakra-ui/react-use-outside-click": { - "version": "2.0.7", - "resolved": "https://registry.npmmirror.com/@chakra-ui/react-use-outside-click/-/react-use-outside-click-2.0.7.tgz", - "integrity": "sha512-MsAuGLkwYNxNJ5rb8lYNvXApXxYMnJ3MzqBpQj1kh5qP/+JSla9XMjE/P94ub4fSEttmNSqs43SmPPrmPuihsQ==", + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@chakra-ui/react-use-outside-click/-/react-use-outside-click-2.1.0.tgz", + "integrity": "sha512-JanCo4QtWvMl9ZZUpKJKV62RlMWDFdPCE0Q64a7eWTOQgWWcpyBW7TOYRunQTqrK30FqkYFJCOlAWOtn+6Rw7A==", "dependencies": { "@chakra-ui/react-use-callback-ref": "2.0.7" }, @@ -2817,9 +2826,9 @@ } }, "node_modules/@chakra-ui/slider": { - "version": "2.0.23", - "resolved": "https://registry.npmmirror.com/@chakra-ui/slider/-/slider-2.0.23.tgz", - "integrity": "sha512-/eyRUXLla+ZdBUPXpakE3SAS2JS8mIJR6qcUYiPVKSpRAi6tMyYeQijAXn2QC1AUVd2JrG8Pz+1Jy7Po3uA7cA==", + "version": "2.0.24", + "resolved": "https://registry.npmmirror.com/@chakra-ui/slider/-/slider-2.0.24.tgz", + "integrity": "sha512-o3hOaIiTzPMG8yf+HYWbrTmhxABicDViVOvOajRSXDodbZSCk1rZy1nmUeahjVtfVUB1IyJoNcXdn76IqJmhdg==", "dependencies": { "@chakra-ui/number-utils": "2.0.7", "@chakra-ui/react-context": "2.0.8", @@ -2863,10 +2872,24 @@ "react": ">=18" } }, + "node_modules/@chakra-ui/stepper": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@chakra-ui/stepper/-/stepper-2.2.0.tgz", + "integrity": "sha512-8ZLxV39oghSVtOUGK8dX8Z6sWVSQiKVmsK4c3OQDa8y2TvxP0VtFD0Z5U1xJlOjQMryZRWhGj9JBc3iQLukuGg==", + "dependencies": { + "@chakra-ui/icon": "3.0.16", + "@chakra-ui/react-context": "2.0.8", + "@chakra-ui/shared-utils": "2.0.5" + }, + "peerDependencies": { + "@chakra-ui/system": ">=2.0.0", + "react": ">=18" + } + }, "node_modules/@chakra-ui/styled-system": { - "version": "2.8.0", - "resolved": "https://registry.npmmirror.com/@chakra-ui/styled-system/-/styled-system-2.8.0.tgz", - "integrity": "sha512-bmRv/8ACJGGKGx84U1npiUddwdNifJ+/ETklGwooS5APM0ymwUtBYZpFxjYNJrqvVYpg3mVY6HhMyBVptLS7iA==", + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/@chakra-ui/styled-system/-/styled-system-2.9.0.tgz", + "integrity": "sha512-rToN30eOezrTZ5qBHmWqEwsYPenHtc3WU6ODAfMUwNnmCJQiu2erRGv8JwIjmRJnKSOEnNKccI2UXe2EwI6+JA==", "dependencies": { "@chakra-ui/shared-utils": "2.0.5", "csstype": "^3.0.11", @@ -2874,11 +2897,11 @@ } }, "node_modules/@chakra-ui/switch": { - "version": "2.0.26", - "resolved": "https://registry.npmmirror.com/@chakra-ui/switch/-/switch-2.0.26.tgz", - "integrity": "sha512-x62lF6VazSZJQuVxosChVR6+0lIJe8Pxgkl/C9vxjhp2yVYb3mew5tcX/sDOu0dYZy8ro/9hMfGkdN4r9xEU8A==", + "version": "2.0.27", + "resolved": "https://registry.npmmirror.com/@chakra-ui/switch/-/switch-2.0.27.tgz", + "integrity": "sha512-z76y2fxwMlvRBrC5W8xsZvo3gP+zAEbT3Nqy5P8uh/IPd5OvDsGeac90t5cgnQTyxMOpznUNNK+1eUZqtLxWnQ==", "dependencies": { - "@chakra-ui/checkbox": "2.2.14", + "@chakra-ui/checkbox": "2.2.15", "@chakra-ui/shared-utils": "2.0.5" }, "peerDependencies": { @@ -2888,15 +2911,15 @@ } }, "node_modules/@chakra-ui/system": { - "version": "2.5.5", - "resolved": "https://registry.npmmirror.com/@chakra-ui/system/-/system-2.5.5.tgz", - "integrity": "sha512-52BIp/Zyvefgxn5RTByfkTeG4J+y81LWEjWm8jCaRFsLVm8IFgqIrngtcq4I7gD5n/UKbneHlb4eLHo4uc5yDQ==", + "version": "2.5.7", + "resolved": "https://registry.npmmirror.com/@chakra-ui/system/-/system-2.5.7.tgz", + "integrity": "sha512-yB6en7YdJPxKvKY2jJROVwkBE2CLFmHS4ZDx27VdYs0Fa4kGiyDFhJAfnMtLBNDVsTy1NhUHL9aqR63u56QqFg==", "dependencies": { "@chakra-ui/color-mode": "2.1.12", - "@chakra-ui/object-utils": "2.0.8", + "@chakra-ui/object-utils": "2.1.0", "@chakra-ui/react-utils": "2.0.12", - "@chakra-ui/styled-system": "2.8.0", - "@chakra-ui/theme-utils": "2.0.15", + "@chakra-ui/styled-system": "2.9.0", + "@chakra-ui/theme-utils": "2.0.17", "@chakra-ui/utils": "2.0.15", "react-fast-compare": "3.2.1" }, @@ -2966,16 +2989,16 @@ } }, "node_modules/@chakra-ui/theme": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/@chakra-ui/theme/-/theme-3.0.1.tgz", - "integrity": "sha512-92kDm/Ux/51uJqhRKevQo/O/rdwucDYcpHg2QuwzdAxISCeYvgtl2TtgOOl5EnqEP0j3IEAvZHZUlv8TTbawaw==", + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@chakra-ui/theme/-/theme-3.1.1.tgz", + "integrity": "sha512-VHcG0CPLd9tgvWnajpAGqrAYhx4HwgfK0E9VOrdwa/3bN+AgY/0EAAXzfe0Q0W2MBWzSgaYqZcQ5cDRpYbiYPA==", "dependencies": { "@chakra-ui/anatomy": "2.1.2", "@chakra-ui/shared-utils": "2.0.5", "@chakra-ui/theme-tools": "2.0.17" }, "peerDependencies": { - "@chakra-ui/styled-system": ">=2.0.0" + "@chakra-ui/styled-system": ">=2.8.0" } }, "node_modules/@chakra-ui/theme-tools": { @@ -2992,20 +3015,20 @@ } }, "node_modules/@chakra-ui/theme-utils": { - "version": "2.0.15", - "resolved": "https://registry.npmmirror.com/@chakra-ui/theme-utils/-/theme-utils-2.0.15.tgz", - "integrity": "sha512-UuxtEgE7gwMTGDXtUpTOI7F5X0iHB9ekEOG5PWPn2wWBL7rlk2JtPI7UP5Um5Yg6vvBfXYGK1ySahxqsgf+87g==", + "version": "2.0.17", + "resolved": "https://registry.npmmirror.com/@chakra-ui/theme-utils/-/theme-utils-2.0.17.tgz", + "integrity": "sha512-aUaVLFIU1Rs8m+5WVOUvqHKapOX8nSgUVGaeRWS4odxBM95dG4j15f4L88LEMw4D4+WWd0CSAS139OnRgj1rCw==", "dependencies": { "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/styled-system": "2.8.0", - "@chakra-ui/theme": "3.0.1", + "@chakra-ui/styled-system": "2.9.0", + "@chakra-ui/theme": "3.1.1", "lodash.mergewith": "4.6.2" } }, "node_modules/@chakra-ui/toast": { - "version": "6.1.1", - "resolved": "https://registry.npmmirror.com/@chakra-ui/toast/-/toast-6.1.1.tgz", - "integrity": "sha512-JtjIKkPVjEu8okGGCipCxNVgK/15h5AicTATZ6RbG2MsHmr4GfKG3fUCvpbuZseArqmLqGLQZQJjVE9vJzaSkQ==", + "version": "6.1.3", + "resolved": "https://registry.npmmirror.com/@chakra-ui/toast/-/toast-6.1.3.tgz", + "integrity": "sha512-dsg/Sdkuq+SCwdOeyzrnBO1ecDA7VKfLFjUtj9QBc/SFEN8r+FQrygy79TNo+QWr7zdjI8icbl8nsp59lpb8ag==", "dependencies": { "@chakra-ui/alert": "2.1.0", "@chakra-ui/close-button": "2.0.17", @@ -3014,22 +3037,22 @@ "@chakra-ui/react-use-timeout": "2.0.5", "@chakra-ui/react-use-update-effect": "2.0.7", "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/styled-system": "2.8.0", - "@chakra-ui/theme": "3.0.1" + "@chakra-ui/styled-system": "2.9.0", + "@chakra-ui/theme": "3.1.1" }, "peerDependencies": { - "@chakra-ui/system": "2.5.5", + "@chakra-ui/system": "2.5.7", "framer-motion": ">=4.0.0", "react": ">=18", "react-dom": ">=18" } }, "node_modules/@chakra-ui/tooltip": { - "version": "2.2.7", - "resolved": "https://registry.npmmirror.com/@chakra-ui/tooltip/-/tooltip-2.2.7.tgz", - "integrity": "sha512-ImUJ6NnVqARaYqpgtO+kzucDRmxo8AF3jMjARw0bx2LxUkKwgRCOEaaRK5p5dHc0Kr6t5/XqjDeUNa19/sLauA==", + "version": "2.2.8", + "resolved": "https://registry.npmmirror.com/@chakra-ui/tooltip/-/tooltip-2.2.8.tgz", + "integrity": "sha512-AqtrCkalADrqqd1SgII4n8F0dDABxqxL3e8uj3yC3HDzT3BU/0NSwSQRA2bp9eoJHk07ZMs9kyzvkkBLc0pr2A==", "dependencies": { - "@chakra-ui/popper": "3.0.13", + "@chakra-ui/popper": "3.0.14", "@chakra-ui/portal": "2.0.16", "@chakra-ui/react-types": "2.0.7", "@chakra-ui/react-use-disclosure": "2.0.8", @@ -3077,65 +3100,65 @@ } }, "node_modules/@emotion/babel-plugin": { - "version": "11.10.6", - "resolved": "https://registry.npmmirror.com/@emotion/babel-plugin/-/babel-plugin-11.10.6.tgz", - "integrity": "sha512-p2dAqtVrkhSa7xz1u/m9eHYdLi+en8NowrmXeF/dKtJpU8lCWli8RUAati7NcSl0afsBott48pdnANuD0wh9QQ==", + "version": "11.11.0", + "resolved": "https://registry.npmmirror.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", + "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.0", - "@emotion/memoize": "^0.8.0", - "@emotion/serialize": "^1.1.1", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/serialize": "^1.1.2", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", - "stylis": "4.1.3" + "stylis": "4.2.0" } }, "node_modules/@emotion/cache": { - "version": "11.10.5", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.10.5.tgz", - "integrity": "sha512-dGYHWyzTdmK+f2+EnIGBpkz1lKc4Zbj2KHd4cX3Wi8/OWr5pKslNjc3yABKH4adRGCvSX4VDC0i04mrrq0aiRA==", + "version": "11.11.0", + "resolved": "https://registry.npmmirror.com/@emotion/cache/-/cache-11.11.0.tgz", + "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", "dependencies": { - "@emotion/memoize": "^0.8.0", - "@emotion/sheet": "^1.2.1", - "@emotion/utils": "^1.2.0", - "@emotion/weak-memoize": "^0.3.0", - "stylis": "4.1.3" + "@emotion/memoize": "^0.8.1", + "@emotion/sheet": "^1.2.2", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "stylis": "4.2.0" } }, "node_modules/@emotion/hash": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.0.tgz", - "integrity": "sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ==" + "version": "0.9.1", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.9.1.tgz", + "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.0.tgz", - "integrity": "sha512-3aDpDprjM0AwaxGE09bOPkNxHpBd+kA6jty3RnaEXdweX1DF1U3VQpPYb0g1IStAuK7SVQ1cy+bNBBKp4W3Fjg==", + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", + "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", "dependencies": { - "@emotion/memoize": "^0.8.0" + "@emotion/memoize": "^0.8.1" } }, "node_modules/@emotion/memoize": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.0.tgz", - "integrity": "sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA==" + "version": "0.8.1", + "resolved": "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" }, "node_modules/@emotion/react": { - "version": "11.10.6", - "resolved": "https://registry.npmmirror.com/@emotion/react/-/react-11.10.6.tgz", - "integrity": "sha512-6HT8jBmcSkfzO7mc+N1L9uwvOnlcGoix8Zn7srt+9ga0MjREo6lRpuVX0kzo6Jp6oTqDhREOFsygN6Ew4fEQbw==", + "version": "11.11.0", + "resolved": "https://registry.npmmirror.com/@emotion/react/-/react-11.11.0.tgz", + "integrity": "sha512-ZSK3ZJsNkwfjT3JpDAWJZlrGD81Z3ytNDsxw1LKq1o+xkmO5pnWfr6gmCC8gHEFf3nSSX/09YrG67jybNPxSUw==", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.10.6", - "@emotion/cache": "^11.10.5", - "@emotion/serialize": "^1.1.1", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", - "@emotion/utils": "^1.2.0", - "@emotion/weak-memoize": "^0.3.0", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/cache": "^11.11.0", + "@emotion/serialize": "^1.1.2", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { @@ -3148,33 +3171,33 @@ } }, "node_modules/@emotion/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-Zl/0LFggN7+L1liljxXdsVSVlg6E/Z/olVWpfxUTxOAmi8NU7YoeWeLfi1RmnB2TATHoaWwIBRoL+FvAJiTUQA==", + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@emotion/serialize/-/serialize-1.1.2.tgz", + "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==", "dependencies": { - "@emotion/hash": "^0.9.0", - "@emotion/memoize": "^0.8.0", - "@emotion/unitless": "^0.8.0", - "@emotion/utils": "^1.2.0", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/unitless": "^0.8.1", + "@emotion/utils": "^1.2.1", "csstype": "^3.0.2" } }, "node_modules/@emotion/sheet": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.1.tgz", - "integrity": "sha512-zxRBwl93sHMsOj4zs+OslQKg/uhF38MB+OMKoCrVuS0nyTkqnau+BM3WGEoOptg9Oz45T/aIGs1qbVAsEFo3nA==" + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/@emotion/sheet/-/sheet-1.2.2.tgz", + "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" }, "node_modules/@emotion/styled": { - "version": "11.10.6", - "resolved": "https://registry.npmmirror.com/@emotion/styled/-/styled-11.10.6.tgz", - "integrity": "sha512-OXtBzOmDSJo5Q0AFemHCfl+bUueT8BIcPSxu0EGTpGk6DmI5dnhSzQANm1e1ze0YZL7TDyAyy6s/b/zmGOS3Og==", + "version": "11.11.0", + "resolved": "https://registry.npmmirror.com/@emotion/styled/-/styled-11.11.0.tgz", + "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", "dependencies": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.10.6", - "@emotion/is-prop-valid": "^1.2.0", - "@emotion/serialize": "^1.1.1", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", - "@emotion/utils": "^1.2.0" + "@emotion/babel-plugin": "^11.11.0", + "@emotion/is-prop-valid": "^1.2.1", + "@emotion/serialize": "^1.1.2", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1" }, "peerDependencies": { "@emotion/react": "^11.0.0-rc.0", @@ -3187,27 +3210,27 @@ } }, "node_modules/@emotion/unitless": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.0.tgz", - "integrity": "sha512-VINS5vEYAscRl2ZUDiT3uMPlrFQupiKgHz5AA4bCH1miKBg4qtwkim1qPmJj/4WG6TreYMY111rEFsjupcOKHw==" + "version": "0.8.1", + "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.0.tgz", - "integrity": "sha512-1eEgUGmkaljiBnRMTdksDV1W4kUnmwgp7X9G8B++9GYwl1lUdqSndSriIrTJ0N7LQaoauY9JJ2yhiOYK5+NI4A==", + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", + "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", "peerDependencies": { "react": ">=16.8.0" } }, "node_modules/@emotion/utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.0.tgz", - "integrity": "sha512-sn3WH53Kzpw8oQ5mgMmIzzyAaH2ZqFEbozVVBSYp538E06OSE6ytOp7pRAjNQR+Q/orwqdQYJSe2m3hCOeznkw==" + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@emotion/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" }, "node_modules/@emotion/weak-memoize": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz", - "integrity": "sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg==" + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", + "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" }, "node_modules/@esbuild/android-arm": { "version": "0.17.12", @@ -3586,14 +3609,14 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.0.2.tgz", - "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==", + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", + "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.5.1", + "espree": "^9.5.2", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -3627,9 +3650,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.37.0", - "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.37.0.tgz", - "integrity": "sha512-x5vzdtOOGgFVDCUs81QRB2+liax8rFg3+7hqM+QhBG0/G3F1ZsoYl97UrqgHgQ9KKT7G6c4V+aTUCgu/n22v1A==", + "version": "8.41.0", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.41.0.tgz", + "integrity": "sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -3833,9 +3856,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.5.0", - "resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.5.0.tgz", - "integrity": "sha512-bkUDCp8o1MvFO+qxkODcbhSqRa6P2GXgrGZVpt0dCXNW2HCSCqYI0ZoAqEOSAjRWmmlKcYgFvN4B4S+zo/f8kg==", + "version": "1.6.2", + "resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.6.2.tgz", + "integrity": "sha512-LzqpSrMK/3JBAVBI9u3NWtOhWNw5AMQfrUFYB0+bDHTSw17z++WJLsPsxAuK+oSddsxk4d7F/JcdDPM1M5YAhA==", "engines": { "node": ">=14" } @@ -3847,31 +3870,39 @@ "dev": true }, "node_modules/@swc/core": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core/-/core-1.3.35.tgz", - "integrity": "sha512-KmiBin0XSVzJhzX19zTiCqmLslZ40Cl7zqskJcTDeIrRhfgKdiAsxzYUanJgMJIRjYtl9Kcg1V/Ip2o2wL8v3w==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core/-/core-1.3.60.tgz", + "integrity": "sha512-dWfic7sVjnrStzGcMWakHd2XPau8UXGPmFUTkx6xGX+DOVtfAQVzG6ZW7ohw/yNcTqI05w6Ser26XMTMGBgXdA==", "dev": true, "hasInstallScript": true, "engines": { "node": ">=10" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.3.35", - "@swc/core-darwin-x64": "1.3.35", - "@swc/core-linux-arm-gnueabihf": "1.3.35", - "@swc/core-linux-arm64-gnu": "1.3.35", - "@swc/core-linux-arm64-musl": "1.3.35", - "@swc/core-linux-x64-gnu": "1.3.35", - "@swc/core-linux-x64-musl": "1.3.35", - "@swc/core-win32-arm64-msvc": "1.3.35", - "@swc/core-win32-ia32-msvc": "1.3.35", - "@swc/core-win32-x64-msvc": "1.3.35" + "@swc/core-darwin-arm64": "1.3.60", + "@swc/core-darwin-x64": "1.3.60", + "@swc/core-linux-arm-gnueabihf": "1.3.60", + "@swc/core-linux-arm64-gnu": "1.3.60", + "@swc/core-linux-arm64-musl": "1.3.60", + "@swc/core-linux-x64-gnu": "1.3.60", + "@swc/core-linux-x64-musl": "1.3.60", + "@swc/core-win32-arm64-msvc": "1.3.60", + "@swc/core-win32-ia32-msvc": "1.3.60", + "@swc/core-win32-x64-msvc": "1.3.60" + }, + "peerDependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.35.tgz", - "integrity": "sha512-zQUFkHx4gZpu0uo2IspvPnKsz8bsdXd5bC33xwjtoAI1cpLerDyqo4v2zIahEp+FdKZjyVsLHtfJiQiA1Qka3A==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.60.tgz", + "integrity": "sha512-oCDKWGdSO1WyErduGfiITRDoq7ZBt9PXETlhi8BGKH/wCc/3mfSNI9wXAg3Stn8mrT0lUJtdsnwMI/eZp6dK+A==", "cpu": [ "arm64" ], @@ -3885,9 +3916,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-darwin-x64/-/core-darwin-x64-1.3.35.tgz", - "integrity": "sha512-oOSkSGWtALovaw22lNevKD434OQTPf8X+dVPvPMrJXJpJ34dWDlFWpLntoc+arvKLNZ7LQmTuk8rR1hkrAY7cw==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-darwin-x64/-/core-darwin-x64-1.3.60.tgz", + "integrity": "sha512-pcE/1oUlmN/BkKndOPtViqTkaM5pomagXATo+Muqn4QNMnkSOEVcmF9T3Lr3nB1A7O/fwCew3/aHwZ5B2TZ1tA==", "cpu": [ "x64" ], @@ -3901,9 +3932,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.35.tgz", - "integrity": "sha512-Yie8k00O6O8BCATS/xeKStquV4OYSskUGRDXBQVDw1FrE23PHaSeHCgg4q6iNZjJzXCOJbaTCKnYoIDn9DMf7A==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.60.tgz", + "integrity": "sha512-Moc+86SWcbPr06PaQYUb0Iwli425F7QgjwTCNEPYA6OYUsjaJhXMaHViW2WdGIXue2+eaQbg31BHQd14jXcoBg==", "cpu": [ "arm" ], @@ -3917,9 +3948,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.35.tgz", - "integrity": "sha512-Zlv3WHa/4x2p51HSvjUWXHfSe1Gl2prqImUZJc8NZOlj75BFzVuR0auhQ+LbwvIQ3gaA1LODX9lyS9wXL3yjxA==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.60.tgz", + "integrity": "sha512-pPGZrTgSXBvp6IrXPXz8UJr82AElf8hMuK4rNHmLGDCqrWnRIFLUpiAsc2WCFIgdwqitZNQoM+F2vbceA/bkKg==", "cpu": [ "arm64" ], @@ -3933,9 +3964,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.35.tgz", - "integrity": "sha512-u6tCYsrSyZ8U+4jLMA/O82veBfLy2aUpn51WxQaeH7wqZGy9TGSJXoO8vWxARQ6b72vjsnKDJHP4MD8hFwcctg==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.60.tgz", + "integrity": "sha512-HSFQaVUkjWYNsQeymAQ3IPX3csRQvHe6MFyqPfvCCQ4dFlxPvlS7VvNaLnGG+ZW1ek7Lc+hEX+4NGzZKsxDIHA==", "cpu": [ "arm64" ], @@ -3949,9 +3980,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.35.tgz", - "integrity": "sha512-Dtxf2IbeH7XlNhP1Qt2/MvUPkpEbn7hhGfpSRs4ot8D3Vf5QEX4S/QtC1OsFWuciiYgHAT1Ybjt4xZic9DSkmA==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.60.tgz", + "integrity": "sha512-WJt/X6HHM3/TszckRA7UKMXec3FHYsB9xswQbIYxN4bfTQodu3Rc8bmpHYtFO7ScMLrhY+RljHLK6wclPvaEXw==", "cpu": [ "x64" ], @@ -3965,9 +3996,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.35.tgz", - "integrity": "sha512-4XavNJ60GprjpTiESCu5daJUnmErixPAqDitJSMu4TV32LNIE8G00S9pDLXinDTW1rgcGtQdq1NLkNRmwwovtg==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.60.tgz", + "integrity": "sha512-DGGBqAPUXy/aPMBKokL3osZC9kM97HchiDPuprzwgTMP40YQ3hGCzNJ5jK7sOk9Tc4PEdZ2Igfr9sBHmCrxxQw==", "cpu": [ "x64" ], @@ -3981,9 +4012,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.35.tgz", - "integrity": "sha512-dNGfKCUSX2M4qVyaS80Lyos0FkXyHRCvrdQ2Y4Hrg3FVokiuw3yY6fLohpUfQ5ws3n2A39dh7jGDeh34+l0sGA==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.60.tgz", + "integrity": "sha512-wQg/BZPJvp5WpUbsBp7VHjhUh0DfYOPhP6dH67WO9QQ07+DvOk2DR2Bfh0z0ts1k7H/FsAqExWtTDCWMCRJiRQ==", "cpu": [ "arm64" ], @@ -3997,9 +4028,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.35.tgz", - "integrity": "sha512-ChuPSrDR+JBf7S7dEKPicnG8A3bM0uWPsW2vG+V2wH4iNfNxKVemESHosmYVeEZXqMpomNMvLyeHep1rjRsc0Q==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.60.tgz", + "integrity": "sha512-nqkd0XIVyGbnBwAxP4GIfx6n45/hAPETpmQYpDSGnucOKFJfvGdFGL81GDG1acPCq/oFtR3tIyTbPpKmJ0N6xQ==", "cpu": [ "ia32" ], @@ -4013,9 +4044,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.35.tgz", - "integrity": "sha512-/RvphT4WfuGfIK84Ha0dovdPrKB1bW/mc+dtdmhv2E3EGkNc5FoueNwYmXWRimxnU7X0X7IkcRhyKB4G5DeAmg==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.60.tgz", + "integrity": "sha512-ouw+s22i9PYQpSE7Xc+ZittEyA87jElXABesviSpP+jgHt10sM5KFUpVAeV8DRlxJCXMJJ5AhOdCf4TAtFr+6A==", "cpu": [ "x64" ], @@ -4029,16 +4060,16 @@ } }, "node_modules/@tanstack/query-core": { - "version": "4.27.0", - "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-4.27.0.tgz", - "integrity": "sha512-sm+QncWaPmM73IPwFlmWSKPqjdTXZeFf/7aEmWh00z7yl2FjqophPt0dE1EHW9P1giMC5rMviv7OUbSDmWzXXA==" + "version": "4.29.7", + "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-4.29.7.tgz", + "integrity": "sha512-GXG4b5hV2Loir+h2G+RXhJdoZhJLnrBWsuLB2r0qBRyhWuXq9w/dWxzvpP89H0UARlH6Mr9DiVj4SMtpkF/aUA==" }, "node_modules/@tanstack/react-query": { - "version": "4.28.0", - "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-4.28.0.tgz", - "integrity": "sha512-8cGBV5300RHlvYdS4ea+G1JcZIt5CIuprXYFnsWggkmGoC0b5JaqG0fIX3qwDL9PTNkKvG76NGThIWbpXivMrQ==", + "version": "4.29.7", + "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-4.29.7.tgz", + "integrity": "sha512-ijBWEzAIo09fB1yd22slRZzprrZ5zMdWYzBnCg5qiXuFbH78uGN1qtGz8+Ed4MuhaPaYSD+hykn+QEKtQviEtg==", "dependencies": { - "@tanstack/query-core": "4.27.0", + "@tanstack/query-core": "4.29.7", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { @@ -4076,9 +4107,9 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.14.192", - "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.14.192.tgz", - "integrity": "sha512-km+Vyn3BYm5ytMO13k9KTp27O75rbQ0NFw+U//g+PX7VZyjCioXaRFisqSIJRECljcTv73G3i6BpglNGHgUQ5A==" + "version": "4.14.194", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.14.194.tgz", + "integrity": "sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==" }, "node_modules/@types/lodash.mergewith": { "version": "4.6.7", @@ -4089,9 +4120,9 @@ } }, "node_modules/@types/node": { - "version": "18.15.11", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.15.11.tgz", - "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==", + "version": "20.2.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.2.3.tgz", + "integrity": "sha512-pg9d0yC4rVNWQzX8U7xb4olIOFuuVL9za3bzMT2pu2SU0SNEi66i2qrvhE2qt0HvkhuCaWJu7pLNOt/Pj8BIrw==", "dev": true }, "node_modules/@types/parse-json": { @@ -4106,9 +4137,9 @@ "devOptional": true }, "node_modules/@types/react": { - "version": "18.0.31", - "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.0.31.tgz", - "integrity": "sha512-EEG67of7DsvRDU6BLLI0p+k1GojDLz9+lZsnCpCRTa/lOokvyPBvp8S5x+A24hME3yyQuIipcP70KJ6H7Qupww==", + "version": "18.2.7", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.2.7.tgz", + "integrity": "sha512-ojrXpSH2XFCmHm7Jy3q44nXDyN54+EYKP2lBhJ2bqfyPj6cIUW/FZW/Csdia34NQgq7KYcAlHi5184m4X88+yw==", "devOptional": true, "dependencies": { "@types/prop-types": "*", @@ -4117,9 +4148,9 @@ } }, "node_modules/@types/react-datepicker": { - "version": "4.10.0", - "resolved": "https://registry.npmmirror.com/@types/react-datepicker/-/react-datepicker-4.10.0.tgz", - "integrity": "sha512-Cq+ks20vBIU6XN67TbkCHu8M7V46Y6vJrKE2n+8q/GfueJyWWTIKeC3Z7cz/d+qxGDq/VCrqA929R0U4lNuztg==", + "version": "4.11.2", + "resolved": "https://registry.npmmirror.com/@types/react-datepicker/-/react-datepicker-4.11.2.tgz", + "integrity": "sha512-ELYyX3lb3K1WltqdlF1hbnaDGgzlF6PIR5T4W38cSEcfrQDIrPE+Ioq5pwRe/KEJ+ihHMjvTVZQkwJx0pWMNHQ==", "dev": true, "dependencies": { "@popperjs/core": "^2.9.2", @@ -4129,9 +4160,9 @@ } }, "node_modules/@types/react-dom": { - "version": "18.0.11", - "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.0.11.tgz", - "integrity": "sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==", + "version": "18.2.4", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.2.4.tgz", + "integrity": "sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==", "dev": true, "dependencies": { "@types/react": "*" @@ -4491,12 +4522,12 @@ } }, "node_modules/@vitejs/plugin-react-swc": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.2.0.tgz", - "integrity": "sha512-IcBoXL/mcH7JdQr/nfDlDwTdIaH8Rg7LpfQDF4nAht+juHWIuv6WhpKPCSfY4+zztAaB07qdBoFz1XCZsgo3pQ==", + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.3.1.tgz", + "integrity": "sha512-ZoYjGxMniXP7X+5ry/W1tpY7w0OeLUEsBF5RHFPmAhpgwwNWie8OF4056MRXRi9QgvYYoZPDzdOXGK3wlCoTfQ==", "dev": true, "dependencies": { - "@swc/core": "^1.3.35" + "@swc/core": "^1.3.56" }, "peerDependencies": { "vite": "^4" @@ -4783,9 +4814,9 @@ } }, "node_modules/axios": { - "version": "1.3.4", - "resolved": "https://registry.npmmirror.com/axios/-/axios-1.3.4.tgz", - "integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==", + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -5010,7 +5041,7 @@ }, "node_modules/camelcase-css": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", "dev": true, "engines": { @@ -5305,11 +5336,11 @@ } }, "node_modules/cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "version": "3.1.6", + "resolved": "https://registry.npmmirror.com/cross-fetch/-/cross-fetch-3.1.6.tgz", + "integrity": "sha512-riRvo06crlE8HiqOwIpQhxwdOk4fOeR7FVM/wXoxchFEqMNUjvbs3bfo4OTgMEMHzppd4DxFBDbyySj8Cv781g==", "dependencies": { - "node-fetch": "2.6.7" + "node-fetch": "^2.6.11" } }, "node_modules/cross-spawn": { @@ -5336,7 +5367,7 @@ }, "node_modules/cssesc": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, "bin": { @@ -5631,15 +5662,15 @@ } }, "node_modules/eslint": { - "version": "8.37.0", - "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.37.0.tgz", - "integrity": "sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==", + "version": "8.41.0", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.41.0.tgz", + "integrity": "sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.2", - "@eslint/js": "8.37.0", + "@eslint/eslintrc": "^2.0.3", + "@eslint/js": "8.41.0", "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -5649,9 +5680,9 @@ "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-visitor-keys": "^3.4.0", - "espree": "^9.5.1", + "eslint-scope": "^7.2.0", + "eslint-visitor-keys": "^3.4.1", + "espree": "^9.5.2", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -5659,13 +5690,12 @@ "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", - "grapheme-splitter": "^1.0.4", + "graphemer": "^1.4.0", "ignore": "^5.2.0", "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", - "js-sdsl": "^4.1.4", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", @@ -5976,9 +6006,9 @@ } }, "node_modules/eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.0.tgz", + "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", "dev": true, "dependencies": { "esrecurse": "^4.3.0", @@ -6016,9 +6046,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.0", - "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz", - "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==", + "version": "3.4.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", + "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -6122,14 +6152,14 @@ } }, "node_modules/espree": { - "version": "9.5.1", - "resolved": "https://registry.npmmirror.com/espree/-/espree-9.5.1.tgz", - "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==", + "version": "9.5.2", + "resolved": "https://registry.npmmirror.com/espree/-/espree-9.5.2.tgz", + "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", "dev": true, "dependencies": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.0" + "eslint-visitor-keys": "^3.4.1" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -6392,9 +6422,9 @@ } }, "node_modules/framer-motion": { - "version": "10.10.0", - "resolved": "https://registry.npmmirror.com/framer-motion/-/framer-motion-10.10.0.tgz", - "integrity": "sha512-eCsyOcJimIRbx9KOzBTO3j9u1rF/H8/o/ybizYqdrzHkEeHx9L2NcEfGWfV0OHTc1JV17ECVzuZpomupEJ4+dw==", + "version": "10.12.16", + "resolved": "https://registry.npmmirror.com/framer-motion/-/framer-motion-10.12.16.tgz", + "integrity": "sha512-w/SfWEIWJkYSgRHYBmln7EhcNo31ao8Xexol8lGXf1pR/tlnBtf1HcxoUmEiEh6pacB4/geku5ami53AAQWHMQ==", "dependencies": { "tslib": "^2.4.0" }, @@ -6622,10 +6652,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/grapheme-splitter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, "node_modules/has": { @@ -6772,9 +6802,9 @@ } }, "node_modules/i18next": { - "version": "22.4.13", - "resolved": "https://registry.npmmirror.com/i18next/-/i18next-22.4.13.tgz", - "integrity": "sha512-GX7flMHRRqQA0I1yGLmaZ4Hwt1JfLqagk8QPDPZsqekbKtXsuIngSVWM/s3SLgNkrEXjA+0sMGNuOEkkmyqmWg==", + "version": "22.5.0", + "resolved": "https://registry.npmmirror.com/i18next/-/i18next-22.5.0.tgz", + "integrity": "sha512-sqWuJFj+wJAKQP2qBQ+b7STzxZNUmnSxrehBCCj9vDOW9RDYPfqCaK1Hbh2frNYQuPziz6O2CGoJPwtzY3vAYA==", "dependencies": { "@babel/runtime": "^7.20.6" } @@ -6788,11 +6818,11 @@ } }, "node_modules/i18next-http-backend": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/i18next-http-backend/-/i18next-http-backend-2.2.0.tgz", - "integrity": "sha512-Z4sM7R6tzdLknSPER9GisEBxKPg5FkI07UrQniuroZmS15PHQrcCPLyuGKj8SS68tf+O2aEDYSUnmy1TZqZSbw==", + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/i18next-http-backend/-/i18next-http-backend-2.2.1.tgz", + "integrity": "sha512-ZXIdn/8NJIBJ0X4hzXfc3STYxKrCKh1fYjji9HPyIpEJfvTvy8/ZlTl8RuTizzCPj2ZcWrfaecyOMKs6bQ7u5A==", "dependencies": { - "cross-fetch": "3.1.5" + "cross-fetch": "3.1.6" } }, "node_modules/ieee754": { @@ -6824,9 +6854,9 @@ } }, "node_modules/immer": { - "version": "9.0.21", - "resolved": "https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz", - "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==" + "version": "10.0.2", + "resolved": "https://registry.npmmirror.com/immer/-/immer-10.0.2.tgz", + "integrity": "sha512-Rx3CqeqQ19sxUtYV9CU911Vhy8/721wRFnJv3REVGWUmoAcIwzifTsdmJte/MV+0/XpM35LZdQMBGkRIoLPwQA==" }, "node_modules/immutable": { "version": "4.2.1", @@ -7204,16 +7234,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/js-sdsl": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", - "integrity": "sha512-dyBIzQBDkCqCu+0upx25Y2jGdbTGxE9fshMsCdK0ViOongpV+n5tXRcZY9v7CaVQ79AGS9KA1KHtojxiM7aXSQ==", - "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7345,9 +7365,9 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, "node_modules/lint-staged": { - "version": "13.2.0", - "resolved": "https://registry.npmmirror.com/lint-staged/-/lint-staged-13.2.0.tgz", - "integrity": "sha512-GbyK5iWinax5Dfw5obm2g2ccUiZXNGtAS4mCbJ0Lv4rq6iEtfBSjOYdcbOtAIFtM114t0vdpViDDetjVTSd8Vw==", + "version": "13.2.2", + "resolved": "https://registry.npmmirror.com/lint-staged/-/lint-staged-13.2.2.tgz", + "integrity": "sha512-71gSwXKy649VrSU09s10uAT0rWCcY3aewhMaHyl2N84oBk4Xs9HgxvUp3AYu+bNsK4NrOYYxvSgg7FyGJ+jGcA==", "dev": true, "dependencies": { "chalk": "5.2.0", @@ -7362,7 +7382,7 @@ "object-inspect": "^1.12.3", "pidtree": "^0.6.0", "string-argv": "^0.3.1", - "yaml": "^2.2.1" + "yaml": "^2.2.2" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -7381,12 +7401,13 @@ } }, "node_modules/lint-staged/node_modules/yaml": { - "version": "2.2.1", - "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.2.1.tgz", - "integrity": "sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw==", + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.3.0.tgz", + "integrity": "sha512-8/1wgzdKc7bc9E6my5wZjmdavHLvO/QOmLG1FBugblEvY4IXrLjlViIOmL24HthU042lWTDRO90Fz1Yp66UnMw==", "dev": true, "engines": { - "node": ">= 14" + "node": ">= 14", + "npm": ">= 7" } }, "node_modules/listr2": { @@ -7689,11 +7710,6 @@ "yallist": "^3.0.2" } }, - "node_modules/make-plural": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.2.0.tgz", - "integrity": "sha512-WkdI+iaWaBCFM2wUXwos8Z7spg5Dt64Xe/VI6NpRaly21cDtD76N6S97K//UtzV0dHOiXX+E90TnszdXHG0aMg==" - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz", @@ -7772,9 +7788,9 @@ } }, "node_modules/monaco-editor": { - "version": "0.36.1", - "resolved": "https://registry.npmmirror.com/monaco-editor/-/monaco-editor-0.36.1.tgz", - "integrity": "sha512-/CaclMHKQ3A6rnzBzOADfwdSJ25BFoFT0Emxsc4zYVyav5SkK9iA6lEtIeuN/oRYbwPgviJT+t3l+sjFa28jYg==" + "version": "0.38.0", + "resolved": "https://registry.npmmirror.com/monaco-editor/-/monaco-editor-0.38.0.tgz", + "integrity": "sha512-11Fkh6yzEmwx7O0YoLxeae0qEGFwmyPRlVxpg7oF9czOOCB/iCjdJrG5I67da5WiXK3YJCxoz9TJFE8Tfq/v9A==" }, "node_modules/ms": { "version": "2.1.2", @@ -7794,9 +7810,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", "dev": true, "bin": { "nanoid": "bin/nanoid.cjs" @@ -7818,9 +7834,9 @@ "dev": true }, "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "version": "2.6.11", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -8187,7 +8203,7 @@ }, "node_modules/pify": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", "dev": true, "engines": { @@ -8210,12 +8226,12 @@ "dev": true }, "node_modules/postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "version": "8.4.23", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.23.tgz", + "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", "dev": true, "dependencies": { - "nanoid": "^3.3.4", + "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -8224,9 +8240,9 @@ } }, "node_modules/postcss-import": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz", - "integrity": "sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==", + "version": "15.1.0", + "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", "dev": true, "dependencies": { "postcss-value-parser": "^4.0.0", @@ -8234,16 +8250,16 @@ "resolve": "^1.1.7" }, "engines": { - "node": ">=10.0.0" + "node": ">=14.0.0" }, "peerDependencies": { "postcss": "^8.0.0" } }, "node_modules/postcss-js": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", - "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", "dev": true, "dependencies": { "camelcase-css": "^2.0.1" @@ -8251,29 +8267,21 @@ "engines": { "node": "^12 || ^14 || >= 16" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, "peerDependencies": { - "postcss": "^8.3.3" + "postcss": "^8.4.21" } }, "node_modules/postcss-load-config": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", - "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-4.0.1.tgz", + "integrity": "sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==", "dev": true, "dependencies": { "lilconfig": "^2.0.5", - "yaml": "^1.10.2" + "yaml": "^2.1.1" }, "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "node": ">= 14" }, "peerDependencies": { "postcss": ">=8.0.9", @@ -8288,29 +8296,35 @@ } } }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.3.0.tgz", + "integrity": "sha512-8/1wgzdKc7bc9E6my5wZjmdavHLvO/QOmLG1FBugblEvY4IXrLjlViIOmL24HthU042lWTDRO90Fz1Yp66UnMw==", + "dev": true, + "engines": { + "node": ">= 14", + "npm": ">= 7" + } + }, "node_modules/postcss-nested": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.0.tgz", - "integrity": "sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==", + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", "dev": true, "dependencies": { - "postcss-selector-parser": "^6.0.10" + "postcss-selector-parser": "^6.0.11" }, "engines": { "node": ">=12.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, "peerDependencies": { "postcss": "^8.2.14" } }, "node_modules/postcss-selector-parser": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz", - "integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==", + "version": "6.0.13", + "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", "dev": true, "dependencies": { "cssesc": "^3.0.0", @@ -8336,9 +8350,9 @@ } }, "node_modules/prettier": { - "version": "2.8.7", - "resolved": "https://registry.npmmirror.com/prettier/-/prettier-2.8.7.tgz", - "integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==", + "version": "2.8.8", + "resolved": "https://registry.npmmirror.com/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "bin": { "prettier": "bin-prettier.js" @@ -8348,16 +8362,15 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.2.6", - "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.2.6.tgz", - "integrity": "sha512-F+7XCl9RLF/LPrGdUMHWpsT6TM31JraonAUyE6eBmpqymFvDwyl0ETHsKFHP1NG+sEfv8bmKqnTxEbWQbHPlBA==", + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.3.0.tgz", + "integrity": "sha512-009/Xqdy7UmkcTBpwlq7jsViDqXAYSOMLDrHAdTMlVZOrKfM2o9Ci7EMWTMZ7SkKBFTG04UM9F9iM2+4i6boDA==", "dev": true, "engines": { "node": ">=12.17.0" }, "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", - "@prettier/plugin-php": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@shufo/prettier-plugin-blade": "*", @@ -8367,6 +8380,7 @@ "prettier-plugin-css-order": "*", "prettier-plugin-import-sort": "*", "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-style-order": "*", @@ -8377,9 +8391,6 @@ "@ianvs/prettier-plugin-sort-imports": { "optional": true }, - "@prettier/plugin-php": { - "optional": true - }, "@prettier/plugin-pug": { "optional": true }, @@ -8404,6 +8415,9 @@ "prettier-plugin-jsdoc": { "optional": true }, + "prettier-plugin-marko": { + "optional": true + }, "prettier-plugin-organize-attributes": { "optional": true }, @@ -8493,18 +8507,6 @@ } ] }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -8595,9 +8597,9 @@ } }, "node_modules/react-i18next": { - "version": "12.2.0", - "resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-12.2.0.tgz", - "integrity": "sha512-5XeVgSygaGfyFmDd2WcXvINRw2WEC1XviW1LXY/xLOEMzsCFRwKqfnHN+hUjla8ZipbVJR27GCMSuTr0BhBBBQ==", + "version": "12.3.1", + "resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-12.3.1.tgz", + "integrity": "sha512-5v8E2XjZDFzK7K87eSwC7AJcAkcLt5xYZ4+yTPDAW1i7C93oOY1dnr4BaQM7un4Hm+GmghuiPvevWwlca5PwDA==", "dependencies": { "@babel/runtime": "^7.20.6", "html-parse-stringify": "^3.0.1" @@ -8713,11 +8715,11 @@ } }, "node_modules/react-router": { - "version": "6.10.0", - "resolved": "https://registry.npmmirror.com/react-router/-/react-router-6.10.0.tgz", - "integrity": "sha512-Nrg0BWpQqrC3ZFFkyewrflCud9dio9ME3ojHCF/WLsprJVzkq3q3UeEhMCAW1dobjeGbWgjNn/PVF6m46ANxXQ==", + "version": "6.11.2", + "resolved": "https://registry.npmmirror.com/react-router/-/react-router-6.11.2.tgz", + "integrity": "sha512-74z9xUSaSX07t3LM+pS6Un0T55ibUE/79CzfZpy5wsPDZaea1F8QkrsiyRnA2YQ7LwE/umaydzXZV80iDCPkMg==", "dependencies": { - "@remix-run/router": "1.5.0" + "@remix-run/router": "1.6.2" }, "engines": { "node": ">=14" @@ -8727,12 +8729,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.10.0", - "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.10.0.tgz", - "integrity": "sha512-E5dfxRPuXKJqzwSe/qGcqdwa18QiWC6f3H3cWXM24qj4N0/beCIf/CWTipop2xm7mR0RCS99NnaqPNjHtrAzCg==", + "version": "6.11.2", + "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.11.2.tgz", + "integrity": "sha512-JNbKtAeh1VSJQnH6RvBDNhxNwemRj7KxCzc5jb7zvDSKRnPWIFj9pO+eXqjM69gQJ0r46hSz1x4l9y0651DKWw==", "dependencies": { - "@remix-run/router": "1.5.0", - "react-router": "6.10.0" + "@remix-run/router": "1.6.2", + "react-router": "6.11.2" }, "engines": { "node": ">=14" @@ -8781,7 +8783,7 @@ }, "node_modules/read-cache": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", "dev": true, "dependencies": { @@ -8927,19 +8929,16 @@ } }, "node_modules/resolve": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", - "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", + "version": "1.22.2", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.2.tgz", + "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", "dependencies": { - "is-core-module": "^2.9.0", + "is-core-module": "^2.11.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, "node_modules/resolve-from": { @@ -9016,9 +9015,9 @@ } }, "node_modules/rollup": { - "version": "3.20.0", - "resolved": "https://registry.npmmirror.com/rollup/-/rollup-3.20.0.tgz", - "integrity": "sha512-YsIfrk80NqUDrxrjWPXUa7PWvAfegZEXHuPsEZg58fGCdjL1I9C1i/NaG+L+27kxxwkrG/QEDEQc8s/ynXWWGQ==", + "version": "3.23.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-3.23.0.tgz", + "integrity": "sha512-h31UlwEi7FHihLe1zbk+3Q7z1k/84rb9BSwmBSr/XjOCEaBJ2YyedQDuM0t/kfOS0IxM+vk1/zI9XxYj9V+NJQ==", "dev": true, "bin": { "rollup": "dist/bin/rollup" @@ -9078,9 +9077,9 @@ } }, "node_modules/sass": { - "version": "1.60.0", - "resolved": "https://registry.npmmirror.com/sass/-/sass-1.60.0.tgz", - "integrity": "sha512-updbwW6fNb5gGm8qMXzVO7V4sWf7LMXnMly/JEyfbfERbVH46Fn6q02BX7/eHTdKpE7d+oTkMMQpFWNUMfFbgQ==", + "version": "1.62.1", + "resolved": "https://registry.npmmirror.com/sass/-/sass-1.62.1.tgz", + "integrity": "sha512-NHpxIzN29MXvWiuswfc1W3I0N8SXBd8UR26WntmDlRYf0bSADnwnOjsyMZ3lMezSlArD33Vs3YFhp7dWvL770A==", "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -9090,7 +9089,7 @@ "sass": "sass.js" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" } }, "node_modules/scheduler": { @@ -9359,16 +9358,17 @@ } }, "node_modules/stylis": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.1.3.tgz", - "integrity": "sha512-GP6WDNWf+o403jrEp9c5jibKavrtLW+/qYGhFxFrG8maXhwTBI7gLLhiBb0o7uFccWN+EOS9aMO6cGHWAO07OA==" + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" }, "node_modules/sucrase": { - "version": "3.31.0", - "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.31.0.tgz", - "integrity": "sha512-6QsHnkqyVEzYcaiHsOKkzOtOgdJcb8i54x6AV2hDwyZcY9ZyykGZVw6L/YN98xC0evwTP6utsWWrKRaa8QlfEQ==", + "version": "3.32.0", + "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.32.0.tgz", + "integrity": "sha512-ydQOU34rpSyj2TGyz4D2p8rbktIOZ8QY9s+DGLvFU1i5pWJE8vkpruCjGCMHsdXwnD7JDcS+noSwM/a7zyNFDQ==", "dev": true, "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "7.1.6", "lines-and-columns": "^1.1.6", @@ -9384,6 +9384,20 @@ "node": ">=8" } }, + "node_modules/sucrase/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/sucrase/node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", @@ -9433,53 +9447,43 @@ } }, "node_modules/tailwindcss": { - "version": "3.3.1", - "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.3.1.tgz", - "integrity": "sha512-Vkiouc41d4CEq0ujXl6oiGFQ7bA3WEhUZdTgXAhtKxSy49OmKs8rEfQmupsfF0IGW8fv2iQkp1EVUuapCFrZ9g==", + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.3.2.tgz", + "integrity": "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==", "dev": true, "dependencies": { + "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", - "color-name": "^1.1.4", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.17.2", - "lilconfig": "^2.0.6", + "jiti": "^1.18.2", + "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", - "postcss": "^8.0.9", - "postcss-import": "^14.1.0", - "postcss-js": "^4.0.0", - "postcss-load-config": "^3.1.4", - "postcss-nested": "6.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", - "quick-lru": "^5.1.1", - "resolve": "^1.22.1", - "sucrase": "^3.29.0" + "resolve": "^1.22.2", + "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" }, "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "postcss": "^8.0.9" + "node": ">=14.0.0" } }, - "node_modules/tailwindcss/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -9544,7 +9548,7 @@ }, "node_modules/tr46": { "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/ts-interface-checker": { @@ -9625,9 +9629,9 @@ } }, "node_modules/typescript": { - "version": "5.0.3", - "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.0.3.tgz", - "integrity": "sha512-xv8mOEDnigb/tN9PSMTwSEqAnUvkoXMQlicOb0IUVDBSQCgBSaAAROUZYy2IcUy5qU6XajK5jjjO7TMWqBTKZA==", + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -9792,20 +9796,19 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, "node_modules/vite": { - "version": "4.2.1", - "resolved": "https://registry.npmmirror.com/vite/-/vite-4.2.1.tgz", - "integrity": "sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg==", + "version": "4.3.8", + "resolved": "https://registry.npmmirror.com/vite/-/vite-4.3.8.tgz", + "integrity": "sha512-uYB8PwN7hbMrf4j1xzGDk/lqjsZvCDbt/JC5dyfxc19Pg8kRm14LinK/uq+HSLNswZEoKmweGdtpbnxRtrAXiQ==", "dev": true, "dependencies": { "esbuild": "^0.17.5", - "postcss": "^8.4.21", - "resolve": "^1.22.1", - "rollup": "^3.18.0" + "postcss": "^8.4.23", + "rollup": "^3.21.0" }, "bin": { "vite": "bin/vite.js" @@ -9879,12 +9882,12 @@ }, "node_modules/webidl-conversions": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/whatwg-url": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "dependencies": { "tr46": "~0.0.3", @@ -10045,9 +10048,9 @@ } }, "node_modules/zustand": { - "version": "4.3.6", - "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.3.6.tgz", - "integrity": "sha512-6J5zDxjxLE+yukC2XZWf/IyWVKnXT9b9HUv09VJ/bwGCpKNcaTqp7Ws28Xr8jnbvnZcdRaidztAPsXFBIqufiw==", + "version": "4.3.8", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.3.8.tgz", + "integrity": "sha512-4h28KCkHg5ii/wcFFJ5Fp+k1J3gJoasaIbppdgZFO4BPJnsNxL0mQXBSFgOgAdCdBj35aDTPvdAJReTMntFPGg==", "dependencies": { "use-sync-external-store": "1.2.0" }, @@ -10069,6 +10072,12 @@ } }, "dependencies": { + "@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true + }, "@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -11383,11 +11392,11 @@ "integrity": "sha512-pKfOS/mztc4sUXHNc8ypJ1gPWSolWT770jrgVRfolVbYlki8y5Y+As996zMF6k5lewTu6j9DQequ7Cc9a69IVQ==" }, "@chakra-ui/avatar": { - "version": "2.2.8", - "resolved": "https://registry.npmmirror.com/@chakra-ui/avatar/-/avatar-2.2.8.tgz", - "integrity": "sha512-uBs9PMrqyK111tPIYIKnOM4n3mwgKqGpvYmtwBnnbQLTNLg4gtiWWVbpTuNMpyu1av0xQYomjUt8Doed8w6p8g==", + "version": "2.2.10", + "resolved": "https://registry.npmmirror.com/@chakra-ui/avatar/-/avatar-2.2.10.tgz", + "integrity": "sha512-Scc0qJtJcxoGOaSS4TkoC2PhVLMacrBcfaNfLqV6wES56BcsjegHvpxREFunZkgVNph/XRHW6J1xOclnsZiPBQ==", "requires": { - "@chakra-ui/image": "2.0.15", + "@chakra-ui/image": "2.0.16", "@chakra-ui/react-children-utils": "2.0.6", "@chakra-ui/react-context": "2.0.8", "@chakra-ui/shared-utils": "2.0.5" @@ -11431,9 +11440,9 @@ } }, "@chakra-ui/checkbox": { - "version": "2.2.14", - "resolved": "https://registry.npmmirror.com/@chakra-ui/checkbox/-/checkbox-2.2.14.tgz", - "integrity": "sha512-uqo6lFWLqYBujPglrvRhTAErtuIXpmdpc5w0W4bjK7kyvLhxOpUh1hlDb2WoqlNpfRn/OaNeF6VinPnf9BJL8w==", + "version": "2.2.15", + "resolved": "https://registry.npmmirror.com/@chakra-ui/checkbox/-/checkbox-2.2.15.tgz", + "integrity": "sha512-Ju2yQjX8azgFa5f6VLPuwdGYobZ+rdbcYqjiks848JvPc75UsPhpS05cb4XlrKT7M16I8txDA5rPJdqqFicHCA==", "requires": { "@chakra-ui/form-control": "2.0.18", "@chakra-ui/react-context": "2.0.8", @@ -11510,9 +11519,9 @@ "integrity": "sha512-PVtDkPrDD5b8aoL6Atg7SLjkwhWb7BwMcLOF1L449L3nZN+DAO3nyAh6iUhZVJyunELj9d0r65CDlnMREyJZmA==" }, "@chakra-ui/editable": { - "version": "2.0.21", - "resolved": "https://registry.npmmirror.com/@chakra-ui/editable/-/editable-2.0.21.tgz", - "integrity": "sha512-oYuXbHnggxSYJN7P9Pn0Scs9tPC91no4z1y58Oe+ILoJKZ+bFAEHtL7FEISDNJxw++MEukeFu7GU1hVqmdLsKQ==", + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/@chakra-ui/editable/-/editable-3.0.0.tgz", + "integrity": "sha512-q/7C/TM3iLaoQKlEiM8AY565i9NoaXtS6N6N4HWIEL5mZJPbMeHKxrCHUZlHxYuQJqFOGc09ZPD9fAFx1GkYwQ==", "requires": { "@chakra-ui/react-context": "2.0.8", "@chakra-ui/react-types": "2.0.7", @@ -11552,9 +11561,9 @@ } }, "@chakra-ui/hooks": { - "version": "2.1.6", - "resolved": "https://registry.npmmirror.com/@chakra-ui/hooks/-/hooks-2.1.6.tgz", - "integrity": "sha512-oMSOeoOF6/UpwTVlDFHSROAA4hPY8WgJ0erdHs1ZkuwAwHv7UzjDkvrb6xYzAAH9qHoFzc5RIBm6jVoh3LCc+Q==", + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@chakra-ui/hooks/-/hooks-2.2.0.tgz", + "integrity": "sha512-GZE64mcr20w+3KbCUPqQJHHmiFnX5Rcp8jS3YntGA4D5X2qU85jka7QkjfBwv/iduZ5Ei0YpCMYGCpi91dhD1Q==", "requires": { "@chakra-ui/react-utils": "2.0.12", "@chakra-ui/utils": "2.0.15", @@ -11571,42 +11580,42 @@ } }, "@chakra-ui/icons": { - "version": "2.0.18", - "resolved": "https://registry.npmmirror.com/@chakra-ui/icons/-/icons-2.0.18.tgz", - "integrity": "sha512-E/+DF/jw7kdN4/XxCZRnr4FdMXhkl50Q34MVwN9rADWMwPK9uSZPGyC7HOx6rilo7q4bFjYDH3yRj9g+VfbVkg==", + "version": "2.0.19", + "resolved": "https://registry.npmmirror.com/@chakra-ui/icons/-/icons-2.0.19.tgz", + "integrity": "sha512-0A6U1ZBZhLIxh3QgdjuvIEhAZi3B9v8g6Qvlfa3mu6vSnXQn2CHBZXmJwxpXxO40NK/2gj/gKXrLeUaFR6H/Qw==", "requires": { "@chakra-ui/icon": "3.0.16" } }, "@chakra-ui/image": { - "version": "2.0.15", - "resolved": "https://registry.npmmirror.com/@chakra-ui/image/-/image-2.0.15.tgz", - "integrity": "sha512-w2rElXtI3FHXuGpMCsSklus+pO1Pl2LWDwsCGdpBQUvGFbnHfl7MftQgTlaGHeD5OS95Pxva39hKrA2VklKHiQ==", + "version": "2.0.16", + "resolved": "https://registry.npmmirror.com/@chakra-ui/image/-/image-2.0.16.tgz", + "integrity": "sha512-iFypk1slgP3OK7VIPOtkB0UuiqVxNalgA59yoRM43xLIeZAEZpKngUVno4A2kFS61yKN0eIY4hXD3Xjm+25EJA==", "requires": { "@chakra-ui/react-use-safe-layout-effect": "2.0.5", "@chakra-ui/shared-utils": "2.0.5" } }, "@chakra-ui/input": { - "version": "2.0.21", - "resolved": "https://registry.npmmirror.com/@chakra-ui/input/-/input-2.0.21.tgz", - "integrity": "sha512-AIWjjg6MgcOtlvKmVoZfPPfgF+sBSWL3Zq2HSCAMvS6h7jfxz/Xv0UTFGPk5F4Wt0YHT7qMySg0Jsm0b78HZJg==", + "version": "2.0.22", + "resolved": "https://registry.npmmirror.com/@chakra-ui/input/-/input-2.0.22.tgz", + "integrity": "sha512-dCIC0/Q7mjZf17YqgoQsnXn0bus6vgriTRn8VmxOc+WcVl+KBSTBWujGrS5yu85WIFQ0aeqQvziDnDQybPqAbA==", "requires": { "@chakra-ui/form-control": "2.0.18", - "@chakra-ui/object-utils": "2.0.8", + "@chakra-ui/object-utils": "2.1.0", "@chakra-ui/react-children-utils": "2.0.6", "@chakra-ui/react-context": "2.0.8", "@chakra-ui/shared-utils": "2.0.5" } }, "@chakra-ui/layout": { - "version": "2.1.18", - "resolved": "https://registry.npmmirror.com/@chakra-ui/layout/-/layout-2.1.18.tgz", - "integrity": "sha512-F4Gh2e+DGdaWdWT5NZduIFD9NM7Bnuh8sXARFHWPvIu7yvAwZ3ddqC9GK4F3qUngdmkJxDLWQqRSwSh96Lxbhw==", + "version": "2.1.19", + "resolved": "https://registry.npmmirror.com/@chakra-ui/layout/-/layout-2.1.19.tgz", + "integrity": "sha512-g7xMVKbQFCODwKCkEF4/OmdPsr/fAavWUV+DGc1ZWVPdroUlg1FGTpK9bOTwkC/gnko7cMClILA+BIPR3Ylu9Q==", "requires": { "@chakra-ui/breakpoint-utils": "2.0.8", "@chakra-ui/icon": "3.0.16", - "@chakra-ui/object-utils": "2.0.8", + "@chakra-ui/object-utils": "2.1.0", "@chakra-ui/react-children-utils": "2.0.6", "@chakra-ui/react-context": "2.0.8", "@chakra-ui/shared-utils": "2.0.5" @@ -11634,22 +11643,22 @@ } }, "@chakra-ui/menu": { - "version": "2.1.12", - "resolved": "https://registry.npmmirror.com/@chakra-ui/menu/-/menu-2.1.12.tgz", - "integrity": "sha512-ylNK1VJlr/3/EGg9dLPZ87cBJJjeiYXeU/gOAphsKXMnByrXWhbp4YVnyyyha2KZ0zEw0aPU4nCZ+A69aT9wrg==", + "version": "2.1.14", + "resolved": "https://registry.npmmirror.com/@chakra-ui/menu/-/menu-2.1.14.tgz", + "integrity": "sha512-z4YzlY/ub1hr4Ee2zCnZDs4t43048yLTf5GhEVYDO+SI92WlOfHlP9gYEzR+uj/CiRZglVFwUDKb3UmFtmKPyg==", "requires": { "@chakra-ui/clickable": "2.0.14", "@chakra-ui/descendant": "3.0.14", "@chakra-ui/lazy-utils": "2.0.5", - "@chakra-ui/popper": "3.0.13", + "@chakra-ui/popper": "3.0.14", "@chakra-ui/react-children-utils": "2.0.6", "@chakra-ui/react-context": "2.0.8", "@chakra-ui/react-use-animation-state": "2.0.8", "@chakra-ui/react-use-controllable-state": "2.0.8", "@chakra-ui/react-use-disclosure": "2.0.8", - "@chakra-ui/react-use-focus-effect": "2.0.9", + "@chakra-ui/react-use-focus-effect": "2.0.10", "@chakra-ui/react-use-merge-refs": "2.0.7", - "@chakra-ui/react-use-outside-click": "2.0.7", + "@chakra-ui/react-use-outside-click": "2.1.0", "@chakra-ui/react-use-update-effect": "2.0.7", "@chakra-ui/shared-utils": "2.0.5", "@chakra-ui/transition": "2.0.16" @@ -11697,9 +11706,9 @@ "integrity": "sha512-yOGxBjXNvLTBvQyhMDqGU0Oj26s91mbAlqKHiuw737AXHt0aPllOthVUqQMeaYLwLCjGMg0jtI7JReRzyi94Dg==" }, "@chakra-ui/object-utils": { - "version": "2.0.8", - "resolved": "https://registry.npmmirror.com/@chakra-ui/object-utils/-/object-utils-2.0.8.tgz", - "integrity": "sha512-2upjT2JgRuiupdrtBWklKBS6tqeGMA77Nh6Q0JaoQuH/8yq+15CGckqn3IUWkWoGI0Fg3bK9LDlbbD+9DLw95Q==" + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@chakra-ui/object-utils/-/object-utils-2.1.0.tgz", + "integrity": "sha512-tgIZOgLHaoti5PYGPTwK3t/cqtcycW0owaiOXoZOcpwwX/vlVb+H1jFsQyWiiwQVPt9RkoSLtxzXamx+aHH+bQ==" }, "@chakra-ui/pin-input": { "version": "2.0.20", @@ -11715,27 +11724,27 @@ } }, "@chakra-ui/popover": { - "version": "2.1.9", - "resolved": "https://registry.npmmirror.com/@chakra-ui/popover/-/popover-2.1.9.tgz", - "integrity": "sha512-OMJ12VVs9N32tFaZSOqikkKPtwAVwXYsES/D1pff/amBrE3ngCrpxJSIp4uvTdORfIYDojJqrR52ZplDKS9hRQ==", + "version": "2.1.11", + "resolved": "https://registry.npmmirror.com/@chakra-ui/popover/-/popover-2.1.11.tgz", + "integrity": "sha512-ntFMKojU+ZIofwSw5IJ+Ur8pN5o+5kf/Fx5r5tCjFZd0DSkrEeJw9i00/UWJ6kYZb+zlpswxriv0FmxBlAF66w==", "requires": { "@chakra-ui/close-button": "2.0.17", "@chakra-ui/lazy-utils": "2.0.5", - "@chakra-ui/popper": "3.0.13", + "@chakra-ui/popper": "3.0.14", "@chakra-ui/react-context": "2.0.8", "@chakra-ui/react-types": "2.0.7", "@chakra-ui/react-use-animation-state": "2.0.8", "@chakra-ui/react-use-disclosure": "2.0.8", - "@chakra-ui/react-use-focus-effect": "2.0.9", + "@chakra-ui/react-use-focus-effect": "2.0.10", "@chakra-ui/react-use-focus-on-pointer-down": "2.0.6", "@chakra-ui/react-use-merge-refs": "2.0.7", "@chakra-ui/shared-utils": "2.0.5" } }, "@chakra-ui/popper": { - "version": "3.0.13", - "resolved": "https://registry.npmmirror.com/@chakra-ui/popper/-/popper-3.0.13.tgz", - "integrity": "sha512-FwtmYz80Ju8oK3Z1HQfisUE7JIMmDsCQsRBu6XuJ3TFQnBHit73yjZmxKjuRJ4JgyT4WBnZoTF3ATbRKSagBeg==", + "version": "3.0.14", + "resolved": "https://registry.npmmirror.com/@chakra-ui/popper/-/popper-3.0.14.tgz", + "integrity": "sha512-RDMmmSfjsmHJbVn2agDyoJpTbQK33fxx//njwJdeyM0zTG/3/4xjI/Cxru3acJ2Y+1jFGmPqhO81stFjnbtfIw==", "requires": { "@chakra-ui/react-types": "2.0.7", "@chakra-ui/react-use-merge-refs": "2.0.7", @@ -11760,14 +11769,14 @@ } }, "@chakra-ui/provider": { - "version": "2.2.2", - "resolved": "https://registry.npmmirror.com/@chakra-ui/provider/-/provider-2.2.2.tgz", - "integrity": "sha512-UVwnIDnAWq1aKroN5AF+OpNpUqLVeIUk7tKvX3z4CY9FsPFFi6LTEhRHdhpwaU1Tau3Tf9agEu5URegpY7S8BA==", + "version": "2.2.4", + "resolved": "https://registry.npmmirror.com/@chakra-ui/provider/-/provider-2.2.4.tgz", + "integrity": "sha512-vz/WMEWhwoITCAkennRNYCeQHsJ6YwB/UjVaAK+61jWY42J7uCsRZ+3nB5rDjQ4m+aqPfTUPof8KLJBrtYrJbw==", "requires": { "@chakra-ui/css-reset": "2.1.1", "@chakra-ui/portal": "2.0.16", "@chakra-ui/react-env": "3.0.0", - "@chakra-ui/system": "2.5.5", + "@chakra-ui/system": "2.5.7", "@chakra-ui/utils": "2.0.15" } }, @@ -11785,58 +11794,59 @@ } }, "@chakra-ui/react": { - "version": "2.5.5", - "resolved": "https://registry.npmmirror.com/@chakra-ui/react/-/react-2.5.5.tgz", - "integrity": "sha512-aBVMUtdWv2MrptD/tKSqICPsuJ+I+jvauegffO1qPUDlK3RrXIDeOHkLGWohgXNcjY5bGVWguFEzJm97//0ooQ==", + "version": "2.6.1", + "resolved": "https://registry.npmmirror.com/@chakra-ui/react/-/react-2.6.1.tgz", + "integrity": "sha512-Lt8c8pLPTz59xxdSuL2FlE7le9MXx4zgjr60UnEc3yjAMjXNTqUAoWHyT4Zn1elCGUPWOedS3rMvp4KTshT+5w==", "requires": { "@chakra-ui/accordion": "2.1.11", "@chakra-ui/alert": "2.1.0", - "@chakra-ui/avatar": "2.2.8", + "@chakra-ui/avatar": "2.2.10", "@chakra-ui/breadcrumb": "2.1.5", "@chakra-ui/button": "2.0.18", "@chakra-ui/card": "2.1.6", - "@chakra-ui/checkbox": "2.2.14", + "@chakra-ui/checkbox": "2.2.15", "@chakra-ui/close-button": "2.0.17", "@chakra-ui/control-box": "2.0.13", "@chakra-ui/counter": "2.0.14", "@chakra-ui/css-reset": "2.1.1", - "@chakra-ui/editable": "2.0.21", + "@chakra-ui/editable": "3.0.0", "@chakra-ui/focus-lock": "2.0.16", "@chakra-ui/form-control": "2.0.18", - "@chakra-ui/hooks": "2.1.6", + "@chakra-ui/hooks": "2.2.0", "@chakra-ui/icon": "3.0.16", - "@chakra-ui/image": "2.0.15", - "@chakra-ui/input": "2.0.21", - "@chakra-ui/layout": "2.1.18", + "@chakra-ui/image": "2.0.16", + "@chakra-ui/input": "2.0.22", + "@chakra-ui/layout": "2.1.19", "@chakra-ui/live-region": "2.0.13", "@chakra-ui/media-query": "3.2.12", - "@chakra-ui/menu": "2.1.12", + "@chakra-ui/menu": "2.1.14", "@chakra-ui/modal": "2.2.11", "@chakra-ui/number-input": "2.0.19", "@chakra-ui/pin-input": "2.0.20", - "@chakra-ui/popover": "2.1.9", - "@chakra-ui/popper": "3.0.13", + "@chakra-ui/popover": "2.1.11", + "@chakra-ui/popper": "3.0.14", "@chakra-ui/portal": "2.0.16", "@chakra-ui/progress": "2.1.6", - "@chakra-ui/provider": "2.2.2", + "@chakra-ui/provider": "2.2.4", "@chakra-ui/radio": "2.0.22", "@chakra-ui/react-env": "3.0.0", "@chakra-ui/select": "2.0.19", "@chakra-ui/skeleton": "2.0.24", - "@chakra-ui/slider": "2.0.23", + "@chakra-ui/slider": "2.0.24", "@chakra-ui/spinner": "2.0.13", "@chakra-ui/stat": "2.0.18", - "@chakra-ui/styled-system": "2.8.0", - "@chakra-ui/switch": "2.0.26", - "@chakra-ui/system": "2.5.5", + "@chakra-ui/stepper": "2.2.0", + "@chakra-ui/styled-system": "2.9.0", + "@chakra-ui/switch": "2.0.27", + "@chakra-ui/system": "2.5.7", "@chakra-ui/table": "2.0.17", "@chakra-ui/tabs": "2.1.9", "@chakra-ui/tag": "3.0.0", "@chakra-ui/textarea": "2.0.19", - "@chakra-ui/theme": "3.0.1", - "@chakra-ui/theme-utils": "2.0.15", - "@chakra-ui/toast": "6.1.1", - "@chakra-ui/tooltip": "2.2.7", + "@chakra-ui/theme": "3.1.1", + "@chakra-ui/theme-utils": "2.0.17", + "@chakra-ui/toast": "6.1.3", + "@chakra-ui/tooltip": "2.2.8", "@chakra-ui/transition": "2.0.16", "@chakra-ui/utils": "2.0.15", "@chakra-ui/visually-hidden": "2.0.15" @@ -11908,9 +11918,9 @@ } }, "@chakra-ui/react-use-focus-effect": { - "version": "2.0.9", - "resolved": "https://registry.npmmirror.com/@chakra-ui/react-use-focus-effect/-/react-use-focus-effect-2.0.9.tgz", - "integrity": "sha512-20nfNkpbVwyb41q9wxp8c4jmVp6TUGAPE3uFTDpiGcIOyPW5aecQtPmTXPMJH+2aa8Nu1wyoT1btxO+UYiQM3g==", + "version": "2.0.10", + "resolved": "https://registry.npmmirror.com/@chakra-ui/react-use-focus-effect/-/react-use-focus-effect-2.0.10.tgz", + "integrity": "sha512-HswfpzjP8gCQM3/fbeR+8wrYqt0B3ChnVTqssnYXqp9Fa/5Y1Kx1ZADUWW93zMs5SF7hIEuNt8uKeh1/3HTcqQ==", "requires": { "@chakra-ui/dom-utils": "2.0.6", "@chakra-ui/react-use-event-listener": "2.0.7", @@ -11947,9 +11957,9 @@ "requires": {} }, "@chakra-ui/react-use-outside-click": { - "version": "2.0.7", - "resolved": "https://registry.npmmirror.com/@chakra-ui/react-use-outside-click/-/react-use-outside-click-2.0.7.tgz", - "integrity": "sha512-MsAuGLkwYNxNJ5rb8lYNvXApXxYMnJ3MzqBpQj1kh5qP/+JSla9XMjE/P94ub4fSEttmNSqs43SmPPrmPuihsQ==", + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@chakra-ui/react-use-outside-click/-/react-use-outside-click-2.1.0.tgz", + "integrity": "sha512-JanCo4QtWvMl9ZZUpKJKV62RlMWDFdPCE0Q64a7eWTOQgWWcpyBW7TOYRunQTqrK30FqkYFJCOlAWOtn+6Rw7A==", "requires": { "@chakra-ui/react-use-callback-ref": "2.0.7" } @@ -12031,9 +12041,9 @@ } }, "@chakra-ui/slider": { - "version": "2.0.23", - "resolved": "https://registry.npmmirror.com/@chakra-ui/slider/-/slider-2.0.23.tgz", - "integrity": "sha512-/eyRUXLla+ZdBUPXpakE3SAS2JS8mIJR6qcUYiPVKSpRAi6tMyYeQijAXn2QC1AUVd2JrG8Pz+1Jy7Po3uA7cA==", + "version": "2.0.24", + "resolved": "https://registry.npmmirror.com/@chakra-ui/slider/-/slider-2.0.24.tgz", + "integrity": "sha512-o3hOaIiTzPMG8yf+HYWbrTmhxABicDViVOvOajRSXDodbZSCk1rZy1nmUeahjVtfVUB1IyJoNcXdn76IqJmhdg==", "requires": { "@chakra-ui/number-utils": "2.0.7", "@chakra-ui/react-context": "2.0.8", @@ -12065,10 +12075,20 @@ "@chakra-ui/shared-utils": "2.0.5" } }, + "@chakra-ui/stepper": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@chakra-ui/stepper/-/stepper-2.2.0.tgz", + "integrity": "sha512-8ZLxV39oghSVtOUGK8dX8Z6sWVSQiKVmsK4c3OQDa8y2TvxP0VtFD0Z5U1xJlOjQMryZRWhGj9JBc3iQLukuGg==", + "requires": { + "@chakra-ui/icon": "3.0.16", + "@chakra-ui/react-context": "2.0.8", + "@chakra-ui/shared-utils": "2.0.5" + } + }, "@chakra-ui/styled-system": { - "version": "2.8.0", - "resolved": "https://registry.npmmirror.com/@chakra-ui/styled-system/-/styled-system-2.8.0.tgz", - "integrity": "sha512-bmRv/8ACJGGKGx84U1npiUddwdNifJ+/ETklGwooS5APM0ymwUtBYZpFxjYNJrqvVYpg3mVY6HhMyBVptLS7iA==", + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/@chakra-ui/styled-system/-/styled-system-2.9.0.tgz", + "integrity": "sha512-rToN30eOezrTZ5qBHmWqEwsYPenHtc3WU6ODAfMUwNnmCJQiu2erRGv8JwIjmRJnKSOEnNKccI2UXe2EwI6+JA==", "requires": { "@chakra-ui/shared-utils": "2.0.5", "csstype": "^3.0.11", @@ -12076,24 +12096,24 @@ } }, "@chakra-ui/switch": { - "version": "2.0.26", - "resolved": "https://registry.npmmirror.com/@chakra-ui/switch/-/switch-2.0.26.tgz", - "integrity": "sha512-x62lF6VazSZJQuVxosChVR6+0lIJe8Pxgkl/C9vxjhp2yVYb3mew5tcX/sDOu0dYZy8ro/9hMfGkdN4r9xEU8A==", + "version": "2.0.27", + "resolved": "https://registry.npmmirror.com/@chakra-ui/switch/-/switch-2.0.27.tgz", + "integrity": "sha512-z76y2fxwMlvRBrC5W8xsZvo3gP+zAEbT3Nqy5P8uh/IPd5OvDsGeac90t5cgnQTyxMOpznUNNK+1eUZqtLxWnQ==", "requires": { - "@chakra-ui/checkbox": "2.2.14", + "@chakra-ui/checkbox": "2.2.15", "@chakra-ui/shared-utils": "2.0.5" } }, "@chakra-ui/system": { - "version": "2.5.5", - "resolved": "https://registry.npmmirror.com/@chakra-ui/system/-/system-2.5.5.tgz", - "integrity": "sha512-52BIp/Zyvefgxn5RTByfkTeG4J+y81LWEjWm8jCaRFsLVm8IFgqIrngtcq4I7gD5n/UKbneHlb4eLHo4uc5yDQ==", + "version": "2.5.7", + "resolved": "https://registry.npmmirror.com/@chakra-ui/system/-/system-2.5.7.tgz", + "integrity": "sha512-yB6en7YdJPxKvKY2jJROVwkBE2CLFmHS4ZDx27VdYs0Fa4kGiyDFhJAfnMtLBNDVsTy1NhUHL9aqR63u56QqFg==", "requires": { "@chakra-ui/color-mode": "2.1.12", - "@chakra-ui/object-utils": "2.0.8", + "@chakra-ui/object-utils": "2.1.0", "@chakra-ui/react-utils": "2.0.12", - "@chakra-ui/styled-system": "2.8.0", - "@chakra-ui/theme-utils": "2.0.15", + "@chakra-ui/styled-system": "2.9.0", + "@chakra-ui/theme-utils": "2.0.17", "@chakra-ui/utils": "2.0.15", "react-fast-compare": "3.2.1" } @@ -12142,9 +12162,9 @@ } }, "@chakra-ui/theme": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/@chakra-ui/theme/-/theme-3.0.1.tgz", - "integrity": "sha512-92kDm/Ux/51uJqhRKevQo/O/rdwucDYcpHg2QuwzdAxISCeYvgtl2TtgOOl5EnqEP0j3IEAvZHZUlv8TTbawaw==", + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@chakra-ui/theme/-/theme-3.1.1.tgz", + "integrity": "sha512-VHcG0CPLd9tgvWnajpAGqrAYhx4HwgfK0E9VOrdwa/3bN+AgY/0EAAXzfe0Q0W2MBWzSgaYqZcQ5cDRpYbiYPA==", "requires": { "@chakra-ui/anatomy": "2.1.2", "@chakra-ui/shared-utils": "2.0.5", @@ -12162,20 +12182,20 @@ } }, "@chakra-ui/theme-utils": { - "version": "2.0.15", - "resolved": "https://registry.npmmirror.com/@chakra-ui/theme-utils/-/theme-utils-2.0.15.tgz", - "integrity": "sha512-UuxtEgE7gwMTGDXtUpTOI7F5X0iHB9ekEOG5PWPn2wWBL7rlk2JtPI7UP5Um5Yg6vvBfXYGK1ySahxqsgf+87g==", + "version": "2.0.17", + "resolved": "https://registry.npmmirror.com/@chakra-ui/theme-utils/-/theme-utils-2.0.17.tgz", + "integrity": "sha512-aUaVLFIU1Rs8m+5WVOUvqHKapOX8nSgUVGaeRWS4odxBM95dG4j15f4L88LEMw4D4+WWd0CSAS139OnRgj1rCw==", "requires": { "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/styled-system": "2.8.0", - "@chakra-ui/theme": "3.0.1", + "@chakra-ui/styled-system": "2.9.0", + "@chakra-ui/theme": "3.1.1", "lodash.mergewith": "4.6.2" } }, "@chakra-ui/toast": { - "version": "6.1.1", - "resolved": "https://registry.npmmirror.com/@chakra-ui/toast/-/toast-6.1.1.tgz", - "integrity": "sha512-JtjIKkPVjEu8okGGCipCxNVgK/15h5AicTATZ6RbG2MsHmr4GfKG3fUCvpbuZseArqmLqGLQZQJjVE9vJzaSkQ==", + "version": "6.1.3", + "resolved": "https://registry.npmmirror.com/@chakra-ui/toast/-/toast-6.1.3.tgz", + "integrity": "sha512-dsg/Sdkuq+SCwdOeyzrnBO1ecDA7VKfLFjUtj9QBc/SFEN8r+FQrygy79TNo+QWr7zdjI8icbl8nsp59lpb8ag==", "requires": { "@chakra-ui/alert": "2.1.0", "@chakra-ui/close-button": "2.0.17", @@ -12184,16 +12204,16 @@ "@chakra-ui/react-use-timeout": "2.0.5", "@chakra-ui/react-use-update-effect": "2.0.7", "@chakra-ui/shared-utils": "2.0.5", - "@chakra-ui/styled-system": "2.8.0", - "@chakra-ui/theme": "3.0.1" + "@chakra-ui/styled-system": "2.9.0", + "@chakra-ui/theme": "3.1.1" } }, "@chakra-ui/tooltip": { - "version": "2.2.7", - "resolved": "https://registry.npmmirror.com/@chakra-ui/tooltip/-/tooltip-2.2.7.tgz", - "integrity": "sha512-ImUJ6NnVqARaYqpgtO+kzucDRmxo8AF3jMjARw0bx2LxUkKwgRCOEaaRK5p5dHc0Kr6t5/XqjDeUNa19/sLauA==", + "version": "2.2.8", + "resolved": "https://registry.npmmirror.com/@chakra-ui/tooltip/-/tooltip-2.2.8.tgz", + "integrity": "sha512-AqtrCkalADrqqd1SgII4n8F0dDABxqxL3e8uj3yC3HDzT3BU/0NSwSQRA2bp9eoJHk07ZMs9kyzvkkBLc0pr2A==", "requires": { - "@chakra-ui/popper": "3.0.13", + "@chakra-ui/popper": "3.0.14", "@chakra-ui/portal": "2.0.16", "@chakra-ui/react-types": "2.0.7", "@chakra-ui/react-use-disclosure": "2.0.8", @@ -12228,118 +12248,118 @@ "requires": {} }, "@emotion/babel-plugin": { - "version": "11.10.6", - "resolved": "https://registry.npmmirror.com/@emotion/babel-plugin/-/babel-plugin-11.10.6.tgz", - "integrity": "sha512-p2dAqtVrkhSa7xz1u/m9eHYdLi+en8NowrmXeF/dKtJpU8lCWli8RUAati7NcSl0afsBott48pdnANuD0wh9QQ==", + "version": "11.11.0", + "resolved": "https://registry.npmmirror.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", + "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", "requires": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.0", - "@emotion/memoize": "^0.8.0", - "@emotion/serialize": "^1.1.1", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/serialize": "^1.1.2", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", - "stylis": "4.1.3" + "stylis": "4.2.0" } }, "@emotion/cache": { - "version": "11.10.5", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.10.5.tgz", - "integrity": "sha512-dGYHWyzTdmK+f2+EnIGBpkz1lKc4Zbj2KHd4cX3Wi8/OWr5pKslNjc3yABKH4adRGCvSX4VDC0i04mrrq0aiRA==", + "version": "11.11.0", + "resolved": "https://registry.npmmirror.com/@emotion/cache/-/cache-11.11.0.tgz", + "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", "requires": { - "@emotion/memoize": "^0.8.0", - "@emotion/sheet": "^1.2.1", - "@emotion/utils": "^1.2.0", - "@emotion/weak-memoize": "^0.3.0", - "stylis": "4.1.3" + "@emotion/memoize": "^0.8.1", + "@emotion/sheet": "^1.2.2", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "stylis": "4.2.0" } }, "@emotion/hash": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.0.tgz", - "integrity": "sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ==" + "version": "0.9.1", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.9.1.tgz", + "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" }, "@emotion/is-prop-valid": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.0.tgz", - "integrity": "sha512-3aDpDprjM0AwaxGE09bOPkNxHpBd+kA6jty3RnaEXdweX1DF1U3VQpPYb0g1IStAuK7SVQ1cy+bNBBKp4W3Fjg==", + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", + "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", "requires": { - "@emotion/memoize": "^0.8.0" + "@emotion/memoize": "^0.8.1" } }, "@emotion/memoize": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.0.tgz", - "integrity": "sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA==" + "version": "0.8.1", + "resolved": "https://registry.npmmirror.com/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" }, "@emotion/react": { - "version": "11.10.6", - "resolved": "https://registry.npmmirror.com/@emotion/react/-/react-11.10.6.tgz", - "integrity": "sha512-6HT8jBmcSkfzO7mc+N1L9uwvOnlcGoix8Zn7srt+9ga0MjREo6lRpuVX0kzo6Jp6oTqDhREOFsygN6Ew4fEQbw==", + "version": "11.11.0", + "resolved": "https://registry.npmmirror.com/@emotion/react/-/react-11.11.0.tgz", + "integrity": "sha512-ZSK3ZJsNkwfjT3JpDAWJZlrGD81Z3ytNDsxw1LKq1o+xkmO5pnWfr6gmCC8gHEFf3nSSX/09YrG67jybNPxSUw==", "requires": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.10.6", - "@emotion/cache": "^11.10.5", - "@emotion/serialize": "^1.1.1", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", - "@emotion/utils": "^1.2.0", - "@emotion/weak-memoize": "^0.3.0", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/cache": "^11.11.0", + "@emotion/serialize": "^1.1.2", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", "hoist-non-react-statics": "^3.3.1" } }, "@emotion/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-Zl/0LFggN7+L1liljxXdsVSVlg6E/Z/olVWpfxUTxOAmi8NU7YoeWeLfi1RmnB2TATHoaWwIBRoL+FvAJiTUQA==", + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@emotion/serialize/-/serialize-1.1.2.tgz", + "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==", "requires": { - "@emotion/hash": "^0.9.0", - "@emotion/memoize": "^0.8.0", - "@emotion/unitless": "^0.8.0", - "@emotion/utils": "^1.2.0", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/unitless": "^0.8.1", + "@emotion/utils": "^1.2.1", "csstype": "^3.0.2" } }, "@emotion/sheet": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.1.tgz", - "integrity": "sha512-zxRBwl93sHMsOj4zs+OslQKg/uhF38MB+OMKoCrVuS0nyTkqnau+BM3WGEoOptg9Oz45T/aIGs1qbVAsEFo3nA==" + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/@emotion/sheet/-/sheet-1.2.2.tgz", + "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" }, "@emotion/styled": { - "version": "11.10.6", - "resolved": "https://registry.npmmirror.com/@emotion/styled/-/styled-11.10.6.tgz", - "integrity": "sha512-OXtBzOmDSJo5Q0AFemHCfl+bUueT8BIcPSxu0EGTpGk6DmI5dnhSzQANm1e1ze0YZL7TDyAyy6s/b/zmGOS3Og==", + "version": "11.11.0", + "resolved": "https://registry.npmmirror.com/@emotion/styled/-/styled-11.11.0.tgz", + "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", "requires": { "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.10.6", - "@emotion/is-prop-valid": "^1.2.0", - "@emotion/serialize": "^1.1.1", - "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", - "@emotion/utils": "^1.2.0" + "@emotion/babel-plugin": "^11.11.0", + "@emotion/is-prop-valid": "^1.2.1", + "@emotion/serialize": "^1.1.2", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1" } }, "@emotion/unitless": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.0.tgz", - "integrity": "sha512-VINS5vEYAscRl2ZUDiT3uMPlrFQupiKgHz5AA4bCH1miKBg4qtwkim1qPmJj/4WG6TreYMY111rEFsjupcOKHw==" + "version": "0.8.1", + "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" }, "@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.0.tgz", - "integrity": "sha512-1eEgUGmkaljiBnRMTdksDV1W4kUnmwgp7X9G8B++9GYwl1lUdqSndSriIrTJ0N7LQaoauY9JJ2yhiOYK5+NI4A==", + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", + "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", "requires": {} }, "@emotion/utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.0.tgz", - "integrity": "sha512-sn3WH53Kzpw8oQ5mgMmIzzyAaH2ZqFEbozVVBSYp538E06OSE6ytOp7pRAjNQR+Q/orwqdQYJSe2m3hCOeznkw==" + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@emotion/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" }, "@emotion/weak-memoize": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz", - "integrity": "sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg==" + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", + "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" }, "@esbuild/android-arm": { "version": "0.17.12", @@ -12511,14 +12531,14 @@ "dev": true }, "@eslint/eslintrc": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.0.2.tgz", - "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==", + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", + "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", "dev": true, "requires": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.5.1", + "espree": "^9.5.2", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -12545,9 +12565,9 @@ } }, "@eslint/js": { - "version": "8.37.0", - "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.37.0.tgz", - "integrity": "sha512-x5vzdtOOGgFVDCUs81QRB2+liax8rFg3+7hqM+QhBG0/G3F1ZsoYl97UrqgHgQ9KKT7G6c4V+aTUCgu/n22v1A==", + "version": "8.41.0", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.41.0.tgz", + "integrity": "sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA==", "dev": true }, "@floating-ui/core": { @@ -12707,9 +12727,9 @@ "integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==" }, "@remix-run/router": { - "version": "1.5.0", - "resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.5.0.tgz", - "integrity": "sha512-bkUDCp8o1MvFO+qxkODcbhSqRa6P2GXgrGZVpt0dCXNW2HCSCqYI0ZoAqEOSAjRWmmlKcYgFvN4B4S+zo/f8kg==" + "version": "1.6.2", + "resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.6.2.tgz", + "integrity": "sha512-LzqpSrMK/3JBAVBI9u3NWtOhWNw5AMQfrUFYB0+bDHTSw17z++WJLsPsxAuK+oSddsxk4d7F/JcdDPM1M5YAhA==" }, "@rushstack/eslint-patch": { "version": "1.2.0", @@ -12718,104 +12738,104 @@ "dev": true }, "@swc/core": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core/-/core-1.3.35.tgz", - "integrity": "sha512-KmiBin0XSVzJhzX19zTiCqmLslZ40Cl7zqskJcTDeIrRhfgKdiAsxzYUanJgMJIRjYtl9Kcg1V/Ip2o2wL8v3w==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core/-/core-1.3.60.tgz", + "integrity": "sha512-dWfic7sVjnrStzGcMWakHd2XPau8UXGPmFUTkx6xGX+DOVtfAQVzG6ZW7ohw/yNcTqI05w6Ser26XMTMGBgXdA==", "dev": true, "requires": { - "@swc/core-darwin-arm64": "1.3.35", - "@swc/core-darwin-x64": "1.3.35", - "@swc/core-linux-arm-gnueabihf": "1.3.35", - "@swc/core-linux-arm64-gnu": "1.3.35", - "@swc/core-linux-arm64-musl": "1.3.35", - "@swc/core-linux-x64-gnu": "1.3.35", - "@swc/core-linux-x64-musl": "1.3.35", - "@swc/core-win32-arm64-msvc": "1.3.35", - "@swc/core-win32-ia32-msvc": "1.3.35", - "@swc/core-win32-x64-msvc": "1.3.35" + "@swc/core-darwin-arm64": "1.3.60", + "@swc/core-darwin-x64": "1.3.60", + "@swc/core-linux-arm-gnueabihf": "1.3.60", + "@swc/core-linux-arm64-gnu": "1.3.60", + "@swc/core-linux-arm64-musl": "1.3.60", + "@swc/core-linux-x64-gnu": "1.3.60", + "@swc/core-linux-x64-musl": "1.3.60", + "@swc/core-win32-arm64-msvc": "1.3.60", + "@swc/core-win32-ia32-msvc": "1.3.60", + "@swc/core-win32-x64-msvc": "1.3.60" } }, "@swc/core-darwin-arm64": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.35.tgz", - "integrity": "sha512-zQUFkHx4gZpu0uo2IspvPnKsz8bsdXd5bC33xwjtoAI1cpLerDyqo4v2zIahEp+FdKZjyVsLHtfJiQiA1Qka3A==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.60.tgz", + "integrity": "sha512-oCDKWGdSO1WyErduGfiITRDoq7ZBt9PXETlhi8BGKH/wCc/3mfSNI9wXAg3Stn8mrT0lUJtdsnwMI/eZp6dK+A==", "dev": true, "optional": true }, "@swc/core-darwin-x64": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-darwin-x64/-/core-darwin-x64-1.3.35.tgz", - "integrity": "sha512-oOSkSGWtALovaw22lNevKD434OQTPf8X+dVPvPMrJXJpJ34dWDlFWpLntoc+arvKLNZ7LQmTuk8rR1hkrAY7cw==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-darwin-x64/-/core-darwin-x64-1.3.60.tgz", + "integrity": "sha512-pcE/1oUlmN/BkKndOPtViqTkaM5pomagXATo+Muqn4QNMnkSOEVcmF9T3Lr3nB1A7O/fwCew3/aHwZ5B2TZ1tA==", "dev": true, "optional": true }, "@swc/core-linux-arm-gnueabihf": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.35.tgz", - "integrity": "sha512-Yie8k00O6O8BCATS/xeKStquV4OYSskUGRDXBQVDw1FrE23PHaSeHCgg4q6iNZjJzXCOJbaTCKnYoIDn9DMf7A==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.60.tgz", + "integrity": "sha512-Moc+86SWcbPr06PaQYUb0Iwli425F7QgjwTCNEPYA6OYUsjaJhXMaHViW2WdGIXue2+eaQbg31BHQd14jXcoBg==", "dev": true, "optional": true }, "@swc/core-linux-arm64-gnu": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.35.tgz", - "integrity": "sha512-Zlv3WHa/4x2p51HSvjUWXHfSe1Gl2prqImUZJc8NZOlj75BFzVuR0auhQ+LbwvIQ3gaA1LODX9lyS9wXL3yjxA==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.60.tgz", + "integrity": "sha512-pPGZrTgSXBvp6IrXPXz8UJr82AElf8hMuK4rNHmLGDCqrWnRIFLUpiAsc2WCFIgdwqitZNQoM+F2vbceA/bkKg==", "dev": true, "optional": true }, "@swc/core-linux-arm64-musl": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.35.tgz", - "integrity": "sha512-u6tCYsrSyZ8U+4jLMA/O82veBfLy2aUpn51WxQaeH7wqZGy9TGSJXoO8vWxARQ6b72vjsnKDJHP4MD8hFwcctg==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.60.tgz", + "integrity": "sha512-HSFQaVUkjWYNsQeymAQ3IPX3csRQvHe6MFyqPfvCCQ4dFlxPvlS7VvNaLnGG+ZW1ek7Lc+hEX+4NGzZKsxDIHA==", "dev": true, "optional": true }, "@swc/core-linux-x64-gnu": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.35.tgz", - "integrity": "sha512-Dtxf2IbeH7XlNhP1Qt2/MvUPkpEbn7hhGfpSRs4ot8D3Vf5QEX4S/QtC1OsFWuciiYgHAT1Ybjt4xZic9DSkmA==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.60.tgz", + "integrity": "sha512-WJt/X6HHM3/TszckRA7UKMXec3FHYsB9xswQbIYxN4bfTQodu3Rc8bmpHYtFO7ScMLrhY+RljHLK6wclPvaEXw==", "dev": true, "optional": true }, "@swc/core-linux-x64-musl": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.35.tgz", - "integrity": "sha512-4XavNJ60GprjpTiESCu5daJUnmErixPAqDitJSMu4TV32LNIE8G00S9pDLXinDTW1rgcGtQdq1NLkNRmwwovtg==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.60.tgz", + "integrity": "sha512-DGGBqAPUXy/aPMBKokL3osZC9kM97HchiDPuprzwgTMP40YQ3hGCzNJ5jK7sOk9Tc4PEdZ2Igfr9sBHmCrxxQw==", "dev": true, "optional": true }, "@swc/core-win32-arm64-msvc": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.35.tgz", - "integrity": "sha512-dNGfKCUSX2M4qVyaS80Lyos0FkXyHRCvrdQ2Y4Hrg3FVokiuw3yY6fLohpUfQ5ws3n2A39dh7jGDeh34+l0sGA==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.60.tgz", + "integrity": "sha512-wQg/BZPJvp5WpUbsBp7VHjhUh0DfYOPhP6dH67WO9QQ07+DvOk2DR2Bfh0z0ts1k7H/FsAqExWtTDCWMCRJiRQ==", "dev": true, "optional": true }, "@swc/core-win32-ia32-msvc": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.35.tgz", - "integrity": "sha512-ChuPSrDR+JBf7S7dEKPicnG8A3bM0uWPsW2vG+V2wH4iNfNxKVemESHosmYVeEZXqMpomNMvLyeHep1rjRsc0Q==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.60.tgz", + "integrity": "sha512-nqkd0XIVyGbnBwAxP4GIfx6n45/hAPETpmQYpDSGnucOKFJfvGdFGL81GDG1acPCq/oFtR3tIyTbPpKmJ0N6xQ==", "dev": true, "optional": true }, "@swc/core-win32-x64-msvc": { - "version": "1.3.35", - "resolved": "https://registry.npmmirror.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.35.tgz", - "integrity": "sha512-/RvphT4WfuGfIK84Ha0dovdPrKB1bW/mc+dtdmhv2E3EGkNc5FoueNwYmXWRimxnU7X0X7IkcRhyKB4G5DeAmg==", + "version": "1.3.60", + "resolved": "https://registry.npmmirror.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.60.tgz", + "integrity": "sha512-ouw+s22i9PYQpSE7Xc+ZittEyA87jElXABesviSpP+jgHt10sM5KFUpVAeV8DRlxJCXMJJ5AhOdCf4TAtFr+6A==", "dev": true, "optional": true }, "@tanstack/query-core": { - "version": "4.27.0", - "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-4.27.0.tgz", - "integrity": "sha512-sm+QncWaPmM73IPwFlmWSKPqjdTXZeFf/7aEmWh00z7yl2FjqophPt0dE1EHW9P1giMC5rMviv7OUbSDmWzXXA==" + "version": "4.29.7", + "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-4.29.7.tgz", + "integrity": "sha512-GXG4b5hV2Loir+h2G+RXhJdoZhJLnrBWsuLB2r0qBRyhWuXq9w/dWxzvpP89H0UARlH6Mr9DiVj4SMtpkF/aUA==" }, "@tanstack/react-query": { - "version": "4.28.0", - "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-4.28.0.tgz", - "integrity": "sha512-8cGBV5300RHlvYdS4ea+G1JcZIt5CIuprXYFnsWggkmGoC0b5JaqG0fIX3qwDL9PTNkKvG76NGThIWbpXivMrQ==", + "version": "4.29.7", + "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-4.29.7.tgz", + "integrity": "sha512-ijBWEzAIo09fB1yd22slRZzprrZ5zMdWYzBnCg5qiXuFbH78uGN1qtGz8+Ed4MuhaPaYSD+hykn+QEKtQviEtg==", "requires": { - "@tanstack/query-core": "4.27.0", + "@tanstack/query-core": "4.29.7", "use-sync-external-store": "^1.2.0" } }, @@ -12840,9 +12860,9 @@ "dev": true }, "@types/lodash": { - "version": "4.14.192", - "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.14.192.tgz", - "integrity": "sha512-km+Vyn3BYm5ytMO13k9KTp27O75rbQ0NFw+U//g+PX7VZyjCioXaRFisqSIJRECljcTv73G3i6BpglNGHgUQ5A==" + "version": "4.14.194", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.14.194.tgz", + "integrity": "sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==" }, "@types/lodash.mergewith": { "version": "4.6.7", @@ -12853,9 +12873,9 @@ } }, "@types/node": { - "version": "18.15.11", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.15.11.tgz", - "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==", + "version": "20.2.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.2.3.tgz", + "integrity": "sha512-pg9d0yC4rVNWQzX8U7xb4olIOFuuVL9za3bzMT2pu2SU0SNEi66i2qrvhE2qt0HvkhuCaWJu7pLNOt/Pj8BIrw==", "dev": true }, "@types/parse-json": { @@ -12870,9 +12890,9 @@ "devOptional": true }, "@types/react": { - "version": "18.0.31", - "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.0.31.tgz", - "integrity": "sha512-EEG67of7DsvRDU6BLLI0p+k1GojDLz9+lZsnCpCRTa/lOokvyPBvp8S5x+A24hME3yyQuIipcP70KJ6H7Qupww==", + "version": "18.2.7", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.2.7.tgz", + "integrity": "sha512-ojrXpSH2XFCmHm7Jy3q44nXDyN54+EYKP2lBhJ2bqfyPj6cIUW/FZW/Csdia34NQgq7KYcAlHi5184m4X88+yw==", "devOptional": true, "requires": { "@types/prop-types": "*", @@ -12881,9 +12901,9 @@ } }, "@types/react-datepicker": { - "version": "4.10.0", - "resolved": "https://registry.npmmirror.com/@types/react-datepicker/-/react-datepicker-4.10.0.tgz", - "integrity": "sha512-Cq+ks20vBIU6XN67TbkCHu8M7V46Y6vJrKE2n+8q/GfueJyWWTIKeC3Z7cz/d+qxGDq/VCrqA929R0U4lNuztg==", + "version": "4.11.2", + "resolved": "https://registry.npmmirror.com/@types/react-datepicker/-/react-datepicker-4.11.2.tgz", + "integrity": "sha512-ELYyX3lb3K1WltqdlF1hbnaDGgzlF6PIR5T4W38cSEcfrQDIrPE+Ioq5pwRe/KEJ+ihHMjvTVZQkwJx0pWMNHQ==", "dev": true, "requires": { "@popperjs/core": "^2.9.2", @@ -12893,9 +12913,9 @@ } }, "@types/react-dom": { - "version": "18.0.11", - "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.0.11.tgz", - "integrity": "sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==", + "version": "18.2.4", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.2.4.tgz", + "integrity": "sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==", "dev": true, "requires": { "@types/react": "*" @@ -13129,12 +13149,12 @@ } }, "@vitejs/plugin-react-swc": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.2.0.tgz", - "integrity": "sha512-IcBoXL/mcH7JdQr/nfDlDwTdIaH8Rg7LpfQDF4nAht+juHWIuv6WhpKPCSfY4+zztAaB07qdBoFz1XCZsgo3pQ==", + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.3.1.tgz", + "integrity": "sha512-ZoYjGxMniXP7X+5ry/W1tpY7w0OeLUEsBF5RHFPmAhpgwwNWie8OF4056MRXRi9QgvYYoZPDzdOXGK3wlCoTfQ==", "dev": true, "requires": { - "@swc/core": "^1.3.35" + "@swc/core": "^1.3.56" } }, "@zag-js/element-size": { @@ -13344,9 +13364,9 @@ "dev": true }, "axios": { - "version": "1.3.4", - "resolved": "https://registry.npmmirror.com/axios/-/axios-1.3.4.tgz", - "integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==", + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", "requires": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -13509,7 +13529,7 @@ }, "camelcase-css": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", "dev": true }, @@ -13729,11 +13749,11 @@ } }, "cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "version": "3.1.6", + "resolved": "https://registry.npmmirror.com/cross-fetch/-/cross-fetch-3.1.6.tgz", + "integrity": "sha512-riRvo06crlE8HiqOwIpQhxwdOk4fOeR7FVM/wXoxchFEqMNUjvbs3bfo4OTgMEMHzppd4DxFBDbyySj8Cv781g==", "requires": { - "node-fetch": "2.6.7" + "node-fetch": "^2.6.11" } }, "cross-spawn": { @@ -13757,7 +13777,7 @@ }, "cssesc": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true }, @@ -13984,15 +14004,15 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, "eslint": { - "version": "8.37.0", - "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.37.0.tgz", - "integrity": "sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==", + "version": "8.41.0", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.41.0.tgz", + "integrity": "sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.2", - "@eslint/js": "8.37.0", + "@eslint/eslintrc": "^2.0.3", + "@eslint/js": "8.41.0", "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -14002,9 +14022,9 @@ "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-visitor-keys": "^3.4.0", - "espree": "^9.5.1", + "eslint-scope": "^7.2.0", + "eslint-visitor-keys": "^3.4.1", + "espree": "^9.5.2", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -14012,13 +14032,12 @@ "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", - "grapheme-splitter": "^1.0.4", + "graphemer": "^1.4.0", "ignore": "^5.2.0", "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", - "js-sdsl": "^4.1.4", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", @@ -14316,9 +14335,9 @@ } }, "eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.0.tgz", + "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", "dev": true, "requires": { "esrecurse": "^4.3.0", @@ -14343,20 +14362,20 @@ } }, "eslint-visitor-keys": { - "version": "3.4.0", - "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz", - "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==", + "version": "3.4.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", + "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", "dev": true }, "espree": { - "version": "9.5.1", - "resolved": "https://registry.npmmirror.com/espree/-/espree-9.5.1.tgz", - "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==", + "version": "9.5.2", + "resolved": "https://registry.npmmirror.com/espree/-/espree-9.5.2.tgz", + "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", "dev": true, "requires": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.0" + "eslint-visitor-keys": "^3.4.1" } }, "esquery": { @@ -14548,9 +14567,9 @@ "dev": true }, "framer-motion": { - "version": "10.10.0", - "resolved": "https://registry.npmmirror.com/framer-motion/-/framer-motion-10.10.0.tgz", - "integrity": "sha512-eCsyOcJimIRbx9KOzBTO3j9u1rF/H8/o/ybizYqdrzHkEeHx9L2NcEfGWfV0OHTc1JV17ECVzuZpomupEJ4+dw==", + "version": "10.12.16", + "resolved": "https://registry.npmmirror.com/framer-motion/-/framer-motion-10.12.16.tgz", + "integrity": "sha512-w/SfWEIWJkYSgRHYBmln7EhcNo31ao8Xexol8lGXf1pR/tlnBtf1HcxoUmEiEh6pacB4/geku5ami53AAQWHMQ==", "requires": { "@emotion/is-prop-valid": "^0.8.2", "tslib": "^2.4.0" @@ -14713,10 +14732,10 @@ "get-intrinsic": "^1.1.3" } }, - "grapheme-splitter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, "has": { @@ -14819,9 +14838,9 @@ "dev": true }, "i18next": { - "version": "22.4.13", - "resolved": "https://registry.npmmirror.com/i18next/-/i18next-22.4.13.tgz", - "integrity": "sha512-GX7flMHRRqQA0I1yGLmaZ4Hwt1JfLqagk8QPDPZsqekbKtXsuIngSVWM/s3SLgNkrEXjA+0sMGNuOEkkmyqmWg==", + "version": "22.5.0", + "resolved": "https://registry.npmmirror.com/i18next/-/i18next-22.5.0.tgz", + "integrity": "sha512-sqWuJFj+wJAKQP2qBQ+b7STzxZNUmnSxrehBCCj9vDOW9RDYPfqCaK1Hbh2frNYQuPziz6O2CGoJPwtzY3vAYA==", "requires": { "@babel/runtime": "^7.20.6" } @@ -14835,11 +14854,11 @@ } }, "i18next-http-backend": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/i18next-http-backend/-/i18next-http-backend-2.2.0.tgz", - "integrity": "sha512-Z4sM7R6tzdLknSPER9GisEBxKPg5FkI07UrQniuroZmS15PHQrcCPLyuGKj8SS68tf+O2aEDYSUnmy1TZqZSbw==", + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/i18next-http-backend/-/i18next-http-backend-2.2.1.tgz", + "integrity": "sha512-ZXIdn/8NJIBJ0X4hzXfc3STYxKrCKh1fYjji9HPyIpEJfvTvy8/ZlTl8RuTizzCPj2ZcWrfaecyOMKs6bQ7u5A==", "requires": { - "cross-fetch": "3.1.5" + "cross-fetch": "3.1.6" } }, "ieee754": { @@ -14854,9 +14873,9 @@ "dev": true }, "immer": { - "version": "9.0.21", - "resolved": "https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz", - "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==" + "version": "10.0.2", + "resolved": "https://registry.npmmirror.com/immer/-/immer-10.0.2.tgz", + "integrity": "sha512-Rx3CqeqQ19sxUtYV9CU911Vhy8/721wRFnJv3REVGWUmoAcIwzifTsdmJte/MV+0/XpM35LZdQMBGkRIoLPwQA==" }, "immutable": { "version": "4.2.1", @@ -15113,12 +15132,6 @@ "integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==", "dev": true }, - "js-sdsl": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", - "integrity": "sha512-dyBIzQBDkCqCu+0upx25Y2jGdbTGxE9fshMsCdK0ViOongpV+n5tXRcZY9v7CaVQ79AGS9KA1KHtojxiM7aXSQ==", - "dev": true - }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15228,9 +15241,9 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, "lint-staged": { - "version": "13.2.0", - "resolved": "https://registry.npmmirror.com/lint-staged/-/lint-staged-13.2.0.tgz", - "integrity": "sha512-GbyK5iWinax5Dfw5obm2g2ccUiZXNGtAS4mCbJ0Lv4rq6iEtfBSjOYdcbOtAIFtM114t0vdpViDDetjVTSd8Vw==", + "version": "13.2.2", + "resolved": "https://registry.npmmirror.com/lint-staged/-/lint-staged-13.2.2.tgz", + "integrity": "sha512-71gSwXKy649VrSU09s10uAT0rWCcY3aewhMaHyl2N84oBk4Xs9HgxvUp3AYu+bNsK4NrOYYxvSgg7FyGJ+jGcA==", "dev": true, "requires": { "chalk": "5.2.0", @@ -15245,7 +15258,7 @@ "object-inspect": "^1.12.3", "pidtree": "^0.6.0", "string-argv": "^0.3.1", - "yaml": "^2.2.1" + "yaml": "^2.2.2" }, "dependencies": { "chalk": { @@ -15255,9 +15268,9 @@ "dev": true }, "yaml": { - "version": "2.2.1", - "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.2.1.tgz", - "integrity": "sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw==", + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.3.0.tgz", + "integrity": "sha512-8/1wgzdKc7bc9E6my5wZjmdavHLvO/QOmLG1FBugblEvY4IXrLjlViIOmL24HthU042lWTDRO90Fz1Yp66UnMw==", "dev": true } } @@ -15503,11 +15516,6 @@ "yallist": "^3.0.2" } }, - "make-plural": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.2.0.tgz", - "integrity": "sha512-WkdI+iaWaBCFM2wUXwos8Z7spg5Dt64Xe/VI6NpRaly21cDtD76N6S97K//UtzV0dHOiXX+E90TnszdXHG0aMg==" - }, "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz", @@ -15565,9 +15573,9 @@ "dev": true }, "monaco-editor": { - "version": "0.36.1", - "resolved": "https://registry.npmmirror.com/monaco-editor/-/monaco-editor-0.36.1.tgz", - "integrity": "sha512-/CaclMHKQ3A6rnzBzOADfwdSJ25BFoFT0Emxsc4zYVyav5SkK9iA6lEtIeuN/oRYbwPgviJT+t3l+sjFa28jYg==" + "version": "0.38.0", + "resolved": "https://registry.npmmirror.com/monaco-editor/-/monaco-editor-0.38.0.tgz", + "integrity": "sha512-11Fkh6yzEmwx7O0YoLxeae0qEGFwmyPRlVxpg7oF9czOOCB/iCjdJrG5I67da5WiXK3YJCxoz9TJFE8Tfq/v9A==" }, "ms": { "version": "2.1.2", @@ -15587,9 +15595,9 @@ } }, "nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", "dev": true }, "natural-compare": { @@ -15605,9 +15613,9 @@ "dev": true }, "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "version": "2.6.11", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==", "requires": { "whatwg-url": "^5.0.0" } @@ -15862,7 +15870,7 @@ }, "pify": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", "dev": true }, @@ -15879,20 +15887,20 @@ "dev": true }, "postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "version": "8.4.23", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.23.tgz", + "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", "dev": true, "requires": { - "nanoid": "^3.3.4", + "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "postcss-import": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz", - "integrity": "sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==", + "version": "15.1.0", + "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", "dev": true, "requires": { "postcss-value-parser": "^4.0.0", @@ -15901,37 +15909,45 @@ } }, "postcss-js": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", - "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", "dev": true, "requires": { "camelcase-css": "^2.0.1" } }, "postcss-load-config": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", - "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-4.0.1.tgz", + "integrity": "sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==", "dev": true, "requires": { "lilconfig": "^2.0.5", - "yaml": "^1.10.2" + "yaml": "^2.1.1" + }, + "dependencies": { + "yaml": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.3.0.tgz", + "integrity": "sha512-8/1wgzdKc7bc9E6my5wZjmdavHLvO/QOmLG1FBugblEvY4IXrLjlViIOmL24HthU042lWTDRO90Fz1Yp66UnMw==", + "dev": true + } } }, "postcss-nested": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.0.tgz", - "integrity": "sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==", + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", "dev": true, "requires": { - "postcss-selector-parser": "^6.0.10" + "postcss-selector-parser": "^6.0.11" } }, "postcss-selector-parser": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz", - "integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==", + "version": "6.0.13", + "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", "dev": true, "requires": { "cssesc": "^3.0.0", @@ -15951,15 +15967,15 @@ "dev": true }, "prettier": { - "version": "2.8.7", - "resolved": "https://registry.npmmirror.com/prettier/-/prettier-2.8.7.tgz", - "integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==", + "version": "2.8.8", + "resolved": "https://registry.npmmirror.com/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true }, "prettier-plugin-tailwindcss": { - "version": "0.2.6", - "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.2.6.tgz", - "integrity": "sha512-F+7XCl9RLF/LPrGdUMHWpsT6TM31JraonAUyE6eBmpqymFvDwyl0ETHsKFHP1NG+sEfv8bmKqnTxEbWQbHPlBA==", + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.3.0.tgz", + "integrity": "sha512-009/Xqdy7UmkcTBpwlq7jsViDqXAYSOMLDrHAdTMlVZOrKfM2o9Ci7EMWTMZ7SkKBFTG04UM9F9iM2+4i6boDA==", "dev": true, "requires": {} }, @@ -16009,12 +16025,6 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, - "quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true - }, "react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -16078,9 +16088,9 @@ "requires": {} }, "react-i18next": { - "version": "12.2.0", - "resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-12.2.0.tgz", - "integrity": "sha512-5XeVgSygaGfyFmDd2WcXvINRw2WEC1XviW1LXY/xLOEMzsCFRwKqfnHN+hUjla8ZipbVJR27GCMSuTr0BhBBBQ==", + "version": "12.3.1", + "resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-12.3.1.tgz", + "integrity": "sha512-5v8E2XjZDFzK7K87eSwC7AJcAkcLt5xYZ4+yTPDAW1i7C93oOY1dnr4BaQM7un4Hm+GmghuiPvevWwlca5PwDA==", "requires": { "@babel/runtime": "^7.20.6", "html-parse-stringify": "^3.0.1" @@ -16140,20 +16150,20 @@ } }, "react-router": { - "version": "6.10.0", - "resolved": "https://registry.npmmirror.com/react-router/-/react-router-6.10.0.tgz", - "integrity": "sha512-Nrg0BWpQqrC3ZFFkyewrflCud9dio9ME3ojHCF/WLsprJVzkq3q3UeEhMCAW1dobjeGbWgjNn/PVF6m46ANxXQ==", + "version": "6.11.2", + "resolved": "https://registry.npmmirror.com/react-router/-/react-router-6.11.2.tgz", + "integrity": "sha512-74z9xUSaSX07t3LM+pS6Un0T55ibUE/79CzfZpy5wsPDZaea1F8QkrsiyRnA2YQ7LwE/umaydzXZV80iDCPkMg==", "requires": { - "@remix-run/router": "1.5.0" + "@remix-run/router": "1.6.2" } }, "react-router-dom": { - "version": "6.10.0", - "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.10.0.tgz", - "integrity": "sha512-E5dfxRPuXKJqzwSe/qGcqdwa18QiWC6f3H3cWXM24qj4N0/beCIf/CWTipop2xm7mR0RCS99NnaqPNjHtrAzCg==", + "version": "6.11.2", + "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.11.2.tgz", + "integrity": "sha512-JNbKtAeh1VSJQnH6RvBDNhxNwemRj7KxCzc5jb7zvDSKRnPWIFj9pO+eXqjM69gQJ0r46hSz1x4l9y0651DKWw==", "requires": { - "@remix-run/router": "1.5.0", - "react-router": "6.10.0" + "@remix-run/router": "1.6.2", + "react-router": "6.11.2" } }, "react-style-singleton": { @@ -16180,7 +16190,7 @@ }, "read-cache": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", "dev": true, "requires": { @@ -16296,11 +16306,11 @@ } }, "resolve": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", - "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", + "version": "1.22.2", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.2.tgz", + "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", "requires": { - "is-core-module": "^2.9.0", + "is-core-module": "^2.11.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" } @@ -16359,9 +16369,9 @@ } }, "rollup": { - "version": "3.20.0", - "resolved": "https://registry.npmmirror.com/rollup/-/rollup-3.20.0.tgz", - "integrity": "sha512-YsIfrk80NqUDrxrjWPXUa7PWvAfegZEXHuPsEZg58fGCdjL1I9C1i/NaG+L+27kxxwkrG/QEDEQc8s/ynXWWGQ==", + "version": "3.23.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-3.23.0.tgz", + "integrity": "sha512-h31UlwEi7FHihLe1zbk+3Q7z1k/84rb9BSwmBSr/XjOCEaBJ2YyedQDuM0t/kfOS0IxM+vk1/zI9XxYj9V+NJQ==", "dev": true, "requires": { "fsevents": "~2.3.2" @@ -16397,9 +16407,9 @@ } }, "sass": { - "version": "1.60.0", - "resolved": "https://registry.npmmirror.com/sass/-/sass-1.60.0.tgz", - "integrity": "sha512-updbwW6fNb5gGm8qMXzVO7V4sWf7LMXnMly/JEyfbfERbVH46Fn6q02BX7/eHTdKpE7d+oTkMMQpFWNUMfFbgQ==", + "version": "1.62.1", + "resolved": "https://registry.npmmirror.com/sass/-/sass-1.62.1.tgz", + "integrity": "sha512-NHpxIzN29MXvWiuswfc1W3I0N8SXBd8UR26WntmDlRYf0bSADnwnOjsyMZ3lMezSlArD33Vs3YFhp7dWvL770A==", "requires": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -16597,16 +16607,17 @@ "dev": true }, "stylis": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.1.3.tgz", - "integrity": "sha512-GP6WDNWf+o403jrEp9c5jibKavrtLW+/qYGhFxFrG8maXhwTBI7gLLhiBb0o7uFccWN+EOS9aMO6cGHWAO07OA==" + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" }, "sucrase": { - "version": "3.31.0", - "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.31.0.tgz", - "integrity": "sha512-6QsHnkqyVEzYcaiHsOKkzOtOgdJcb8i54x6AV2hDwyZcY9ZyykGZVw6L/YN98xC0evwTP6utsWWrKRaa8QlfEQ==", + "version": "3.32.0", + "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.32.0.tgz", + "integrity": "sha512-ydQOU34rpSyj2TGyz4D2p8rbktIOZ8QY9s+DGLvFU1i5pWJE8vkpruCjGCMHsdXwnD7JDcS+noSwM/a7zyNFDQ==", "dev": true, "requires": { + "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "7.1.6", "lines-and-columns": "^1.1.6", @@ -16615,6 +16626,17 @@ "ts-interface-checker": "^0.1.9" }, "dependencies": { + "@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, "commander": { "version": "4.1.1", "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", @@ -16651,43 +16673,34 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" }, "tailwindcss": { - "version": "3.3.1", - "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.3.1.tgz", - "integrity": "sha512-Vkiouc41d4CEq0ujXl6oiGFQ7bA3WEhUZdTgXAhtKxSy49OmKs8rEfQmupsfF0IGW8fv2iQkp1EVUuapCFrZ9g==", + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.3.2.tgz", + "integrity": "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==", "dev": true, "requires": { + "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", - "color-name": "^1.1.4", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.17.2", - "lilconfig": "^2.0.6", + "jiti": "^1.18.2", + "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", - "postcss": "^8.0.9", - "postcss-import": "^14.1.0", - "postcss-js": "^4.0.0", - "postcss-load-config": "^3.1.4", - "postcss-nested": "6.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", - "quick-lru": "^5.1.1", - "resolve": "^1.22.1", - "sucrase": "^3.29.0" - }, - "dependencies": { - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - } + "resolve": "^1.22.2", + "sucrase": "^3.32.0" } }, "text-table": { @@ -16745,7 +16758,7 @@ }, "tr46": { "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "ts-interface-checker": { @@ -16815,9 +16828,9 @@ "dev": true }, "typescript": { - "version": "5.0.3", - "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.0.3.tgz", - "integrity": "sha512-xv8mOEDnigb/tN9PSMTwSEqAnUvkoXMQlicOb0IUVDBSQCgBSaAAROUZYy2IcUy5qU6XajK5jjjO7TMWqBTKZA==", + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "dev": true }, "unbox-primitive": { @@ -16911,21 +16924,20 @@ }, "util-deprecate": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, "vite": { - "version": "4.2.1", - "resolved": "https://registry.npmmirror.com/vite/-/vite-4.2.1.tgz", - "integrity": "sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg==", + "version": "4.3.8", + "resolved": "https://registry.npmmirror.com/vite/-/vite-4.3.8.tgz", + "integrity": "sha512-uYB8PwN7hbMrf4j1xzGDk/lqjsZvCDbt/JC5dyfxc19Pg8kRm14LinK/uq+HSLNswZEoKmweGdtpbnxRtrAXiQ==", "dev": true, "requires": { "esbuild": "^0.17.5", "fsevents": "~2.3.2", - "postcss": "^8.4.21", - "resolve": "^1.22.1", - "rollup": "^3.18.0" + "postcss": "^8.4.23", + "rollup": "^3.21.0" } }, "vite-plugin-rewrite-all": { @@ -16952,12 +16964,12 @@ }, "webidl-conversions": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "whatwg-url": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "requires": { "tr46": "~0.0.3", @@ -17081,9 +17093,9 @@ "dev": true }, "zustand": { - "version": "4.3.6", - "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.3.6.tgz", - "integrity": "sha512-6J5zDxjxLE+yukC2XZWf/IyWVKnXT9b9HUv09VJ/bwGCpKNcaTqp7Ws28Xr8jnbvnZcdRaidztAPsXFBIqufiw==", + "version": "4.3.8", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.3.8.tgz", + "integrity": "sha512-4h28KCkHg5ii/wcFFJ5Fp+k1J3gJoasaIbppdgZFO4BPJnsNxL0mQXBSFgOgAdCdBj35aDTPvdAJReTMntFPGg==", "requires": { "use-sync-external-store": "1.2.0" } diff --git a/web/package.json b/web/package.json index ddf7079cf5..f52cea054f 100644 --- a/web/package.json +++ b/web/package.json @@ -15,58 +15,57 @@ }, "dependencies": { "@chakra-ui/anatomy": "^2.1.1", - "@chakra-ui/icons": "^2.0.18", - "@chakra-ui/react": "^2.5.5", - "@emotion/react": "^11.10.6", - "@emotion/styled": "^11.10.6", - "@tanstack/react-query": "^4.28.0", - "axios": "^1.3.4", + "@chakra-ui/icons": "^2.0.19", + "@chakra-ui/react": "^2.6.1", + "@emotion/react": "^11.11.0", + "@emotion/styled": "^11.11.0", + "@tanstack/react-query": "^4.29.7", + "axios": "^1.4.0", "clsx": "^1.2.1", "dayjs": "^1.11.7", "dotenv": "^16.0.3", - "framer-motion": "^10.10.0", - "i18next": "^22.4.13", + "framer-motion": "^10.12.16", + "i18next": "^22.5.0", "i18next-browser-languagedetector": "7.0.1", - "i18next-http-backend": "2.2.0", - "immer": "^9.0.21", + "i18next-http-backend": "2.2.1", + "immer": "^10.0.2", "laf-client-sdk": "^1.0.0-beta.8", "lodash": "^4.17.21", - "make-plural": "^7.2.0", - "monaco-editor": "^0.36.1", + "monaco-editor": "^0.38.0", "qrcode.react": "^3.1.0", "react": "18.2.0", "react-datepicker": "^4.11.0", "react-dom": "18.2.0", "react-hook-form": "^7.43.9", - "react-i18next": "^12.2.0", + "react-i18next": "^12.3.1", "react-icons": "^4.8.0", - "react-router-dom": "^6.10.0", + "react-router-dom": "^6.11.2", "react-syntax-highlighter": "^15.5.0", - "sass": "^1.60.0", - "zustand": "^4.3.6" + "sass": "^1.62.1", + "zustand": "^4.3.8" }, "devDependencies": { - "@types/lodash": "^4.14.192", - "@types/node": "18.15.11", - "@types/react": "^18.0.31", - "@types/react-datepicker": "^4.10.0", - "@types/react-dom": "^18.0.11", + "@types/lodash": "^4.14.194", + "@types/node": "20.2.3", + "@types/react": "^18.2.7", + "@types/react-datepicker": "^4.11.2", + "@types/react-dom": "^18.2.4", "@types/react-syntax-highlighter": "^15.5.6", - "@vitejs/plugin-react-swc": "^3.2.0", + "@vitejs/plugin-react-swc": "^3.3.1", "autoprefixer": "^10.4.14", "click-to-react-component": "^1.0.8", - "eslint": "^8.37.0", + "eslint": "^8.41.0", "eslint-config-react-app": "^7.0.1", "eslint-plugin-simple-import-sort": "^10.0.0", "husky": "^8.0.3", "install": "^0.13.0", - "lint-staged": "^13.2.0", - "postcss": "^8.4.21", - "prettier": "^2.8.7", - "prettier-plugin-tailwindcss": "^0.2.6", - "tailwindcss": "^3.3.1", - "typescript": "5.0.3", - "vite": "^4.2.1", + "lint-staged": "^13.2.2", + "postcss": "^8.4.23", + "prettier": "^2.8.8", + "prettier-plugin-tailwindcss": "^0.3.0", + "tailwindcss": "^3.3.2", + "typescript": "5.0.4", + "vite": "^4.3.8", "vite-plugin-rewrite-all": "^1.0.1" }, "lint-staged": { diff --git a/web/public/locales/en/translation.json b/web/public/locales/en/translation.json index ab1e44fd03..9375c463cd 100644 --- a/web/public/locales/en/translation.json +++ b/web/public/locales/en/translation.json @@ -43,6 +43,7 @@ "Copied": "copied", "Copy": "copy", "Create": "New application", + "Change": "Change application", "CreateNow": "Create Now", "Custom": "customize", "Days": "days", @@ -113,7 +114,7 @@ "APP": "Android or iOS app", "Application": "Application ", "Blog": "Personal blog, corporate official website", - "BundleName": "Specification", + "BundleName": "Spec", "Develop": "Develop", "Enterprise": "Enterprise information construction", "Explore": "waiting for you to explore", @@ -324,7 +325,6 @@ "CreateTime": "Created", "EndTime": "Expired", "Renew": "Renew", - "TotalPrice": "Total", "Balance": "Balance", "ChargeNow": "Charge", "balance is insufficient": "balance is insufficient", @@ -419,5 +419,8 @@ "ChatGPT example": "ChatGPT example", "LinkCopied": "Link Copied", "create success": "create success", - "ServerStatus": "STATUS" + "ServerStatus": "STATUS", + "Fee": "Fee", + "PleaseCloseApplicationFirst": "Please close your application first.", + "custom": "Custom" } diff --git a/web/public/locales/zh-CN/translation.json b/web/public/locales/zh-CN/translation.json index d73a54bf61..c010c9d38f 100644 --- a/web/public/locales/zh-CN/translation.json +++ b/web/public/locales/zh-CN/translation.json @@ -43,6 +43,7 @@ "Copied": "已复制", "Copy": "复制", "Create": "新建", + "Change": "变更配置", "CreateNow": "立即创建", "Custom": "自定义", "Days": "天", @@ -324,7 +325,6 @@ "CreateTime": "创建时间", "EndTime": "到期时间", "Renew": "续期", - "TotalPrice": "共需支付", "Balance": "账户余额", "ChargeNow": "充值", "balance is insufficient": "余额不足", @@ -419,5 +419,8 @@ "ChatGPT example": "ChatGPT 示例", "LinkCopied": "链接复制成功", "create success": "创建成功", - "ServerStatus": "服务状态" + "ServerStatus": "服务状态", + "Fee": "费用", + "PleaseCloseApplicationFirst": "请先关闭应用", + "custom": "自定义" } diff --git a/web/public/locales/zh/translation.json b/web/public/locales/zh/translation.json index d73a54bf61..c010c9d38f 100644 --- a/web/public/locales/zh/translation.json +++ b/web/public/locales/zh/translation.json @@ -43,6 +43,7 @@ "Copied": "已复制", "Copy": "复制", "Create": "新建", + "Change": "变更配置", "CreateNow": "立即创建", "Custom": "自定义", "Days": "天", @@ -324,7 +325,6 @@ "CreateTime": "创建时间", "EndTime": "到期时间", "Renew": "续期", - "TotalPrice": "共需支付", "Balance": "账户余额", "ChargeNow": "充值", "balance is insufficient": "余额不足", @@ -419,5 +419,8 @@ "ChatGPT example": "ChatGPT 示例", "LinkCopied": "链接复制成功", "create success": "创建成功", - "ServerStatus": "服务状态" + "ServerStatus": "服务状态", + "Fee": "费用", + "PleaseCloseApplicationFirst": "请先关闭应用", + "custom": "自定义" } diff --git a/web/src/apis/typing.d.ts b/web/src/apis/typing.d.ts index dcb10c585a..779aa360d3 100644 --- a/web/src/apis/typing.d.ts +++ b/web/src/apis/typing.d.ts @@ -1,5 +1,5 @@ export type TApplicationDetail = { - id: string; + _id: string; name: string; appid: string; regionId: string; @@ -11,7 +11,7 @@ export type TApplicationDetail = { updatedAt: string; lockedAt: string; createdBy: string; - bundle: TBundle; + bundle: TCurrentBundle; runtime: TRuntime; configuration: TConfiguration; domain: TDomain; @@ -22,26 +22,62 @@ export type TApplicationDetail = { function_debug_token: string; host?: string; origin?: string; - subscription: TSubscription; }; -export type TBundle = { - id: string; +export type TCurrentBundle = { + _id: string; + appid: string; + bundleId: string; name: string; displayName: string; - priority: number; - state: string; + createdAt: string; + updatedAt: string; resource: TResource; - limitCountPerUser: number; - notes: { content: string }[]; - subscriptionOptions: TSubscriptionOption[]; +}; + +export type TBundle = { + _id: string; + regionId: string; + name: string; + displayName: string; + spec: TSpec; + enableFreeTier: boolean; + limitCountOfFreeTierPerUser: number; + createdAt: string; + updatedAt: string; +}; + +export type TSpec = { + cpu: Cpu; + memory: Memory; + databaseCapacity: DatabaseCapacity; + storageCapacity: StorageCapacity; + networkTraffic: NetworkTraffic; +}; + +export type Cpu = { + value: number; +}; + +export type Memory = { + value: number; +}; + +export type DatabaseCapacity = { + value: number; +}; + +export type StorageCapacity = { + value: number; +}; + +export type NetworkTraffic = { + value: number; }; export type TResource = { limitCPU: number; limitMemory: number; - requestCPU: number; - requestMemory: number; databaseCapacity: number; storageCapacity: number; networkTrafficOutbound: number; @@ -64,13 +100,13 @@ export type TSubscriptionOption = { }; export type TRuntime = { - id: string; + _id: string; name: string; type: string; - image: TImage; state: string; version: string; latest: boolean; + image: TImage; }; export type TImage = { @@ -80,12 +116,12 @@ export type TImage = { }; export type TConfiguration = { - id: string; + _id: string; appid: string; - environments: TEnvironment[]; dependencies: any[]; createdAt: string; updatedAt: string; + environments: TEnvironment[]; }; export type TEnvironment = { @@ -94,7 +130,7 @@ export type TEnvironment = { }; export type TDomain = { - id: string; + _id: string; appid: string; domain: string; state: string; @@ -106,7 +142,7 @@ export type TDomain = { export type TStorage = { credentials: TCredentials; - id: string; + _id: string; appid: string; accessKey: string; secretKey: string; @@ -126,7 +162,7 @@ export type TCredentials = { }; export type TRegion = { - id: string; + _id: string; name: string; displayName: string; state: string; @@ -134,7 +170,7 @@ export type TRegion = { }; export type TBucket = { - id: string; + _id: string; appid: string; name: string; shortName: string; @@ -149,7 +185,7 @@ export type TBucket = { }; export type TWebsiteHosting = { - id: string; + _id: string; appid: string; bucketName: string; domain: string; @@ -162,7 +198,7 @@ export type TWebsiteHosting = { }; export type TSetting = { - id: string; + _id: string; key: string; value: string; desc: string; @@ -222,7 +258,7 @@ export type Key = { }; export type TFunction = { - id: string; + _id: string; appid: string; name: string; source: Source; @@ -255,97 +291,50 @@ export type TLogItem = { created_at: string; }; -// user data -export type TUserInfo = { - id: string; - username: string; - email: any; - phone: any; - createdAt: string; - updatedAt: string; - profile: TProfile; -}; - -export type TProfile = { - id: string; - uid: string; - openid: string; - from: string; - avatar: string; - name: string; - createdAt: string; - updatedAt: string; -}; - export type TApplicationItem = { - id: string; - name: string; + _id: string; appid: string; - regionId: string; - runtimeId: string; - tags: Array; + name: string; state: string; phase: string; + tags: Array; + createdBy: string; + lockedAt: string; + regionId: string; + runtimeId: string; + billingLockedAt: string; createdAt: string; updatedAt: string; - lockedAt: string; - createdBy: string; bundle: { - id: string; + _id: string; appid: string; - bundleId: string; - name: string; - displayName: string; resource: { limitCPU: number; limitMemory: number; - requestCPU: number; - requestMemory: number; databaseCapacity: number; storageCapacity: number; - networkTrafficOutbound: number; limitCountOfCloudFunction: number; limitCountOfBucket: number; limitCountOfDatabasePolicy: number; limitCountOfTrigger: number; limitCountOfWebsiteHosting: number; - reservedTimeAfterExpired: number; limitDatabaseTPS: number; limitStorageTPS: number; + reservedTimeAfterExpired: number; }; createdAt: string; updatedAt: string; }; runtime: { - id: string; + _id: string; name: string; type: string; - image: { - main: string; - init: string; - sidecar: any; - }; state: string; version: string; latest: boolean; - }; - subscription: { - id: string; - input: { - name: string; - state: string; - runtimeId: string; - regionId: string; + image: { + main: string; + init: string; }; - bundleId: string; - appid: string; - state: string; - phase: string; - renewalPlan: string; - expiredAt: string; - lockedAt: string; - createdAt: string; - updatedAt: string; - createdBy: string; }; }; diff --git a/web/src/apis/v1/accounts.ts b/web/src/apis/v1/accounts.ts index c4d2a65cff..2a6da2b377 100644 --- a/web/src/apis/v1/accounts.ts +++ b/web/src/apis/v1/accounts.ts @@ -15,7 +15,10 @@ import useGlobalStore from "@/pages/globalStore"; */ export async function AccountControllerFindOne( params: Paths.AccountControllerFindOne.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Definitions.Account; +}> { // /v1/accounts let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -32,7 +35,10 @@ export async function AccountControllerFindOne( */ export async function AccountControllerGetChargeOrder( params: Paths.AccountControllerGetChargeOrder.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Definitions.AccountChargeOrder; +}> { // /v1/accounts/charge-order/{id} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -49,7 +55,10 @@ export async function AccountControllerGetChargeOrder( */ export async function AccountControllerCharge( params: Definitions.CreateChargeOrderDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.AccountControllerCharge.Responses; +}> { // /v1/accounts/charge-order let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -66,7 +75,10 @@ export async function AccountControllerCharge( */ export async function AccountControllerWechatNotify( params: Paths.AccountControllerWechatNotify.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.AccountControllerWechatNotify.Responses; +}> { // /v1/accounts/payment/wechat-notify let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", diff --git a/web/src/apis/v1/api-auto.d.ts b/web/src/apis/v1/api-auto.d.ts index 0442f69b02..bf3fab3fe4 100644 --- a/web/src/apis/v1/api-auto.d.ts +++ b/web/src/apis/v1/api-auto.d.ts @@ -19,11 +19,37 @@ declare namespace Definitions { code?: string /* The source code of the function */; }; + export type CreateApplicationDto = { + cpu?: number; + memory?: number; + databaseCapacity?: number; + storageCapacity?: number; + name?: string; + state?: string; + regionId?: string; + runtimeId?: string; + }; + export type UpdateApplicationDto = { name?: string; state?: string; }; + export type UpdateApplicationNameDto = { + name?: string; + }; + + export type UpdateApplicationStateDto = { + state?: string; + }; + + export type UpdateApplicationBundleDto = { + cpu?: number; + memory?: number; + databaseCapacity?: number; + storageCapacity?: number; + }; + export type CreateEnvironmentDto = { name?: string; value?: string; @@ -42,6 +68,14 @@ declare namespace Definitions { name?: string; }; + export type Collection = { + name?: string; + type?: string; + options?: {}; + info?: {}; + idIndex?: {}; + }; + export type UpdateCollectionDto = { validatorSchema?: {}; validationLevel?: string; @@ -64,6 +98,35 @@ declare namespace Definitions { value?: string; }; + export type Account = { + _id?: string; + balance?: number; + state?: string; + createdAt?: string; + updatedAt?: string; + createdBy?: string; + }; + + export type AccountChargeOrder = { + _id?: string; + accountId?: string; + amount?: number; + currency?: string; + phase?: string; + channel?: string; + result?: {}; + message?: string; + createdAt?: string; + updatedAt?: string; + createdBy?: string; + }; + + export type CreateChargeOrderDto = { + amount?: number; + channel?: string; + currency?: string; + }; + export type CreateWebsiteDto = { bucketName?: string; state?: string; @@ -77,6 +140,16 @@ declare namespace Definitions { pat?: string /* PAT */; }; + export type UserWithProfile = { + _id?: string; + username?: string; + email?: string; + phone?: string; + createdAt?: string; + updatedAt?: string; + profile?: Definitions.UserProfile; + }; + export type PasswdSignupDto = { username?: string /* username, 3-64 characters */; password?: string /* password, 8-64 characters */; @@ -139,30 +212,52 @@ declare namespace Definitions { name?: string; }; - export type CreateSubscriptionDto = { - name?: string; + export type ApplicationBilling = { + _id?: string; + appid?: string; state?: string; + amount?: number; + detail?: Definitions.ApplicationBillingDetail; + startAt?: string; + endAt?: string; + createdAt?: string; + updatedAt?: string; + }; + + export type CalculatePriceDto = { regionId?: string; - bundleId?: string; - runtimeId?: string; - duration?: number; + cpu?: number; + memory?: number; + databaseCapacity?: number; + storageCapacity?: number; }; - export type RenewSubscriptionDto = { - duration?: number; + export type UserProfile = { + _id?: string; + uid?: string; + openData?: {}; + avatar?: string; + name?: string; + createdAt?: string; + updatedAt?: string; }; - export type UpgradeSubscriptionDto = {}; + export type ApplicationBillingDetail = { + cpu?: Definitions.ApplicationBillingDetailItem; + memory?: Definitions.ApplicationBillingDetailItem; + databaseCapacity?: Definitions.ApplicationBillingDetailItem; + storageCapacity?: Definitions.ApplicationBillingDetailItem; + networkTraffic?: Definitions.ApplicationBillingDetailItem; + }; - export type CreateChargeOrderDto = { + export type ApplicationBillingDetailItem = { + usage?: number; amount?: number; - channel?: string; - currency?: string; }; } declare namespace Paths { - namespace AuthControllerCode2token { + namespace AppControllerGetRuntimes { export type QueryParameters = any; export type BodyParameters = any; @@ -170,15 +265,15 @@ declare namespace Paths { export type Responses = any; } - namespace AuthControllerGetSignupUrl { + namespace FunctionControllerCreate { export type QueryParameters = any; - export type BodyParameters = any; + export type BodyParameters = Definitions.CreateFunctionDto; export type Responses = any; } - namespace AuthControllerGetSigninUrl { + namespace FunctionControllerFindAll { export type QueryParameters = any; export type BodyParameters = any; @@ -186,7 +281,7 @@ declare namespace Paths { export type Responses = any; } - namespace AppControllerGetRuntimes { + namespace FunctionControllerFindOne { export type QueryParameters = any; export type BodyParameters = any; @@ -194,15 +289,15 @@ declare namespace Paths { export type Responses = any; } - namespace FunctionControllerCreate { + namespace FunctionControllerUpdate { export type QueryParameters = any; - export type BodyParameters = Definitions.CreateFunctionDto; + export type BodyParameters = Definitions.UpdateFunctionDto; export type Responses = any; } - namespace FunctionControllerFindAll { + namespace FunctionControllerRemove { export type QueryParameters = any; export type BodyParameters = any; @@ -210,23 +305,23 @@ declare namespace Paths { export type Responses = any; } - namespace FunctionControllerFindOne { + namespace FunctionControllerCompile { export type QueryParameters = any; - export type BodyParameters = any; + export type BodyParameters = Definitions.CompileFunctionDto; export type Responses = any; } - namespace FunctionControllerUpdate { + namespace ApplicationControllerCreate { export type QueryParameters = any; - export type BodyParameters = Definitions.UpdateFunctionDto; + export type BodyParameters = Definitions.CreateApplicationDto; export type Responses = any; } - namespace FunctionControllerRemove { + namespace ApplicationControllerFindAll { export type QueryParameters = any; export type BodyParameters = any; @@ -234,15 +329,15 @@ declare namespace Paths { export type Responses = any; } - namespace FunctionControllerCompile { + namespace ApplicationControllerFindOne { export type QueryParameters = any; - export type BodyParameters = Definitions.CompileFunctionDto; + export type BodyParameters = any; export type Responses = any; } - namespace ApplicationControllerFindAll { + namespace ApplicationControllerDelete { export type QueryParameters = any; export type BodyParameters = any; @@ -250,18 +345,34 @@ declare namespace Paths { export type Responses = any; } - namespace ApplicationControllerFindOne { + namespace ApplicationControllerUpdate { export type QueryParameters = any; - export type BodyParameters = any; + export type BodyParameters = Definitions.UpdateApplicationDto; export type Responses = any; } - namespace ApplicationControllerUpdate { + namespace ApplicationControllerUpdateName { export type QueryParameters = any; - export type BodyParameters = Definitions.UpdateApplicationDto; + export type BodyParameters = Definitions.UpdateApplicationNameDto; + + export type Responses = any; + } + + namespace ApplicationControllerUpdateState { + export type QueryParameters = any; + + export type BodyParameters = Definitions.UpdateApplicationStateDto; + + export type Responses = any; + } + + namespace ApplicationControllerUpdateBundle { + export type QueryParameters = any; + + export type BodyParameters = Definitions.UpdateApplicationBundleDto; export type Responses = any; } @@ -450,6 +561,38 @@ declare namespace Paths { export type Responses = any; } + namespace AccountControllerFindOne { + export type QueryParameters = any; + + export type BodyParameters = any; + + export type Responses = any; + } + + namespace AccountControllerGetChargeOrder { + export type QueryParameters = any; + + export type BodyParameters = any; + + export type Responses = any; + } + + namespace AccountControllerCharge { + export type QueryParameters = any; + + export type BodyParameters = Definitions.CreateChargeOrderDto; + + export type Responses = any; + } + + namespace AccountControllerWechatNotify { + export type QueryParameters = any; + + export type BodyParameters = any; + + export type Responses = any; + } + namespace WebsiteControllerCreate { export type QueryParameters = any; @@ -682,23 +825,7 @@ declare namespace Paths { export type Responses = any; } - namespace SubscriptionControllerCreate { - export type QueryParameters = any; - - export type BodyParameters = Definitions.CreateSubscriptionDto; - - export type Responses = any; - } - - namespace SubscriptionControllerFindAll { - export type QueryParameters = any; - - export type BodyParameters = any; - - export type Responses = any; - } - - namespace SubscriptionControllerFindOne { + namespace SettingControllerGetSettings { export type QueryParameters = any; export type BodyParameters = any; @@ -706,23 +833,7 @@ declare namespace Paths { export type Responses = any; } - namespace SubscriptionControllerRenew { - export type QueryParameters = any; - - export type BodyParameters = Definitions.RenewSubscriptionDto; - - export type Responses = any; - } - - namespace SubscriptionControllerUpgrade { - export type QueryParameters = any; - - export type BodyParameters = Definitions.UpgradeSubscriptionDto; - - export type Responses = any; - } - - namespace SubscriptionControllerRemove { + namespace SettingControllerGetSettingByKey { export type QueryParameters = any; export type BodyParameters = any; @@ -730,7 +841,7 @@ declare namespace Paths { export type Responses = any; } - namespace AccountControllerFindOne { + namespace BillingControllerFindAllByAppId { export type QueryParameters = any; export type BodyParameters = any; @@ -738,7 +849,7 @@ declare namespace Paths { export type Responses = any; } - namespace AccountControllerGetChargeOrder { + namespace BillingControllerFindOne { export type QueryParameters = any; export type BodyParameters = any; @@ -746,15 +857,15 @@ declare namespace Paths { export type Responses = any; } - namespace AccountControllerCharge { + namespace ResourceControllerCalculatePrice { export type QueryParameters = any; - export type BodyParameters = Definitions.CreateChargeOrderDto; + export type BodyParameters = Definitions.CalculatePriceDto; export type Responses = any; } - namespace AccountControllerWechatNotify { + namespace ResourceControllerGetResourceOptions { export type QueryParameters = any; export type BodyParameters = any; @@ -762,7 +873,7 @@ declare namespace Paths { export type Responses = any; } - namespace SettingControllerGetSettings { + namespace ResourceControllerGetResourceOptionsByRegionId { export type QueryParameters = any; export type BodyParameters = any; @@ -770,7 +881,7 @@ declare namespace Paths { export type Responses = any; } - namespace SettingControllerGetSettingByKey { + namespace ResourceControllerGetResourceBundles { export type QueryParameters = any; export type BodyParameters = any; diff --git a/web/src/apis/v1/applications.ts b/web/src/apis/v1/applications.ts index 7a2ae000f5..9c96816859 100644 --- a/web/src/apis/v1/applications.ts +++ b/web/src/apis/v1/applications.ts @@ -10,12 +10,35 @@ import request from "@/utils/request"; import useGlobalStore from "@/pages/globalStore"; +/** + * Create application + */ +export async function ApplicationControllerCreate( + params: Definitions.CreateApplicationDto | any, +): Promise<{ + error: string; + data: Paths.ApplicationControllerCreate.Responses; +}> { + // /v1/applications + let _params: { [key: string]: any } = { + appid: useGlobalStore.getState().currentApp?.appid || "", + ...params, + }; + return request(`/v1/applications`, { + method: "POST", + data: params, + }); +} + /** * Get user application list */ export async function ApplicationControllerFindAll( params: Paths.ApplicationControllerFindAll.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.ApplicationControllerFindAll.Responses; +}> { // /v1/applications let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -32,7 +55,10 @@ export async function ApplicationControllerFindAll( */ export async function ApplicationControllerFindOne( params: Paths.ApplicationControllerFindOne.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.ApplicationControllerFindOne.Responses; +}> { // /v1/applications/{appid} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -44,12 +70,35 @@ export async function ApplicationControllerFindOne( }); } +/** + * Delete an application + */ +export async function ApplicationControllerDelete( + params: Paths.ApplicationControllerDelete.BodyParameters | any, +): Promise<{ + error: string; + data: Paths.ApplicationControllerDelete.Responses; +}> { + // /v1/applications/{appid} + let _params: { [key: string]: any } = { + appid: useGlobalStore.getState().currentApp?.appid || "", + ...params, + }; + return request(`/v1/applications/${_params.appid}`, { + method: "DELETE", + data: params, + }); +} + /** * Update an application */ export async function ApplicationControllerUpdate( params: Definitions.UpdateApplicationDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.ApplicationControllerUpdate.Responses; +}> { // /v1/applications/{appid} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -60,3 +109,63 @@ export async function ApplicationControllerUpdate( data: params, }); } + +/** + * Update application name + */ +export async function ApplicationControllerUpdateName( + params: Definitions.UpdateApplicationNameDto | any, +): Promise<{ + error: string; + data: Paths.ApplicationControllerUpdateName.Responses; +}> { + // /v1/applications/{appid}/name + let _params: { [key: string]: any } = { + appid: useGlobalStore.getState().currentApp?.appid || "", + ...params, + }; + return request(`/v1/applications/${_params.appid}/name`, { + method: "PATCH", + data: params, + }); +} + +/** + * Update application state + */ +export async function ApplicationControllerUpdateState( + params: Definitions.UpdateApplicationStateDto | any, +): Promise<{ + error: string; + data: Paths.ApplicationControllerUpdateState.Responses; +}> { + // /v1/applications/{appid}/state + let _params: { [key: string]: any } = { + appid: useGlobalStore.getState().currentApp?.appid || "", + ...params, + }; + return request(`/v1/applications/${_params.appid}/state`, { + method: "PATCH", + data: params, + }); +} + +/** + * Update application bundle + */ +export async function ApplicationControllerUpdateBundle( + params: Definitions.UpdateApplicationBundleDto | any, +): Promise<{ + error: string; + data: Paths.ApplicationControllerUpdateBundle.Responses; +}> { + // /v1/applications/{appid}/bundle + let _params: { [key: string]: any } = { + appid: useGlobalStore.getState().currentApp?.appid || "", + ...params, + }; + return request(`/v1/applications/${_params.appid}/bundle`, { + method: "PATCH", + data: params, + }); +} diff --git a/web/src/apis/v1/apps.ts b/web/src/apis/v1/apps.ts index eadc02b4a6..97accbcbbc 100644 --- a/web/src/apis/v1/apps.ts +++ b/web/src/apis/v1/apps.ts @@ -15,7 +15,10 @@ import useGlobalStore from "@/pages/globalStore"; */ export async function FunctionControllerCreate( params: Definitions.CreateFunctionDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.FunctionControllerCreate.Responses; +}> { // /v1/apps/{appid}/functions let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -32,7 +35,10 @@ export async function FunctionControllerCreate( */ export async function FunctionControllerFindAll( params: Paths.FunctionControllerFindAll.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.FunctionControllerFindAll.Responses; +}> { // /v1/apps/{appid}/functions let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -49,7 +55,10 @@ export async function FunctionControllerFindAll( */ export async function FunctionControllerFindOne( params: Paths.FunctionControllerFindOne.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.FunctionControllerFindOne.Responses; +}> { // /v1/apps/{appid}/functions/{name} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -66,7 +75,10 @@ export async function FunctionControllerFindOne( */ export async function FunctionControllerUpdate( params: Definitions.UpdateFunctionDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.FunctionControllerUpdate.Responses; +}> { // /v1/apps/{appid}/functions/{name} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -83,7 +95,10 @@ export async function FunctionControllerUpdate( */ export async function FunctionControllerRemove( params: Paths.FunctionControllerRemove.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.FunctionControllerRemove.Responses; +}> { // /v1/apps/{appid}/functions/{name} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -100,7 +115,10 @@ export async function FunctionControllerRemove( */ export async function FunctionControllerCompile( params: Definitions.CompileFunctionDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.FunctionControllerCompile.Responses; +}> { // /v1/apps/{appid}/functions/{name}/compile let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -117,7 +135,10 @@ export async function FunctionControllerCompile( */ export async function EnvironmentVariableControllerUpdateAll( params: Paths.EnvironmentVariableControllerUpdateAll.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.EnvironmentVariableControllerUpdateAll.Responses; +}> { // /v1/apps/{appid}/environments let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -134,7 +155,10 @@ export async function EnvironmentVariableControllerUpdateAll( */ export async function EnvironmentVariableControllerAdd( params: Definitions.CreateEnvironmentDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.EnvironmentVariableControllerAdd.Responses; +}> { // /v1/apps/{appid}/environments let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -151,7 +175,10 @@ export async function EnvironmentVariableControllerAdd( */ export async function EnvironmentVariableControllerGet( params: Paths.EnvironmentVariableControllerGet.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.EnvironmentVariableControllerGet.Responses; +}> { // /v1/apps/{appid}/environments let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -168,7 +195,10 @@ export async function EnvironmentVariableControllerGet( */ export async function EnvironmentVariableControllerDelete( params: Paths.EnvironmentVariableControllerDelete.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.EnvironmentVariableControllerDelete.Responses; +}> { // /v1/apps/{appid}/environments/{name} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -183,9 +213,10 @@ export async function EnvironmentVariableControllerDelete( /** * Create a new bucket */ -export async function BucketControllerCreate( - params: Definitions.CreateBucketDto | any, -): Promise { +export async function BucketControllerCreate(params: Definitions.CreateBucketDto | any): Promise<{ + error: string; + data: Paths.BucketControllerCreate.Responses; +}> { // /v1/apps/{appid}/buckets let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -202,7 +233,10 @@ export async function BucketControllerCreate( */ export async function BucketControllerFindAll( params: Paths.BucketControllerFindAll.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.BucketControllerFindAll.Responses; +}> { // /v1/apps/{appid}/buckets let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -219,7 +253,10 @@ export async function BucketControllerFindAll( */ export async function BucketControllerFindOne( params: Paths.BucketControllerFindOne.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.BucketControllerFindOne.Responses; +}> { // /v1/apps/{appid}/buckets/{name} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -234,9 +271,10 @@ export async function BucketControllerFindOne( /** * Update a bucket */ -export async function BucketControllerUpdate( - params: Definitions.UpdateBucketDto | any, -): Promise { +export async function BucketControllerUpdate(params: Definitions.UpdateBucketDto | any): Promise<{ + error: string; + data: Paths.BucketControllerUpdate.Responses; +}> { // /v1/apps/{appid}/buckets/{name} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -253,7 +291,10 @@ export async function BucketControllerUpdate( */ export async function BucketControllerRemove( params: Paths.BucketControllerRemove.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.BucketControllerRemove.Responses; +}> { // /v1/apps/{appid}/buckets/{name} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -270,7 +311,10 @@ export async function BucketControllerRemove( */ export async function CollectionControllerCreate( params: Definitions.CreateCollectionDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.CollectionControllerCreate.Responses; +}> { // /v1/apps/{appid}/collections let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -287,7 +331,10 @@ export async function CollectionControllerCreate( */ export async function CollectionControllerFindAll( params: Paths.CollectionControllerFindAll.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Definitions.Collection; +}> { // /v1/apps/{appid}/collections let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -304,7 +351,10 @@ export async function CollectionControllerFindAll( */ export async function CollectionControllerFindOne( params: Paths.CollectionControllerFindOne.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Definitions.Collection; +}> { // /v1/apps/{appid}/collections/{name} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -321,7 +371,10 @@ export async function CollectionControllerFindOne( */ export async function CollectionControllerUpdate( params: Definitions.UpdateCollectionDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.CollectionControllerUpdate.Responses; +}> { // /v1/apps/{appid}/collections/{name} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -338,7 +391,10 @@ export async function CollectionControllerUpdate( */ export async function CollectionControllerRemove( params: Paths.CollectionControllerRemove.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.CollectionControllerRemove.Responses; +}> { // /v1/apps/{appid}/collections/{name} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -353,9 +409,10 @@ export async function CollectionControllerRemove( /** * Create database policy */ -export async function PolicyControllerCreate( - params: Definitions.CreatePolicyDto | any, -): Promise { +export async function PolicyControllerCreate(params: Definitions.CreatePolicyDto | any): Promise<{ + error: string; + data: Paths.PolicyControllerCreate.Responses; +}> { // /v1/apps/{appid}/policies let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -372,7 +429,10 @@ export async function PolicyControllerCreate( */ export async function PolicyControllerFindAll( params: Paths.PolicyControllerFindAll.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.PolicyControllerFindAll.Responses; +}> { // /v1/apps/{appid}/policies let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -387,9 +447,10 @@ export async function PolicyControllerFindAll( /** * Update database policy */ -export async function PolicyControllerUpdate( - params: Definitions.UpdatePolicyDto | any, -): Promise { +export async function PolicyControllerUpdate(params: Definitions.UpdatePolicyDto | any): Promise<{ + error: string; + data: Paths.PolicyControllerUpdate.Responses; +}> { // /v1/apps/{appid}/policies/{name} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -406,7 +467,10 @@ export async function PolicyControllerUpdate( */ export async function PolicyControllerRemove( params: Paths.PolicyControllerRemove.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.PolicyControllerRemove.Responses; +}> { // /v1/apps/{appid}/policies/{name} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -423,7 +487,10 @@ export async function PolicyControllerRemove( */ export async function DatabaseControllerProxy( params: Paths.DatabaseControllerProxy.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.DatabaseControllerProxy.Responses; +}> { // /v1/apps/{appid}/databases/proxy let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -440,7 +507,10 @@ export async function DatabaseControllerProxy( */ export async function PolicyRuleControllerCreate( params: Definitions.CreatePolicyRuleDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.PolicyRuleControllerCreate.Responses; +}> { // /v1/apps/{appid}/policies/{name}/rules let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -457,7 +527,10 @@ export async function PolicyRuleControllerCreate( */ export async function PolicyRuleControllerFindAll( params: Paths.PolicyRuleControllerFindAll.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.PolicyRuleControllerFindAll.Responses; +}> { // /v1/apps/{appid}/policies/{name}/rules let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -474,7 +547,10 @@ export async function PolicyRuleControllerFindAll( */ export async function PolicyRuleControllerUpdate( params: Definitions.UpdatePolicyRuleDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.PolicyRuleControllerUpdate.Responses; +}> { // /v1/apps/{appid}/policies/{name}/rules/{collection} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -491,7 +567,10 @@ export async function PolicyRuleControllerUpdate( */ export async function PolicyRuleControllerRemove( params: Paths.PolicyRuleControllerRemove.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.PolicyRuleControllerRemove.Responses; +}> { // /v1/apps/{appid}/policies/{name}/rules/{collection} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -506,9 +585,10 @@ export async function PolicyRuleControllerRemove( /** * Create a new website */ -export async function WebsiteControllerCreate( - params: Definitions.CreateWebsiteDto | any, -): Promise { +export async function WebsiteControllerCreate(params: Definitions.CreateWebsiteDto | any): Promise<{ + error: string; + data: Paths.WebsiteControllerCreate.Responses; +}> { // /v1/apps/{appid}/websites let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -525,7 +605,10 @@ export async function WebsiteControllerCreate( */ export async function WebsiteControllerFindAll( params: Paths.WebsiteControllerFindAll.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.WebsiteControllerFindAll.Responses; +}> { // /v1/apps/{appid}/websites let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -542,7 +625,10 @@ export async function WebsiteControllerFindAll( */ export async function WebsiteControllerFindOne( params: Paths.WebsiteControllerFindOne.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.WebsiteControllerFindOne.Responses; +}> { // /v1/apps/{appid}/websites/{id} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -559,7 +645,10 @@ export async function WebsiteControllerFindOne( */ export async function WebsiteControllerBindDomain( params: Definitions.BindCustomDomainDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.WebsiteControllerBindDomain.Responses; +}> { // /v1/apps/{appid}/websites/{id} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -576,7 +665,10 @@ export async function WebsiteControllerBindDomain( */ export async function WebsiteControllerRemove( params: Paths.WebsiteControllerRemove.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.WebsiteControllerRemove.Responses; +}> { // /v1/apps/{appid}/websites/{id} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -593,7 +685,10 @@ export async function WebsiteControllerRemove( */ export async function WebsiteControllerCheckResolved( params: Definitions.BindCustomDomainDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.WebsiteControllerCheckResolved.Responses; +}> { // /v1/apps/{appid}/websites/{id}/resolved let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -608,9 +703,10 @@ export async function WebsiteControllerCheckResolved( /** * Create a cron trigger */ -export async function TriggerControllerCreate( - params: Definitions.CreateTriggerDto | any, -): Promise { +export async function TriggerControllerCreate(params: Definitions.CreateTriggerDto | any): Promise<{ + error: string; + data: Paths.TriggerControllerCreate.Responses; +}> { // /v1/apps/{appid}/triggers let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -627,7 +723,10 @@ export async function TriggerControllerCreate( */ export async function TriggerControllerFindAll( params: Paths.TriggerControllerFindAll.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.TriggerControllerFindAll.Responses; +}> { // /v1/apps/{appid}/triggers let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -644,7 +743,10 @@ export async function TriggerControllerFindAll( */ export async function TriggerControllerRemove( params: Paths.TriggerControllerRemove.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.TriggerControllerRemove.Responses; +}> { // /v1/apps/{appid}/triggers/{id} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -661,7 +763,10 @@ export async function TriggerControllerRemove( */ export async function LogControllerGetLogs( params: Paths.LogControllerGetLogs.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.LogControllerGetLogs.Responses; +}> { // /v1/apps/{appid}/logs/functions let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -678,7 +783,10 @@ export async function LogControllerGetLogs( */ export async function DependencyControllerAdd( params: Paths.DependencyControllerAdd.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.DependencyControllerAdd.Responses; +}> { // /v1/apps/{appid}/dependencies let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -695,7 +803,10 @@ export async function DependencyControllerAdd( */ export async function DependencyControllerUpdate( params: Paths.DependencyControllerUpdate.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.DependencyControllerUpdate.Responses; +}> { // /v1/apps/{appid}/dependencies let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -712,7 +823,10 @@ export async function DependencyControllerUpdate( */ export async function DependencyControllerGetDependencies( params: Paths.DependencyControllerGetDependencies.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.DependencyControllerGetDependencies.Responses; +}> { // /v1/apps/{appid}/dependencies let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -729,7 +843,10 @@ export async function DependencyControllerGetDependencies( */ export async function DependencyControllerRemove( params: Definitions.DeleteDependencyDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.DependencyControllerRemove.Responses; +}> { // /v1/apps/{appid}/dependencies let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -740,3 +857,43 @@ export async function DependencyControllerRemove( data: params, }); } + +/** + * Get billings of an application + */ +export async function BillingControllerFindAllByAppId( + params: Paths.BillingControllerFindAllByAppId.BodyParameters | any, +): Promise<{ + error: string; + data: Paths.BillingControllerFindAllByAppId.Responses; +}> { + // /v1/apps/{appid}/billings + let _params: { [key: string]: any } = { + appid: useGlobalStore.getState().currentApp?.appid || "", + ...params, + }; + return request(`/v1/apps/${_params.appid}/billings`, { + method: "GET", + params: params, + }); +} + +/** + * Get billing by id + */ +export async function BillingControllerFindOne( + params: Paths.BillingControllerFindOne.BodyParameters | any, +): Promise<{ + error: string; + data: Definitions.ApplicationBilling; +}> { + // /v1/apps/{appid}/billings/{id} + let _params: { [key: string]: any } = { + appid: useGlobalStore.getState().currentApp?.appid || "", + ...params, + }; + return request(`/v1/apps/${_params.appid}/billings/${_params.id}`, { + method: "GET", + params: params, + }); +} diff --git a/web/src/apis/v1/auth.ts b/web/src/apis/v1/auth.ts index 03a7083efb..7c0315d950 100644 --- a/web/src/apis/v1/auth.ts +++ b/web/src/apis/v1/auth.ts @@ -15,7 +15,10 @@ import useGlobalStore from "@/pages/globalStore"; */ export async function UserPasswordControllerSignup( params: Definitions.PasswdSignupDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.UserPasswordControllerSignup.Responses; +}> { // /v1/auth/passwd/signup let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -32,7 +35,10 @@ export async function UserPasswordControllerSignup( */ export async function UserPasswordControllerSignin( params: Definitions.PasswdSigninDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.UserPasswordControllerSignin.Responses; +}> { // /v1/auth/passwd/signin let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -49,7 +55,10 @@ export async function UserPasswordControllerSignin( */ export async function UserPasswordControllerReset( params: Definitions.PasswdResetDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.UserPasswordControllerReset.Responses; +}> { // /v1/auth/passwd/reset let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -66,7 +75,10 @@ export async function UserPasswordControllerReset( */ export async function UserPasswordControllerCheck( params: Definitions.PasswdCheckDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.UserPasswordControllerCheck.Responses; +}> { // /v1/auth/passwd/check let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -81,9 +93,10 @@ export async function UserPasswordControllerCheck( /** * Send phone verify code */ -export async function PhoneControllerSendCode( - params: Definitions.SendPhoneCodeDto | any, -): Promise { +export async function PhoneControllerSendCode(params: Definitions.SendPhoneCodeDto | any): Promise<{ + error: string; + data: Paths.PhoneControllerSendCode.Responses; +}> { // /v1/auth/phone/sms/code let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -98,9 +111,10 @@ export async function PhoneControllerSendCode( /** * Signin by phone and verify code */ -export async function PhoneControllerSignin( - params: Definitions.PhoneSigninDto | any, -): Promise { +export async function PhoneControllerSignin(params: Definitions.PhoneSigninDto | any): Promise<{ + error: string; + data: Paths.PhoneControllerSignin.Responses; +}> { // /v1/auth/phone/signin let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -117,7 +131,10 @@ export async function PhoneControllerSignin( */ export async function AuthenticationControllerGetProviders( params: Paths.AuthenticationControllerGetProviders.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.AuthenticationControllerGetProviders.Responses; +}> { // /v1/auth/providers let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -134,7 +151,10 @@ export async function AuthenticationControllerGetProviders( */ export async function AuthenticationControllerBindPhone( params: Definitions.BindPhoneDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.AuthenticationControllerBindPhone.Responses; +}> { // /v1/auth/bind/phone let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -151,7 +171,10 @@ export async function AuthenticationControllerBindPhone( */ export async function AuthenticationControllerBindUsername( params: Definitions.BindUsernameDto | any, -): Promise { +): Promise<{ + error: string; + data: Paths.AuthenticationControllerBindUsername.Responses; +}> { // /v1/auth/bind/username let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", diff --git a/web/src/apis/v1/code2token.ts b/web/src/apis/v1/code2token.ts deleted file mode 100644 index 92ba63ae7a..0000000000 --- a/web/src/apis/v1/code2token.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-ignore -/* eslint-disable */ -/////////////////////////////////////////////////////////////////////// -// // -// this file is autogenerated by service-generate // -// do not edit this file manually // -// // -/////////////////////////////////////////////////////////////////////// -/// -import request from "@/utils/request"; -import useGlobalStore from "@/pages/globalStore"; - -/** - * Get user token by auth code - */ -export async function AuthControllerCode2token( - params: Paths.AuthControllerCode2token.BodyParameters | any, -): Promise { - // /v1/code2token - let _params: { [key: string]: any } = { - appid: useGlobalStore.getState().currentApp?.appid || "", - ...params, - }; - return request(`/v1/code2token`, { - method: "GET", - params: params, - }); -} diff --git a/web/src/apis/v1/login.ts b/web/src/apis/v1/login.ts deleted file mode 100644 index 6393c43747..0000000000 --- a/web/src/apis/v1/login.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-ignore -/* eslint-disable */ -/////////////////////////////////////////////////////////////////////// -// // -// this file is autogenerated by service-generate // -// do not edit this file manually // -// // -/////////////////////////////////////////////////////////////////////// -/// -import request from "@/utils/request"; -import useGlobalStore from "@/pages/globalStore"; - -/** - * Redirect to login page - */ -export async function AuthControllerGetSigninUrl( - params: Paths.AuthControllerGetSigninUrl.BodyParameters | any, -): Promise { - // /v1/login - let _params: { [key: string]: any } = { - appid: useGlobalStore.getState().currentApp?.appid || "", - ...params, - }; - return request(`/v1/login`, { - method: "GET", - params: params, - }); -} diff --git a/web/src/apis/v1/pat2token.ts b/web/src/apis/v1/pat2token.ts index 5a32849ae9..47da2c1b8d 100644 --- a/web/src/apis/v1/pat2token.ts +++ b/web/src/apis/v1/pat2token.ts @@ -13,9 +13,10 @@ import useGlobalStore from "@/pages/globalStore"; /** * Get user token by PAT */ -export async function AuthControllerPat2token( - params: Definitions.Pat2TokenDto | any, -): Promise { +export async function AuthControllerPat2token(params: Definitions.Pat2TokenDto | any): Promise<{ + error: string; + data: Paths.AuthControllerPat2token.Responses; +}> { // /v1/pat2token let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", diff --git a/web/src/apis/v1/pats.ts b/web/src/apis/v1/pats.ts index ae88425fb6..194b60df47 100644 --- a/web/src/apis/v1/pats.ts +++ b/web/src/apis/v1/pats.ts @@ -13,9 +13,10 @@ import useGlobalStore from "@/pages/globalStore"; /** * Create a PAT */ -export async function PatControllerCreate( - params: Definitions.CreatePATDto | any, -): Promise { +export async function PatControllerCreate(params: Definitions.CreatePATDto | any): Promise<{ + error: string; + data: Paths.PatControllerCreate.Responses; +}> { // /v1/pats let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -32,7 +33,10 @@ export async function PatControllerCreate( */ export async function PatControllerFindAll( params: Paths.PatControllerFindAll.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.PatControllerFindAll.Responses; +}> { // /v1/pats let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -49,7 +53,10 @@ export async function PatControllerFindAll( */ export async function PatControllerRemove( params: Paths.PatControllerRemove.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.PatControllerRemove.Responses; +}> { // /v1/pats/{id} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", diff --git a/web/src/apis/v1/profile.ts b/web/src/apis/v1/profile.ts index 983acf8b25..938f467d29 100644 --- a/web/src/apis/v1/profile.ts +++ b/web/src/apis/v1/profile.ts @@ -15,7 +15,10 @@ import useGlobalStore from "@/pages/globalStore"; */ export async function AuthControllerGetProfile( params: Paths.AuthControllerGetProfile.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Definitions.UserWithProfile; +}> { // /v1/profile let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", diff --git a/web/src/apis/v1/regions.ts b/web/src/apis/v1/regions.ts index 9aa3d852c2..40cc02b733 100644 --- a/web/src/apis/v1/regions.ts +++ b/web/src/apis/v1/regions.ts @@ -15,7 +15,10 @@ import useGlobalStore from "@/pages/globalStore"; */ export async function RegionControllerGetRegions( params: Paths.RegionControllerGetRegions.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.RegionControllerGetRegions.Responses; +}> { // /v1/regions let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", diff --git a/web/src/apis/v1/register.ts b/web/src/apis/v1/register.ts deleted file mode 100644 index 8efd18f407..0000000000 --- a/web/src/apis/v1/register.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-ignore -/* eslint-disable */ -/////////////////////////////////////////////////////////////////////// -// // -// this file is autogenerated by service-generate // -// do not edit this file manually // -// // -/////////////////////////////////////////////////////////////////////// -/// -import request from "@/utils/request"; -import useGlobalStore from "@/pages/globalStore"; - -/** - * Redirect to register page - */ -export async function AuthControllerGetSignupUrl( - params: Paths.AuthControllerGetSignupUrl.BodyParameters | any, -): Promise { - // /v1/register - let _params: { [key: string]: any } = { - appid: useGlobalStore.getState().currentApp?.appid || "", - ...params, - }; - return request(`/v1/register`, { - method: "GET", - params: params, - }); -} diff --git a/web/src/apis/v1/resources.ts b/web/src/apis/v1/resources.ts new file mode 100644 index 0000000000..c9b97516d7 --- /dev/null +++ b/web/src/apis/v1/resources.ts @@ -0,0 +1,91 @@ +// @ts-ignore +/* eslint-disable */ +/////////////////////////////////////////////////////////////////////// +// // +// this file is autogenerated by service-generate // +// do not edit this file manually // +// // +/////////////////////////////////////////////////////////////////////// +/// +import request from "@/utils/request"; +import useGlobalStore from "@/pages/globalStore"; + +/** + * Calculate pricing + */ +export async function ResourceControllerCalculatePrice( + params: Definitions.CalculatePriceDto | any, +): Promise<{ + error: string; + data: Paths.ResourceControllerCalculatePrice.Responses; +}> { + // /v1/resources/price + let _params: { [key: string]: any } = { + appid: useGlobalStore.getState().currentApp?.appid || "", + ...params, + }; + return request(`/v1/resources/price`, { + method: "POST", + data: params, + }); +} + +/** + * Get resource option list + */ +export async function ResourceControllerGetResourceOptions( + params: Paths.ResourceControllerGetResourceOptions.BodyParameters | any, +): Promise<{ + error: string; + data: Paths.ResourceControllerGetResourceOptions.Responses; +}> { + // /v1/resources/resource-options + let _params: { [key: string]: any } = { + appid: useGlobalStore.getState().currentApp?.appid || "", + ...params, + }; + return request(`/v1/resources/resource-options`, { + method: "GET", + params: params, + }); +} + +/** + * Get resource option list by region id + */ +export async function ResourceControllerGetResourceOptionsByRegionId( + params: Paths.ResourceControllerGetResourceOptionsByRegionId.BodyParameters | any, +): Promise<{ + error: string; + data: Paths.ResourceControllerGetResourceOptionsByRegionId.Responses; +}> { + // /v1/resources/resource-options/{regionId} + let _params: { [key: string]: any } = { + appid: useGlobalStore.getState().currentApp?.appid || "", + ...params, + }; + return request(`/v1/resources/resource-options/${_params.regionId}`, { + method: "GET", + params: params, + }); +} + +/** + * Get resource template list + */ +export async function ResourceControllerGetResourceBundles( + params: Paths.ResourceControllerGetResourceBundles.BodyParameters | any, +): Promise<{ + error: string; + data: Paths.ResourceControllerGetResourceBundles.Responses; +}> { + // /v1/resources/resource-bundles + let _params: { [key: string]: any } = { + appid: useGlobalStore.getState().currentApp?.appid || "", + ...params, + }; + return request(`/v1/resources/resource-bundles`, { + method: "GET", + params: params, + }); +} diff --git a/web/src/apis/v1/runtimes.ts b/web/src/apis/v1/runtimes.ts index 9a3909f231..6719fb51ef 100644 --- a/web/src/apis/v1/runtimes.ts +++ b/web/src/apis/v1/runtimes.ts @@ -15,7 +15,10 @@ import useGlobalStore from "@/pages/globalStore"; */ export async function AppControllerGetRuntimes( params: Paths.AppControllerGetRuntimes.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.AppControllerGetRuntimes.Responses; +}> { // /v1/runtimes let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", diff --git a/web/src/apis/v1/settings.ts b/web/src/apis/v1/settings.ts index 8dc33b079a..e6f3a06c70 100644 --- a/web/src/apis/v1/settings.ts +++ b/web/src/apis/v1/settings.ts @@ -15,7 +15,10 @@ import useGlobalStore from "@/pages/globalStore"; */ export async function SettingControllerGetSettings( params: Paths.SettingControllerGetSettings.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.SettingControllerGetSettings.Responses; +}> { // /v1/settings let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", @@ -32,7 +35,10 @@ export async function SettingControllerGetSettings( */ export async function SettingControllerGetSettingByKey( params: Paths.SettingControllerGetSettingByKey.BodyParameters | any, -): Promise { +): Promise<{ + error: string; + data: Paths.SettingControllerGetSettingByKey.Responses; +}> { // /v1/settings/{key} let _params: { [key: string]: any } = { appid: useGlobalStore.getState().currentApp?.appid || "", diff --git a/web/src/apis/v1/subscriptions.ts b/web/src/apis/v1/subscriptions.ts deleted file mode 100644 index 04ac6087d1..0000000000 --- a/web/src/apis/v1/subscriptions.ts +++ /dev/null @@ -1,113 +0,0 @@ -// @ts-ignore -/* eslint-disable */ -/////////////////////////////////////////////////////////////////////// -// // -// this file is autogenerated by service-generate // -// do not edit this file manually // -// // -/////////////////////////////////////////////////////////////////////// -/// -import request from "@/utils/request"; -import useGlobalStore from "@/pages/globalStore"; - -/** - * Create a new subscription - */ -export async function SubscriptionControllerCreate( - params: Definitions.CreateSubscriptionDto | any, -): Promise { - // /v1/subscriptions - let _params: { [key: string]: any } = { - appid: useGlobalStore.getState().currentApp?.appid || "", - ...params, - }; - return request(`/v1/subscriptions`, { - method: "POST", - data: params, - }); -} - -/** - * Get user's subscriptions - */ -export async function SubscriptionControllerFindAll( - params: Paths.SubscriptionControllerFindAll.BodyParameters | any, -): Promise { - // /v1/subscriptions - let _params: { [key: string]: any } = { - appid: useGlobalStore.getState().currentApp?.appid || "", - ...params, - }; - return request(`/v1/subscriptions`, { - method: "GET", - params: params, - }); -} - -/** - * Get subscription by appid - */ -export async function SubscriptionControllerFindOne( - params: Paths.SubscriptionControllerFindOne.BodyParameters | any, -): Promise { - // /v1/subscriptions/{appid} - let _params: { [key: string]: any } = { - appid: useGlobalStore.getState().currentApp?.appid || "", - ...params, - }; - return request(`/v1/subscriptions/${_params.appid}`, { - method: "GET", - params: params, - }); -} - -/** - * Renew a subscription - */ -export async function SubscriptionControllerRenew( - params: Definitions.RenewSubscriptionDto | any, -): Promise { - // /v1/subscriptions/{id}/renewal - let _params: { [key: string]: any } = { - appid: useGlobalStore.getState().currentApp?.appid || "", - ...params, - }; - return request(`/v1/subscriptions/${_params.id}/renewal`, { - method: "POST", - data: params, - }); -} - -/** - * Upgrade a subscription - TODO - */ -export async function SubscriptionControllerUpgrade( - params: Definitions.UpgradeSubscriptionDto | any, -): Promise { - // /v1/subscriptions/{id}/upgrade - let _params: { [key: string]: any } = { - appid: useGlobalStore.getState().currentApp?.appid || "", - ...params, - }; - return request(`/v1/subscriptions/${_params.id}/upgrade`, { - method: "PATCH", - data: params, - }); -} - -/** - * Delete a subscription - */ -export async function SubscriptionControllerRemove( - params: Paths.SubscriptionControllerRemove.BodyParameters | any, -): Promise { - // /v1/subscriptions/{id} - let _params: { [key: string]: any } = { - appid: useGlobalStore.getState().currentApp?.appid || "", - ...params, - }; - return request(`/v1/subscriptions/${_params.id}`, { - method: "DELETE", - data: params, - }); -} diff --git a/web/src/components/ChargeButton/index.tsx b/web/src/components/ChargeButton/index.tsx index 483a9fc49c..11297fcc8f 100644 --- a/web/src/components/ChargeButton/index.tsx +++ b/web/src/components/ChargeButton/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React from "react"; import { Button, Input, @@ -26,44 +26,37 @@ export default function ChargeButton(props: { amount?: number; children: React.R const { children } = props; const { isOpen, onOpen, onClose } = useDisclosure(); - const initialAmount = props.amount && props.amount > 0 ? props.amount : 100; + const [amount, setAmount] = React.useState(); - const [amount, setAmount] = React.useState(initialAmount); + const [phaseStatus, setPhaseStatus] = React.useState(); - const [phaseStatus, setPhaseStatus] = React.useState<"Pending" | "Paid" | undefined>(); - - const createChargeOrder = useMutation( + const { data: createOrderRes, ...createChargeOrder } = useMutation( ["AccountControllerCharge"], (params: any) => AccountControllerCharge(params), {}, ); - const accountQuery = useAccountQuery(); + const { data: accountRes, refetch: accountRefetch } = useAccountQuery(); useQuery( ["AccountControllerGetChargeOrder"], () => AccountControllerGetChargeOrder({ - id: createChargeOrder.data?.data?.order?.id, + id: createOrderRes?.data?.order?.id, }), { - enabled: !!createChargeOrder.data?.data?.order?.id && isOpen, + enabled: !!createOrderRes?.data?.order?.id && isOpen, refetchInterval: phaseStatus === "Pending" && isOpen ? 1000 : false, - onSuccess: (data) => { - setPhaseStatus(data.phase); - if (data.phase === "Paid") { - accountQuery.refetch(); + onSuccess: (res) => { + setPhaseStatus(res?.data?.phase); + if (res?.data?.phase === "Paid") { + accountRefetch(); onClose(); } }, }, ); - useEffect(() => { - const initialAmount = props.amount && props.amount > 0 ? props.amount : 100; - setAmount(initialAmount); - }, [props.amount]); - return ( <> {React.cloneElement(children, { onClick: onOpen })} @@ -76,7 +69,7 @@ export default function ChargeButton(props: { amount?: number; children: React.R

    {t("Balance")}

    - {formatPrice(accountQuery.data?.balance)} + {formatPrice(accountRes?.data?.balance)}

    {t("Recharge amount")}

    @@ -90,13 +83,28 @@ export default function ChargeButton(props: { amount?: number; children: React.R }} /> +
    + {[1000, 5000, 10000, 50000, 100000, 500000].map((item) => ( + + ))} +
    - {createChargeOrder.data?.data?.result?.code_url && ( + {createOrderRes?.data?.result?.code_url && (

    {t("Scan with WeChat")}

    - {t("Order Number")}:{createChargeOrder.data?.data?.order?.id} + {t("Order Number")}:{createOrderRes?.data?.order?._id}

    {t("payment status")}: {phaseStatus} diff --git a/web/src/layouts/Header/index.tsx b/web/src/layouts/Header/index.tsx index ccf16712ef..8e94de597c 100644 --- a/web/src/layouts/Header/index.tsx +++ b/web/src/layouts/Header/index.tsx @@ -14,6 +14,7 @@ import useGlobalStore from "@/pages/globalStore"; export default function Header(props: { size: "sm" | "lg" }) { const { userInfo } = useGlobalStore((state) => state); + const { t } = useTranslation(); const { colorMode } = useColorMode(); @@ -39,10 +40,10 @@ export default function Header(props: { size: "sm" | "lg" }) {

    - {userInfo?.profile ? ( + {userInfo?._id ? ( <> diff --git a/web/src/pages/LoginCallback.tsx b/web/src/pages/LoginCallback.tsx deleted file mode 100644 index 84b5592b75..0000000000 --- a/web/src/pages/LoginCallback.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useNavigate, useSearchParams } from "react-router-dom"; -import { Center, Spinner } from "@chakra-ui/react"; -import { useQuery } from "@tanstack/react-query"; - -import { Routes } from "@/constants"; - -import { AuthControllerCode2token } from "@/apis/v1/code2token"; - -export default function LoginCallBack() { - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const code = searchParams.get("code"); - - const tokenRes = useQuery(["tokenRes"], () => { - return AuthControllerCode2token({ - code, - }); - }); - - if (!tokenRes.isLoading && tokenRes.data?.data) { - localStorage.setItem("token", tokenRes.data?.data); - navigate(Routes.dashboard, { replace: true }); - } - - return ( -
    {tokenRes.isLoading ? : <>{tokenRes.data.error}}
    - ); -} diff --git a/web/src/pages/app/functions/mods/AIChatPanel/index.tsx b/web/src/pages/app/functions/mods/AIChatPanel/index.tsx new file mode 100644 index 0000000000..afbf66e17d --- /dev/null +++ b/web/src/pages/app/functions/mods/AIChatPanel/index.tsx @@ -0,0 +1,5 @@ +import React from "react"; + +export default function AIChatPanel() { + return
    AIChatPanel
    ; +} diff --git a/web/src/pages/app/functions/mods/DebugPanel/index.tsx b/web/src/pages/app/functions/mods/DebugPanel/index.tsx index e303e7062c..a16ec3dcb8 100644 --- a/web/src/pages/app/functions/mods/DebugPanel/index.tsx +++ b/web/src/pages/app/functions/mods/DebugPanel/index.tsx @@ -25,6 +25,7 @@ import { COLOR_MODE, Pages } from "@/constants"; import { useCompileMutation, useUpdateFunctionMutation } from "../../service"; import useFunctionStore from "../../store"; +import AIChatPanel from "../AIChatPanel"; import BodyParamsTab from "./BodyParamsTab"; import QueryParamsTab from "./QueryParamsTab"; @@ -77,11 +78,11 @@ export default function DebugPanel(props: { containerRef: any; showOverlay: bool }, [setRunningMethod, currentFunction]); const runningCode = async () => { - if (isLoading || !currentFunction?.id) return; + if (isLoading || !currentFunction?._id) return; setIsLoading(true); try { const compileRes = await compileMutation.mutateAsync({ - code: functionCache.getCache(currentFunction!.id, currentFunction!.source?.code), + code: functionCache.getCache(currentFunction!._id, currentFunction!.source?.code), name: currentFunction!.name, }); @@ -129,7 +130,14 @@ export default function DebugPanel(props: { containerRef: any; showOverlay: bool return ( <> - + - {/* 历史请求 */} + Laf Pilot @@ -317,7 +325,9 @@ export default function DebugPanel(props: { containerRef: any; showOverlay: bool src={String(t("HomePage.DocsLink"))} /> - {/* to be continued... */} + + + diff --git a/web/src/pages/app/functions/mods/DeployButton/index.tsx b/web/src/pages/app/functions/mods/DeployButton/index.tsx index a4b7ce30c4..f8449dec21 100644 --- a/web/src/pages/app/functions/mods/DeployButton/index.tsx +++ b/web/src/pages/app/functions/mods/DeployButton/index.tsx @@ -51,7 +51,7 @@ export default function DeployButton() { const deploy = async () => { const res = await updateFunctionMutation.mutateAsync({ description: store.currentFunction?.desc, - code: functionCache.getCache(store.currentFunction!.id, store.currentFunction!.source?.code), + code: functionCache.getCache(store.currentFunction!._id, store.currentFunction!.source?.code), methods: store.currentFunction?.methods, websocket: store.currentFunction?.websocket, name: store.currentFunction?.name, @@ -60,7 +60,7 @@ export default function DeployButton() { if (!res.error) { store.setCurrentFunction(res.data); // delete cache after deploy - functionCache.removeCache(store.currentFunction!.id); + functionCache.removeCache(store.currentFunction!._id); onClose(); showSuccess(t("FunctionPanel.DeploySuccess")); } @@ -94,7 +94,7 @@ export default function DeployButton() { diff --git a/web/src/pages/app/functions/mods/EditorPanel/index.tsx b/web/src/pages/app/functions/mods/EditorPanel/index.tsx index 9a073b09c9..cdc07f3e59 100644 --- a/web/src/pages/app/functions/mods/EditorPanel/index.tsx +++ b/web/src/pages/app/functions/mods/EditorPanel/index.tsx @@ -41,8 +41,8 @@ function EditorPanel() { {currentFunction?.name} - {currentFunction?.id && - functionCache.getCache(currentFunction?.id, currentFunction?.source?.code) !== + {currentFunction?._id && + functionCache.getCache(currentFunction?._id, currentFunction?.source?.code) !== currentFunction?.source?.code && ( )} @@ -93,11 +93,11 @@ function EditorPanel() { marginLeft: -14, marginRight: -14, }} - path={currentFunction?.id || ""} - value={functionCache.getCache(currentFunction!.id, currentFunction!.source?.code)} + path={currentFunction?._id || ""} + value={functionCache.getCache(currentFunction!._id, currentFunction!.source?.code)} onChange={(value) => { updateFunctionCode(currentFunction, value || ""); - functionCache.setCache(currentFunction!.id, value || ""); + functionCache.setCache(currentFunction!._id, value || ""); }} /> )} diff --git a/web/src/pages/app/functions/mods/FunctionPanel/index.tsx b/web/src/pages/app/functions/mods/FunctionPanel/index.tsx index 16a13d5160..51001ed068 100644 --- a/web/src/pages/app/functions/mods/FunctionPanel/index.tsx +++ b/web/src/pages/app/functions/mods/FunctionPanel/index.tsx @@ -81,7 +81,7 @@ export default function FunctionList() { }); setTagsList(newTags); - if (!currentFunction?.id && data.data.length > 0) { + if (!currentFunction?._id && data.data.length > 0) { const currentFunction = data.data.find((item: TFunction) => item.name === functionName) || data.data[0]; setCurrentFunction(currentFunction); diff --git a/web/src/pages/app/functions/store.ts b/web/src/pages/app/functions/store.ts index 262d173b31..f435800bc2 100644 --- a/web/src/pages/app/functions/store.ts +++ b/web/src/pages/app/functions/store.ts @@ -57,7 +57,7 @@ const useFunctionStore = create()( updateFunctionCode: async (currentFunction, codes) => { set((state) => { - state.functionCodes[currentFunction!.id] = codes; + state.functionCodes[currentFunction!._id] = codes; }); }, })), diff --git a/web/src/pages/app/mods/SideBar/index.tsx b/web/src/pages/app/mods/SideBar/index.tsx index 8ffb74b072..f7d04e12dd 100644 --- a/web/src/pages/app/mods/SideBar/index.tsx +++ b/web/src/pages/app/mods/SideBar/index.tsx @@ -64,7 +64,7 @@ export default function SideBar() { pageId: Pages.userSetting, component: ( diff --git a/web/src/pages/app/mods/StatusBar/index.tsx b/web/src/pages/app/mods/StatusBar/index.tsx index 70d4ad1cd9..8e753169c4 100644 --- a/web/src/pages/app/mods/StatusBar/index.tsx +++ b/web/src/pages/app/mods/StatusBar/index.tsx @@ -1,10 +1,8 @@ import { useTranslation } from "react-i18next"; import { HStack } from "@chakra-ui/react"; import clsx from "clsx"; -import dayjs from "dayjs"; import Panel from "@/components/Panel"; -import { formatDate } from "@/utils/format"; import Icons from "../SideBar/Icons"; @@ -35,16 +33,9 @@ function StatusBar() {
    {t("Spec.RAM")}: {`${currentApp?.bundle?.resource.limitMemory} ${t("Unit.MB")}`}
    -
    - {t("EndTime")}: {formatDate(currentApp?.subscription.expiredAt)} - +
    + {/* {t("EndTime")}: {formatDate(currentApp?.subscription.expiredAt)} */} + {t("Renew")} diff --git a/web/src/pages/app/setting/AppInfoList/index.tsx b/web/src/pages/app/setting/AppInfoList/index.tsx index 61903a1b23..1247f7b7c8 100644 --- a/web/src/pages/app/setting/AppInfoList/index.tsx +++ b/web/src/pages/app/setting/AppInfoList/index.tsx @@ -6,7 +6,6 @@ import { Box, Button, HStack, useColorMode } from "@chakra-ui/react"; import clsx from "clsx"; import { APP_PHASE_STATUS, APP_STATUS, COLOR_MODE, Routes } from "@/constants/index"; -import { formatDate } from "@/utils/format"; import InfoDetail from "./InfoDetail"; @@ -25,7 +24,7 @@ const AppEnvList = () => { return <>; } - const currentRegion = regions.find((item) => item.id === currentApp?.regionId); + const currentRegion = regions.find((item) => item._id === currentApp?.regionId); return ( <> @@ -147,7 +146,7 @@ const AppEnvList = () => { ]} /> - { value: `${formatDate(currentApp?.subscription?.expiredAt)}`, }, ]} - /> + /> */}
    diff --git a/web/src/pages/app/setting/UserInfo/index.tsx b/web/src/pages/app/setting/UserInfo/index.tsx index 0488756a92..f2f02ed242 100644 --- a/web/src/pages/app/setting/UserInfo/index.tsx +++ b/web/src/pages/app/setting/UserInfo/index.tsx @@ -30,7 +30,7 @@ export default function UserInfo() { { deleteWebsiteMutation.mutateAsync({ - id: currentStorage?.websiteHosting?.id, + id: currentStorage?.websiteHosting?._id, }); }} > @@ -177,7 +177,7 @@ function CreateWebsiteModal() { isLoading={updateWebsiteMutation.isLoading} onClick={handleSubmit(async (value) => { const res: any = await updateWebsiteMutation.mutateAsync({ - id: currentStorage?.websiteHosting.id, + id: currentStorage?.websiteHosting._id, domain: value.domain, }); if (res.data) { diff --git a/web/src/pages/app/storages/mods/StorageListPanel/index.tsx b/web/src/pages/app/storages/mods/StorageListPanel/index.tsx index ded5b5b4bd..f7ded870f0 100644 --- a/web/src/pages/app/storages/mods/StorageListPanel/index.tsx +++ b/web/src/pages/app/storages/mods/StorageListPanel/index.tsx @@ -34,7 +34,7 @@ export default function StorageListPanel() { store.setCurrentStorage(data?.data[0]); } else { store.setCurrentStorage( - data?.data?.filter((item: any) => item.id === store?.currentStorage?.id)[0], + data?.data?.filter((item: any) => item.id === store?.currentStorage?._id)[0], ); } } else { diff --git a/web/src/pages/globalStore.ts b/web/src/pages/globalStore.ts index e666acc85f..566e143c12 100644 --- a/web/src/pages/globalStore.ts +++ b/web/src/pages/globalStore.ts @@ -3,26 +3,24 @@ import { create } from "zustand"; import { devtools } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; -import { APP_PHASE_STATUS, APP_STATUS, CHAKRA_UI_COLOR_MODE_KEY } from "@/constants"; +import { APP_STATUS, CHAKRA_UI_COLOR_MODE_KEY } from "@/constants"; import { formatPort } from "@/utils/format"; -import { TApplicationDetail, TRegion, TRuntime, TUserInfo } from "@/apis/typing"; +import { TApplicationDetail, TRegion, TRuntime } from "@/apis/typing"; import { ApplicationControllerUpdate } from "@/apis/v1/applications"; -import { AuthControllerGetSigninUrl } from "@/apis/v1/login"; import { AuthControllerGetProfile } from "@/apis/v1/profile"; import { RegionControllerGetRegions } from "@/apis/v1/regions"; import { AppControllerGetRuntimes } from "@/apis/v1/runtimes"; -import { SubscriptionControllerRemove } from "@/apis/v1/subscriptions"; const { toast } = createStandaloneToast(); type State = { - userInfo: TUserInfo | undefined; + userInfo: Definitions.UserWithProfile | undefined; loading: boolean; runtimes?: TRuntime[]; regions?: TRegion[]; - currentApp: TApplicationDetail | undefined; - setCurrentApp(app: TApplicationDetail | undefined): void; + currentApp: TApplicationDetail | any; + setCurrentApp(app: TApplicationDetail | any): void; init(appid?: string): void; updateCurrentApp(app: TApplicationDetail, state: APP_STATUS): void; deleteCurrentApp(): void; @@ -60,7 +58,7 @@ const useGlobalStore = create()( init: async () => { const userInfo = get().userInfo; - if (userInfo?.id) { + if (userInfo?._id) { return; } @@ -101,16 +99,16 @@ const useGlobalStore = create()( if (!app) { return; } - const deleteRes = await SubscriptionControllerRemove({ - appid: app.appid, - }); - if (!deleteRes.error) { - set((state) => { - if (state.currentApp) { - state.currentApp.phase = APP_PHASE_STATUS.Deleting; - } - }); - } + // const deleteRes = await SubscriptionControllerRemove({ + // appid: app.appid, + // }); + // if (!deleteRes.error) { + // set((state) => { + // if (state.currentApp) { + // state.currentApp.phase = APP_PHASE_STATUS.Deleting; + // } + // }); + // } }, setCurrentApp: (app) => { @@ -126,10 +124,6 @@ const useGlobalStore = create()( }); }, - login: async () => { - await AuthControllerGetSigninUrl({}); - }, - showSuccess: (text: string | React.ReactNode) => { toast({ position: "top", diff --git a/web/src/pages/home/mods/CreateAppModal/BundleItem/index.tsx b/web/src/pages/home/mods/CreateAppModal/BundleItem/index.tsx index f9d63ce799..87dec33478 100644 --- a/web/src/pages/home/mods/CreateAppModal/BundleItem/index.tsx +++ b/web/src/pages/home/mods/CreateAppModal/BundleItem/index.tsx @@ -1,101 +1,31 @@ import { useColorMode } from "@chakra-ui/react"; import clsx from "clsx"; -import { t } from "i18next"; import { COLOR_MODE } from "@/constants"; -import { - formatLimitCapacity, - formatLimitCPU, - formatLimitMemory, - formatLimitTraffic, - formatPrice, -} from "@/utils/format"; import { TBundle } from "@/apis/typing"; -const ListItem = (props: { item: { key: string; value: string | number } }) => { - const { item } = props; - return ( -
    -
    {item.key}
    -
    {item.value}
    -
    - ); -}; - export default function BundleItem(props: { onChange: (...event: any[]) => void; - bundle: TBundle; - durationIndex: number; + bundle: TBundle | any; isActive: boolean; }) { const { bundle, isActive, onChange } = props; const { colorMode } = useColorMode(); const darkMode = colorMode === COLOR_MODE.dark; - let durationIndex = props.durationIndex; - if (durationIndex < 0) { - durationIndex = 0; - } - - const months = bundle.subscriptionOptions[durationIndex].duration / (60 * 60 * 24 * 31); return (
    onChange(bundle.id)} - key={bundle.name} - className={clsx("min-w-[170px] cursor-pointer rounded-md border p-2", { - "border-primary-500 bg-lafWhite-400": isActive && !darkMode, + onClick={() => onChange(bundle._id)} + key={bundle._id} + className={clsx("mb-2 min-w-[170px] cursor-pointer rounded-md border bg-lafWhite-600 p-2", { + "bg-primary-500 text-white": isActive && !darkMode, + "border-primary-500": isActive && !darkMode, "bg-lafDark-400": isActive && darkMode, })} > -
    -

    {bundle.displayName}

    -

    - {bundle.subscriptionOptions[durationIndex].specialPrice === 0 ? ( - t("Price.Free") - ) : ( - <> - {formatPrice(bundle.subscriptionOptions[durationIndex].specialPrice / months)} - / {t("Monthly")} - - )} -

    -
    - - - - - +

    {bundle.displayName}

    ); diff --git a/web/src/pages/home/mods/CreateAppModal/index.tsx b/web/src/pages/home/mods/CreateAppModal/index.tsx index 006c445136..c61df35abf 100644 --- a/web/src/pages/home/mods/CreateAppModal/index.tsx +++ b/web/src/pages/home/mods/CreateAppModal/index.tsx @@ -1,7 +1,8 @@ import React, { useEffect } from "react"; -import { Controller, useForm, useWatch } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; import { CheckIcon } from "@chakra-ui/icons"; import { + Box, Button, FormControl, FormErrorMessage, @@ -15,14 +16,17 @@ import { ModalFooter, ModalHeader, ModalOverlay, - Radio, - Stack, + Slider, + SliderFilledTrack, + SliderMark, + SliderThumb, + SliderTrack, useDisclosure, VStack, } from "@chakra-ui/react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { t } from "i18next"; -import { sortBy } from "lodash"; +import { debounce, find } from "lodash"; import ChargeButton from "@/components/ChargeButton"; // import ChargeButton from "@/components/ChargeButton"; @@ -30,18 +34,24 @@ import { APP_STATUS } from "@/constants/index"; import { formatPrice } from "@/utils/format"; import { APP_LIST_QUERY_KEY } from "../.."; -import { useAccountQuery } from "../../service"; +import { queryKeys, useAccountQuery } from "../../service"; import BundleItem from "./BundleItem"; -import RuntimeItem from "./RuntimeItem"; import { TApplicationItem, TBundle } from "@/apis/typing"; -import { ApplicationControllerUpdate } from "@/apis/v1/applications"; -import { SubscriptionControllerCreate, SubscriptionControllerRenew } from "@/apis/v1/subscriptions"; +import { + ApplicationControllerCreate, + ApplicationControllerUpdate, + ApplicationControllerUpdateBundle, +} from "@/apis/v1/applications"; +import { + ResourceControllerCalculatePrice, + ResourceControllerGetResourceOptions, +} from "@/apis/v1/resources"; import useGlobalStore from "@/pages/globalStore"; const CreateAppModal = (props: { - type: "create" | "edit" | "renewal"; + type: "create" | "edit" | "change"; application?: TApplicationItem; children: React.ReactElement; }) => { @@ -50,49 +60,54 @@ const CreateAppModal = (props: { const { application, type } = props; - const title = type === "edit" ? t("Edit") : type === "renewal" ? t("Renew") : t("Create"); + const title = type === "edit" ? t("Edit") : type === "change" ? t("Change") : t("Create"); const { runtimes = [], regions = [] } = useGlobalStore(); - const accountQuery = useAccountQuery(); + const { data: accountRes } = useAccountQuery(); + + const { data: billingResourceOptionsRes } = useQuery( + queryKeys.useBillingResourceOptionsQuery, + async () => { + return ResourceControllerGetResourceOptions({}); + }, + { + enabled: isOpen, + }, + ); type FormData = { name: string; state: APP_STATUS | string; regionId: string; - bundleId: string; runtimeId: string; - subscriptionOption: - | { - id: string; - } - | any; + bundleId: string; + cpu: number; + memory: number; + databaseCapacity: number; + storageCapacity: number; }; const currentRegion = regions.find((item: any) => item.id === application?.regionId) || regions[0]; - const bundles = sortBy(currentRegion.bundles, (item: TBundle) => item.priority); + const bundles = currentRegion.bundles; let defaultValues = { name: application?.name, state: application?.state, regionId: application?.regionId, - bundleId: application?.bundle?.bundleId, - subscriptionOption: bundles.find((item: TBundle) => item.id === application?.bundle?.bundleId) - ?.subscriptionOptions[0], - runtimeId: runtimes[0].id, + runtimeId: runtimes[0]._id, + bundleId: bundles[0]._id, }; if (type === "create") { defaultValues = { name: "", state: APP_STATUS.Running, - regionId: regions[0].id, - bundleId: bundles[0].id, - subscriptionOption: - (bundles[0].subscriptionOptions && bundles[0].subscriptionOptions[0]) || {}, - runtimeId: runtimes[0].id, + regionId: regions[0]._id, + runtimeId: runtimes[0]._id, + bundleId: bundles[0]._id, }; } @@ -102,57 +117,88 @@ const CreateAppModal = (props: { control, setFocus, reset, - setValue, formState: { errors }, + getValues, } = useForm({ defaultValues, }); - const bundleId = useWatch({ - control, - name: "bundleId", - }); - - const currentBundle: TBundle = - bundles.find((item: TBundle) => item.id === bundleId) || bundles[0]; + const defaultBundle: { + cpu: number; + memory: number; + databaseCapacity: number; + storageCapacity: number; + } = { + cpu: application?.bundle.resource.limitCPU || bundles[0].spec.cpu.value, + memory: application?.bundle.resource.limitMemory || bundles[0].spec.memory.value, + databaseCapacity: + application?.bundle.resource.databaseCapacity || bundles[0].spec.databaseCapacity.value, + storageCapacity: + application?.bundle.resource.storageCapacity || bundles[0].spec.storageCapacity.value, + }; - const subscriptionOption = useWatch({ - control, - name: "subscriptionOption", - defaultValue: currentBundle.subscriptionOptions[0], - }); + const [bundle, setBundle] = React.useState(defaultBundle); - const currentSubscription = currentBundle.subscriptionOptions[0]; + const [customActive, setCustomActive] = React.useState(false); const { showSuccess } = useGlobalStore(); - const totalPrice = subscriptionOption.specialPrice; - - const subscriptionControllerCreate = useMutation((params: any) => - SubscriptionControllerCreate(params), + const [totalPrice, setTotalPrice] = React.useState(0); + + const billingQuery = useQuery( + [queryKeys.useBillingPriceQuery, bundle, isOpen], + async () => { + return ResourceControllerCalculatePrice({ + ...getValues(), + ...bundle, + }); + }, + { + enabled: false, + staleTime: 1000, + onSuccess(res) { + setTotalPrice(res?.data?.total || 0); + }, + }, ); - const subscriptionOptionRenew = useMutation((params: any) => SubscriptionControllerRenew(params)); + + const debouncedInputChange = debounce((value) => { + if (isOpen) { + billingQuery.refetch(); + } + }, 600); + + useEffect(() => { + debouncedInputChange(bundle); + return () => { + debouncedInputChange.cancel(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bundle, isOpen]); const updateAppMutation = useMutation((params: any) => ApplicationControllerUpdate(params)); + const createAppMutation = useMutation((params: any) => ApplicationControllerCreate(params)); + const changeBundleMutation = useMutation((params: any) => + ApplicationControllerUpdateBundle(params), + ); const onSubmit = async (data: any) => { let res: any = {}; + switch (type) { case "edit": res = await updateAppMutation.mutateAsync({ ...data, appid: application?.appid }); break; - case "create": - res = await subscriptionControllerCreate.mutateAsync({ - ...data, - duration: subscriptionOption.duration, - }); + case "change": + res = await changeBundleMutation.mutateAsync({ ...bundle, appid: application?.appid }); break; - case "renewal": - res = await subscriptionOptionRenew.mutateAsync({ - id: application?.subscription?.id, - duration: subscriptionOption.duration, + case "create": + res = await createAppMutation.mutateAsync({ + ...data, + ...bundle, + // duration: subscriptionOption.duration, }); break; @@ -162,7 +208,7 @@ const CreateAppModal = (props: { if (!res.error) { onClose(); - if (type === "edit" || type === "renewal") { + if (type === "edit") { showSuccess(t("update success")); } setTimeout(() => { @@ -171,9 +217,22 @@ const CreateAppModal = (props: { } }; - useEffect(() => { - setValue("subscriptionOption", currentSubscription); - }, [currentSubscription, setValue]); + const activeBundle = find(bundles, { + spec: { + cpu: { + value: bundle.cpu, + }, + memory: { + value: bundle.memory, + }, + databaseCapacity: { + value: bundle.databaseCapacity, + }, + storageCapacity: { + value: bundle.storageCapacity, + }, + }, + }); return ( <> @@ -188,7 +247,7 @@ const CreateAppModal = (props: { }, })} - + {title} @@ -196,7 +255,12 @@ const CreateAppModal = (props: { - + -
    - ))} */} - {item.specs.length > 0 ? ( - { - setBundle({ - ...bundle, - [item.type]: item.specs[v].value, - }); - }} - value={item.specs.findIndex( - (spec: any) => spec.value === bundle[item.type], - )} - > - {item.specs.map((spec: any, i: number) => ( - - 0 ? ( + { + setBundle({ + ...bundle, + [item.type]: item.specs[v].value, + }); + }} + value={item.specs.findIndex( + (spec: any) => spec.value === bundle[item.type], + )} + > + {item.specs.map((spec: any, i: number) => ( + - {spec.label} - - - ))} - - - - - - ) : ( - {item.price} - )} + + {spec.label} + + + ))} + + + + + + ) : ( + {item.price} + )} */}
    - ); + ) : null; }, )} From cd4487285dc41380030bdc4491c1c27f5b121e03 Mon Sep 17 00:00:00 2001 From: maslow Date: Fri, 26 May 2023 18:59:13 +0800 Subject: [PATCH 37/48] chore: update entity typings --- server/src/account/dto/create-charge-order.dto.ts | 11 ++++++++--- server/src/application/dto/create-application.dto.ts | 5 ++++- server/src/application/entities/application-bundle.ts | 5 ++++- server/src/application/entities/application.ts | 3 --- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/server/src/account/dto/create-charge-order.dto.ts b/server/src/account/dto/create-charge-order.dto.ts index bff80932c1..47e3f3ae7c 100644 --- a/server/src/account/dto/create-charge-order.dto.ts +++ b/server/src/account/dto/create-charge-order.dto.ts @@ -25,10 +25,15 @@ export class CreateChargeOrderDto { currency: Currency } +export class WeChatPaymentCreateOrderResult { + @ApiProperty() + code_url: string +} + export class CreateChargeOrderOutDto { - @ApiProperty({ type: AccountChargeOrder }) + @ApiProperty() order: AccountChargeOrder - @ApiProperty({ type: Object }) - result: any + @ApiProperty() + result: WeChatPaymentCreateOrderResult } diff --git a/server/src/application/dto/create-application.dto.ts b/server/src/application/dto/create-application.dto.ts index 6cc11e2a1b..46f316108f 100644 --- a/server/src/application/dto/create-application.dto.ts +++ b/server/src/application/dto/create-application.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger' +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' import { IsIn, IsNotEmpty, IsString, Length } from 'class-validator' import { ApplicationState } from '../entities/application' import { UpdateApplicationBundleDto } from './update-application.dto' @@ -28,6 +28,9 @@ export class CreateApplicationDto extends UpdateApplicationBundleDto { @IsString() runtimeId: string + @ApiPropertyOptional() + isTrialTier?: boolean + validate() { return null } diff --git a/server/src/application/entities/application-bundle.ts b/server/src/application/entities/application-bundle.ts index 49b0d3bbf6..d4f36302cb 100644 --- a/server/src/application/entities/application-bundle.ts +++ b/server/src/application/entities/application-bundle.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger' +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' import { ObjectId } from 'mongodb' export class ApplicationBundleResource { @@ -53,6 +53,9 @@ export class ApplicationBundle { @ApiProperty() resource: ApplicationBundleResource + @ApiPropertyOptional() + isTrialTier?: boolean + @ApiProperty() createdAt: Date diff --git a/server/src/application/entities/application.ts b/server/src/application/entities/application.ts index 911deeb30e..92fe4942a7 100644 --- a/server/src/application/entities/application.ts +++ b/server/src/application/entities/application.ts @@ -49,9 +49,6 @@ export class Application { @ApiProperty({ enum: ApplicationPhase }) phase: ApplicationPhase - @ApiPropertyOptional() - isTrialTier?: boolean - @ApiProperty() createdAt: Date From 172f9cccc4e4a5e1d0a8e92e4c0f0fd15e1ecb33 Mon Sep 17 00:00:00 2001 From: allence Date: Fri, 26 May 2023 20:35:02 +0800 Subject: [PATCH 38/48] fix(web): modal height (#1175) --- web/public/locales/en/translation.json | 6 +-- web/src/pages/app/mods/StatusBar/index.tsx | 2 +- .../pages/home/mods/CreateAppModal/index.tsx | 42 +------------------ web/src/pages/home/mods/List/BundleInfo.tsx | 5 ++- web/src/pages/home/mods/List/index.tsx | 9 +--- 5 files changed, 12 insertions(+), 52 deletions(-) diff --git a/web/public/locales/en/translation.json b/web/public/locales/en/translation.json index 3fa3f28170..831018559f 100644 --- a/web/public/locales/en/translation.json +++ b/web/public/locales/en/translation.json @@ -43,7 +43,7 @@ "Copied": "copied", "Copy": "copy", "Create": "New application", - "Change": "Change application", + "Change": "Change", "CreateNow": "Create Now", "Custom": "customize", "Days": "days", @@ -51,7 +51,7 @@ "DeleteConfirm": "Are you sure to delete this row of data?", "DeleteSuccess": "successfully deleted", "DeleteTip": "cannot be undone", - "Edit": "Edit Name", + "Edit": "Edit", "Empty": "Empty", "Generate": "generate", "InputTip": "Please enter", @@ -429,4 +429,4 @@ "Fee": "Fee", "PleaseCloseApplicationFirst": "Please close your application first.", "custom": "Custom" -} \ No newline at end of file +} diff --git a/web/src/pages/app/mods/StatusBar/index.tsx b/web/src/pages/app/mods/StatusBar/index.tsx index 8e753169c4..0a12405072 100644 --- a/web/src/pages/app/mods/StatusBar/index.tsx +++ b/web/src/pages/app/mods/StatusBar/index.tsx @@ -37,7 +37,7 @@ function StatusBar() { {/* {t("EndTime")}: {formatDate(currentApp?.subscription.expiredAt)} */} - {t("Renew")} + {t("Change")} diff --git a/web/src/pages/home/mods/CreateAppModal/index.tsx b/web/src/pages/home/mods/CreateAppModal/index.tsx index 53b7826e16..ccdb1d1491 100644 --- a/web/src/pages/home/mods/CreateAppModal/index.tsx +++ b/web/src/pages/home/mods/CreateAppModal/index.tsx @@ -247,13 +247,13 @@ const CreateAppModal = (props: { }, })} - + {title} - + ))} - {/* {item.specs.length > 0 ? ( - { - setBundle({ - ...bundle, - [item.type]: item.specs[v].value, - }); - }} - value={item.specs.findIndex( - (spec: any) => spec.value === bundle[item.type], - )} - > - {item.specs.map((spec: any, i: number) => ( - - - {spec.label} - - - ))} - - - - - - ) : ( - {item.price} - )} */} ) : null; }, diff --git a/web/src/pages/home/mods/List/BundleInfo.tsx b/web/src/pages/home/mods/List/BundleInfo.tsx index e6561a5c9a..f2ed577cfd 100644 --- a/web/src/pages/home/mods/List/BundleInfo.tsx +++ b/web/src/pages/home/mods/List/BundleInfo.tsx @@ -6,11 +6,14 @@ function BundleInfo(props: { bundle: any }) { const { bundle } = props; if (!bundle) return null; return ( -
    +
    {formatLimitCPU(bundle?.resource?.limitCPU)} / {formatLimitMemory(bundle?.resource?.limitMemory)} + {/* + ≈¥10.00/Day + */}
    ); diff --git a/web/src/pages/home/mods/List/index.tsx b/web/src/pages/home/mods/List/index.tsx index 61a57a94f5..17fc840099 100644 --- a/web/src/pages/home/mods/List/index.tsx +++ b/web/src/pages/home/mods/List/index.tsx @@ -97,15 +97,10 @@ function List(props: { appListQuery: any; setShouldRefetch: any }) {
    -
    - {item?.name} - {/* - {item?.bundle?.displayName} - */} -
    +
    {item?.name}
    From 6a240c9030000039bfae895ac0ef3c8461b0fa19 Mon Sep 17 00:00:00 2001 From: maslow Date: Sat, 27 May 2023 03:45:04 +0800 Subject: [PATCH 39/48] add application trial tier limit --- .../src/application/application.controller.ts | 23 +++++++ server/src/application/application.module.ts | 2 + server/src/application/application.service.ts | 31 ++++++++++ server/src/billing/resource.service.ts | 60 ++++++++++++++++--- server/src/user/entities/user-quota.ts | 25 ++++++++ 5 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 server/src/user/entities/user-quota.ts diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index 522e1b9fbf..541e05dc10 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -41,6 +41,7 @@ import { SystemDatabase } from 'src/system-database' import { Runtime } from './entities/runtime' import { ObjectId } from 'mongodb' import { ApplicationBundle } from './entities/application-bundle' +import { ResourceService } from 'src/billing/resource.service' @ApiTags('Application') @Controller('applications') @@ -54,6 +55,7 @@ export class ApplicationController { private readonly region: RegionService, private readonly storage: StorageService, private readonly account: AccountService, + private readonly resource: ResourceService, ) {} /** @@ -82,6 +84,27 @@ export class ApplicationController { return ResponseUtil.error(`runtime ${dto.runtimeId} not found`) } + // check if trial tier + if (dto.isTrialTier) { + const isTrialTier = await this.resource.isTrialBundle(dto) + if (!isTrialTier) { + return ResponseUtil.error(`trial tier is not available`) + } + + const regionId = new ObjectId(dto.regionId) + const bundle = await this.resource.findTrialBundle(regionId) + const trials = await this.application.findTrialApplications(user._id) + if (trials.length >= (bundle?.limitCountOfFreeTierPerUser || 0)) { + return ResponseUtil.error(`trial tier is not available`) + } + } + + // 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 account balance const account = await this.account.findOne(user._id) const balance = account?.balance || 0 diff --git a/server/src/application/application.module.ts b/server/src/application/application.module.ts index ec00c8b210..cfc1c723c9 100644 --- a/server/src/application/application.module.ts +++ b/server/src/application/application.module.ts @@ -15,6 +15,7 @@ import { TriggerService } from 'src/trigger/trigger.service' import { WebsiteService } from 'src/website/website.service' import { AccountModule } from 'src/account/account.module' import { BundleService } from './bundle.service' +import { ResourceService } from 'src/billing/resource.service' @Module({ imports: [StorageModule, DatabaseModule, GatewayModule, AccountModule], @@ -30,6 +31,7 @@ import { BundleService } from './bundle.service' TriggerService, WebsiteService, BundleService, + ResourceService, ], exports: [ApplicationService, BundleService], }) diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index e46bf37353..23573b33dd 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -67,6 +67,7 @@ export class ApplicationService { { appid, resource: this.buildBundleResource(dto), + isTrialTier: dto.isTrialTier, createdAt: new Date(), updatedAt: new Date(), }, @@ -226,6 +227,36 @@ export class ApplicationService { return doc } + async findTrialApplications(userid: ObjectId) { + const db = SystemDatabase.db + + const apps = await db + .collection('Application') + .aggregate() + .match({ createdBy: userid }) + .lookup({ + from: 'ApplicationBundle', + localField: 'appid', + foreignField: 'appid', + as: 'bundle', + }) + .unwind('$bundle') + .match({ 'bundle.isTrialTier': true }) + .toArray() + + return apps + } + + async countByUser(userid: ObjectId) { + const db = SystemDatabase.db + + const count = await db + .collection('Application') + .countDocuments({ createdBy: userid }) + + return count + } + async updateName(appid: string, name: string) { const db = SystemDatabase.db const res = await db diff --git a/server/src/billing/resource.service.ts b/server/src/billing/resource.service.ts index a0756a5747..27e189fb16 100644 --- a/server/src/billing/resource.service.ts +++ b/server/src/billing/resource.service.ts @@ -6,6 +6,7 @@ import { ResourceBundle, ResourceType, } from './entities/resource' +import { CreateApplicationDto } from 'src/application/dto/create-application.dto' @Injectable() export class ResourceService { @@ -42,6 +43,19 @@ export class ResourceService { return options } + groupByType(options: ResourceOption[]) { + type GroupedOptions = { + [key in ResourceType]: ResourceOption + } + + const groupedOptions = options.reduce((acc, cur) => { + acc[cur.type] = cur + return acc + }, {} as GroupedOptions) + + return groupedOptions + } + async findAllBundles() { const options = await this.db .collection('ResourceBundle') @@ -51,16 +65,46 @@ export class ResourceService { return options } - groupByType(options: ResourceOption[]) { - type GroupedOptions = { - [key in ResourceType]: ResourceOption + async findAllBundlesByRegionId(regionId: ObjectId) { + const options = await this.db + .collection('ResourceBundle') + .find({ regionId }) + .toArray() + + return options + } + + async findTrialBundle(regionId: ObjectId) { + const bundle = await this.db + .collection('ResourceBundle') + .findOne({ enableFreeTier: true, regionId }) + + return bundle + } + + // check if input bundle is trial bundle + async isTrialBundle(input: CreateApplicationDto) { + const regionId = new ObjectId(input.regionId) + const bundle = await this.findTrialBundle(regionId) + + if (!bundle) { + return false } - const groupedOptions = options.reduce((acc, cur) => { - acc[cur.type] = cur - return acc - }, {} as GroupedOptions) + const cpu = bundle.spec.cpu.value + const memory = bundle.spec.memory.value + const storage = bundle.spec.storageCapacity.value + const database = bundle.spec.databaseCapacity.value - return groupedOptions + if ( + cpu === input.cpu && + memory === input.memory && + storage === input.storageCapacity && + database === input.databaseCapacity + ) { + return true + } + + return false } } diff --git a/server/src/user/entities/user-quota.ts b/server/src/user/entities/user-quota.ts new file mode 100644 index 0000000000..b3fc125bae --- /dev/null +++ b/server/src/user/entities/user-quota.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger' +import { ObjectId } from 'mongodb' + +export class UserQuota { + @ApiProperty({ type: String }) + _id?: ObjectId + + @ApiProperty({ type: String }) + uid: ObjectId + + @ApiProperty() + limitOfCPU: number + + @ApiProperty() + limitOfMemory: number + + @ApiProperty() + limitCountOfApplication: number + + @ApiProperty() + createdAt: Date + + @ApiProperty() + updatedAt: Date +} From b51af3d0d6f162ec988fac53b157d7b20dd1c5eb Mon Sep 17 00:00:00 2001 From: limbo <43649186+HUAHUAI23@users.noreply.github.com> Date: Mon, 29 May 2023 14:40:20 +0800 Subject: [PATCH 40/48] feat(server): add invite code feature and billings pagination (#1183) --- server/src/auth/dto/passwd-signup.dto.ts | 12 ++++ server/src/auth/dto/phone-signin.dto.ts | 12 +++- server/src/auth/entities/invite-code.ts | 25 +++++++++ server/src/auth/phone/phone.service.ts | 35 ++++++++++-- .../user-passwd/user-password.controller.ts | 9 ++- .../auth/user-passwd/user-password.service.ts | 33 ++++++++++- server/src/billing/billing.controller.ts | 55 +++++++++++++++++-- server/src/billing/billing.module.ts | 2 +- server/src/billing/billing.service.ts | 36 ++++++++++-- 9 files changed, 199 insertions(+), 20 deletions(-) create mode 100644 server/src/auth/entities/invite-code.ts diff --git a/server/src/auth/dto/passwd-signup.dto.ts b/server/src/auth/dto/passwd-signup.dto.ts index 12973e5c5b..a31849eb05 100644 --- a/server/src/auth/dto/passwd-signup.dto.ts +++ b/server/src/auth/dto/passwd-signup.dto.ts @@ -17,6 +17,7 @@ export class PasswdSignupDto { @IsString() @IsNotEmpty() @Length(3, 64) + @Matches(/^\S+$/, { message: 'invalid characters' }) username: string @ApiProperty({ @@ -26,6 +27,7 @@ export class PasswdSignupDto { @IsString() @IsNotEmpty() @Length(8, 64) + @Matches(/^\S+$/, { message: 'invalid characters' }) password: string @ApiPropertyOptional({ @@ -53,4 +55,14 @@ export class PasswdSignupDto { @IsOptional() @IsEnum(SmsVerifyCodeType) type: SmsVerifyCodeType + + @ApiPropertyOptional({ + description: 'invite code', + example: 'iLeMi7x', + }) + @IsOptional() + @IsString() + @Length(7, 7) + @Matches(/^\S+$/, { message: 'invalid characters' }) + inviteCode: string } diff --git a/server/src/auth/dto/phone-signin.dto.ts b/server/src/auth/dto/phone-signin.dto.ts index ba136ce7ba..6f1978fad2 100644 --- a/server/src/auth/dto/phone-signin.dto.ts +++ b/server/src/auth/dto/phone-signin.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger' +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' import { IsNotEmpty, IsString, @@ -39,4 +39,14 @@ export class PhoneSigninDto { @IsString() @Length(8, 64) password: string + + @ApiPropertyOptional({ + description: 'invite code', + example: 'iLeMi7x', + }) + @IsOptional() + @IsString() + @Length(7, 7) + @Matches(/^\S+$/, { message: 'invalid characters' }) + inviteCode: string } diff --git a/server/src/auth/entities/invite-code.ts b/server/src/auth/entities/invite-code.ts new file mode 100644 index 0000000000..3921fe457a --- /dev/null +++ b/server/src/auth/entities/invite-code.ts @@ -0,0 +1,25 @@ +import { ObjectId } from 'mongodb' + +export enum InviteCodeState { + Enabled = 'Active', + Disabled = 'Inactive', +} + +export class InviteCode { + _id?: ObjectId + uid: ObjectId + code: string + state: InviteCodeState + name: string + description: string + createdAt: Date + updatedAt: Date +} + +export class InviteRelation { + _id?: ObjectId + uid: ObjectId + invitedBy: ObjectId + codeId: ObjectId + createdAt: Date +} diff --git a/server/src/auth/phone/phone.service.ts b/server/src/auth/phone/phone.service.ts index b079f330fd..9a08f84028 100644 --- a/server/src/auth/phone/phone.service.ts +++ b/server/src/auth/phone/phone.service.ts @@ -5,6 +5,11 @@ import { PhoneSigninDto } from '../dto/phone-signin.dto' import { hashPassword } from 'src/utils/crypto' import { SmsVerifyCodeType } from '../entities/sms-verify-code' import { User } from 'src/user/entities/user' +import { + InviteRelation, + InviteCode, + InviteCodeState, +} from '../entities/invite-code' import { SystemDatabase } from 'src/system-database' import { UserService } from 'src/user/user.service' import { @@ -60,7 +65,7 @@ export class PhoneService { * @returns */ async signup(dto: PhoneSigninDto, withUsername = false) { - const { phone, username, password } = dto + const { phone, username, password, inviteCode } = dto const client = SystemDatabase.client const session = client.startSession() @@ -79,12 +84,32 @@ export class PhoneService { { session }, ) - const user = await this.userService.findOneById(res.insertedId) + // create invite relation + if (inviteCode && inviteCode.length === 7) { + const result = await this.db + .collection('InviteCode') + .findOne({ + code: inviteCode, + state: InviteCodeState.Enabled, + }) + + if (result) { + await this.db.collection('InviteRelation').insertOne( + { + uid: res.insertedId, + invitedBy: result.uid, + codeId: result._id, + createdAt: new Date(), + }, + { session }, + ) + } + } // create profile await this.db.collection('UserProfile').insertOne( { - uid: user._id, + uid: res.insertedId, name: username, createdAt: new Date(), updatedAt: new Date(), @@ -96,7 +121,7 @@ export class PhoneService { // create password await this.db.collection('UserPassword').insertOne( { - uid: user._id, + uid: res.insertedId, password: hashPassword(password), state: UserPasswordState.Active, createdAt: new Date(), @@ -107,7 +132,7 @@ export class PhoneService { } await session.commitTransaction() - return user + return await this.userService.findOneById(res.insertedId) } catch (err) { await session.abortTransaction() throw err diff --git a/server/src/auth/user-passwd/user-password.controller.ts b/server/src/auth/user-passwd/user-password.controller.ts index ecff138434..cdd47ce3ff 100644 --- a/server/src/auth/user-passwd/user-password.controller.ts +++ b/server/src/auth/user-passwd/user-password.controller.ts @@ -29,7 +29,7 @@ export class UserPasswordController { @ApiResponse({ type: ResponseUtil }) @Post('passwd/signup') async signup(@Body() dto: PasswdSignupDto) { - const { username, password, phone } = dto + const { username, password, phone, inviteCode } = dto // check if user exists const doc = await this.userService.findOneByUsername(username) if (doc) { @@ -58,7 +58,12 @@ export class UserPasswordController { } // signup user - const user = await this.passwdService.signup(username, password, phone) + const user = await this.passwdService.signup( + username, + password, + phone, + inviteCode, + ) // signin for created user const token = this.passwdService.signin(user) diff --git a/server/src/auth/user-passwd/user-password.service.ts b/server/src/auth/user-passwd/user-password.service.ts index 232b780f73..9de075b865 100644 --- a/server/src/auth/user-passwd/user-password.service.ts +++ b/server/src/auth/user-passwd/user-password.service.ts @@ -7,6 +7,11 @@ import { UserPassword, UserPasswordState, } from 'src/user/entities/user-password' +import { + InviteCode, + InviteRelation, + InviteCodeState, +} from '../entities/invite-code' import { UserProfile } from 'src/user/entities/user-profile' import { UserService } from 'src/user/user.service' import { ObjectId } from 'mongodb' @@ -22,7 +27,12 @@ export class UserPasswordService { ) {} // Singup by username and password - async signup(username: string, password: string, phone: string) { + async signup( + username: string, + password: string, + phone: string, + inviteCode: string, + ) { const client = SystemDatabase.client const session = client.startSession() @@ -52,6 +62,27 @@ export class UserPasswordService { { session }, ) + // create invite relation + if (inviteCode && inviteCode.length === 7) { + const result = await this.db + .collection('InviteCode') + .findOne({ + code: inviteCode, + state: InviteCodeState.Enabled, + }) + if (result) { + await this.db.collection('InviteRelation').insertOne( + { + uid: res.insertedId, + invitedBy: result.uid, + codeId: result._id, + createdAt: new Date(), + }, + { session }, + ) + } + } + // create profile await this.db.collection('UserProfile').insertOne( { diff --git a/server/src/billing/billing.controller.ts b/server/src/billing/billing.controller.ts index 4ef1549240..e0d370ee58 100644 --- a/server/src/billing/billing.controller.ts +++ b/server/src/billing/billing.controller.ts @@ -1,8 +1,15 @@ -import { Controller, Get, Logger, Param, UseGuards } from '@nestjs/common' -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' import { - ApiResponseArray, + Controller, + Get, + Logger, + Param, + Query, + UseGuards, +} from '@nestjs/common' +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger' +import { ApiResponseObject, + ApiResponsePagination, ResponseUtil, } from 'src/utils/response' import { BillingService } from './billing.service' @@ -24,11 +31,47 @@ export class BillingController { * Get all billing of application */ @ApiOperation({ summary: 'Get billings of an application' }) - @ApiResponseArray(ApplicationBilling) + @ApiResponsePagination(ApplicationBilling) @UseGuards(JwtAuthGuard, ApplicationAuthGuard) + @ApiQuery({ + name: 'startTime', + type: String, + description: 'pagination start time', + required: true, + }) + @ApiQuery({ + name: 'endTime', + type: String, + description: 'pagination end time', + required: true, + }) + @ApiQuery({ + name: 'page', + type: Number, + description: 'page number', + required: true, + }) + @ApiQuery({ + name: 'pageSize', + type: Number, + description: 'page size', + required: true, + }) @Get() - async findAllByAppId(@Param('appid') appid: string) { - const billings = await this.billing.findAllByAppId(appid) + async findAllByAppId( + @Param('appid') appid: string, + @Query('startTime') startTime: string, + @Query('endTime') endTime: string, + @Query('page') page: number, + @Query('pageSize') pageSize: number, + ) { + const billings = await this.billing.findAllByAppId( + appid, + new Date(startTime), + new Date(endTime), + page, + pageSize, + ) return ResponseUtil.ok(billings) } diff --git a/server/src/billing/billing.module.ts b/server/src/billing/billing.module.ts index 8f3a26e9b0..5f1adf9d68 100644 --- a/server/src/billing/billing.module.ts +++ b/server/src/billing/billing.module.ts @@ -4,7 +4,7 @@ import { ResourceService } from './resource.service' import { BillingController } from './billing.controller' import { BillingTaskService } from './billing-task.service' import { ApplicationModule } from 'src/application/application.module' -import { ResourceController } from './resource.controller'; +import { ResourceController } from './resource.controller' @Module({ imports: [ApplicationModule], diff --git a/server/src/billing/billing.service.ts b/server/src/billing/billing.service.ts index 97fc8079f0..18a3a0bf31 100644 --- a/server/src/billing/billing.service.ts +++ b/server/src/billing/billing.service.ts @@ -5,8 +5,8 @@ import { ObjectId } from 'mongodb' import { ResourceType } from './entities/resource' import { Decimal } from 'decimal.js' import * as assert from 'assert' -import { CalculatePriceDto } from './dto/calculate-price.dto' import { ApplicationBilling } from './entities/application-billing' +import { CalculatePriceDto } from './dto/calculate-price.dto' @Injectable() export class BillingService { @@ -14,13 +14,41 @@ export class BillingService { constructor(private readonly resource: ResourceService) {} - async findAllByAppId(appid: string) { + async findAllByAppId( + appid: string, + startTime: Date, + endTime: Date, + page: number, + pageSize: number, + ) { + // startTime = new Date(startTime) + // endTime = new Date(endTime) + const total = await this.db + .collection('ApplicationBilling') + .countDocuments({ appid, createdAt: { $gte: startTime, $lte: endTime } }) + const billings = await this.db .collection('ApplicationBilling') - .find({ appid }) + .find({ + appid, + createdAt: { + $gte: startTime, + $lte: endTime, + }, + }) + .skip((page - 1) * pageSize) + .limit(pageSize) + .sort({ startTime: -1 }) .toArray() - return billings + const res = { + list: billings, + total: total, + page, + pageSize, + } + + return res } async findOne(appid: string, id: ObjectId) { From aab7d8e21b5acc03cc6b02ad68f35d6b5228d5c8 Mon Sep 17 00:00:00 2001 From: maslow Date: Mon, 29 May 2023 14:45:42 +0800 Subject: [PATCH 41/48] feat(server): process free trial billing --- .../charts/laf-web/templates/ingress.yaml | 1 + .../src/application/application.controller.ts | 72 +++---------------- server/src/application/application.service.ts | 24 ++----- server/src/billing/billing-task.service.ts | 38 ++++++---- 4 files changed, 40 insertions(+), 95 deletions(-) diff --git a/deploy/build/charts/laf-web/templates/ingress.yaml b/deploy/build/charts/laf-web/templates/ingress.yaml index 66126e0c8b..c048d763d9 100644 --- a/deploy/build/charts/laf-web/templates/ingress.yaml +++ b/deploy/build/charts/laf-web/templates/ingress.yaml @@ -13,6 +13,7 @@ spec: backends: - serviceName: laf-web servicePort: 80 + websocket: true plugins: - name: gzip enable: true diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index 541e05dc10..39127adf3c 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -21,7 +21,6 @@ import { import { ApplicationAuthGuard } from '../auth/application.auth.guard' import { UpdateApplicationBundleDto, - UpdateApplicationDto, UpdateApplicationNameDto, UpdateApplicationStateDto, } from './dto/update-application.dto' @@ -218,6 +217,14 @@ export class ApplicationController { @Req() req: IRequest, ) { const app = req.application + const user = req.user + + // check account balance + const account = await this.account.findOne(user._id) + const balance = account?.balance || 0 + if (balance <= 0) { + return ResponseUtil.error(`account balance is not enough`) + } // check: only running application can restart if ( @@ -305,67 +312,4 @@ export class ApplicationController { const doc = await this.application.remove(appid) return ResponseUtil.ok(doc) } - - /** - * Update an application - * @deprecated use updateName and updateState instead - * @param dto - * @returns - */ - @ApiOperation({ summary: 'Update an application', deprecated: true }) - @UseGuards(JwtAuthGuard, ApplicationAuthGuard) - @Patch(':appid') - async update( - @Param('appid') appid: string, - @Body() dto: UpdateApplicationDto, - ) { - // check dto - const error = dto.validate() - if (error) { - return ResponseUtil.error(error) - } - - // check if the corresponding subscription status has expired - const app = await this.application.findOne(appid) - - // check: only running application can restart - if ( - dto.state === ApplicationState.Restarting && - app.state !== ApplicationState.Running && - app.phase !== ApplicationPhase.Started - ) { - return ResponseUtil.error( - 'The application is not running, can not restart it', - ) - } - - // check: only running application can stop - if ( - dto.state === ApplicationState.Stopped && - app.state !== ApplicationState.Running && - app.phase !== ApplicationPhase.Started - ) { - return ResponseUtil.error( - 'The application is not running, can not stop it', - ) - } - - // check: only stopped application can start - if ( - dto.state === ApplicationState.Running && - app.state !== ApplicationState.Stopped && - app.phase !== ApplicationPhase.Stopped - ) { - return ResponseUtil.error( - 'The application is not stopped, can not start it', - ) - } - - // update app - const doc = await this.application.update(appid, dto) - if (!doc) { - return ResponseUtil.error('update application error') - } - return ResponseUtil.ok(doc) - } } diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index 23573b33dd..c317a1a130 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -1,9 +1,6 @@ import { Injectable, Logger } from '@nestjs/common' import * as nanoid from 'nanoid' -import { - UpdateApplicationBundleDto, - UpdateApplicationDto, -} from './dto/update-application.dto' +import { UpdateApplicationBundleDto } from './dto/update-application.dto' import { APPLICATION_SECRET_KEY, ServerConfig, @@ -290,7 +287,10 @@ export class ApplicationService { .collection('ApplicationBundle') .findOneAndUpdate( { appid }, - { $set: { resource, updatedAt: new Date() } }, + { + $set: { resource, updatedAt: new Date() }, + $unset: { isTrialTier: '' }, + }, { projection: { 'bundle.resource.requestCPU': 0, @@ -303,20 +303,6 @@ export class ApplicationService { return res.value } - async update(appid: string, dto: UpdateApplicationDto) { - const db = SystemDatabase.db - const data: Partial = { updatedAt: new Date() } - - if (dto.name) data.name = dto.name - if (dto.state) data.state = dto.state - - const doc = await db - .collection('Application') - .findOneAndUpdate({ appid }, { $set: data }, { returnDocument: 'after' }) - - return doc - } - async remove(appid: string) { const db = SystemDatabase.db const doc = await db diff --git a/server/src/billing/billing-task.service.ts b/server/src/billing/billing-task.service.ts index 6484aa13f2..277554b379 100644 --- a/server/src/billing/billing-task.service.ts +++ b/server/src/billing/billing-task.service.ts @@ -18,6 +18,7 @@ import { ApplicationBundle } from 'src/application/entities/application-bundle' import * as assert from 'assert' import { Account } from 'src/account/entities/account' import { AccountTransaction } from 'src/account/entities/account-transaction' +import Decimal from 'decimal.js' @Injectable() export class BillingTaskService { @@ -105,11 +106,12 @@ export class BillingTaskService { try { await session.withTransaction(async () => { // update the account balance + const amount = new Decimal(billing.amount).mul(100).toNumber() const res = await db .collection('Account') .findOneAndUpdate( { _id: account._id }, - { $inc: { balance: -billing.amount } }, + { $inc: { balance: -amount } }, { session, returnDocument: 'after' }, ) @@ -119,7 +121,7 @@ export class BillingTaskService { await db.collection('AccountTransaction').insertOne( { accountId: account._id, - amount: -billing.amount, + amount: -amount, balance: res.value.balance, message: `Application ${app.appid} billing`, billingId: billing._id, @@ -215,14 +217,33 @@ export class BillingTaskService { return } + // get application bundle + const bundle = await db + .collection('ApplicationBundle') + .findOne({ appid: app.appid }) + + assert(bundle, `bundle not found ${app.appid}`) + // calculate billing price - const priceInput = await this.buildCalculatePriceInput(app, meteringData) + const priceInput = await this.buildCalculatePriceInput( + app, + meteringData, + bundle, + ) const priceResult = await this.billing.calculatePrice(priceInput) + // free trial + if (bundle.isTrialTier) { + priceResult.total = 0 + } + // create billing await db.collection('ApplicationBilling').insertOne({ appid, - state: ApplicationBillingState.Pending, + state: + priceResult.total === 0 + ? ApplicationBillingState.Done + : ApplicationBillingState.Pending, amount: priceResult.total, detail: { cpu: { @@ -255,6 +276,7 @@ export class BillingTaskService { private async buildCalculatePriceInput( app: Application, meteringData: any[], + bundle: ApplicationBundle, ) { const dto = new CalculatePriceDto() dto.regionId = app.regionId.toString() @@ -268,14 +290,6 @@ export class BillingTaskService { if (item.property === 'memory') dto.memory = item.value } - // get application bundle - const db = SystemDatabase.db - const bundle = await db - .collection('ApplicationBundle') - .findOne({ appid: app.appid }) - - assert(bundle, `bundle not found ${app.appid}`) - dto.storageCapacity = bundle.resource.storageCapacity dto.databaseCapacity = bundle.resource.databaseCapacity From 5c06979a83187fb53081b442218b31d73a1a6bf4 Mon Sep 17 00:00:00 2001 From: allence Date: Mon, 29 May 2023 15:40:27 +0800 Subject: [PATCH 42/48] fix(web): update app state api & rules add api (#1184) * fix(web): update app state api (cherry picked from commit 33f89bfca718e17f62880c0a08959af345732fd5) * fix(web): rules add (cherry picked from commit a4cc073848d7d42b9bb55364636ebae7422e439e) * fix(web): id -> _id (cherry picked from commit 1bd3ab0a7d3372689306cf43cc49c3f6995045f7) --- web/.swagger.config.js | 2 +- web/src/apis/v1/accounts.ts | 2 +- web/src/apis/v1/api-auto.d.ts | 128 ++++- web/src/apis/v1/applications.ts | 30 +- web/src/apis/v1/resources.ts | 2 +- web/src/components/ChargeButton/index.tsx | 4 +- .../mods/DataPanel/index.tsx | 124 ++--- .../app/database/PolicyDataList/index.tsx | 6 +- .../app/database/PolicyListPanel/index.tsx | 6 +- .../functions/mods/FunctionPanel/index.tsx | 2 +- .../app/functions/mods/TriggerModal/index.tsx | 4 +- web/src/pages/app/functions/service.ts | 2 +- web/src/pages/app/mods/SideBar/index.tsx | 4 +- .../setting/AppInfoList/InfoDetail/index.tsx | 12 +- .../pages/app/setting/AppInfoList/index.tsx | 46 +- web/src/pages/app/setting/PATList/index.tsx | 10 +- web/src/pages/app/setting/index.tsx | 2 +- .../storages/mods/StorageListPanel/index.tsx | 2 +- web/src/pages/globalStore.ts | 5 +- .../pages/home/mods/CreateAppModal/index.tsx | 445 ++++++++++-------- web/src/pages/home/mods/List/index.tsx | 12 +- 21 files changed, 490 insertions(+), 360 deletions(-) diff --git a/web/.swagger.config.js b/web/.swagger.config.js index 4362e168f7..117b34e53c 100644 --- a/web/.swagger.config.js +++ b/web/.swagger.config.js @@ -1,6 +1,6 @@ module.exports = [ { - swaggerPath: "http://api.dev.laf.run/-json", + swaggerPath: "http://api.maslow-dev.lafyun.com/-json", typingFileName: "api-auto.d.ts", outDir: "src/apis/v1", diff --git a/web/src/apis/v1/accounts.ts b/web/src/apis/v1/accounts.ts index 2a6da2b377..e6c8c9caa4 100644 --- a/web/src/apis/v1/accounts.ts +++ b/web/src/apis/v1/accounts.ts @@ -57,7 +57,7 @@ export async function AccountControllerCharge( params: Definitions.CreateChargeOrderDto | any, ): Promise<{ error: string; - data: Paths.AccountControllerCharge.Responses; + data: Definitions.CreateChargeOrderOutDto; }> { // /v1/accounts/charge-order let _params: { [key: string]: any } = { diff --git a/web/src/apis/v1/api-auto.d.ts b/web/src/apis/v1/api-auto.d.ts index bf3fab3fe4..507c74bb72 100644 --- a/web/src/apis/v1/api-auto.d.ts +++ b/web/src/apis/v1/api-auto.d.ts @@ -28,11 +28,40 @@ declare namespace Definitions { state?: string; regionId?: string; runtimeId?: string; + isTrialTier?: boolean; }; - export type UpdateApplicationDto = { + export type ApplicationWithRelations = { + _id?: string; name?: string; + appid?: string; + regionId?: string; + runtimeId?: string; + tags?: string[]; state?: string; + phase?: string; + createdAt?: string; + updatedAt?: string; + createdBy?: string; + region?: Definitions.Region; + bundle?: Definitions.ApplicationBundle; + runtime?: Definitions.Runtime; + configuration?: Definitions.ApplicationConfiguration; + domain?: Definitions.RuntimeDomain; + }; + + export type Application = { + _id?: string; + name?: string; + appid?: string; + regionId?: string; + runtimeId?: string; + tags?: string[]; + state?: string; + phase?: string; + createdAt?: string; + updatedAt?: string; + createdBy?: string; }; export type UpdateApplicationNameDto = { @@ -50,6 +79,15 @@ declare namespace Definitions { storageCapacity?: number; }; + export type ApplicationBundle = { + _id?: string; + appid?: string; + resource?: Definitions.ApplicationBundleResource; + isTrialTier?: boolean; + createdAt?: string; + updatedAt?: string; + }; + export type CreateEnvironmentDto = { name?: string; value?: string; @@ -127,6 +165,11 @@ declare namespace Definitions { currency?: string; }; + export type CreateChargeOrderOutDto = { + order?: Definitions.AccountChargeOrder; + result?: Definitions.WeChatPaymentCreateOrderResult; + }; + export type CreateWebsiteDto = { bucketName?: string; state?: string; @@ -232,6 +275,81 @@ declare namespace Definitions { storageCapacity?: number; }; + export type CalculatePriceResultDto = { + cpu?: number; + memory?: number; + storageCapacity?: number; + databaseCapacity?: number; + total?: number; + }; + + export type Region = { + _id?: string; + name?: string; + displayName?: string; + tls?: boolean; + state?: string; + createdAt?: string; + updatedAt?: string; + }; + + export type ApplicationBundleResource = { + limitCPU?: number; + limitMemory?: number; + databaseCapacity?: number; + storageCapacity?: number; + limitCountOfCloudFunction?: number; + limitCountOfBucket?: number; + limitCountOfDatabasePolicy?: number; + limitCountOfTrigger?: number; + limitCountOfWebsiteHosting?: number; + reservedTimeAfterExpired?: number; + }; + + export type Runtime = { + _id?: string; + name?: string; + type?: string; + image?: Definitions.RuntimeImageGroup; + state?: string; + version?: string; + latest?: boolean; + }; + + export type RuntimeImageGroup = { + main?: string; + init?: string; + sidecar?: string; + }; + + export type ApplicationConfiguration = { + _id?: string; + appid?: string; + environments?: Definitions.EnvironmentVariable[]; + dependencies?: string[]; + createdAt?: string; + updatedAt?: string; + }; + + export type EnvironmentVariable = { + name?: string; + value?: string; + }; + + export type RuntimeDomain = { + _id?: string; + appid?: string; + domain?: string; + state?: string; + phase?: string; + createdAt?: string; + updatedAt?: string; + }; + + export type WeChatPaymentCreateOrderResult = { + code_url?: string; + }; + export type UserProfile = { _id?: string; uid?: string; @@ -345,14 +463,6 @@ declare namespace Paths { export type Responses = any; } - namespace ApplicationControllerUpdate { - export type QueryParameters = any; - - export type BodyParameters = Definitions.UpdateApplicationDto; - - export type Responses = any; - } - namespace ApplicationControllerUpdateName { export type QueryParameters = any; diff --git a/web/src/apis/v1/applications.ts b/web/src/apis/v1/applications.ts index 9c96816859..724e3af03a 100644 --- a/web/src/apis/v1/applications.ts +++ b/web/src/apis/v1/applications.ts @@ -17,7 +17,7 @@ export async function ApplicationControllerCreate( params: Definitions.CreateApplicationDto | any, ): Promise<{ error: string; - data: Paths.ApplicationControllerCreate.Responses; + data: Definitions.ApplicationWithRelations; }> { // /v1/applications let _params: { [key: string]: any } = { @@ -77,7 +77,7 @@ export async function ApplicationControllerDelete( params: Paths.ApplicationControllerDelete.BodyParameters | any, ): Promise<{ error: string; - data: Paths.ApplicationControllerDelete.Responses; + data: Definitions.Application; }> { // /v1/applications/{appid} let _params: { [key: string]: any } = { @@ -90,26 +90,6 @@ export async function ApplicationControllerDelete( }); } -/** - * Update an application - */ -export async function ApplicationControllerUpdate( - params: Definitions.UpdateApplicationDto | any, -): Promise<{ - error: string; - data: Paths.ApplicationControllerUpdate.Responses; -}> { - // /v1/applications/{appid} - let _params: { [key: string]: any } = { - appid: useGlobalStore.getState().currentApp?.appid || "", - ...params, - }; - return request(`/v1/applications/${_params.appid}`, { - method: "PATCH", - data: params, - }); -} - /** * Update application name */ @@ -117,7 +97,7 @@ export async function ApplicationControllerUpdateName( params: Definitions.UpdateApplicationNameDto | any, ): Promise<{ error: string; - data: Paths.ApplicationControllerUpdateName.Responses; + data: Definitions.Application; }> { // /v1/applications/{appid}/name let _params: { [key: string]: any } = { @@ -137,7 +117,7 @@ export async function ApplicationControllerUpdateState( params: Definitions.UpdateApplicationStateDto | any, ): Promise<{ error: string; - data: Paths.ApplicationControllerUpdateState.Responses; + data: Definitions.Application; }> { // /v1/applications/{appid}/state let _params: { [key: string]: any } = { @@ -157,7 +137,7 @@ export async function ApplicationControllerUpdateBundle( params: Definitions.UpdateApplicationBundleDto | any, ): Promise<{ error: string; - data: Paths.ApplicationControllerUpdateBundle.Responses; + data: Definitions.ApplicationBundle; }> { // /v1/applications/{appid}/bundle let _params: { [key: string]: any } = { diff --git a/web/src/apis/v1/resources.ts b/web/src/apis/v1/resources.ts index c9b97516d7..26bbfa0610 100644 --- a/web/src/apis/v1/resources.ts +++ b/web/src/apis/v1/resources.ts @@ -17,7 +17,7 @@ export async function ResourceControllerCalculatePrice( params: Definitions.CalculatePriceDto | any, ): Promise<{ error: string; - data: Paths.ResourceControllerCalculatePrice.Responses; + data: Definitions.CalculatePriceResultDto; }> { // /v1/resources/price let _params: { [key: string]: any } = { diff --git a/web/src/components/ChargeButton/index.tsx b/web/src/components/ChargeButton/index.tsx index 11297fcc8f..009930df56 100644 --- a/web/src/components/ChargeButton/index.tsx +++ b/web/src/components/ChargeButton/index.tsx @@ -42,10 +42,10 @@ export default function ChargeButton(props: { amount?: number; children: React.R ["AccountControllerGetChargeOrder"], () => AccountControllerGetChargeOrder({ - id: createOrderRes?.data?.order?.id, + id: createOrderRes?.data?.order?._id, }), { - enabled: !!createOrderRes?.data?.order?.id && isOpen, + enabled: !!createOrderRes?.data?.order?._id && isOpen, refetchInterval: phaseStatus === "Pending" && isOpen ? 1000 : false, onSuccess: (res) => { setPhaseStatus(res?.data?.phase); diff --git a/web/src/pages/app/database/CollectionDataList/mods/DataPanel/index.tsx b/web/src/pages/app/database/CollectionDataList/mods/DataPanel/index.tsx index 86638a326d..c28be90395 100644 --- a/web/src/pages/app/database/CollectionDataList/mods/DataPanel/index.tsx +++ b/web/src/pages/app/database/CollectionDataList/mods/DataPanel/index.tsx @@ -225,7 +225,7 @@ export default function DataPanel() { />
    - {entryDataQuery.status !== "loading" && entryDataQuery?.data?.list?.length! === 0 && ( + {entryDataQuery.status !== "loading" && entryDataQuery?.data?.list?.length === 0 && (
    {t("CollectionPanel.EmptyDataText")} @@ -238,67 +238,69 @@ export default function DataPanel() { )} - <> - currentData.data?._id === item._id} - customStyle={{ - "border-lafWhite-600": colorMode === COLOR_MODE.light, - }} - onClick={(data: any) => { - setCurrentData({ - data: data, - record: JSON.stringify(data), - }); - }} - deleteRuleMutation={deleteDataMutation} - component={(item: any) => { - return ; - }} - toolComponent={(item: any) => { - const newData = { ...item }; - delete newData._id; - return ( - - + currentData.data?._id === item._id} + customStyle={{ + "border-lafWhite-600": colorMode === COLOR_MODE.light, + }} + onClick={(data: any) => { + setCurrentData({ + data: data, + record: JSON.stringify(data), + }); + }} + deleteRuleMutation={deleteDataMutation} + component={(item: any) => { + return ; + }} + toolComponent={(item: any) => { + const newData = { ...item }; + delete newData._id; + return ( + - - - - ); - }} - /> - -
    - { - setCurrentData((pre: any) => { - return { - ...pre, - record: values!, - }; - }); - }} - /> -
    -
    - + + + + + ); + }} + /> + +
    + { + setCurrentData((pre: any) => { + return { + ...pre, + record: values!, + }; + }); + }} + /> +
    +
    + + )}
    ); diff --git a/web/src/pages/app/database/PolicyDataList/index.tsx b/web/src/pages/app/database/PolicyDataList/index.tsx index 3585fc722c..cf38637ceb 100644 --- a/web/src/pages/app/database/PolicyDataList/index.tsx +++ b/web/src/pages/app/database/PolicyDataList/index.tsx @@ -116,8 +116,8 @@ export default function PolicyDataList() { <> currentData?.id === item.id} + setKey="_id" + isActive={(item: any) => currentData?._id === item._id} onClick={(data: any) => { setCurrentData(data); setRecord(JSON.stringify(data.value, null, 2)); @@ -161,7 +161,7 @@ export default function PolicyDataList() { }} /> { return ( { store.setCurrentPolicy(item); }} diff --git a/web/src/pages/app/functions/mods/FunctionPanel/index.tsx b/web/src/pages/app/functions/mods/FunctionPanel/index.tsx index 51001ed068..1357e04784 100644 --- a/web/src/pages/app/functions/mods/FunctionPanel/index.tsx +++ b/web/src/pages/app/functions/mods/FunctionPanel/index.tsx @@ -195,7 +195,7 @@ export default function FunctionList() { {func?.name}
    - {functionCache.getCache(func?.id, func?.source?.code) !== + {functionCache.getCache(func?._id, func?.source?.code) !== func?.source?.code && ( )} diff --git a/web/src/pages/app/functions/mods/TriggerModal/index.tsx b/web/src/pages/app/functions/mods/TriggerModal/index.tsx index 70455fe80a..517c45bbc4 100644 --- a/web/src/pages/app/functions/mods/TriggerModal/index.tsx +++ b/web/src/pages/app/functions/mods/TriggerModal/index.tsx @@ -108,7 +108,7 @@ export default function TriggerModal(props: { children: React.ReactElement }) { return item.desc.indexOf(searchKey) > -1; }) .map((item: any) => ( - + {item.desc} @@ -128,7 +128,7 @@ export default function TriggerModal(props: { children: React.ReactElement }) { - deleteTriggerMutation.mutate({ id: item.id }) + deleteTriggerMutation.mutate({ id: item._id }) } headerText={String(t("Delete"))} bodyText={t("TriggerPanel.DeleteConfirm")} diff --git a/web/src/pages/app/functions/service.ts b/web/src/pages/app/functions/service.ts index a3e3d649cf..a51abf527c 100644 --- a/web/src/pages/app/functions/service.ts +++ b/web/src/pages/app/functions/service.ts @@ -88,7 +88,7 @@ export const useDeleteFunctionMutation = () => { if (!data.error) { queryClient.invalidateQueries(queryKeys.useFunctionListQuery); store.setCurrentFunction({}); - functionCache.removeCache(data?.data?.id); + functionCache.removeCache(data?.data?._id); } }, }, diff --git a/web/src/pages/app/mods/SideBar/index.tsx b/web/src/pages/app/mods/SideBar/index.tsx index f7d04e12dd..e96217c497 100644 --- a/web/src/pages/app/mods/SideBar/index.tsx +++ b/web/src/pages/app/mods/SideBar/index.tsx @@ -26,7 +26,9 @@ export default function SideBar() { const { pageId } = useParams(); const navigate = useNavigate(); const { currentApp, setCurrentPage, userInfo, regions = [] } = useGlobalStore(); - const currentRegion = regions.find((item: any) => item.id === currentApp?.regionId) || regions[0]; + const currentRegion = + regions.find((item: any) => item._id === currentApp?.regionId) || regions[0]; + const ICONS: TIcon[] = [ { pageId: "nav", diff --git a/web/src/pages/app/setting/AppInfoList/InfoDetail/index.tsx b/web/src/pages/app/setting/AppInfoList/InfoDetail/index.tsx index a01ed1e015..41c52ff04d 100644 --- a/web/src/pages/app/setting/AppInfoList/InfoDetail/index.tsx +++ b/web/src/pages/app/setting/AppInfoList/InfoDetail/index.tsx @@ -11,18 +11,18 @@ const InfoDetail = function (props: { }) { const { title, leftData, rightData, className } = props; return ( -
    +
    - - + + {title} - + {leftData.map((item) => (
    - {item.key} : + {item.key} : {item.value}
    ))} @@ -30,7 +30,7 @@ const InfoDetail = function (props: { {rightData.map((item) => (
    - {item.key} : + {item.key} : {item.value}
    ))} diff --git a/web/src/pages/app/setting/AppInfoList/index.tsx b/web/src/pages/app/setting/AppInfoList/index.tsx index 5b9415d382..c47df3e9b2 100644 --- a/web/src/pages/app/setting/AppInfoList/index.tsx +++ b/web/src/pages/app/setting/AppInfoList/index.tsx @@ -76,19 +76,21 @@ const AppEnvList = () => { )} - + {currentApp?.phase === APP_PHASE_STATUS.Started ? ( + + ) : null} {
    -
    +
    { key: t("Spec.RAM"), value: `${currentApp?.bundle?.resource.limitMemory} ${t("Unit.MB")}`, }, + ]} + rightData={[ { key: t("Spec.Database"), value: `${currentApp?.bundle?.resource.databaseCapacity! / 1024} ${t("Unit.GB")}`, }, - ]} - rightData={[ { key: t("Spec.Storage"), value: `${currentApp?.bundle?.resource.storageCapacity! / 1024} ${t("Unit.GB")}`, }, - { - key: t("Spec.NetworkTraffic"), - value: `${currentApp?.bundle?.resource.networkTrafficOutbound! / 1024} ${t( - "Unit.GB", - )}`, - }, ]} /> diff --git a/web/src/pages/app/setting/PATList/index.tsx b/web/src/pages/app/setting/PATList/index.tsx index b2524c19f6..811e8cff70 100644 --- a/web/src/pages/app/setting/PATList/index.tsx +++ b/web/src/pages/app/setting/PATList/index.tsx @@ -26,7 +26,7 @@ const PATList = () => { const addPATMutation = useAddPATMutation((data: any) => { const newTokenList = [...tokenList]; newTokenList.push({ - id: data.id, + id: data._id, token: data.token, }); setTokenList(newTokenList); @@ -70,13 +70,13 @@ const PATList = () => { }, ]} configuration={{ - key: "id", + key: "_id", tableHeight: "40vh", hiddenEditButton: true, addButtonText: t("Add") + "Token", saveButtonText: t("Generate") + "Token", operationButtonsRender: (data: any) => { - const tokenItem = tokenList?.filter((item) => item.id === data.id); + const tokenItem = tokenList?.filter((item: any) => item._id === data._id); return tokenItem?.length === 1 ? ( @@ -88,8 +88,8 @@ const PATList = () => { onEdit={async () => {}} onDelete={async (data) => { await delPATMutation.mutateAsync({ id: data }); - const newTokenList = tokenList.filter((token) => { - return token.id !== data; + const newTokenList = tokenList.filter((token: any) => { + return token._id !== data; }); setTokenList(newTokenList); }} diff --git a/web/src/pages/app/setting/index.tsx b/web/src/pages/app/setting/index.tsx index 9c0b1e05c0..5047cfaff5 100644 --- a/web/src/pages/app/setting/index.tsx +++ b/web/src/pages/app/setting/index.tsx @@ -59,7 +59,7 @@ const SettingModal = (props: { - + {tabMatch.map((tab) => { return ( item.id === store?.currentStorage?._id)[0], + data?.data?.filter((item: any) => item._id === store?.currentStorage?._id)[0], ); } } else { diff --git a/web/src/pages/globalStore.ts b/web/src/pages/globalStore.ts index 566e143c12..b5dcbaf4e4 100644 --- a/web/src/pages/globalStore.ts +++ b/web/src/pages/globalStore.ts @@ -7,7 +7,7 @@ import { APP_STATUS, CHAKRA_UI_COLOR_MODE_KEY } from "@/constants"; import { formatPort } from "@/utils/format"; import { TApplicationDetail, TRegion, TRuntime } from "@/apis/typing"; -import { ApplicationControllerUpdate } from "@/apis/v1/applications"; +import { ApplicationControllerUpdateState } from "@/apis/v1/applications"; import { AuthControllerGetProfile } from "@/apis/v1/profile"; import { RegionControllerGetRegions } from "@/apis/v1/regions"; import { AppControllerGetRuntimes } from "@/apis/v1/runtimes"; @@ -79,9 +79,8 @@ const useGlobalStore = create()( if (!app) { return; } - const restartRes = await ApplicationControllerUpdate({ + const restartRes = await ApplicationControllerUpdateState({ appid: app.appid, - name: app.name, state: newState, }); if (!restartRes.error) { diff --git a/web/src/pages/home/mods/CreateAppModal/index.tsx b/web/src/pages/home/mods/CreateAppModal/index.tsx index ccdb1d1491..18fe791503 100644 --- a/web/src/pages/home/mods/CreateAppModal/index.tsx +++ b/web/src/pages/home/mods/CreateAppModal/index.tsx @@ -44,6 +44,13 @@ import { } from "@/apis/v1/resources"; import useGlobalStore from "@/pages/globalStore"; +type TypeBundle = { + cpu: number; + memory: number; + databaseCapacity: number; + storageCapacity: number; +}; + const CreateAppModal = (props: { type: "create" | "edit" | "change"; application?: TApplicationItem; @@ -60,7 +67,7 @@ const CreateAppModal = (props: { const { data: accountRes } = useAccountQuery(); - const { data: billingResourceOptionsRes } = useQuery( + const { data: billingResourceOptionsRes, isLoading } = useQuery( queryKeys.useBillingResourceOptionsQuery, async () => { return ResourceControllerGetResourceOptions({}); @@ -83,7 +90,7 @@ const CreateAppModal = (props: { }; const currentRegion = - regions.find((item: any) => item.id === application?.regionId) || regions[0]; + regions.find((item: any) => item._id === application?.regionId) || regions[0]; const bundles = currentRegion.bundles; @@ -117,12 +124,7 @@ const CreateAppModal = (props: { defaultValues, }); - const defaultBundle: { - cpu: number; - memory: number; - databaseCapacity: number; - storageCapacity: number; - } = { + const defaultBundle: TypeBundle = { cpu: application?.bundle.resource.limitCPU || bundles[0].spec.cpu.value, memory: application?.bundle.resource.limitMemory || bundles[0].spec.memory.value, databaseCapacity: @@ -156,14 +158,14 @@ const CreateAppModal = (props: { }, ); - const debouncedInputChange = debounce((value) => { + const debouncedInputChange = debounce(() => { if (isOpen) { billingQuery.refetch(); } }, 600); useEffect(() => { - debouncedInputChange(bundle); + debouncedInputChange(); return () => { debouncedInputChange.cancel(); }; @@ -246,155 +248,193 @@ const CreateAppModal = (props: { }, 0); }, })} - - - - - {title} - - - - - - - + {activeBundle?.message && ( +
    + )} {/* {t("HomePanel.RuntimeName")} @@ -454,7 +423,7 @@ const CreateAppModal = (props: {
    - {type === "edit" ? null : totalPrice <= 0 ? ( + {type === "edit" ? null : totalPrice <= 0 && isLoading ? (
    {t("Price.Free")}
    From 8440f77cf3c9af2e3c44d18165590435fd467bb0 Mon Sep 17 00:00:00 2001 From: maslow Date: Mon, 29 May 2023 19:23:49 +0800 Subject: [PATCH 45/48] fix(server): fix trial app logic --- .../src/application/application.controller.ts | 10 +++---- server/src/application/application.service.ts | 9 +++++-- .../application/dto/create-application.dto.ts | 5 +--- server/src/billing/billing.service.ts | 2 -- server/src/billing/dto/calculate-price.dto.ts | 26 +++---------------- server/src/billing/resource.controller.ts | 6 +++++ server/src/billing/resource.service.ts | 4 +-- 7 files changed, 22 insertions(+), 40 deletions(-) diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index 39127adf3c..9d1c21ec04 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -84,12 +84,8 @@ export class ApplicationController { } // check if trial tier - if (dto.isTrialTier) { - const isTrialTier = await this.resource.isTrialBundle(dto) - if (!isTrialTier) { - return ResponseUtil.error(`trial tier is not available`) - } - + const isTrialTier = await this.resource.isTrialBundle(dto) + if (isTrialTier) { const regionId = new ObjectId(dto.regionId) const bundle = await this.resource.findTrialBundle(regionId) const trials = await this.application.findTrialApplications(user._id) @@ -113,7 +109,7 @@ export class ApplicationController { // create application const appid = await this.application.tryGenerateUniqueAppid() - await this.application.create(user._id, appid, dto) + await this.application.create(user._id, appid, dto, isTrialTier) const app = await this.application.findOne(appid) return ResponseUtil.ok(app) diff --git a/server/src/application/application.service.ts b/server/src/application/application.service.ts index c317a1a130..b4d7c93479 100644 --- a/server/src/application/application.service.ts +++ b/server/src/application/application.service.ts @@ -32,7 +32,12 @@ export class ApplicationService { * - create bundle * - create application */ - async create(userid: ObjectId, appid: string, dto: CreateApplicationDto) { + async create( + userid: ObjectId, + appid: string, + dto: CreateApplicationDto, + isTrialTier: boolean, + ) { const client = SystemDatabase.client const db = client.db() const session = client.startSession() @@ -64,7 +69,7 @@ export class ApplicationService { { appid, resource: this.buildBundleResource(dto), - isTrialTier: dto.isTrialTier, + isTrialTier: isTrialTier, createdAt: new Date(), updatedAt: new Date(), }, diff --git a/server/src/application/dto/create-application.dto.ts b/server/src/application/dto/create-application.dto.ts index 46f316108f..6cc11e2a1b 100644 --- a/server/src/application/dto/create-application.dto.ts +++ b/server/src/application/dto/create-application.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { ApiProperty } from '@nestjs/swagger' import { IsIn, IsNotEmpty, IsString, Length } from 'class-validator' import { ApplicationState } from '../entities/application' import { UpdateApplicationBundleDto } from './update-application.dto' @@ -28,9 +28,6 @@ export class CreateApplicationDto extends UpdateApplicationBundleDto { @IsString() runtimeId: string - @ApiPropertyOptional() - isTrialTier?: boolean - validate() { return null } diff --git a/server/src/billing/billing.service.ts b/server/src/billing/billing.service.ts index 18a3a0bf31..28767ec54b 100644 --- a/server/src/billing/billing.service.ts +++ b/server/src/billing/billing.service.ts @@ -21,8 +21,6 @@ export class BillingService { page: number, pageSize: number, ) { - // startTime = new Date(startTime) - // endTime = new Date(endTime) const total = await this.db .collection('ApplicationBilling') .countDocuments({ appid, createdAt: { $gte: startTime, $lte: endTime } }) diff --git a/server/src/billing/dto/calculate-price.dto.ts b/server/src/billing/dto/calculate-price.dto.ts index 7f5e4d8422..f2ef28dcdc 100644 --- a/server/src/billing/dto/calculate-price.dto.ts +++ b/server/src/billing/dto/calculate-price.dto.ts @@ -1,33 +1,13 @@ import { ApiProperty } from '@nestjs/swagger' -import { IsInt, IsNotEmpty, IsString } from 'class-validator' +import { IsNotEmpty, IsString } from 'class-validator' +import { UpdateApplicationBundleDto } from 'src/application/dto/update-application.dto' -export class CalculatePriceDto { +export class CalculatePriceDto extends UpdateApplicationBundleDto { @ApiProperty() @IsNotEmpty() @IsString() regionId: string - // build resources - @ApiProperty({ example: 200 }) - @IsNotEmpty() - @IsInt() - cpu: number - - @ApiProperty({ example: 256 }) - @IsNotEmpty() - @IsInt() - memory: number - - @ApiProperty({ example: 2048 }) - @IsNotEmpty() - @IsInt() - databaseCapacity: number - - @ApiProperty({ example: 4096 }) - @IsNotEmpty() - @IsInt() - storageCapacity: number - validate() { return null } diff --git a/server/src/billing/resource.controller.ts b/server/src/billing/resource.controller.ts index b91a7674ea..ff69d3cfe0 100644 --- a/server/src/billing/resource.controller.ts +++ b/server/src/billing/resource.controller.ts @@ -42,6 +42,12 @@ export class ResourceController { } const result = await this.billing.calculatePrice(dto) + + // check if trial tier + const isTrialTier = await this.resource.isTrialBundle(dto) + if (isTrialTier) { + result.total = 0 + } return ResponseUtil.ok(result) } diff --git a/server/src/billing/resource.service.ts b/server/src/billing/resource.service.ts index 27e189fb16..147f48a19d 100644 --- a/server/src/billing/resource.service.ts +++ b/server/src/billing/resource.service.ts @@ -6,7 +6,7 @@ import { ResourceBundle, ResourceType, } from './entities/resource' -import { CreateApplicationDto } from 'src/application/dto/create-application.dto' +import { CalculatePriceDto } from './dto/calculate-price.dto' @Injectable() export class ResourceService { @@ -83,7 +83,7 @@ export class ResourceService { } // check if input bundle is trial bundle - async isTrialBundle(input: CreateApplicationDto) { + async isTrialBundle(input: CalculatePriceDto) { const regionId = new ObjectId(input.regionId) const bundle = await this.findTrialBundle(regionId) From 5234138706c1ba71b20f27106f8db2ffea9eddaf Mon Sep 17 00:00:00 2001 From: allence Date: Mon, 29 May 2023 19:27:18 +0800 Subject: [PATCH 46/48] Feat definition (#1186) * refactor(web): api definitions * fix(web): show fee --- .../pages/home/mods/CreateAppModal/index.tsx | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/web/src/pages/home/mods/CreateAppModal/index.tsx b/web/src/pages/home/mods/CreateAppModal/index.tsx index f92dce299d..c5bb077d33 100644 --- a/web/src/pages/home/mods/CreateAppModal/index.tsx +++ b/web/src/pages/home/mods/CreateAppModal/index.tsx @@ -423,29 +423,32 @@ const CreateAppModal = (props: {
    - {type === "edit" ? null : totalPrice <= 0 && isLoading ? ( -
    - {t("Price.Free")} -
    - ) : ( -
    + {type === "edit" || isLoading ? null : ( +
    {t("Fee")}: - - {totalPrice} / hour - - - {t("Balance")}: - {formatPrice(accountRes?.data?.balance)} - - {totalPrice > accountRes?.data?.balance! ? ( - {t("balance is insufficient")} - ) : null} - - {t("ChargeNow")} - + {totalPrice <= 0 ? ( + + {t("Price.Free")} + + ) : ( + + {totalPrice} / hour + + )}
    )} - +
    + + {t("Balance")}: + {formatPrice(accountRes?.data?.balance)} + + {totalPrice > accountRes?.data?.balance! ? ( + {t("balance is insufficient")} + ) : null} + + {t("ChargeNow")} + +
    {type !== "edit" && totalPrice <= accountRes?.data?.balance! && ( + + {isOpen && !functionDetailQuery.isFetching ? ( + + + + Code Diff + + + + + + + + + + + + ) : null} + + ); +} diff --git a/web/src/pages/app/functions/mods/FunctionPanel/CreateModal/PromptModal.tsx b/web/src/pages/app/functions/mods/FunctionPanel/CreateModal/PromptModal.tsx index cefffd718b..34ac88ca1c 100644 --- a/web/src/pages/app/functions/mods/FunctionPanel/CreateModal/PromptModal.tsx +++ b/web/src/pages/app/functions/mods/FunctionPanel/CreateModal/PromptModal.tsx @@ -38,13 +38,17 @@ import useFunctionStore from "../../../store"; import { TMethod } from "@/apis/typing"; import useGlobalStore from "@/pages/globalStore"; -const PromptModal = (props: { functionItem?: any; children?: React.ReactElement }) => { +const PromptModal = (props: { + functionItem?: any; + children?: React.ReactElement; + tagList?: any; +}) => { const { isOpen, onOpen, onClose } = useDisclosure(); const store = useFunctionStore(); const { showSuccess } = useGlobalStore(); const { t } = useTranslation(); - const { functionItem, children = null } = props; + const { functionItem, children = null, tagList } = props; const isEdit = !!functionItem; const CancelToken = axios.CancelToken; @@ -164,7 +168,7 @@ const PromptModal = (props: { functionItem?: any; children?: React.ReactElement name="tags" control={control} render={({ field: { onChange, value } }) => ( - + )} /> {errors.tags && errors.tags.message} diff --git a/web/src/pages/app/functions/mods/FunctionPanel/CreateModal/functionTemplates.ts b/web/src/pages/app/functions/mods/FunctionPanel/CreateModal/functionTemplates.ts index ea396249f6..b42da7f9c5 100644 --- a/web/src/pages/app/functions/mods/FunctionPanel/CreateModal/functionTemplates.ts +++ b/web/src/pages/app/functions/mods/FunctionPanel/CreateModal/functionTemplates.ts @@ -15,83 +15,83 @@ export default async function (ctx: FunctionContext) { label: t("database example"), value: `import cloud from '@lafjs/cloud' - export default async function (ctx: FunctionContext) { - const db = cloud.database() - - // insert data - await db.collection('test').add({ name: "hello laf" }) - - // get data - const res = await db.collection('test').getOne() - console.log(res) - - return res.data - } +export default async function (ctx: FunctionContext) { + const db = cloud.database() + + // insert data + await db.collection('test').add({ name: "hello laf" }) + + // get data + const res = await db.collection('test').getOne() + console.log(res) + + return res.data +} `, }, { label: t("upload example"), value: `import cloud from '@lafjs/cloud' - import { S3 } from "@aws-sdk/client-s3" - - export default async function (ctx: FunctionContext) { - // Create your bucket first - const BUCKET = "kcqcau-test" - const client = new S3({ - region: cloud.env.OSS_REGION, - endpoint: cloud.env.OSS_EXTERNAL_ENDPOINT, - credentials: { - accessKeyId: cloud.env.OSS_ACCESS_KEY, - secretAccessKey: cloud.env.OSS_ACCESS_SECRET, - }, - forcePathStyle: true, - }) - - const file = ctx.files[0] - console.log(file) - const stream = require('fs').createReadStream(file.path) - - const res = await client.putObject({ - Bucket: BUCKET, - Key: ctx.files[0].filename, - Body: stream, - ContentType: file.mimetype, - }) - console.log(res) - return res - } +import { S3 } from "@aws-sdk/client-s3" + +export default async function (ctx: FunctionContext) { + // Create your bucket first + const BUCKET = "kcqcau-test" + const client = new S3({ + region: cloud.env.OSS_REGION, + endpoint: cloud.env.OSS_EXTERNAL_ENDPOINT, + credentials: { + accessKeyId: cloud.env.OSS_ACCESS_KEY, + secretAccessKey: cloud.env.OSS_ACCESS_SECRET, + }, + forcePathStyle: true, + }) + + const file = ctx.files[0] + console.log(file) + const stream = require('fs').createReadStream(file.path) + + const res = await client.putObject({ + Bucket: BUCKET, + Key: ctx.files[0].filename, + Body: stream, + ContentType: file.mimetype, + }) + console.log(res) + return res +} `, }, { label: t("ChatGPT example"), value: `import cloud from '@lafjs/cloud' - const apiKey = cloud.env.API_KEY - - export default async function (ctx: FunctionContext) { - const { ChatGPTAPI } = await import('chatgpt') - const { body, response } = ctx - - // get chatgpt api - let api = cloud.shared.get('api') - if (!api) { - api = new ChatGPTAPI({ apiKey }) - cloud.shared.set('api', api) - } - - // set stream response type - response.setHeader('Content-Type', 'application/octet-stream'); - - // send message - const res = await api.sendMessage(body.message, { - onProgress: (partialResponse) => { - if (partialResponse?.delta != undefined) - response.write(partialResponse.delta) - }, - parentMessageId: body.parentMessageId || '' - }) - - response.end("--!" + res.id) - } +const apiKey = cloud.env.API_KEY + +export default async function (ctx: FunctionContext) { + const { ChatGPTAPI } = await import('chatgpt') + const { body, response } = ctx + + // get chatgpt api + let api = cloud.shared.get('api') + if (!api) { + api = new ChatGPTAPI({ apiKey }) + cloud.shared.set('api', api) + } + + // set stream response type + response.setHeader('Content-Type', 'application/octet-stream'); + + // send message + const res = await api.sendMessage(body.message, { + onProgress: (partialResponse) => { + if (partialResponse?.delta != undefined) + response.write(partialResponse.delta) + }, + parentMessageId: body.parentMessageId || '' + }) + + response.end("--!" + res.id) +} `, }, ]; diff --git a/web/src/pages/app/functions/mods/FunctionPanel/CreateModal/index.tsx b/web/src/pages/app/functions/mods/FunctionPanel/CreateModal/index.tsx index d43f1653af..e7b9008a49 100644 --- a/web/src/pages/app/functions/mods/FunctionPanel/CreateModal/index.tsx +++ b/web/src/pages/app/functions/mods/FunctionPanel/CreateModal/index.tsx @@ -34,12 +34,16 @@ import functionTemplates from "./functionTemplates"; import { TMethod } from "@/apis/typing"; import useGlobalStore from "@/pages/globalStore"; -const CreateModal = (props: { functionItem?: any; children?: React.ReactElement }) => { +const CreateModal = (props: { + functionItem?: any; + children?: React.ReactElement; + tagList?: any; +}) => { const { isOpen, onOpen, onClose } = useDisclosure(); const store = useFunctionStore(); const { showSuccess } = useGlobalStore(); - const { functionItem, children = null } = props; + const { functionItem, children = null, tagList } = props; const isEdit = !!functionItem; const defaultValues = { @@ -136,7 +140,7 @@ const CreateModal = (props: { functionItem?: any; children?: React.ReactElement name="tags" control={control} render={({ field: { onChange, value } }) => ( - + )} /> {errors.tags && errors.tags.message} diff --git a/web/src/pages/app/functions/mods/FunctionPanel/index.tsx b/web/src/pages/app/functions/mods/FunctionPanel/index.tsx index 1357e04784..5b7e549010 100644 --- a/web/src/pages/app/functions/mods/FunctionPanel/index.tsx +++ b/web/src/pages/app/functions/mods/FunctionPanel/index.tsx @@ -150,7 +150,7 @@ export default function FunctionList() { , - + @@ -204,7 +204,7 @@ export default function FunctionList() { label={t("Operation")} > <> - + } text={t("Edit")} /> string; - setCurrentRequestId: (requestId: string | undefined) => void; setAllFunctionList: (functionList: TFunction[]) => void; setCurrentFunction: (currentFunction: TFunction | { [key: string]: any }) => void; updateFunctionCode: (current: TFunction | { [key: string]: any }, codes: string) => void; + setIsFetchButtonClicked: () => void; }; const useFunctionStore = create()( @@ -25,6 +26,7 @@ const useFunctionStore = create()( currentFunction: {}, functionCodes: {}, currentRequestId: undefined, + isFetchButtonClicked: false, getFunctionUrl: () => { const currentApp = useGlobalStore.getState().currentApp; @@ -60,6 +62,12 @@ const useFunctionStore = create()( state.functionCodes[currentFunction!._id] = codes; }); }, + + setIsFetchButtonClicked: async () => { + set((state) => { + state.isFetchButtonClicked = !state.isFetchButtonClicked; + }); + }, })), ), ); diff --git a/web/src/pages/auth/signin/mods/LoginByPhonePanel/index.tsx b/web/src/pages/auth/signin/mods/LoginByPhonePanel/index.tsx index 9165e37d49..0eb7f0b712 100644 --- a/web/src/pages/auth/signin/mods/LoginByPhonePanel/index.tsx +++ b/web/src/pages/auth/signin/mods/LoginByPhonePanel/index.tsx @@ -13,6 +13,7 @@ import { t } from "i18next"; import { Routes } from "@/constants"; +import useInviteCode from "@/hooks/useInviteCode"; import { useSendSmsCodeMutation, useSigninBySmsCodeMutation } from "@/pages/auth/service"; import useGlobalStore from "@/pages/globalStore"; @@ -47,10 +48,13 @@ export default function LoginByPhonePanel({ }, }); + const inviteCode = useInviteCode(); + const onSubmit = async (data: FormData) => { const res = await signinBySmsCodeMutation.mutateAsync({ phone: data.phone, code: data.validationCode, + inviteCode: inviteCode, }); if (res?.data) { diff --git a/web/src/pages/auth/signup/index.tsx b/web/src/pages/auth/signup/index.tsx index 7f5b0bba68..1b7f0a4cf5 100644 --- a/web/src/pages/auth/signup/index.tsx +++ b/web/src/pages/auth/signup/index.tsx @@ -18,6 +18,7 @@ import { t } from "i18next"; import { COLOR_MODE } from "@/constants"; +import useInviteCode from "@/hooks/useInviteCode"; import { useGetProvidersQuery, useSendSmsCodeMutation, @@ -63,32 +64,7 @@ export default function SignUp() { const [isSendSmsCode, setIsSendSmsCode] = useState(false); const [countdown, setCountdown] = useState(60); const [isShowPassword, setIsShowPassword] = useState(false); - - let inviteCode = new URLSearchParams(window.location.search).get("code"); - - if (inviteCode) { - const now = new Date(); - const expirationDays = 7; - const expiration = new Date(now.getTime() + expirationDays * 24 * 60 * 60 * 1000); - - const item = { - value: inviteCode, - expiration: expiration.getTime(), - }; - - localStorage.setItem("inviteCode", JSON.stringify(item)); - } else { - const item = localStorage.getItem("inviteCode"); - - if (item) { - const data = JSON.parse(item); - if (new Date().getTime() > data.expiration) { - localStorage.removeItem("inviteCode"); - } else { - inviteCode = data.value; - } - } - } + const inviteCode = useInviteCode(); const { register, diff --git a/web/src/pages/homepage/index.tsx b/web/src/pages/homepage/index.tsx index 67408c9760..6f40091522 100644 --- a/web/src/pages/homepage/index.tsx +++ b/web/src/pages/homepage/index.tsx @@ -8,7 +8,11 @@ import Navbar from "./navbar"; import "./homepage.css"; +import useInviteCode from "@/hooks/useInviteCode"; + export default function Home() { + useInviteCode(); + return (
    From f89d90cb9966673ba833817d51a1c782d7f59f46 Mon Sep 17 00:00:00 2001 From: maslow Date: Mon, 29 May 2023 20:00:33 +0800 Subject: [PATCH 48/48] add metering yaml to laf helm charts --- .../charts/laf-server/templates/metering.yaml | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 deploy/build/charts/laf-server/templates/metering.yaml diff --git a/deploy/build/charts/laf-server/templates/metering.yaml b/deploy/build/charts/laf-server/templates/metering.yaml new file mode 100644 index 0000000000..4db2d60bcd --- /dev/null +++ b/deploy/build/charts/laf-server/templates/metering.yaml @@ -0,0 +1,356 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + control-plane: metering-manager + name: resources-metering-manager + namespace: resources-system +spec: + replicas: 1 + selector: + matchLabels: + control-plane: metering-manager + template: + metadata: + labels: + control-plane: metering-manager + spec: + containers: + - image: docker.io/bxy4543/laf-resources-metering:v0.0.1 + name: resource-metering + command: + - /metering + - "start" + - "--debug" + - "--show-path" + resources: + limits: + cpu: 1000m + memory: 1280Mi + requests: + cpu: 5m + memory: 64Mi + env: + - name: MONGO_URI + value: {{ .Values.meteringDatabaseUrl | quote }} + imagePullPolicy: Always +--- +apiVersion: v1 +kind: Namespace +metadata: + labels: + control-plane: controller-manager + name: resources-system +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: resources-controller-manager + namespace: resources-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: resources-leader-election-role + namespace: resources-system +rules: + - apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + creationTimestamp: null + name: resources-manager-role +rules: + - apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch + - apiGroups: + - "" + resources: + - pods + verbs: + - get + - list + - watch + - apiGroups: + - "" + resources: + - resourcequotas + verbs: + - get + - list + - watch + - apiGroups: + - "" + resources: + - resourcequotas/status + verbs: + - get + - list + - watch + - apiGroups: + - infra.sealos.io + resources: + - infras + verbs: + - get + - list + - watch + - apiGroups: + - infra.sealos.io + resources: + - infras/finalizers + verbs: + - get + - list + - watch + - apiGroups: + - infra.sealos.io + resources: + - infras/status + verbs: + - get + - list + - watch + - apiGroups: + - resources.sealos.io + resources: + - meterings + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - resources.sealos.io + resources: + - meterings/finalizers + verbs: + - update + - apiGroups: + - resources.sealos.io + resources: + - meterings/status + verbs: + - get + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: resources-metrics-reader +rules: + - nonResourceURLs: + - /metrics + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: resources-proxy-role +rules: + - apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create + - apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: resources-leader-election-rolebinding + namespace: resources-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: resources-leader-election-role +subjects: + - kind: ServiceAccount + name: resources-controller-manager + namespace: resources-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: resources-manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: resources-manager-role +subjects: + - kind: ServiceAccount + name: resources-controller-manager + namespace: resources-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: resources-proxy-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: resources-proxy-role +subjects: + - kind: ServiceAccount + name: resources-controller-manager + namespace: resources-system +--- +apiVersion: v1 +data: + controller_manager_config.yaml: | + apiVersion: controller-runtime.sigs.k8s.io/v1alpha1 + kind: ControllerManagerConfig + health: + healthProbeBindAddress: :8081 + metrics: + bindAddress: 127.0.0.1:8080 + webhook: + port: 9443 + leaderElection: + leaderElect: true + resourceName: a63686c3.sealos.io +kind: ConfigMap +metadata: + name: resources-manager-config + namespace: resources-system +--- +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + name: resources-controller-manager-metrics-service + namespace: resources-system +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: https + selector: + control-plane: controller-manager +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + control-plane: controller-manager + name: resources-controller-manager + namespace: resources-system +spec: + replicas: 1 + selector: + matchLabels: + control-plane: controller-manager + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + labels: + control-plane: controller-manager + spec: + containers: + - args: + - --secure-listen-address=0.0.0.0:8443 + - --upstream=http://127.0.0.1:8080/ + - --logtostderr=true + - --v=0 + #image: gcr.io/kubebuilder/kube-rbac-proxy:v0.11.0 + image: docker.io/lafyun/kube-rbac-proxy:v0.12.0 + name: kube-rbac-proxy + ports: + - containerPort: 8443 + name: https + protocol: TCP + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 5m + memory: 64Mi + - args: + - --health-probe-bind-address=:8081 + - --metrics-bind-address=127.0.0.1:8080 + - --leader-elect + command: + - /manager + env: + - name: MONGO_URI + value: {{ .Values.meteringDatabaseUrl | quote }} + image: docker.io/bxy4543/laf-resources-controller:v0.0.1 + imagePullPolicy: Always + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + name: manager + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + limits: + cpu: 1000m + memory: 1280Mi + requests: + cpu: 10m + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + securityContext: + runAsNonRoot: true + serviceAccountName: resources-controller-manager + terminationGracePeriodSeconds: 10 \ No newline at end of file