From c50509a6062a625ddaccc3d5d58ec0e9ed2ba841 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 1 May 2025 18:38:15 -0500 Subject: [PATCH 01/55] 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 --- .env.development | 9 +- api/acm/index.js | 53 +++++ api/resolvers/domain.js | 105 ++++++++ api/resolvers/index.js | 3 +- api/resolvers/sub.js | 3 + api/ssrApollo.js | 12 + api/typeDefs/domain.js | 41 ++++ api/typeDefs/index.js | 3 +- api/typeDefs/sub.js | 2 +- components/form.js | 10 +- components/item.module.css | 28 +++ components/territory-domains.js | 224 ++++++++++++++++++ components/territory-form.js | 11 + components/territory-header.js | 1 + docker-compose.yml | 14 +- docker/{s3 => aws}/cors.json | 0 docker/{s3 => aws}/init-s3.sh | 0 docs/dev/custom-domains-base.md | 123 ++++++++++ fragments/domains.js | 68 ++++++ fragments/subs.js | 5 + lib/domain-verification.js | 112 +++++++++ lib/domains.js | 28 +++ lib/validate.js | 9 + middleware.js | 61 ++++- pages/_app.js | 61 ++--- pages/api/domains/index.js | 40 ++++ .../migration.sql | 44 ++++ prisma/schema.prisma | 17 ++ worker/domainVerification.js | 168 +++++++++++++ worker/index.js | 2 + 30 files changed, 1212 insertions(+), 45 deletions(-) create mode 100644 api/acm/index.js create mode 100644 api/resolvers/domain.js create mode 100644 api/typeDefs/domain.js create mode 100644 components/territory-domains.js rename docker/{s3 => aws}/cors.json (100%) rename docker/{s3 => aws}/init-s3.sh (100%) create mode 100644 docs/dev/custom-domains-base.md create mode 100644 fragments/domains.js create mode 100644 lib/domain-verification.js create mode 100644 lib/domains.js create mode 100644 pages/api/domains/index.js create mode 100644 prisma/migrations/20250501200326_custom_domains/migration.sql create mode 100644 worker/domainVerification.js diff --git a/.env.development b/.env.development index 7572da564..b2f9e8245 100644 --- a/.env.development +++ b/.env.development @@ -114,7 +114,7 @@ NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000 # containers can't use localhost, so we need to use the container name IMGPROXY_URL_DOCKER=http://imgproxy:8080 -MEDIA_URL_DOCKER=http://s3:4566/uploads +MEDIA_URL_DOCKER=http://aws:4566/uploads # postgres container stuff POSTGRES_PASSWORD=password @@ -177,6 +177,7 @@ AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY PERSISTENCE=1 SKIP_SSL_CERT_DOWNLOAD=1 +LOCALSTACK_ENDPOINT=http://aws:4566 # tor proxy TOR_PROXY=http://tor:7050/ @@ -190,4 +191,8 @@ CPU_SHARES_IMPORTANT=1024 CPU_SHARES_MODERATE=512 CPU_SHARES_LOW=256 -NEXT_TELEMETRY_DISABLED=1 \ No newline at end of file +NEXT_TELEMETRY_DISABLED=1 + +# custom domains stuff +# DNS resolver for custom domain verification +DNS_RESOLVER=1.1.1.1 \ No newline at end of file diff --git a/api/acm/index.js b/api/acm/index.js new file mode 100644 index 000000000..cf76f4160 --- /dev/null +++ b/api/acm/index.js @@ -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() + 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 +} diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js new file mode 100644 index 000000000..6a60f2aa0 --- /dev/null +++ b/api/resolvers/domain.js @@ -0,0 +1,105 @@ +import { validateSchema, customDomainSchema } from '@/lib/validate' +import { GqlAuthenticationError, GqlInputError } from '@/lib/error' +import { randomBytes } from 'node:crypto' +import { getDomainMapping } from '@/lib/domains' + +export default { + Query: { + customDomain: async (parent, { subName }, { models }) => { + return models.customDomain.findUnique({ where: { subName } }) + }, + domainMapping: async (parent, { domain }, { models }) => { + const mapping = await getDomainMapping(domain) + return mapping + } + }, + 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') + } + + 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') + } + } + } + } +} diff --git a/api/resolvers/index.js b/api/resolvers/index.js index eccfaf1d0..b503518b6 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -20,6 +20,7 @@ import { GraphQLScalarType, Kind } from 'graphql' import { createIntScalar } from 'graphql-scalar' import paidAction from './paidAction' import vault from './vault' +import domain from './domain' const date = new GraphQLScalarType({ name: 'Date', @@ -56,4 +57,4 @@ const limit = createIntScalar({ export default [user, item, message, wallet, lnurl, notifications, invite, sub, upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee, - { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault] + domain, { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault] diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index 320670b66..aea327325 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -310,6 +310,9 @@ export default { return sub.SubSubscription?.length > 0 }, + customDomain: async (sub, args, { models }) => { + return models.customDomain.findUnique({ where: { subName: sub.name } }) + }, createdAt: sub => sub.createdAt || sub.created_at } } diff --git a/api/ssrApollo.js b/api/ssrApollo.js index d5513b7d9..23c4a8546 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -152,6 +152,17 @@ export function getGetServerSideProps ( const client = await getSSRApolloClient({ req, res }) + const isCustomDomain = req.headers.host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '') + const subName = req.headers['x-stacker-news-subname'] || null + let customDomain = null + if (isCustomDomain && subName) { + customDomain = { + domain: req.headers.host, + subName + // TODO: custom branding + } + } + let { data: { me } } = await client.query({ query: ME }) // required to redirect to /signup on page reload @@ -216,6 +227,7 @@ export function getGetServerSideProps ( return { props: { ...props, + customDomain, me, price, blockHeight, diff --git a/api/typeDefs/domain.js b/api/typeDefs/domain.js new file mode 100644 index 000000000..d59fb3e38 --- /dev/null +++ b/api/typeDefs/domain.js @@ -0,0 +1,41 @@ +import { gql } from 'graphql-tag' + +export default gql` + extend type Query { + customDomain(subName: String!): CustomDomain + domainMapping(domain: String!): DomainMapping + } + extend type Mutation { + setCustomDomain(subName: String!, domain: String!): CustomDomain + } + type CustomDomain { + createdAt: Date! + updatedAt: Date! + domain: String! + subName: String! + lastVerifiedAt: Date + failedAttempts: Int + status: String + verification: CustomDomainVerification + } + + type DomainMapping { + domain: String! + subName: String! + } + type CustomDomainVerification { + dns: CustomDomainVerificationDNS + ssl: CustomDomainVerificationSSL + } + type CustomDomainVerificationDNS { + state: String + cname: String + txt: String + } + type CustomDomainVerificationSSL { + state: String + arn: String + cname: String + value: String + } +` diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index eb4e1e427..0acd7dc44 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -19,6 +19,7 @@ import blockHeight from './blockHeight' import chainFee from './chainFee' import paidAction from './paidAction' import vault from './vault' +import domain from './domain' const common = gql` type Query { @@ -39,4 +40,4 @@ const common = gql` ` export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite, - sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault] + sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, domain, paidAction, vault] diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index 8401f1854..97ae104db 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -55,7 +55,7 @@ export default gql` nposts(when: String, from: String, to: String): Int! ncomments(when: String, from: String, to: String): Int! meSubscription: Boolean! - + customDomain: CustomDomain optional: SubOptional! } diff --git a/components/form.js b/components/form.js index c429ab7c9..12497a9b9 100644 --- a/components/form.js +++ b/components/form.js @@ -77,7 +77,7 @@ export function SubmitButton ({ ) } -function CopyButton ({ value, icon, ...props }) { +export function CopyButton ({ value, icon, append, ...props }) { const toaster = useToast() const [copied, setCopied] = useState(false) @@ -100,6 +100,14 @@ function CopyButton ({ value, icon, ...props }) { ) } + if (append) { + return ( + + {append} + + ) + } + return (