fix: dns cname

- Enhanced AGENTS.md to provide a more detailed description of the dashboard's design aesthetic, emphasizing a linear, data-first approach.
- Updated UI design guidelines in the dashboard to reflect a clearer description of the design principles, focusing on simplicity and clarity.
- Improved domain verification instructions in the CustomDomainCard and DomainListItem components, ensuring better user guidance for DNS setup.
- Added new localization keys for improved clarity in domain verification steps and UI elements.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-23 21:33:21 +08:00
parent 7844b3a0b2
commit a56f6aac4e
7 changed files with 217 additions and 50 deletions

View File

@@ -76,7 +76,7 @@ The project is divided into four main applications:
* **SSR for Shared Pages**: Server-renders specific pages to provide fast initial load times.
- **`be/apps/core`**: The complete backend server (Hono) for real-time data. For a detailed breakdown of its architecture, see `be/apps/core/AGENTS.md`.
- **`be/apps/dashboard`**: The administration panel for the backend. See `be/apps/dashboard/AGENTS.md` for UI guidelines.
- **`be/apps/dashboard`**: The administration panel for the backend, using a linear, data-first admin aesthetic (crisp frames, subtle gradients). See `be/apps/dashboard/AGENTS.md` for full UI guidelines.
### Monorepo Structure
@@ -85,7 +85,7 @@ This is a pnpm workspace with multiple applications and packages:
- `apps/web/` - Main frontend React application (Vite + React 19 SPA).
- `apps/ssr/` - Next.js 15 application serving as an SPA host and dynamic SEO/OG generator.
- `be/apps/core/` - The complete backend server (Hono) for real-time data.
- `be/apps/dashboard/` - The administration panel for the backend.
- `be/apps/dashboard/` - The administration panel for the backend (linear, data-first admin look).
- `packages/builder/` - Photo processing and manifest generation tool.
- `packages/webgl-viewer/` - High-performance WebGL-based photo viewer component.
- `packages/data/` - Shared data access layer and PhotoLoader singleton.
@@ -216,8 +216,8 @@ class PhotoLoader {
This project contains multiple web applications with distinct design systems. For specific UI and design guidelines, please refer to the `AGENTS.md` file within each application's directory:
- **`apps/web`**: Contains the "Glassmorphic Depth Design System" for the main user-facing photo gallery. See `apps/web/AGENTS.md` for details.
- **`be/apps/dashboard`**: Contains guidelines for the functional, data-driven UI of the administration panel. See `be/apps/dashboard/AGENTS.md` for details.
- **`be/apps/dashboard`**: Contains guidelines for the functional, data-driven UI of the administration panel (linear, data-first aesthetic). See `be/apps/dashboard/AGENTS.md` for details.
## IMPORTANT
Avoid feature gates/flags and any backwards compability changes - since our app is still unreleased" is really helpful.
Avoid feature gates/flags and any backwards compability changes - since our app is still unreleased" is really helpful.

View File

@@ -15,6 +15,7 @@ import { TenantDomainRepository } from './tenant-domain.repository'
@injectable()
export class TenantDomainService {
private readonly log = logger.extend('TenantDomainService')
private readonly verificationTxtLabel = '_afilmory-verification'
constructor(
private readonly repository: TenantDomainRepository,
@@ -143,43 +144,125 @@ export class TenantDomainService {
private async performDnsVerification(domain: TenantDomainRecord): Promise<{ ok: boolean; reason?: string }> {
const baseDomain = await this.getBaseDomain()
const [cnameTargets, txtRecords] = await Promise.all([
this.resolveCname(domain.domain),
this.resolveTxt(domain.domain),
])
const normalizedBase = baseDomain.toLowerCase()
const cnameMatches = cnameTargets.some((target) => this.matchesBaseDomain(target, normalizedBase))
const txtMatches =
domain.verificationToken?.length > 0 && txtRecords.some((entries) => entries.includes(domain.verificationToken))
const token = domain.verificationToken ?? ''
if (cnameMatches || txtMatches) {
const txtHosts = [domain.domain, `${this.verificationTxtLabel}.${domain.domain}`]
this.log.verbose('Starting DNS verification', {
domainId: domain.id,
domain: domain.domain,
tokenPresent: token.length > 0,
txtHosts,
})
const txtRecordsPerHost = await Promise.all(txtHosts.map((host) => this.resolveTxt(host)))
const txtMatches = token.length > 0 && this.txtContainsToken(txtRecordsPerHost.flat(), token)
if (txtMatches) {
this.log.info('DNS verification via TXT succeeded', {
domainId: domain.id,
domain: domain.domain,
txtHosts,
txtRecords: txtRecordsPerHost,
})
return { ok: true }
}
const cnameChain = await this.resolveCnameChain(domain.domain)
const cnameTerminal = cnameChain.at(-1)
const pointsToBase = cnameTerminal ? this.matchesBaseDomain(cnameTerminal, normalizedBase) : false
const reasonDetails = pointsToBase
? '已检测到 CNAME 指向基础域名,但缺少 TXT 验证记录'
: cnameChain.length > 0
? `当前 CNAME 链终点为 ${cnameTerminal ?? cnameChain.at(-1)}`
: '未检测到 CNAME 记录'
this.log.warn('DNS verification failed', {
domainId: domain.id,
domain: domain.domain,
txtHosts,
txtMatches,
txtRecords: txtRecordsPerHost,
cnameChain,
pointsToBase,
})
return {
ok: false,
reason: `检测到指向 ${normalizedBase} 的 CNAME 或包含验证 token 的 TXT 记录`,
reason: `找到包含验证 token 的 TXT 记录;${reasonDetails}`,
}
}
private async resolveCname(domain: string): Promise<string[]> {
try {
return await dns.resolveCname(domain)
} catch (error) {
this.log.debug(`resolveCname failed for ${domain}`, error)
return []
private async resolveCnameChain(domain: string): Promise<string[]> {
const resolvers = await this.getResolvers(domain)
for (const resolver of resolvers) {
try {
const chain: string[] = []
const visited = new Set<string>()
let current = domain
while (!visited.has(current) && chain.length < 10) {
visited.add(current)
const records = await resolver.resolveCname(current)
if (!records?.length) break
const target = records[0].replace(/\.$/, '').toLowerCase()
chain.push(target)
current = target
}
return chain
} catch (error) {
this.log.debug(`resolveCname failed for ${domain} via resolver`, error)
}
}
return []
}
private async resolveTxt(domain: string): Promise<string[][]> {
try {
return await dns.resolveTxt(domain)
} catch (error) {
this.log.debug(`resolveTxt failed for ${domain}`, error)
return []
const resolvers = await this.getResolvers(domain)
for (const resolver of resolvers) {
try {
return await resolver.resolveTxt(domain)
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code
if (code === 'ENOTFOUND' || code === 'ENODATA' || code === 'NXDOMAIN') {
this.log.debug(`resolveTxt no data for ${domain} via resolver`, error)
return []
}
this.log.debug(`resolveTxt failed for ${domain} via resolver`, error)
}
}
return []
}
private async getResolvers(domain: string): Promise<Array<typeof dns | dns.Resolver>> {
const resolvers: Array<typeof dns | dns.Resolver> = []
const authoritative = await this.createAuthoritativeResolver(domain)
if (authoritative) {
resolvers.push(authoritative)
}
resolvers.push(dns)
return resolvers
}
private async createAuthoritativeResolver(domain: string): Promise<dns.Resolver | null> {
try {
const nameServers = await dns.resolveNs(domain)
if (nameServers.length === 0) {
return null
}
const resolver = new dns.Resolver()
resolver.setServers(nameServers)
return resolver
} catch (error) {
this.log.debug(`resolveNs failed for ${domain}`, error)
return null
}
}
private txtContainsToken(records: string[][], token: string): boolean {
return records.some((entries) => entries.some((txt) => txt.includes(token)))
}
private matchesBaseDomain(target: string, baseDomain: string): boolean {

View File

@@ -194,7 +194,7 @@ export const Component = () => {
UI Design Guidelines:
This dashboard follows a **linear design language** with clean lines and subtle gradients. The design emphasizes simplicity and clarity without rounded corners or heavy visual effects.
This dashboard uses a **linear, data-first admin aesthetic**: crisp container edges, subtle gradient dividers, neutral backgrounds, and minimal ornamentation. Keep page frames sharp while allowing gentle rounding on interactive elements; avoid glassmorphism, blobs, and heavy shadows.
Core Design Principles:

View File

@@ -27,15 +27,15 @@ function normalizeHostname(): string {
function buildVerificationInstructions(normalizedBase = 'your-domain.com') {
return [
{
titleKey: 'settings.domain.steps.txt.title',
descriptionKey: 'settings.domain.steps.txt.desc',
},
{
titleKey: 'settings.domain.steps.cname.title',
descriptionKey: 'settings.domain.steps.cname.desc',
meta: normalizedBase,
},
{
titleKey: 'settings.domain.steps.txt.title',
descriptionKey: 'settings.domain.steps.txt.desc',
},
{
titleKey: 'settings.domain.steps.verify.title',
descriptionKey: 'settings.domain.steps.verify.desc',

View File

@@ -3,6 +3,7 @@ import { Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
import { resolveBaseDomain } from '~/modules/auth/utils/domain'
import type { TenantDomain } from '../types'
import { DomainBadge } from './DomainBadge'
@@ -17,6 +18,9 @@ interface DomainListItemProps {
export function DomainListItem({ domain, onVerify, onDelete, isVerifying, isDeleting }: DomainListItemProps) {
const { t } = useTranslation()
const baseDomain = resolveBaseDomain(typeof window !== 'undefined' ? window.location.host : '')
const txtName = `_afilmory-verification.${domain.domain}`
const verificationToken = domain.verificationToken ?? '—'
return (
<LinearBorderPanel className="bg-background p-4 transition-all duration-200 hover:bg-fill/30">
@@ -49,16 +53,37 @@ export function DomainListItem({ domain, onVerify, onDelete, isVerifying, isDele
</div>
{domain.status === 'pending' ? (
<LinearBorderPanel className="bg-fill/50 p-3">
<div className="space-y-2">
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-wide text-text-secondary">
{t('settings.domain.token.label')}
{t('settings.domain.dns.txt.title')}
</p>
<code className="block w-full rounded-lg bg-background border border-fill-tertiary px-3 py-2 text-xs font-mono text-text break-all">
{domain.verificationToken}
</code>
<div className="space-y-2 rounded-lg border border-fill bg-background p-3">
<KeyValueRow label={t('settings.domain.dns.type')} value="TXT" />
<KeyValueRow label={t('settings.domain.dns.name')} value={txtName} copyable monospace />
<KeyValueRow
label={t('settings.domain.dns.value')}
value={verificationToken}
monospace
copyable
copyLabel={t('settings.domain.actions.copy')}
/>
<KeyValueRow label={t('settings.domain.dns.ttl')} value={t('settings.domain.dns.hint.ttl')} />
</div>
<FormHelperText className="text-xs text-text-tertiary">
{t('settings.domain.token.helper')}
</FormHelperText>
<p className="text-xs font-medium uppercase tracking-wide text-text-secondary">
{t('settings.domain.dns.cname.title')}
</p>
<div className="space-y-2 rounded-lg border border-fill bg-background p-3">
<KeyValueRow label={t('settings.domain.dns.type')} value="CNAME" />
<KeyValueRow label={t('settings.domain.dns.name')} value={domain.domain} copyable monospace />
<KeyValueRow label={t('settings.domain.dns.value')} value={baseDomain} copyable monospace />
<FormHelperText className="text-xs text-text-tertiary">
{t('settings.domain.dns.cname.helper')}
</FormHelperText>
</div>
</div>
</LinearBorderPanel>
) : null}
@@ -66,3 +91,44 @@ export function DomainListItem({ domain, onVerify, onDelete, isVerifying, isDele
</LinearBorderPanel>
)
}
function KeyValueRow({
label,
value,
monospace,
copyable,
copyLabel,
}: {
label: string
value: string
monospace?: boolean
copyable?: boolean
copyLabel?: string
}) {
const common = 'text-sm text-text'
return (
<div className="flex items-center gap-3">
<span className="w-28 shrink-0 text-xs uppercase tracking-wide text-text-tertiary">{label}</span>
<div className="flex-1 truncate">
<span className={monospace ? `${common} font-mono break-all` : common}>{value}</span>
</div>
{copyable ? <CopyButton value={value} label={copyLabel} /> : null}
</div>
)
}
function CopyButton({ value, label = 'Copy' }: { value: string; label?: string }) {
return (
<Button
variant="text"
size="xs"
onClick={() => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
navigator.clipboard.writeText(value)
}
}}
>
{label}
</Button>
)
}

View File

@@ -505,11 +505,20 @@
"settings.account.title": "Account & Login",
"settings.data.description": "Run database maintenance tasks to keep photo data consistent with object storage.",
"settings.data.title": "Data Management",
"settings.domain.actions.copy": "Copy",
"settings.domain.actions.verify": "Verify",
"settings.domain.banner.pending": "Pending verification for {{domain}}. DNS changes may take a few minutes to propagate.",
"settings.domain.bound-list.empty": "No custom domains yet. Add one on the left to start verification.",
"settings.domain.bound-list.title": "Bound domains",
"settings.domain.description": "Bind your own domain to serve the gallery under a branded URL. We support CNAME or TXT verification.",
"settings.domain.description": "Bind your own domain to serve the gallery under a branded URL. Verification requires the TXT token; after it passes, point a CNAME to {{base}} to route traffic.",
"settings.domain.dns.cname.helper": "Once TXT passes, point your custom domain to this target.",
"settings.domain.dns.cname.title": "CNAME target (after TXT verified)",
"settings.domain.dns.hint.ttl": "300s or provider default",
"settings.domain.dns.name": "Name / Host",
"settings.domain.dns.ttl": "TTL",
"settings.domain.dns.txt.title": "TXT record (required)",
"settings.domain.dns.type": "Type",
"settings.domain.dns.value": "Value",
"settings.domain.input.cta": "Bind domain",
"settings.domain.input.helper": "Use the root domain or a subdomain. Avoid the platform base domain {{base}} itself.",
"settings.domain.input.label": "Custom domain",
@@ -517,12 +526,12 @@
"settings.domain.status.disabled": "Disabled",
"settings.domain.status.pending": "Pending DNS",
"settings.domain.status.verified": "Active",
"settings.domain.steps.cname.desc": "Create a CNAME record pointing to your workspace entry. This is the recommended approach.",
"settings.domain.steps.cname.title": "Add a CNAME record",
"settings.domain.steps.cname.desc": "After TXT verification succeeds, point a CNAME to {{base}} so the domain serves your site.",
"settings.domain.steps.cname.title": "Point CNAME to workspace",
"settings.domain.steps.title": "Verification steps",
"settings.domain.steps.txt.desc": "Alternatively, add a TXT record with the verification token below if CNAME is not available.",
"settings.domain.steps.txt.title": "Optional TXT verification",
"settings.domain.steps.verify.desc": "After DNS propagates, click Verify. We will also accept the TXT token if present.",
"settings.domain.steps.txt.desc": "Create a TXT record (preferred at _afilmory-verification.<your-domain>) containing the verification token.",
"settings.domain.steps.txt.title": "Add TXT verification (required)",
"settings.domain.steps.verify.desc": "When the TXT record is live, click Verify to activate the domain.",
"settings.domain.steps.verify.title": "Verify and publish",
"settings.domain.title": "Custom domain",
"settings.domain.toast.delete-failed": "Failed to remove domain",
@@ -532,7 +541,7 @@
"settings.domain.toast.request-success": "Domain added. Please complete DNS and verify.",
"settings.domain.toast.verify-failed": "Verification failed. Please verify DNS and try again.",
"settings.domain.toast.verify-success": "Domain verified and activated",
"settings.domain.token.helper": "Place this token in a TXT record if you cannot point a CNAME.",
"settings.domain.token.helper": "Publish this token in a TXT record (ideally _afilmory-verification.<your-domain>).",
"settings.domain.token.label": "Verification token",
"settings.nav.account": "Account & Login",
"settings.nav.data": "Data Management",

View File

@@ -504,11 +504,20 @@
"settings.account.title": "账号与登录",
"settings.data.description": "执行数据库级别的维护操作,以保持照片数据与对象存储一致。",
"settings.data.title": "数据管理",
"settings.domain.actions.copy": "复制",
"settings.domain.actions.verify": "验证",
"settings.domain.banner.pending": "{{domain}} 正在验证中DNS 生效可能需要几分钟。",
"settings.domain.bound-list.empty": "还没有绑定域名,请在左侧输入后开始验证。",
"settings.domain.bound-list.title": "已绑定域名",
"settings.domain.description": "绑定自己的域名,让相册以品牌地址访问。支持 CNAME 或 TXT 验证。",
"settings.domain.description": "绑定自己的域名,品牌地址访问。验证必须通过 TXT Token完成后再将 CNAME 指向 {{base}} 以正式接入。",
"settings.domain.dns.cname.helper": "TXT 验证通过后,将自定义域名指向此目标。",
"settings.domain.dns.cname.title": "CNAME 目标TXT 通过后)",
"settings.domain.dns.hint.ttl": "300 秒或使用默认值",
"settings.domain.dns.name": "主机名",
"settings.domain.dns.ttl": "TTL",
"settings.domain.dns.txt.title": "TXT 记录(必填)",
"settings.domain.dns.type": "记录类型",
"settings.domain.dns.value": "记录值",
"settings.domain.input.cta": "绑定域名",
"settings.domain.input.helper": "可以使用根域或子域,避免直接填写平台基础域名 {{base}}。",
"settings.domain.input.label": "自定义域名",
@@ -516,12 +525,12 @@
"settings.domain.status.disabled": "已停用",
"settings.domain.status.pending": "等待 DNS",
"settings.domain.status.verified": "已生效",
"settings.domain.steps.cname.desc": "创建指向工作空间入口的 CNAME 记录,推荐优先使用。",
"settings.domain.steps.cname.title": "添加 CNAME 记录",
"settings.domain.steps.cname.desc": "TXT 验证通过后,再把 CNAME 指向 {{base}},让流量正式路由到站点。",
"settings.domain.steps.cname.title": " CNAME 指向工作空间",
"settings.domain.steps.title": "验证步骤",
"settings.domain.steps.txt.desc": "若无法使用 CNAME可添加 TXT 记录并填写下方验证 Token。",
"settings.domain.steps.txt.title": "可选 TXT 验证",
"settings.domain.steps.verify.desc": "DNS 生效后点击验证。如果存在 TXT Token 也会一并校验。",
"settings.domain.steps.txt.desc": "创建 TXT 记录(推荐 _afilmory-verification.<你的域名>并填写下方验证 Token。",
"settings.domain.steps.txt.title": "添加 TXT 验证(必需)",
"settings.domain.steps.verify.desc": "TXT 生效后点击验证”即可激活域名。",
"settings.domain.steps.verify.title": "完成验证并生效",
"settings.domain.title": "自定义域名",
"settings.domain.toast.delete-failed": "移除域名失败",
@@ -531,7 +540,7 @@
"settings.domain.toast.request-success": "域名已添加,请完成 DNS 配置后再验证。",
"settings.domain.toast.verify-failed": "验证失败,请检查 DNS 后重试。",
"settings.domain.toast.verify-success": "域名已验证并启用",
"settings.domain.token.helper": "无法配置 CNAME 时,可将此 Token 写入 TXT 记录完成验证。",
"settings.domain.token.helper": "将此 Token 写入 TXT 记录(推荐 _afilmory-verification.<你的域名>。",
"settings.domain.token.label": "验证 Token",
"settings.nav.account": "账号与登录",
"settings.nav.data": "数据管理",