-
-
Notifications
You must be signed in to change notification settings - Fork 128
Custom Domains for Territories #1958
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bd72179
2acbb44
fa3b9a2
cada0ee
7bb6166
003ebe9
a39e5b0
c930106
5072aa1
04df5d5
f4f37c3
cf27645
3ac04a6
2a9297d
f959c7f
3947ff8
9552bf8
1d04948
d906afa
25bafc0
390a15d
0c79d9f
23bba32
76df54a
6db07b8
d4d2a70
4d6d659
81f1550
d26e6c1
9fbf794
1dd2e58
1508719
bd78954
413387d
987245d
f0649e8
260c2e6
310e766
934fc3f
46db09b
7256701
0abf460
5b314aa
cc334f4
272d81c
a44f4c4
600a373
db1e15d
82d544b
54ee4b1
2a14780
bc117ec
4147ed7
18a0675
e3bf0e8
6d25286
1fee3af
207e314
1e63e4b
851c580
1ba661f
f1ba0de
a5a9c10
3264540
3331353
c9942fb
5dfefa1
8788e10
de73dba
2e65b10
8a672e2
f405dd1
2f7e852
a63004b
4595b60
eba5d09
21a15d9
06608b1
d65f170
07d90ab
9eb8d96
c547eaa
4bfda81
53c2522
007723c
885faa8
9df1814
9f6bdb0
5130057
1774241
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import AWS from 'aws-sdk' | ||
|
||
AWS.config.update({ | ||
region: 'us-east-1' | ||
}) | ||
|
||
const config = {} | ||
|
||
export async function requestCertificate (domain) { | ||
// for local development, we use the LOCALSTACK_ENDPOINT | ||
if (process.env.NODE_ENV === 'development') { | ||
config.endpoint = process.env.LOCALSTACK_ENDPOINT | ||
} | ||
|
||
const acm = new AWS.ACM(config) | ||
const params = { | ||
DomainName: domain, | ||
ValidationMethod: 'DNS', | ||
Tags: [ | ||
{ | ||
Key: 'ManagedBy', | ||
Value: 'stacker.news' | ||
} | ||
] | ||
} | ||
|
||
const certificate = await acm.requestCertificate(params).promise() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For how long are we requesting/issuing a certificate? Certificates should expire when their territory expires or have to be renewed every year. I tried to find how this is determined in the AWS docs but I couldn't find it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Super question, I didn't think of that, the way I described it to you during the meeting got me confused so I'll explain better here: ACM issues certificates with a fixed validity of 13 months or 395 days, to have some kind of control here we could send a DeleteCertificate request to ACM after the territory period of grace ends without successful renewal. I'll annotate this to implement it. But! If we don't ever delete a certificate (good territory owner), after 13 months, ACM will automatically renew it so we don't have to do anything. |
||
return certificate.CertificateArn | ||
} | ||
|
||
export async function describeCertificate (certificateArn) { | ||
if (process.env.NODE_ENV === 'development') { | ||
config.endpoint = process.env.LOCALSTACK_ENDPOINT | ||
} | ||
const acm = new AWS.ACM(config) | ||
const certificate = await acm.describeCertificate({ CertificateArn: certificateArn }).promise() | ||
return certificate | ||
} | ||
|
||
export async function getCertificateStatus (certificateArn) { | ||
const certificate = await describeCertificate(certificateArn) | ||
return certificate.Certificate.Status | ||
} | ||
|
||
export async function deleteCertificate (certificateArn) { | ||
if (process.env.NODE_ENV === 'development') { | ||
config.endpoint = process.env.LOCALSTACK_ENDPOINT | ||
} | ||
const acm = new AWS.ACM(config) | ||
const result = await acm.deleteCertificate({ CertificateArn: certificateArn }).promise() | ||
console.log(`delete certificate attempt for ${certificateArn}, result: ${JSON.stringify(result)}`) | ||
return result | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error' | ||
import { validateSchema, customBrandingSchema } from '@/lib/validate' | ||
|
||
export default { | ||
Query: { | ||
customBranding: async (parent, { subName }, { models }) => { | ||
return models.customBranding.findUnique({ where: { subName } }) | ||
} | ||
}, | ||
Mutation: { | ||
setCustomBranding: async (parent, { subName, branding }, { me, models }) => { | ||
if (!me) { | ||
throw new GqlAuthenticationError() | ||
} | ||
|
||
const sub = await models.sub.findUnique({ where: { name: subName } }) | ||
if (!sub) { | ||
throw new GqlInputError('sub not found') | ||
} | ||
|
||
if (sub.userId !== me.id) { | ||
throw new GqlInputError('you do not own this sub') | ||
} | ||
|
||
const { title, colors, logoId, faviconId } = branding | ||
|
||
const parsedLogoId = parseInt(logoId) | ||
const parsedFaviconId = parseInt(faviconId) | ||
|
||
if (parsedLogoId) { | ||
const logo = await models.upload.findUnique({ where: { id: parsedLogoId } }) | ||
if (!logo) { | ||
throw new GqlInputError('logo not found') | ||
} | ||
} | ||
|
||
if (parsedFaviconId) { | ||
const favicon = await models.upload.findUnique({ where: { id: parsedFaviconId } }) | ||
if (!favicon) { | ||
throw new GqlInputError('favicon not found') | ||
} | ||
} | ||
|
||
if (!validateSchema(customBrandingSchema, { | ||
title, | ||
primary: colors.primary, | ||
secondary: colors.secondary, | ||
info: colors.info, | ||
success: colors.success, | ||
danger: colors.danger, | ||
logoId, | ||
faviconId | ||
})) { | ||
throw new GqlInputError('invalid branding') | ||
} | ||
|
||
return await models.customBranding.upsert({ | ||
where: { subName }, | ||
update: { | ||
title: title || subName, | ||
colors, | ||
...(parsedLogoId && { logo: { connect: { id: parsedLogoId } } }), | ||
...(parsedFaviconId && { favicon: { connect: { id: parsedFaviconId } } }) | ||
}, | ||
create: { | ||
title: title || subName, | ||
colors, | ||
...(parsedLogoId && { logo: { connect: { id: parsedLogoId } } }), | ||
...(parsedFaviconId && { favicon: { connect: { id: parsedFaviconId } } }), | ||
sub: { connect: { name: subName } } | ||
} | ||
}) | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
import { validateSchema, customDomainSchema } from '@/lib/validate' | ||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error' | ||
import { randomBytes } from 'node:crypto' | ||
import { getDomainMappingsCache } from '@/lib/domains' | ||
|
||
export default { | ||
Query: { | ||
customDomain: async (parent, { subName }, { models }) => { | ||
return models.customDomain.findUnique({ where: { subName } }) | ||
}, | ||
domainMapping: async (parent, { domain }, { models }) => { | ||
const domainMappings = await getDomainMappingsCache() | ||
return domainMappings?.[domain] | ||
} | ||
}, | ||
Mutation: { | ||
setCustomDomain: async (parent, { subName, domain }, { me, models }) => { | ||
if (!me) { | ||
throw new GqlAuthenticationError() | ||
} | ||
|
||
const sub = await models.sub.findUnique({ where: { name: subName } }) | ||
if (!sub) { | ||
throw new GqlInputError('sub not found') | ||
} | ||
|
||
if (sub.userId !== me.id) { | ||
throw new GqlInputError('you do not own this sub') | ||
} | ||
|
||
domain = domain.trim() // protect against trailing spaces | ||
if (domain && !validateSchema(customDomainSchema, { domain })) { | ||
throw new GqlInputError('invalid domain format') | ||
} | ||
|
||
const existing = await models.customDomain.findUnique({ where: { subName } }) | ||
|
||
if (domain) { | ||
if (existing && existing.domain === domain && existing.status !== 'HOLD') { | ||
throw new GqlInputError('domain already set') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this have to be an error for some reason? If I try to save the domain with the same value, I would expect it to just work™ but this throws. Next to not sure if this has to be an error, the client only shows the generic error message "failed to update domain." There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a way to prevent users to trigger useless verifications that could lead to an offline state, changing TXT values etc. About the error: I think I forgot to update the toasts after the latest commits, fixing it asap! |
||
} | ||
|
||
const initializeDomain = { | ||
domain, | ||
createdAt: new Date(), | ||
status: 'PENDING', | ||
verification: { | ||
dns: { | ||
state: 'PENDING', | ||
cname: 'stacker.news', | ||
// generate a random txt record only if it's a new domain | ||
txt: existing?.domain === domain && existing.verification.dns.txt | ||
? existing.verification.dns.txt | ||
: randomBytes(32).toString('base64') | ||
}, | ||
ssl: { | ||
state: 'WAITING', | ||
arn: null, | ||
cname: null, | ||
value: null | ||
} | ||
} | ||
} | ||
|
||
const updatedDomain = await models.customDomain.upsert({ | ||
where: { subName }, | ||
update: { | ||
...initializeDomain | ||
}, | ||
create: { | ||
...initializeDomain, | ||
sub: { | ||
connect: { name: subName } | ||
} | ||
} | ||
}) | ||
|
||
// schedule domain verification in 30 seconds | ||
await models.$executeRaw` | ||
INSERT INTO pgboss.job (name, data, retrylimit, retrydelay, startafter, keepuntil) | ||
VALUES ('domainVerification', | ||
jsonb_build_object('domainId', ${updatedDomain.id}::INTEGER), | ||
3, | ||
30, | ||
now() + interval '30 seconds', | ||
now() + interval '2 days')` | ||
|
||
return updatedDomain | ||
} else { | ||
try { | ||
// delete any existing domain verification jobs | ||
await models.$queryRaw` | ||
DELETE FROM pgboss.job | ||
WHERE name = 'domainVerification' | ||
AND data->>'domainId' = ${existing.id}::TEXT` | ||
|
||
return await models.customDomain.delete({ where: { subName } }) | ||
} catch (error) { | ||
console.error(error) | ||
throw new GqlInputError('failed to delete domain') | ||
} | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import { gql } from 'graphql-tag' | ||
|
||
export default gql` | ||
extend type Query { | ||
customBranding(subName: String!): CustomBranding | ||
} | ||
|
||
extend type Mutation { | ||
setCustomBranding(subName: String!, branding: CustomBrandingInput!): CustomBranding | ||
} | ||
|
||
type CustomBranding { | ||
title: String | ||
colors: JSONObject | ||
logoId: String | ||
faviconId: String | ||
subName: String | ||
} | ||
|
||
input CustomBrandingInput { | ||
title: String | ||
colors: JSONObject | ||
logoId: String | ||
faviconId: String | ||
subName: String | ||
} | ||
` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this needs to be this:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, I thought I pushed it during a cleanup commit, so sorry, it remained in local.
I'll push it with the next commit!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, I deleted my comment because I wanted to make it part of my review lol
Cool, no worries!