diff --git a/.vscode/settings.json b/.vscode/settings.json index af8118b2e3..e35e34a163 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,6 +26,7 @@ "apiextensions", "appid", "automount", + "binded", "bodyparser", "bson", "buildah", diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 60276602f5..0d5a43f9e5 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -409,7 +409,6 @@ model WebsiteHosting { 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 - isResolved Boolean @default(false) // if true, domain is resolved to bucket domain (cname) state DomainState @default(Active) phase DomainPhase @default(Creating) createdAt DateTime @default(now()) diff --git a/server/src/app.module.ts b/server/src/app.module.ts index d5ba633c08..fd318caf96 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common' import { AppController } from './app.controller' import { AppService } from './app.service' -import { WebsitesModule } from './website/websites.module' +import { WebsiteModule } from './website/website.module' import { FunctionModule } from './function/function.module' import { HttpModule } from '@nestjs/axios' import { ApplicationModule } from './application/application.module' @@ -27,7 +27,7 @@ import { GatewayModule } from './gateway/gateway.module' limit: 10, }), FunctionModule, - WebsitesModule, + WebsiteModule, HttpModule, AuthModule, ApplicationModule, diff --git a/server/src/storage/bucket-task.service.ts b/server/src/storage/bucket-task.service.ts index b70d6b1259..68a65dfc6d 100644 --- a/server/src/storage/bucket-task.service.ts +++ b/server/src/storage/bucket-task.service.ts @@ -1,9 +1,5 @@ import { Injectable, Logger } from '@nestjs/common' -import { - DomainPhase, - DomainState, - StorageBucket, -} from '@prisma/client' +import { DomainPhase, DomainState, StorageBucket } from '@prisma/client' import { RegionService } from 'src/region/region.service' import * as assert from 'node:assert' import { Cron, CronExpression } from '@nestjs/schedule' diff --git a/server/src/website/dto/update-website.dto.ts b/server/src/website/dto/update-website.dto.ts index 1f3ccf7ae5..6258130988 100644 --- a/server/src/website/dto/update-website.dto.ts +++ b/server/src/website/dto/update-website.dto.ts @@ -1,4 +1,9 @@ -import { PartialType } from '@nestjs/mapped-types' -import { CreateWebsiteDto } from './create-website.dto' +import { ApiProperty } from '@nestjs/swagger' +import { IsNotEmpty, IsString } from 'class-validator' -export class UpdateWebsiteDto extends PartialType(CreateWebsiteDto) {} +export class BindCustomDomainDto { + @ApiProperty() + @IsNotEmpty() + @IsString() + domain: string +} diff --git a/server/src/website/entities/website.entity.ts b/server/src/website/entities/website.entity.ts deleted file mode 100644 index 95fc04c1b9..0000000000 --- a/server/src/website/entities/website.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export class Website {} diff --git a/server/src/website/website.controller.ts b/server/src/website/website.controller.ts new file mode 100644 index 0000000000..aa105939ae --- /dev/null +++ b/server/src/website/website.controller.ts @@ -0,0 +1,177 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, +} from '@nestjs/common' +import { WebsiteService } from './website.service' +import { CreateWebsiteDto } from './dto/create-website.dto' +import { BindCustomDomainDto } from './dto/update-website.dto' +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger' +import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' +import { ApplicationAuthGuard } from 'src/auth/application.auth.guard' +import { ResponseUtil } from 'src/utils/response' + +@ApiTags('WebsiteHosting') +@ApiBearerAuth('Authorization') +@Controller('apps/:appid/websites') +export class WebsiteController { + constructor(private readonly websiteService: WebsiteService) {} + + /** + * Create a new website + * @param appid + * @param dto + * @param req + * @returns + */ + @ApiResponse({ type: ResponseUtil }) + @ApiOperation({ summary: 'Create a new website' }) + @UseGuards(JwtAuthGuard, ApplicationAuthGuard) + @Post() + async create(@Param('appid') appid: string, @Body() dto: CreateWebsiteDto) { + const site = await this.websiteService.create(appid, dto) + + if (!site) { + return ResponseUtil.error('failed to create website') + } + + return ResponseUtil.ok(site) + } + + /** + * Get all websites of an app + * @param req + * @returns + */ + @ApiResponse({ type: ResponseUtil }) + @ApiOperation({ summary: 'Get all websites of an app' }) + @UseGuards(JwtAuthGuard, ApplicationAuthGuard) + @Get() + async findAll(@Param('appid') appid: string) { + const sites = await this.websiteService.findAll(appid) + return ResponseUtil.ok(sites) + } + + /** + * Get a website hosting of an app + * @param id + * @returns + */ + @ApiResponse({ type: ResponseUtil }) + @ApiOperation({ summary: 'Get a website hosting of an app' }) + @UseGuards(JwtAuthGuard, ApplicationAuthGuard) + @Get(':id') + async findOne(@Param('appid') _appid: string, @Param('id') id: string) { + const site = await this.websiteService.findOne(id) + if (!site) { + return ResponseUtil.error('website hosting not found') + } + + return ResponseUtil.ok(site) + } + + /** + * Bind custom domain to website + * @param id + * @param dto + * @param req + * @returns + */ + @ApiResponse({ type: ResponseUtil }) + @ApiOperation({ summary: 'Bind custom domain to website' }) + @UseGuards(JwtAuthGuard, ApplicationAuthGuard) + @Patch(':id') + async bindDomain( + @Param('appid') _appid: string, + @Param('id') id: string, + @Body() dto: BindCustomDomainDto, + ) { + // get website + const site = await this.websiteService.findOne(id) + if (!site) { + return ResponseUtil.error('website hosting not found') + } + + // check if domain resolved + const resolved = await this.websiteService.checkResolved(site, dto.domain) + if (!resolved) { + return ResponseUtil.error('domain not resolved') + } + + // TODO: check if domain is already binded, remove old domain + + // bind domain + const binded = await this.websiteService.bindCustomDomain( + site.id, + dto.domain, + ) + if (!binded) { + return ResponseUtil.error('failed to bind domain') + } + + return ResponseUtil.ok(binded) + } + + /** + * Check if domain is resolved + * @param id + * @param dto + * @returns + */ + @ApiResponse({ type: ResponseUtil }) + @ApiOperation({ summary: 'Check if domain is resolved' }) + @UseGuards(JwtAuthGuard, ApplicationAuthGuard) + @Post(':id/resolved') + async checkResolved( + @Param('appid') _appid: string, + @Param('id') id: string, + @Body() dto: BindCustomDomainDto, + ) { + // get website + const site = await this.websiteService.findOne(id) + if (!site) { + return ResponseUtil.error('website hosting not found') + } + + // check if domain resolved + const resolved = await this.websiteService.checkResolved(site, dto.domain) + if (!resolved) { + return ResponseUtil.error('domain not resolved') + } + + return ResponseUtil.ok(resolved) + } + + /** + * Delete a website hosting + * @param id + * @returns + */ + @ApiResponse({ type: ResponseUtil }) + @ApiOperation({ summary: 'Delete a website hosting' }) + @UseGuards(JwtAuthGuard, ApplicationAuthGuard) + @Delete(':id') + async remove(@Param('appid') _appid: string, @Param('id') id: string) { + const site = await this.websiteService.findOne(id) + if (!site) { + return ResponseUtil.error('website hosting not found') + } + + const deleted = await this.websiteService.remove(site.id) + if (!deleted) { + return ResponseUtil.error('failed to delete website hosting') + } + + return ResponseUtil.ok(deleted) + } +} diff --git a/server/src/website/website.module.ts b/server/src/website/website.module.ts new file mode 100644 index 0000000000..cdb6907467 --- /dev/null +++ b/server/src/website/website.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { WebsiteService } from './website.service' +import { WebsiteController } from './website.controller' +import { PrismaService } from 'src/prisma.service' +import { RegionModule } from 'src/region/region.module' +import { ApplicationService } from 'src/application/application.service' + +@Module({ + imports: [RegionModule], + controllers: [WebsiteController], + providers: [WebsiteService, PrismaService, ApplicationService], +}) +export class WebsiteModule {} diff --git a/server/src/website/website.service.ts b/server/src/website/website.service.ts new file mode 100644 index 0000000000..67a6b49776 --- /dev/null +++ b/server/src/website/website.service.ts @@ -0,0 +1,127 @@ +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.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' + +@Injectable() +export class WebsiteService { + private readonly logger = new Logger(WebsiteService.name) + + constructor( + private readonly prisma: PrismaService, + private readonly regionService: RegionService, + ) {} + + async create(appid: string, dto: CreateWebsiteDto) { + const region = await this.regionService.findByAppId(appid) + assert(region, 'region not found') + + // generate default website domain + const domain = `${dto.bucketName}.${region.gatewayConf.websiteDomain}` + + const website = await this.prisma.websiteHosting.create({ + data: { + appid: appid, + domain: domain, + isCustom: false, + state: DomainState.Active, + phase: DomainPhase.Creating, + lockedAt: TASK_LOCK_INIT_TIME, + bucket: { + connect: { + name: dto.bucketName, + }, + }, + }, + }) + + return website + } + + async findAll(appid: string) { + const websites = await this.prisma.websiteHosting.findMany({ + where: { + appid: appid, + }, + include: { + bucket: true, + }, + }) + + return websites + } + + async findOne(id: string) { + const website = await this.prisma.websiteHosting.findFirst({ + where: { + id, + }, + include: { + bucket: true, + }, + }) + + return website + } + + async checkResolved(website: WebsiteHosting, customDomain: string) { + // get bucket domain + const bucketDomain = await this.prisma.bucketDomain.findFirst({ + where: { + appid: website.appid, + bucketName: website.bucketName, + }, + }) + + const cnameTarget = bucketDomain.domain + + // check domain is available + const resolver = new dns.promises.Resolver({ timeout: 3000, tries: 1 }) + const result = await resolver + .resolveCname(customDomain as string) + .catch(() => { + return + }) + + 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, + }, + }) + + return website + } + + async remove(id: string) { + const website = await this.prisma.websiteHosting.update({ + where: { + id, + }, + data: { + state: DomainState.Deleted, + }, + }) + + return website + } +} diff --git a/server/src/website/websites.controller.ts b/server/src/website/websites.controller.ts deleted file mode 100644 index 2ebaf2375f..0000000000 --- a/server/src/website/websites.controller.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - Controller, - Get, - Post, - Body, - Patch, - Param, - Delete, -} from '@nestjs/common' -import { WebsitesService } from './websites.service' -import { CreateWebsiteDto } from './dto/create-website.dto' -import { UpdateWebsiteDto } from './dto/update-website.dto' -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger' - -@ApiTags('Website') -@ApiBearerAuth('Authorization') -@Controller('apps/:appid/websites') -export class WebsitesController { - constructor(private readonly websitesService: WebsitesService) {} - - @Post() - @ApiOperation({ summary: 'TODO - ⌛️' }) - create(@Body() createWebsiteDto: CreateWebsiteDto) { - return this.websitesService.create(createWebsiteDto) - } - - @Get() - @ApiOperation({ summary: 'TODO - ⌛️' }) - findAll() { - return this.websitesService.findAll() - } - - @Get(':id') - @ApiOperation({ summary: 'TODO - ⌛️' }) - findOne(@Param('id') id: string) { - return this.websitesService.findOne(+id) - } - - @Patch(':id') - @ApiOperation({ summary: 'TODO - ⌛️' }) - update(@Param('id') id: string, @Body() updateWebsiteDto: UpdateWebsiteDto) { - return this.websitesService.update(+id, updateWebsiteDto) - } - - @ApiOperation({ summary: 'TODO - ⌛️' }) - @Delete(':id') - remove(@Param('id') id: string) { - return this.websitesService.remove(+id) - } -} diff --git a/server/src/website/websites.module.ts b/server/src/website/websites.module.ts deleted file mode 100644 index 631108050c..0000000000 --- a/server/src/website/websites.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common' -import { WebsitesService } from './websites.service' -import { WebsitesController } from './websites.controller' -import { PrismaService } from 'src/prisma.service' - -@Module({ - controllers: [WebsitesController], - providers: [WebsitesService, PrismaService], -}) -export class WebsitesModule {} diff --git a/server/src/website/websites.service.ts b/server/src/website/websites.service.ts deleted file mode 100644 index ecdf14eb8a..0000000000 --- a/server/src/website/websites.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Injectable } from '@nestjs/common' -import { PrismaService } from 'src/prisma.service' -import { CreateWebsiteDto } from './dto/create-website.dto' -import { UpdateWebsiteDto } from './dto/update-website.dto' - -@Injectable() -export class WebsitesService { - constructor(private readonly prisma: PrismaService) {} - - async create(createWebsiteDto: CreateWebsiteDto) { - return 'This action adds a new website' - } - - findAll() { - return `This action returns all websites` - } - - findOne(id: number) { - return `This action returns a #${id} website` - } - - update(id: number, updateWebsiteDto: UpdateWebsiteDto) { - return `This action updates a #${id} website` - } - - remove(id: number) { - return `This action removes a #${id} website` - } -}