Skip to content

Commit c50509a

Browse files
committed
Custom Domains CRUD, Verification
- ACM support - custom domains crud, resolvers, fragments - custom domains form, guidelines - custom domains context - domain verification every 5 minutes via pgboss - domain validation schema - basic custom domains middleware, to be completed - TODOs tracings
1 parent b864290 commit c50509a

30 files changed

+1212
-45
lines changed

.env.development

+7-2
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000
114114

115115
# containers can't use localhost, so we need to use the container name
116116
IMGPROXY_URL_DOCKER=http://imgproxy:8080
117-
MEDIA_URL_DOCKER=http://s3:4566/uploads
117+
MEDIA_URL_DOCKER=http://aws:4566/uploads
118118

119119
# postgres container stuff
120120
POSTGRES_PASSWORD=password
@@ -177,6 +177,7 @@ AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
177177
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
178178
PERSISTENCE=1
179179
SKIP_SSL_CERT_DOWNLOAD=1
180+
LOCALSTACK_ENDPOINT=http://aws:4566
180181

181182
# tor proxy
182183
TOR_PROXY=http://tor:7050/
@@ -190,4 +191,8 @@ CPU_SHARES_IMPORTANT=1024
190191
CPU_SHARES_MODERATE=512
191192
CPU_SHARES_LOW=256
192193

193-
NEXT_TELEMETRY_DISABLED=1
194+
NEXT_TELEMETRY_DISABLED=1
195+
196+
# custom domains stuff
197+
# DNS resolver for custom domain verification
198+
DNS_RESOLVER=1.1.1.1

api/acm/index.js

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import AWS from 'aws-sdk'
2+
3+
AWS.config.update({
4+
region: 'us-east-1'
5+
})
6+
7+
const config = {}
8+
9+
export async function requestCertificate (domain) {
10+
// for local development, we use the LOCALSTACK_ENDPOINT
11+
if (process.env.NODE_ENV === 'development') {
12+
config.endpoint = process.env.LOCALSTACK_ENDPOINT
13+
}
14+
15+
const acm = new AWS.ACM(config)
16+
const params = {
17+
DomainName: domain,
18+
ValidationMethod: 'DNS',
19+
Tags: [
20+
{
21+
Key: 'ManagedBy',
22+
Value: 'stacker.news'
23+
}
24+
]
25+
}
26+
27+
const certificate = await acm.requestCertificate(params).promise()
28+
return certificate.CertificateArn
29+
}
30+
31+
export async function describeCertificate (certificateArn) {
32+
if (process.env.NODE_ENV === 'development') {
33+
config.endpoint = process.env.LOCALSTACK_ENDPOINT
34+
}
35+
const acm = new AWS.ACM(config)
36+
const certificate = await acm.describeCertificate({ CertificateArn: certificateArn }).promise()
37+
return certificate
38+
}
39+
40+
export async function getCertificateStatus (certificateArn) {
41+
const certificate = await describeCertificate(certificateArn)
42+
return certificate.Certificate.Status
43+
}
44+
45+
export async function deleteCertificate (certificateArn) {
46+
if (process.env.NODE_ENV === 'development') {
47+
config.endpoint = process.env.LOCALSTACK_ENDPOINT
48+
}
49+
const acm = new AWS.ACM(config)
50+
const result = await acm.deleteCertificate({ CertificateArn: certificateArn }).promise()
51+
console.log(`delete certificate attempt for ${certificateArn}, result: ${JSON.stringify(result)}`)
52+
return result
53+
}

api/resolvers/domain.js

+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { validateSchema, customDomainSchema } from '@/lib/validate'
2+
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
3+
import { randomBytes } from 'node:crypto'
4+
import { getDomainMapping } from '@/lib/domains'
5+
6+
export default {
7+
Query: {
8+
customDomain: async (parent, { subName }, { models }) => {
9+
return models.customDomain.findUnique({ where: { subName } })
10+
},
11+
domainMapping: async (parent, { domain }, { models }) => {
12+
const mapping = await getDomainMapping(domain)
13+
return mapping
14+
}
15+
},
16+
Mutation: {
17+
setCustomDomain: async (parent, { subName, domain }, { me, models }) => {
18+
if (!me) {
19+
throw new GqlAuthenticationError()
20+
}
21+
22+
const sub = await models.sub.findUnique({ where: { name: subName } })
23+
if (!sub) {
24+
throw new GqlInputError('sub not found')
25+
}
26+
27+
if (sub.userId !== me.id) {
28+
throw new GqlInputError('you do not own this sub')
29+
}
30+
31+
domain = domain.trim() // protect against trailing spaces
32+
if (domain && !validateSchema(customDomainSchema, { domain })) {
33+
throw new GqlInputError('invalid domain format')
34+
}
35+
36+
const existing = await models.customDomain.findUnique({ where: { subName } })
37+
38+
if (domain) {
39+
if (existing && existing.domain === domain && existing.status !== 'HOLD') {
40+
throw new GqlInputError('domain already set')
41+
}
42+
43+
const initializeDomain = {
44+
domain,
45+
createdAt: new Date(),
46+
status: 'PENDING',
47+
verification: {
48+
dns: {
49+
state: 'PENDING',
50+
cname: 'stacker.news',
51+
// generate a random txt record only if it's a new domain
52+
txt: existing?.domain === domain && existing.verification.dns.txt
53+
? existing.verification.dns.txt
54+
: randomBytes(32).toString('base64')
55+
},
56+
ssl: {
57+
state: 'WAITING',
58+
arn: null,
59+
cname: null,
60+
value: null
61+
}
62+
}
63+
}
64+
65+
const updatedDomain = await models.customDomain.upsert({
66+
where: { subName },
67+
update: {
68+
...initializeDomain
69+
},
70+
create: {
71+
...initializeDomain,
72+
sub: {
73+
connect: { name: subName }
74+
}
75+
}
76+
})
77+
78+
// schedule domain verification in 30 seconds
79+
await models.$executeRaw`
80+
INSERT INTO pgboss.job (name, data, retrylimit, retrydelay, startafter, keepuntil)
81+
VALUES ('domainVerification',
82+
jsonb_build_object('domainId', ${updatedDomain.id}::INTEGER),
83+
3,
84+
30,
85+
now() + interval '30 seconds',
86+
now() + interval '2 days')`
87+
88+
return updatedDomain
89+
} else {
90+
try {
91+
// delete any existing domain verification jobs
92+
await models.$queryRaw`
93+
DELETE FROM pgboss.job
94+
WHERE name = 'domainVerification'
95+
AND data->>'domainId' = ${existing.id}::TEXT`
96+
97+
return await models.customDomain.delete({ where: { subName } })
98+
} catch (error) {
99+
console.error(error)
100+
throw new GqlInputError('failed to delete domain')
101+
}
102+
}
103+
}
104+
}
105+
}

api/resolvers/index.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { GraphQLScalarType, Kind } from 'graphql'
2020
import { createIntScalar } from 'graphql-scalar'
2121
import paidAction from './paidAction'
2222
import vault from './vault'
23+
import domain from './domain'
2324

2425
const date = new GraphQLScalarType({
2526
name: 'Date',
@@ -56,4 +57,4 @@ const limit = createIntScalar({
5657

5758
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
5859
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
59-
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]
60+
domain, { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]

api/resolvers/sub.js

+3
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,9 @@ export default {
310310

311311
return sub.SubSubscription?.length > 0
312312
},
313+
customDomain: async (sub, args, { models }) => {
314+
return models.customDomain.findUnique({ where: { subName: sub.name } })
315+
},
313316
createdAt: sub => sub.createdAt || sub.created_at
314317
}
315318
}

api/ssrApollo.js

+12
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,17 @@ export function getGetServerSideProps (
152152

153153
const client = await getSSRApolloClient({ req, res })
154154

155+
const isCustomDomain = req.headers.host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
156+
const subName = req.headers['x-stacker-news-subname'] || null
157+
let customDomain = null
158+
if (isCustomDomain && subName) {
159+
customDomain = {
160+
domain: req.headers.host,
161+
subName
162+
// TODO: custom branding
163+
}
164+
}
165+
155166
let { data: { me } } = await client.query({ query: ME })
156167

157168
// required to redirect to /signup on page reload
@@ -216,6 +227,7 @@ export function getGetServerSideProps (
216227
return {
217228
props: {
218229
...props,
230+
customDomain,
219231
me,
220232
price,
221233
blockHeight,

api/typeDefs/domain.js

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { gql } from 'graphql-tag'
2+
3+
export default gql`
4+
extend type Query {
5+
customDomain(subName: String!): CustomDomain
6+
domainMapping(domain: String!): DomainMapping
7+
}
8+
extend type Mutation {
9+
setCustomDomain(subName: String!, domain: String!): CustomDomain
10+
}
11+
type CustomDomain {
12+
createdAt: Date!
13+
updatedAt: Date!
14+
domain: String!
15+
subName: String!
16+
lastVerifiedAt: Date
17+
failedAttempts: Int
18+
status: String
19+
verification: CustomDomainVerification
20+
}
21+
22+
type DomainMapping {
23+
domain: String!
24+
subName: String!
25+
}
26+
type CustomDomainVerification {
27+
dns: CustomDomainVerificationDNS
28+
ssl: CustomDomainVerificationSSL
29+
}
30+
type CustomDomainVerificationDNS {
31+
state: String
32+
cname: String
33+
txt: String
34+
}
35+
type CustomDomainVerificationSSL {
36+
state: String
37+
arn: String
38+
cname: String
39+
value: String
40+
}
41+
`

api/typeDefs/index.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import blockHeight from './blockHeight'
1919
import chainFee from './chainFee'
2020
import paidAction from './paidAction'
2121
import vault from './vault'
22+
import domain from './domain'
2223

2324
const common = gql`
2425
type Query {
@@ -39,4 +40,4 @@ const common = gql`
3940
`
4041

4142
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
42-
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault]
43+
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, domain, paidAction, vault]

api/typeDefs/sub.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export default gql`
5555
nposts(when: String, from: String, to: String): Int!
5656
ncomments(when: String, from: String, to: String): Int!
5757
meSubscription: Boolean!
58-
58+
customDomain: CustomDomain
5959
optional: SubOptional!
6060
}
6161

components/form.js

+9-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export function SubmitButton ({
7777
)
7878
}
7979

80-
function CopyButton ({ value, icon, ...props }) {
80+
export function CopyButton ({ value, icon, append, ...props }) {
8181
const toaster = useToast()
8282
const [copied, setCopied] = useState(false)
8383

@@ -100,6 +100,14 @@ function CopyButton ({ value, icon, ...props }) {
100100
)
101101
}
102102

103+
if (append) {
104+
return (
105+
<span className={styles.appendButton} {...props} onClick={handleClick}>
106+
{append}
107+
</span>
108+
)
109+
}
110+
103111
return (
104112
<Button className={styles.appendButton} {...props} onClick={handleClick}>
105113
{copied ? <Thumb width={18} height={18} /> : 'copy'}

components/item.module.css

+28
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,31 @@ a.link:visited {
247247
.skeleton .otherItemLonger {
248248
width: 60px;
249249
}
250+
251+
.record {
252+
display: flex;
253+
flex-direction: column;
254+
margin-right: 0.5rem;
255+
}
256+
257+
.clipboard {
258+
cursor: pointer;
259+
fill: var(--theme-grey);
260+
width: 1rem;
261+
height: 1rem;
262+
}
263+
264+
.clipboard:hover {
265+
fill: var(--bs-primary);
266+
}
267+
268+
.refresh {
269+
cursor: pointer;
270+
fill: var(--theme-grey);
271+
width: 1rem;
272+
height: 1rem;
273+
}
274+
275+
.refresh:hover {
276+
fill: var(--bs-primary);
277+
}

0 commit comments

Comments
 (0)