Skip to content

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

Closed
wants to merge 90 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
bd72179
Middleware for Custom Domains; Sync auth if coming from main domain; …
Soxasora Mar 8, 2025
2acbb44
Middleware tweaks; TODOs
Soxasora Mar 9, 2025
fa3b9a2
Merge branch 'master' into custom_domain
Soxasora Mar 9, 2025
cada0ee
values from customDomain in Territory Edit, domain string validation
Soxasora Mar 9, 2025
7bb6166
Fetch and cache all verified domains; allow only verified domains
Soxasora Mar 9, 2025
003ebe9
wip Custom Domain form for Territory Edit; fix endpoint typo; upsert …
Soxasora Mar 9, 2025
a39e5b0
Merge branch 'master' into custom_domain
Soxasora Mar 10, 2025
c930106
fix Sorts active key; referrer cookies workaround; structure for terr…
Soxasora Mar 10, 2025
5072aa1
don't show territory selector; don't redirect to main domain
Soxasora Mar 10, 2025
04df5d5
consider referrer cookies
Soxasora Mar 10, 2025
f4f37c3
check validity of CNAME and TXT records via worker; clean sub-select;…
Soxasora Mar 11, 2025
cf27645
DNS Resolver env setting, fixes inconsistencies with results
Soxasora Mar 11, 2025
3ac04a6
custom domain form and validation; FAILED verification only if it was…
Soxasora Mar 11, 2025
2a9297d
fix form submit; update Sub onSubmit
Soxasora Mar 13, 2025
f959c7f
wip One-click single sign on
Soxasora Mar 13, 2025
3947ff8
Merge branch 'master' into custom_domain
Soxasora Mar 14, 2025
9552bf8
issue and check SSL with worker; refactor customDomain; early ACM imp…
Soxasora Mar 14, 2025
1d04948
adjust schema and worker validation
Soxasora Mar 15, 2025
d906afa
Merge branch 'master' into custom_domain
Soxasora Mar 16, 2025
25bafc0
switch to AWS localstack; mock ACM integration; add certificate DNS v…
Soxasora Mar 17, 2025
390a15d
Merge branch 'master' into custom_domain
Soxasora Mar 18, 2025
0c79d9f
consequential domain verification flow
Soxasora Mar 18, 2025
23bba32
poll every 30 seconds SSL and DNS verification states
Soxasora Mar 18, 2025
76df54a
refactor custom domain queries, todo dynamic callback for login
Soxasora Mar 19, 2025
6db07b8
fix login flow, temporarily disable auto-auth, fix OAuth login
Soxasora Mar 21, 2025
d4d2a70
Merge branch 'master' into custom_domain
Soxasora Mar 22, 2025
4d6d659
middleware multiAuth support, referrer redirect support
Soxasora Mar 23, 2025
81f1550
refetch and start polling on domain update; domain verification tweak…
Soxasora Mar 23, 2025
d26e6c1
check for allowed domain earlier; remove domain after 5 failed verifi…
Soxasora Mar 23, 2025
9fbf794
handle isCustomDomain server-level
Soxasora Mar 24, 2025
1dd2e58
use AccountProvider for sync signIn and multiAuth
Soxasora Mar 24, 2025
1508719
Merge branch 'master' into custom_domain
Soxasora Mar 24, 2025
bd78954
compose auth sync callback with search params
Soxasora Mar 24, 2025
413387d
add comments to domainVerification worker
Soxasora Mar 24, 2025
987245d
comments: middleware, domain verification worker
Soxasora Mar 25, 2025
f0649e8
comments: auth sync, domain mapper, login page; fix: multiAuth signup
Soxasora Mar 25, 2025
260c2e6
comments: territory domains edit, auth, small comments
Soxasora Mar 25, 2025
310e766
restore nav commons
Soxasora Mar 25, 2025
934fc3f
cleanup: territory domains
Soxasora Mar 25, 2025
46db09b
Merge branch 'master' into custom_domain
Soxasora Mar 25, 2025
7256701
move auth sync out of Account Provider; remove faq todo
Soxasora Mar 25, 2025
0abf460
Merge branch 'master' into custom_domain
Soxasora Mar 25, 2025
5b314aa
update isCustomDomain also on client side navigation
Soxasora Mar 26, 2025
cc334f4
allow only www or subdomains
Soxasora Mar 26, 2025
272d81c
tweaks to CNAME validation UI/UX, add custom domain to territory info
Soxasora Mar 27, 2025
a44f4c4
Merge branch 'master' into custom_domain
Soxasora Mar 27, 2025
600a373
pass sub with DomainProvider, pass custom branding SSR, refactor bran…
Soxasora Mar 30, 2025
db1e15d
add button global styles
Soxasora Mar 31, 2025
82d544b
branding client and server-side, button global styles, pass ssr custo…
Soxasora Mar 31, 2025
54ee4b1
refactor custom domains and branding contexts, check for SSR values b…
Soxasora Apr 1, 2025
2a14780
safer auth sync, hard check domain's validity
Soxasora Apr 1, 2025
bc117ec
refactor isCustomDomain, fix typo on auth sync
Soxasora Apr 1, 2025
4147ed7
json colors, add more colors
Soxasora Apr 1, 2025
18a0675
logo and favicon upload, branding schema validation, experimental log…
Soxasora Apr 2, 2025
e3bf0e8
Merge branch 'master' into custom_domain
Soxasora Apr 3, 2025
6d25286
polling status on territory upsert, dynamic favicon and brand, verify…
Soxasora Apr 3, 2025
1fee3af
control domain verification via pgboss job, don't verify domains fail…
Soxasora Apr 3, 2025
207e314
switch to JSON verification model, use general status for custom doma…
Soxasora Apr 4, 2025
1e63e4b
fix typo domainVerification, better polling control
Soxasora Apr 4, 2025
851c580
Merge branch 'master' into custom_domain
Soxasora Apr 7, 2025
1ba661f
refactor, cleanup: domain mapper in resolver, can get mappings from g…
Soxasora Apr 8, 2025
f1ba0de
fix merge
Soxasora Apr 8, 2025
a5a9c10
close modal on custom domain logout, validate branding
Soxasora Apr 8, 2025
3264540
territory branding ui/ux cleanup, general cleanup
Soxasora Apr 8, 2025
3331353
cleanup: middleware, custom branding, gql fragments, adjust migration…
Soxasora Apr 8, 2025
c9942fb
cleanup: auth sync, respect redirectUrl and callbackUrl
Soxasora Apr 9, 2025
5dfefa1
cleanup: squash migrations, indentation, custom_domain_logger env var
Soxasora Apr 9, 2025
8788e10
use pgboss exponential backoff for domainVerification jobs
Soxasora Apr 9, 2025
de73dba
Merge branch 'master' into custom_domain
Soxasora Apr 9, 2025
2e65b10
adjust exponential backoff to match 48 hours of retries by starting a…
Soxasora Apr 9, 2025
8a672e2
logger factory caching, remove later
Soxasora Apr 9, 2025
f405dd1
hotfix: update MEDIA_URL_DOCKER to aws container instead of s3
Soxasora Apr 10, 2025
2f7e852
Merge branch 'master' into custom_domain
Soxasora Apr 23, 2025
a63004b
Middleware review changes
Soxasora Apr 23, 2025
4595b60
revert usage of direct prisma calls on domain mappings
Soxasora Apr 23, 2025
eba5d09
hotfix: cachedFetcher's domain mappings have to be called before acce…
Soxasora Apr 23, 2025
21a15d9
Territory Edit UI/UX changes
Soxasora Apr 24, 2025
06608b1
keep verification state in PENDING instead of FAILED; use _snverify a…
Soxasora Apr 24, 2025
d65f170
UI/UX: error messages, hover states, light cleanup
Soxasora Apr 25, 2025
07d90ab
use Resolver class of dns/promises, avoiding messing with other netwo…
Soxasora Apr 25, 2025
9eb8d96
Domain verification changes
Soxasora Apr 27, 2025
c547eaa
Better domain verification
Soxasora Apr 27, 2025
4bfda81
README: Custom domains prerequisites
Soxasora Apr 28, 2025
53c2522
atomic territory domains, move custom domain settings to main domain
Soxasora May 30, 2025
007723c
refactor domain components, prepare for branding settings
Soxasora May 30, 2025
885faa8
custom branding model, resolver, typedefs
Soxasora May 31, 2025
9df1814
adjust resolvers, allow custom branding upsert
Soxasora May 31, 2025
9f6bdb0
add custom branding to Sub fragment
Soxasora May 31, 2025
5130057
adjust SEO to custom values, BrandingProvider, implement base custom …
Soxasora May 31, 2025
1774241
Merge branch 'master' into custom_domain
Soxasora Mar 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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://localhost:4566
Copy link
Member

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:

Suggested change
LOCALSTACK_ENDPOINT=http://localhost:4566
LOCALSTACK_ENDPOINT=http://aws:4566

Copy link
Member Author

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!

Copy link
Member

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

Oh, I thought I pushed it during a cleanup commit, so sorry, it remained in local.

Cool, no worries!


# tor proxy
TOR_PROXY=http://tor:7050/
Expand All @@ -190,4 +191,10 @@ CPU_SHARES_IMPORTANT=1024
CPU_SHARES_MODERATE=512
CPU_SHARES_LOW=256

NEXT_TELEMETRY_DISABLED=1
NEXT_TELEMETRY_DISABLED=1

# DNS resolver for custom domain verification
DNS_RESOLVER=1.1.1.1

# domain debug logger
CUSTOM_DOMAIN_LOGGER=false
3 changes: 3 additions & 0 deletions .env.production
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ DB_APP_CONNECTION_LIMIT=4
DB_WORKER_CONNECTION_LIMIT=2
DB_TRANSACTION_TIMEOUT=10000
NEXT_TELEMETRY_DISABLED=1

# DNS resolver for custom domain verification
DNS_RESOLVER=1.1.1.1
53 changes: 53 additions & 0 deletions api/acm/index.js
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()
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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
}
75 changes: 75 additions & 0 deletions api/resolvers/branding.js
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 } }
}
})
}
}
}
105 changes: 105 additions & 0 deletions api/resolvers/domain.js
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')
Copy link
Member

Choose a reason for hiding this comment

The 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."

Copy link
Member Author

@Soxasora Soxasora Apr 25, 2025

Choose a reason for hiding this comment

The 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.
It's possible to allow domain re-saving but this singlehandedly prevents mistakes!

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')
}
}
}
}
}
4 changes: 3 additions & 1 deletion api/resolvers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { GraphQLScalarType, Kind } from 'graphql'
import { createIntScalar } from 'graphql-scalar'
import paidAction from './paidAction'
import vault from './vault'
import domain from './domain'
import branding from './branding'

const date = new GraphQLScalarType({
name: 'Date',
Expand Down Expand Up @@ -56,4 +58,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, branding, { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]
6 changes: 6 additions & 0 deletions api/resolvers/sub.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,12 @@ export default {

return sub.SubSubscription?.length > 0
},
customDomain: async (sub, args, { models }) => {
return models.customDomain.findUnique({ where: { subName: sub.name } })
},
customBranding: async (sub, args, { models }) => {
return models.customBranding.findUnique({ where: { subName: sub.name } })
},
createdAt: sub => sub.createdAt || sub.created_at
}
}
Expand Down
14 changes: 14 additions & 0 deletions api/ssrApollo.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ME } from '@/fragments/users'
import { PRICE } from '@/fragments/price'
import { BLOCK_HEIGHT } from '@/fragments/blockHeight'
import { CHAIN_FEE } from '@/fragments/chainFee'
import { GET_CUSTOM_BRANDING } from '@/fragments/brandings'
import { getServerSession } from 'next-auth/next'
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
import { NOFOLLOW_LIMIT } from '@/lib/constants'
Expand Down Expand Up @@ -152,6 +153,18 @@ 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) {
const { data: { customBranding } } = await client.query({ query: GET_CUSTOM_BRANDING, variables: { subName } })
customDomain = {
domain: req.headers.host,
subName,
branding: customBranding || null
}
}

let { data: { me } } = await client.query({ query: ME })

// required to redirect to /signup on page reload
Expand Down Expand Up @@ -216,6 +229,7 @@ export function getGetServerSideProps (
return {
props: {
...props,
customDomain,
me,
price,
blockHeight,
Expand Down
27 changes: 27 additions & 0 deletions api/typeDefs/branding.js
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
}
`
Loading