Skip to content

Commit

Permalink
feat(server): implement website hosting module (#763)
Browse files Browse the repository at this point in the history
  • Loading branch information
maslow authored Feb 9, 2023
1 parent e11b7ae commit 41da999
Show file tree
Hide file tree
Showing 12 changed files with 329 additions and 101 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"apiextensions",
"appid",
"automount",
"binded",
"bodyparser",
"bson",
"buildah",
Expand Down
1 change: 0 additions & 1 deletion server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
4 changes: 2 additions & 2 deletions server/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -27,7 +27,7 @@ import { GatewayModule } from './gateway/gateway.module'
limit: 10,
}),
FunctionModule,
WebsitesModule,
WebsiteModule,
HttpModule,
AuthModule,
ApplicationModule,
Expand Down
6 changes: 1 addition & 5 deletions server/src/storage/bucket-task.service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
11 changes: 8 additions & 3 deletions server/src/website/dto/update-website.dto.ts
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 0 additions & 1 deletion server/src/website/entities/website.entity.ts

This file was deleted.

177 changes: 177 additions & 0 deletions server/src/website/website.controller.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 13 additions & 0 deletions server/src/website/website.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
127 changes: 127 additions & 0 deletions server/src/website/website.service.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading

0 comments on commit 41da999

Please sign in to comment.