Skip to content

Commit 9552bf8

Browse files
committed
issue and check SSL with worker; refactor customDomain; early ACM implementation
1 parent 3947ff8 commit 9552bf8

File tree

13 files changed

+195
-111
lines changed

13 files changed

+195
-111
lines changed

api/acm/index.js

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { ACM } from 'aws-sdk'
2+
// TODO: skeleton
3+
4+
const region = 'us-east-1' // cloudfront ACM is in us-east-1
5+
const acm = new ACM({ region })
6+
7+
export async function requestCertificate (domain) {
8+
const params = {
9+
DomainName: domain,
10+
ValidationMethod: 'DNS',
11+
Tags: [
12+
{
13+
Key: 'ManagedBy',
14+
Value: 'stackernews'
15+
}
16+
]
17+
}
18+
19+
const certificate = await acm.requestCertificate(params).promise()
20+
return certificate.CertificateArn
21+
}
22+
23+
export async function getCertificateStatus (certificateArn) {
24+
const certificate = await acm.describeCertificate({ CertificateArn: certificateArn }).promise()
25+
return certificate.Certificate.Status
26+
}

api/resolvers/sub.js

+8-9
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ export default {
279279

280280
return await performPaidAction('TERRITORY_UNARCHIVE', data, { me, models, lnd })
281281
},
282-
updateCustomDomain: async (parent, { subName, domain }, { me, models }) => {
282+
setCustomDomain: async (parent, { subName, domain }, { me, models }) => {
283283
if (!me) {
284284
throw new GqlAuthenticationError()
285285
}
@@ -292,28 +292,27 @@ export default {
292292
if (sub.userId !== me.id) {
293293
throw new GqlInputError('you do not own this sub')
294294
}
295-
domain = domain.trim()
295+
domain = domain.trim() // protect against trailing spaces
296296
if (domain && !validateSchema(customDomainSchema, { domain })) {
297297
throw new GqlInputError('Invalid domain format')
298298
}
299299

300-
console.log('domain', domain)
301-
console.log('sub.customDomain?.domain', sub.customDomain?.domain)
302-
303300
if (domain) {
304301
const existing = await models.customDomain.findUnique({ where: { subName } })
305302
if (existing) {
306303
if (domain === existing.domain) {
307304
throw new GqlInputError('domain already set')
308305
}
309-
return await models.customDomain.update({ where: { subName }, data: { domain, verificationState: 'PENDING' } })
306+
return await models.customDomain.update({
307+
where: { subName },
308+
data: { domain, dnsState: 'PENDING', sslState: 'PENDING' }
309+
})
310310
} else {
311311
return await models.customDomain.create({
312312
data: {
313313
domain,
314-
verificationState: 'PENDING',
315-
cname: 'parallel.soxa.dev',
316-
verificationTxt: randomBytes(32).toString('base64'),
314+
cname: 'todo', // TODO: explore other options
315+
verificationTxt: randomBytes(32).toString('base64'), // TODO: explore other options
317316
sub: {
318317
connect: { name: subName }
319318
}

api/typeDefs/sub.js

+6-4
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ export default gql`
1414
updatedAt: Date!
1515
domain: String!
1616
subName: String!
17-
sslEnabled: Boolean!
18-
sslCertExpiry: Date
19-
verificationState: String!
17+
dnsState: String!
18+
sslState: String!
19+
certificateArn: String
2020
lastVerifiedAt: Date
21+
cname: String!
22+
verificationTxt: String!
2123
}
2224
2325
type Subs {
@@ -39,7 +41,7 @@ export default gql`
3941
replyCost: Int!, postTypes: [String!]!,
4042
billingType: String!, billingAutoRenew: Boolean!,
4143
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
42-
updateCustomDomain(subName: String!, domain: String!): CustomDomain
44+
setCustomDomain(subName: String!, domain: String!): CustomDomain
4345
}
4446
4547
type Sub {

components/nav/common.js

+2-3
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,10 @@ export function SignUpButton ({ className = 'py-0', width }) {
249249
export default function LoginButton () {
250250
const router = useRouter()
251251

252+
// TODO: atp let main domain handle the login UX/UI
253+
// decree a better position/way for this
252254
useEffect(() => {
253255
if (router.query.type === 'sync') {
254-
console.log('signing in with sync')
255-
console.log('token', router.query.token)
256-
console.log('callbackUrl', router.query.callbackUrl)
257256
signIn('sync', { token: router.query.token, callbackUrl: router.query.callbackUrl, redirect: false })
258257
}
259258
}, [router.query.type, router.query.token, router.query.callbackUrl])

components/territory-domains.js

+27-18
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,25 @@ import { customDomainSchema } from '@/lib/validate'
66
import ActionTooltip from './action-tooltip'
77
import { useToast } from '@/components/toast'
88

9-
const UPDATE_CUSTOM_DOMAIN = gql`
10-
mutation UpdateCustomDomain($subName: String!, $domain: String!) {
11-
updateCustomDomain(subName: $subName, domain: $domain) {
9+
const SET_CUSTOM_DOMAIN = gql`
10+
mutation SetCustomDomain($subName: String!, $domain: String!) {
11+
setCustomDomain(subName: $subName, domain: $domain) {
1212
domain
13-
verificationState
13+
dnsState
14+
sslState
1415
}
1516
}
1617
`
1718

1819
// TODO: verification states should refresh
1920
export default function CustomDomainForm ({ sub }) {
20-
const [updateCustomDomain] = useMutation(UPDATE_CUSTOM_DOMAIN, {
21+
const [setCustomDomain] = useMutation(SET_CUSTOM_DOMAIN, {
2122
refetchQueries: ['Sub']
2223
})
2324
const toaster = useToast()
2425

2526
const onSubmit = async ({ domain }) => {
26-
await updateCustomDomain({
27+
await setCustomDomain({
2728
variables: {
2829
subName: sub.name,
2930
domain
@@ -35,20 +36,22 @@ export default function CustomDomainForm ({ sub }) {
3536
const getStatusBadge = (status) => {
3637
switch (status) {
3738
case 'VERIFIED':
38-
return <Badge bg='success'>verified</Badge>
39+
return <Badge bg='success'>DNS verified</Badge>
3940
case 'PENDING':
40-
return <Badge bg='warning'>pending</Badge>
41+
return <Badge bg='warning'>DNS pending</Badge>
4142
case 'FAILED':
42-
return <Badge bg='danger'>failed</Badge>
43+
return <Badge bg='danger'>DNS failed</Badge>
4344
}
4445
}
4546

46-
const getSSLStatusBadge = (sslEnabled) => {
47-
switch (sslEnabled) {
48-
case true:
49-
return <Badge bg='success'>SSL enabled</Badge>
50-
case false:
51-
return <Badge bg='danger'>SSL disabled</Badge>
47+
const getSSLStatusBadge = (sslState) => {
48+
switch (sslState) {
49+
case 'VERIFIED':
50+
return <Badge bg='success'>SSL verified</Badge>
51+
case 'PENDING':
52+
return <Badge bg='warning'>SSL pending</Badge>
53+
case 'FAILED':
54+
return <Badge bg='danger'>SSL failed</Badge>
5255
}
5356
}
5457

@@ -71,10 +74,10 @@ export default function CustomDomainForm ({ sub }) {
7174
<>
7275
<div className='d-flex align-items-center gap-2'>
7376
<ActionTooltip overlayText={new Date(sub.customDomain.lastVerifiedAt).toUTCString()}>
74-
{getStatusBadge(sub.customDomain.verificationState)}
77+
{getStatusBadge(sub.customDomain.dnsState)}
7578
</ActionTooltip>
76-
{getSSLStatusBadge(sub.customDomain.sslEnabled)}
77-
{sub.customDomain.verificationState === 'PENDING' && (
79+
{getSSLStatusBadge(sub.customDomain.sslState)}
80+
{sub.customDomain.dnsState === 'PENDING' && (
7881
<Info>
7982
<h6>Verify your domain</h6>
8083
<p>Add the following DNS records to verify ownership of your domain:</p>
@@ -85,6 +88,12 @@ export default function CustomDomainForm ({ sub }) {
8588
</pre>
8689
</Info>
8790
)}
91+
{sub.customDomain.sslState === 'PENDING' && (
92+
<Info>
93+
<h6>SSL certificate pending</h6>
94+
<p>Our system will issue an SSL certificate for your domain.</p>
95+
</Info>
96+
)}
8897
</div>
8998
</>
9099
)}

components/territory-form.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ export default function TerritoryForm ({ sub }) {
294294
body={
295295
<>
296296
<TerritoryDomains sub={sub} />
297-
{sub?.customDomain?.verificationState === 'VERIFIED' &&
297+
{sub?.customDomain?.dnsState === 'VERIFIED' && sub?.customDomain?.sslState === 'VERIFIED' &&
298298
<>
299299
<BootstrapForm.Label>[NOT IMPLEMENTED] branding</BootstrapForm.Label>
300300
<div className='mb-3'>WIP</div>

fragments/subs.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ export const SUB_FIELDS = gql`
4040
updatedAt
4141
domain
4242
subName
43-
sslEnabled
44-
sslCertExpiry
45-
verificationState
43+
dnsState
44+
sslState
45+
certificateArn
4646
lastVerifiedAt
47+
cname
48+
verificationTxt
4749
}
4850
}`
4951

lib/domains.js

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { requestCertificate, getCertificateStatus } from '@/api/acm'
2+
import { promises as dnsPromises } from 'node:dns'
3+
4+
// TODO: skeleton
5+
export async function issueDomainCertificate (domainName) {
6+
try {
7+
const certificateArn = await requestCertificate(domainName)
8+
return certificateArn
9+
} catch (error) {
10+
console.error(`Failed to issue certificate for domain ${domainName}:`, error)
11+
return null
12+
}
13+
}
14+
15+
// TODO: skeleton
16+
export async function checkCertificateStatus (certificateArn) {
17+
let certStatus
18+
try {
19+
certStatus = await getCertificateStatus(certificateArn)
20+
} catch (error) {
21+
console.error(`Certificate status check failed: ${error.message}`)
22+
return 'FAILED'
23+
}
24+
25+
// map ACM statuses
26+
switch (certStatus) {
27+
case 'ISSUED':
28+
return 'ISSUED'
29+
case 'PENDING_VALIDATION':
30+
return 'PENDING'
31+
case 'VALIDATION_TIMED_OUT':
32+
case 'FAILED':
33+
return 'FAILED'
34+
default:
35+
return 'PENDING'
36+
}
37+
}
38+
39+
export async function verifyDomainDNS (domainName, verificationTxt, cname) {
40+
const result = {
41+
txtValid: false,
42+
cnameValid: false,
43+
error: null
44+
}
45+
46+
dnsPromises.setServers([process.env.DNS_RESOLVER || '1.1.1.1']) // cloudflare DNS resolver
47+
48+
// TXT Records checking
49+
// TODO: we should give a randomly generated string to the user and check if it's included in the TXT record
50+
try {
51+
const txtRecords = await dnsPromises.resolve(domainName, 'TXT')
52+
const txtText = txtRecords.flat().join(' ')
53+
54+
// the TXT record should include the verificationTxt that we have in the database
55+
result.txtValid = txtText.includes(verificationTxt)
56+
} catch (error) {
57+
if (error.code === 'ENODATA' || error.code === 'ENOTFOUND') {
58+
result.error = `TXT record not found: ${error.code}`
59+
} else {
60+
result.error = `TXT error: ${error.message}`
61+
}
62+
}
63+
64+
// CNAME Records checking
65+
try {
66+
const cnameRecords = await dnsPromises.resolve(domainName, 'CNAME')
67+
68+
// the CNAME record should include the cname that we have in the database
69+
result.cnameValid = cnameRecords.some(record =>
70+
record.includes(cname)
71+
)
72+
} catch (error) {
73+
if (!result.error) { // this is to avoid overriding the error from the TXT check
74+
if (error.code === 'ENODATA' || error.code === 'ENOTFOUND') {
75+
result.error = `CNAME record not found: ${error.code}`
76+
} else {
77+
result.error = `CNAME error: ${error.message}`
78+
}
79+
}
80+
}
81+
return result
82+
}

middleware.js

+2-3
Original file line numberDiff line numberDiff line change
@@ -268,13 +268,12 @@ export function applySecurityHeaders (resp) {
268268
}
269269

270270
export async function middleware (request) {
271-
const host = request.headers.get('host')
272-
const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
273-
274271
// First run referrer middleware to capture referrer data
275272
const referrerResp = referrerMiddleware(request)
276273

277274
// If we're on a custom domain, handle that next
275+
const host = request.headers.get('host')
276+
const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
278277
if (isCustomDomain) {
279278
const customDomainResp = await customDomainMiddleware(request, referrerResp)
280279
return applySecurityHeaders(customDomainResp)

pages/api/domains/index.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ export default async function handler (req, res) {
1919
subName: true
2020
},
2121
where: {
22-
verificationState: 'VERIFIED'
22+
dnsState: 'VERIFIED',
23+
sslState: 'VERIFIED'
2324
}
2425
})
2526

prisma/migrations/20250304121322_custom_domains/migration.sql

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ CREATE TABLE "CustomDomain" (
55
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
66
"domain" TEXT NOT NULL,
77
"subName" CITEXT NOT NULL,
8-
"sslEnabled" BOOLEAN NOT NULL DEFAULT false,
9-
"sslCertExpiry" TIMESTAMP(3),
10-
"verificationState" TEXT,
8+
"dnsState" TEXT NOT NULL DEFAULT 'PENDING',
9+
"sslState" TEXT NOT NULL DEFAULT 'PENDING',
10+
"certificateArn" TEXT,
1111
"lastVerifiedAt" TIMESTAMP(3),
1212
"cname" TEXT NOT NULL,
1313
"verificationTxt" TEXT NOT NULL,

prisma/schema.prisma

+4-4
Original file line numberDiff line numberDiff line change
@@ -1207,13 +1207,13 @@ model CustomDomain {
12071207
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
12081208
domain String @unique
12091209
subName String @unique @db.Citext
1210-
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
1211-
sslEnabled Boolean @default(false)
1212-
sslCertExpiry DateTime?
1213-
verificationState String?
1210+
dnsState String @default("PENDING")
1211+
sslState String @default("PENDING")
1212+
certificateArn String
12141213
lastVerifiedAt DateTime?
12151214
cname String
12161215
verificationTxt String
1216+
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
12171217
12181218
@@index([domain])
12191219
@@index([createdAt])

0 commit comments

Comments
 (0)