Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions pages/api/v2/app/version-policies/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { timingSafeEqual } from 'crypto'
import { ZodIssue, z } from 'zod'
import { BadRequestError } from 'server/domain/error'
import { ENV } from 'server/ENV'
import { Provider } from 'server/provider'
import { AppVersionService } from 'server/service/app-version.service'
import {
AppVersionPolicyResponse,
AppVersionPolicyUpdateSchema,
} from 'src/lib/schemas/app-versions'

const isValidInternalApiKey = (apiKey: string | string[] | undefined): boolean => {
const expectedApiKey = ENV.APP_VERSION_POLICY.API_KEY
if (!expectedApiKey || !apiKey || Array.isArray(apiKey)) {
return false
}

const expectedBuffer = new TextEncoder().encode(expectedApiKey)
const actualBuffer = new TextEncoder().encode(apiKey)
return (
expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer)
)
}

export default async function handler(
req: NextApiRequest,
res: NextApiResponse<AppVersionPolicyResponse | string | ZodIssue[]>,
) {
try {
if (req.method === 'POST') {
if (!isValidInternalApiKey(req.headers['x-internal-api-key'])) {
return res.status(401).send('unauthorized')
}

const appVersionService = Provider.getService(AppVersionService)
const body = AppVersionPolicyUpdateSchema.parse(req.body)
const policy = await appVersionService.upsertPolicy(body)

return res.status(200).json({
success: true,
message: '앱 버전 정책이 저장되었습니다.',
data: policy,
})
}
} catch (err) {
if (err instanceof BadRequestError) {
return res.status(400).send(err.message)
}
if (err instanceof z.ZodError) {
return res.status(400).json(err.errors)
}
console.error('upsertAppVersionPolicy error: ', err)
return res.status(500).send('Internal Server Error')
}

return res.status(405).end()
}
37 changes: 37 additions & 0 deletions pages/api/v2/app/version/check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { ZodIssue, z } from 'zod'
import { Provider } from 'server/provider'
import { NotFoundError } from 'server/domain/error'
import { AppVersionService } from 'server/service/app-version.service'
import { AppVersionCheckResponse, AppVersionCheckSchema } from 'src/lib/schemas/app-versions'

export default async function handler(
req: NextApiRequest,
res: NextApiResponse<AppVersionCheckResponse | string | ZodIssue[]>,
) {
try {
if (req.method === 'POST') {
const appVersionService = Provider.getService(AppVersionService)
const { clientType, appVersion } = AppVersionCheckSchema.parse(req.body)
const result = await appVersionService.checkVersion(clientType, appVersion)

return res.status(200).json({
updateRequired: result.updateRequired,
clientType: result.clientType,
minSupportedVersion: result.minSupportedVersion,
storeUrl: result.storeUrl,
})
}
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).send('app version policy not found')
}
if (err instanceof z.ZodError) {
return res.status(400).json(err.errors)
}
console.error('checkAppVersion error: ', err)
return res.status(500).send('Internal Server Error')
}

return res.status(405).end()
}
3 changes: 3 additions & 0 deletions server/ENV.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export const ENV = {
REDIS: {
URL: REDIS_URL,
},
APP_VERSION_POLICY: {
API_KEY: process.env.APP_VERSION_POLICY_API_KEY ?? '',
},
R2: {
ACCOUNT_ID: process.env.R2_ACCOUNT_ID ?? '',
ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID ?? '',
Expand Down
2 changes: 2 additions & 0 deletions server/infra/database/datasource.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SnakeNamingStrategy } from 'typeorm-naming-strategies'
import {
AccountEntity,
AccountUserEntity,
AppVersionPolicyEntity,
AnnouncementDismissEntity,
AnnouncementEntity,
ClubEntity,
Expand Down Expand Up @@ -39,6 +40,7 @@ export const MAIN_DATA_SOURCE_OPTIONS: DataSourceOptions = {
AccountUserEntity,
ServiceUserEntity,
DeviceEntity,
AppVersionPolicyEntity,
AnnouncementEntity,
AnnouncementDismissEntity,
UserActivityLogEntity,
Expand Down
13 changes: 13 additions & 0 deletions server/infra/database/entities/app-version-policy.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Column, Entity, PrimaryColumn } from 'typeorm'
import { BaseTimeStampMixin } from './TimeStampMixin'

export type AppClientType = 'android' | 'ios'

@Entity('app_version_policy')
export class AppVersionPolicyEntity extends BaseTimeStampMixin {
@PrimaryColumn({ type: 'varchar', length: 16, name: 'client_type' })
clientType: AppClientType

@Column({ type: 'varchar', length: 32, name: 'min_supported_version' })
minSupportedVersion: string
}
1 change: 1 addition & 0 deletions server/infra/database/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './user.entity'
export * from './account-user.entity'
export * from './service-user.entity'
export * from './device.entity'
export * from './app-version-policy.entity'
export * from './announcement.entity'
export * from './announcement-dismiss.entity'
export * from './club.entity'
Expand Down
153 changes: 153 additions & 0 deletions server/service/app-version.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { Repository } from 'typeorm'
import { AppVersionPolicyEntity, type AppClientType } from 'server/infra/database/entities'
import { getRedisClient } from 'server/infra/redis/client'
import { BadRequestError, NotFoundError } from 'server/domain/error'
import { InjectRepository, Service } from 'server/provider'

const APP_VERSION_POLICY_CACHE_TTL_SECONDS = 24 * 60 * 60
const STORE_URLS: Record<AppClientType, string> = {
android: 'https://play.google.com/store/apps/details?id=com.padocorp.clubhouse.applicationId',
ios: 'https://apps.apple.com/kr/app/%ED%81%B4%EB%9F%BD%ED%95%98%EC%9A%B0%EC%8A%A4-%EC%9A%B0%EB%A6%AC-%ED%95%99%EA%B5%90-%EB%AA%A8%EB%93%A0-%EB%8F%99%EC%95%84%EB%A6%AC/id6461214029',
}

type AppVersionPolicy = {
clientType: AppClientType
minSupportedVersion: string
}

type AppVersionPolicyUpdate = {
clientType: AppClientType
minSupportedVersion: string
}

type AppVersionCheckResult = AppVersionPolicy & {
updateRequired: boolean
storeUrl: string
}

const appVersionPolicyCacheKey = (clientType: AppClientType) =>
`app-version-policy:${clientType}`

function parseVersion(version: string): number[] {
return version.split('.').map((part) => Number(part))
}

function compareVersions(left: string, right: string): number {
const leftParts = parseVersion(left)
const rightParts = parseVersion(right)
const length = Math.max(leftParts.length, rightParts.length)

for (let index = 0; index < length; index += 1) {
const leftValue = leftParts[index] ?? 0
const rightValue = rightParts[index] ?? 0
if (leftValue !== rightValue) {
return leftValue > rightValue ? 1 : -1
}
}

return 0
}

@Service
export class AppVersionService {
@InjectRepository(AppVersionPolicyEntity)
private readonly appVersionPolicyRepository: Repository<AppVersionPolicyEntity>

async checkVersion(
clientType: AppClientType,
appVersion: string,
): Promise<AppVersionCheckResult> {
const policy = await this.getPolicy(clientType)

return {
...policy,
updateRequired: compareVersions(appVersion, policy.minSupportedVersion) < 0,
storeUrl: STORE_URLS[clientType],
}
}

async upsertPolicy(policy: AppVersionPolicyUpdate): Promise<AppVersionPolicy> {
await this.appVersionPolicyRepository.upsert(policy, {
conflictPaths: ['clientType'],
skipUpdateIfNoValuesChanged: true,
})
await this.deleteCachedPolicy(policy.clientType)
const savedPolicy = await this.appVersionPolicyRepository.findOneBy({
clientType: policy.clientType,
})
if (!savedPolicy) {
throw new BadRequestError('failed to save app version policy')
}
return this.toPolicy(savedPolicy)
}

private async getPolicy(clientType: AppClientType): Promise<AppVersionPolicy> {
const cachedPolicy = await this.getCachedPolicy(clientType)
if (cachedPolicy) {
return cachedPolicy
}

const policy = await this.appVersionPolicyRepository.findOneBy({ clientType })
if (!policy) {
throw new NotFoundError('app version policy not found')
}

const result = this.toPolicy(policy)
await this.cachePolicy(result)
return result
}

private toPolicy(policy: AppVersionPolicyEntity): AppVersionPolicy {
return {
clientType: policy.clientType,
minSupportedVersion: policy.minSupportedVersion,
}
}

private async getCachedPolicy(clientType: AppClientType): Promise<AppVersionPolicy | null> {
try {
const redis = await getRedisClient()
if (!redis) {
return null
}
const rawPolicy = (await redis.sendCommand([
'GET',
appVersionPolicyCacheKey(clientType),
])) as string | null
return rawPolicy ? (JSON.parse(rawPolicy) as AppVersionPolicy) : null
} catch (err) {
console.error('getCachedAppVersionPolicy error: ', err)
return null
}
}

private async cachePolicy(policy: AppVersionPolicy): Promise<void> {
try {
const redis = await getRedisClient()
if (!redis) {
return
}
await redis.sendCommand([
'SET',
appVersionPolicyCacheKey(policy.clientType),
JSON.stringify(policy),
'EX',
String(APP_VERSION_POLICY_CACHE_TTL_SECONDS),
])
} catch (err) {
console.error('cacheAppVersionPolicy error: ', err)
}
}

private async deleteCachedPolicy(clientType: AppClientType): Promise<void> {
try {
const redis = await getRedisClient()
if (!redis) {
return
}
await redis.sendCommand(['DEL', appVersionPolicyCacheKey(clientType)])
} catch (err) {
console.error('deleteCachedAppVersionPolicy error: ', err)
}
}
}
20 changes: 20 additions & 0 deletions sql/260526.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
- Android/iOS 앱 진입 시 강제 업데이트 여부를 판단하기 위한 최소 지원 버전 정책 테이블
- client_type별로 하나의 정책만 유지한다
*/

CREATE TABLE IF NOT EXISTS public.app_version_policy (
client_type VARCHAR(16) PRIMARY KEY,
min_supported_version VARCHAR(32) NOT NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
CONSTRAINT ck_app_version_policy_client_type CHECK (client_type IN ('android', 'ios')),
CONSTRAINT ck_app_version_policy_min_supported_version
CHECK (min_supported_version ~ '^[0-9]+(\.[0-9]+){0,3}$')
);

INSERT INTO public.app_version_policy (client_type, min_supported_version)
VALUES
('android', '1.0.0'),
('ios', '1.0.0')
ON CONFLICT (client_type) DO NOTHING;
Loading
Loading