Skip to content

Commit a56f6aa

Browse files
committed
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>
1 parent 7844b3a commit a56f6aa

File tree

7 files changed

+216
-49
lines changed

7 files changed

+216
-49
lines changed

AGENTS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ The project is divided into four main applications:
7676
* **SSR for Shared Pages**: Server-renders specific pages to provide fast initial load times.
7777

7878
- **`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`.
79-
- **`be/apps/dashboard`**: The administration panel for the backend. See `be/apps/dashboard/AGENTS.md` for UI guidelines.
79+
- **`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.
8080

8181
### Monorepo Structure
8282

@@ -85,7 +85,7 @@ This is a pnpm workspace with multiple applications and packages:
8585
- `apps/web/` - Main frontend React application (Vite + React 19 SPA).
8686
- `apps/ssr/` - Next.js 15 application serving as an SPA host and dynamic SEO/OG generator.
8787
- `be/apps/core/` - The complete backend server (Hono) for real-time data.
88-
- `be/apps/dashboard/` - The administration panel for the backend.
88+
- `be/apps/dashboard/` - The administration panel for the backend (linear, data-first admin look).
8989
- `packages/builder/` - Photo processing and manifest generation tool.
9090
- `packages/webgl-viewer/` - High-performance WebGL-based photo viewer component.
9191
- `packages/data/` - Shared data access layer and PhotoLoader singleton.
@@ -216,8 +216,8 @@ class PhotoLoader {
216216
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:
217217

218218
- **`apps/web`**: Contains the "Glassmorphic Depth Design System" for the main user-facing photo gallery. See `apps/web/AGENTS.md` for details.
219-
- **`be/apps/dashboard`**: Contains guidelines for the functional, data-driven UI of the administration panel. See `be/apps/dashboard/AGENTS.md` for details.
219+
- **`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.
220220

221221
## IMPORTANT
222222

223-
Avoid feature gates/flags and any backwards compability changes - since our app is still unreleased" is really helpful.
223+
Avoid feature gates/flags and any backwards compability changes - since our app is still unreleased" is really helpful.

be/apps/core/src/modules/platform/tenant/tenant-domain.service.ts

Lines changed: 104 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { TenantDomainRepository } from './tenant-domain.repository'
1515
@injectable()
1616
export class TenantDomainService {
1717
private readonly log = logger.extend('TenantDomainService')
18+
private readonly verificationTxtLabel = '_afilmory-verification'
1819

1920
constructor(
2021
private readonly repository: TenantDomainRepository,
@@ -143,45 +144,127 @@ export class TenantDomainService {
143144

144145
private async performDnsVerification(domain: TenantDomainRecord): Promise<{ ok: boolean; reason?: string }> {
145146
const baseDomain = await this.getBaseDomain()
146-
147-
const [cnameTargets, txtRecords] = await Promise.all([
148-
this.resolveCname(domain.domain),
149-
this.resolveTxt(domain.domain),
150-
])
151-
152147
const normalizedBase = baseDomain.toLowerCase()
153-
const cnameMatches = cnameTargets.some((target) => this.matchesBaseDomain(target, normalizedBase))
154-
const txtMatches =
155-
domain.verificationToken?.length > 0 && txtRecords.some((entries) => entries.includes(domain.verificationToken))
156-
157-
if (cnameMatches || txtMatches) {
148+
const token = domain.verificationToken ?? ''
149+
150+
const txtHosts = [domain.domain, `${this.verificationTxtLabel}.${domain.domain}`]
151+
this.log.verbose('Starting DNS verification', {
152+
domainId: domain.id,
153+
domain: domain.domain,
154+
tokenPresent: token.length > 0,
155+
txtHosts,
156+
})
157+
const txtRecordsPerHost = await Promise.all(txtHosts.map((host) => this.resolveTxt(host)))
158+
const txtMatches = token.length > 0 && this.txtContainsToken(txtRecordsPerHost.flat(), token)
159+
160+
if (txtMatches) {
161+
this.log.info('DNS verification via TXT succeeded', {
162+
domainId: domain.id,
163+
domain: domain.domain,
164+
txtHosts,
165+
txtRecords: txtRecordsPerHost,
166+
})
158167
return { ok: true }
159168
}
160169

170+
const cnameChain = await this.resolveCnameChain(domain.domain)
171+
const cnameTerminal = cnameChain.at(-1)
172+
const pointsToBase = cnameTerminal ? this.matchesBaseDomain(cnameTerminal, normalizedBase) : false
173+
const reasonDetails = pointsToBase
174+
? '已检测到 CNAME 指向基础域名,但缺少 TXT 验证记录'
175+
: cnameChain.length > 0
176+
? `当前 CNAME 链终点为 ${cnameTerminal ?? cnameChain.at(-1)}`
177+
: '未检测到 CNAME 记录'
178+
179+
this.log.warn('DNS verification failed', {
180+
domainId: domain.id,
181+
domain: domain.domain,
182+
txtHosts,
183+
txtMatches,
184+
txtRecords: txtRecordsPerHost,
185+
cnameChain,
186+
pointsToBase,
187+
})
188+
161189
return {
162190
ok: false,
163-
reason: `未检测到指向 ${normalizedBase} 的 CNAME 或包含验证 token 的 TXT 记录`,
191+
reason: `未找到包含验证 token 的 TXT 记录${reasonDetails}`,
164192
}
165193
}
166194

167-
private async resolveCname(domain: string): Promise<string[]> {
168-
try {
169-
return await dns.resolveCname(domain)
170-
} catch (error) {
171-
this.log.debug(`resolveCname failed for ${domain}`, error)
172-
return []
195+
private async resolveCnameChain(domain: string): Promise<string[]> {
196+
const resolvers = await this.getResolvers(domain)
197+
for (const resolver of resolvers) {
198+
try {
199+
const chain: string[] = []
200+
const visited = new Set<string>()
201+
let current = domain
202+
203+
while (!visited.has(current) && chain.length < 10) {
204+
visited.add(current)
205+
const records = await resolver.resolveCname(current)
206+
if (!records?.length) break
207+
const target = records[0].replace(/\.$/, '').toLowerCase()
208+
chain.push(target)
209+
current = target
210+
}
211+
212+
return chain
213+
} catch (error) {
214+
this.log.debug(`resolveCname failed for ${domain} via resolver`, error)
215+
}
173216
}
217+
return []
174218
}
175219

176220
private async resolveTxt(domain: string): Promise<string[][]> {
221+
const resolvers = await this.getResolvers(domain)
222+
for (const resolver of resolvers) {
223+
try {
224+
return await resolver.resolveTxt(domain)
225+
} catch (error) {
226+
const code = (error as NodeJS.ErrnoException)?.code
227+
if (code === 'ENOTFOUND' || code === 'ENODATA' || code === 'NXDOMAIN') {
228+
this.log.debug(`resolveTxt no data for ${domain} via resolver`, error)
229+
return []
230+
}
231+
this.log.debug(`resolveTxt failed for ${domain} via resolver`, error)
232+
}
233+
}
234+
return []
235+
}
236+
237+
private async getResolvers(domain: string): Promise<Array<typeof dns | dns.Resolver>> {
238+
const resolvers: Array<typeof dns | dns.Resolver> = []
239+
240+
const authoritative = await this.createAuthoritativeResolver(domain)
241+
if (authoritative) {
242+
resolvers.push(authoritative)
243+
}
244+
245+
resolvers.push(dns)
246+
return resolvers
247+
}
248+
249+
private async createAuthoritativeResolver(domain: string): Promise<dns.Resolver | null> {
177250
try {
178-
return await dns.resolveTxt(domain)
251+
const nameServers = await dns.resolveNs(domain)
252+
if (nameServers.length === 0) {
253+
return null
254+
}
255+
const resolver = new dns.Resolver()
256+
resolver.setServers(nameServers)
257+
return resolver
179258
} catch (error) {
180-
this.log.debug(`resolveTxt failed for ${domain}`, error)
181-
return []
259+
this.log.debug(`resolveNs failed for ${domain}`, error)
260+
return null
182261
}
183262
}
184263

264+
private txtContainsToken(records: string[][], token: string): boolean {
265+
return records.some((entries) => entries.some((txt) => txt.includes(token)))
266+
}
267+
185268
private matchesBaseDomain(target: string, baseDomain: string): boolean {
186269
const normalizedTarget = target.trim().toLowerCase().replace(/\.$/, '')
187270
const normalizedBase = baseDomain.trim().toLowerCase().replace(/\.$/, '')

be/apps/dashboard/agents.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ export const Component = () => {
194194

195195
UI Design Guidelines:
196196

197-
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.
197+
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.
198198

199199
Core Design Principles:
200200

be/apps/dashboard/src/modules/site-settings/components/CustomDomainCard.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@ function normalizeHostname(): string {
2727

2828
function buildVerificationInstructions(normalizedBase = 'your-domain.com') {
2929
return [
30+
{
31+
titleKey: 'settings.domain.steps.txt.title',
32+
descriptionKey: 'settings.domain.steps.txt.desc',
33+
},
3034
{
3135
titleKey: 'settings.domain.steps.cname.title',
3236
descriptionKey: 'settings.domain.steps.cname.desc',
3337
meta: normalizedBase,
3438
},
35-
{
36-
titleKey: 'settings.domain.steps.txt.title',
37-
descriptionKey: 'settings.domain.steps.txt.desc',
38-
},
3939
{
4040
titleKey: 'settings.domain.steps.verify.title',
4141
descriptionKey: 'settings.domain.steps.verify.desc',

be/apps/dashboard/src/modules/site-settings/components/DomainListItem.tsx

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Trash2 } from 'lucide-react'
33
import { useTranslation } from 'react-i18next'
44

55
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
6+
import { resolveBaseDomain } from '~/modules/auth/utils/domain'
67

78
import type { TenantDomain } from '../types'
89
import { DomainBadge } from './DomainBadge'
@@ -17,6 +18,9 @@ interface DomainListItemProps {
1718

1819
export function DomainListItem({ domain, onVerify, onDelete, isVerifying, isDeleting }: DomainListItemProps) {
1920
const { t } = useTranslation()
21+
const baseDomain = resolveBaseDomain(typeof window !== 'undefined' ? window.location.host : '')
22+
const txtName = `_afilmory-verification.${domain.domain}`
23+
const verificationToken = domain.verificationToken ?? '—'
2024

2125
return (
2226
<LinearBorderPanel className="bg-background p-4 transition-all duration-200 hover:bg-fill/30">
@@ -49,20 +53,82 @@ export function DomainListItem({ domain, onVerify, onDelete, isVerifying, isDele
4953
</div>
5054
{domain.status === 'pending' ? (
5155
<LinearBorderPanel className="bg-fill/50 p-3">
52-
<div className="space-y-2">
56+
<div className="space-y-3">
5357
<p className="text-xs font-medium uppercase tracking-wide text-text-secondary">
54-
{t('settings.domain.token.label')}
58+
{t('settings.domain.dns.txt.title')}
5559
</p>
56-
<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">
57-
{domain.verificationToken}
58-
</code>
60+
<div className="space-y-2 rounded-lg border border-fill bg-background p-3">
61+
<KeyValueRow label={t('settings.domain.dns.type')} value="TXT" />
62+
<KeyValueRow label={t('settings.domain.dns.name')} value={txtName} copyable monospace />
63+
<KeyValueRow
64+
label={t('settings.domain.dns.value')}
65+
value={verificationToken}
66+
monospace
67+
copyable
68+
copyLabel={t('settings.domain.actions.copy')}
69+
/>
70+
<KeyValueRow label={t('settings.domain.dns.ttl')} value={t('settings.domain.dns.hint.ttl')} />
71+
</div>
5972
<FormHelperText className="text-xs text-text-tertiary">
6073
{t('settings.domain.token.helper')}
6174
</FormHelperText>
75+
76+
<p className="text-xs font-medium uppercase tracking-wide text-text-secondary">
77+
{t('settings.domain.dns.cname.title')}
78+
</p>
79+
<div className="space-y-2 rounded-lg border border-fill bg-background p-3">
80+
<KeyValueRow label={t('settings.domain.dns.type')} value="CNAME" />
81+
<KeyValueRow label={t('settings.domain.dns.name')} value={domain.domain} copyable monospace />
82+
<KeyValueRow label={t('settings.domain.dns.value')} value={baseDomain} copyable monospace />
83+
<FormHelperText className="text-xs text-text-tertiary">
84+
{t('settings.domain.dns.cname.helper')}
85+
</FormHelperText>
86+
</div>
6287
</div>
6388
</LinearBorderPanel>
6489
) : null}
6590
</div>
6691
</LinearBorderPanel>
6792
)
6893
}
94+
95+
function KeyValueRow({
96+
label,
97+
value,
98+
monospace,
99+
copyable,
100+
copyLabel,
101+
}: {
102+
label: string
103+
value: string
104+
monospace?: boolean
105+
copyable?: boolean
106+
copyLabel?: string
107+
}) {
108+
const common = 'text-sm text-text'
109+
return (
110+
<div className="flex items-center gap-3">
111+
<span className="w-28 shrink-0 text-xs uppercase tracking-wide text-text-tertiary">{label}</span>
112+
<div className="flex-1 truncate">
113+
<span className={monospace ? `${common} font-mono break-all` : common}>{value}</span>
114+
</div>
115+
{copyable ? <CopyButton value={value} label={copyLabel} /> : null}
116+
</div>
117+
)
118+
}
119+
120+
function CopyButton({ value, label = 'Copy' }: { value: string; label?: string }) {
121+
return (
122+
<Button
123+
variant="text"
124+
size="xs"
125+
onClick={() => {
126+
if (typeof navigator !== 'undefined' && navigator.clipboard) {
127+
navigator.clipboard.writeText(value)
128+
}
129+
}}
130+
>
131+
{label}
132+
</Button>
133+
)
134+
}

locales/dashboard/en.json

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -505,24 +505,33 @@
505505
"settings.account.title": "Account & Login",
506506
"settings.data.description": "Run database maintenance tasks to keep photo data consistent with object storage.",
507507
"settings.data.title": "Data Management",
508+
"settings.domain.actions.copy": "Copy",
508509
"settings.domain.actions.verify": "Verify",
509510
"settings.domain.banner.pending": "Pending verification for {{domain}}. DNS changes may take a few minutes to propagate.",
510511
"settings.domain.bound-list.empty": "No custom domains yet. Add one on the left to start verification.",
511512
"settings.domain.bound-list.title": "Bound domains",
512-
"settings.domain.description": "Bind your own domain to serve the gallery under a branded URL. We support CNAME or TXT verification.",
513+
"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.",
514+
"settings.domain.dns.cname.helper": "Once TXT passes, point your custom domain to this target.",
515+
"settings.domain.dns.cname.title": "CNAME target (after TXT verified)",
516+
"settings.domain.dns.hint.ttl": "300s or provider default",
517+
"settings.domain.dns.name": "Name / Host",
518+
"settings.domain.dns.ttl": "TTL",
519+
"settings.domain.dns.txt.title": "TXT record (required)",
520+
"settings.domain.dns.type": "Type",
521+
"settings.domain.dns.value": "Value",
513522
"settings.domain.input.cta": "Bind domain",
514523
"settings.domain.input.helper": "Use the root domain or a subdomain. Avoid the platform base domain {{base}} itself.",
515524
"settings.domain.input.label": "Custom domain",
516525
"settings.domain.input.placeholder": "photos.yourdomain.com",
517526
"settings.domain.status.disabled": "Disabled",
518527
"settings.domain.status.pending": "Pending DNS",
519528
"settings.domain.status.verified": "Active",
520-
"settings.domain.steps.cname.desc": "Create a CNAME record pointing to your workspace entry. This is the recommended approach.",
521-
"settings.domain.steps.cname.title": "Add a CNAME record",
529+
"settings.domain.steps.cname.desc": "After TXT verification succeeds, point a CNAME to {{base}} so the domain serves your site.",
530+
"settings.domain.steps.cname.title": "Point CNAME to workspace",
522531
"settings.domain.steps.title": "Verification steps",
523-
"settings.domain.steps.txt.desc": "Alternatively, add a TXT record with the verification token below if CNAME is not available.",
524-
"settings.domain.steps.txt.title": "Optional TXT verification",
525-
"settings.domain.steps.verify.desc": "After DNS propagates, click Verify. We will also accept the TXT token if present.",
532+
"settings.domain.steps.txt.desc": "Create a TXT record (preferred at _afilmory-verification.<your-domain>) containing the verification token.",
533+
"settings.domain.steps.txt.title": "Add TXT verification (required)",
534+
"settings.domain.steps.verify.desc": "When the TXT record is live, click Verify to activate the domain.",
526535
"settings.domain.steps.verify.title": "Verify and publish",
527536
"settings.domain.title": "Custom domain",
528537
"settings.domain.toast.delete-failed": "Failed to remove domain",
@@ -532,7 +541,7 @@
532541
"settings.domain.toast.request-success": "Domain added. Please complete DNS and verify.",
533542
"settings.domain.toast.verify-failed": "Verification failed. Please verify DNS and try again.",
534543
"settings.domain.toast.verify-success": "Domain verified and activated",
535-
"settings.domain.token.helper": "Place this token in a TXT record if you cannot point a CNAME.",
544+
"settings.domain.token.helper": "Publish this token in a TXT record (ideally _afilmory-verification.<your-domain>).",
536545
"settings.domain.token.label": "Verification token",
537546
"settings.nav.account": "Account & Login",
538547
"settings.nav.data": "Data Management",

0 commit comments

Comments
 (0)