From bd7217977089ca99329ce0ea36adb43b184ee588 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Sat, 8 Mar 2025 22:25:27 +0000
Subject: [PATCH 01/74] Middleware for Custom Domains; Sync auth if coming from
main domain; placeholders
---
api/paidAction/territoryDomain.js | 1 +
api/resolvers/customDomain.js | 43 ++++++
api/resolvers/sub.js | 3 +
api/typeDefs/sub.js | 6 +-
components/sub-select.js | 18 +++
components/territory-form.js | 4 +
fragments/subs.js | 4 +
middleware.js | 139 +++++++++++++++++-
pages/api/auth/sync.js | 83 +++++++++++
.../migration.sql | 29 ++++
prisma/schema.prisma | 17 +++
11 files changed, 344 insertions(+), 3 deletions(-)
create mode 100644 api/paidAction/territoryDomain.js
create mode 100644 api/resolvers/customDomain.js
create mode 100644 pages/api/auth/sync.js
create mode 100644 prisma/migrations/20250304121322_custom_domains/migration.sql
diff --git a/api/paidAction/territoryDomain.js b/api/paidAction/territoryDomain.js
new file mode 100644
index 000000000..ee07963c1
--- /dev/null
+++ b/api/paidAction/territoryDomain.js
@@ -0,0 +1 @@
+// TODO: Custom domain will be a paid action
\ No newline at end of file
diff --git a/api/resolvers/customDomain.js b/api/resolvers/customDomain.js
new file mode 100644
index 000000000..d9a9ed131
--- /dev/null
+++ b/api/resolvers/customDomain.js
@@ -0,0 +1,43 @@
+// Concept of interoperability with cache
+export default {
+ Query: {
+ customDomains: async (_, __, { models }) => {
+ return models.customDomain.findMany()
+ }
+ },
+
+ Mutation: {
+ upsertCustomDomain: async (_, { domain, subName, sslEnabled }, { models, boss }) => {
+ const result = await models.customDomain.upsert({
+ where: { domain },
+ update: {
+ subName,
+ sslEnabled: sslEnabled ?? false
+ },
+ create: {
+ domain,
+ subName,
+ sslEnabled: sslEnabled ?? false
+ }
+ })
+
+ // maybe a job?
+ // BUT pgboss will be used
+ await boss.send('invalidateDomainCache', {}, { priority: 'high' })
+
+ return result
+ },
+
+ deleteCustomDomain: async (_, { domain }, { models, boss }) => {
+ await models.customDomain.delete({
+ where: { domain }
+ })
+
+ // maybe a job? x2
+ // BUT pgboss will be used
+ await boss.send('invalidateDomainCache', {}, { priority: 'high' })
+
+ return true
+ }
+ }
+}
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/typeDefs/sub.js b/api/typeDefs/sub.js
index 8401f1854..ab8f2df35 100644
--- a/api/typeDefs/sub.js
+++ b/api/typeDefs/sub.js
@@ -9,6 +9,10 @@ export default gql`
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
}
+ type CustomDomain {
+ domain: String!
+ }
+
type Subs {
cursor: String
subs: [Sub!]!
@@ -55,7 +59,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/sub-select.js b/components/sub-select.js
index 5f7bcdea5..f7e82ca32 100644
--- a/components/sub-select.js
+++ b/components/sub-select.js
@@ -79,6 +79,24 @@ export default function SubSelect ({ prependSubs, sub, onChange, size, appendSub
return
}
+ // Check if we're on a custom domain
+
+ // TODO: main domain should be in the env
+ // If we're on stacker.news and selecting a territory, redirect to territory subdomain
+ const host = window.location.host
+ console.log('host', host)
+ if (host === 'sn.soxa.dev' && sub) {
+ // Get the base domain (e.g., soxa.dev) from environment or config
+ const protocol = window.location.protocol
+
+ // Create the territory subdomain URL
+ const territoryUrl = `${protocol}//${sub}.soxa.dev/?source=stackernews`
+
+ // Redirect to the territory subdomain
+ window.location.href = territoryUrl
+ return
+ }
+
let asPath
// are we currently in a sub (ie not home)
if (router.query.sub) {
diff --git a/components/territory-form.js b/components/territory-form.js
index 0983002c8..6686a1f79 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -274,6 +274,10 @@ export default function TerritoryForm ({ sub }) {
name='nsfw'
groupClassName='ms-1'
/>
+ personalized domains
+ WIP {sub?.customDomain?.domain}
+ color scheme
+ WIP
>
}
diff --git a/fragments/subs.js b/fragments/subs.js
index 1ff2c492e..1e3914538 100644
--- a/fragments/subs.js
+++ b/fragments/subs.js
@@ -13,6 +13,7 @@ const STREAK_FIELDS = gql`
}
`
+// TODO: better place
export const SUB_FIELDS = gql`
fragment SubFields on Sub {
name
@@ -34,6 +35,9 @@ export const SUB_FIELDS = gql`
meMuteSub
meSubscription
nsfw
+ customDomain {
+ domain
+ }
}`
export const SUB_FULL_FIELDS = gql`
diff --git a/middleware.js b/middleware.js
index f88bda31c..278071127 100644
--- a/middleware.js
+++ b/middleware.js
@@ -1,5 +1,4 @@
import { NextResponse, URLPattern } from 'next/server'
-
const referrerPattern = new URLPattern({ pathname: ':pathname(*)/r/:referrer([\\w_]+)' })
const itemPattern = new URLPattern({ pathname: '/items/:id(\\d+){/:other(\\w+)}?' })
const profilePattern = new URLPattern({ pathname: '/:name([\\w_]+){/:type(\\w+)}?' })
@@ -12,6 +11,129 @@ const SN_REFERRER_NONCE = 'sn_referrer_nonce'
// key for referred pages
const SN_REFEREE_LANDING = 'sn_referee_landing'
+const TERRITORY_PATHS = [
+ '/',
+ '/~',
+ '/recent',
+ '/random',
+ '/top',
+ '/items'
+]
+
+function getDomainMapping () {
+ // placeholder for cachedFetcher
+ return {
+ 'forum.pizza.com': { subName: 'pizza' }
+ // placeholder
+ }
+}
+
+export function customDomainMiddleware (request, referrerResp) {
+ const host = request.headers.get('host')
+ const referer = request.headers.get('referer')
+ const url = request.nextUrl.clone()
+ const pathname = url.pathname
+ const mainDomain = process.env.NEXT_PUBLIC_URL
+
+ console.log('referer', referer)
+
+ const domainMapping = getDomainMapping() // placeholder
+ const domainInfo = domainMapping[host.toLowerCase()]
+ if (!domainInfo) {
+ return NextResponse.redirect(new URL(pathname, mainDomain))
+ }
+
+ // For territory paths, handle them directly on the custom domain
+ if (TERRITORY_PATHS.includes(pathname)) {
+ // Internally rewrite the request to the territory path without changing the URL
+ const internalUrl = new URL(url)
+
+ // If we're at the root path, internally rewrite to the territory path
+ if (pathname === '/' || pathname === '/~') {
+ internalUrl.pathname = `/~${domainInfo.subName}`
+ console.log('Internal rewrite to:', internalUrl.pathname)
+
+ // NextResponse.rewrite() keeps the URL the same for the user
+ // but internally fetches from the rewritten path
+ return NextResponse.rewrite(internalUrl)
+ }
+
+ // For other territory paths like /recent, /top, etc.
+ // We need to rewrite them to the territory-specific versions
+ if (pathname === '/recent' || pathname === '/top' || pathname === '/random' || pathname === '/items') {
+ internalUrl.pathname = `/~${domainInfo.subName}${pathname}`
+ console.log('Internal rewrite to:', internalUrl.pathname)
+ return NextResponse.rewrite(internalUrl)
+ }
+
+ // Handle auth if needed
+ if (!referer || referer !== mainDomain) {
+ const authResp = customDomainAuthMiddleware(request, url)
+ if (authResp && authResp.status !== 200) {
+ // copy referrer cookies to auth redirect
+ for (const [key, value] of referrerResp.cookies.getAll()) {
+ authResp.cookies.set(key, value.value, value)
+ }
+ return authResp
+ }
+ }
+ return referrerResp
+ }
+
+ // redirect to main domain for non-territory paths
+ // create redirect response but preserve referrer cookies
+ const redirectResp = NextResponse.redirect(new URL(pathname, mainDomain))
+
+ // copy referrer cookies
+ for (const [key, value] of referrerResp.cookies.getAll()) {
+ redirectResp.cookies.set(key, value.value, value)
+ }
+
+ return redirectResp
+}
+
+// TODO: dirty of previous iterations, refactor
+// Not safe, tokens are visible in the URL
+export function customDomainAuthMiddleware (request, url) {
+ const pathname = url.pathname
+ const host = request.headers.get('host')
+ const authDomain = process.env.NEXT_PUBLIC_URL
+ const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
+ const secure = process.env.NODE_ENV === 'development'
+
+ // check for session both in session token and in multi_auth cookie
+ const sessionCookieName = secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
+ const multiAuthUserId = request.cookies.get('multi_auth.user-id')?.value
+
+ // 1. We have a session token directly, or
+ // 2. We have a multi_auth user ID and the corresponding multi_auth cookie
+ const hasActiveSession = !!request.cookies.get(sessionCookieName)?.value
+ const hasMultiAuthSession = multiAuthUserId && !!request.cookies.get(`multi_auth.${multiAuthUserId}`)?.value
+
+ const hasSession = hasActiveSession || hasMultiAuthSession
+ const response = NextResponse.next()
+
+ if (!hasSession && isCustomDomain) {
+ // Use the original request's host and protocol for the redirect URL
+ // TODO: original request url points to localhost, this is a workaround atm
+ const protocol = secure ? 'https' : 'http'
+ const originalDomain = `${protocol}://${host}`
+ const redirectTarget = `${originalDomain}${pathname}`
+
+ // Create the auth sync URL with the correct original domain
+ const syncUrl = new URL(`${authDomain}/api/auth/sync`)
+ syncUrl.searchParams.set('redirectUrl', redirectTarget)
+
+ console.log('AUTH: Redirecting to:', syncUrl.toString())
+ console.log('AUTH: With redirect back to:', redirectTarget)
+ const redirectResponse = NextResponse.redirect(syncUrl)
+ return redirectResponse
+ }
+
+ console.log('No redirect')
+ return response
+}
+
function getContentReferrer (request, url) {
if (itemPattern.test(url)) {
let id = request.nextUrl.searchParams.get('commentId')
@@ -85,7 +207,20 @@ function referrerMiddleware (request) {
}
export function middleware (request) {
- const resp = referrerMiddleware(request)
+ const host = request.headers.get('host')
+ const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
+
+ // First run referrer middleware to capture referrer data
+ const referrerResp = referrerMiddleware(request)
+
+ // If we're on a custom domain, handle that next
+ if (isCustomDomain) {
+ return customDomainMiddleware(request, referrerResp)
+ }
+
+ const resp = referrerResp
+
+ // TODO: This doesn't run for custom domains, need to support it
const isDev = process.env.NODE_ENV === 'development'
diff --git a/pages/api/auth/sync.js b/pages/api/auth/sync.js
new file mode 100644
index 000000000..7748b9178
--- /dev/null
+++ b/pages/api/auth/sync.js
@@ -0,0 +1,83 @@
+import { getServerSession } from 'next-auth/next'
+import { getAuthOptions } from './[...nextauth]'
+import { serialize } from 'cookie'
+import { datePivot } from '@/lib/time'
+
+// TODO: not safe, tokens are visible in the URL
+export default async function handler (req, res) {
+ console.log(req.query)
+ if (req.query.token) {
+ const session = JSON.parse(decodeURIComponent(req.query.token))
+ return saveCookie(req, res, session)
+ } else {
+ const { redirectUrl } = req.query
+ const session = await getServerSession(req, res, getAuthOptions(req))
+ if (session) {
+ console.log('session', session)
+ console.log('req.cookies', req.cookies)
+
+ const userId = session.user.id
+ const multiAuthCookieName = `multi_auth.${userId}`
+ const multiAuthToken = req.cookies[multiAuthCookieName]
+
+ if (!multiAuthToken) {
+ console.error('No multi_auth token found for user', userId)
+ return res.status(400).json({ error: 'No multi_auth token found' })
+ }
+
+ const transferData = {
+ session,
+ multiAuthToken,
+ userId
+ }
+
+ // redirect back to the custom domain with the token data
+ const callbackUrl = new URL('/api/auth/sync', redirectUrl)
+ callbackUrl.searchParams.set('token', encodeURIComponent(JSON.stringify(transferData)))
+ callbackUrl.searchParams.set('redirectUrl', req.query.redirectUrl || '/')
+
+ return res.redirect(callbackUrl.toString())
+ }
+ return res.redirect(redirectUrl)
+ }
+}
+
+export async function saveCookie (req, res, tokenData) {
+ const secure = process.env.NODE_ENV === 'development'
+ if (!tokenData) {
+ return res.status(400).json({ error: 'Missing token' })
+ }
+
+ try {
+ const expiresAt = datePivot(new Date(), { months: 1 })
+ const cookieOptions = {
+ path: '/',
+ httpOnly: true,
+ secure,
+ sameSite: 'lax',
+ expires: expiresAt
+ }
+ // extract the data from the token
+ const { multiAuthToken, userId } = tokenData
+ console.log('Received session and multi_auth token for user', userId)
+
+ // set the session cookie
+ const sessionCookieName = secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
+ // create cookies
+ const sessionCookie = serialize(sessionCookieName, multiAuthToken, cookieOptions)
+ // also set the multi_auth cookie on the custom domain
+ const multiAuthCookie = serialize(`multi_auth.${userId}`, multiAuthToken, cookieOptions)
+ // set the cookie pointer
+ const pointerCookie = serialize('multi_auth.user-id', userId, cookieOptions)
+
+ // set the cookies in the response
+ res.setHeader('Set-Cookie', [sessionCookie, multiAuthCookie, pointerCookie])
+
+ // redirect to the home page or a specified return URL
+ const returnTo = req.query.redirectUrl || '/'
+ return res.redirect(returnTo)
+ } catch (error) {
+ console.error('Error processing auth callback:', error)
+ return res.status(500).json({ error: 'Failed to process authentication' })
+ }
+}
diff --git a/prisma/migrations/20250304121322_custom_domains/migration.sql b/prisma/migrations/20250304121322_custom_domains/migration.sql
new file mode 100644
index 000000000..eb273acfe
--- /dev/null
+++ b/prisma/migrations/20250304121322_custom_domains/migration.sql
@@ -0,0 +1,29 @@
+-- CreateTable
+CREATE TABLE "CustomDomain" (
+ "id" SERIAL NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "domain" TEXT NOT NULL,
+ "subName" CITEXT NOT NULL,
+ "sslEnabled" BOOLEAN NOT NULL DEFAULT false,
+ "sslCertExpiry" TIMESTAMP(3),
+ "verificationState" TEXT,
+ "lastVerifiedAt" TIMESTAMP(3),
+
+ CONSTRAINT "CustomDomain_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CustomDomain_domain_key" ON "CustomDomain"("domain");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CustomDomain_subName_key" ON "CustomDomain"("subName");
+
+-- CreateIndex
+CREATE INDEX "CustomDomain_domain_idx" ON "CustomDomain"("domain");
+
+-- CreateIndex
+CREATE INDEX "CustomDomain_created_at_idx" ON "CustomDomain"("created_at");
+
+-- AddForeignKey
+ALTER TABLE "CustomDomain" ADD CONSTRAINT "CustomDomain_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a3f986b62..18e02b830 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -760,6 +760,7 @@ model Sub {
MuteSub MuteSub[]
SubSubscription SubSubscription[]
TerritoryTransfer TerritoryTransfer[]
+ customDomain CustomDomain?
@@index([parentName])
@@index([createdAt])
@@ -1200,6 +1201,22 @@ model Reminder {
@@index([userId, remindAt], map: "Reminder.userId_reminderAt_index")
}
+model CustomDomain {
+ id Int @id @default(autoincrement())
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
+ domain String @unique
+ subName String @unique @db.Citext
+ sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
+ sslEnabled Boolean @default(false)
+ sslCertExpiry DateTime?
+ verificationState String?
+ lastVerifiedAt DateTime?
+
+ @@index([domain])
+ @@index([createdAt])
+}
+
enum EarnType {
POST
COMMENT
From 2acbb445f45b20caeec5706d5301d6df63f2d8ac Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Sun, 9 Mar 2025 00:30:46 +0000
Subject: [PATCH 02/74] Middleware tweaks; TODOs
---
api/paidAction/territoryDomain.js | 1 -
api/resolvers/customDomain.js | 43 -------------
components/sub-select.js | 20 +-----
components/territory-form.js | 7 ++-
docs/user/faq.md | 4 ++
middleware.js | 100 +++++++++++++++---------------
pages/api/auth/sync.js | 6 +-
7 files changed, 64 insertions(+), 117 deletions(-)
delete mode 100644 api/paidAction/territoryDomain.js
delete mode 100644 api/resolvers/customDomain.js
diff --git a/api/paidAction/territoryDomain.js b/api/paidAction/territoryDomain.js
deleted file mode 100644
index ee07963c1..000000000
--- a/api/paidAction/territoryDomain.js
+++ /dev/null
@@ -1 +0,0 @@
-// TODO: Custom domain will be a paid action
\ No newline at end of file
diff --git a/api/resolvers/customDomain.js b/api/resolvers/customDomain.js
deleted file mode 100644
index d9a9ed131..000000000
--- a/api/resolvers/customDomain.js
+++ /dev/null
@@ -1,43 +0,0 @@
-// Concept of interoperability with cache
-export default {
- Query: {
- customDomains: async (_, __, { models }) => {
- return models.customDomain.findMany()
- }
- },
-
- Mutation: {
- upsertCustomDomain: async (_, { domain, subName, sslEnabled }, { models, boss }) => {
- const result = await models.customDomain.upsert({
- where: { domain },
- update: {
- subName,
- sslEnabled: sslEnabled ?? false
- },
- create: {
- domain,
- subName,
- sslEnabled: sslEnabled ?? false
- }
- })
-
- // maybe a job?
- // BUT pgboss will be used
- await boss.send('invalidateDomainCache', {}, { priority: 'high' })
-
- return result
- },
-
- deleteCustomDomain: async (_, { domain }, { models, boss }) => {
- await models.customDomain.delete({
- where: { domain }
- })
-
- // maybe a job? x2
- // BUT pgboss will be used
- await boss.send('invalidateDomainCache', {}, { priority: 'high' })
-
- return true
- }
- }
-}
diff --git a/components/sub-select.js b/components/sub-select.js
index f7e82ca32..b5c3e4f61 100644
--- a/components/sub-select.js
+++ b/components/sub-select.js
@@ -52,6 +52,8 @@ export function useSubs ({ prependSubs = DEFAULT_PREPEND_SUBS, sub, filterSubs =
...appendSubs])
}, [data])
+ // TODO: can pass custom domain
+
return subs
}
@@ -79,23 +81,7 @@ export default function SubSelect ({ prependSubs, sub, onChange, size, appendSub
return
}
- // Check if we're on a custom domain
-
- // TODO: main domain should be in the env
- // If we're on stacker.news and selecting a territory, redirect to territory subdomain
- const host = window.location.host
- console.log('host', host)
- if (host === 'sn.soxa.dev' && sub) {
- // Get the base domain (e.g., soxa.dev) from environment or config
- const protocol = window.location.protocol
-
- // Create the territory subdomain URL
- const territoryUrl = `${protocol}//${sub}.soxa.dev/?source=stackernews`
-
- // Redirect to the territory subdomain
- window.location.href = territoryUrl
- return
- }
+ // TODO: redirect to the custom domain if it has one
let asPath
// are we currently in a sub (ie not home)
diff --git a/components/territory-form.js b/components/territory-form.js
index 6686a1f79..961bd109b 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -84,6 +84,7 @@ export default function TerritoryForm ({ sub }) {
}
}, [sub, billing])
+ // TODO: Add a custom domain textbox and verification status; validation too
return (
- personalized domains
- WIP {sub?.customDomain?.domain}
- color scheme
+ personalized domains (TODO textbox/status)
+ WIP {sub?.customDomain?.domain || 'not set'}
+ color scheme (TODO 5 options)
WIP
>
diff --git a/docs/user/faq.md b/docs/user/faq.md
index db4cac3a4..f90a3bb45 100644
--- a/docs/user/faq.md
+++ b/docs/user/faq.md
@@ -267,6 +267,10 @@ The stats for each territory are the following:
You can filter the same stats by different periods in [top territories](/top/territories/day).
+### TODO: How can I add a custom domain to a territory?
+
+TODO
+
---
## Zaps
diff --git a/middleware.js b/middleware.js
index 278071127..5db724aec 100644
--- a/middleware.js
+++ b/middleware.js
@@ -11,20 +11,14 @@ const SN_REFERRER_NONCE = 'sn_referrer_nonce'
// key for referred pages
const SN_REFEREE_LANDING = 'sn_referee_landing'
-const TERRITORY_PATHS = [
- '/',
- '/~',
- '/recent',
- '/random',
- '/top',
- '/items'
-]
+const TERRITORY_PATHS = ['/~', '/recent', '/random', '/top', '/post', '/edit']
+const NO_REWRITE_PATHS = ['/api', '/_next', '/_error', '/404', '/500', '/offline', '/static', '/items']
function getDomainMapping () {
// placeholder for cachedFetcher
return {
'forum.pizza.com': { subName: 'pizza' }
- // placeholder
+ // placeholder for other domains
}
}
@@ -33,7 +27,9 @@ export function customDomainMiddleware (request, referrerResp) {
const referer = request.headers.get('referer')
const url = request.nextUrl.clone()
const pathname = url.pathname
- const mainDomain = process.env.NEXT_PUBLIC_URL
+ const mainDomain = process.env.NEXT_PUBLIC_URL + '/'
+ console.log('host', host)
+ console.log('mainDomain', mainDomain)
console.log('referer', referer)
@@ -43,31 +39,25 @@ export function customDomainMiddleware (request, referrerResp) {
return NextResponse.redirect(new URL(pathname, mainDomain))
}
- // For territory paths, handle them directly on the custom domain
- if (TERRITORY_PATHS.includes(pathname)) {
- // Internally rewrite the request to the territory path without changing the URL
- const internalUrl = new URL(url)
+ if (NO_REWRITE_PATHS.some(p => pathname.startsWith(p)) || pathname.includes('.')) {
+ return NextResponse.next()
+ }
- // If we're at the root path, internally rewrite to the territory path
- if (pathname === '/' || pathname === '/~') {
- internalUrl.pathname = `/~${domainInfo.subName}`
- console.log('Internal rewrite to:', internalUrl.pathname)
+ console.log('pathname', pathname)
+ console.log('query', url.searchParams)
- // NextResponse.rewrite() keeps the URL the same for the user
- // but internally fetches from the rewritten path
- return NextResponse.rewrite(internalUrl)
- }
-
- // For other territory paths like /recent, /top, etc.
- // We need to rewrite them to the territory-specific versions
- if (pathname === '/recent' || pathname === '/top' || pathname === '/random' || pathname === '/items') {
- internalUrl.pathname = `/~${domainInfo.subName}${pathname}`
- console.log('Internal rewrite to:', internalUrl.pathname)
- return NextResponse.rewrite(internalUrl)
- }
+ // if the url contains the territory path, remove it
+ if (pathname.startsWith(`/~${domainInfo.subName}`)) {
+ // remove the territory prefix from the path
+ const cleanPath = pathname.replace(`/~${domainInfo.subName}`, '') || '/'
+ console.log('Redirecting to clean path:', cleanPath)
+ return NextResponse.redirect(new URL(cleanPath + url.search, url.origin))
+ }
- // Handle auth if needed
- if (!referer || referer !== mainDomain) {
+ // if territory path, retain custom domain
+ if (pathname === '/' || TERRITORY_PATHS.some(p => pathname.startsWith(p))) {
+ // if coming from main domain, handle auth automatically
+ if (referer && referer === mainDomain) {
const authResp = customDomainAuthMiddleware(request, url)
if (authResp && authResp.status !== 200) {
// copy referrer cookies to auth redirect
@@ -77,7 +67,15 @@ export function customDomainMiddleware (request, referrerResp) {
return authResp
}
}
- return referrerResp
+
+ const internalUrl = new URL(url)
+
+ // rewrite to the territory path if we're at the root
+ internalUrl.pathname = `/~${domainInfo.subName}${pathname === '/' ? '' : pathname}`
+ console.log('Rewrite to:', internalUrl.pathname)
+
+ // rewrite to the territory path
+ return NextResponse.rewrite(internalUrl)
}
// redirect to main domain for non-territory paths
@@ -93,7 +91,7 @@ export function customDomainMiddleware (request, referrerResp) {
}
// TODO: dirty of previous iterations, refactor
-// Not safe, tokens are visible in the URL
+// UNSAFE UNSAFE UNSAFE tokens are visible in the URL
export function customDomainAuthMiddleware (request, url) {
const pathname = url.pathname
const host = request.headers.get('host')
@@ -114,7 +112,6 @@ export function customDomainAuthMiddleware (request, url) {
const response = NextResponse.next()
if (!hasSession && isCustomDomain) {
- // Use the original request's host and protocol for the redirect URL
// TODO: original request url points to localhost, this is a workaround atm
const protocol = secure ? 'https' : 'http'
const originalDomain = `${protocol}://${host}`
@@ -206,22 +203,7 @@ function referrerMiddleware (request) {
return response
}
-export function middleware (request) {
- const host = request.headers.get('host')
- const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
-
- // First run referrer middleware to capture referrer data
- const referrerResp = referrerMiddleware(request)
-
- // If we're on a custom domain, handle that next
- if (isCustomDomain) {
- return customDomainMiddleware(request, referrerResp)
- }
-
- const resp = referrerResp
-
- // TODO: This doesn't run for custom domains, need to support it
-
+export function applySecurityHeaders (resp) {
const isDev = process.env.NODE_ENV === 'development'
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
@@ -268,6 +250,22 @@ export function middleware (request) {
return resp
}
+export function middleware (request) {
+ const host = request.headers.get('host')
+ const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
+
+ // First run referrer middleware to capture referrer data
+ const referrerResp = referrerMiddleware(request)
+
+ // If we're on a custom domain, handle that next
+ if (isCustomDomain) {
+ const customDomainResp = customDomainMiddleware(request, referrerResp)
+ return applySecurityHeaders(customDomainResp)
+ }
+
+ return applySecurityHeaders(referrerResp)
+}
+
export const config = {
matcher: [
// NextJS recommends to not add the CSP header to prefetches and static assets
diff --git a/pages/api/auth/sync.js b/pages/api/auth/sync.js
index 7748b9178..b1826eb69 100644
--- a/pages/api/auth/sync.js
+++ b/pages/api/auth/sync.js
@@ -3,7 +3,8 @@ import { getAuthOptions } from './[...nextauth]'
import { serialize } from 'cookie'
import { datePivot } from '@/lib/time'
-// TODO: not safe, tokens are visible in the URL
+// TODO: dirty of previous iterations, refactor
+// UNSAFE UNSAFE UNSAFE tokens are visible in the URL
export default async function handler (req, res) {
console.log(req.query)
if (req.query.token) {
@@ -12,6 +13,7 @@ export default async function handler (req, res) {
} else {
const { redirectUrl } = req.query
const session = await getServerSession(req, res, getAuthOptions(req))
+ // TODO: use session to create a verification token
if (session) {
console.log('session', session)
console.log('req.cookies', req.cookies)
@@ -43,12 +45,12 @@ export default async function handler (req, res) {
}
export async function saveCookie (req, res, tokenData) {
- const secure = process.env.NODE_ENV === 'development'
if (!tokenData) {
return res.status(400).json({ error: 'Missing token' })
}
try {
+ const secure = process.env.NODE_ENV === 'development'
const expiresAt = datePivot(new Date(), { months: 1 })
const cookieOptions = {
path: '/',
From cada0eead2a45ee8e7d9dc295257048ed77324e0 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Sun, 9 Mar 2025 14:06:39 +0000
Subject: [PATCH 03/74] values from customDomain in Territory Edit, domain
string validation
---
api/typeDefs/sub.js | 7 +++++++
components/territory-form.js | 33 ++++++++++++++++++++++++++++-----
fragments/subs.js | 7 +++++++
lib/validate.js | 5 ++++-
4 files changed, 46 insertions(+), 6 deletions(-)
diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js
index ab8f2df35..63dd4312e 100644
--- a/api/typeDefs/sub.js
+++ b/api/typeDefs/sub.js
@@ -10,7 +10,14 @@ export default gql`
}
type CustomDomain {
+ createdAt: Date!
+ updatedAt: Date!
domain: String!
+ subName: String!
+ sslEnabled: Boolean!
+ sslCertExpiry: Date
+ verificationState: String!
+ lastVerifiedAt: Date
}
type Subs {
diff --git a/components/territory-form.js b/components/territory-form.js
index 961bd109b..588470cd2 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -97,7 +97,8 @@ export default function TerritoryForm ({ sub }) {
billingType: sub?.billingType || 'MONTHLY',
billingAutoRenew: sub?.billingAutoRenew || false,
moderated: sub?.moderated || false,
- nsfw: sub?.nsfw || false
+ nsfw: sub?.nsfw || false,
+ customDomain: sub?.customDomain?.domain || ''
}}
schema={schema}
onSubmit={onSubmit}
@@ -275,10 +276,32 @@ export default function TerritoryForm ({ sub }) {
name='nsfw'
groupClassName='ms-1'
/>
- personalized domains (TODO textbox/status)
- WIP {sub?.customDomain?.domain || 'not set'}
- color scheme (TODO 5 options)
- WIP
+ [NOT IMPLEMENTED] custom domain
+
+
+ TODO Immediate infos on Custom Domains
+
+
+
+ }
+ name='customDomain'
+ type='text'
+ required
+ append={
+ <>
+ {sub?.customDomain?.verificationState || 'not verified'}
+ >
+ }
+ />
+ {sub?.customDomain?.verificationState === 'VERIFIED' &&
+ <>
+ [NOT IMPLEMENTED] branding
+ WIP
+ [NOT IMPLEMENTED] color scheme
+ WIP
+ >}
>
}
diff --git a/fragments/subs.js b/fragments/subs.js
index 1e3914538..b8a80f602 100644
--- a/fragments/subs.js
+++ b/fragments/subs.js
@@ -36,7 +36,14 @@ export const SUB_FIELDS = gql`
meSubscription
nsfw
customDomain {
+ createdAt
+ updatedAt
domain
+ subName
+ sslEnabled
+ sslCertExpiry
+ verificationState
+ lastVerifiedAt
}
}`
diff --git a/lib/validate.js b/lib/validate.js
index e0d55bfb4..fdabefb38 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -323,7 +323,10 @@ export function territorySchema (args) {
.max(100000, 'must be at most 100k'),
postTypes: array().of(string().oneOf(POST_TYPES)).min(1, 'must support at least one post type'),
billingType: string().required('required').oneOf(TERRITORY_BILLING_TYPES, 'required'),
- nsfw: boolean()
+ nsfw: boolean(),
+ customDomain: string().matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
+ message: 'enter a valid domain name (e.g., example.com)'
+ }).nullable()
})
}
From 7bb6166e27b135f17851e346eb864f19d1c062c4 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Sun, 9 Mar 2025 17:32:33 +0000
Subject: [PATCH 04/74] Fetch and cache all verified domains; allow only
verified domains
---
middleware.js | 37 +++++++++++++++++++++++++-----------
pages/api/domains/index.js | 39 ++++++++++++++++++++++++++++++++++++++
2 files changed, 65 insertions(+), 11 deletions(-)
create mode 100644 pages/api/domains/index.js
diff --git a/middleware.js b/middleware.js
index 5db724aec..8a064f989 100644
--- a/middleware.js
+++ b/middleware.js
@@ -1,4 +1,5 @@
import { NextResponse, URLPattern } from 'next/server'
+import { cachedFetcher } from '@/lib/fetch'
const referrerPattern = new URLPattern({ pathname: ':pathname(*)/r/:referrer([\\w_]+)' })
const itemPattern = new URLPattern({ pathname: '/items/:id(\\d+){/:other(\\w+)}?' })
const profilePattern = new URLPattern({ pathname: '/:name([\\w_]+){/:type(\\w+)}?' })
@@ -14,15 +15,29 @@ const SN_REFEREE_LANDING = 'sn_referee_landing'
const TERRITORY_PATHS = ['/~', '/recent', '/random', '/top', '/post', '/edit']
const NO_REWRITE_PATHS = ['/api', '/_next', '/_error', '/404', '/500', '/offline', '/static', '/items']
-function getDomainMapping () {
- // placeholder for cachedFetcher
- return {
- 'forum.pizza.com': { subName: 'pizza' }
- // placeholder for other domains
+// fetch custom domain mappings from our API, caching it for 5 minutes
+const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings () {
+ const url = `${process.env.NEXT_PUBLIC_URL}/api/domains/map`
+ try {
+ const response = await fetch(url)
+ if (!response.ok) {
+ console.error(`Cannot fetch domain mappings: ${response.status} ${response.statusText}`)
+ return null
+ }
+
+ const data = await response.json()
+ return Object.keys(data).length > 0 ? data : null
+ } catch (error) {
+ console.error('Cannot fetch domain mappings:', error)
+ return null
}
-}
+}, {
+ cacheExpiry: 300000, // 5 minutes cache
+ forceRefreshThreshold: 600000, // 10 minutes before force refresh
+ keyGenerator: () => 'domain_mappings'
+})
-export function customDomainMiddleware (request, referrerResp) {
+export async function customDomainMiddleware (request, referrerResp) {
const host = request.headers.get('host')
const referer = request.headers.get('referer')
const url = request.nextUrl.clone()
@@ -33,8 +48,8 @@ export function customDomainMiddleware (request, referrerResp) {
console.log('referer', referer)
- const domainMapping = getDomainMapping() // placeholder
- const domainInfo = domainMapping[host.toLowerCase()]
+ const domainMapping = await getDomainMappingsCache()
+ const domainInfo = domainMapping?.[host.toLowerCase()]
if (!domainInfo) {
return NextResponse.redirect(new URL(pathname, mainDomain))
}
@@ -250,7 +265,7 @@ export function applySecurityHeaders (resp) {
return resp
}
-export function middleware (request) {
+export async function middleware (request) {
const host = request.headers.get('host')
const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
@@ -259,7 +274,7 @@ export function middleware (request) {
// If we're on a custom domain, handle that next
if (isCustomDomain) {
- const customDomainResp = customDomainMiddleware(request, referrerResp)
+ const customDomainResp = await customDomainMiddleware(request, referrerResp)
return applySecurityHeaders(customDomainResp)
}
diff --git a/pages/api/domains/index.js b/pages/api/domains/index.js
new file mode 100644
index 000000000..8799faa6d
--- /dev/null
+++ b/pages/api/domains/index.js
@@ -0,0 +1,39 @@
+import prisma from '@/api/models'
+
+// TODO: Authentication for this?
+export default async function handler (req, res) {
+ res.setHeader('Access-Control-Allow-Origin', '*')
+ res.setHeader('Access-Control-Allow-Methods', 'GET')
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
+
+ // Only allow GET requests
+ if (req.method !== 'GET') {
+ return res.status(405).json({ error: 'Method not allowed' })
+ }
+
+ try {
+ // fetch all custom domains from the database
+ const domains = await prisma.customDomain.findMany({
+ select: {
+ domain: true,
+ subName: true
+ },
+ where: {
+ verificationState: 'VERIFIED'
+ }
+ })
+
+ // map domains to a key-value pair
+ const domainMappings = domains.reduce((acc, domain) => {
+ acc[domain.domain.toLowerCase()] = {
+ subName: domain.subName
+ }
+ return acc
+ }, {})
+
+ return res.status(200).json(domainMappings)
+ } catch (error) {
+ console.error('cannot fetch domains:', error)
+ return res.status(500).json({ error: 'Failed to fetch domains' })
+ }
+}
From 003ebe95cb093bb54d551f0cdf261df0f60d8c33 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Sun, 9 Mar 2025 23:03:08 +0000
Subject: [PATCH 05/74] wip Custom Domain form for Territory Edit; fix endpoint
typo; upsert customDomain resolver
---
api/resolvers/sub.js | 31 ++++++++++-
api/typeDefs/sub.js | 1 +
components/territory-domains.js | 94 +++++++++++++++++++++++++++++++++
components/territory-form.js | 21 +-------
lib/validate.js | 14 +++--
middleware.js | 3 +-
6 files changed, 139 insertions(+), 25 deletions(-)
create mode 100644 components/territory-domains.js
diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js
index aea327325..25729e0ef 100644
--- a/api/resolvers/sub.js
+++ b/api/resolvers/sub.js
@@ -1,5 +1,5 @@
import { whenRange } from '@/lib/time'
-import { validateSchema, territorySchema } from '@/lib/validate'
+import { validateSchema, validateDomain, territorySchema } from '@/lib/validate'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { viewGroup } from './growth'
import { notifyTerritoryTransfer } from '@/lib/webPush'
@@ -277,6 +277,35 @@ export default {
}
return await performPaidAction('TERRITORY_UNARCHIVE', data, { me, models, lnd })
+ },
+ updateCustomDomain: 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()
+ if (domain && !validateDomain(domain)) {
+ throw new GqlInputError('Invalid domain format')
+ }
+
+ if (domain) {
+ const existing = await models.customDomain.findUnique({ where: { subName } })
+ if (existing) {
+ return await models.customDomain.update({ where: { subName }, data: { domain, verificationState: 'PENDING' } })
+ } else {
+ return await models.customDomain.create({ data: { domain, subName, verificationState: 'PENDING' } })
+ }
+ } else {
+ return await models.customDomain.delete({ where: { subName } })
+ }
}
},
Sub: {
diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js
index 63dd4312e..3c8e0e415 100644
--- a/api/typeDefs/sub.js
+++ b/api/typeDefs/sub.js
@@ -39,6 +39,7 @@ export default gql`
replyCost: Int!, postTypes: [String!]!,
billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
+ updateCustomDomain(subName: String!, domain: String!): CustomDomain
}
type Sub {
diff --git a/components/territory-domains.js b/components/territory-domains.js
new file mode 100644
index 000000000..6914134f4
--- /dev/null
+++ b/components/territory-domains.js
@@ -0,0 +1,94 @@
+import { useState } from 'react'
+import { Badge } from 'react-bootstrap'
+import { Form, Input, SubmitButton } from './form'
+import { gql, useMutation } from '@apollo/client'
+import Info from './info'
+
+const UPDATE_CUSTOM_DOMAIN = gql`
+ mutation UpdateCustomDomain($subName: String!, $domain: String!) {
+ updateCustomDomain(subName: $subName, domain: $domain) {
+ domain
+ verificationState
+ lastVerifiedAt
+ }
+ }
+`
+
+export default function CustomDomainForm ({ sub }) {
+ const [updateCustomDomain] = useMutation(UPDATE_CUSTOM_DOMAIN)
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(false)
+
+ const onSubmit = async ({ domain }) => {
+ setError(null)
+ setSuccess(false)
+ console.log('domain', domain)
+
+ const { data } = await updateCustomDomain({
+ variables: {
+ subName: sub.name,
+ domain
+ }
+ })
+ console.log('success', data)
+ setSuccess(true)
+ }
+
+ const getStatusBadge = (status) => {
+ switch (status) {
+ case 'VERIFIED':
+ return verified
+ case 'PENDING':
+ return pending
+ case 'FAILED':
+ return failed
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/components/territory-form.js b/components/territory-form.js
index 588470cd2..f8dbee484 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -14,6 +14,7 @@ import { purchasedType } from '@/lib/territory'
import { SUB } from '@/fragments/subs'
import { usePaidMutation } from './use-paid-mutation'
import { UNARCHIVE_TERRITORY, UPSERT_SUB } from '@/fragments/paidAction'
+import TerritoryDomains from './territory-domains'
export default function TerritoryForm ({ sub }) {
const router = useRouter()
@@ -276,25 +277,7 @@ export default function TerritoryForm ({ sub }) {
name='nsfw'
groupClassName='ms-1'
/>
- [NOT IMPLEMENTED] custom domain
-
-
- TODO Immediate infos on Custom Domains
-
-
-
- }
- name='customDomain'
- type='text'
- required
- append={
- <>
- {sub?.customDomain?.verificationState || 'not verified'}
- >
- }
- />
+
{sub?.customDomain?.verificationState === 'VERIFIED' &&
<>
[NOT IMPLEMENTED] branding
diff --git a/lib/validate.js b/lib/validate.js
index fdabefb38..a83eea763 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -323,10 +323,7 @@ export function territorySchema (args) {
.max(100000, 'must be at most 100k'),
postTypes: array().of(string().oneOf(POST_TYPES)).min(1, 'must support at least one post type'),
billingType: string().required('required').oneOf(TERRITORY_BILLING_TYPES, 'required'),
- nsfw: boolean(),
- customDomain: string().matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
- message: 'enter a valid domain name (e.g., example.com)'
- }).nullable()
+ nsfw: boolean()
})
}
@@ -349,6 +346,15 @@ export function territoryTransferSchema ({ me, ...args }) {
})
}
+// TODO: validate domain
+export function customDomainSchema (args) {
+ return object({
+ customDomain: string().matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
+ message: 'enter a valid domain name (e.g., example.com)'
+ }).nullable()
+ })
+}
+
export function userSchema (args) {
return object({
name: nameValidator
diff --git a/middleware.js b/middleware.js
index 8a064f989..e689affe4 100644
--- a/middleware.js
+++ b/middleware.js
@@ -17,7 +17,7 @@ const NO_REWRITE_PATHS = ['/api', '/_next', '/_error', '/404', '/500', '/offline
// fetch custom domain mappings from our API, caching it for 5 minutes
const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings () {
- const url = `${process.env.NEXT_PUBLIC_URL}/api/domains/map`
+ const url = `${process.env.NEXT_PUBLIC_URL}/api/domains`
try {
const response = await fetch(url)
if (!response.ok) {
@@ -49,6 +49,7 @@ export async function customDomainMiddleware (request, referrerResp) {
console.log('referer', referer)
const domainMapping = await getDomainMappingsCache()
+ console.log('domainMapping', domainMapping)
const domainInfo = domainMapping?.[host.toLowerCase()]
if (!domainInfo) {
return NextResponse.redirect(new URL(pathname, mainDomain))
From c930106193941d7f330e7920ab2fad514348c15e Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Mon, 10 Mar 2025 12:12:00 +0000
Subject: [PATCH 06/74] fix Sorts active key; referrer cookies workaround;
structure for territory domains editing
---
components/nav/index.js | 10 ++++++++--
components/territory-domains.js | 2 +-
components/territory-form.js | 14 ++++++++++----
middleware.js | 30 +++++++++++++++++++++++++-----
4 files changed, 44 insertions(+), 12 deletions(-)
diff --git a/components/nav/index.js b/components/nav/index.js
index beacbd6fc..3a9e9aaf1 100644
--- a/components/nav/index.js
+++ b/components/nav/index.js
@@ -7,12 +7,18 @@ import { PriceCarouselProvider } from './price-carousel'
export default function Navigation ({ sub }) {
const router = useRouter()
const path = router.asPath.split('?')[0]
+ // TODO: this works but it can be better
+ const isCustomDomain = sub && !path.includes(`/~${sub}`)
const props = {
prefix: sub ? `/~${sub}` : '',
path,
pathname: router.pathname,
- topNavKey: path.split('/')[sub ? 2 : 1] ?? '',
- dropNavKey: path.split('/').slice(sub ? 2 : 1).join('/'),
+ topNavKey: isCustomDomain
+ ? path.split('/')[1] ?? ''
+ : path.split('/')[sub ? 2 : 1] ?? '',
+ dropNavKey: isCustomDomain
+ ? path.split('/').slice(1).join('/')
+ : path.split('/').slice(sub ? 2 : 1).join('/'),
sub
}
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 6914134f4..ab1aeb40e 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -56,7 +56,7 @@ export default function CustomDomainForm ({ sub }) {
+
domain
{error && error }
{success && Domain settings updated successfully! }
diff --git a/components/territory-form.js b/components/territory-form.js
index f8dbee484..098efd7e0 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -257,7 +257,7 @@ export default function TerritoryForm ({ sub }) {
- }
+ }
name='moderated'
groupClassName='ms-1'
/>
@@ -273,10 +273,17 @@ export default function TerritoryForm ({ sub }) {
- }
+ }
name='nsfw'
groupClassName='ms-1'
/>
+ >
+ }
+ />
+ personalization}
+ body={
+ <>
{sub?.customDomain?.verificationState === 'VERIFIED' &&
<>
@@ -286,8 +293,7 @@ export default function TerritoryForm ({ sub }) {
WIP
>}
>
-
-}
+ }
/>
Date: Mon, 10 Mar 2025 19:45:22 +0000
Subject: [PATCH 07/74] don't show territory selector; don't redirect to main
domain
---
components/nav/desktop/second-bar.js | 8 ++--
components/nav/index.js | 1 +
components/nav/mobile/top-bar.js | 3 +-
middleware.js | 57 ++++++++++++----------------
pages/~/index.js | 6 ++-
5 files changed, 38 insertions(+), 37 deletions(-)
diff --git a/components/nav/desktop/second-bar.js b/components/nav/desktop/second-bar.js
index 4120c3065..8cef17ec0 100644
--- a/components/nav/desktop/second-bar.js
+++ b/components/nav/desktop/second-bar.js
@@ -3,7 +3,7 @@ import { NavSelect, PostItem, Sorts, hasNavSelect } from '../common'
import styles from '../../header.module.css'
export default function SecondBar (props) {
- const { prefix, topNavKey, sub } = props
+ const { prefix, topNavKey, isCustomDomain, sub } = props
if (!hasNavSelect(props)) return null
return (
@@ -11,8 +11,10 @@ export default function SecondBar (props) {
className={styles.navbarNav}
activeKey={topNavKey}
>
-
-
+ {!isCustomDomain && }
+
+
+
diff --git a/components/nav/index.js b/components/nav/index.js
index 3a9e9aaf1..da034ccbd 100644
--- a/components/nav/index.js
+++ b/components/nav/index.js
@@ -19,6 +19,7 @@ export default function Navigation ({ sub }) {
dropNavKey: isCustomDomain
? path.split('/').slice(1).join('/')
: path.split('/').slice(sub ? 2 : 1).join('/'),
+ isCustomDomain,
sub
}
diff --git a/components/nav/mobile/top-bar.js b/components/nav/mobile/top-bar.js
index 7cb13191d..a8f77f887 100644
--- a/components/nav/mobile/top-bar.js
+++ b/components/nav/mobile/top-bar.js
@@ -3,8 +3,9 @@ import styles from '../../header.module.css'
import { Back, NavPrice, NavSelect, NavWalletSummary, SignUpButton, hasNavSelect } from '../common'
import { useMe } from '@/components/me'
-export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey }) {
+export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey, isCustomDomain }) {
const { me } = useMe()
+ if (hasNavSelect({ path, pathname }) && isCustomDomain) return null
return (
pathname.startsWith(p))) {
- // if coming from main domain, handle auth automatically
- if (referer && referer === mainDomain) {
- const authResp = customDomainAuthMiddleware(request, url)
- if (authResp && authResp.status !== 200) {
- // copy referrer cookies to auth redirect
- console.log('referrerResp', referrerResp)
- for (const cookie of referrerResp.cookies.getAll()) {
- authResp.cookies.set(
- cookie.name,
- cookie.value,
- {
- maxAge: cookie.maxAge,
- expires: cookie.expires,
- path: cookie.path
- }
- )
- }
- return authResp
+ // if coming from main domain, handle auth automatically
+ if (referer && referer === mainDomain) {
+ const authResp = customDomainAuthMiddleware(request, url)
+ if (authResp && authResp.status !== 200) {
+ // copy referrer cookies to auth redirect
+ console.log('referrerResp', referrerResp)
+ for (const cookie of referrerResp.cookies.getAll()) {
+ authResp.cookies.set(
+ cookie.name,
+ cookie.value,
+ {
+ maxAge: cookie.maxAge,
+ expires: cookie.expires,
+ path: cookie.path
+ }
+ )
}
+ return authResp
}
+ }
- // TODO: preserve referrer cookies in a DRY way
-
- const internalUrl = new URL(url)
+ const internalUrl = new URL(url)
- // rewrite to the territory path if we're at the root
+ // rewrite to the territory path if we're at the root
+ if (pathname === '/' || TERRITORY_PATHS.some(p => pathname.startsWith(p))) {
internalUrl.pathname = `/~${domainInfo.subName}${pathname === '/' ? '' : pathname}`
- console.log('Rewrite to:', internalUrl.pathname)
-
- // rewrite to the territory path
- return NextResponse.rewrite(internalUrl)
}
+ console.log('Rewrite to:', internalUrl.pathname)
- // redirect to main domain for non-territory paths
- // create redirect response but preserve referrer cookies
- const redirectResp = NextResponse.redirect(new URL(pathname, mainDomain))
-
+ // rewrite to the territory path
+ const redirectResp = NextResponse.rewrite(internalUrl)
+ // TODO: preserve referrer cookies in a DRY way
for (const cookie of referrerResp.cookies.getAll()) {
redirectResp.cookies.set(
cookie.name,
diff --git a/pages/~/index.js b/pages/~/index.js
index b2b5162b4..c4f949cc3 100644
--- a/pages/~/index.js
+++ b/pages/~/index.js
@@ -21,9 +21,13 @@ export default function Sub ({ ssrData }) {
if (!data && !ssrData) return
const { sub } = data || ssrData
+ const path = router.asPath.split('?')[0]
+ // TODO: this works but it can be better
+ const isCustomDomain = sub && !path.includes(`/~${sub?.name}`)
+
return (
- {sub
+ {sub && !isCustomDomain
?
: (
<>
From 04df5d5007e704049f6daeee16331ce405510795 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Mon, 10 Mar 2025 20:17:14 +0000
Subject: [PATCH 08/74] consider referrer cookies
---
middleware.js | 56 +++++++++++++++++++++------------------------------
1 file changed, 23 insertions(+), 33 deletions(-)
diff --git a/middleware.js b/middleware.js
index 24a43cbe1..0e896d12d 100644
--- a/middleware.js
+++ b/middleware.js
@@ -13,7 +13,7 @@ const SN_REFERRER_NONCE = 'sn_referrer_nonce'
const SN_REFEREE_LANDING = 'sn_referee_landing'
const TERRITORY_PATHS = ['/~', '/recent', '/random', '/top', '/post', '/edit']
-const NO_REWRITE_PATHS = ['/api', '/_next', '/_error', '/404', '/500', '/offline', '/static', '/items']
+const NO_REWRITE_PATHS = ['/api', '/_next', '/_error', '/404', '/500', '/offline', '/static']
// fetch custom domain mappings from our API, caching it for 5 minutes
const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings () {
@@ -67,54 +67,28 @@ export async function customDomainMiddleware (request, referrerResp) {
// remove the territory prefix from the path
const cleanPath = pathname.replace(`/~${domainInfo.subName}`, '') || '/'
console.log('Redirecting to clean path:', cleanPath)
- return NextResponse.redirect(new URL(cleanPath + url.search, url.origin))
+ const redirectResp = NextResponse.redirect(new URL(cleanPath + url.search, url.origin))
+ return applyReferrerCookies(redirectResp, referrerResp)
}
// if coming from main domain, handle auth automatically
if (referer && referer === mainDomain) {
const authResp = customDomainAuthMiddleware(request, url)
if (authResp && authResp.status !== 200) {
- // copy referrer cookies to auth redirect
- console.log('referrerResp', referrerResp)
- for (const cookie of referrerResp.cookies.getAll()) {
- authResp.cookies.set(
- cookie.name,
- cookie.value,
- {
- maxAge: cookie.maxAge,
- expires: cookie.expires,
- path: cookie.path
- }
- )
- }
- return authResp
+ return applyReferrerCookies(authResp, referrerResp)
}
}
const internalUrl = new URL(url)
-
// rewrite to the territory path if we're at the root
if (pathname === '/' || TERRITORY_PATHS.some(p => pathname.startsWith(p))) {
internalUrl.pathname = `/~${domainInfo.subName}${pathname === '/' ? '' : pathname}`
}
console.log('Rewrite to:', internalUrl.pathname)
-
// rewrite to the territory path
- const redirectResp = NextResponse.rewrite(internalUrl)
- // TODO: preserve referrer cookies in a DRY way
- for (const cookie of referrerResp.cookies.getAll()) {
- redirectResp.cookies.set(
- cookie.name,
- cookie.value,
- {
- maxAge: cookie.maxAge,
- expires: cookie.expires,
- path: cookie.path
- }
- )
- }
-
- return redirectResp
+ const resp = NextResponse.rewrite(internalUrl)
+ // copy referrer cookies to the rewritten response
+ return applyReferrerCookies(resp, referrerResp)
}
// TODO: dirty of previous iterations, refactor
@@ -176,6 +150,22 @@ function getContentReferrer (request, url) {
}
}
+function applyReferrerCookies (response, referrer) {
+ for (const cookie of referrer.cookies.getAll()) {
+ response.cookies.set(
+ cookie.name,
+ cookie.value,
+ {
+ maxAge: cookie.maxAge,
+ expires: cookie.expires,
+ path: cookie.path
+ }
+ )
+ }
+ console.log('response.cookies', response.cookies)
+ return response
+}
+
// we store the referrers in cookies for a future signup event
// we pass the referrers in the request headers so we can use them in referral rewards for logged in stackers
function referrerMiddleware (request) {
From f4f37c3a40cc66dce0190515364d6e78bbf979ad Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 11 Mar 2025 11:27:44 +0000
Subject: [PATCH 09/74] check validity of CNAME and TXT records via worker;
clean sub-select; middleware slight readability
---
components/sub-select.js | 4 -
middleware.js | 11 ++-
.../migration.sql | 10 +++
.../migration.sql | 17 ++++
prisma/schema.prisma | 2 +
worker/domainVerification.js | 83 +++++++++++++++++++
worker/index.js | 2 +
7 files changed, 119 insertions(+), 10 deletions(-)
create mode 100644 prisma/migrations/20250311105915_verify_dns_records/migration.sql
create mode 100644 prisma/migrations/20250311112522_domain_verification_job/migration.sql
create mode 100644 worker/domainVerification.js
diff --git a/components/sub-select.js b/components/sub-select.js
index b5c3e4f61..5f7bcdea5 100644
--- a/components/sub-select.js
+++ b/components/sub-select.js
@@ -52,8 +52,6 @@ export function useSubs ({ prependSubs = DEFAULT_PREPEND_SUBS, sub, filterSubs =
...appendSubs])
}, [data])
- // TODO: can pass custom domain
-
return subs
}
@@ -81,8 +79,6 @@ export default function SubSelect ({ prependSubs, sub, onChange, size, appendSub
return
}
- // TODO: redirect to the custom domain if it has one
-
let asPath
// are we currently in a sub (ie not home)
if (router.query.sub) {
diff --git a/middleware.js b/middleware.js
index 0e896d12d..180186e8a 100644
--- a/middleware.js
+++ b/middleware.js
@@ -94,13 +94,12 @@ export async function customDomainMiddleware (request, referrerResp) {
// TODO: dirty of previous iterations, refactor
// UNSAFE UNSAFE UNSAFE tokens are visible in the URL
export function customDomainAuthMiddleware (request, url) {
- const pathname = url.pathname
const host = request.headers.get('host')
- const authDomain = process.env.NEXT_PUBLIC_URL
- const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
- const secure = process.env.NODE_ENV === 'development'
+ const mainDomain = process.env.NEXT_PUBLIC_URL
+ const pathname = url.pathname
// check for session both in session token and in multi_auth cookie
+ const secure = process.env.NODE_ENV === 'development' // TODO: change this to production
const sessionCookieName = secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
const multiAuthUserId = request.cookies.get('multi_auth.user-id')?.value
@@ -112,14 +111,14 @@ export function customDomainAuthMiddleware (request, url) {
const hasSession = hasActiveSession || hasMultiAuthSession
const response = NextResponse.next()
- if (!hasSession && isCustomDomain) {
+ if (!hasSession) {
// TODO: original request url points to localhost, this is a workaround atm
const protocol = secure ? 'https' : 'http'
const originalDomain = `${protocol}://${host}`
const redirectTarget = `${originalDomain}${pathname}`
// Create the auth sync URL with the correct original domain
- const syncUrl = new URL(`${authDomain}/api/auth/sync`)
+ const syncUrl = new URL(`${mainDomain}/api/auth/sync`)
syncUrl.searchParams.set('redirectUrl', redirectTarget)
console.log('AUTH: Redirecting to:', syncUrl.toString())
diff --git a/prisma/migrations/20250311105915_verify_dns_records/migration.sql b/prisma/migrations/20250311105915_verify_dns_records/migration.sql
new file mode 100644
index 000000000..be2275a91
--- /dev/null
+++ b/prisma/migrations/20250311105915_verify_dns_records/migration.sql
@@ -0,0 +1,10 @@
+/*
+ Warnings:
+
+ - Added the required column `cname` to the `CustomDomain` table without a default value. This is not possible if the table is not empty.
+ - Added the required column `verificationTxt` to the `CustomDomain` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- AlterTable
+ALTER TABLE "CustomDomain" ADD COLUMN "cname" TEXT NOT NULL,
+ADD COLUMN "verificationTxt" TEXT NOT NULL;
diff --git a/prisma/migrations/20250311112522_domain_verification_job/migration.sql b/prisma/migrations/20250311112522_domain_verification_job/migration.sql
new file mode 100644
index 000000000..5f804de71
--- /dev/null
+++ b/prisma/migrations/20250311112522_domain_verification_job/migration.sql
@@ -0,0 +1,17 @@
+CREATE OR REPLACE FUNCTION schedule_domain_verification_job()
+RETURNS INTEGER
+LANGUAGE plpgsql
+AS $$
+DECLARE
+BEGIN
+ -- every 5 minutes
+ INSERT INTO pgboss.schedule (name, cron, timezone)
+ VALUES ('domainVerification', '*/5 * * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
+ return 0;
+EXCEPTION WHEN OTHERS THEN
+ return 0;
+END;
+$$;
+
+SELECT schedule_domain_verification_job();
+DROP FUNCTION IF EXISTS schedule_domain_verification_job;
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 18e02b830..6fdd62425 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1212,6 +1212,8 @@ model CustomDomain {
sslCertExpiry DateTime?
verificationState String?
lastVerifiedAt DateTime?
+ cname String
+ verificationTxt String
@@index([domain])
@@index([createdAt])
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
new file mode 100644
index 000000000..4abe79a01
--- /dev/null
+++ b/worker/domainVerification.js
@@ -0,0 +1,83 @@
+import createPrisma from '@/lib/create-prisma'
+import { promises as dnsPromises } from 'node:dns'
+
+// TODO: Add comments
+export async function domainVerification () {
+ const models = createPrisma({ connectionParams: { connection_limit: 1 } })
+
+ try {
+ const domains = await models.customDomain.findMany()
+
+ for (const domain of domains) {
+ const { domain: domainName, verificationTxt, cname, id } = domain
+ try {
+ const { txtValid, cnameValid, error } = await verifyDomain(domainName, verificationTxt, cname)
+ console.log(`${domainName}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
+
+ // verificationState is based on the results of the TXT and CNAME checks
+ const verificationState = (txtValid && cnameValid) ? 'VERIFIED' : 'FAILED'
+ await models.customDomain.update({
+ where: { id },
+ data: { verificationState, lastVerifiedAt: new Date() }
+ })
+
+ if (error) {
+ console.log(`${domainName} verification error:`, error)
+ }
+ } catch (error) {
+ console.error(`Failed to verify domain ${domainName}:`, error)
+
+ // Update to FAILED on any error
+ await models.customDomain.update({
+ where: { id },
+ data: { verificationState: 'NOT_VERIFIED', lastVerifiedAt: new Date() }
+ })
+ }
+ }
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+async function verifyDomain (domainName, verificationTxt, cname) {
+ const result = {
+ txtValid: false,
+ cnameValid: false,
+ error: null
+ }
+
+ // TXT Records checking
+ // TODO: we should give a randomly generated string to the user and check if it's included in the TXT record
+ try {
+ const txtRecords = await dnsPromises.resolve(domainName, 'TXT')
+ const txtText = txtRecords.flat().join(' ')
+
+ // the TXT record should include the verificationTxt that we have in the database
+ result.txtValid = txtText.includes(verificationTxt)
+ } catch (error) {
+ if (error.code === 'ENODATA' || error.code === 'ENOTFOUND') {
+ result.error = `TXT record not found: ${error.code}`
+ } else {
+ result.error = `TXT error: ${error.message}`
+ }
+ }
+
+ // CNAME Records checking
+ try {
+ const cnameRecords = await dnsPromises.resolve(domainName, 'CNAME')
+
+ // the CNAME record should include the cname that we have in the database
+ result.cnameValid = cnameRecords.some(record =>
+ record.includes(cname)
+ )
+ } catch (error) {
+ if (!result.error) { // this is to avoid overriding the error from the TXT check
+ if (error.code === 'ENODATA' || error.code === 'ENOTFOUND') {
+ result.error = `CNAME record not found: ${error.code}`
+ } else {
+ result.error = `CNAME error: ${error.message}`
+ }
+ }
+ }
+ return result
+}
diff --git a/worker/index.js b/worker/index.js
index dfb6c4f97..fb579cf2b 100644
--- a/worker/index.js
+++ b/worker/index.js
@@ -37,6 +37,7 @@ import { payWeeklyPostBounty, weeklyPost } from './weeklyPosts'
import { expireBoost } from './expireBoost'
import { payingActionConfirmed, payingActionFailed } from './payingAction'
import { autoDropBolt11s } from './autoDropBolt11'
+import { domainVerification } from './domainVerification'
// WebSocket polyfill
import ws from 'isomorphic-ws'
@@ -122,6 +123,7 @@ async function work () {
await boss.work('imgproxy', jobWrapper(imgproxy))
await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages))
}
+ await boss.work('domainVerification', jobWrapper(domainVerification))
await boss.work('expireBoost', jobWrapper(expireBoost))
await boss.work('weeklyPost-*', jobWrapper(weeklyPost))
await boss.work('payWeeklyPostBounty', jobWrapper(payWeeklyPostBounty))
From cf276458882c5442c039aa8068d57dda83e2deac Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 11 Mar 2025 12:12:03 +0000
Subject: [PATCH 10/74] DNS Resolver env setting, fixes inconsistencies with
results
---
.env.development | 5 ++++-
.env.production | 3 +++
worker/domainVerification.js | 2 ++
3 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/.env.development b/.env.development
index 419252140..c0a230b11 100644
--- a/.env.development
+++ b/.env.development
@@ -183,4 +183,7 @@ 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
+
+# DNS resolver for custom domain verification
+DNS_RESOLVER=1.1.1.1
\ No newline at end of file
diff --git a/.env.production b/.env.production
index 78e66ab81..e0a4a58d6 100644
--- a/.env.production
+++ b/.env.production
@@ -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
\ No newline at end of file
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 4abe79a01..69c3f64e7 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -46,6 +46,8 @@ async function verifyDomain (domainName, verificationTxt, cname) {
error: null
}
+ dnsPromises.setServers([process.env.DNS_RESOLVER || '1.1.1.1']) // cloudflare DNS resolver
+
// TXT Records checking
// TODO: we should give a randomly generated string to the user and check if it's included in the TXT record
try {
From 3ac04a60c9970231e79580d0296c4e2191271cb9 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 11 Mar 2025 12:59:54 +0000
Subject: [PATCH 11/74] custom domain form and validation; FAILED verification
only if it was VERIFIED
---
api/resolvers/sub.js | 23 ++++++--
components/territory-domains.js | 53 ++++++++++++-------
components/territory-form.js | 30 +++++------
lib/validate.js | 2 +-
middleware.js | 1 +
.../migration.sql | 20 +++++++
.../migration.sql | 10 ----
.../migration.sql | 17 ------
worker/domainVerification.js | 10 +++-
9 files changed, 98 insertions(+), 68 deletions(-)
delete mode 100644 prisma/migrations/20250311105915_verify_dns_records/migration.sql
delete mode 100644 prisma/migrations/20250311112522_domain_verification_job/migration.sql
diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js
index 25729e0ef..a8adb04d2 100644
--- a/api/resolvers/sub.js
+++ b/api/resolvers/sub.js
@@ -1,10 +1,11 @@
import { whenRange } from '@/lib/time'
-import { validateSchema, validateDomain, territorySchema } from '@/lib/validate'
+import { validateSchema, customDomainSchema, territorySchema } from '@/lib/validate'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { viewGroup } from './growth'
import { notifyTerritoryTransfer } from '@/lib/webPush'
import performPaidAction from '../paidAction'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
+import { randomBytes } from 'node:crypto'
export async function getSub (parent, { name }, { models, me }) {
if (!name) return null
@@ -292,16 +293,32 @@ export default {
throw new GqlInputError('you do not own this sub')
}
domain = domain.trim()
- if (domain && !validateDomain(domain)) {
+ if (domain && !validateSchema(customDomainSchema, { domain })) {
throw new GqlInputError('Invalid domain format')
}
+ console.log('domain', domain)
+ console.log('sub.customDomain?.domain', sub.customDomain?.domain)
+
if (domain) {
const existing = await models.customDomain.findUnique({ where: { subName } })
if (existing) {
+ if (domain === existing.domain) {
+ throw new GqlInputError('domain already set')
+ }
return await models.customDomain.update({ where: { subName }, data: { domain, verificationState: 'PENDING' } })
} else {
- return await models.customDomain.create({ data: { domain, subName, verificationState: 'PENDING' } })
+ return await models.customDomain.create({
+ data: {
+ domain,
+ verificationState: 'PENDING',
+ cname: 'parallel.soxa.dev',
+ verificationTxt: randomBytes(32).toString('base64'),
+ sub: {
+ connect: { name: subName }
+ }
+ }
+ })
}
} else {
return await models.customDomain.delete({ where: { subName } })
diff --git a/components/territory-domains.js b/components/territory-domains.js
index ab1aeb40e..4facecf02 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -3,17 +3,19 @@ import { Badge } from 'react-bootstrap'
import { Form, Input, SubmitButton } from './form'
import { gql, useMutation } from '@apollo/client'
import Info from './info'
+import { customDomainSchema } from '@/lib/validate'
+import ActionTooltip from './action-tooltip'
const UPDATE_CUSTOM_DOMAIN = gql`
mutation UpdateCustomDomain($subName: String!, $domain: String!) {
updateCustomDomain(subName: $subName, domain: $domain) {
domain
verificationState
- lastVerifiedAt
}
}
`
+// TODO: verification states should refresh
export default function CustomDomainForm ({ sub }) {
const [updateCustomDomain] = useMutation(UPDATE_CUSTOM_DOMAIN)
const [error, setError] = useState(null)
@@ -45,14 +47,25 @@ export default function CustomDomainForm ({ sub }) {
}
}
+ const getSSLStatusBadge = (sslEnabled) => {
+ switch (sslEnabled) {
+ case true:
+ return SSL enabled
+ case false:
+ return SSL disabled
+ }
+ }
+
return (
}
- body={
- <>
-
- {sub?.customDomain?.verificationState === 'VERIFIED' &&
- <>
- [NOT IMPLEMENTED] branding
- WIP
- [NOT IMPLEMENTED] color scheme
- WIP
- >}
- >
- }
- />
+ personalization}
+ body={
+ <>
+
+ {sub?.customDomain?.verificationState === 'VERIFIED' &&
+ <>
+ [NOT IMPLEMENTED] branding
+ WIP
+ [NOT IMPLEMENTED] color scheme
+ WIP
+ >}
+ >
+ }
+ />
)
}
diff --git a/lib/validate.js b/lib/validate.js
index a83eea763..83e8fce4c 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -349,7 +349,7 @@ export function territoryTransferSchema ({ me, ...args }) {
// TODO: validate domain
export function customDomainSchema (args) {
return object({
- customDomain: string().matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
+ domain: string().matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
message: 'enter a valid domain name (e.g., example.com)'
}).nullable()
})
diff --git a/middleware.js b/middleware.js
index 180186e8a..c66ec9b82 100644
--- a/middleware.js
+++ b/middleware.js
@@ -52,6 +52,7 @@ export async function customDomainMiddleware (request, referrerResp) {
console.log('domainMapping', domainMapping)
const domainInfo = domainMapping?.[host.toLowerCase()]
if (!domainInfo) {
+ console.log('Redirecting to main domain')
return NextResponse.redirect(new URL(pathname, mainDomain))
}
diff --git a/prisma/migrations/20250304121322_custom_domains/migration.sql b/prisma/migrations/20250304121322_custom_domains/migration.sql
index eb273acfe..a6aaffcb6 100644
--- a/prisma/migrations/20250304121322_custom_domains/migration.sql
+++ b/prisma/migrations/20250304121322_custom_domains/migration.sql
@@ -9,6 +9,8 @@ CREATE TABLE "CustomDomain" (
"sslCertExpiry" TIMESTAMP(3),
"verificationState" TEXT,
"lastVerifiedAt" TIMESTAMP(3),
+ "cname" TEXT NOT NULL,
+ "verificationTxt" TEXT NOT NULL,
CONSTRAINT "CustomDomain_pkey" PRIMARY KEY ("id")
);
@@ -27,3 +29,21 @@ CREATE INDEX "CustomDomain_created_at_idx" ON "CustomDomain"("created_at");
-- AddForeignKey
ALTER TABLE "CustomDomain" ADD CONSTRAINT "CustomDomain_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
+
+CREATE OR REPLACE FUNCTION schedule_domain_verification_job()
+RETURNS INTEGER
+LANGUAGE plpgsql
+AS $$
+DECLARE
+BEGIN
+ -- every 5 minutes
+ INSERT INTO pgboss.schedule (name, cron, timezone)
+ VALUES ('domainVerification', '*/5 * * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
+ return 0;
+EXCEPTION WHEN OTHERS THEN
+ return 0;
+END;
+$$;
+
+SELECT schedule_domain_verification_job();
+DROP FUNCTION IF EXISTS schedule_domain_verification_job;
diff --git a/prisma/migrations/20250311105915_verify_dns_records/migration.sql b/prisma/migrations/20250311105915_verify_dns_records/migration.sql
deleted file mode 100644
index be2275a91..000000000
--- a/prisma/migrations/20250311105915_verify_dns_records/migration.sql
+++ /dev/null
@@ -1,10 +0,0 @@
-/*
- Warnings:
-
- - Added the required column `cname` to the `CustomDomain` table without a default value. This is not possible if the table is not empty.
- - Added the required column `verificationTxt` to the `CustomDomain` table without a default value. This is not possible if the table is not empty.
-
-*/
--- AlterTable
-ALTER TABLE "CustomDomain" ADD COLUMN "cname" TEXT NOT NULL,
-ADD COLUMN "verificationTxt" TEXT NOT NULL;
diff --git a/prisma/migrations/20250311112522_domain_verification_job/migration.sql b/prisma/migrations/20250311112522_domain_verification_job/migration.sql
deleted file mode 100644
index 5f804de71..000000000
--- a/prisma/migrations/20250311112522_domain_verification_job/migration.sql
+++ /dev/null
@@ -1,17 +0,0 @@
-CREATE OR REPLACE FUNCTION schedule_domain_verification_job()
-RETURNS INTEGER
-LANGUAGE plpgsql
-AS $$
-DECLARE
-BEGIN
- -- every 5 minutes
- INSERT INTO pgboss.schedule (name, cron, timezone)
- VALUES ('domainVerification', '*/5 * * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
- return 0;
-EXCEPTION WHEN OTHERS THEN
- return 0;
-END;
-$$;
-
-SELECT schedule_domain_verification_job();
-DROP FUNCTION IF EXISTS schedule_domain_verification_job;
\ No newline at end of file
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 69c3f64e7..7043cd5be 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -15,7 +15,12 @@ export async function domainVerification () {
console.log(`${domainName}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
// verificationState is based on the results of the TXT and CNAME checks
- const verificationState = (txtValid && cnameValid) ? 'VERIFIED' : 'FAILED'
+ const verificationResult = txtValid && cnameValid
+ const verificationState = verificationResult // TODO: clean this up, working proof of concept
+ ? 'VERIFIED'
+ : domain.verificationState === 'PENDING' && !verificationResult
+ ? 'PENDING'
+ : 'FAILED'
await models.customDomain.update({
where: { id },
data: { verificationState, lastVerifiedAt: new Date() }
@@ -27,10 +32,11 @@ export async function domainVerification () {
} catch (error) {
console.error(`Failed to verify domain ${domainName}:`, error)
+ // TODO: DNS inconcistencies can happen, we should retry at least 3 times before marking it as FAILED
// Update to FAILED on any error
await models.customDomain.update({
where: { id },
- data: { verificationState: 'NOT_VERIFIED', lastVerifiedAt: new Date() }
+ data: { verificationState: 'FAILED', lastVerifiedAt: new Date() }
})
}
}
From 2a9297d42c60bae562ddbb71e4bd948b2320123d Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Thu, 13 Mar 2025 18:31:11 +0000
Subject: [PATCH 12/74] fix form submit; update Sub onSubmit
---
components/territory-domains.js | 22 ++++++++--------------
components/territory-form.js | 32 +++++++++++++++++---------------
2 files changed, 25 insertions(+), 29 deletions(-)
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 4facecf02..46c857cda 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -1,10 +1,10 @@
-import { useState } from 'react'
import { Badge } from 'react-bootstrap'
import { Form, Input, SubmitButton } from './form'
import { gql, useMutation } from '@apollo/client'
import Info from './info'
import { customDomainSchema } from '@/lib/validate'
import ActionTooltip from './action-tooltip'
+import { useToast } from '@/components/toast'
const UPDATE_CUSTOM_DOMAIN = gql`
mutation UpdateCustomDomain($subName: String!, $domain: String!) {
@@ -17,23 +17,19 @@ const UPDATE_CUSTOM_DOMAIN = gql`
// TODO: verification states should refresh
export default function CustomDomainForm ({ sub }) {
- const [updateCustomDomain] = useMutation(UPDATE_CUSTOM_DOMAIN)
- const [error, setError] = useState(null)
- const [success, setSuccess] = useState(false)
+ const [updateCustomDomain] = useMutation(UPDATE_CUSTOM_DOMAIN, {
+ refetchQueries: ['Sub']
+ })
+ const toaster = useToast()
const onSubmit = async ({ domain }) => {
- setError(null)
- setSuccess(false)
- console.log('domain', domain)
-
- const { data } = await updateCustomDomain({
+ await updateCustomDomain({
variables: {
subName: sub.name,
domain
}
})
- console.log('success', data)
- setSuccess(true)
+ toaster.success('domain updated successfully')
}
const getStatusBadge = (status) => {
@@ -70,9 +66,7 @@ export default function CustomDomainForm ({ sub }) {
- domain
- {error && error }
- {success && Domain settings updated successfully! }
+ custom domain
{sub?.customDomain && (
<>
diff --git a/components/territory-form.js b/components/territory-form.js
index b277e29d0..d4f5332b3 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -288,21 +288,23 @@ export default function TerritoryForm ({ sub }) {
/>
- personalization}
- body={
- <>
-
- {sub?.customDomain?.verificationState === 'VERIFIED' &&
- <>
- [NOT IMPLEMENTED] branding
- WIP
- [NOT IMPLEMENTED] color scheme
- WIP
- >}
- >
- }
- />
+ }
+ body={
+ <>
+
+ {sub?.customDomain?.verificationState === 'VERIFIED' &&
+ <>
+ [NOT IMPLEMENTED] branding
+ WIP
+ [NOT IMPLEMENTED] color scheme
+ WIP
+ >}
+ >
+ }
+ />
+
)
}
From f959c7f40e514146c398fd6119560236b4290091 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Thu, 13 Mar 2025 23:01:29 +0000
Subject: [PATCH 13/74] wip One-click single sign on
---
components/nav/common.js | 39 +++++++++++--
pages/api/auth/[...nextauth].js | 34 +++++++++++-
pages/api/auth/sync.js | 97 +++++++++------------------------
3 files changed, 91 insertions(+), 79 deletions(-)
diff --git a/components/nav/common.js b/components/nav/common.js
index 7f0fc32fd..1400b215a 100644
--- a/components/nav/common.js
+++ b/components/nav/common.js
@@ -12,7 +12,7 @@ import NoteIcon from '../../svgs/notification-4-fill.svg'
import { useMe } from '../me'
import { abbrNum } from '../../lib/format'
import { useServiceWorker } from '../serviceworker'
-import { signOut } from 'next-auth/react'
+import { signIn, signOut } from 'next-auth/react'
import Badges from '../badge'
import { randInRange } from '../../lib/rand'
import { useLightning } from '../lightning'
@@ -248,10 +248,37 @@ export function SignUpButton ({ className = 'py-0', width }) {
export default function LoginButton () {
const router = useRouter()
- const handleLogin = useCallback(async pathname => await router.push({
- pathname,
- query: { callbackUrl: window.location.origin + router.asPath }
- }), [router])
+
+ useEffect(() => {
+ if (router.query.type === 'sync') {
+ console.log('signing in with sync')
+ console.log('token', router.query.token)
+ console.log('callbackUrl', router.query.callbackUrl)
+ signIn('sync', { token: router.query.token, callbackUrl: router.query.callbackUrl, redirect: false })
+ }
+ }, [router.query.type, router.query.token, router.query.callbackUrl])
+
+ const handleLogin = useCallback(async () => {
+ // todo: custom domain check
+ const mainDomain = process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
+ const isCustomDomain = window.location.hostname !== mainDomain
+
+ if (isCustomDomain && router.query.type !== 'noAuth') {
+ // TODO: dirty of previous iterations, refactor
+ // redirect to sync endpoint on main domain
+ const protocol = window.location.protocol
+ const mainDomainUrl = `${protocol}//${mainDomain}`
+ const currentUrl = window.location.origin + router.asPath
+
+ window.location.href = `${mainDomainUrl}/api/auth/sync?redirectUrl=${encodeURIComponent(currentUrl)}`
+ } else {
+ // normal login on main domain
+ await router.push({
+ pathname: '/login',
+ query: { callbackUrl: window.location.origin + router.asPath }
+ })
+ }
+ }, [router])
return (
handleLogin('/login')}
+ onClick={handleLogin}
>
login
diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js
index 17ebd7f2f..b960323a4 100644
--- a/pages/api/auth/[...nextauth].js
+++ b/pages/api/auth/[...nextauth].js
@@ -274,6 +274,38 @@ const getProviders = res => [
return await pubkeyAuth(credentials, req, res, 'nostrAuthPubkey')
}
}),
+ CredentialsProvider({
+ id: 'sync',
+ name: 'Auth Sync',
+ credentials: {
+ token: { label: 'token', type: 'text' }
+ },
+ authorize: async ({ token }, req) => {
+ try {
+ const verificationToken = await prisma.verificationToken.findUnique({ where: { token } })
+ if (!verificationToken) return null
+
+ // has to be a sync token
+ if (!verificationToken.identifier.startsWith('sync:')) return null
+
+ // sync has user id
+ const userId = parseInt(verificationToken.identifier.split(':')[1], 10)
+ if (!userId) return null
+
+ // delete the token to prevent reuse
+ await prisma.verificationToken.delete({
+ where: { id: verificationToken.id }
+ })
+ if (new Date() > verificationToken.expires) return null
+
+ // return the user
+ return await prisma.user.findUnique({ where: { id: userId } })
+ } catch (error) {
+ console.error('auth sync error:', error)
+ return null
+ }
+ }
+ }),
GitHubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
@@ -431,7 +463,7 @@ export default async (req, res) => {
await NextAuth(req, res, getAuthOptions(req, res))
}
-function generateRandomString (length = 6, charset = BECH32_CHARSET) {
+export function generateRandomString (length = 6, charset = BECH32_CHARSET) {
const bytes = randomBytes(length)
let result = ''
diff --git a/pages/api/auth/sync.js b/pages/api/auth/sync.js
index b1826eb69..877ce7128 100644
--- a/pages/api/auth/sync.js
+++ b/pages/api/auth/sync.js
@@ -1,85 +1,38 @@
import { getServerSession } from 'next-auth/next'
-import { getAuthOptions } from './[...nextauth]'
-import { serialize } from 'cookie'
-import { datePivot } from '@/lib/time'
+import { getAuthOptions, generateRandomString } from './[...nextauth]'
+import prisma from '@/api/models'
-// TODO: dirty of previous iterations, refactor
-// UNSAFE UNSAFE UNSAFE tokens are visible in the URL
export default async function handler (req, res) {
- console.log(req.query)
- if (req.query.token) {
- const session = JSON.parse(decodeURIComponent(req.query.token))
- return saveCookie(req, res, session)
- } else {
- const { redirectUrl } = req.query
- const session = await getServerSession(req, res, getAuthOptions(req))
- // TODO: use session to create a verification token
- if (session) {
- console.log('session', session)
- console.log('req.cookies', req.cookies)
-
- const userId = session.user.id
- const multiAuthCookieName = `multi_auth.${userId}`
- const multiAuthToken = req.cookies[multiAuthCookieName]
-
- if (!multiAuthToken) {
- console.error('No multi_auth token found for user', userId)
- return res.status(400).json({ error: 'No multi_auth token found' })
- }
-
- const transferData = {
- session,
- multiAuthToken,
- userId
- }
-
- // redirect back to the custom domain with the token data
- const callbackUrl = new URL('/api/auth/sync', redirectUrl)
- callbackUrl.searchParams.set('token', encodeURIComponent(JSON.stringify(transferData)))
- callbackUrl.searchParams.set('redirectUrl', req.query.redirectUrl || '/')
-
- return res.redirect(callbackUrl.toString())
- }
- return res.redirect(redirectUrl)
+ const { redirectUrl } = req.query
+ if (!redirectUrl) {
+ return res.status(400).json({ error: 'Missing redirectUrl parameter' })
}
-}
-export async function saveCookie (req, res, tokenData) {
- if (!tokenData) {
- return res.status(400).json({ error: 'Missing token' })
+ const session = await getServerSession(req, res, getAuthOptions(req, res))
+
+ if (!session) {
+ // TODO: redirect to login page, this goes to login overlapping other paths
+ return res.redirect(redirectUrl + '/login?callbackUrl=' + encodeURIComponent(redirectUrl))
}
try {
- const secure = process.env.NODE_ENV === 'development'
- const expiresAt = datePivot(new Date(), { months: 1 })
- const cookieOptions = {
- path: '/',
- httpOnly: true,
- secure,
- sameSite: 'lax',
- expires: expiresAt
- }
- // extract the data from the token
- const { multiAuthToken, userId } = tokenData
- console.log('Received session and multi_auth token for user', userId)
-
- // set the session cookie
- const sessionCookieName = secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
- // create cookies
- const sessionCookie = serialize(sessionCookieName, multiAuthToken, cookieOptions)
- // also set the multi_auth cookie on the custom domain
- const multiAuthCookie = serialize(`multi_auth.${userId}`, multiAuthToken, cookieOptions)
- // set the cookie pointer
- const pointerCookie = serialize('multi_auth.user-id', userId, cookieOptions)
+ const token = generateRandomString()
+ // create a sync token
+ await prisma.verificationToken.create({
+ data: {
+ identifier: `sync:${session.user.id}`,
+ token,
+ expires: new Date(Date.now() + 1 * 60 * 1000) // 1 minute
+ }
+ })
- // set the cookies in the response
- res.setHeader('Set-Cookie', [sessionCookie, multiAuthCookie, pointerCookie])
+ const customDomainCallback = new URL('/?type=sync', redirectUrl)
+ customDomainCallback.searchParams.set('token', token)
+ customDomainCallback.searchParams.set('callbackUrl', redirectUrl)
- // redirect to the home page or a specified return URL
- const returnTo = req.query.redirectUrl || '/'
- return res.redirect(returnTo)
+ return res.redirect(customDomainCallback.toString())
} catch (error) {
- console.error('Error processing auth callback:', error)
- return res.status(500).json({ error: 'Failed to process authentication' })
+ console.error('Error generating token:', error)
+ return res.status(500).json({ error: 'Failed to generate token' })
}
}
From 9552bf896ca5acd681419bfe26adc45c659014f8 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Fri, 14 Mar 2025 21:36:42 +0000
Subject: [PATCH 14/74] issue and check SSL with worker; refactor customDomain;
early ACM implementation
---
api/acm/index.js | 26 ++++++
api/resolvers/sub.js | 17 ++--
api/typeDefs/sub.js | 10 ++-
components/nav/common.js | 5 +-
components/territory-domains.js | 45 ++++++----
components/territory-form.js | 2 +-
fragments/subs.js | 8 +-
lib/domains.js | 82 +++++++++++++++++
middleware.js | 5 +-
pages/api/domains/index.js | 3 +-
.../migration.sql | 6 +-
prisma/schema.prisma | 8 +-
worker/domainVerification.js | 89 ++++++-------------
13 files changed, 195 insertions(+), 111 deletions(-)
create mode 100644 api/acm/index.js
create mode 100644 lib/domains.js
diff --git a/api/acm/index.js b/api/acm/index.js
new file mode 100644
index 000000000..910da152a
--- /dev/null
+++ b/api/acm/index.js
@@ -0,0 +1,26 @@
+import { ACM } from 'aws-sdk'
+// TODO: skeleton
+
+const region = 'us-east-1' // cloudfront ACM is in us-east-1
+const acm = new ACM({ region })
+
+export async function requestCertificate (domain) {
+ const params = {
+ DomainName: domain,
+ ValidationMethod: 'DNS',
+ Tags: [
+ {
+ Key: 'ManagedBy',
+ Value: 'stackernews'
+ }
+ ]
+ }
+
+ const certificate = await acm.requestCertificate(params).promise()
+ return certificate.CertificateArn
+}
+
+export async function getCertificateStatus (certificateArn) {
+ const certificate = await acm.describeCertificate({ CertificateArn: certificateArn }).promise()
+ return certificate.Certificate.Status
+}
diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js
index a8adb04d2..f526398be 100644
--- a/api/resolvers/sub.js
+++ b/api/resolvers/sub.js
@@ -279,7 +279,7 @@ export default {
return await performPaidAction('TERRITORY_UNARCHIVE', data, { me, models, lnd })
},
- updateCustomDomain: async (parent, { subName, domain }, { me, models }) => {
+ setCustomDomain: async (parent, { subName, domain }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
@@ -292,28 +292,27 @@ export default {
if (sub.userId !== me.id) {
throw new GqlInputError('you do not own this sub')
}
- domain = domain.trim()
+ domain = domain.trim() // protect against trailing spaces
if (domain && !validateSchema(customDomainSchema, { domain })) {
throw new GqlInputError('Invalid domain format')
}
- console.log('domain', domain)
- console.log('sub.customDomain?.domain', sub.customDomain?.domain)
-
if (domain) {
const existing = await models.customDomain.findUnique({ where: { subName } })
if (existing) {
if (domain === existing.domain) {
throw new GqlInputError('domain already set')
}
- return await models.customDomain.update({ where: { subName }, data: { domain, verificationState: 'PENDING' } })
+ return await models.customDomain.update({
+ where: { subName },
+ data: { domain, dnsState: 'PENDING', sslState: 'PENDING' }
+ })
} else {
return await models.customDomain.create({
data: {
domain,
- verificationState: 'PENDING',
- cname: 'parallel.soxa.dev',
- verificationTxt: randomBytes(32).toString('base64'),
+ cname: 'todo', // TODO: explore other options
+ verificationTxt: randomBytes(32).toString('base64'), // TODO: explore other options
sub: {
connect: { name: subName }
}
diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js
index 3c8e0e415..77f3768ce 100644
--- a/api/typeDefs/sub.js
+++ b/api/typeDefs/sub.js
@@ -14,10 +14,12 @@ export default gql`
updatedAt: Date!
domain: String!
subName: String!
- sslEnabled: Boolean!
- sslCertExpiry: Date
- verificationState: String!
+ dnsState: String!
+ sslState: String!
+ certificateArn: String
lastVerifiedAt: Date
+ cname: String!
+ verificationTxt: String!
}
type Subs {
@@ -39,7 +41,7 @@ export default gql`
replyCost: Int!, postTypes: [String!]!,
billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
- updateCustomDomain(subName: String!, domain: String!): CustomDomain
+ setCustomDomain(subName: String!, domain: String!): CustomDomain
}
type Sub {
diff --git a/components/nav/common.js b/components/nav/common.js
index 1400b215a..2b0dd5651 100644
--- a/components/nav/common.js
+++ b/components/nav/common.js
@@ -249,11 +249,10 @@ export function SignUpButton ({ className = 'py-0', width }) {
export default function LoginButton () {
const router = useRouter()
+ // TODO: atp let main domain handle the login UX/UI
+ // decree a better position/way for this
useEffect(() => {
if (router.query.type === 'sync') {
- console.log('signing in with sync')
- console.log('token', router.query.token)
- console.log('callbackUrl', router.query.callbackUrl)
signIn('sync', { token: router.query.token, callbackUrl: router.query.callbackUrl, redirect: false })
}
}, [router.query.type, router.query.token, router.query.callbackUrl])
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 46c857cda..e7ce94da3 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -6,24 +6,25 @@ import { customDomainSchema } from '@/lib/validate'
import ActionTooltip from './action-tooltip'
import { useToast } from '@/components/toast'
-const UPDATE_CUSTOM_DOMAIN = gql`
- mutation UpdateCustomDomain($subName: String!, $domain: String!) {
- updateCustomDomain(subName: $subName, domain: $domain) {
+const SET_CUSTOM_DOMAIN = gql`
+ mutation SetCustomDomain($subName: String!, $domain: String!) {
+ setCustomDomain(subName: $subName, domain: $domain) {
domain
- verificationState
+ dnsState
+ sslState
}
}
`
// TODO: verification states should refresh
export default function CustomDomainForm ({ sub }) {
- const [updateCustomDomain] = useMutation(UPDATE_CUSTOM_DOMAIN, {
+ const [setCustomDomain] = useMutation(SET_CUSTOM_DOMAIN, {
refetchQueries: ['Sub']
})
const toaster = useToast()
const onSubmit = async ({ domain }) => {
- await updateCustomDomain({
+ await setCustomDomain({
variables: {
subName: sub.name,
domain
@@ -35,20 +36,22 @@ export default function CustomDomainForm ({ sub }) {
const getStatusBadge = (status) => {
switch (status) {
case 'VERIFIED':
- return verified
+ return DNS verified
case 'PENDING':
- return pending
+ return DNS pending
case 'FAILED':
- return failed
+ return DNS failed
}
}
- const getSSLStatusBadge = (sslEnabled) => {
- switch (sslEnabled) {
- case true:
- return SSL enabled
- case false:
- return SSL disabled
+ const getSSLStatusBadge = (sslState) => {
+ switch (sslState) {
+ case 'VERIFIED':
+ return SSL verified
+ case 'PENDING':
+ return SSL pending
+ case 'FAILED':
+ return SSL failed
}
}
@@ -71,10 +74,10 @@ export default function CustomDomainForm ({ sub }) {
<>
- {getStatusBadge(sub.customDomain.verificationState)}
+ {getStatusBadge(sub.customDomain.dnsState)}
- {getSSLStatusBadge(sub.customDomain.sslEnabled)}
- {sub.customDomain.verificationState === 'PENDING' && (
+ {getSSLStatusBadge(sub.customDomain.sslState)}
+ {sub.customDomain.dnsState === 'PENDING' && (
Verify your domain
Add the following DNS records to verify ownership of your domain:
@@ -85,6 +88,12 @@ export default function CustomDomainForm ({ sub }) {
)}
+ {sub.customDomain.sslState === 'PENDING' && (
+
+ SSL certificate pending
+ Our system will issue an SSL certificate for your domain.
+
+ )}
>
)}
diff --git a/components/territory-form.js b/components/territory-form.js
index d4f5332b3..93927f450 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -294,7 +294,7 @@ export default function TerritoryForm ({ sub }) {
body={
<>
- {sub?.customDomain?.verificationState === 'VERIFIED' &&
+ {sub?.customDomain?.dnsState === 'VERIFIED' && sub?.customDomain?.sslState === 'VERIFIED' &&
<>
[NOT IMPLEMENTED] branding
WIP
diff --git a/fragments/subs.js b/fragments/subs.js
index b8a80f602..ff6be09d6 100644
--- a/fragments/subs.js
+++ b/fragments/subs.js
@@ -40,10 +40,12 @@ export const SUB_FIELDS = gql`
updatedAt
domain
subName
- sslEnabled
- sslCertExpiry
- verificationState
+ dnsState
+ sslState
+ certificateArn
lastVerifiedAt
+ cname
+ verificationTxt
}
}`
diff --git a/lib/domains.js b/lib/domains.js
new file mode 100644
index 000000000..62785809d
--- /dev/null
+++ b/lib/domains.js
@@ -0,0 +1,82 @@
+import { requestCertificate, getCertificateStatus } from '@/api/acm'
+import { promises as dnsPromises } from 'node:dns'
+
+// TODO: skeleton
+export async function issueDomainCertificate (domainName) {
+ try {
+ const certificateArn = await requestCertificate(domainName)
+ return certificateArn
+ } catch (error) {
+ console.error(`Failed to issue certificate for domain ${domainName}:`, error)
+ return null
+ }
+}
+
+// TODO: skeleton
+export async function checkCertificateStatus (certificateArn) {
+ let certStatus
+ try {
+ certStatus = await getCertificateStatus(certificateArn)
+ } catch (error) {
+ console.error(`Certificate status check failed: ${error.message}`)
+ return 'FAILED'
+ }
+
+ // map ACM statuses
+ switch (certStatus) {
+ case 'ISSUED':
+ return 'ISSUED'
+ case 'PENDING_VALIDATION':
+ return 'PENDING'
+ case 'VALIDATION_TIMED_OUT':
+ case 'FAILED':
+ return 'FAILED'
+ default:
+ return 'PENDING'
+ }
+}
+
+export async function verifyDomainDNS (domainName, verificationTxt, cname) {
+ const result = {
+ txtValid: false,
+ cnameValid: false,
+ error: null
+ }
+
+ dnsPromises.setServers([process.env.DNS_RESOLVER || '1.1.1.1']) // cloudflare DNS resolver
+
+ // TXT Records checking
+ // TODO: we should give a randomly generated string to the user and check if it's included in the TXT record
+ try {
+ const txtRecords = await dnsPromises.resolve(domainName, 'TXT')
+ const txtText = txtRecords.flat().join(' ')
+
+ // the TXT record should include the verificationTxt that we have in the database
+ result.txtValid = txtText.includes(verificationTxt)
+ } catch (error) {
+ if (error.code === 'ENODATA' || error.code === 'ENOTFOUND') {
+ result.error = `TXT record not found: ${error.code}`
+ } else {
+ result.error = `TXT error: ${error.message}`
+ }
+ }
+
+ // CNAME Records checking
+ try {
+ const cnameRecords = await dnsPromises.resolve(domainName, 'CNAME')
+
+ // the CNAME record should include the cname that we have in the database
+ result.cnameValid = cnameRecords.some(record =>
+ record.includes(cname)
+ )
+ } catch (error) {
+ if (!result.error) { // this is to avoid overriding the error from the TXT check
+ if (error.code === 'ENODATA' || error.code === 'ENOTFOUND') {
+ result.error = `CNAME record not found: ${error.code}`
+ } else {
+ result.error = `CNAME error: ${error.message}`
+ }
+ }
+ }
+ return result
+}
diff --git a/middleware.js b/middleware.js
index c66ec9b82..ae3b3207d 100644
--- a/middleware.js
+++ b/middleware.js
@@ -268,13 +268,12 @@ export function applySecurityHeaders (resp) {
}
export async function middleware (request) {
- const host = request.headers.get('host')
- const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
-
// First run referrer middleware to capture referrer data
const referrerResp = referrerMiddleware(request)
// If we're on a custom domain, handle that next
+ const host = request.headers.get('host')
+ const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
if (isCustomDomain) {
const customDomainResp = await customDomainMiddleware(request, referrerResp)
return applySecurityHeaders(customDomainResp)
diff --git a/pages/api/domains/index.js b/pages/api/domains/index.js
index 8799faa6d..26f2872bb 100644
--- a/pages/api/domains/index.js
+++ b/pages/api/domains/index.js
@@ -19,7 +19,8 @@ export default async function handler (req, res) {
subName: true
},
where: {
- verificationState: 'VERIFIED'
+ dnsState: 'VERIFIED',
+ sslState: 'VERIFIED'
}
})
diff --git a/prisma/migrations/20250304121322_custom_domains/migration.sql b/prisma/migrations/20250304121322_custom_domains/migration.sql
index a6aaffcb6..7bc68e550 100644
--- a/prisma/migrations/20250304121322_custom_domains/migration.sql
+++ b/prisma/migrations/20250304121322_custom_domains/migration.sql
@@ -5,9 +5,9 @@ CREATE TABLE "CustomDomain" (
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"domain" TEXT NOT NULL,
"subName" CITEXT NOT NULL,
- "sslEnabled" BOOLEAN NOT NULL DEFAULT false,
- "sslCertExpiry" TIMESTAMP(3),
- "verificationState" TEXT,
+ "dnsState" TEXT NOT NULL DEFAULT 'PENDING',
+ "sslState" TEXT NOT NULL DEFAULT 'PENDING',
+ "certificateArn" TEXT,
"lastVerifiedAt" TIMESTAMP(3),
"cname" TEXT NOT NULL,
"verificationTxt" TEXT NOT NULL,
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 6fdd62425..febeb878d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1207,13 +1207,13 @@ model CustomDomain {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
domain String @unique
subName String @unique @db.Citext
- sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
- sslEnabled Boolean @default(false)
- sslCertExpiry DateTime?
- verificationState String?
+ dnsState String @default("PENDING")
+ sslState String @default("PENDING")
+ certificateArn String
lastVerifiedAt DateTime?
cname String
verificationTxt String
+ sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
@@index([domain])
@@index([createdAt])
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 7043cd5be..1e4b7098b 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -1,5 +1,5 @@
import createPrisma from '@/lib/create-prisma'
-import { promises as dnsPromises } from 'node:dns'
+import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus } from '@/lib/domains'
// TODO: Add comments
export async function domainVerification () {
@@ -11,24 +11,34 @@ export async function domainVerification () {
for (const domain of domains) {
const { domain: domainName, verificationTxt, cname, id } = domain
try {
- const { txtValid, cnameValid, error } = await verifyDomain(domainName, verificationTxt, cname)
- console.log(`${domainName}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
+ const data = { lastVerifiedAt: new Date() }
+ // DNS verification
+ if (domain.dnsState === 'PENDING' || domain.dnsState === 'FAILED') {
+ const { txtValid, cnameValid } = await verifyDomainDNS(domainName, verificationTxt, cname)
+ console.log(`${domainName}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
+ data.dnsState = txtValid && cnameValid ? 'VERIFIED' : 'FAILED'
+ }
- // verificationState is based on the results of the TXT and CNAME checks
- const verificationResult = txtValid && cnameValid
- const verificationState = verificationResult // TODO: clean this up, working proof of concept
- ? 'VERIFIED'
- : domain.verificationState === 'PENDING' && !verificationResult
- ? 'PENDING'
- : 'FAILED'
- await models.customDomain.update({
- where: { id },
- data: { verificationState, lastVerifiedAt: new Date() }
- })
+ // SSL issuing
+ if (domain.dnsState === 'VERIFIED' && (domain.sslState === 'NOT_ISSUED' || domain.sslState === 'FAILED')) {
+ const certificateArn = await issueDomainCertificate(domainName)
+ console.log(`${domainName}: Certificate issued: ${certificateArn}`)
+ if (certificateArn) {
+ const sslState = await checkCertificateStatus(certificateArn)
+ console.log(`${domainName}: Issued certificate status: ${sslState}`)
+ if (sslState) data.sslState = sslState
+ data.certificateArn = certificateArn
+ }
+ }
- if (error) {
- console.log(`${domainName} verification error:`, error)
+ // SSL checking
+ if (domain.dnsState === 'VERIFIED' && domain.sslState === 'PENDING') {
+ const sslState = await checkCertificateStatus(domain.certificateArn)
+ console.log(`${domainName}: Certificate status: ${sslState}`)
+ if (sslState) data.sslState = sslState
}
+
+ await models.customDomain.update({ where: { id }, data })
} catch (error) {
console.error(`Failed to verify domain ${domainName}:`, error)
@@ -36,7 +46,7 @@ export async function domainVerification () {
// Update to FAILED on any error
await models.customDomain.update({
where: { id },
- data: { verificationState: 'FAILED', lastVerifiedAt: new Date() }
+ data: { dnsState: 'FAILED', lastVerifiedAt: new Date() }
})
}
}
@@ -44,48 +54,3 @@ export async function domainVerification () {
console.error(error)
}
}
-
-async function verifyDomain (domainName, verificationTxt, cname) {
- const result = {
- txtValid: false,
- cnameValid: false,
- error: null
- }
-
- dnsPromises.setServers([process.env.DNS_RESOLVER || '1.1.1.1']) // cloudflare DNS resolver
-
- // TXT Records checking
- // TODO: we should give a randomly generated string to the user and check if it's included in the TXT record
- try {
- const txtRecords = await dnsPromises.resolve(domainName, 'TXT')
- const txtText = txtRecords.flat().join(' ')
-
- // the TXT record should include the verificationTxt that we have in the database
- result.txtValid = txtText.includes(verificationTxt)
- } catch (error) {
- if (error.code === 'ENODATA' || error.code === 'ENOTFOUND') {
- result.error = `TXT record not found: ${error.code}`
- } else {
- result.error = `TXT error: ${error.message}`
- }
- }
-
- // CNAME Records checking
- try {
- const cnameRecords = await dnsPromises.resolve(domainName, 'CNAME')
-
- // the CNAME record should include the cname that we have in the database
- result.cnameValid = cnameRecords.some(record =>
- record.includes(cname)
- )
- } catch (error) {
- if (!result.error) { // this is to avoid overriding the error from the TXT check
- if (error.code === 'ENODATA' || error.code === 'ENOTFOUND') {
- result.error = `CNAME record not found: ${error.code}`
- } else {
- result.error = `CNAME error: ${error.message}`
- }
- }
- }
- return result
-}
From 1d04948f7aa98ebf66c4204173000838d36b847b Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Sat, 15 Mar 2025 08:36:38 +0000
Subject: [PATCH 15/74] adjust schema and worker validation
---
api/resolvers/sub.js | 1 +
api/typeDefs/sub.js | 8 ++++----
.../20250304121322_custom_domains/migration.sql | 8 ++++----
prisma/schema.prisma | 10 +++++-----
worker/domainVerification.js | 13 ++++++++-----
5 files changed, 22 insertions(+), 18 deletions(-)
diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js
index f526398be..18fe400d2 100644
--- a/api/resolvers/sub.js
+++ b/api/resolvers/sub.js
@@ -311,6 +311,7 @@ export default {
return await models.customDomain.create({
data: {
domain,
+ dnsState: 'PENDING',
cname: 'todo', // TODO: explore other options
verificationTxt: randomBytes(32).toString('base64'), // TODO: explore other options
sub: {
diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js
index 77f3768ce..cb6791292 100644
--- a/api/typeDefs/sub.js
+++ b/api/typeDefs/sub.js
@@ -14,12 +14,12 @@ export default gql`
updatedAt: Date!
domain: String!
subName: String!
- dnsState: String!
- sslState: String!
+ dnsState: String
+ sslState: String
certificateArn: String
lastVerifiedAt: Date
- cname: String!
- verificationTxt: String!
+ cname: String
+ verificationTxt: String
}
type Subs {
diff --git a/prisma/migrations/20250304121322_custom_domains/migration.sql b/prisma/migrations/20250304121322_custom_domains/migration.sql
index 7bc68e550..de1d877ba 100644
--- a/prisma/migrations/20250304121322_custom_domains/migration.sql
+++ b/prisma/migrations/20250304121322_custom_domains/migration.sql
@@ -5,12 +5,12 @@ CREATE TABLE "CustomDomain" (
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"domain" TEXT NOT NULL,
"subName" CITEXT NOT NULL,
- "dnsState" TEXT NOT NULL DEFAULT 'PENDING',
- "sslState" TEXT NOT NULL DEFAULT 'PENDING',
+ "dnsState" TEXT,
+ "sslState" TEXT,
"certificateArn" TEXT,
"lastVerifiedAt" TIMESTAMP(3),
- "cname" TEXT NOT NULL,
- "verificationTxt" TEXT NOT NULL,
+ "cname" TEXT,
+ "verificationTxt" TEXT,
CONSTRAINT "CustomDomain_pkey" PRIMARY KEY ("id")
);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index febeb878d..4f58de41d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1207,12 +1207,12 @@ model CustomDomain {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
domain String @unique
subName String @unique @db.Citext
- dnsState String @default("PENDING")
- sslState String @default("PENDING")
- certificateArn String
+ dnsState String?
+ sslState String?
+ certificateArn String?
lastVerifiedAt DateTime?
- cname String
- verificationTxt String
+ cname String?
+ verificationTxt String?
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
@@index([domain])
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 1e4b7098b..7c13ef422 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -9,18 +9,18 @@ export async function domainVerification () {
const domains = await models.customDomain.findMany()
for (const domain of domains) {
- const { domain: domainName, verificationTxt, cname, id } = domain
+ const { domain: domainName, dnsState, sslState, certificateArn, verificationTxt, cname, id } = domain
try {
const data = { lastVerifiedAt: new Date() }
// DNS verification
- if (domain.dnsState === 'PENDING' || domain.dnsState === 'FAILED') {
+ if (dnsState === 'PENDING' || dnsState === 'FAILED') {
const { txtValid, cnameValid } = await verifyDomainDNS(domainName, verificationTxt, cname)
console.log(`${domainName}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
data.dnsState = txtValid && cnameValid ? 'VERIFIED' : 'FAILED'
}
// SSL issuing
- if (domain.dnsState === 'VERIFIED' && (domain.sslState === 'NOT_ISSUED' || domain.sslState === 'FAILED')) {
+ if (dnsState === 'VERIFIED' && (!certificateArn || sslState === 'FAILED')) {
const certificateArn = await issueDomainCertificate(domainName)
console.log(`${domainName}: Certificate issued: ${certificateArn}`)
if (certificateArn) {
@@ -28,18 +28,21 @@ export async function domainVerification () {
console.log(`${domainName}: Issued certificate status: ${sslState}`)
if (sslState) data.sslState = sslState
data.certificateArn = certificateArn
+ } else {
+ data.sslState = 'FAILED'
}
}
// SSL checking
- if (domain.dnsState === 'VERIFIED' && domain.sslState === 'PENDING') {
- const sslState = await checkCertificateStatus(domain.certificateArn)
+ if (dnsState === 'VERIFIED' && sslState === 'PENDING') {
+ const sslState = await checkCertificateStatus(certificateArn)
console.log(`${domainName}: Certificate status: ${sslState}`)
if (sslState) data.sslState = sslState
}
await models.customDomain.update({ where: { id }, data })
} catch (error) {
+ // TODO: this considers only DNS verification errors, we should also consider SSL verification errors
console.error(`Failed to verify domain ${domainName}:`, error)
// TODO: DNS inconcistencies can happen, we should retry at least 3 times before marking it as FAILED
From 25bafc06c583a95459cda0c467444fc50fde4eea Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Mon, 17 Mar 2025 22:57:46 +0000
Subject: [PATCH 16/74] switch to AWS localstack; mock ACM integration; add
certificate DNS values; show DNS configs on territory edit
---
.env.development | 1 +
api/acm/index.js | 33 ++++++++++--
api/resolvers/sub.js | 1 -
api/typeDefs/sub.js | 3 +-
components/territory-domains.js | 50 ++++++++++++-------
docker-compose.yml | 14 +++---
docker/{ => aws}/s3/cors.json | 0
docker/{ => aws}/s3/init-s3.sh | 0
fragments/subs.js | 3 +-
lib/domains.js | 25 ++++++++--
middleware.js | 1 +
pages/api/domains/index.js | 2 +-
.../migration.sql | 3 +-
prisma/schema.prisma | 25 +++++-----
worker/domainVerification.js | 19 +++++--
15 files changed, 125 insertions(+), 55 deletions(-)
rename docker/{ => aws}/s3/cors.json (100%)
rename docker/{ => aws}/s3/init-s3.sh (100%)
diff --git a/.env.development b/.env.development
index c0a230b11..a4c1d03b5 100644
--- a/.env.development
+++ b/.env.development
@@ -170,6 +170,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
# tor proxy
TOR_PROXY=http://tor:7050/
diff --git a/api/acm/index.js b/api/acm/index.js
index 910da152a..d7b341256 100644
--- a/api/acm/index.js
+++ b/api/acm/index.js
@@ -1,10 +1,22 @@
-import { ACM } from 'aws-sdk'
-// TODO: skeleton
+import AWS from 'aws-sdk'
+// TODO: boilerplate
-const region = 'us-east-1' // cloudfront ACM is in us-east-1
-const acm = new ACM({ region })
+AWS.config.update({
+ region: 'us-east-1'
+})
+
+const config = {
+ s3ForcePathStyle: process.env.NODE_ENV === 'development'
+}
export async function requestCertificate (domain) {
+ // for local development, we use the LOCALSTACK_ENDPOINT which
+ // is reachable from the host machine
+ if (process.env.NODE_ENV === 'development') {
+ config.endpoint = process.env.LOCALSTACK_ENDPOINT
+ }
+
+ const acm = new AWS.ACM(config)
const params = {
DomainName: domain,
ValidationMethod: 'DNS',
@@ -20,7 +32,18 @@ export async function requestCertificate (domain) {
return certificate.CertificateArn
}
-export async function getCertificateStatus (certificateArn) {
+export async function describeCertificate (certificateArn) {
+ // for local development, we use the LOCALSTACK_ENDPOINT which
+ // is reachable from the host machine
+ 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
}
diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js
index 18fe400d2..f95dc03f4 100644
--- a/api/resolvers/sub.js
+++ b/api/resolvers/sub.js
@@ -312,7 +312,6 @@ export default {
data: {
domain,
dnsState: 'PENDING',
- cname: 'todo', // TODO: explore other options
verificationTxt: randomBytes(32).toString('base64'), // TODO: explore other options
sub: {
connect: { name: subName }
diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js
index cb6791292..f8628d3f1 100644
--- a/api/typeDefs/sub.js
+++ b/api/typeDefs/sub.js
@@ -18,7 +18,8 @@ export default gql`
sslState: String
certificateArn: String
lastVerifiedAt: Date
- cname: String
+ verificationCname: String
+ verificationCnameValue: String
verificationTxt: String
}
diff --git a/components/territory-domains.js b/components/territory-domains.js
index e7ce94da3..7373638d5 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -1,7 +1,6 @@
import { Badge } from 'react-bootstrap'
import { Form, Input, SubmitButton } from './form'
import { gql, useMutation } from '@apollo/client'
-import Info from './info'
import { customDomainSchema } from '@/lib/validate'
import ActionTooltip from './action-tooltip'
import { useToast } from '@/components/toast'
@@ -77,23 +76,6 @@ export default function CustomDomainForm ({ sub }) {
{getStatusBadge(sub.customDomain.dnsState)}
{getSSLStatusBadge(sub.customDomain.sslState)}
- {sub.customDomain.dnsState === 'PENDING' && (
-
- Verify your domain
- Add the following DNS records to verify ownership of your domain:
-
- CNAME record:
- Host: @
- Value: stacker.news
-
-
- )}
- {sub.customDomain.sslState === 'PENDING' && (
-
- SSL certificate pending
- Our system will issue an SSL certificate for your domain.
-
- )}
>
)}
@@ -105,6 +87,38 @@ export default function CustomDomainForm ({ sub }) {
{/* TODO: toaster */}
save
+ {(sub.customDomain.dnsState === 'PENDING' || sub.customDomain.dnsState === 'FAILED') && (
+ <>
+ Verify your domain
+ Add the following DNS records to verify ownership of your domain:
+
+ CNAME:
+ Host: @
+ Value: stacker.news
+
+
+ TXT:
+ Host: @
+ Value: ${sub.customDomain.verificationTxt}
+
+ >
+ )}
+ {sub.customDomain.sslState === 'PENDING' && (
+ <>
+ SSL verification pending
+ We issued an SSL certificate for your domain.
+
+ CNAME:
+ Host: ${sub.customDomain.verificationCname}
+ Value: ${sub.customDomain.verificationCnameValue}
+
+
+ TXT:
+ Host: @
+ Value: ${sub.customDomain.verificationTxt}
+
+ >
+ )}
)
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 31825444e..3fdd8ad7a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -136,9 +136,9 @@ services:
labels:
- "CONNECT=localhost:3001"
cpu_shares: "${CPU_SHARES_LOW}"
- s3:
- container_name: s3
- image: localstack/localstack:s3-latest
+ aws:
+ container_name: aws
+ image: localstack/localstack:latest
# healthcheck:
# test: ["CMD-SHELL", "awslocal", "s3", "ls", "s3://uploads"]
# interval: 10s
@@ -156,9 +156,9 @@ services:
expose:
- "4566"
volumes:
- - 's3:/var/lib/localstack'
- - './docker/s3/init-s3.sh:/etc/localstack/init/ready.d/init-s3.sh'
- - './docker/s3/cors.json:/etc/localstack/init/ready.d/cors.json'
+ - 'aws:/var/lib/localstack'
+ - './docker/aws/s3/init-s3.sh:/etc/localstack/init/ready.d/init-s3.sh'
+ - './docker/aws/s3/cors.json:/etc/localstack/init/ready.d/cors.json'
labels:
- "CONNECT=localhost:4566"
cpu_shares: "${CPU_SHARES_LOW}"
@@ -829,7 +829,7 @@ volumes:
lnd:
cln:
router_lnd:
- s3:
+ aws:
nwc_send:
nwc_recv:
tordata:
diff --git a/docker/s3/cors.json b/docker/aws/s3/cors.json
similarity index 100%
rename from docker/s3/cors.json
rename to docker/aws/s3/cors.json
diff --git a/docker/s3/init-s3.sh b/docker/aws/s3/init-s3.sh
similarity index 100%
rename from docker/s3/init-s3.sh
rename to docker/aws/s3/init-s3.sh
diff --git a/fragments/subs.js b/fragments/subs.js
index ff6be09d6..f2f362eb0 100644
--- a/fragments/subs.js
+++ b/fragments/subs.js
@@ -44,7 +44,8 @@ export const SUB_FIELDS = gql`
sslState
certificateArn
lastVerifiedAt
- cname
+ verificationCname
+ verificationCnameValue
verificationTxt
}
}`
diff --git a/lib/domains.js b/lib/domains.js
index 62785809d..2f392c9a2 100644
--- a/lib/domains.js
+++ b/lib/domains.js
@@ -1,4 +1,4 @@
-import { requestCertificate, getCertificateStatus } from '@/api/acm'
+import { requestCertificate, getCertificateStatus, describeCertificate } from '@/api/acm'
import { promises as dnsPromises } from 'node:dns'
// TODO: skeleton
@@ -25,7 +25,7 @@ export async function checkCertificateStatus (certificateArn) {
// map ACM statuses
switch (certStatus) {
case 'ISSUED':
- return 'ISSUED'
+ return 'VERIFIED'
case 'PENDING_VALIDATION':
return 'PENDING'
case 'VALIDATION_TIMED_OUT':
@@ -36,7 +36,26 @@ export async function checkCertificateStatus (certificateArn) {
}
}
-export async function verifyDomainDNS (domainName, verificationTxt, cname) {
+export async function certDetails (certificateArn) {
+ try {
+ const certificate = await describeCertificate(certificateArn)
+ return certificate
+ } catch (error) {
+ console.error(`Certificate description failed: ${error.message}`)
+ return null
+ }
+}
+
+export async function getValidationValues (certificateArn) {
+ const certificate = await certDetails(certificateArn)
+ console.log(certificate.DomainValidationOptions)
+ return {
+ cname: certificate.DomainValidationOptions[0].ResourceRecord.Name,
+ value: certificate.DomainValidationOptions[0].ResourceRecord.Value
+ }
+}
+
+export async function verifyDomainDNS (domainName, verificationTxt, cname = 'stacker.news') {
const result = {
txtValid: false,
cnameValid: false,
diff --git a/middleware.js b/middleware.js
index ae3b3207d..83dfa9da4 100644
--- a/middleware.js
+++ b/middleware.js
@@ -94,6 +94,7 @@ export async function customDomainMiddleware (request, referrerResp) {
// TODO: dirty of previous iterations, refactor
// UNSAFE UNSAFE UNSAFE tokens are visible in the URL
+// Redirect to Auth Sync if user is not logged in or has no multi_auth sessions
export function customDomainAuthMiddleware (request, url) {
const host = request.headers.get('host')
const mainDomain = process.env.NEXT_PUBLIC_URL
diff --git a/pages/api/domains/index.js b/pages/api/domains/index.js
index 26f2872bb..c0357c311 100644
--- a/pages/api/domains/index.js
+++ b/pages/api/domains/index.js
@@ -12,7 +12,7 @@ export default async function handler (req, res) {
}
try {
- // fetch all custom domains from the database
+ // fetch all VERIFIED custom domains from the database
const domains = await prisma.customDomain.findMany({
select: {
domain: true,
diff --git a/prisma/migrations/20250304121322_custom_domains/migration.sql b/prisma/migrations/20250304121322_custom_domains/migration.sql
index de1d877ba..41d985b51 100644
--- a/prisma/migrations/20250304121322_custom_domains/migration.sql
+++ b/prisma/migrations/20250304121322_custom_domains/migration.sql
@@ -9,7 +9,8 @@ CREATE TABLE "CustomDomain" (
"sslState" TEXT,
"certificateArn" TEXT,
"lastVerifiedAt" TIMESTAMP(3),
- "cname" TEXT,
+ "verificationCname" TEXT,
+ "verificationCnameValue" TEXT,
"verificationTxt" TEXT,
CONSTRAINT "CustomDomain_pkey" PRIMARY KEY ("id")
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 83eb68492..973a18abf 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1221,18 +1221,19 @@ model Reminder {
}
model CustomDomain {
- id Int @id @default(autoincrement())
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
- domain String @unique
- subName String @unique @db.Citext
- dnsState String?
- sslState String?
- certificateArn String?
- lastVerifiedAt DateTime?
- cname String?
- verificationTxt String?
- sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
+ id Int @id @default(autoincrement())
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
+ domain String @unique
+ subName String @unique @db.Citext
+ dnsState String?
+ sslState String?
+ certificateArn String?
+ lastVerifiedAt DateTime?
+ verificationCname String?
+ verificationCnameValue String?
+ verificationTxt String?
+ sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
@@index([domain])
@@index([createdAt])
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 7c13ef422..9d4ac0e7d 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -1,5 +1,5 @@
import createPrisma from '@/lib/create-prisma'
-import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus } from '@/lib/domains'
+import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus, getValidationValues } from '@/lib/domains'
// TODO: Add comments
export async function domainVerification () {
@@ -9,16 +9,16 @@ export async function domainVerification () {
const domains = await models.customDomain.findMany()
for (const domain of domains) {
- const { domain: domainName, dnsState, sslState, certificateArn, verificationTxt, cname, id } = domain
+ const { domain: domainName, dnsState, sslState, certificateArn, verificationTxt, id } = domain
try {
const data = { lastVerifiedAt: new Date() }
// DNS verification
if (dnsState === 'PENDING' || dnsState === 'FAILED') {
- const { txtValid, cnameValid } = await verifyDomainDNS(domainName, verificationTxt, cname)
+ const { txtValid, cnameValid } = await verifyDomainDNS(domainName, verificationTxt)
console.log(`${domainName}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
data.dnsState = txtValid && cnameValid ? 'VERIFIED' : 'FAILED'
}
-
+ // TODO: make this consequential, don't wait for the next cron to issue the certificate
// SSL issuing
if (dnsState === 'VERIFIED' && (!certificateArn || sslState === 'FAILED')) {
const certificateArn = await issueDomainCertificate(domainName)
@@ -26,6 +26,15 @@ export async function domainVerification () {
if (certificateArn) {
const sslState = await checkCertificateStatus(certificateArn)
console.log(`${domainName}: Issued certificate status: ${sslState}`)
+ if (sslState === 'PENDING') {
+ try {
+ const { cname, value } = await getValidationValues(certificateArn)
+ data.verificationCname = cname
+ data.verificationCnameValue = value
+ } catch (error) {
+ console.error(`Failed to get validation values for domain ${domainName}:`, error)
+ }
+ }
if (sslState) data.sslState = sslState
data.certificateArn = certificateArn
} else {
@@ -42,7 +51,7 @@ export async function domainVerification () {
await models.customDomain.update({ where: { id }, data })
} catch (error) {
- // TODO: this considers only DNS verification errors, we should also consider SSL verification errors
+ // TODO: this declares any error as a DNS verification error, we should also consider SSL verification errors
console.error(`Failed to verify domain ${domainName}:`, error)
// TODO: DNS inconcistencies can happen, we should retry at least 3 times before marking it as FAILED
From 0c79d9fe68a0c3b7abdbc151f4f7a451c3039149 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 18 Mar 2025 19:04:00 +0000
Subject: [PATCH 17/74] consequential domain verification flow
---
worker/domainVerification.js | 35 +++++++++++++++++------------------
1 file changed, 17 insertions(+), 18 deletions(-)
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 9d4ac0e7d..eabf99fcf 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -6,33 +6,32 @@ export async function domainVerification () {
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
try {
- const domains = await models.customDomain.findMany()
+ const domains = await models.customDomain.findMany({ where: { OR: [{ dnsState: 'PENDING' }, { sslState: 'PENDING' }] } })
for (const domain of domains) {
- const { domain: domainName, dnsState, sslState, certificateArn, verificationTxt, id } = domain
try {
- const data = { lastVerifiedAt: new Date() }
+ const data = { ...domain, lastVerifiedAt: new Date() }
// DNS verification
- if (dnsState === 'PENDING' || dnsState === 'FAILED') {
- const { txtValid, cnameValid } = await verifyDomainDNS(domainName, verificationTxt)
- console.log(`${domainName}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
+ if (data.dnsState === 'PENDING' || data.dnsState === 'FAILED') {
+ const { txtValid, cnameValid } = await verifyDomainDNS(domain.name, domain.verificationTxt)
+ console.log(`${domain.name}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
data.dnsState = txtValid && cnameValid ? 'VERIFIED' : 'FAILED'
}
- // TODO: make this consequential, don't wait for the next cron to issue the certificate
+
// SSL issuing
- if (dnsState === 'VERIFIED' && (!certificateArn || sslState === 'FAILED')) {
- const certificateArn = await issueDomainCertificate(domainName)
- console.log(`${domainName}: Certificate issued: ${certificateArn}`)
+ if (data.dnsState === 'VERIFIED' && (!data.certificateArn || data.sslState === 'FAILED')) {
+ const certificateArn = await issueDomainCertificate(domain.name)
+ console.log(`${domain.name}: Certificate issued: ${certificateArn}`)
if (certificateArn) {
const sslState = await checkCertificateStatus(certificateArn)
- console.log(`${domainName}: Issued certificate status: ${sslState}`)
+ console.log(`${domain.name}: Issued certificate status: ${sslState}`)
if (sslState === 'PENDING') {
try {
const { cname, value } = await getValidationValues(certificateArn)
data.verificationCname = cname
data.verificationCnameValue = value
} catch (error) {
- console.error(`Failed to get validation values for domain ${domainName}:`, error)
+ console.error(`Failed to get validation values for domain ${domain.name}:`, error)
}
}
if (sslState) data.sslState = sslState
@@ -43,21 +42,21 @@ export async function domainVerification () {
}
// SSL checking
- if (dnsState === 'VERIFIED' && sslState === 'PENDING') {
- const sslState = await checkCertificateStatus(certificateArn)
- console.log(`${domainName}: Certificate status: ${sslState}`)
+ if (data.dnsState === 'VERIFIED' && data.sslState === 'PENDING') {
+ const sslState = await checkCertificateStatus(data.certificateArn)
+ console.log(`${domain.name}: Certificate status: ${sslState}`)
if (sslState) data.sslState = sslState
}
- await models.customDomain.update({ where: { id }, data })
+ await models.customDomain.update({ where: { id: domain.id }, data })
} catch (error) {
// TODO: this declares any error as a DNS verification error, we should also consider SSL verification errors
- console.error(`Failed to verify domain ${domainName}:`, error)
+ console.error(`Failed to verify domain ${domain.name}:`, error)
// TODO: DNS inconcistencies can happen, we should retry at least 3 times before marking it as FAILED
// Update to FAILED on any error
await models.customDomain.update({
- where: { id },
+ where: { id: domain.id },
data: { dnsState: 'FAILED', lastVerifiedAt: new Date() }
})
}
From 23bba3287aa2757a7fd3645d2356009e592dfa26 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 18 Mar 2025 21:54:24 +0000
Subject: [PATCH 18/74] poll every 30 seconds SSL and DNS verification states
---
api/acm/index.js | 1 +
api/resolvers/sub.js | 3 ++
api/typeDefs/sub.js | 1 +
components/territory-domains.js | 59 +++++++++++++++++++++++----------
components/territory-form.js | 35 +++++++++----------
lib/domains.js | 2 +-
worker/domainVerification.js | 18 +++++-----
7 files changed, 74 insertions(+), 45 deletions(-)
diff --git a/api/acm/index.js b/api/acm/index.js
index d7b341256..b5294f936 100644
--- a/api/acm/index.js
+++ b/api/acm/index.js
@@ -16,6 +16,7 @@ export async function requestCertificate (domain) {
config.endpoint = process.env.LOCALSTACK_ENDPOINT
}
+ // TODO: Research real values
const acm = new AWS.ACM(config)
const params = {
DomainName: domain,
diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js
index f95dc03f4..cf5013934 100644
--- a/api/resolvers/sub.js
+++ b/api/resolvers/sub.js
@@ -150,6 +150,9 @@ export default {
cursor: subs.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
subs
}
+ },
+ customDomain: async (parent, { subName }, { models }) => {
+ return models.customDomain.findUnique({ where: { subName } })
}
},
Mutation: {
diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js
index f8628d3f1..246709d0e 100644
--- a/api/typeDefs/sub.js
+++ b/api/typeDefs/sub.js
@@ -7,6 +7,7 @@ export default gql`
subs: [Sub!]!
topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
+ customDomain(subName: String!): CustomDomain
}
type CustomDomain {
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 7373638d5..b9afbc3eb 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -1,9 +1,10 @@
import { Badge } from 'react-bootstrap'
import { Form, Input, SubmitButton } from './form'
-import { gql, useMutation } from '@apollo/client'
+import { gql, useMutation, useQuery } from '@apollo/client'
import { customDomainSchema } from '@/lib/validate'
import ActionTooltip from './action-tooltip'
import { useToast } from '@/components/toast'
+import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
const SET_CUSTOM_DOMAIN = gql`
mutation SetCustomDomain($subName: String!, $domain: String!) {
@@ -15,11 +16,38 @@ const SET_CUSTOM_DOMAIN = gql`
}
`
-// TODO: verification states should refresh
+const GET_CUSTOM_DOMAIN = gql`
+ query CustomDomain($subName: String!) {
+ customDomain(subName: $subName) {
+ domain
+ dnsState
+ sslState
+ verificationCname
+ verificationCnameValue
+ verificationTxt
+ lastVerifiedAt
+ }
+ }
+`
+
+// TODO: clean this up
export default function CustomDomainForm ({ sub }) {
const [setCustomDomain] = useMutation(SET_CUSTOM_DOMAIN, {
refetchQueries: ['Sub']
})
+ const { data, stopPolling } = useQuery(GET_CUSTOM_DOMAIN, SSR
+ ? {}
+ : {
+ variables: { subName: sub.name },
+ pollInterval: NORMAL_POLL_INTERVAL,
+ skip: !sub || !sub.customDomain,
+ onCompleted: (data) => {
+ if (data?.customDomain?.sslState === 'VERIFIED' &&
+ data?.customDomain?.dnsState === 'VERIFIED') {
+ stopPolling()
+ }
+ }
+ })
const toaster = useToast()
const onSubmit = async ({ domain }) => {
@@ -57,7 +85,7 @@ export default function CustomDomainForm ({ sub }) {
return (
- }
- body={
- <>
-
- {sub?.customDomain?.dnsState === 'VERIFIED' && sub?.customDomain?.sslState === 'VERIFIED' &&
- <>
- [NOT IMPLEMENTED] branding
- WIP
- [NOT IMPLEMENTED] color scheme
- WIP
- >}
- >
- }
- />
-
+ {sub &&
+ }
+ body={
+ <>
+
+ {sub?.customDomain?.dnsState === 'VERIFIED' && sub?.customDomain?.sslState === 'VERIFIED' &&
+ <>
+ [NOT IMPLEMENTED] branding
+ WIP
+ [NOT IMPLEMENTED] color scheme
+ WIP
+ >}
+ >
+ }
+ />
+ }
)
}
diff --git a/lib/domains.js b/lib/domains.js
index 2f392c9a2..3e57b7bbf 100644
--- a/lib/domains.js
+++ b/lib/domains.js
@@ -55,7 +55,7 @@ export async function getValidationValues (certificateArn) {
}
}
-export async function verifyDomainDNS (domainName, verificationTxt, cname = 'stacker.news') {
+export async function verifyDomainDNS (domainName, verificationTxt, cname = 'parallel.soxa.dev') {
const result = {
txtValid: false,
cnameValid: false,
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index eabf99fcf..ba3830f17 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -6,32 +6,32 @@ export async function domainVerification () {
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
try {
- const domains = await models.customDomain.findMany({ where: { OR: [{ dnsState: 'PENDING' }, { sslState: 'PENDING' }] } })
+ const domains = await models.customDomain.findMany({ where: { NOT: { AND: [{ dnsState: 'VERIFIED' }, { sslState: 'VERIFIED' }] } } })
for (const domain of domains) {
try {
const data = { ...domain, lastVerifiedAt: new Date() }
// DNS verification
if (data.dnsState === 'PENDING' || data.dnsState === 'FAILED') {
- const { txtValid, cnameValid } = await verifyDomainDNS(domain.name, domain.verificationTxt)
- console.log(`${domain.name}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
+ const { txtValid, cnameValid } = await verifyDomainDNS(data.domain, domain.verificationTxt)
+ console.log(`${data.domain}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
data.dnsState = txtValid && cnameValid ? 'VERIFIED' : 'FAILED'
}
// SSL issuing
if (data.dnsState === 'VERIFIED' && (!data.certificateArn || data.sslState === 'FAILED')) {
- const certificateArn = await issueDomainCertificate(domain.name)
- console.log(`${domain.name}: Certificate issued: ${certificateArn}`)
+ const certificateArn = await issueDomainCertificate(data.domain)
+ console.log(`${data.domain}: Certificate issued: ${certificateArn}`)
if (certificateArn) {
const sslState = await checkCertificateStatus(certificateArn)
- console.log(`${domain.name}: Issued certificate status: ${sslState}`)
+ console.log(`${data.domain}: Issued certificate status: ${sslState}`)
if (sslState === 'PENDING') {
try {
const { cname, value } = await getValidationValues(certificateArn)
data.verificationCname = cname
data.verificationCnameValue = value
} catch (error) {
- console.error(`Failed to get validation values for domain ${domain.name}:`, error)
+ console.error(`Failed to get validation values for domain ${data.domain}:`, error)
}
}
if (sslState) data.sslState = sslState
@@ -44,14 +44,14 @@ export async function domainVerification () {
// SSL checking
if (data.dnsState === 'VERIFIED' && data.sslState === 'PENDING') {
const sslState = await checkCertificateStatus(data.certificateArn)
- console.log(`${domain.name}: Certificate status: ${sslState}`)
+ console.log(`${data.domain}: Certificate status: ${sslState}`)
if (sslState) data.sslState = sslState
}
await models.customDomain.update({ where: { id: domain.id }, data })
} catch (error) {
// TODO: this declares any error as a DNS verification error, we should also consider SSL verification errors
- console.error(`Failed to verify domain ${domain.name}:`, error)
+ console.error(`Failed to verify domain ${domain.domain}:`, error)
// TODO: DNS inconcistencies can happen, we should retry at least 3 times before marking it as FAILED
// Update to FAILED on any error
From 76df54a6751b00f293f76bf872f5231c73b8566b Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Wed, 19 Mar 2025 15:52:03 +0000
Subject: [PATCH 19/74] refactor custom domain queries, todo dynamic callback
for login
---
api/resolvers/domain.js | 57 +++++++++++++++++++++++++++++++++
api/resolvers/index.js | 3 +-
api/resolvers/sub.js | 50 +----------------------------
api/typeDefs/domain.js | 25 +++++++++++++++
api/typeDefs/index.js | 3 +-
api/typeDefs/sub.js | 16 ---------
components/territory-domains.js | 27 ++--------------
fragments/domains.js | 33 +++++++++++++++++++
lib/domains.js | 1 +
middleware.js | 17 +++++++---
pages/login.js | 1 +
11 files changed, 136 insertions(+), 97 deletions(-)
create mode 100644 api/resolvers/domain.js
create mode 100644 api/typeDefs/domain.js
create mode 100644 fragments/domains.js
diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js
new file mode 100644
index 000000000..51aeb7881
--- /dev/null
+++ b/api/resolvers/domain.js
@@ -0,0 +1,57 @@
+import { validateSchema, customDomainSchema } from '@/lib/validate'
+import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
+import { randomBytes } from 'node:crypto'
+
+export default {
+ Query: {
+ customDomain: async (parent, { subName }, { models }) => {
+ return models.customDomain.findUnique({ where: { subName } })
+ }
+ },
+ 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')
+ }
+
+ if (domain) {
+ const existing = await models.customDomain.findUnique({ where: { subName } })
+ if (existing) {
+ if (domain === existing.domain) {
+ throw new GqlInputError('domain already set')
+ }
+ return await models.customDomain.update({
+ where: { subName },
+ data: { domain, dnsState: 'PENDING', sslState: 'PENDING' }
+ })
+ } else {
+ return await models.customDomain.create({
+ data: {
+ domain,
+ dnsState: 'PENDING',
+ verificationTxt: randomBytes(32).toString('base64'), // TODO: explore other options
+ sub: {
+ connect: { name: subName }
+ }
+ }
+ })
+ }
+ } else {
+ return await models.customDomain.delete({ where: { subName } })
+ }
+ }
+ }
+}
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 cf5013934..aea327325 100644
--- a/api/resolvers/sub.js
+++ b/api/resolvers/sub.js
@@ -1,11 +1,10 @@
import { whenRange } from '@/lib/time'
-import { validateSchema, customDomainSchema, territorySchema } from '@/lib/validate'
+import { validateSchema, territorySchema } from '@/lib/validate'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { viewGroup } from './growth'
import { notifyTerritoryTransfer } from '@/lib/webPush'
import performPaidAction from '../paidAction'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
-import { randomBytes } from 'node:crypto'
export async function getSub (parent, { name }, { models, me }) {
if (!name) return null
@@ -150,9 +149,6 @@ export default {
cursor: subs.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
subs
}
- },
- customDomain: async (parent, { subName }, { models }) => {
- return models.customDomain.findUnique({ where: { subName } })
}
},
Mutation: {
@@ -281,50 +277,6 @@ export default {
}
return await performPaidAction('TERRITORY_UNARCHIVE', data, { me, models, lnd })
- },
- 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')
- }
-
- if (domain) {
- const existing = await models.customDomain.findUnique({ where: { subName } })
- if (existing) {
- if (domain === existing.domain) {
- throw new GqlInputError('domain already set')
- }
- return await models.customDomain.update({
- where: { subName },
- data: { domain, dnsState: 'PENDING', sslState: 'PENDING' }
- })
- } else {
- return await models.customDomain.create({
- data: {
- domain,
- dnsState: 'PENDING',
- verificationTxt: randomBytes(32).toString('base64'), // TODO: explore other options
- sub: {
- connect: { name: subName }
- }
- }
- })
- }
- } else {
- return await models.customDomain.delete({ where: { subName } })
- }
}
},
Sub: {
diff --git a/api/typeDefs/domain.js b/api/typeDefs/domain.js
new file mode 100644
index 000000000..0ef64e5c4
--- /dev/null
+++ b/api/typeDefs/domain.js
@@ -0,0 +1,25 @@
+import { gql } from 'graphql-tag'
+
+export default gql`
+ extend type Query {
+ customDomain(subName: String!): CustomDomain
+ }
+
+ extend type Mutation {
+ setCustomDomain(subName: String!, domain: String!): CustomDomain
+ }
+
+ type CustomDomain {
+ createdAt: Date!
+ updatedAt: Date!
+ domain: String!
+ subName: String!
+ dnsState: String
+ sslState: String
+ certificateArn: String
+ lastVerifiedAt: Date
+ verificationCname: String
+ verificationCnameValue: String
+ verificationTxt: 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 246709d0e..97ae104db 100644
--- a/api/typeDefs/sub.js
+++ b/api/typeDefs/sub.js
@@ -7,21 +7,6 @@ export default gql`
subs: [Sub!]!
topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
- customDomain(subName: String!): CustomDomain
- }
-
- type CustomDomain {
- createdAt: Date!
- updatedAt: Date!
- domain: String!
- subName: String!
- dnsState: String
- sslState: String
- certificateArn: String
- lastVerifiedAt: Date
- verificationCname: String
- verificationCnameValue: String
- verificationTxt: String
}
type Subs {
@@ -43,7 +28,6 @@ export default gql`
replyCost: Int!, postTypes: [String!]!,
billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, nsfw: Boolean!): SubPaidAction!
- setCustomDomain(subName: String!, domain: String!): CustomDomain
}
type Sub {
diff --git a/components/territory-domains.js b/components/territory-domains.js
index b9afbc3eb..92d5bd5db 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -1,34 +1,11 @@
import { Badge } from 'react-bootstrap'
import { Form, Input, SubmitButton } from './form'
-import { gql, useMutation, useQuery } from '@apollo/client'
+import { useMutation, useQuery } from '@apollo/client'
import { customDomainSchema } from '@/lib/validate'
import ActionTooltip from './action-tooltip'
import { useToast } from '@/components/toast'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
-
-const SET_CUSTOM_DOMAIN = gql`
- mutation SetCustomDomain($subName: String!, $domain: String!) {
- setCustomDomain(subName: $subName, domain: $domain) {
- domain
- dnsState
- sslState
- }
- }
-`
-
-const GET_CUSTOM_DOMAIN = gql`
- query CustomDomain($subName: String!) {
- customDomain(subName: $subName) {
- domain
- dnsState
- sslState
- verificationCname
- verificationCnameValue
- verificationTxt
- lastVerifiedAt
- }
- }
-`
+import { GET_CUSTOM_DOMAIN, SET_CUSTOM_DOMAIN } from '@/fragments/domains'
// TODO: clean this up
export default function CustomDomainForm ({ sub }) {
diff --git a/fragments/domains.js b/fragments/domains.js
new file mode 100644
index 000000000..df3af313a
--- /dev/null
+++ b/fragments/domains.js
@@ -0,0 +1,33 @@
+import { gql } from 'graphql-tag'
+
+export const GET_CUSTOM_DOMAIN = gql`
+ query CustomDomain($subName: String!) {
+ customDomain(subName: $subName) {
+ domain
+ dnsState
+ sslState
+ verificationCname
+ verificationCnameValue
+ verificationTxt
+ lastVerifiedAt
+ }
+ }
+`
+
+export const GET_CUSTOM_DOMAIN_FULL = gql`
+ ${GET_CUSTOM_DOMAIN}
+ fragment CustomDomainFull on CustomDomain {
+ ...CustomDomainFields
+ certificateArn
+ }
+`
+
+export const SET_CUSTOM_DOMAIN = gql`
+ mutation SetCustomDomain($subName: String!, $domain: String!) {
+ setCustomDomain(subName: $subName, domain: $domain) {
+ domain
+ dnsState
+ sslState
+ }
+ }
+`
diff --git a/lib/domains.js b/lib/domains.js
index 3e57b7bbf..596404543 100644
--- a/lib/domains.js
+++ b/lib/domains.js
@@ -46,6 +46,7 @@ export async function certDetails (certificateArn) {
}
}
+// TODO: Test with real values, localstack don't have this info until the certificate is issued
export async function getValidationValues (certificateArn) {
const certificate = await certDetails(certificateArn)
console.log(certificate.DomainValidationOptions)
diff --git a/middleware.js b/middleware.js
index 83dfa9da4..e2bcbe270 100644
--- a/middleware.js
+++ b/middleware.js
@@ -1,5 +1,6 @@
import { NextResponse, URLPattern } from 'next/server'
import { cachedFetcher } from '@/lib/fetch'
+
const referrerPattern = new URLPattern({ pathname: ':pathname(*)/r/:referrer([\\w_]+)' })
const itemPattern = new URLPattern({ pathname: '/items/:id(\\d+){/:other(\\w+)}?' })
const profilePattern = new URLPattern({ pathname: '/:name([\\w_]+){/:type(\\w+)}?' })
@@ -13,10 +14,11 @@ const SN_REFERRER_NONCE = 'sn_referrer_nonce'
const SN_REFEREE_LANDING = 'sn_referee_landing'
const TERRITORY_PATHS = ['/~', '/recent', '/random', '/top', '/post', '/edit']
-const NO_REWRITE_PATHS = ['/api', '/_next', '/_error', '/404', '/500', '/offline', '/static']
+const NO_REWRITE_PATHS = ['/api', '/_next', '/_error', '/404', '/500', '/offline', '/static', '/signup', '/login', '/logout']
+// TODO: move this to a separate file
// fetch custom domain mappings from our API, caching it for 5 minutes
-const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings () {
+export const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings () {
const url = `${process.env.NEXT_PUBLIC_URL}/api/domains`
try {
const response = await fetch(url)
@@ -37,6 +39,12 @@ const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings
keyGenerator: () => 'domain_mappings'
})
+// get a domain mapping from the cache
+export async function getDomainMapping (domain) {
+ const domainMappings = await getDomainMappingsCache()
+ return domainMappings?.[domain]
+}
+
export async function customDomainMiddleware (request, referrerResp) {
const host = request.headers.get('host')
const referer = request.headers.get('referer')
@@ -48,14 +56,13 @@ export async function customDomainMiddleware (request, referrerResp) {
console.log('referer', referer)
- const domainMapping = await getDomainMappingsCache()
- console.log('domainMapping', domainMapping)
- const domainInfo = domainMapping?.[host.toLowerCase()]
+ const domainInfo = await getDomainMapping(host?.toLowerCase())
if (!domainInfo) {
console.log('Redirecting to main domain')
return NextResponse.redirect(new URL(pathname, mainDomain))
}
+ // todo: obviously this is not the best way to do this
if (NO_REWRITE_PATHS.some(p => pathname.startsWith(p)) || pathname.includes('.')) {
return NextResponse.next()
}
diff --git a/pages/login.js b/pages/login.js
index e3e453c67..9b777b5e5 100644
--- a/pages/login.js
+++ b/pages/login.js
@@ -25,6 +25,7 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, mult
console.error('error decoding callback:', callbackUrl, err)
}
+ // TODO: custom domain mapping
if (external) {
callbackUrl = '/'
}
From 6db07b8839da5accb2cf54ff45bac574466e93c8 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Fri, 21 Mar 2025 23:04:56 +0000
Subject: [PATCH 20/74] fix login flow, temporarily disable auto-auth, fix
OAuth login
---
components/login.js | 2 ++
components/nav/common.js | 37 +++++++++++++--------------------
components/territory-header.js | 7 +++++++
lib/url.js | 14 +++++++++++++
middleware.js | 16 +++++++++++---
pages/api/auth/[...nextauth].js | 2 +-
pages/login.js | 10 +++++++--
pages/~/index.js | 6 +-----
8 files changed, 60 insertions(+), 34 deletions(-)
diff --git a/components/login.js b/components/login.js
index 3d3d1d845..f7c44a112 100644
--- a/components/login.js
+++ b/components/login.js
@@ -109,6 +109,8 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
text={`${text || 'Login'} with`}
/>
)
+ case 'Sync': // TODO: remove this
+ return null
default:
return (
{
+ console.log(router.query)
if (router.query.type === 'sync') {
signIn('sync', { token: router.query.token, callbackUrl: router.query.callbackUrl, redirect: false })
}
- }, [router.query.type, router.query.token, router.query.callbackUrl])
+ }, [router.query])
const handleLogin = useCallback(async () => {
- // todo: custom domain check
- const mainDomain = process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
- const isCustomDomain = window.location.hostname !== mainDomain
-
- if (isCustomDomain && router.query.type !== 'noAuth') {
- // TODO: dirty of previous iterations, refactor
- // redirect to sync endpoint on main domain
- const protocol = window.location.protocol
- const mainDomainUrl = `${protocol}//${mainDomain}`
- const currentUrl = window.location.origin + router.asPath
-
- window.location.href = `${mainDomainUrl}/api/auth/sync?redirectUrl=${encodeURIComponent(currentUrl)}`
- } else {
- // normal login on main domain
- await router.push({
- pathname: '/login',
- query: { callbackUrl: window.location.origin + router.asPath }
- })
- }
+ // normal login on main domain
+ await router.push({
+ pathname: '/login',
+ query: { callbackUrl: window.location.origin + router.asPath }
+ })
}, [router])
return (
@@ -297,6 +283,11 @@ function LogoutObstacle ({ onClose }) {
const { removeLocalWallets } = useWallets()
const { nextAccount } = useAccounts()
const router = useRouter()
+ const [isCustomDomain, setIsCustomDomain] = useState(false)
+
+ useEffect(() => {
+ setIsCustomDomain(router.host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, ''))
+ }, [router.host])
return (
@@ -328,7 +319,7 @@ function LogoutObstacle ({ onClose }) {
removeLocalWallets()
- await signOut({ callbackUrl: '/' })
+ await signOut({ callbackUrl: '/', redirect: !isCustomDomain })
}}
>
logout
diff --git a/components/territory-header.js b/components/territory-header.js
index 8d64f0144..9cd1b6fab 100644
--- a/components/territory-header.js
+++ b/components/territory-header.js
@@ -12,6 +12,7 @@ import { gql, useMutation } from '@apollo/client'
import { useToast } from './toast'
import ActionDropdown from './action-dropdown'
import { TerritoryTransferDropdownItem } from './territory-transfer'
+import { useRouter } from 'next/router'
export function TerritoryDetails ({ sub, children }) {
return (
@@ -77,6 +78,10 @@ export function TerritoryInfo ({ sub }) {
export default function TerritoryHeader ({ sub }) {
const { me } = useMe()
const toaster = useToast()
+ const router = useRouter()
+ // TODO: this works but it can be better
+ const path = router.asPath.split('?')[0]
+ const isCustomDomain = sub && !path.includes(`/~${sub?.name}`)
const [toggleMuteSub] = useMutation(
gql`
@@ -96,6 +101,8 @@ export default function TerritoryHeader ({ sub }) {
const isMine = Number(sub.userId) === Number(me?.id)
+ if (isCustomDomain && !isMine) return null
+
return (
<>
diff --git a/lib/url.js b/lib/url.js
index 0322c72c0..48e050320 100644
--- a/lib/url.js
+++ b/lib/url.js
@@ -264,6 +264,20 @@ export function isMisleadingLink (text, href) {
return misleading
}
+export function isCustomDomain ({ req, url }) {
+ const mainDomain = new URL(process.env.NEXT_PUBLIC_URL).hostname
+ if (typeof window !== 'undefined') {
+ return window.location.hostname !== mainDomain
+ }
+ if (req) {
+ return req.headers.host !== mainDomain
+ }
+ if (url) {
+ return new URL(url).hostname !== mainDomain
+ }
+ return false
+}
+
// eslint-disable-next-line
export const URL_REGEXP = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
diff --git a/middleware.js b/middleware.js
index e2bcbe270..ac2a72b12 100644
--- a/middleware.js
+++ b/middleware.js
@@ -14,7 +14,7 @@ const SN_REFERRER_NONCE = 'sn_referrer_nonce'
const SN_REFEREE_LANDING = 'sn_referee_landing'
const TERRITORY_PATHS = ['/~', '/recent', '/random', '/top', '/post', '/edit']
-const NO_REWRITE_PATHS = ['/api', '/_next', '/_error', '/404', '/500', '/offline', '/static', '/signup', '/login', '/logout']
+const NO_REWRITE_PATHS = ['/api', '/_next', '/_error', '/404', '/500', '/offline', '/static', '/logout']
// TODO: move this to a separate file
// fetch custom domain mappings from our API, caching it for 5 minutes
@@ -70,6 +70,14 @@ export async function customDomainMiddleware (request, referrerResp) {
console.log('pathname', pathname)
console.log('query', url.searchParams)
+ if (pathname === '/login' || pathname === '/signup') {
+ const redirectUrl = new URL(pathname, mainDomain)
+ redirectUrl.searchParams.set('domain', host)
+ redirectUrl.searchParams.set('callbackUrl', url.searchParams.get('callbackUrl'))
+ const redirectResp = NextResponse.redirect(redirectUrl)
+ return applyReferrerCookies(redirectResp, referrerResp)
+ }
+
// if the url contains the territory path, remove it
if (pathname.startsWith(`/~${domainInfo.subName}`)) {
// remove the territory prefix from the path
@@ -80,12 +88,14 @@ export async function customDomainMiddleware (request, referrerResp) {
}
// if coming from main domain, handle auth automatically
- if (referer && referer === mainDomain) {
+ // TODO: uncomment and work on this
+
+ /* if (referer && referer === mainDomain) {
const authResp = customDomainAuthMiddleware(request, url)
if (authResp && authResp.status !== 200) {
return applyReferrerCookies(authResp, referrerResp)
}
- }
+ } */
const internalUrl = new URL(url)
// rewrite to the territory path if we're at the root
diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js
index b960323a4..d575631b8 100644
--- a/pages/api/auth/[...nextauth].js
+++ b/pages/api/auth/[...nextauth].js
@@ -276,7 +276,7 @@ const getProviders = res => [
}),
CredentialsProvider({
id: 'sync',
- name: 'Auth Sync',
+ name: 'Sync',
credentials: {
token: { label: 'token', type: 'text' }
},
diff --git a/pages/login.js b/pages/login.js
index 9b777b5e5..e102527a4 100644
--- a/pages/login.js
+++ b/pages/login.js
@@ -6,7 +6,7 @@ import { StaticLayout } from '@/components/layout'
import Login from '@/components/login'
import { isExternal } from '@/lib/url'
-export async function getServerSideProps ({ req, res, query: { callbackUrl, multiAuth = false, error = null } }) {
+export async function getServerSideProps ({ req, res, query: { callbackUrl, multiAuth = false, error = null, domain } }) {
let session = await getServerSession(req, res, getAuthOptions(req))
// required to prevent infinite redirect loops if we switch to anon
@@ -25,11 +25,17 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, mult
console.error('error decoding callback:', callbackUrl, err)
}
- // TODO: custom domain mapping
if (external) {
callbackUrl = '/'
}
+ // TODO: custom domain mapping security
+ if (domain) {
+ callbackUrl = '/api/auth/sync?redirectUrl=https://' + domain
+ }
+
+ console.log('callbackUrl', callbackUrl)
+
if (session && callbackUrl && !multiAuth) {
// in the case of auth linking we want to pass the error back to settings
// in the case of multi auth, don't redirect if there is already a session
diff --git a/pages/~/index.js b/pages/~/index.js
index c4f949cc3..b2b5162b4 100644
--- a/pages/~/index.js
+++ b/pages/~/index.js
@@ -21,13 +21,9 @@ export default function Sub ({ ssrData }) {
if (!data && !ssrData) return
const { sub } = data || ssrData
- const path = router.asPath.split('?')[0]
- // TODO: this works but it can be better
- const isCustomDomain = sub && !path.includes(`/~${sub?.name}`)
-
return (
- {sub && !isCustomDomain
+ {sub
?
: (
<>
From 4d6d6596bf648489f32eb247f7c1e601fe630dbc Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Sun, 23 Mar 2025 16:50:10 +0000
Subject: [PATCH 21/74] middleware multiAuth support, referrer redirect support
---
middleware.js | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/middleware.js b/middleware.js
index ac2a72b12..5b3eea820 100644
--- a/middleware.js
+++ b/middleware.js
@@ -20,6 +20,7 @@ const NO_REWRITE_PATHS = ['/api', '/_next', '/_error', '/404', '/500', '/offline
// fetch custom domain mappings from our API, caching it for 5 minutes
export const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings () {
const url = `${process.env.NEXT_PUBLIC_URL}/api/domains`
+ console.log('fetching domain mappings from', url)
try {
const response = await fetch(url)
if (!response.ok) {
@@ -74,6 +75,9 @@ export async function customDomainMiddleware (request, referrerResp) {
const redirectUrl = new URL(pathname, mainDomain)
redirectUrl.searchParams.set('domain', host)
redirectUrl.searchParams.set('callbackUrl', url.searchParams.get('callbackUrl'))
+ if (url.searchParams.get('multiAuth')) {
+ redirectUrl.searchParams.set('multiAuth', url.searchParams.get('multiAuth'))
+ }
const redirectResp = NextResponse.redirect(redirectUrl)
return applyReferrerCookies(redirectResp, referrerResp)
}
@@ -288,6 +292,10 @@ export function applySecurityHeaders (resp) {
export async function middleware (request) {
// First run referrer middleware to capture referrer data
const referrerResp = referrerMiddleware(request)
+ if (referrerResp.headers.get('Location')) {
+ // This is a redirect from the referrer middleware
+ return applySecurityHeaders(referrerResp)
+ }
// If we're on a custom domain, handle that next
const host = request.headers.get('host')
From 81f15501e9de18d5e0c864e6146f830177ce4862 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Sun, 23 Mar 2025 18:52:58 +0000
Subject: [PATCH 22/74] refetch and start polling on domain update; domain
verification tweaks; upsert domain
---
api/resolvers/domain.js | 45 +++++++++++++++++--------------
components/territory-domains.js | 48 +++++++++++++++++++--------------
components/territory-form.js | 1 +
worker/domainVerification.js | 4 +--
4 files changed, 56 insertions(+), 42 deletions(-)
diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js
index 51aeb7881..c7d42fbc3 100644
--- a/api/resolvers/domain.js
+++ b/api/resolvers/domain.js
@@ -29,28 +29,33 @@ export default {
if (domain) {
const existing = await models.customDomain.findUnique({ where: { subName } })
- if (existing) {
- if (domain === existing.domain) {
- throw new GqlInputError('domain already set')
- }
- return await models.customDomain.update({
- where: { subName },
- data: { domain, dnsState: 'PENDING', sslState: 'PENDING' }
- })
- } else {
- return await models.customDomain.create({
- data: {
- domain,
- dnsState: 'PENDING',
- verificationTxt: randomBytes(32).toString('base64'), // TODO: explore other options
- sub: {
- connect: { name: subName }
- }
- }
- })
+ if (existing && existing.domain === domain) {
+ throw new GqlInputError('domain already set')
}
+ return await models.customDomain.upsert({
+ where: { subName },
+ update: {
+ domain,
+ dnsState: 'PENDING',
+ sslState: 'WAITING',
+ certificateArn: null
+ },
+ create: {
+ domain,
+ dnsState: 'PENDING',
+ verificationTxt: randomBytes(32).toString('base64'),
+ sub: {
+ connect: { name: subName }
+ }
+ }
+ })
} else {
- return await models.customDomain.delete({ where: { subName } })
+ try {
+ return await models.customDomain.delete({ where: { subName } })
+ } catch (error) {
+ console.error(error)
+ throw new GqlInputError('failed to delete domain')
+ }
}
}
}
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 92d5bd5db..f12124dc9 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -6,35 +6,41 @@ import ActionTooltip from './action-tooltip'
import { useToast } from '@/components/toast'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { GET_CUSTOM_DOMAIN, SET_CUSTOM_DOMAIN } from '@/fragments/domains'
+import { useEffect } from 'react'
-// TODO: clean this up
+// TODO: clean this up, might not need all this refreshing
export default function CustomDomainForm ({ sub }) {
- const [setCustomDomain] = useMutation(SET_CUSTOM_DOMAIN, {
- refetchQueries: ['Sub']
- })
- const { data, stopPolling } = useQuery(GET_CUSTOM_DOMAIN, SSR
+ const [setCustomDomain] = useMutation(SET_CUSTOM_DOMAIN)
+
+ const { data, startPolling, stopPolling, refetch } = useQuery(GET_CUSTOM_DOMAIN, SSR
? {}
: {
variables: { subName: sub.name },
- pollInterval: NORMAL_POLL_INTERVAL,
- skip: !sub || !sub.customDomain,
- onCompleted: (data) => {
- if (data?.customDomain?.sslState === 'VERIFIED' &&
- data?.customDomain?.dnsState === 'VERIFIED') {
- stopPolling()
- }
- }
+ pollInterval: NORMAL_POLL_INTERVAL
})
const toaster = useToast()
+ useEffect(() => {
+ if (data?.customDomain?.sslState === 'VERIFIED' &&
+ data?.customDomain?.dnsState === 'VERIFIED') {
+ stopPolling()
+ }
+ }, [data, stopPolling])
+
const onSubmit = async ({ domain }) => {
- await setCustomDomain({
- variables: {
- subName: sub.name,
- domain
- }
- })
- toaster.success('domain updated successfully')
+ try {
+ await setCustomDomain({
+ variables: {
+ subName: sub.name,
+ domain
+ }
+ })
+ refetch()
+ startPolling(NORMAL_POLL_INTERVAL)
+ toaster.success('domain updated successfully')
+ } catch (error) {
+ toaster.error('failed to update domain', { error })
+ }
}
const getStatusBadge = (status) => {
@@ -56,6 +62,8 @@ export default function CustomDomainForm ({ sub }) {
return SSL pending
case 'FAILED':
return SSL failed
+ case 'WAITING':
+ return SSL waiting
}
}
diff --git a/components/territory-form.js b/components/territory-form.js
index 0be3c3d46..0a3da18be 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -295,6 +295,7 @@ export default function TerritoryForm ({ sub }) {
body={
<>
+ {/* TODO: doesn't follow the custom domain state */}
{sub?.customDomain?.dnsState === 'VERIFIED' && sub?.customDomain?.sslState === 'VERIFIED' &&
<>
[NOT IMPLEMENTED] branding
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index ba3830f17..1e28f7d8c 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -25,7 +25,7 @@ export async function domainVerification () {
if (certificateArn) {
const sslState = await checkCertificateStatus(certificateArn)
console.log(`${data.domain}: Issued certificate status: ${sslState}`)
- if (sslState === 'PENDING') {
+ if (sslState !== 'VERIFIED') {
try {
const { cname, value } = await getValidationValues(certificateArn)
data.verificationCname = cname
@@ -42,7 +42,7 @@ export async function domainVerification () {
}
// SSL checking
- if (data.dnsState === 'VERIFIED' && data.sslState === 'PENDING') {
+ if (data.dnsState === 'VERIFIED' && data.sslState !== 'VERIFIED') {
const sslState = await checkCertificateStatus(data.certificateArn)
console.log(`${data.domain}: Certificate status: ${sslState}`)
if (sslState) data.sslState = sslState
From d26e6c12365a76bac4acf8008672da0e47d21627 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Sun, 23 Mar 2025 22:55:56 +0000
Subject: [PATCH 23/74] check for allowed domain earlier; remove domain after 5
failed verification attempts
---
api/typeDefs/domain.js | 1 +
components/territory-domains.js | 4 ++--
fragments/domains.js | 1 +
middleware.js | 18 ++++++------------
.../migration.sql | 1 +
prisma/schema.prisma | 1 +
worker/domainVerification.js | 10 ++++++++++
7 files changed, 22 insertions(+), 14 deletions(-)
diff --git a/api/typeDefs/domain.js b/api/typeDefs/domain.js
index 0ef64e5c4..40a69ae96 100644
--- a/api/typeDefs/domain.js
+++ b/api/typeDefs/domain.js
@@ -21,5 +21,6 @@ export default gql`
verificationCname: String
verificationCnameValue: String
verificationTxt: String
+ failedAttempts: Int
}
`
diff --git a/components/territory-domains.js b/components/territory-domains.js
index f12124dc9..204892b20 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -100,7 +100,7 @@ export default function CustomDomainForm ({ sub }) {
{/* TODO: toaster */}
save
- {(data?.customDomain.dnsState === 'PENDING' || data?.customDomain.dnsState === 'FAILED') && (
+ {(data?.customDomain?.dnsState === 'PENDING' || data?.customDomain?.dnsState === 'FAILED') && (
<>
Verify your domain
Add the following DNS records to verify ownership of your domain:
@@ -116,7 +116,7 @@ export default function CustomDomainForm ({ sub }) {
>
)}
- {data?.customDomain.sslState === 'PENDING' && (
+ {data?.customDomain?.sslState === 'PENDING' && (
<>
SSL verification pending
We issued an SSL certificate for your domain. To validate it, add the following CNAME record:
diff --git a/fragments/domains.js b/fragments/domains.js
index df3af313a..b876644de 100644
--- a/fragments/domains.js
+++ b/fragments/domains.js
@@ -19,6 +19,7 @@ export const GET_CUSTOM_DOMAIN_FULL = gql`
fragment CustomDomainFull on CustomDomain {
...CustomDomainFields
certificateArn
+ failedAttempts
}
`
diff --git a/middleware.js b/middleware.js
index 5b3eea820..55dc69941 100644
--- a/middleware.js
+++ b/middleware.js
@@ -46,7 +46,7 @@ export async function getDomainMapping (domain) {
return domainMappings?.[domain]
}
-export async function customDomainMiddleware (request, referrerResp) {
+export async function customDomainMiddleware (request, referrerResp, domain) {
const host = request.headers.get('host')
const referer = request.headers.get('referer')
const url = request.nextUrl.clone()
@@ -57,12 +57,6 @@ export async function customDomainMiddleware (request, referrerResp) {
console.log('referer', referer)
- const domainInfo = await getDomainMapping(host?.toLowerCase())
- if (!domainInfo) {
- console.log('Redirecting to main domain')
- return NextResponse.redirect(new URL(pathname, mainDomain))
- }
-
// todo: obviously this is not the best way to do this
if (NO_REWRITE_PATHS.some(p => pathname.startsWith(p)) || pathname.includes('.')) {
return NextResponse.next()
@@ -83,9 +77,9 @@ export async function customDomainMiddleware (request, referrerResp) {
}
// if the url contains the territory path, remove it
- if (pathname.startsWith(`/~${domainInfo.subName}`)) {
+ if (pathname.startsWith(`/~${domain.subName}`)) {
// remove the territory prefix from the path
- const cleanPath = pathname.replace(`/~${domainInfo.subName}`, '') || '/'
+ const cleanPath = pathname.replace(`/~${domain.subName}`, '') || '/'
console.log('Redirecting to clean path:', cleanPath)
const redirectResp = NextResponse.redirect(new URL(cleanPath + url.search, url.origin))
return applyReferrerCookies(redirectResp, referrerResp)
@@ -104,7 +98,7 @@ export async function customDomainMiddleware (request, referrerResp) {
const internalUrl = new URL(url)
// rewrite to the territory path if we're at the root
if (pathname === '/' || TERRITORY_PATHS.some(p => pathname.startsWith(p))) {
- internalUrl.pathname = `/~${domainInfo.subName}${pathname === '/' ? '' : pathname}`
+ internalUrl.pathname = `/~${domain.subName}${pathname === '/' ? '' : pathname}`
}
console.log('Rewrite to:', internalUrl.pathname)
// rewrite to the territory path
@@ -299,8 +293,8 @@ export async function middleware (request) {
// If we're on a custom domain, handle that next
const host = request.headers.get('host')
- const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
- if (isCustomDomain) {
+ const isAllowedDomain = await getDomainMapping(host?.toLowerCase())
+ if (isAllowedDomain) {
const customDomainResp = await customDomainMiddleware(request, referrerResp)
return applySecurityHeaders(customDomainResp)
}
diff --git a/prisma/migrations/20250304121322_custom_domains/migration.sql b/prisma/migrations/20250304121322_custom_domains/migration.sql
index 41d985b51..13ccc8f0a 100644
--- a/prisma/migrations/20250304121322_custom_domains/migration.sql
+++ b/prisma/migrations/20250304121322_custom_domains/migration.sql
@@ -12,6 +12,7 @@ CREATE TABLE "CustomDomain" (
"verificationCname" TEXT,
"verificationCnameValue" TEXT,
"verificationTxt" TEXT,
+ "failedAttempts" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "CustomDomain_pkey" PRIMARY KEY ("id")
);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 973a18abf..da7939ad5 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1233,6 +1233,7 @@ model CustomDomain {
verificationCname String?
verificationCnameValue String?
verificationTxt String?
+ failedAttempts Int @default(0)
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
@@index([domain])
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 1e28f7d8c..3cd7db86b 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -48,6 +48,16 @@ export async function domainVerification () {
if (sslState) data.sslState = sslState
}
+ // Delete domain if it verification has failed 5 times
+ if (data.dnsState === 'FAILED' || data.sslState === 'FAILED') {
+ data.attempts += 1
+ if (data.attempts >= 5) {
+ return models.customDomain.delete({ where: { id: domain.id } })
+ }
+ } else {
+ data.attempts = 0
+ }
+
await models.customDomain.update({ where: { id: domain.id }, data })
} catch (error) {
// TODO: this declares any error as a DNS verification error, we should also consider SSL verification errors
From 9fbf794c6629c50049914e45824e7aef8c9bf751 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Mon, 24 Mar 2025 08:52:38 +0000
Subject: [PATCH 24/74] handle isCustomDomain server-level
---
api/ssrApollo.js | 3 ++
components/nav/common.js | 8 ++--
components/nav/index.js | 5 ++-
components/territory-domains.js | 17 +++++++-
components/territory-header.js | 8 +---
lib/url.js | 14 -------
middleware.js | 2 +-
pages/_app.js | 69 +++++++++++++++++----------------
8 files changed, 64 insertions(+), 62 deletions(-)
diff --git a/api/ssrApollo.js b/api/ssrApollo.js
index 7af73317e..4eac61589 100644
--- a/api/ssrApollo.js
+++ b/api/ssrApollo.js
@@ -151,6 +151,8 @@ export function getGetServerSideProps (
const client = await getSSRApolloClient({ req, res })
+ const customDomain = req.headers.host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
+
let { data: { me } } = await client.query({ query: ME })
// required to redirect to /signup on page reload
@@ -215,6 +217,7 @@ export function getGetServerSideProps (
return {
props: {
...props,
+ customDomain,
me,
price,
blockHeight,
diff --git a/components/nav/common.js b/components/nav/common.js
index 1ae5aecdc..df810db5b 100644
--- a/components/nav/common.js
+++ b/components/nav/common.js
@@ -25,6 +25,8 @@ import { useWallets } from '@/wallets/index'
import SwitchAccountList, { useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal'
import { numWithUnits } from '@/lib/format'
+import { useDomain } from '@/components/territory-domains'
+
export function Brand ({ className }) {
return (
@@ -287,11 +289,7 @@ function LogoutObstacle ({ onClose }) {
const { removeLocalWallets } = useWallets()
const { nextAccount } = useAccounts()
const router = useRouter()
- const [isCustomDomain, setIsCustomDomain] = useState(false)
-
- useEffect(() => {
- setIsCustomDomain(router.host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, ''))
- }, [router.host])
+ const { isCustomDomain } = useDomain()
return (
diff --git a/components/nav/index.js b/components/nav/index.js
index da034ccbd..23badf4c0 100644
--- a/components/nav/index.js
+++ b/components/nav/index.js
@@ -3,12 +3,13 @@ import DesktopHeader from './desktop/header'
import MobileHeader from './mobile/header'
import StickyBar from './sticky-bar'
import { PriceCarouselProvider } from './price-carousel'
+import { useDomain } from '@/components/territory-domains'
export default function Navigation ({ sub }) {
const router = useRouter()
+ const { isCustomDomain } = useDomain()
+
const path = router.asPath.split('?')[0]
- // TODO: this works but it can be better
- const isCustomDomain = sub && !path.includes(`/~${sub}`)
const props = {
prefix: sub ? `/~${sub}` : '',
path,
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 204892b20..2ba3a15e9 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -6,7 +6,22 @@ import ActionTooltip from './action-tooltip'
import { useToast } from '@/components/toast'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { GET_CUSTOM_DOMAIN, SET_CUSTOM_DOMAIN } from '@/fragments/domains'
-import { useEffect } from 'react'
+import { useEffect, createContext, useContext } from 'react'
+
+// domain context
+const DomainContext = createContext({
+ isCustomDomain: false
+})
+
+export const DomainProvider = ({ isCustomDomain, children }) => {
+ return (
+
+ {children}
+
+ )
+}
+
+export const useDomain = () => useContext(DomainContext)
// TODO: clean this up, might not need all this refreshing
export default function CustomDomainForm ({ sub }) {
diff --git a/components/territory-header.js b/components/territory-header.js
index 9cd1b6fab..3d45af4e0 100644
--- a/components/territory-header.js
+++ b/components/territory-header.js
@@ -12,7 +12,7 @@ import { gql, useMutation } from '@apollo/client'
import { useToast } from './toast'
import ActionDropdown from './action-dropdown'
import { TerritoryTransferDropdownItem } from './territory-transfer'
-import { useRouter } from 'next/router'
+import { useDomain } from './territory-domains'
export function TerritoryDetails ({ sub, children }) {
return (
@@ -78,10 +78,7 @@ export function TerritoryInfo ({ sub }) {
export default function TerritoryHeader ({ sub }) {
const { me } = useMe()
const toaster = useToast()
- const router = useRouter()
- // TODO: this works but it can be better
- const path = router.asPath.split('?')[0]
- const isCustomDomain = sub && !path.includes(`/~${sub?.name}`)
+ const { isCustomDomain } = useDomain()
const [toggleMuteSub] = useMutation(
gql`
@@ -100,7 +97,6 @@ export default function TerritoryHeader ({ sub }) {
)
const isMine = Number(sub.userId) === Number(me?.id)
-
if (isCustomDomain && !isMine) return null
return (
diff --git a/lib/url.js b/lib/url.js
index 48e050320..0322c72c0 100644
--- a/lib/url.js
+++ b/lib/url.js
@@ -264,20 +264,6 @@ export function isMisleadingLink (text, href) {
return misleading
}
-export function isCustomDomain ({ req, url }) {
- const mainDomain = new URL(process.env.NEXT_PUBLIC_URL).hostname
- if (typeof window !== 'undefined') {
- return window.location.hostname !== mainDomain
- }
- if (req) {
- return req.headers.host !== mainDomain
- }
- if (url) {
- return new URL(url).hostname !== mainDomain
- }
- return false
-}
-
// eslint-disable-next-line
export const URL_REGEXP = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
diff --git a/middleware.js b/middleware.js
index 55dc69941..a747ee12e 100644
--- a/middleware.js
+++ b/middleware.js
@@ -295,7 +295,7 @@ export async function middleware (request) {
const host = request.headers.get('host')
const isAllowedDomain = await getDomainMapping(host?.toLowerCase())
if (isAllowedDomain) {
- const customDomainResp = await customDomainMiddleware(request, referrerResp)
+ const customDomainResp = await customDomainMiddleware(request, referrerResp, isAllowedDomain)
return applySecurityHeaders(customDomainResp)
}
diff --git a/pages/_app.js b/pages/_app.js
index 9c540d557..fe73b2965 100644
--- a/pages/_app.js
+++ b/pages/_app.js
@@ -23,6 +23,7 @@ import { HasNewNotesProvider } from '@/components/use-has-new-notes'
import { WebLnProvider } from '@/wallets/webln/client'
import { AccountProvider } from '@/components/account'
import { WalletsProvider } from '@/wallets/index'
+import { DomainProvider } from '@/components/territory-domains'
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
@@ -99,7 +100,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
If we are on the client, we populate the apollo cache with the
ssr data
*/
- const { apollo, ssrData, me, price, blockHeight, chainFee, ...otherProps } = props
+ const { apollo, ssrData, me, price, blockHeight, chainFee, customDomain, ...otherProps } = props
useEffect(() => {
writeQuery(client, apollo, ssrData)
}, [client, apollo, ssrData])
@@ -111,38 +112,40 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {!router?.query?.disablePrompt && }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!router?.query?.disablePrompt && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
>
From 1dd2e5883760cbc5a975d435219d9f76c255634b Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Mon, 24 Mar 2025 11:44:32 +0000
Subject: [PATCH 25/74] use AccountProvider for sync signIn and multiAuth
---
components/account.js | 11 ++++++++
components/nav/common.js | 10 +------
pages/api/auth/[...nextauth].js | 50 ++++++++++++++++++---------------
pages/api/auth/sync.js | 5 +++-
pages/login.js | 10 +++----
5 files changed, 48 insertions(+), 38 deletions(-)
diff --git a/components/account.js b/components/account.js
index f74c1b861..a50733b1b 100644
--- a/components/account.js
+++ b/components/account.js
@@ -10,6 +10,7 @@ import Link from 'next/link'
import AddIcon from '@/svgs/add-fill.svg'
import { MultiAuthErrorBanner } from '@/components/banners'
import { cookieOptions } from '@/lib/auth'
+import { signIn } from 'next-auth/react'
const AccountContext = createContext()
@@ -21,6 +22,16 @@ export const AccountProvider = ({ children }) => {
const [accounts, setAccounts] = useState([])
const [meAnon, setMeAnon] = useState(true)
const [errors, setErrors] = useState([])
+ const router = useRouter()
+
+ // TODO: alternative to this, for test only
+ useEffect(() => {
+ console.log(router.query)
+ if (router.query.type === 'sync') {
+ console.log('signing in with sync')
+ signIn('sync', { token: router.query.token, callbackUrl: router.query.callbackUrl, multiAuth: router.query.multiAuth, redirect: false })
+ }
+ }, [])
const updateAccountsFromCookie = useCallback(() => {
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
diff --git a/components/nav/common.js b/components/nav/common.js
index df810db5b..74948384f 100644
--- a/components/nav/common.js
+++ b/components/nav/common.js
@@ -12,7 +12,7 @@ import NoteIcon from '../../svgs/notification-4-fill.svg'
import { useMe } from '../me'
import { abbrNum } from '../../lib/format'
import { useServiceWorker } from '../serviceworker'
-import { signIn, signOut } from 'next-auth/react'
+import { signOut } from 'next-auth/react'
import Badges from '../badge'
import { randInRange } from '../../lib/rand'
import { useLightning } from '../lightning'
@@ -255,14 +255,6 @@ export function SignUpButton ({ className = 'py-0', width }) {
export default function LoginButton () {
const router = useRouter()
- // TODO: alternative to this, for test only
- useEffect(() => {
- console.log(router.query)
- if (router.query.type === 'sync') {
- signIn('sync', { token: router.query.token, callbackUrl: router.query.callbackUrl, redirect: false })
- }
- }, [router.query])
-
const handleLogin = useCallback(async () => {
// normal login on main domain
await router.push({
diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js
index 82f2c05a9..b5fd38c0a 100644
--- a/pages/api/auth/[...nextauth].js
+++ b/pages/api/auth/[...nextauth].js
@@ -128,6 +128,7 @@ function getCallbacks (req, res) {
if (user && req && res) {
// add multi_auth cookie for user that just logged in
+ console.log('adding multi_auth cookie')
const secret = process.env.NEXTAUTH_SECRET
const jwt = await encodeJWT({ token, secret })
const me = await prisma.user.findUnique({ where: { id: token.id } })
@@ -222,6 +223,31 @@ async function nostrEventAuth (event) {
return { k1, pubkey }
}
+const syncAuth = async (token, req, res) => {
+ try {
+ const verificationToken = await prisma.verificationToken.findUnique({ where: { token } })
+ if (!verificationToken) return null
+
+ // has to be a sync token
+ if (!verificationToken.identifier.startsWith('sync:')) return null
+
+ // sync has user id
+ const userId = parseInt(verificationToken.identifier.split(':')[1], 10)
+ if (!userId) return null
+
+ // delete the token to prevent reuse
+ await prisma.verificationToken.delete({
+ where: { id: verificationToken.id }
+ })
+ if (new Date() > verificationToken.expires) return null
+
+ return await prisma.user.findUnique({ where: { id: userId } })
+ } catch (error) {
+ console.error('auth sync error:', error)
+ return null
+ }
+}
+
/** @type {import('next-auth/providers').Provider[]} */
const getProviders = (req, res) => [
CredentialsProvider({
@@ -253,29 +279,7 @@ const getProviders = (req, res) => [
token: { label: 'token', type: 'text' }
},
authorize: async ({ token }, req) => {
- try {
- const verificationToken = await prisma.verificationToken.findUnique({ where: { token } })
- if (!verificationToken) return null
-
- // has to be a sync token
- if (!verificationToken.identifier.startsWith('sync:')) return null
-
- // sync has user id
- const userId = parseInt(verificationToken.identifier.split(':')[1], 10)
- if (!userId) return null
-
- // delete the token to prevent reuse
- await prisma.verificationToken.delete({
- where: { id: verificationToken.id }
- })
- if (new Date() > verificationToken.expires) return null
-
- // return the user
- return await prisma.user.findUnique({ where: { id: userId } })
- } catch (error) {
- console.error('auth sync error:', error)
- return null
- }
+ return await syncAuth(token, req, res)
}
}),
GitHubProvider({
diff --git a/pages/api/auth/sync.js b/pages/api/auth/sync.js
index 877ce7128..3fb7a389a 100644
--- a/pages/api/auth/sync.js
+++ b/pages/api/auth/sync.js
@@ -3,7 +3,7 @@ import { getAuthOptions, generateRandomString } from './[...nextauth]'
import prisma from '@/api/models'
export default async function handler (req, res) {
- const { redirectUrl } = req.query
+ const { redirectUrl, multiAuth } = req.query
if (!redirectUrl) {
return res.status(400).json({ error: 'Missing redirectUrl parameter' })
}
@@ -29,6 +29,9 @@ export default async function handler (req, res) {
const customDomainCallback = new URL('/?type=sync', redirectUrl)
customDomainCallback.searchParams.set('token', token)
customDomainCallback.searchParams.set('callbackUrl', redirectUrl)
+ if (multiAuth) {
+ customDomainCallback.searchParams.set('multiAuth', multiAuth)
+ }
return res.redirect(customDomainCallback.toString())
} catch (error) {
diff --git a/pages/login.js b/pages/login.js
index 5fb5450e1..f6c871d22 100644
--- a/pages/login.js
+++ b/pages/login.js
@@ -6,7 +6,7 @@ import { StaticLayout } from '@/components/layout'
import Login from '@/components/login'
import { isExternal } from '@/lib/url'
-export async function getServerSideProps ({ req, res, query: { callbackUrl, multiAuth = false, error = null, domain } }) {
+export async function getServerSideProps ({ req, res, query: { callbackUrl, multiAuth = false, error = null, domain = null } }) {
let session = await getServerSession(req, res, getAuthOptions(req))
// required to prevent infinite redirect loops if we switch to anon
@@ -31,7 +31,7 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, mult
// TODO: custom domain mapping security
if (domain) {
- callbackUrl = '/api/auth/sync?redirectUrl=https://' + domain
+ callbackUrl = '/api/auth/sync' + (multiAuth ? '?multiAuth=true' : '') + '&redirectUrl=https://' + domain
}
console.log('callbackUrl', callbackUrl)
@@ -65,9 +65,9 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, mult
}
}
-function LoginFooter ({ callbackUrl }) {
+function LoginFooter ({ callbackUrl, multiAuth }) {
return (
- New to town? sign up
+ New to town? sign up
)
}
@@ -86,7 +86,7 @@ export default function LoginPage (props) {
return (
}
+ Footer={() => }
Header={() => }
signin
{...props}
From bd78954c2d3eb6514d32787e57660a409500028d Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Mon, 24 Mar 2025 19:09:35 +0000
Subject: [PATCH 26/74] compose auth sync callback with search params
---
pages/login.js | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/pages/login.js b/pages/login.js
index 6b6db0de4..684b064e5 100644
--- a/pages/login.js
+++ b/pages/login.js
@@ -32,7 +32,12 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, mult
// TODO: custom domain mapping security
if (domain) {
- callbackUrl = '/api/auth/sync' + (multiAuth ? '?multiAuth=true' : '') + '&redirectUrl=https://' + domain
+ const params = new URLSearchParams()
+ params.set('redirectUrl', 'https://' + encodeURIComponent(domain))
+ if (multiAuth) {
+ params.set('multiAuth', multiAuth)
+ }
+ callbackUrl = '/api/auth/sync?' + params.toString()
}
console.log('callbackUrl', callbackUrl)
From 413387d4cfe73274ea19997f31bd4ef6e1465818 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Mon, 24 Mar 2025 23:46:03 +0000
Subject: [PATCH 27/74] add comments to domainVerification worker
---
worker/domainVerification.js | 53 +++++++++++++++++++++++++-----------
1 file changed, 37 insertions(+), 16 deletions(-)
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 3cd7db86b..7d97beebe 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -1,32 +1,49 @@
import createPrisma from '@/lib/create-prisma'
import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus, getValidationValues } from '@/lib/domains'
-// TODO: Add comments
export async function domainVerification () {
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
try {
- const domains = await models.customDomain.findMany({ where: { NOT: { AND: [{ dnsState: 'VERIFIED' }, { sslState: 'VERIFIED' }] } } })
+ const domains = await models.customDomain.findMany({
+ where: {
+ NOT: {
+ AND: [{ dnsState: 'VERIFIED' }, { sslState: 'VERIFIED' }]
+ }
+ },
+ orderBy: {
+ failedAttempts: 'asc' // process domains with less failed attempts first
+ }
+ })
for (const domain of domains) {
try {
+ // set lastVerifiedAt to now
const data = { ...domain, lastVerifiedAt: new Date() }
- // DNS verification
- if (data.dnsState === 'PENDING' || data.dnsState === 'FAILED') {
- const { txtValid, cnameValid } = await verifyDomainDNS(data.domain, domain.verificationTxt)
+
+ // DNS verification on pending or failed domains
+ if (data.dnsState !== 'VERIFIED') {
+ const { txtValid, cnameValid } = await verifyDomainDNS(data.domain, data.verificationTxt)
console.log(`${data.domain}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
+
+ // update dnsState to VERIFIED if both TXT and CNAME are valid, otherwise set to FAILED
data.dnsState = txtValid && cnameValid ? 'VERIFIED' : 'FAILED'
}
- // SSL issuing
+ // issue SSL certificate for verified domains, if we didn't already or we failed to issue it
if (data.dnsState === 'VERIFIED' && (!data.certificateArn || data.sslState === 'FAILED')) {
+ // use ACM to issue a certificate for the domain
const certificateArn = await issueDomainCertificate(data.domain)
console.log(`${data.domain}: Certificate issued: ${certificateArn}`)
if (certificateArn) {
+ // get the status of the certificate
const sslState = await checkCertificateStatus(certificateArn)
console.log(`${data.domain}: Issued certificate status: ${sslState}`)
+ // if we didn't validate already, obtain the ACM CNAME values for the certificate validation
if (sslState !== 'VERIFIED') {
try {
+ // obtain the ACM CNAME values for the certificate validation
+ // ACM will use these values to verify the domain
const { cname, value } = await getValidationValues(certificateArn)
data.verificationCname = cname
data.verificationCnameValue = value
@@ -34,44 +51,48 @@ export async function domainVerification () {
console.error(`Failed to get validation values for domain ${data.domain}:`, error)
}
}
+ // update the sslState with the status of the certificate
if (sslState) data.sslState = sslState
data.certificateArn = certificateArn
} else {
+ // if we failed to issue the certificate, set the sslState to FAILED
data.sslState = 'FAILED'
}
}
- // SSL checking
+ // update the status of the certificate while pending
if (data.dnsState === 'VERIFIED' && data.sslState !== 'VERIFIED') {
const sslState = await checkCertificateStatus(data.certificateArn)
console.log(`${data.domain}: Certificate status: ${sslState}`)
if (sslState) data.sslState = sslState
}
- // Delete domain if it verification has failed 5 times
+ // delete domain if any verification has failed 5 times
if (data.dnsState === 'FAILED' || data.sslState === 'FAILED') {
- data.attempts += 1
- if (data.attempts >= 5) {
+ data.failedAttempts += 1
+ if (data.failedAttempts >= 5) {
return models.customDomain.delete({ where: { id: domain.id } })
}
} else {
- data.attempts = 0
+ data.failedAttempts = 0
}
+ // update the domain with the new status
await models.customDomain.update({ where: { id: domain.id }, data })
} catch (error) {
- // TODO: this declares any error as a DNS verification error, we should also consider SSL verification errors
console.error(`Failed to verify domain ${domain.domain}:`, error)
-
- // TODO: DNS inconcistencies can happen, we should retry at least 3 times before marking it as FAILED
// Update to FAILED on any error
await models.customDomain.update({
where: { id: domain.id },
- data: { dnsState: 'FAILED', lastVerifiedAt: new Date() }
+ data: {
+ dnsState: 'FAILED',
+ lastVerifiedAt: new Date(),
+ failedAttempts: domain.failedAttempts + 1
+ }
})
}
}
} catch (error) {
- console.error(error)
+ console.error('cannot verify domains:', error)
}
}
From 987245d77c4e9f21face6efb31ca7fd7fdeeb0c4 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 25 Mar 2025 13:40:47 +0000
Subject: [PATCH 28/74] comments: middleware, domain verification worker
---
middleware.js | 43 ++++++++++++++++++------------------
worker/domainVerification.js | 2 ++
2 files changed, 24 insertions(+), 21 deletions(-)
diff --git a/middleware.js b/middleware.js
index a747ee12e..3912a9b04 100644
--- a/middleware.js
+++ b/middleware.js
@@ -12,15 +12,14 @@ const SN_REFERRER = 'sn_referrer'
const SN_REFERRER_NONCE = 'sn_referrer_nonce'
// key for referred pages
const SN_REFEREE_LANDING = 'sn_referee_landing'
-
+// rewrite to ~subname paths
const TERRITORY_PATHS = ['/~', '/recent', '/random', '/top', '/post', '/edit']
-const NO_REWRITE_PATHS = ['/api', '/_next', '/_error', '/404', '/500', '/offline', '/static', '/logout']
// TODO: move this to a separate file
// fetch custom domain mappings from our API, caching it for 5 minutes
export const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings () {
const url = `${process.env.NEXT_PUBLIC_URL}/api/domains`
- console.log('fetching domain mappings from', url)
+ console.log('fetching domain mappings from', url) // TEST
try {
const response = await fetch(url)
if (!response.ok) {
@@ -46,43 +45,43 @@ export async function getDomainMapping (domain) {
return domainMappings?.[domain]
}
+// Redirects and rewrites for custom domains
export async function customDomainMiddleware (request, referrerResp, domain) {
const host = request.headers.get('host')
const referer = request.headers.get('referer')
const url = request.nextUrl.clone()
const pathname = url.pathname
const mainDomain = process.env.NEXT_PUBLIC_URL + '/'
+
+ // TEST
console.log('host', host)
console.log('mainDomain', mainDomain)
-
console.log('referer', referer)
-
- // todo: obviously this is not the best way to do this
- if (NO_REWRITE_PATHS.some(p => pathname.startsWith(p)) || pathname.includes('.')) {
- return NextResponse.next()
- }
-
console.log('pathname', pathname)
console.log('query', url.searchParams)
+ // Auth sync redirects with domain and optional callbackUrl and multiAuth params
if (pathname === '/login' || pathname === '/signup') {
const redirectUrl = new URL(pathname, mainDomain)
redirectUrl.searchParams.set('domain', host)
- redirectUrl.searchParams.set('callbackUrl', url.searchParams.get('callbackUrl'))
+ if (url.searchParams.get('callbackUrl')) {
+ redirectUrl.searchParams.set('callbackUrl', url.searchParams.get('callbackUrl'))
+ }
if (url.searchParams.get('multiAuth')) {
redirectUrl.searchParams.set('multiAuth', url.searchParams.get('multiAuth'))
}
const redirectResp = NextResponse.redirect(redirectUrl)
- return applyReferrerCookies(redirectResp, referrerResp)
+ return applyReferrerCookies(redirectResp, referrerResp) // apply referrer cookies to the redirect
}
- // if the url contains the territory path, remove it
+ // If trying to access a ~subname path, rewrite to /
if (pathname.startsWith(`/~${domain.subName}`)) {
// remove the territory prefix from the path
const cleanPath = pathname.replace(`/~${domain.subName}`, '') || '/'
+ // TEST
console.log('Redirecting to clean path:', cleanPath)
const redirectResp = NextResponse.redirect(new URL(cleanPath + url.search, url.origin))
- return applyReferrerCookies(redirectResp, referrerResp)
+ return applyReferrerCookies(redirectResp, referrerResp) // apply referrer cookies to the redirect
}
// if coming from main domain, handle auth automatically
@@ -95,18 +94,20 @@ export async function customDomainMiddleware (request, referrerResp, domain) {
}
} */
- const internalUrl = new URL(url)
- // rewrite to the territory path if we're at the root
+ // If we're at the root or a territory path, rewrite to the territory path
if (pathname === '/' || TERRITORY_PATHS.some(p => pathname.startsWith(p))) {
+ const internalUrl = new URL(url)
internalUrl.pathname = `/~${domain.subName}${pathname === '/' ? '' : pathname}`
+ console.log('Rewrite to:', internalUrl.pathname)
+ // rewrite to the territory path
+ const resp = NextResponse.rewrite(internalUrl)
+ return applyReferrerCookies(resp, referrerResp) // apply referrer cookies to the rewrite
}
- console.log('Rewrite to:', internalUrl.pathname)
- // rewrite to the territory path
- const resp = NextResponse.rewrite(internalUrl)
- // copy referrer cookies to the rewritten response
- return applyReferrerCookies(resp, referrerResp)
+
+ return NextResponse.next() // continue if we don't need to rewrite or redirect
}
+// UNUSED
// TODO: dirty of previous iterations, refactor
// UNSAFE UNSAFE UNSAFE tokens are visible in the URL
// Redirect to Auth Sync if user is not logged in or has no multi_auth sessions
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 7d97beebe..b1b18f4f3 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -1,6 +1,8 @@
import createPrisma from '@/lib/create-prisma'
import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus, getValidationValues } from '@/lib/domains'
+// This worker verifies the DNS and SSL certificates for domains that are pending or failed
+// It will also delete domains that have failed to verify 5 times
export async function domainVerification () {
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
From f0649e8d33898a743e1386e5539f53ccfa607812 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 25 Mar 2025 14:06:24 +0000
Subject: [PATCH 29/74] comments: auth sync, domain mapper, login page; fix:
multiAuth signup
---
pages/api/auth/sync.js | 11 ++++++++---
pages/api/domains/index.js | 1 +
pages/login.js | 13 +++++++++----
3 files changed, 18 insertions(+), 7 deletions(-)
diff --git a/pages/api/auth/sync.js b/pages/api/auth/sync.js
index 3fb7a389a..f1c632880 100644
--- a/pages/api/auth/sync.js
+++ b/pages/api/auth/sync.js
@@ -2,17 +2,20 @@ import { getServerSession } from 'next-auth/next'
import { getAuthOptions, generateRandomString } from './[...nextauth]'
import prisma from '@/api/models'
+// API Endpoint for syncing a user's session to a custom domain
export default async function handler (req, res) {
const { redirectUrl, multiAuth } = req.query
if (!redirectUrl) {
return res.status(400).json({ error: 'Missing redirectUrl parameter' })
}
- const session = await getServerSession(req, res, getAuthOptions(req, res))
+ const mainDomain = process.env.NEXT_PUBLIC_MAIN_DOMAIN
+ // get the user's session
+ const session = await getServerSession(req, res, getAuthOptions(req, res))
if (!session) {
- // TODO: redirect to login page, this goes to login overlapping other paths
- return res.redirect(redirectUrl + '/login?callbackUrl=' + encodeURIComponent(redirectUrl))
+ // redirect to the login page, middleware will handle the rest
+ return res.redirect(mainDomain + '/login?callbackUrl=' + encodeURIComponent(redirectUrl))
}
try {
@@ -26,6 +29,7 @@ export default async function handler (req, res) {
}
})
+ // Account Provider will handle this sync request
const customDomainCallback = new URL('/?type=sync', redirectUrl)
customDomainCallback.searchParams.set('token', token)
customDomainCallback.searchParams.set('callbackUrl', redirectUrl)
@@ -33,6 +37,7 @@ export default async function handler (req, res) {
customDomainCallback.searchParams.set('multiAuth', multiAuth)
}
+ // redirect to the custom domain callback
return res.redirect(customDomainCallback.toString())
} catch (error) {
console.error('Error generating token:', error)
diff --git a/pages/api/domains/index.js b/pages/api/domains/index.js
index c0357c311..3c89fbe47 100644
--- a/pages/api/domains/index.js
+++ b/pages/api/domains/index.js
@@ -1,6 +1,7 @@
import prisma from '@/api/models'
// TODO: Authentication for this?
+// API Endpoint for getting all VERIFIED custom domains, used by a cachedFetcher
export default async function handler (req, res) {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET')
diff --git a/pages/login.js b/pages/login.js
index 684b064e5..549a0ebf7 100644
--- a/pages/login.js
+++ b/pages/login.js
@@ -31,17 +31,16 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, mult
}
// TODO: custom domain mapping security
+ // If we're coming from a custom domain, set as callbackUrl the auth sync endpoint
if (domain) {
const params = new URLSearchParams()
params.set('redirectUrl', 'https://' + encodeURIComponent(domain))
- if (multiAuth) {
+ if (multiAuth) { // take care of multiAuth if requested
params.set('multiAuth', multiAuth)
}
callbackUrl = '/api/auth/sync?' + params.toString()
}
- console.log('callbackUrl', callbackUrl)
-
if (session && callbackUrl && !multiAuth) {
// in the case of auth linking we want to pass the error back to settings
// in the case of multi auth, don't redirect if there is already a session
@@ -72,8 +71,14 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, mult
}
function LoginFooter ({ callbackUrl, multiAuth }) {
+ const query = {
+ callbackUrl
+ }
+ if (multiAuth) { // multiAuth can be optional
+ query.multiAuth = multiAuth
+ }
return (
- New to town? sign up
+ New to town? sign up
)
}
From 260c2e6d4b697bf3f86cc147dc02db4ea0b3a6cc Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 25 Mar 2025 14:25:30 +0000
Subject: [PATCH 30/74] comments: territory domains edit, auth, small comments
---
api/ssrApollo.js | 1 +
components/territory-domains.js | 24 +++++++++++-------------
lib/domains.js | 14 +++++++++-----
lib/validate.js | 2 +-
pages/api/auth/[...nextauth].js | 1 +
5 files changed, 23 insertions(+), 19 deletions(-)
diff --git a/api/ssrApollo.js b/api/ssrApollo.js
index 9b104b374..01cd043ce 100644
--- a/api/ssrApollo.js
+++ b/api/ssrApollo.js
@@ -152,6 +152,7 @@ export function getGetServerSideProps (
const client = await getSSRApolloClient({ req, res })
+ // custom domain SSR check
const customDomain = req.headers.host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
let { data: { me } } = await client.query({ query: ME })
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 2ba3a15e9..634de70be 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -8,7 +8,7 @@ import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { GET_CUSTOM_DOMAIN, SET_CUSTOM_DOMAIN } from '@/fragments/domains'
import { useEffect, createContext, useContext } from 'react'
-// domain context
+// Domain context for custom domains
const DomainContext = createContext({
isCustomDomain: false
})
@@ -27,14 +27,13 @@ export const useDomain = () => useContext(DomainContext)
export default function CustomDomainForm ({ sub }) {
const [setCustomDomain] = useMutation(SET_CUSTOM_DOMAIN)
+ // Get the custom domain and poll for changes
const { data, startPolling, stopPolling, refetch } = useQuery(GET_CUSTOM_DOMAIN, SSR
? {}
- : {
- variables: { subName: sub.name },
- pollInterval: NORMAL_POLL_INTERVAL
- })
+ : { variables: { subName: sub.name } })
const toaster = useToast()
+ // Stop polling when the domain is verified
useEffect(() => {
if (data?.customDomain?.sslState === 'VERIFIED' &&
data?.customDomain?.dnsState === 'VERIFIED') {
@@ -42,6 +41,7 @@ export default function CustomDomainForm ({ sub }) {
}
}, [data, stopPolling])
+ // Update the custom domain
const onSubmit = async ({ domain }) => {
try {
await setCustomDomain({
@@ -85,36 +85,34 @@ export default function CustomDomainForm ({ sub }) {
return (
+ {/* TODO: move this to a separate sub component */}
{(data?.customDomain?.dnsState === 'PENDING' || data?.customDomain?.dnsState === 'FAILED') && (
<>
Verify your domain
diff --git a/lib/domains.js b/lib/domains.js
index 596404543..964577ecf 100644
--- a/lib/domains.js
+++ b/lib/domains.js
@@ -1,7 +1,7 @@
import { requestCertificate, getCertificateStatus, describeCertificate } from '@/api/acm'
import { promises as dnsPromises } from 'node:dns'
-// TODO: skeleton
+// Issue a certificate for a custom domain
export async function issueDomainCertificate (domainName) {
try {
const certificateArn = await requestCertificate(domainName)
@@ -12,7 +12,7 @@ export async function issueDomainCertificate (domainName) {
}
}
-// TODO: skeleton
+// Check the status of a certificate for a custom domain
export async function checkCertificateStatus (certificateArn) {
let certStatus
try {
@@ -36,6 +36,7 @@ export async function checkCertificateStatus (certificateArn) {
}
}
+// Get the details of a certificate for a custom domain
export async function certDetails (certificateArn) {
try {
const certificate = await describeCertificate(certificateArn)
@@ -46,6 +47,7 @@ export async function certDetails (certificateArn) {
}
}
+// Get the validation values for a certificate for a custom domain
// TODO: Test with real values, localstack don't have this info until the certificate is issued
export async function getValidationValues (certificateArn) {
const certificate = await certDetails(certificateArn)
@@ -56,17 +58,19 @@ export async function getValidationValues (certificateArn) {
}
}
-export async function verifyDomainDNS (domainName, verificationTxt, cname = 'parallel.soxa.dev') {
+// Verify the DNS records for a custom domain
+export async function verifyDomainDNS (domainName, verificationTxt, verificationCname) {
+ const cname = verificationCname || process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
const result = {
txtValid: false,
cnameValid: false,
error: null
}
- dnsPromises.setServers([process.env.DNS_RESOLVER || '1.1.1.1']) // cloudflare DNS resolver
+ // by default use cloudflare DNS resolver
+ dnsPromises.setServers([process.env.DNS_RESOLVER || '1.1.1.1'])
// TXT Records checking
- // TODO: we should give a randomly generated string to the user and check if it's included in the TXT record
try {
const txtRecords = await dnsPromises.resolve(domainName, 'TXT')
const txtText = txtRecords.flat().join(' ')
diff --git a/lib/validate.js b/lib/validate.js
index 83e8fce4c..325bbd5fd 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -346,7 +346,7 @@ export function territoryTransferSchema ({ me, ...args }) {
})
}
-// TODO: validate domain
+// Custom Domain validation schema
export function customDomainSchema (args) {
return object({
domain: string().matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js
index b5fd38c0a..6e54ab643 100644
--- a/pages/api/auth/[...nextauth].js
+++ b/pages/api/auth/[...nextauth].js
@@ -223,6 +223,7 @@ async function nostrEventAuth (event) {
return { k1, pubkey }
}
+// Authentication Provider for syncing a user's session to a custom domain
const syncAuth = async (token, req, res) => {
try {
const verificationToken = await prisma.verificationToken.findUnique({ where: { token } })
From 310e7668a8192e5effd206f0980ec70865b359e1 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 25 Mar 2025 14:27:34 +0000
Subject: [PATCH 31/74] restore nav commons
---
components/nav/common.js | 14 +++++---------
1 file changed, 5 insertions(+), 9 deletions(-)
diff --git a/components/nav/common.js b/components/nav/common.js
index 74948384f..2fe280763 100644
--- a/components/nav/common.js
+++ b/components/nav/common.js
@@ -254,14 +254,10 @@ export function SignUpButton ({ className = 'py-0', width }) {
export default function LoginButton () {
const router = useRouter()
-
- const handleLogin = useCallback(async () => {
- // normal login on main domain
- await router.push({
- pathname: '/login',
- query: { callbackUrl: window.location.origin + router.asPath }
- })
- }, [router])
+ const handleLogin = useCallback(async pathname => await router.push({
+ pathname,
+ query: { callbackUrl: window.location.origin + router.asPath }
+ }), [router])
return (
handleLogin('/login')}
>
login
From 934fc3f93e98a8616c6546922b24b1051c60a4b1 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 25 Mar 2025 19:12:48 +0000
Subject: [PATCH 32/74] cleanup: territory domains
---
components/territory-domains.js | 28 ++++++++++++++--------------
1 file changed, 14 insertions(+), 14 deletions(-)
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 634de70be..bb8c25f59 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -33,10 +33,11 @@ export default function CustomDomainForm ({ sub }) {
: { variables: { subName: sub.name } })
const toaster = useToast()
+ const { domain, sslState, dnsState, lastVerifiedAt } = data?.customDomain || {}
+
// Stop polling when the domain is verified
useEffect(() => {
- if (data?.customDomain?.sslState === 'VERIFIED' &&
- data?.customDomain?.dnsState === 'VERIFIED') {
+ if (sslState === 'VERIFIED' && dnsState === 'VERIFIED') {
stopPolling()
}
}, [data, stopPolling])
@@ -44,6 +45,7 @@ export default function CustomDomainForm ({ sub }) {
// Update the custom domain
const onSubmit = async ({ domain }) => {
try {
+ stopPolling()
await setCustomDomain({
variables: {
subName: sub.name,
@@ -54,7 +56,7 @@ export default function CustomDomainForm ({ sub }) {
startPolling(NORMAL_POLL_INTERVAL)
toaster.success('domain updated successfully')
} catch (error) {
- toaster.error('failed to update domain', { error })
+ toaster.danger('failed to update domain', { error })
}
}
@@ -69,8 +71,8 @@ export default function CustomDomainForm ({ sub }) {
}
}
- const getSSLStatusBadge = (sslState) => {
- switch (sslState) {
+ const getSSLStatusBadge = (status) => {
+ switch (status) {
case 'VERIFIED':
return SSL verified
case 'PENDING':
@@ -84,9 +86,7 @@ export default function CustomDomainForm ({ sub }) {
return (
+ TXT
+
+ Host:
{domain || 'www'}
+ Value: {data?.customDomain.verificationTxt}
+
>
)}
{sslState === 'PENDING' && (
<>
- SSL verification pending
+ Step 2: Prepare your domain for SSL
We issued an SSL certificate for your domain. To validate it, add the following CNAME record:
CNAME:
- Host: ${data?.customDomain.verificationCname}
- Value: ${data?.customDomain.verificationCnameValue}
+ Host: ${data?.customDomain.verificationCname || 'waiting for SSL certificate'}
+ Value: ${data?.customDomain.verificationCnameValue || 'waiting for SSL certificate'}
>
)}
diff --git a/lib/validate.js b/lib/validate.js
index 325bbd5fd..0e39a9078 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -349,8 +349,8 @@ export function territoryTransferSchema ({ me, ...args }) {
// Custom Domain validation schema
export function customDomainSchema (args) {
return object({
- domain: string().matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
- message: 'enter a valid domain name (e.g., example.com)'
+ domain: string().matches(/^(?:[a-z0-9-]+\.){2,}[a-z]{2,}$/, {
+ message: 'enter a valid domain name (e.g., www.example.com)'
}).nullable()
})
}
From 272d81cec051b4760c59f4664e3e4609c1b7f5b2 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Thu, 27 Mar 2025 09:59:20 +0000
Subject: [PATCH 36/74] tweaks to CNAME validation UI/UX, add custom domain to
territory info
---
components/territory-domains.js | 14 ++++++++------
components/territory-header.js | 6 ++++++
2 files changed, 14 insertions(+), 6 deletions(-)
diff --git a/components/territory-domains.js b/components/territory-domains.js
index dff9c4de4..823ad149b 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -47,7 +47,7 @@ export const DomainProvider = ({ isCustomDomain: initialIsCustomDomain, children
export const useDomain = () => useContext(DomainContext)
-// TODO: clean this up, might not need all this refreshing
+// TODO: clean this up, might not need all this refreshing, plus all this polling is not done correctly
export default function CustomDomainForm ({ sub }) {
const [setCustomDomain] = useMutation(SET_CUSTOM_DOMAIN)
@@ -63,6 +63,8 @@ export default function CustomDomainForm ({ sub }) {
useEffect(() => {
if (sslState === 'VERIFIED' && dnsState === 'VERIFIED') {
stopPolling()
+ } else {
+ startPolling(NORMAL_POLL_INTERVAL)
}
}, [data, stopPolling])
@@ -157,11 +159,11 @@ export default function CustomDomainForm ({ sub }) {
<>
Step 2: Prepare your domain for SSL
We issued an SSL certificate for your domain. To validate it, add the following CNAME record:
-
- CNAME:
- Host: ${data?.customDomain.verificationCname || 'waiting for SSL certificate'}
- Value: ${data?.customDomain.verificationCnameValue || 'waiting for SSL certificate'}
-
+ CNAME
+
+ Host:
{data?.customDomain.verificationCname || 'waiting for SSL certificate'}
+ Value: {data?.customDomain.verificationCnameValue || 'waiting for SSL certificate'}
+
>
)}
diff --git a/components/territory-header.js b/components/territory-header.js
index 3d45af4e0..c7e9436fb 100644
--- a/components/territory-header.js
+++ b/components/territory-header.js
@@ -69,6 +69,12 @@ export function TerritoryInfo ({ sub }) {
{numWithUnits(sub.replyCost)}
+ {sub.customDomain && (
+
+ website
+ {sub.customDomain.domain}
+
+ )}
>
From 53c2522f4165305d8e5f7418088b95c88f4154ac Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Fri, 30 May 2025 11:30:48 -0500
Subject: [PATCH 37/74] atomic territory domains, move custom domain settings
to main domain
---
components/territory-domains.js | 177 +++++++++++++++++---------------
components/territory-form.js | 11 +-
2 files changed, 103 insertions(+), 85 deletions(-)
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 823ad149b..24b74e0ec 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -47,6 +47,82 @@ export const DomainProvider = ({ isCustomDomain: initialIsCustomDomain, children
export const useDomain = () => useContext(DomainContext)
+const getStatusBadge = (status) => {
+ switch (status) {
+ case 'VERIFIED':
+ return DNS verified
+ case 'PENDING':
+ return DNS pending
+ case 'FAILED':
+ return DNS failed
+ }
+}
+
+const getSSLStatusBadge = (status) => {
+ switch (status) {
+ case 'VERIFIED':
+ return SSL verified
+ case 'PENDING':
+ return SSL pending
+ case 'FAILED':
+ return SSL failed
+ case 'WAITING':
+ return SSL waiting
+ }
+}
+
+export function DomainLabel ({ customDomain }) {
+ const { domain, dnsState, sslState, lastVerifiedAt } = customDomain || {}
+ return (
+
+
custom domain
+ {domain && (
+
+
+ {getStatusBadge(dnsState)}
+ {getSSLStatusBadge(sslState)}
+
+
+ )}
+
+ )
+}
+
+export function DomainGuidelines ({ customDomain }) {
+ const { domain, dnsState, sslState, verificationTxt, verificationCname, verificationCnameValue } = customDomain || {}
+ return (
+ <>
+ {(dnsState && dnsState !== 'VERIFIED') && (
+ <>
+ Step 1: Verify your domain
+ Add the following DNS records to verify ownership of your domain:
+ CNAME
+
+ Host:
{domain || 'www'}
+ Value: stacker.news
+
+ TXT
+
+ Host:
{domain || 'www'}
+ Value: {verificationTxt}
+
+ >
+ )}
+ {sslState === 'PENDING' && (
+ <>
+ Step 2: Prepare your domain for SSL
+ We issued an SSL certificate for your domain. To validate it, add the following CNAME record:
+ CNAME
+
+ Host:
{verificationCname || 'waiting for SSL certificate'}
+ Value: {verificationCnameValue || 'waiting for SSL certificate'}
+
+ >
+ )}
+ >
+ )
+}
+
// TODO: clean this up, might not need all this refreshing, plus all this polling is not done correctly
export default function CustomDomainForm ({ sub }) {
const [setCustomDomain] = useMutation(SET_CUSTOM_DOMAIN)
@@ -57,7 +133,7 @@ export default function CustomDomainForm ({ sub }) {
: { variables: { subName: sub.name } })
const toaster = useToast()
- const { domain, sslState, dnsState, lastVerifiedAt } = data?.customDomain || {}
+ const { domain, sslState, dnsState } = data?.customDomain || {}
// Stop polling when the domain is verified
useEffect(() => {
@@ -86,86 +162,25 @@ export default function CustomDomainForm ({ sub }) {
}
}
- const getStatusBadge = (status) => {
- switch (status) {
- case 'VERIFIED':
- return DNS verified
- case 'PENDING':
- return DNS pending
- case 'FAILED':
- return DNS failed
- }
- }
-
- const getSSLStatusBadge = (status) => {
- switch (status) {
- case 'VERIFIED':
- return SSL verified
- case 'PENDING':
- return SSL pending
- case 'FAILED':
- return SSL failed
- case 'WAITING':
- return SSL waiting
- }
- }
-
return (
-
+ <>
+
+
+ >
)
}
diff --git a/components/territory-form.js b/components/territory-form.js
index 0a3da18be..21072cd48 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -14,12 +14,15 @@ import { purchasedType } from '@/lib/territory'
import { SUB } from '@/fragments/subs'
import { usePaidMutation } from './use-paid-mutation'
import { UNARCHIVE_TERRITORY, UPSERT_SUB } from '@/fragments/paidAction'
-import TerritoryDomains from './territory-domains'
+import TerritoryDomains, { useDomain } from './territory-domains'
+import Link from 'next/link'
export default function TerritoryForm ({ sub }) {
const router = useRouter()
const client = useApolloClient()
const { me } = useMe()
+ const { isCustomDomain } = useDomain()
+
const [upsertSub] = usePaidMutation(UPSERT_SUB)
const [unarchiveTerritory] = usePaidMutation(UNARCHIVE_TERRITORY)
@@ -98,8 +101,7 @@ export default function TerritoryForm ({ sub }) {
billingType: sub?.billingType || 'MONTHLY',
billingAutoRenew: sub?.billingAutoRenew || false,
moderated: sub?.moderated || false,
- nsfw: sub?.nsfw || false,
- customDomain: sub?.customDomain?.domain || ''
+ nsfw: sub?.nsfw || false
}}
schema={schema}
onSubmit={onSubmit}
@@ -288,7 +290,7 @@ export default function TerritoryForm ({ sub }) {
/>
- {sub &&
+ {sub && !isCustomDomain &&
}
@@ -307,6 +309,7 @@ export default function TerritoryForm ({ sub }) {
}
/>
}
+ {sub && isCustomDomain && domain settings on stacker.news}
)
}
From 007723cbb0aa16970ee799ac6cadf53f23dcd2cb Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Fri, 30 May 2025 14:20:46 -0500
Subject: [PATCH 38/74] refactor domain components, prepare for branding
settings
---
components/domains/branding/branding-form.js | 38 ++++++++++++++
components/domains/branding/custom-styles.js | 0
components/domains/branding/index.js | 49 +++++++++++++++++++
components/{ => domains}/territory-domains.js | 4 +-
components/form.js | 24 +++++++++
components/nav/common.js | 2 +-
components/nav/index.js | 2 +-
components/territory-form.js | 10 ++--
components/territory-header.js | 2 +-
pages/_app.js | 2 +-
10 files changed, 120 insertions(+), 13 deletions(-)
create mode 100644 components/domains/branding/branding-form.js
create mode 100644 components/domains/branding/custom-styles.js
create mode 100644 components/domains/branding/index.js
rename components/{ => domains}/territory-domains.js (98%)
diff --git a/components/domains/branding/branding-form.js b/components/domains/branding/branding-form.js
new file mode 100644
index 000000000..534ce7c3a
--- /dev/null
+++ b/components/domains/branding/branding-form.js
@@ -0,0 +1,38 @@
+import { Form, SubmitButton, ColorPicker } from '../../form'
+// import { useMutation } from '@apollo/client'
+import { useToast } from '../../toast'
+import { brandingSchema } from '@/lib/validate'
+// import { SET_TERRITORY_BRANDING } from '@/fragments/branding'
+
+export default function BrandingForm ({ sub }) {
+ // const [setTerritoryBranding] = useMutation(SET_TERRITORY_BRANDING)
+ const toaster = useToast()
+
+ const onSubmit = async (values) => {
+ try {
+ console.log(values)
+ toaster.success('Branding updated successfully')
+ } catch (error) {
+ toaster.danger('Failed to update branding', { error })
+ }
+ }
+
+ const initialValues = {
+ primaryColor: sub?.branding?.primaryColor || '#FADA5E',
+ secondaryColor: sub?.branding?.secondaryColor || '#F6911D'
+ }
+
+ // TODO: add logo and favicon upload
+ // TODO: color picker is too big
+ return (
+
+ )
+}
diff --git a/components/domains/branding/custom-styles.js b/components/domains/branding/custom-styles.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/components/domains/branding/index.js b/components/domains/branding/index.js
new file mode 100644
index 000000000..44da4989c
--- /dev/null
+++ b/components/domains/branding/index.js
@@ -0,0 +1,49 @@
+import { createContext, useContext } from 'react'
+// import { useQuery } from '@apollo/client'
+// import { useDomain } from '../territory-domains'
+// import { GET_TERRITORY_BRANDING } from '@/fragments/branding'
+// import Head from 'next/head'
+
+const defaultBranding = {
+ primaryColor: '#FADA5E',
+ secondaryColor: '#F6911D',
+ title: 'Stacker News',
+ logo: null,
+ favicon: null
+}
+
+const BrandingContext = createContext(defaultBranding)
+
+export const BrandingProvider = ({ children }) => {
+ // const { isCustomDomain } = useDomain()
+ // const [branding, setBranding] = useState(defaultBranding)
+
+ // if on a custom domain, fetch and cache the branding
+ // const { data } = useQuery(GET_TERRITORY_BRANDING, {
+ // skip: !isCustomDomain,
+ // fetchPolicy: 'cache-and-network'
+ // })
+
+ // useEffect(() => {
+ // if (data?.territoryBranding) {
+ // setBranding({
+ // ...defaultBranding,
+ // ...data.territoryBranding
+ // })
+ // }
+ // }, [data])
+
+ return (
+ //
+ // {isCustomDomain && branding.title && (
+ //
+ // {branding.title}
+ //
+ // )}
+ // {children}
+ //
+ todo
+ )
+}
+
+export const useBranding = () => useContext(BrandingContext)
diff --git a/components/territory-domains.js b/components/domains/territory-domains.js
similarity index 98%
rename from components/territory-domains.js
rename to components/domains/territory-domains.js
index 24b74e0ec..120987e4a 100644
--- a/components/territory-domains.js
+++ b/components/domains/territory-domains.js
@@ -1,8 +1,8 @@
import { Badge } from 'react-bootstrap'
-import { Form, Input, SubmitButton } from './form'
+import { Form, Input, SubmitButton } from '../form'
import { useMutation, useQuery } from '@apollo/client'
import { customDomainSchema } from '@/lib/validate'
-import ActionTooltip from './action-tooltip'
+import ActionTooltip from '../action-tooltip'
import { useToast } from '@/components/toast'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { GET_CUSTOM_DOMAIN, SET_CUSTOM_DOMAIN } from '@/fragments/domains'
diff --git a/components/form.js b/components/form.js
index c429ab7c9..c048ee8b3 100644
--- a/components/form.js
+++ b/components/form.js
@@ -1385,5 +1385,29 @@ export function MultiInput ({
)
}
+export function ColorPicker ({ label, groupClassName, name, ...props }) {
+ const [field, , helpers] = useField({ ...props, name })
+
+ useEffect(() => {
+ helpers.setValue(field.value)
+ }, [field.value])
+
+ return (
+
+ {
+ field.onChange(e)
+ if (props.onChange) {
+ props.onChange(formik, e)
+ }
+ }}
+ />
+
+ )
+}
+
export const ClientInput = Client(Input)
export const ClientCheckbox = Client(Checkbox)
diff --git a/components/nav/common.js b/components/nav/common.js
index a6df8932d..ad98cc20d 100644
--- a/components/nav/common.js
+++ b/components/nav/common.js
@@ -25,7 +25,7 @@ import { useWallets } from '@/wallets/index'
import SwitchAccountList, { nextAccount, useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal'
import { numWithUnits } from '@/lib/format'
-import { useDomain } from '@/components/territory-domains'
+import { useDomain } from '@/components/domains/territory-domains'
export function Brand ({ className }) {
return (
diff --git a/components/nav/index.js b/components/nav/index.js
index 23badf4c0..cd68cc5d3 100644
--- a/components/nav/index.js
+++ b/components/nav/index.js
@@ -3,7 +3,7 @@ import DesktopHeader from './desktop/header'
import MobileHeader from './mobile/header'
import StickyBar from './sticky-bar'
import { PriceCarouselProvider } from './price-carousel'
-import { useDomain } from '@/components/territory-domains'
+import { useDomain } from '@/components/domains/territory-domains'
export default function Navigation ({ sub }) {
const router = useRouter()
diff --git a/components/territory-form.js b/components/territory-form.js
index 21072cd48..af8161334 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -14,8 +14,9 @@ import { purchasedType } from '@/lib/territory'
import { SUB } from '@/fragments/subs'
import { usePaidMutation } from './use-paid-mutation'
import { UNARCHIVE_TERRITORY, UPSERT_SUB } from '@/fragments/paidAction'
-import TerritoryDomains, { useDomain } from './territory-domains'
+import TerritoryDomains, { useDomain } from './domains/territory-domains'
import Link from 'next/link'
+import BrandingForm from './domains/branding/branding-form'
export default function TerritoryForm ({ sub }) {
const router = useRouter()
@@ -299,12 +300,7 @@ export default function TerritoryForm ({ sub }) {
{/* TODO: doesn't follow the custom domain state */}
{sub?.customDomain?.dnsState === 'VERIFIED' && sub?.customDomain?.sslState === 'VERIFIED' &&
- <>
- [NOT IMPLEMENTED] branding
- WIP
- [NOT IMPLEMENTED] color scheme
- WIP
- >}
+ }
>
}
/>
diff --git a/components/territory-header.js b/components/territory-header.js
index c7e9436fb..cb2757409 100644
--- a/components/territory-header.js
+++ b/components/territory-header.js
@@ -12,7 +12,7 @@ import { gql, useMutation } from '@apollo/client'
import { useToast } from './toast'
import ActionDropdown from './action-dropdown'
import { TerritoryTransferDropdownItem } from './territory-transfer'
-import { useDomain } from './territory-domains'
+import { useDomain } from './domains/territory-domains'
export function TerritoryDetails ({ sub, children }) {
return (
diff --git a/pages/_app.js b/pages/_app.js
index 99182c29d..a481841fc 100644
--- a/pages/_app.js
+++ b/pages/_app.js
@@ -22,7 +22,7 @@ import dynamic from 'next/dynamic'
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
import { WebLnProvider } from '@/wallets/webln/client'
import { WalletsProvider } from '@/wallets/index'
-import { DomainProvider } from '@/components/territory-domains'
+import { DomainProvider } from '@/components/domains/territory-domains'
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
From 885faa8de8155cea0cbfa13f92be4a58c4c19c95 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Fri, 30 May 2025 19:50:04 -0500
Subject: [PATCH 39/74] custom branding model, resolver, typedefs
---
api/resolvers/branding.js | 59 +++++++++++++++++++
api/resolvers/index.js | 3 +-
api/typeDefs/branding.js | 29 +++++++++
api/typeDefs/index.js | 3 +-
fragments/brandings.js | 39 ++++++++++++
.../migration.sql | 24 ++++++++
prisma/schema.prisma | 16 +++++
7 files changed, 171 insertions(+), 2 deletions(-)
create mode 100644 api/resolvers/branding.js
create mode 100644 api/typeDefs/branding.js
create mode 100644 fragments/brandings.js
create mode 100644 prisma/migrations/20250531003413_custom_brandings/migration.sql
diff --git a/api/resolvers/branding.js b/api/resolvers/branding.js
new file mode 100644
index 000000000..42ae2e817
--- /dev/null
+++ b/api/resolvers/branding.js
@@ -0,0 +1,59 @@
+import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
+
+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, primaryColor, secondaryColor, logoId, faviconId } = branding
+
+ if (logoId) {
+ const logo = await models.upload.findUnique({ where: { id: logoId } })
+ if (!logo) {
+ throw new GqlInputError('logo not found')
+ }
+ }
+
+ if (faviconId) {
+ const favicon = await models.upload.findUnique({ where: { id: faviconId } })
+ if (!favicon) {
+ throw new GqlInputError('favicon not found')
+ }
+ }
+
+ return await models.customBranding.upsert({
+ where: { subName },
+ update: {
+ title,
+ primaryColor,
+ secondaryColor,
+ logo: { connect: { id: logoId } },
+ favicon: { connect: { id: faviconId } }
+ },
+ create: {
+ title,
+ primaryColor,
+ secondaryColor,
+ logo: { connect: { id: logoId } },
+ favicon: { connect: { id: faviconId } }
+ }
+ })
+ }
+ }
+}
diff --git a/api/resolvers/index.js b/api/resolvers/index.js
index b503518b6..e3a11dc36 100644
--- a/api/resolvers/index.js
+++ b/api/resolvers/index.js
@@ -21,6 +21,7 @@ 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',
@@ -57,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,
- domain, { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]
+ domain, branding, { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault]
diff --git a/api/typeDefs/branding.js b/api/typeDefs/branding.js
new file mode 100644
index 000000000..98346c661
--- /dev/null
+++ b/api/typeDefs/branding.js
@@ -0,0 +1,29 @@
+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
+ primaryColor: String
+ secondaryColor: String
+ logoId: Int
+ faviconId: Int
+ subName: String
+ }
+
+ input CustomBrandingInput {
+ title: String
+ primaryColor: String
+ secondaryColor: String
+ logoId: Int
+ faviconId: Int
+ subName: String
+ }
+`
diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js
index 0acd7dc44..5119ec7ee 100644
--- a/api/typeDefs/index.js
+++ b/api/typeDefs/index.js
@@ -20,6 +20,7 @@ import chainFee from './chainFee'
import paidAction from './paidAction'
import vault from './vault'
import domain from './domain'
+import branding from './branding'
const common = gql`
type Query {
@@ -40,4 +41,4 @@ const common = gql`
`
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
- sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, domain, paidAction, vault]
+ sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, domain, branding, paidAction, vault]
diff --git a/fragments/brandings.js b/fragments/brandings.js
new file mode 100644
index 000000000..1c6bd8e3e
--- /dev/null
+++ b/fragments/brandings.js
@@ -0,0 +1,39 @@
+import { gql } from 'graphql-tag'
+
+export const GET_CUSTOM_BRANDING = gql`
+ query CustomBranding($subName: String!) {
+ customBranding(subName: $subName) {
+ title
+ primaryColor
+ secondaryColor
+ logoId
+ faviconId
+ subName
+ }
+ }
+`
+
+export const GET_CUSTOM_BRANDING_FIELDS = gql`
+ fragment CustomBrandingFields on CustomBranding {
+ title
+ primaryColor
+ secondaryColor
+ logoId
+ faviconId
+ subName
+ }
+`
+
+export const GET_CUSTOM_BRANDING_FULL = gql`
+ ${GET_CUSTOM_BRANDING}
+ fragment CustomBrandingFull on CustomBranding {
+ ...CustomBrandingFields
+ }
+`
+export const SET_CUSTOM_BRANDING = gql`
+ mutation SetCustomBranding($subName: String!, $branding: CustomBrandingInput!) {
+ setCustomBranding(subName: $subName, branding: $branding) {
+ ...CustomBrandingFields
+ }
+ }
+`
diff --git a/prisma/migrations/20250531003413_custom_brandings/migration.sql b/prisma/migrations/20250531003413_custom_brandings/migration.sql
new file mode 100644
index 000000000..bdbbf3681
--- /dev/null
+++ b/prisma/migrations/20250531003413_custom_brandings/migration.sql
@@ -0,0 +1,24 @@
+-- CreateTable
+CREATE TABLE "CustomBranding" (
+ "id" SERIAL NOT NULL,
+ "title" TEXT,
+ "primaryColor" TEXT,
+ "secondaryColor" TEXT,
+ "logoId" INTEGER,
+ "faviconId" INTEGER,
+ "subName" CITEXT NOT NULL,
+
+ CONSTRAINT "CustomBranding_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CustomBranding_subName_key" ON "CustomBranding"("subName");
+
+-- AddForeignKey
+ALTER TABLE "CustomBranding" ADD CONSTRAINT "CustomBranding_logoId_fkey" FOREIGN KEY ("logoId") REFERENCES "Upload"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CustomBranding" ADD CONSTRAINT "CustomBranding_faviconId_fkey" FOREIGN KEY ("faviconId") REFERENCES "Upload"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CustomBranding" ADD CONSTRAINT "CustomBranding_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 809e73270..8f68c849f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -451,6 +451,8 @@ model Upload {
user User @relation("Uploads", fields: [userId], references: [id], onDelete: Cascade)
User User[]
ItemUpload ItemUpload[]
+ logoBranding CustomBranding[] @relation("LogoUpload")
+ faviconBranding CustomBranding[] @relation("FaviconUpload")
@@index([createdAt], map: "Upload.created_at_index")
@@index([userId], map: "Upload.userId_index")
@@ -797,6 +799,7 @@ model Sub {
TerritoryTransfer TerritoryTransfer[]
UserSubTrust UserSubTrust[]
customDomain CustomDomain?
+ customBranding CustomBranding?
@@index([parentName])
@@index([createdAt])
@@ -1259,6 +1262,19 @@ model CustomDomain {
@@index([createdAt])
}
+model CustomBranding {
+ id Int @id @default(autoincrement())
+ title String?
+ primaryColor String?
+ secondaryColor String?
+ logoId Int?
+ logo Upload? @relation("LogoUpload", fields: [logoId], references: [id])
+ faviconId Int?
+ favicon Upload? @relation("FaviconUpload", fields: [faviconId], references: [id])
+ subName String @unique @db.Citext
+ sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
+}
+
enum EarnType {
POST
COMMENT
From 9df1814dbb0eae79b1b4eea3b61abc00f7617057 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Fri, 30 May 2025 20:48:59 -0500
Subject: [PATCH 40/74] adjust resolvers, allow custom branding upsert
---
api/resolvers/branding.js | 9 ++--
api/resolvers/sub.js | 3 ++
api/typeDefs/sub.js | 1 +
components/domains/branding/branding-form.js | 24 ++++++---
components/domains/branding/index.js | 55 ++++++++++----------
fragments/brandings.js | 7 +--
6 files changed, 55 insertions(+), 44 deletions(-)
diff --git a/api/resolvers/branding.js b/api/resolvers/branding.js
index 42ae2e817..40387b796 100644
--- a/api/resolvers/branding.js
+++ b/api/resolvers/branding.js
@@ -43,15 +43,16 @@ export default {
title,
primaryColor,
secondaryColor,
- logo: { connect: { id: logoId } },
- favicon: { connect: { id: faviconId } }
+ ...(logoId && { logo: { connect: { id: logoId } } }),
+ ...(faviconId && { favicon: { connect: { id: faviconId } } })
},
create: {
title,
primaryColor,
secondaryColor,
- logo: { connect: { id: logoId } },
- favicon: { connect: { id: faviconId } }
+ ...(logoId && { logo: { connect: { id: logoId } } }),
+ ...(faviconId && { favicon: { connect: { id: faviconId } } }),
+ sub: { connect: { name: subName } }
}
})
}
diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js
index aea327325..09918be98 100644
--- a/api/resolvers/sub.js
+++ b/api/resolvers/sub.js
@@ -313,6 +313,9 @@ export default {
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
}
}
diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js
index 97ae104db..9e612a222 100644
--- a/api/typeDefs/sub.js
+++ b/api/typeDefs/sub.js
@@ -56,6 +56,7 @@ export default gql`
ncomments(when: String, from: String, to: String): Int!
meSubscription: Boolean!
customDomain: CustomDomain
+ customBranding: CustomBranding
optional: SubOptional!
}
diff --git a/components/domains/branding/branding-form.js b/components/domains/branding/branding-form.js
index 534ce7c3a..7330ceea1 100644
--- a/components/domains/branding/branding-form.js
+++ b/components/domains/branding/branding-form.js
@@ -1,25 +1,35 @@
-import { Form, SubmitButton, ColorPicker } from '../../form'
-// import { useMutation } from '@apollo/client'
+import { Form, SubmitButton, ColorPicker, Input } from '../../form'
+import { useMutation } from '@apollo/client'
import { useToast } from '../../toast'
import { brandingSchema } from '@/lib/validate'
-// import { SET_TERRITORY_BRANDING } from '@/fragments/branding'
+import { SET_CUSTOM_BRANDING } from '@/fragments/brandings'
export default function BrandingForm ({ sub }) {
- // const [setTerritoryBranding] = useMutation(SET_TERRITORY_BRANDING)
+ const [setCustomBranding] = useMutation(SET_CUSTOM_BRANDING)
const toaster = useToast()
const onSubmit = async (values) => {
+ console.log(values)
try {
- console.log(values)
+ await setCustomBranding({
+ variables: {
+ subName: sub.name,
+ branding: values
+ }
+ })
toaster.success('Branding updated successfully')
} catch (error) {
+ console.error(error)
toaster.danger('Failed to update branding', { error })
}
}
const initialValues = {
+ title: sub?.branding?.title || sub?.subName,
primaryColor: sub?.branding?.primaryColor || '#FADA5E',
- secondaryColor: sub?.branding?.secondaryColor || '#F6911D'
+ secondaryColor: sub?.branding?.secondaryColor || '#F6911D',
+ logoId: sub?.branding?.logoId || null,
+ faviconId: sub?.branding?.faviconId || null
}
// TODO: add logo and favicon upload
@@ -30,8 +40,10 @@ export default function BrandingForm ({ sub }) {
schema={brandingSchema}
onSubmit={onSubmit}
>
+
+ {/* TODO: add logo and favicon upload */}
Save Branding
)
diff --git a/components/domains/branding/index.js b/components/domains/branding/index.js
index 44da4989c..05a46cbd3 100644
--- a/components/domains/branding/index.js
+++ b/components/domains/branding/index.js
@@ -1,8 +1,8 @@
-import { createContext, useContext } from 'react'
-// import { useQuery } from '@apollo/client'
-// import { useDomain } from '../territory-domains'
-// import { GET_TERRITORY_BRANDING } from '@/fragments/branding'
-// import Head from 'next/head'
+import { createContext, useContext, useState, useEffect } from 'react'
+import { useQuery } from '@apollo/client'
+import { useDomain } from '../territory-domains'
+import { GET_CUSTOM_BRANDING } from '@/fragments/brandings'
+import Head from 'next/head'
const defaultBranding = {
primaryColor: '#FADA5E',
@@ -15,34 +15,33 @@ const defaultBranding = {
const BrandingContext = createContext(defaultBranding)
export const BrandingProvider = ({ children }) => {
- // const { isCustomDomain } = useDomain()
- // const [branding, setBranding] = useState(defaultBranding)
+ const { isCustomDomain } = useDomain()
+ const [branding, setBranding] = useState(defaultBranding)
// if on a custom domain, fetch and cache the branding
- // const { data } = useQuery(GET_TERRITORY_BRANDING, {
- // skip: !isCustomDomain,
- // fetchPolicy: 'cache-and-network'
- // })
+ const { data } = useQuery(GET_CUSTOM_BRANDING, {
+ skip: !isCustomDomain,
+ fetchPolicy: 'cache-and-network'
+ })
- // useEffect(() => {
- // if (data?.territoryBranding) {
- // setBranding({
- // ...defaultBranding,
- // ...data.territoryBranding
- // })
- // }
- // }, [data])
+ useEffect(() => {
+ if (data?.territoryBranding) {
+ setBranding({
+ ...defaultBranding,
+ ...data.territoryBranding
+ })
+ }
+ }, [data])
return (
- //
- // {isCustomDomain && branding.title && (
- //
- // {branding.title}
- //
- // )}
- // {children}
- //
- todo
+
+ {isCustomDomain && branding.title && (
+
+ {branding.title}
+
+ )}
+ {children}
+
)
}
diff --git a/fragments/brandings.js b/fragments/brandings.js
index 1c6bd8e3e..104c21cc5 100644
--- a/fragments/brandings.js
+++ b/fragments/brandings.js
@@ -24,13 +24,8 @@ export const GET_CUSTOM_BRANDING_FIELDS = gql`
}
`
-export const GET_CUSTOM_BRANDING_FULL = gql`
- ${GET_CUSTOM_BRANDING}
- fragment CustomBrandingFull on CustomBranding {
- ...CustomBrandingFields
- }
-`
export const SET_CUSTOM_BRANDING = gql`
+ ${GET_CUSTOM_BRANDING_FIELDS}
mutation SetCustomBranding($subName: String!, $branding: CustomBrandingInput!) {
setCustomBranding(subName: $subName, branding: $branding) {
...CustomBrandingFields
From 9f6bdb0cd88ad162abb3d86656604af64a886625 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Fri, 30 May 2025 20:53:39 -0500
Subject: [PATCH 41/74] add custom branding to Sub fragment
---
components/domains/branding/branding-form.js | 10 +++++-----
fragments/subs.js | 7 +++++++
2 files changed, 12 insertions(+), 5 deletions(-)
diff --git a/components/domains/branding/branding-form.js b/components/domains/branding/branding-form.js
index 7330ceea1..ed32b7d28 100644
--- a/components/domains/branding/branding-form.js
+++ b/components/domains/branding/branding-form.js
@@ -25,11 +25,11 @@ export default function BrandingForm ({ sub }) {
}
const initialValues = {
- title: sub?.branding?.title || sub?.subName,
- primaryColor: sub?.branding?.primaryColor || '#FADA5E',
- secondaryColor: sub?.branding?.secondaryColor || '#F6911D',
- logoId: sub?.branding?.logoId || null,
- faviconId: sub?.branding?.faviconId || null
+ title: sub?.customBranding?.title || sub?.subName,
+ primaryColor: sub?.customBranding?.primaryColor || '#FADA5E',
+ secondaryColor: sub?.customBranding?.secondaryColor || '#F6911D',
+ logoId: sub?.customBranding?.logoId || null,
+ faviconId: sub?.customBranding?.faviconId || null
}
// TODO: add logo and favicon upload
diff --git a/fragments/subs.js b/fragments/subs.js
index f2f362eb0..3cd3293b1 100644
--- a/fragments/subs.js
+++ b/fragments/subs.js
@@ -48,6 +48,13 @@ export const SUB_FIELDS = gql`
verificationCnameValue
verificationTxt
}
+ customBranding {
+ title
+ primaryColor
+ secondaryColor
+ logoId
+ faviconId
+ }
}`
export const SUB_FULL_FIELDS = gql`
From 513005707ba22358a0e7fd3713984d9663b8d727 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Sat, 31 May 2025 12:56:23 -0500
Subject: [PATCH 42/74] adjust SEO to custom values, BrandingProvider,
implement base custom colors
---
api/resolvers/branding.js | 14 +++--
components/domains/branding/branding-form.js | 4 +-
components/domains/branding/custom-styles.js | 34 +++++++++++
components/domains/branding/index.js | 22 ++++++--
components/seo.js | 23 +++++---
pages/_app.js | 59 ++++++++++----------
6 files changed, 106 insertions(+), 50 deletions(-)
diff --git a/api/resolvers/branding.js b/api/resolvers/branding.js
index 40387b796..c10454f2e 100644
--- a/api/resolvers/branding.js
+++ b/api/resolvers/branding.js
@@ -37,19 +37,21 @@ export default {
}
}
+ // TODO: validation, even of logo and favicon.
+
return await models.customBranding.upsert({
where: { subName },
update: {
- title,
- primaryColor,
- secondaryColor,
+ title: title || subName,
+ primaryColor: primaryColor || '#FADA5E',
+ secondaryColor: secondaryColor || '#F6911D',
...(logoId && { logo: { connect: { id: logoId } } }),
...(faviconId && { favicon: { connect: { id: faviconId } } })
},
create: {
- title,
- primaryColor,
- secondaryColor,
+ title: title || subName,
+ primaryColor: primaryColor || '#FADA5E',
+ secondaryColor: secondaryColor || '#F6911D',
...(logoId && { logo: { connect: { id: logoId } } }),
...(faviconId && { favicon: { connect: { id: faviconId } } }),
sub: { connect: { name: subName } }
diff --git a/components/domains/branding/branding-form.js b/components/domains/branding/branding-form.js
index ed32b7d28..4aa08d715 100644
--- a/components/domains/branding/branding-form.js
+++ b/components/domains/branding/branding-form.js
@@ -40,11 +40,11 @@ export default function BrandingForm ({ sub }) {
schema={brandingSchema}
onSubmit={onSubmit}
>
-
+
{/* TODO: add logo and favicon upload */}
- Save Branding
+ save branding
)
}
diff --git a/components/domains/branding/custom-styles.js b/components/domains/branding/custom-styles.js
index e69de29bb..c713d95c4 100644
--- a/components/domains/branding/custom-styles.js
+++ b/components/domains/branding/custom-styles.js
@@ -0,0 +1,34 @@
+import { useBranding } from './index'
+import { useEffect } from 'react'
+
+export default function CustomStyles () {
+ const branding = useBranding()
+
+ useEffect(() => {
+ if (branding && branding.primaryColor) {
+ // dynamic colors
+ document.documentElement.style.setProperty('--bs-primary', branding.primaryColor)
+ document.documentElement.style.setProperty('--bs-secondary', branding.secondaryColor)
+ // hex to rgb for compat
+ document.documentElement.style.setProperty('--bs-primary-rgb', hexToRgb(branding.primaryColor))
+ document.documentElement.style.setProperty('--bs-secondary-rgb', hexToRgb(branding.secondaryColor))
+ }
+
+ return () => {
+ // TODO: not sure if this is a good practice: reset to default values when component unmounts
+ document.documentElement.style.removeProperty('--bs-primary')
+ document.documentElement.style.removeProperty('--bs-secondary')
+ document.documentElement.style.removeProperty('--bs-primary-rgb')
+ document.documentElement.style.removeProperty('--bs-secondary-rgb')
+ }
+ }, [branding])
+}
+
+// hex to rgb for compat
+function hexToRgb (hex) {
+ hex = hex.replace('#', '')
+ const r = parseInt(hex.substring(0, 2), 16)
+ const g = parseInt(hex.substring(2, 4), 16)
+ const b = parseInt(hex.substring(4, 6), 16)
+ return `${r}, ${g}, ${b}`
+}
diff --git a/components/domains/branding/index.js b/components/domains/branding/index.js
index 05a46cbd3..d5c346489 100644
--- a/components/domains/branding/index.js
+++ b/components/domains/branding/index.js
@@ -2,12 +2,13 @@ import { createContext, useContext, useState, useEffect } from 'react'
import { useQuery } from '@apollo/client'
import { useDomain } from '../territory-domains'
import { GET_CUSTOM_BRANDING } from '@/fragments/brandings'
+import CustomStyles from './custom-styles'
import Head from 'next/head'
const defaultBranding = {
primaryColor: '#FADA5E',
secondaryColor: '#F6911D',
- title: 'Stacker News',
+ title: 'stacker news',
logo: null,
favicon: null
}
@@ -21,14 +22,19 @@ export const BrandingProvider = ({ children }) => {
// if on a custom domain, fetch and cache the branding
const { data } = useQuery(GET_CUSTOM_BRANDING, {
skip: !isCustomDomain,
+ variables: {
+ subName: 'sambido'
+ },
fetchPolicy: 'cache-and-network'
})
+ console.log('branding', data)
+
useEffect(() => {
- if (data?.territoryBranding) {
+ if (data?.customBranding) {
setBranding({
...defaultBranding,
- ...data.territoryBranding
+ ...data.customBranding
})
}
}, [data])
@@ -36,9 +42,13 @@ export const BrandingProvider = ({ children }) => {
return (
{isCustomDomain && branding.title && (
-
- {branding.title}
-
+ <>
+
+ {branding.title && {branding.title} }
+ {/* branding.favicon && */}
+
+
+ >
)}
{children}
diff --git a/components/seo.js b/components/seo.js
index 2aaa6615b..0e2bf75f5 100644
--- a/components/seo.js
+++ b/components/seo.js
@@ -2,26 +2,30 @@ import { NextSeo } from 'next-seo'
import { useRouter } from 'next/router'
import removeMd from 'remove-markdown'
import { numWithUnits } from '@/lib/format'
+import { useBranding } from '@/components/domains/branding'
+import { useDomain } from '@/components/domains/territory-domains'
export function SeoSearch ({ sub }) {
const router = useRouter()
- const subStr = sub ? ` ~${sub}` : ''
- const title = `${router.query.q || 'search'} \\ stacker news${subStr}`
+ const { isCustomDomain } = useDomain()
+ const { title } = isCustomDomain ? useBranding() : { title: 'stacker news' }
+ const subStr = sub && !isCustomDomain ? ` ~${sub}` : ''
+ const snStr = `${router.query.q || 'search'} \\ ${title}${subStr}`
const desc = `SN${subStr} search: ${router.query.q || ''}`
return (
import('react-ios-pwa-prompt'), { ssr: false })
@@ -113,34 +114,36 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {!router?.query?.disablePrompt && }
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!router?.query?.disablePrompt && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 600a373d7360ece74a397529221b6a4913347468 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Sun, 30 Mar 2025 18:45:31 -0500
Subject: [PATCH 43/74] pass sub with DomainProvider, pass custom branding SSR,
refactor branding form, new todos
---
api/ssrApollo.js | 16 ++++++++++-
components/domains/branding/index.js | 38 ++++---------------------
components/domains/territory-domains.js | 22 ++++++++++----
components/nav/common.js | 2 +-
components/nav/index.js | 2 +-
components/seo.js | 4 +--
components/territory-form.js | 12 ++------
components/territory-header.js | 2 +-
middleware.js | 21 +++++++++++---
pages/_app.js | 6 ++--
10 files changed, 64 insertions(+), 61 deletions(-)
diff --git a/api/ssrApollo.js b/api/ssrApollo.js
index 01cd043ce..ca1e5059f 100644
--- a/api/ssrApollo.js
+++ b/api/ssrApollo.js
@@ -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'
@@ -152,8 +153,20 @@ export function getGetServerSideProps (
const client = await getSSRApolloClient({ req, res })
+ // TODO: doesn't seem the best place for this
+ let branding = null
+
// custom domain SSR check
- const customDomain = req.headers.host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
+ const customDomain = {
+ // TODO: pass domain
+ isCustomDomain: req.headers.host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, ''),
+ subName: req.headers['x-stacker-news-subname'] || null
+ }
+ if (customDomain?.isCustomDomain && customDomain?.subName) {
+ // TODO: cleanup
+ const { data: { customBranding } } = await client.query({ query: GET_CUSTOM_BRANDING, variables: { subName: customDomain?.subName } })
+ branding = customBranding
+ }
let { data: { me } } = await client.query({ query: ME })
@@ -220,6 +233,7 @@ export function getGetServerSideProps (
props: {
...props,
customDomain,
+ branding,
me,
price,
blockHeight,
diff --git a/components/domains/branding/index.js b/components/domains/branding/index.js
index d5c346489..0989f20d7 100644
--- a/components/domains/branding/index.js
+++ b/components/domains/branding/index.js
@@ -1,7 +1,4 @@
-import { createContext, useContext, useState, useEffect } from 'react'
-import { useQuery } from '@apollo/client'
-import { useDomain } from '../territory-domains'
-import { GET_CUSTOM_BRANDING } from '@/fragments/brandings'
+import { createContext, useContext } from 'react'
import CustomStyles from './custom-styles'
import Head from 'next/head'
@@ -15,39 +12,16 @@ const defaultBranding = {
const BrandingContext = createContext(defaultBranding)
-export const BrandingProvider = ({ children }) => {
- const { isCustomDomain } = useDomain()
- const [branding, setBranding] = useState(defaultBranding)
-
- // if on a custom domain, fetch and cache the branding
- const { data } = useQuery(GET_CUSTOM_BRANDING, {
- skip: !isCustomDomain,
- variables: {
- subName: 'sambido'
- },
- fetchPolicy: 'cache-and-network'
- })
-
- console.log('branding', data)
-
- useEffect(() => {
- if (data?.customBranding) {
- setBranding({
- ...defaultBranding,
- ...data.customBranding
- })
- }
- }, [data])
-
+export const BrandingProvider = ({ children, customBranding }) => {
return (
-
- {isCustomDomain && branding.title && (
+
+ {customBranding && (
<>
- {branding.title && {branding.title} }
+ {customBranding?.title && {customBranding?.title} }
{/* branding.favicon && */}
-
+ {customBranding?.primaryColor && }
>
)}
{children}
diff --git a/components/domains/territory-domains.js b/components/domains/territory-domains.js
index 120987e4a..720bc1346 100644
--- a/components/domains/territory-domains.js
+++ b/components/domains/territory-domains.js
@@ -9,25 +9,33 @@ import { GET_CUSTOM_DOMAIN, SET_CUSTOM_DOMAIN } from '@/fragments/domains'
import { useEffect, createContext, useContext, useState } from 'react'
import { useRouter } from 'next/router'
import { signIn } from 'next-auth/react'
+import BrandingForm from '@/components/domains/branding/branding-form'
// Domain context for custom domains
const DomainContext = createContext({
- isCustomDomain: false
+ customDomain: {
+ isCustomDomain: false,
+ subName: null
+ }
})
-export const DomainProvider = ({ isCustomDomain: initialIsCustomDomain, children }) => {
+export const DomainProvider = ({ customDomain: initialCustomDomain, children }) => {
const router = useRouter()
- const [isCustomDomain, setIsCustomDomain] = useState(initialIsCustomDomain)
+ const [customDomain, setCustomDomain] = useState(initialCustomDomain)
useEffect(() => {
// client side navigation
if (typeof window !== 'undefined') {
const hostname = window.location.hostname
- const isCustom = hostname !== new URL(process.env.NEXT_PUBLIC_URL).hostname
- setIsCustomDomain(isCustom)
+ setCustomDomain({
+ isCustomDomain: hostname !== new URL(process.env.NEXT_PUBLIC_URL).hostname,
+ subName: router.query.sub
+ })
}
}, [router.asPath])
+ console.log('customDomain', customDomain)
+
// TODO: alternative to this, for test only
// auth sync
useEffect(() => {
@@ -39,7 +47,7 @@ export const DomainProvider = ({ isCustomDomain: initialIsCustomDomain, children
}, [router.query.type])
return (
-
+
{children}
)
@@ -181,6 +189,8 @@ export default function CustomDomainForm ({ sub }) {
+ {dnsState === 'VERIFIED' && sslState === 'VERIFIED' &&
+ }
>
)
}
diff --git a/components/nav/common.js b/components/nav/common.js
index ad98cc20d..2ebda314d 100644
--- a/components/nav/common.js
+++ b/components/nav/common.js
@@ -292,7 +292,7 @@ function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const { removeLocalWallets } = useWallets()
const router = useRouter()
- const { isCustomDomain } = useDomain()
+ const { customDomain: { isCustomDomain } } = useDomain()
return (
diff --git a/components/nav/index.js b/components/nav/index.js
index cd68cc5d3..ddd25c64b 100644
--- a/components/nav/index.js
+++ b/components/nav/index.js
@@ -7,7 +7,7 @@ import { useDomain } from '@/components/domains/territory-domains'
export default function Navigation ({ sub }) {
const router = useRouter()
- const { isCustomDomain } = useDomain()
+ const { customDomain: { isCustomDomain } } = useDomain()
const path = router.asPath.split('?')[0]
const props = {
diff --git a/components/seo.js b/components/seo.js
index 0e2bf75f5..446a1052c 100644
--- a/components/seo.js
+++ b/components/seo.js
@@ -7,7 +7,7 @@ import { useDomain } from '@/components/domains/territory-domains'
export function SeoSearch ({ sub }) {
const router = useRouter()
- const { isCustomDomain } = useDomain()
+ const { customDomain: { isCustomDomain } } = useDomain()
const { title } = isCustomDomain ? useBranding() : { title: 'stacker news' }
const subStr = sub && !isCustomDomain ? ` ~${sub}` : ''
const snStr = `${router.query.q || 'search'} \\ ${title}${subStr}`
@@ -43,7 +43,7 @@ export function SeoSearch ({ sub }) {
export default function Seo ({ sub, item, user }) {
const router = useRouter()
- const { isCustomDomain } = useDomain()
+ const { customDomain: { isCustomDomain } } = useDomain()
const { title } = isCustomDomain ? useBranding() : { title: 'stacker news' }
const pathNoQuery = router.asPath.split('?')[0]
const defaultTitle = pathNoQuery.slice(1)
diff --git a/components/territory-form.js b/components/territory-form.js
index af8161334..419bcd813 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -16,13 +16,12 @@ import { usePaidMutation } from './use-paid-mutation'
import { UNARCHIVE_TERRITORY, UPSERT_SUB } from '@/fragments/paidAction'
import TerritoryDomains, { useDomain } from './domains/territory-domains'
import Link from 'next/link'
-import BrandingForm from './domains/branding/branding-form'
export default function TerritoryForm ({ sub }) {
const router = useRouter()
const client = useApolloClient()
const { me } = useMe()
- const { isCustomDomain } = useDomain()
+ const { customDomain: { isCustomDomain } } = useDomain()
const [upsertSub] = usePaidMutation(UPSERT_SUB)
const [unarchiveTerritory] = usePaidMutation(UNARCHIVE_TERRITORY)
@@ -295,14 +294,7 @@ export default function TerritoryForm ({ sub }) {
}
- body={
- <>
-
- {/* TODO: doesn't follow the custom domain state */}
- {sub?.customDomain?.dnsState === 'VERIFIED' && sub?.customDomain?.sslState === 'VERIFIED' &&
-
}
- >
- }
+ body={
}
/>
}
{sub && isCustomDomain && domain settings on stacker.news}
diff --git a/components/territory-header.js b/components/territory-header.js
index cb2757409..974ca4eda 100644
--- a/components/territory-header.js
+++ b/components/territory-header.js
@@ -84,7 +84,7 @@ export function TerritoryInfo ({ sub }) {
export default function TerritoryHeader ({ sub }) {
const { me } = useMe()
const toaster = useToast()
- const { isCustomDomain } = useDomain()
+ const { customDomain: { isCustomDomain } } = useDomain()
const [toggleMuteSub] = useMutation(
gql`
diff --git a/middleware.js b/middleware.js
index 3912a9b04..76601fb2e 100644
--- a/middleware.js
+++ b/middleware.js
@@ -60,6 +60,9 @@ export async function customDomainMiddleware (request, referrerResp, domain) {
console.log('pathname', pathname)
console.log('query', url.searchParams)
+ const requestHeaders = new Headers(request.headers)
+ requestHeaders.set('x-stacker-news-subname', domain.subName)
+
// Auth sync redirects with domain and optional callbackUrl and multiAuth params
if (pathname === '/login' || pathname === '/signup') {
const redirectUrl = new URL(pathname, mainDomain)
@@ -70,7 +73,9 @@ export async function customDomainMiddleware (request, referrerResp, domain) {
if (url.searchParams.get('multiAuth')) {
redirectUrl.searchParams.set('multiAuth', url.searchParams.get('multiAuth'))
}
- const redirectResp = NextResponse.redirect(redirectUrl)
+ const redirectResp = NextResponse.redirect(redirectUrl, {
+ headers: requestHeaders
+ })
return applyReferrerCookies(redirectResp, referrerResp) // apply referrer cookies to the redirect
}
@@ -80,7 +85,9 @@ export async function customDomainMiddleware (request, referrerResp, domain) {
const cleanPath = pathname.replace(`/~${domain.subName}`, '') || '/'
// TEST
console.log('Redirecting to clean path:', cleanPath)
- const redirectResp = NextResponse.redirect(new URL(cleanPath + url.search, url.origin))
+ const redirectResp = NextResponse.redirect(new URL(cleanPath + url.search, url.origin), {
+ headers: requestHeaders
+ })
return applyReferrerCookies(redirectResp, referrerResp) // apply referrer cookies to the redirect
}
@@ -100,11 +107,17 @@ export async function customDomainMiddleware (request, referrerResp, domain) {
internalUrl.pathname = `/~${domain.subName}${pathname === '/' ? '' : pathname}`
console.log('Rewrite to:', internalUrl.pathname)
// rewrite to the territory path
- const resp = NextResponse.rewrite(internalUrl)
+ const resp = NextResponse.rewrite(internalUrl, {
+ headers: requestHeaders
+ })
return applyReferrerCookies(resp, referrerResp) // apply referrer cookies to the rewrite
}
- return NextResponse.next() // continue if we don't need to rewrite or redirect
+ return NextResponse.next({ // continue if we don't need to rewrite or redirect
+ request: {
+ headers: requestHeaders
+ }
+ })
}
// UNUSED
diff --git a/pages/_app.js b/pages/_app.js
index 73ba44200..4717bf8bc 100644
--- a/pages/_app.js
+++ b/pages/_app.js
@@ -100,7 +100,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
If we are on the client, we populate the apollo cache with the
ssr data
*/
- const { apollo, ssrData, me, price, blockHeight, chainFee, customDomain, ...otherProps } = props
+ const { apollo, ssrData, me, price, blockHeight, chainFee, customDomain, branding, ...otherProps } = props
useEffect(() => {
writeQuery(client, apollo, ssrData)
}, [client, apollo, ssrData])
@@ -112,9 +112,9 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
-
+
-
+
From db1e15df66303dccae1fc12a9985623f149757bf Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Mon, 31 Mar 2025 04:00:25 -0500
Subject: [PATCH 44/74] add button global styles
---
components/domains/branding/custom-styles.js | 1 +
components/domains/branding/index.js | 2 +-
styles/globals.scss | 18 ++++++++++++++++++
3 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/components/domains/branding/custom-styles.js b/components/domains/branding/custom-styles.js
index c713d95c4..fb4bc515c 100644
--- a/components/domains/branding/custom-styles.js
+++ b/components/domains/branding/custom-styles.js
@@ -9,6 +9,7 @@ export default function CustomStyles () {
// dynamic colors
document.documentElement.style.setProperty('--bs-primary', branding.primaryColor)
document.documentElement.style.setProperty('--bs-secondary', branding.secondaryColor)
+
// hex to rgb for compat
document.documentElement.style.setProperty('--bs-primary-rgb', hexToRgb(branding.primaryColor))
document.documentElement.style.setProperty('--bs-secondary-rgb', hexToRgb(branding.secondaryColor))
diff --git a/components/domains/branding/index.js b/components/domains/branding/index.js
index 0989f20d7..cb1a07be1 100644
--- a/components/domains/branding/index.js
+++ b/components/domains/branding/index.js
@@ -14,7 +14,7 @@ const BrandingContext = createContext(defaultBranding)
export const BrandingProvider = ({ children, customBranding }) => {
return (
-
+
{customBranding && (
<>
diff --git a/styles/globals.scss b/styles/globals.scss
index b7923c828..a3aa503f3 100644
--- a/styles/globals.scss
+++ b/styles/globals.scss
@@ -216,6 +216,24 @@ $zindex-sticky: 900;
--bs-gradient: none;
}
+.btn-primary {
+ --bs-btn-color: #fff;
+ --bs-btn-border-color: var(--bs-primary);
+ --bs-btn-hover-color: #fff;
+ --bs-btn-hover-bg: var(--bs-primary);
+ --bs-btn-hover-border-color: var(--bs-primary);
+ --bs-btn-bg: var(--bs-primary);
+}
+
+.btn-secondary {
+ --bs-btn-color: #fff;
+ --bs-btn-border-color: var(--bs-secondary);
+ --bs-btn-hover-color: #fff;
+ --bs-btn-hover-bg: var(--bs-secondary);
+ --bs-btn-hover-border-color: var(--bs-secondary);
+ --bs-btn-bg: var(--bs-secondary);
+}
+
* {
scroll-margin-top: 60px;
}
From 82d544b77d713e0c1294cc02d7e565d9c6a38737 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Mon, 31 Mar 2025 05:56:42 -0500
Subject: [PATCH 45/74] branding client and server-side, button global styles,
pass ssr custom domain to domain provider
---
components/domains/branding/custom-styles.js | 1 +
components/domains/branding/index.js | 34 ++++++++++++++++----
components/domains/territory-domains.js | 2 +-
styles/globals.scss | 31 +++++++++++++-----
4 files changed, 53 insertions(+), 15 deletions(-)
diff --git a/components/domains/branding/custom-styles.js b/components/domains/branding/custom-styles.js
index fb4bc515c..e89054488 100644
--- a/components/domains/branding/custom-styles.js
+++ b/components/domains/branding/custom-styles.js
@@ -9,6 +9,7 @@ export default function CustomStyles () {
// dynamic colors
document.documentElement.style.setProperty('--bs-primary', branding.primaryColor)
document.documentElement.style.setProperty('--bs-secondary', branding.secondaryColor)
+ document.documentElement.style.setProperty('--theme-primary', branding.primaryColor)
// hex to rgb for compat
document.documentElement.style.setProperty('--bs-primary-rgb', hexToRgb(branding.primaryColor))
diff --git a/components/domains/branding/index.js b/components/domains/branding/index.js
index cb1a07be1..83ab8d5b5 100644
--- a/components/domains/branding/index.js
+++ b/components/domains/branding/index.js
@@ -1,6 +1,9 @@
-import { createContext, useContext } from 'react'
+import { createContext, useContext, useState, useEffect } from 'react'
import CustomStyles from './custom-styles'
import Head from 'next/head'
+import { useQuery } from '@apollo/client'
+import { GET_CUSTOM_BRANDING } from '@/fragments/brandings'
+import { useDomain } from '@/components/domains/territory-domains'
const defaultBranding = {
primaryColor: '#FADA5E',
@@ -13,15 +16,34 @@ const defaultBranding = {
const BrandingContext = createContext(defaultBranding)
export const BrandingProvider = ({ children, customBranding }) => {
+ const { customDomain } = useDomain()
+ const [branding, setBranding] = useState(customBranding)
+
+ const { data } = useQuery(GET_CUSTOM_BRANDING, {
+ skip: !!customBranding,
+ variables: {
+ subName: customDomain?.subName
+ },
+ fetchPolicy: 'cache-and-network'
+ })
+
+ useEffect(() => {
+ if (customBranding) {
+ setBranding(customBranding)
+ } else if (data) {
+ setBranding(data?.customBranding || defaultBranding)
+ }
+ }, [data, customBranding])
+
return (
-
- {customBranding && (
+
+ {branding && (
<>
- {customBranding?.title && {customBranding?.title} }
- {/* branding.favicon && */}
+ {branding?.title && {branding?.title} }
+ {branding?.favicon && }
- {customBranding?.primaryColor && }
+ {branding?.primaryColor && }
>
)}
{children}
diff --git a/components/domains/territory-domains.js b/components/domains/territory-domains.js
index 720bc1346..63b99a716 100644
--- a/components/domains/territory-domains.js
+++ b/components/domains/territory-domains.js
@@ -29,7 +29,7 @@ export const DomainProvider = ({ customDomain: initialCustomDomain, children })
const hostname = window.location.hostname
setCustomDomain({
isCustomDomain: hostname !== new URL(process.env.NEXT_PUBLIC_URL).hostname,
- subName: router.query.sub
+ subName: router.query.sub || initialCustomDomain?.subName
})
}
}, [router.asPath])
diff --git a/styles/globals.scss b/styles/globals.scss
index a3aa503f3..04343e6dc 100644
--- a/styles/globals.scss
+++ b/styles/globals.scss
@@ -217,23 +217,38 @@ $zindex-sticky: 900;
}
.btn-primary {
- --bs-btn-color: #fff;
+ --bs-btn-color: #212529;
+ --bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
- --bs-btn-hover-color: #fff;
- --bs-btn-hover-bg: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
- --bs-btn-bg: var(--bs-primary);
+ --bs-btn-hover-color: #212529;
+ --bs-btn-hover-bg: var(--bs-primary);
+ --bs-btn-active-color: #212529;
+ --bs-btn-active-bg: var(--bs-primary);
+ --bs-btn-focus-shadow-rgb: 233, 236, 239;
+ --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+ --bs-btn-disabled-color: var(--bs-primary);
+ --bs-btn-disabled-bg: transparent;
+ --bs-btn-disabled-border-color: var(--bs-primary);
}
.btn-secondary {
- --bs-btn-color: #fff;
+ --bs-btn-color: #212529;
+ --bs-btn-bg: var(--bs-secondary);
--bs-btn-border-color: var(--bs-secondary);
- --bs-btn-hover-color: #fff;
- --bs-btn-hover-bg: var(--bs-secondary);
--bs-btn-hover-border-color: var(--bs-secondary);
- --bs-btn-bg: var(--bs-secondary);
+ --bs-btn-hover-color: #212529;
+ --bs-btn-hover-bg: var(--bs-secondary);
+ --bs-btn-active-color: #212529;
+ --bs-btn-active-bg: var(--bs-secondary);
+ --bs-btn-focus-shadow-rgb: 233, 236, 239;
+ --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+ --bs-btn-disabled-color: var(--bs-secondary);
+ --bs-btn-disabled-bg: transparent;
+ --bs-btn-disabled-border-color: var(--bs-secondary);
}
+
* {
scroll-margin-top: 60px;
}
From 54ee4b16317881640af97204db1ab028b125356d Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 1 Apr 2025 14:20:45 -0500
Subject: [PATCH 46/74] refactor custom domains and branding contexts, check
for SSR values before applying client-side values
---
api/ssrApollo.js | 24 +++---
components/domains/branding/custom-styles.js | 36 ---------
components/domains/branding/index.js | 54 --------------
components/nav/common.js | 6 +-
components/nav/desktop/second-bar.js | 3 +-
components/nav/index.js | 10 +--
components/nav/mobile/top-bar.js | 3 +-
components/seo.js | 15 ++--
...ing-form.js => territory-branding-form.js} | 6 +-
components/{domains => }/territory-domains.js | 73 ++++++++++++++++---
components/territory-form.js | 6 +-
components/territory-header.js | 6 +-
pages/_app.js | 71 +++++++++---------
13 files changed, 139 insertions(+), 174 deletions(-)
delete mode 100644 components/domains/branding/custom-styles.js
delete mode 100644 components/domains/branding/index.js
rename components/{domains/branding/branding-form.js => territory-branding-form.js} (90%)
rename components/{domains => }/territory-domains.js (68%)
diff --git a/api/ssrApollo.js b/api/ssrApollo.js
index ca1e5059f..6ce7c77dc 100644
--- a/api/ssrApollo.js
+++ b/api/ssrApollo.js
@@ -153,19 +153,16 @@ export function getGetServerSideProps (
const client = await getSSRApolloClient({ req, res })
- // TODO: doesn't seem the best place for this
- let branding = null
-
- // custom domain SSR check
- const customDomain = {
- // TODO: pass domain
- isCustomDomain: req.headers.host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, ''),
- subName: req.headers['x-stacker-news-subname'] || null
- }
- if (customDomain?.isCustomDomain && customDomain?.subName) {
- // TODO: cleanup
- const { data: { customBranding } } = await client.query({ query: GET_CUSTOM_BRANDING, variables: { subName: customDomain?.subName } })
- branding = customBranding
+ 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 })
@@ -233,7 +230,6 @@ export function getGetServerSideProps (
props: {
...props,
customDomain,
- branding,
me,
price,
blockHeight,
diff --git a/components/domains/branding/custom-styles.js b/components/domains/branding/custom-styles.js
deleted file mode 100644
index e89054488..000000000
--- a/components/domains/branding/custom-styles.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { useBranding } from './index'
-import { useEffect } from 'react'
-
-export default function CustomStyles () {
- const branding = useBranding()
-
- useEffect(() => {
- if (branding && branding.primaryColor) {
- // dynamic colors
- document.documentElement.style.setProperty('--bs-primary', branding.primaryColor)
- document.documentElement.style.setProperty('--bs-secondary', branding.secondaryColor)
- document.documentElement.style.setProperty('--theme-primary', branding.primaryColor)
-
- // hex to rgb for compat
- document.documentElement.style.setProperty('--bs-primary-rgb', hexToRgb(branding.primaryColor))
- document.documentElement.style.setProperty('--bs-secondary-rgb', hexToRgb(branding.secondaryColor))
- }
-
- return () => {
- // TODO: not sure if this is a good practice: reset to default values when component unmounts
- document.documentElement.style.removeProperty('--bs-primary')
- document.documentElement.style.removeProperty('--bs-secondary')
- document.documentElement.style.removeProperty('--bs-primary-rgb')
- document.documentElement.style.removeProperty('--bs-secondary-rgb')
- }
- }, [branding])
-}
-
-// hex to rgb for compat
-function hexToRgb (hex) {
- hex = hex.replace('#', '')
- const r = parseInt(hex.substring(0, 2), 16)
- const g = parseInt(hex.substring(2, 4), 16)
- const b = parseInt(hex.substring(4, 6), 16)
- return `${r}, ${g}, ${b}`
-}
diff --git a/components/domains/branding/index.js b/components/domains/branding/index.js
deleted file mode 100644
index 83ab8d5b5..000000000
--- a/components/domains/branding/index.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import { createContext, useContext, useState, useEffect } from 'react'
-import CustomStyles from './custom-styles'
-import Head from 'next/head'
-import { useQuery } from '@apollo/client'
-import { GET_CUSTOM_BRANDING } from '@/fragments/brandings'
-import { useDomain } from '@/components/domains/territory-domains'
-
-const defaultBranding = {
- primaryColor: '#FADA5E',
- secondaryColor: '#F6911D',
- title: 'stacker news',
- logo: null,
- favicon: null
-}
-
-const BrandingContext = createContext(defaultBranding)
-
-export const BrandingProvider = ({ children, customBranding }) => {
- const { customDomain } = useDomain()
- const [branding, setBranding] = useState(customBranding)
-
- const { data } = useQuery(GET_CUSTOM_BRANDING, {
- skip: !!customBranding,
- variables: {
- subName: customDomain?.subName
- },
- fetchPolicy: 'cache-and-network'
- })
-
- useEffect(() => {
- if (customBranding) {
- setBranding(customBranding)
- } else if (data) {
- setBranding(data?.customBranding || defaultBranding)
- }
- }, [data, customBranding])
-
- return (
-
- {branding && (
- <>
-
- {branding?.title && {branding?.title} }
- {branding?.favicon && }
-
- {branding?.primaryColor && }
- >
- )}
- {children}
-
- )
-}
-
-export const useBranding = () => useContext(BrandingContext)
diff --git a/components/nav/common.js b/components/nav/common.js
index 2ebda314d..a765db56f 100644
--- a/components/nav/common.js
+++ b/components/nav/common.js
@@ -25,7 +25,7 @@ import { useWallets } from '@/wallets/index'
import SwitchAccountList, { nextAccount, useAccounts } from '@/components/account'
import { useShowModal } from '@/components/modal'
import { numWithUnits } from '@/lib/format'
-import { useDomain } from '@/components/domains/territory-domains'
+import { useDomain } from '@/components/territory-domains'
export function Brand ({ className }) {
return (
@@ -292,7 +292,9 @@ function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const { removeLocalWallets } = useWallets()
const router = useRouter()
- const { customDomain: { isCustomDomain } } = useDomain()
+ const { customDomain: { domain } } = useDomain()
+
+ const isCustomDomain = !!domain
return (
diff --git a/components/nav/desktop/second-bar.js b/components/nav/desktop/second-bar.js
index 8cef17ec0..8e3c49f7d 100644
--- a/components/nav/desktop/second-bar.js
+++ b/components/nav/desktop/second-bar.js
@@ -3,7 +3,8 @@ import { NavSelect, PostItem, Sorts, hasNavSelect } from '../common'
import styles from '../../header.module.css'
export default function SecondBar (props) {
- const { prefix, topNavKey, isCustomDomain, sub } = props
+ const { prefix, topNavKey, domain, sub } = props
+ const isCustomDomain = !!domain
if (!hasNavSelect(props)) return null
return (
diff --git a/components/nav/index.js b/components/nav/index.js
index ddd25c64b..83e9c7e42 100644
--- a/components/nav/index.js
+++ b/components/nav/index.js
@@ -3,24 +3,24 @@ import DesktopHeader from './desktop/header'
import MobileHeader from './mobile/header'
import StickyBar from './sticky-bar'
import { PriceCarouselProvider } from './price-carousel'
-import { useDomain } from '@/components/domains/territory-domains'
+import { useDomain } from '@/components/territory-domains'
export default function Navigation ({ sub }) {
const router = useRouter()
- const { customDomain: { isCustomDomain } } = useDomain()
+ const { customDomain: { domain } } = useDomain()
const path = router.asPath.split('?')[0]
const props = {
prefix: sub ? `/~${sub}` : '',
path,
pathname: router.pathname,
- topNavKey: isCustomDomain
+ topNavKey: domain
? path.split('/')[1] ?? ''
: path.split('/')[sub ? 2 : 1] ?? '',
- dropNavKey: isCustomDomain
+ dropNavKey: domain
? path.split('/').slice(1).join('/')
: path.split('/').slice(sub ? 2 : 1).join('/'),
- isCustomDomain,
+ domain,
sub
}
diff --git a/components/nav/mobile/top-bar.js b/components/nav/mobile/top-bar.js
index a8f77f887..1f653b1d0 100644
--- a/components/nav/mobile/top-bar.js
+++ b/components/nav/mobile/top-bar.js
@@ -3,8 +3,9 @@ import styles from '../../header.module.css'
import { Back, NavPrice, NavSelect, NavWalletSummary, SignUpButton, hasNavSelect } from '../common'
import { useMe } from '@/components/me'
-export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey, isCustomDomain }) {
+export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey, domain }) {
const { me } = useMe()
+ const isCustomDomain = !!domain
if (hasNavSelect({ path, pathname }) && isCustomDomain) return null
return (
diff --git a/components/seo.js b/components/seo.js
index 446a1052c..9962e73ca 100644
--- a/components/seo.js
+++ b/components/seo.js
@@ -2,14 +2,13 @@ import { NextSeo } from 'next-seo'
import { useRouter } from 'next/router'
import removeMd from 'remove-markdown'
import { numWithUnits } from '@/lib/format'
-import { useBranding } from '@/components/domains/branding'
-import { useDomain } from '@/components/domains/territory-domains'
+import { useDomain } from '@/components/territory-domains'
export function SeoSearch ({ sub }) {
const router = useRouter()
- const { customDomain: { isCustomDomain } } = useDomain()
- const { title } = isCustomDomain ? useBranding() : { title: 'stacker news' }
- const subStr = sub && !isCustomDomain ? ` ~${sub}` : ''
+ const { customDomain: { domain, branding } } = useDomain()
+ const title = branding?.title || 'stacker news'
+ const subStr = sub && !domain ? ` ~${sub}` : ''
const snStr = `${router.query.q || 'search'} \\ ${title}${subStr}`
const desc = `SN${subStr} search: ${router.query.q || ''}`
@@ -43,11 +42,11 @@ export function SeoSearch ({ sub }) {
export default function Seo ({ sub, item, user }) {
const router = useRouter()
- const { customDomain: { isCustomDomain } } = useDomain()
- const { title } = isCustomDomain ? useBranding() : { title: 'stacker news' }
+ const { customDomain: { domain, branding } } = useDomain()
+ const title = branding?.title || 'stacker news'
const pathNoQuery = router.asPath.split('?')[0]
const defaultTitle = pathNoQuery.slice(1)
- const snStr = `${title}${sub && !isCustomDomain ? ` ~${sub}` : ''}`
+ const snStr = `${title}${sub && !domain ? ` ~${sub}` : ''}`
let fullTitle = `${defaultTitle && `${defaultTitle} \\ `}${title}`
let desc = "It's like Hacker News but we pay you Bitcoin."
if (item) {
diff --git a/components/domains/branding/branding-form.js b/components/territory-branding-form.js
similarity index 90%
rename from components/domains/branding/branding-form.js
rename to components/territory-branding-form.js
index 4aa08d715..411d50147 100644
--- a/components/domains/branding/branding-form.js
+++ b/components/territory-branding-form.js
@@ -1,6 +1,6 @@
-import { Form, SubmitButton, ColorPicker, Input } from '../../form'
+import { Form, SubmitButton, ColorPicker, Input } from './form'
import { useMutation } from '@apollo/client'
-import { useToast } from '../../toast'
+import { useToast } from './toast'
import { brandingSchema } from '@/lib/validate'
import { SET_CUSTOM_BRANDING } from '@/fragments/brandings'
@@ -37,7 +37,7 @@ export default function BrandingForm ({ sub }) {
return (
}
+ body={
+
+
+
+
+
+ }
+ />
+
+ save branding
+
)
}
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 085819ef9..f29adbeae 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -41,7 +41,8 @@ export const DomainProvider = ({ customDomain: ssrCustomDomain, children }) => {
}, [router.query.type])
const branding = customDomain?.branding || null
-
+ const colors = branding?.colors || null
+ console.log('colors', colors)
return (
{branding && (
@@ -50,7 +51,7 @@ export const DomainProvider = ({ customDomain: ssrCustomDomain, children }) => {
{branding?.title && {branding?.title} }
{branding?.favicon && }
- {branding?.primaryColor && }
+ {colors && }
>
)}
{children}
@@ -202,35 +203,43 @@ export default function CustomDomainForm ({ sub }) {
export function CustomStyles ({ branding }) {
useEffect(() => {
- if (branding && branding.primaryColor) {
- // TODO: mvp placeholder transition
- document.documentElement.style.setProperty('--bs-transition', 'all 0.3s ease')
+ const colors = branding?.colors || null
+ if (colors) {
const styleElement = document.createElement('style')
styleElement.textContent = `
+ :root {
+ --bs-transition: all 0.3s ease;
+ --bs-primary: ${colors.primary};
+ --bs-secondary: ${colors.secondary};
+ --bs-info: ${colors.info};
+ --bs-success: ${colors.success};
+ --bs-danger: ${colors.danger};
+ --bs-primary-rgb: ${hexToRgb(colors.primary)};
+ --bs-secondary-rgb: ${hexToRgb(colors.secondary)};
+ --bs-info-rgb: ${hexToRgb(colors.info)};
+ --bs-success-rgb: ${hexToRgb(colors.success)};
+ --bs-danger-rgb: ${hexToRgb(colors.danger)};
+ }
+
+ .navbar-brand svg {
+ transition: var(--bs-transition);
+ fill: var(--bs-primary);
+ }
+
.btn-primary, .btn-secondary,
.bg-primary, .bg-secondary,
.text-primary, .text-secondary,
- .border-primary, .border-secondary,
- svg,
+ .border-primary, .border-secondary
[class*="btn-outline-primary"], [class*="btn-outline-secondary"],
[style*="--bs-primary"], [style*="--bs-secondary"] {
transition: var(--bs-transition);
}
`
document.head.appendChild(styleElement)
- // dynamic colors
- document.documentElement.style.setProperty('--bs-primary', branding.primaryColor)
- document.documentElement.style.setProperty('--bs-secondary', branding.secondaryColor)
- // hex to rgb for compat
- document.documentElement.style.setProperty('--bs-primary-rgb', hexToRgb(branding.primaryColor))
- document.documentElement.style.setProperty('--bs-secondary-rgb', hexToRgb(branding.secondaryColor))
+
+ // Cleanup function
return () => {
- // TODO: not sure if this is a good practice: reset to default values when component unmounts
- document.documentElement.style.removeProperty('transition')
- document.documentElement.style.removeProperty('--bs-primary')
- document.documentElement.style.removeProperty('--bs-secondary')
- document.documentElement.style.removeProperty('--bs-primary-rgb')
- document.documentElement.style.removeProperty('--bs-secondary-rgb')
+ document.head.removeChild(styleElement)
}
}
}, [branding])
diff --git a/fragments/brandings.js b/fragments/brandings.js
index 104c21cc5..1d49ffd39 100644
--- a/fragments/brandings.js
+++ b/fragments/brandings.js
@@ -4,8 +4,7 @@ export const GET_CUSTOM_BRANDING = gql`
query CustomBranding($subName: String!) {
customBranding(subName: $subName) {
title
- primaryColor
- secondaryColor
+ colors
logoId
faviconId
subName
@@ -16,8 +15,7 @@ export const GET_CUSTOM_BRANDING = gql`
export const GET_CUSTOM_BRANDING_FIELDS = gql`
fragment CustomBrandingFields on CustomBranding {
title
- primaryColor
- secondaryColor
+ colors
logoId
faviconId
subName
diff --git a/fragments/subs.js b/fragments/subs.js
index 3cd3293b1..36c502d60 100644
--- a/fragments/subs.js
+++ b/fragments/subs.js
@@ -50,8 +50,7 @@ export const SUB_FIELDS = gql`
}
customBranding {
title
- primaryColor
- secondaryColor
+ colors
logoId
faviconId
}
diff --git a/prisma/migrations/20250531003413_custom_brandings/migration.sql b/prisma/migrations/20250531003413_custom_brandings/migration.sql
index bdbbf3681..884a34733 100644
--- a/prisma/migrations/20250531003413_custom_brandings/migration.sql
+++ b/prisma/migrations/20250531003413_custom_brandings/migration.sql
@@ -2,8 +2,7 @@
CREATE TABLE "CustomBranding" (
"id" SERIAL NOT NULL,
"title" TEXT,
- "primaryColor" TEXT,
- "secondaryColor" TEXT,
+ "colors" JSONB DEFAULT '{}',
"logoId" INTEGER,
"faviconId" INTEGER,
"subName" CITEXT NOT NULL,
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 8f68c849f..295d99a49 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1265,8 +1265,7 @@ model CustomDomain {
model CustomBranding {
id Int @id @default(autoincrement())
title String?
- primaryColor String?
- secondaryColor String?
+ colors Json? @default("{}")
logoId Int?
logo Upload? @relation("LogoUpload", fields: [logoId], references: [id])
faviconId Int?
diff --git a/styles/globals.scss b/styles/globals.scss
index 04343e6dc..055b36b91 100644
--- a/styles/globals.scss
+++ b/styles/globals.scss
@@ -217,13 +217,10 @@ $zindex-sticky: 900;
}
.btn-primary {
- --bs-btn-color: #212529;
--bs-btn-bg: var(--bs-primary);
--bs-btn-border-color: var(--bs-primary);
--bs-btn-hover-border-color: var(--bs-primary);
- --bs-btn-hover-color: #212529;
--bs-btn-hover-bg: var(--bs-primary);
- --bs-btn-active-color: #212529;
--bs-btn-active-bg: var(--bs-primary);
--bs-btn-focus-shadow-rgb: 233, 236, 239;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
@@ -233,13 +230,10 @@ $zindex-sticky: 900;
}
.btn-secondary {
- --bs-btn-color: #212529;
--bs-btn-bg: var(--bs-secondary);
--bs-btn-border-color: var(--bs-secondary);
--bs-btn-hover-border-color: var(--bs-secondary);
- --bs-btn-hover-color: #212529;
--bs-btn-hover-bg: var(--bs-secondary);
- --bs-btn-active-color: #212529;
--bs-btn-active-bg: var(--bs-secondary);
--bs-btn-focus-shadow-rgb: 233, 236, 239;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
@@ -248,6 +242,45 @@ $zindex-sticky: 900;
--bs-btn-disabled-border-color: var(--bs-secondary);
}
+.btn-info {
+ --bs-btn-bg: var(--bs-info);
+ --bs-btn-border-color: var(--bs-info);
+ --bs-btn-hover-border-color: var(--bs-info);
+ --bs-btn-hover-bg: var(--bs-info);
+ --bs-btn-active-bg: var(--bs-info);
+ --bs-btn-focus-shadow-rgb: 233, 236, 239;
+ --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+ --bs-btn-disabled-color: var(--bs-info);
+ --bs-btn-disabled-bg: transparent;
+ --bs-btn-disabled-border-color: var(--bs-info);
+}
+
+.btn-success {
+ --bs-btn-bg: var(--bs-success);
+ --bs-btn-border-color: var(--bs-success);
+ --bs-btn-hover-border-color: var(--bs-success);
+ --bs-btn-hover-bg: var(--bs-success);
+ --bs-btn-active-bg: var(--bs-success);
+ --bs-btn-focus-shadow-rgb: 233, 236, 239;
+ --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+ --bs-btn-disabled-color: var(--bs-success);
+ --bs-btn-disabled-bg: transparent;
+ --bs-btn-disabled-border-color: var(--bs-success);
+}
+
+.btn-danger {
+ --bs-btn-bg: var(--bs-danger);
+ --bs-btn-border-color: var(--bs-danger);
+ --bs-btn-hover-border-color: var(--bs-danger);
+ --bs-btn-hover-bg: var(--bs-danger);
+ --bs-btn-active-bg: var(--bs-danger);
+ --bs-btn-focus-shadow-rgb: 233, 236, 239;
+ --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+ --bs-btn-disabled-color: var(--bs-danger);
+ --bs-btn-disabled-bg: transparent;
+ --bs-btn-disabled-border-color: var(--bs-danger);
+}
+
* {
scroll-margin-top: 60px;
From 18a0675237612d9f65894a9d2d499f01ffb4b1a5 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Wed, 2 Apr 2025 04:18:52 -0500
Subject: [PATCH 50/74] logo and favicon upload, branding schema validation,
experimental logo and favicon placements
---
api/resolvers/branding.js | 19 +++++++------
api/typeDefs/branding.js | 8 +++---
components/form.js | 17 +++++++++++
components/nav/common.js | 26 +++++++++++++++--
components/territory-branding-form.js | 41 +++++++++++++++++++++++++--
lib/validate.js | 17 +++++++++++
6 files changed, 110 insertions(+), 18 deletions(-)
diff --git a/api/resolvers/branding.js b/api/resolvers/branding.js
index c6d66e484..f2d99905e 100644
--- a/api/resolvers/branding.js
+++ b/api/resolvers/branding.js
@@ -23,15 +23,18 @@ export default {
const { title, colors, logoId, faviconId } = branding
- if (logoId) {
- const logo = await models.upload.findUnique({ where: { id: logoId } })
+ 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 (faviconId) {
- const favicon = await models.upload.findUnique({ where: { id: faviconId } })
+ if (parsedFaviconId) {
+ const favicon = await models.upload.findUnique({ where: { id: parsedFaviconId } })
if (!favicon) {
throw new GqlInputError('favicon not found')
}
@@ -44,14 +47,14 @@ export default {
update: {
title: title || subName,
colors,
- ...(logoId && { logo: { connect: { id: logoId } } }),
- ...(faviconId && { favicon: { connect: { id: faviconId } } })
+ ...(parsedLogoId && { logo: { connect: { id: parsedLogoId } } }),
+ ...(parsedFaviconId && { favicon: { connect: { id: parsedFaviconId } } })
},
create: {
title: title || subName,
colors,
- ...(logoId && { logo: { connect: { id: logoId } } }),
- ...(faviconId && { favicon: { connect: { id: faviconId } } }),
+ ...(parsedLogoId && { logo: { connect: { id: parsedLogoId } } }),
+ ...(parsedFaviconId && { favicon: { connect: { id: parsedFaviconId } } }),
sub: { connect: { name: subName } }
}
})
diff --git a/api/typeDefs/branding.js b/api/typeDefs/branding.js
index b6ef30ed6..085955eb8 100644
--- a/api/typeDefs/branding.js
+++ b/api/typeDefs/branding.js
@@ -12,16 +12,16 @@ export default gql`
type CustomBranding {
title: String
colors: JSONObject
- logoId: Int
- faviconId: Int
+ logoId: String
+ faviconId: String
subName: String
}
input CustomBrandingInput {
title: String
colors: JSONObject
- logoId: Int
- faviconId: Int
+ logoId: String
+ faviconId: String
subName: String
}
`
diff --git a/components/form.js b/components/form.js
index c048ee8b3..2c7daf43a 100644
--- a/components/form.js
+++ b/components/form.js
@@ -41,6 +41,7 @@ import dynamic from 'next/dynamic'
import { qrImageSettings } from './qr'
import { useIsClient } from './use-client'
import PageLoading from './page-loading'
+import Avatar from './avatar'
export class SessionRequiredError extends Error {
constructor () {
@@ -1409,5 +1410,21 @@ export function ColorPicker ({ label, groupClassName, name, ...props }) {
)
}
+export function BrandingUpload ({ label, groupClassName, name, ...props }) {
+ const [, , helpers] = useField({ ...props, name })
+
+ return (
+
+ {
+ // This is called when the upload is successful
+ // We'll update the form value for logoId
+ helpers.setValue(id)
+ }}
+ />
+
+ )
+}
+
export const ClientInput = Client(Input)
export const ClientCheckbox = Client(Checkbox)
diff --git a/components/nav/common.js b/components/nav/common.js
index fe42dd4af..01f7eb066 100644
--- a/components/nav/common.js
+++ b/components/nav/common.js
@@ -28,10 +28,22 @@ import { numWithUnits } from '@/lib/format'
import { useDomain } from '@/components/territory-domains'
export function Brand ({ className }) {
+ const { customDomain } = useDomain()
+ const branding = customDomain?.branding || {}
return (
-
+ {branding.logoId
+ ? (
+
+ )
+ : (
+
+ )}
)
@@ -120,11 +132,19 @@ export function NavSelect ({ sub: subName, className, size }) {
export function NavNotifications ({ className }) {
const hasNewNotes = useHasNewNotes()
-
+ const { customDomain } = useDomain()
+ const branding = customDomain?.branding || {}
return (
<>
-
+
diff --git a/components/territory-branding-form.js b/components/territory-branding-form.js
index 8a4b991f8..50ce35390 100644
--- a/components/territory-branding-form.js
+++ b/components/territory-branding-form.js
@@ -1,7 +1,7 @@
-import { Form, SubmitButton, ColorPicker, Input } from './form'
+import { Form, SubmitButton, ColorPicker, Input, BrandingUpload } from './form'
import { useMutation } from '@apollo/client'
import { useToast } from './toast'
-import { brandingSchema } from '@/lib/validate'
+import { customBrandingSchema } from '@/lib/validate'
import { SET_CUSTOM_BRANDING } from '@/fragments/brandings'
import AccordianItem from './accordian-item'
@@ -36,6 +36,8 @@ export default function BrandingForm ({ sub }) {
}
}
+ console.log(`${process.env.NEXT_PUBLIC_MEDIA_URL}/${sub.customBranding.logoId}`)
+
const initialValues = {
title: sub?.customBranding?.title || sub?.subName,
primary: sub?.customBranding?.colors?.primary || '#FADA5E',
@@ -51,7 +53,7 @@ export default function BrandingForm ({ sub }) {
return (
@@ -69,6 +71,39 @@ export default function BrandingForm ({ sub }) {
}
/>
+ logo and favicon}
+ body={
+
+
+
logo
+
+ {sub?.customBranding?.logoId && (
+
+ )}
+
+
+
+
+
favicon
+
+ {sub?.customBranding?.faviconId && (
+
+ )}
+
+
+
+
+ }
+ />
save branding
diff --git a/lib/validate.js b/lib/validate.js
index 0e39a9078..0fcc6f34d 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -355,6 +355,23 @@ export function customDomainSchema (args) {
})
}
+export function customBrandingSchema (args) {
+ const hexRegex = /^#([0-9a-fA-F]{6})$/
+ return object({
+ title: string().trim().max(
+ 50,
+ ({ max, value }) => `-${Math.abs(max - value.length)} characters remaining`
+ ).nullable(),
+ primary: string().matches(hexRegex, 'must be a valid hex color code (e.g. #FADA5E)').nullable(),
+ secondary: string().matches(hexRegex, 'must be a valid hex color code (e.g. #F6911D)').nullable(),
+ info: string().matches(hexRegex, 'must be a valid hex color code (e.g. #007cbe)').nullable(),
+ success: string().matches(hexRegex, 'must be a valid hex color code (e.g. #5c8001)').nullable(),
+ danger: string().matches(hexRegex, 'must be a valid hex color code (e.g. #c03221)').nullable(),
+ logoId: string().nullable(),
+ faviconId: string().nullable()
+ })
+}
+
export function userSchema (args) {
return object({
name: nameValidator
From 6d252869d8bb36ebd8c03de9b21abbacd841620c Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Thu, 3 Apr 2025 15:58:11 -0500
Subject: [PATCH 51/74] polling status on territory upsert, dynamic favicon and
brand, verify domain every 30 seconds for up to 10 times on upsert, maintain
routine domain verification every 10 minutes
---
api/resolvers/domain.js | 7 +-
components/form.js | 6 +-
components/territory-branding-form.js | 45 +++--
components/territory-domains.js | 11 +-
.../migration.sql | 4 +-
worker/domainVerification.js | 157 ++++++++++--------
worker/index.js | 5 +-
7 files changed, 137 insertions(+), 98 deletions(-)
diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js
index c7d42fbc3..d4bc7a22d 100644
--- a/api/resolvers/domain.js
+++ b/api/resolvers/domain.js
@@ -32,7 +32,7 @@ export default {
if (existing && existing.domain === domain) {
throw new GqlInputError('domain already set')
}
- return await models.customDomain.upsert({
+ const updatedDomain = await models.customDomain.upsert({
where: { subName },
update: {
domain,
@@ -49,6 +49,11 @@ export default {
}
}
})
+
+ // schedule domain verification in 5 seconds, then every 30 seconds
+ await models.$executeRaw`INSERT INTO pgboss.job (name, data)
+ VALUES ('immediateDomainVerification', jsonb_build_object('domainId', ${updatedDomain.id}::INTEGER))`
+ return updatedDomain
} else {
try {
return await models.customDomain.delete({ where: { subName } })
diff --git a/components/form.js b/components/form.js
index 2c7daf43a..57ab6338f 100644
--- a/components/form.js
+++ b/components/form.js
@@ -1416,11 +1416,7 @@ export function BrandingUpload ({ label, groupClassName, name, ...props }) {
return (
{
- // This is called when the upload is successful
- // We'll update the form value for logoId
- helpers.setValue(id)
- }}
+ onSuccess={(id) => helpers.setValue(id)}
/>
)
diff --git a/components/territory-branding-form.js b/components/territory-branding-form.js
index 50ce35390..21b092b60 100644
--- a/components/territory-branding-form.js
+++ b/components/territory-branding-form.js
@@ -4,6 +4,7 @@ import { useToast } from './toast'
import { customBrandingSchema } from '@/lib/validate'
import { SET_CUSTOM_BRANDING } from '@/fragments/brandings'
import AccordianItem from './accordian-item'
+import SnIcon from '@/svgs/sn.svg'
export default function BrandingForm ({ sub }) {
const [setCustomBranding] = useMutation(SET_CUSTOM_BRANDING)
@@ -36,8 +37,6 @@ export default function BrandingForm ({ sub }) {
}
}
- console.log(`${process.env.NEXT_PUBLIC_MEDIA_URL}/${sub.customBranding.logoId}`)
-
const initialValues = {
title: sub?.customBranding?.title || sub?.subName,
primary: sub?.customBranding?.colors?.primary || '#FADA5E',
@@ -49,7 +48,7 @@ export default function BrandingForm ({ sub }) {
faviconId: sub?.customBranding?.faviconId || null
}
- // TODO: add logo and favicon upload
+ // TODO: cleanup
return (
logo
- {sub?.customBranding?.logoId && (
-
- )}
+ {sub?.customBranding?.logoId
+ ? (
+
+ )
+ : (
+
+
+
+ )}
favicon
- {sub?.customBranding?.faviconId && (
-
- )}
+ {sub?.customBranding?.faviconId
+ ? (
+
+ )
+ : (
+
+
+
+ )}
diff --git a/components/territory-domains.js b/components/territory-domains.js
index f29adbeae..834bddee8 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -11,6 +11,7 @@ import { useRouter } from 'next/router'
import { signIn } from 'next-auth/react'
import BrandingForm from '@/components/territory-branding-form'
import Head from 'next/head'
+import Moon from '@/svgs/moon-fill.svg'
// Domain context for custom domains
const DomainContext = createContext({
@@ -85,7 +86,7 @@ const getSSLStatusBadge = (status) => {
}
}
-export function DomainLabel ({ customDomain }) {
+export function DomainLabel ({ customDomain, isPolling }) {
const { domain, dnsState, sslState, lastVerifiedAt } = customDomain || {}
return (
@@ -95,6 +96,7 @@ export function DomainLabel ({ customDomain }) {
{getStatusBadge(dnsState)}
{getSSLStatusBadge(sslState)}
+ {isPolling && }
)}
@@ -139,6 +141,7 @@ export function DomainGuidelines ({ customDomain }) {
// TODO: clean this up, might not need all this refreshing, plus all this polling is not done correctly
export default function CustomDomainForm ({ sub }) {
+ const [isPolling, setIsPolling] = useState(false)
const [setCustomDomain] = useMutation(SET_CUSTOM_DOMAIN)
// Get the custom domain and poll for changes
@@ -153,7 +156,9 @@ export default function CustomDomainForm ({ sub }) {
useEffect(() => {
if (sslState === 'VERIFIED' && dnsState === 'VERIFIED') {
stopPolling()
+ setIsPolling(false)
} else {
+ setIsPolling(true)
startPolling(NORMAL_POLL_INTERVAL)
}
}, [data, stopPolling])
@@ -162,6 +167,7 @@ export default function CustomDomainForm ({ sub }) {
const onSubmit = async ({ domain }) => {
try {
stopPolling()
+ setIsPolling(false)
await setCustomDomain({
variables: {
subName: sub.name,
@@ -169,6 +175,7 @@ export default function CustomDomainForm ({ sub }) {
}
})
refetch()
+ setIsPolling(true)
startPolling(NORMAL_POLL_INTERVAL)
toaster.success('domain updated successfully')
} catch (error) {
@@ -187,7 +194,7 @@ export default function CustomDomainForm ({ sub }) {
{/* TODO: too many flexes */}
}
+ label={
}
name='domain'
placeholder='www.example.com'
/>
diff --git a/prisma/migrations/20250304121322_custom_domains/migration.sql b/prisma/migrations/20250304121322_custom_domains/migration.sql
index 13ccc8f0a..2734f2272 100644
--- a/prisma/migrations/20250304121322_custom_domains/migration.sql
+++ b/prisma/migrations/20250304121322_custom_domains/migration.sql
@@ -38,9 +38,9 @@ LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
- -- every 5 minutes
+ -- every 10 minutes
INSERT INTO pgboss.schedule (name, cron, timezone)
- VALUES ('domainVerification', '*/5 * * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
+ VALUES ('routineDomainVerification', '*/10 * * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
return 0;
EXCEPTION WHEN OTHERS THEN
return 0;
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index b1b18f4f3..1640eae75 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -3,7 +3,7 @@ import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus, getVal
// This worker verifies the DNS and SSL certificates for domains that are pending or failed
// It will also delete domains that have failed to verify 5 times
-export async function domainVerification () {
+export async function routineDomainVerification () {
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
try {
@@ -18,83 +18,102 @@ export async function domainVerification () {
}
})
- for (const domain of domains) {
+ await Promise.all(domains.map(async (domain) => {
try {
- // set lastVerifiedAt to now
- const data = { ...domain, lastVerifiedAt: new Date() }
+ await verifyDomain(domain, models)
+ } catch (error) {
+ console.error(`Failed to verify domain ${domain.domain}:`, error)
+ domain.failedAttempts += 1
+ if (domain.failedAttempts >= 5) {
+ await models.customDomain.delete({ where: { id: domain.id } })
+ }
+ }
+ }))
+ } catch (error) {
+ console.error('cannot verify domains:', error)
+ } finally {
+ await models.$disconnect()
+ }
+}
- // DNS verification on pending or failed domains
- if (data.dnsState !== 'VERIFIED') {
- const { txtValid, cnameValid } = await verifyDomainDNS(data.domain, data.verificationTxt)
- console.log(`${data.domain}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
+export async function immediateDomainVerification ({ data: { domainId }, boss }) {
+ const models = createPrisma({ connectionParams: { connection_limit: 1 } })
+ console.log('immediateDomainVerification', domainId)
+ const domain = await models.customDomain.findUnique({ where: { id: domainId } })
+ console.log('domain', domain)
+ const result = await verifyDomain(domain, models)
+ if (result) {
+ if (result.dnsState !== 'VERIFIED' || result.sslState !== 'VERIFIED') {
+ await boss.send('immediateDomainVerification', { domainId }, { startAfter: new Date(Date.now() + 30 * 1000) })
+ }
+ }
+}
- // update dnsState to VERIFIED if both TXT and CNAME are valid, otherwise set to FAILED
- data.dnsState = txtValid && cnameValid ? 'VERIFIED' : 'FAILED'
- }
+async function verifyDomain (domain, models) {
+ // track verification
+ const data = { ...domain, lastVerifiedAt: new Date() }
- // issue SSL certificate for verified domains, if we didn't already or we failed to issue it
- if (data.dnsState === 'VERIFIED' && (!data.certificateArn || data.sslState === 'FAILED')) {
- // use ACM to issue a certificate for the domain
- const certificateArn = await issueDomainCertificate(data.domain)
- console.log(`${data.domain}: Certificate issued: ${certificateArn}`)
- if (certificateArn) {
- // get the status of the certificate
- const sslState = await checkCertificateStatus(certificateArn)
- console.log(`${data.domain}: Issued certificate status: ${sslState}`)
- // if we didn't validate already, obtain the ACM CNAME values for the certificate validation
- if (sslState !== 'VERIFIED') {
- try {
- // obtain the ACM CNAME values for the certificate validation
- // ACM will use these values to verify the domain
- const { cname, value } = await getValidationValues(certificateArn)
- data.verificationCname = cname
- data.verificationCnameValue = value
- } catch (error) {
- console.error(`Failed to get validation values for domain ${data.domain}:`, error)
- }
- }
- // update the sslState with the status of the certificate
- if (sslState) data.sslState = sslState
- data.certificateArn = certificateArn
- } else {
- // if we failed to issue the certificate, set the sslState to FAILED
- data.sslState = 'FAILED'
- }
- }
+ if (data.dnsState !== 'VERIFIED') {
+ await verifyDNS(data)
+ }
- // update the status of the certificate while pending
- if (data.dnsState === 'VERIFIED' && data.sslState !== 'VERIFIED') {
- const sslState = await checkCertificateStatus(data.certificateArn)
- console.log(`${data.domain}: Certificate status: ${sslState}`)
- if (sslState) data.sslState = sslState
- }
+ if (data.dnsState === 'VERIFIED' && (!data.certificateArn || data.sslState === 'FAILED')) {
+ await issueCertificate(data)
+ }
- // delete domain if any verification has failed 5 times
- if (data.dnsState === 'FAILED' || data.sslState === 'FAILED') {
- data.failedAttempts += 1
- if (data.failedAttempts >= 5) {
- return models.customDomain.delete({ where: { id: domain.id } })
- }
- } else {
- data.failedAttempts = 0
- }
+ if (data.dnsState === 'VERIFIED' && data.sslState !== 'VERIFIED') {
+ await updateCertificateStatus(data)
+ }
+
+ if (data.dnsState === 'FAILED' || data.sslState === 'FAILED') {
+ data.failedAttempts += 1
+ if (data.failedAttempts >= 5) {
+ return await models.customDomain.delete({ where: { id: domain.id } })
+ }
+ } else {
+ data.failedAttempts = 0
+ }
+
+ await models.customDomain.update({ where: { id: domain.id }, data })
+ return data
+}
+
+async function verifyDNS (data) {
+ const { txtValid, cnameValid } = await verifyDomainDNS(data.domain, data.verificationTxt)
+ console.log(`${data.domain}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
+
+ data.dnsState = txtValid && cnameValid ? 'VERIFIED' : 'FAILED'
+ return data
+}
- // update the domain with the new status
- await models.customDomain.update({ where: { id: domain.id }, data })
+async function issueCertificate (data) {
+ const certificateArn = await issueDomainCertificate(data.domain)
+ console.log(`${data.domain}: Certificate issued: ${certificateArn}`)
+
+ if (certificateArn) {
+ const sslState = await checkCertificateStatus(certificateArn)
+ console.log(`${data.domain}: Issued certificate status: ${sslState}`)
+ if (sslState !== 'VERIFIED') {
+ try {
+ const { cname, value } = await getValidationValues(certificateArn)
+ data.verificationCname = cname
+ data.verificationCnameValue = value
} catch (error) {
- console.error(`Failed to verify domain ${domain.domain}:`, error)
- // Update to FAILED on any error
- await models.customDomain.update({
- where: { id: domain.id },
- data: {
- dnsState: 'FAILED',
- lastVerifiedAt: new Date(),
- failedAttempts: domain.failedAttempts + 1
- }
- })
+ console.error(`Failed to get validation values for domain ${data.domain}:`, error)
}
}
- } catch (error) {
- console.error('cannot verify domains:', error)
+ if (sslState) data.sslState = sslState
+ data.certificateArn = certificateArn
+ } else {
+ data.sslState = 'FAILED'
}
+
+ return data
+}
+
+async function updateCertificateStatus (data) {
+ const sslState = await checkCertificateStatus(data.certificateArn)
+ console.log(`${data.domain}: Certificate status: ${sslState}`)
+ if (sslState) data.sslState = sslState
+ return data
}
diff --git a/worker/index.js b/worker/index.js
index 769b80ab6..888e27fc2 100644
--- a/worker/index.js
+++ b/worker/index.js
@@ -38,7 +38,7 @@ import { expireBoost } from './expireBoost'
import { payingActionConfirmed, payingActionFailed } from './payingAction'
import { autoDropBolt11s } from './autoDropBolt11'
import { postToSocial } from './socialPoster'
-import { domainVerification } from './domainVerification'
+import { routineDomainVerification, immediateDomainVerification } from './domainVerification'
// WebSocket polyfill
import ws from 'isomorphic-ws'
@@ -124,7 +124,8 @@ async function work () {
await boss.work('imgproxy', jobWrapper(imgproxy))
await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages))
}
- await boss.work('domainVerification', jobWrapper(domainVerification))
+ await boss.work('routineDomainVerification', jobWrapper(routineDomainVerification))
+ await boss.work('immediateDomainVerification', jobWrapper(immediateDomainVerification))
await boss.work('expireBoost', jobWrapper(expireBoost))
await boss.work('weeklyPost-*', jobWrapper(weeklyPost))
await boss.work('payWeeklyPostBounty', jobWrapper(payWeeklyPostBounty))
From 1fee3af71bdcaa51735f0566402f528375156726 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Thu, 3 Apr 2025 17:03:08 -0500
Subject: [PATCH 52/74] control domain verification via pgboss job, don't
verify domains failed in 24 hours via HOLD status
---
api/resolvers/domain.js | 5 ++-
api/typeDefs/domain.js | 1 +
components/territory-domains.js | 2 +-
fragments/domains.js | 1 +
fragments/subs.js | 1 +
.../migration.sql | 1 +
prisma/schema.prisma | 1 +
worker/domainVerification.js | 34 ++++++++++++-------
8 files changed, 31 insertions(+), 15 deletions(-)
diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js
index d4bc7a22d..b7a139e5b 100644
--- a/api/resolvers/domain.js
+++ b/api/resolvers/domain.js
@@ -38,11 +38,14 @@ export default {
domain,
dnsState: 'PENDING',
sslState: 'WAITING',
+ status: 'PENDING',
certificateArn: null
},
create: {
domain,
dnsState: 'PENDING',
+ sslState: 'WAITING',
+ status: 'PENDING',
verificationTxt: randomBytes(32).toString('base64'),
sub: {
connect: { name: subName }
@@ -50,7 +53,7 @@ export default {
}
})
- // schedule domain verification in 5 seconds, then every 30 seconds
+ // schedule domain verification in 5 seconds, worker will do the rest
await models.$executeRaw`INSERT INTO pgboss.job (name, data)
VALUES ('immediateDomainVerification', jsonb_build_object('domainId', ${updatedDomain.id}::INTEGER))`
return updatedDomain
diff --git a/api/typeDefs/domain.js b/api/typeDefs/domain.js
index 40a69ae96..6d60b7770 100644
--- a/api/typeDefs/domain.js
+++ b/api/typeDefs/domain.js
@@ -22,5 +22,6 @@ export default gql`
verificationCnameValue: String
verificationTxt: String
failedAttempts: Int
+ status: String
}
`
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 834bddee8..1427cd4fd 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -96,7 +96,7 @@ export function DomainLabel ({ customDomain, isPolling }) {
{getStatusBadge(dnsState)}
{getSSLStatusBadge(sslState)}
- {isPolling && }
+ {isPolling && }
)}
diff --git a/fragments/domains.js b/fragments/domains.js
index b876644de..79e45ceab 100644
--- a/fragments/domains.js
+++ b/fragments/domains.js
@@ -9,6 +9,7 @@ export const GET_CUSTOM_DOMAIN = gql`
verificationCname
verificationCnameValue
verificationTxt
+ status
lastVerifiedAt
}
}
diff --git a/fragments/subs.js b/fragments/subs.js
index 36c502d60..edef32325 100644
--- a/fragments/subs.js
+++ b/fragments/subs.js
@@ -47,6 +47,7 @@ export const SUB_FIELDS = gql`
verificationCname
verificationCnameValue
verificationTxt
+ status
}
customBranding {
title
diff --git a/prisma/migrations/20250304121322_custom_domains/migration.sql b/prisma/migrations/20250304121322_custom_domains/migration.sql
index 2734f2272..c292dd613 100644
--- a/prisma/migrations/20250304121322_custom_domains/migration.sql
+++ b/prisma/migrations/20250304121322_custom_domains/migration.sql
@@ -13,6 +13,7 @@ CREATE TABLE "CustomDomain" (
"verificationCnameValue" TEXT,
"verificationTxt" TEXT,
"failedAttempts" INTEGER NOT NULL DEFAULT 0,
+ "status" TEXT,
CONSTRAINT "CustomDomain_pkey" PRIMARY KEY ("id")
);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e72ccf42a..54a7f3d19 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1256,6 +1256,7 @@ model CustomDomain {
verificationCnameValue String?
verificationTxt String?
failedAttempts Int @default(0)
+ status String?
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
@@index([domain])
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 1640eae75..4b9f0c046 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -10,7 +10,7 @@ export async function routineDomainVerification () {
const domains = await models.customDomain.findMany({
where: {
NOT: {
- AND: [{ dnsState: 'VERIFIED' }, { sslState: 'VERIFIED' }]
+ AND: [{ dnsState: 'VERIFIED' }, { sslState: 'VERIFIED' }, { status: 'HOLD' }]
}
},
orderBy: {
@@ -24,8 +24,9 @@ export async function routineDomainVerification () {
} catch (error) {
console.error(`Failed to verify domain ${domain.domain}:`, error)
domain.failedAttempts += 1
- if (domain.failedAttempts >= 5) {
- await models.customDomain.delete({ where: { id: domain.id } })
+ const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
+ if (domain.failedAttempts >= 5 && domain.updatedAt < oneDayAgo) {
+ await models.customDomain.update({ where: { id: domain.id }, data: { status: 'HOLD' } })
}
}
}))
@@ -42,10 +43,17 @@ export async function immediateDomainVerification ({ data: { domainId }, boss })
const domain = await models.customDomain.findUnique({ where: { id: domainId } })
console.log('domain', domain)
const result = await verifyDomain(domain, models)
- if (result) {
- if (result.dnsState !== 'VERIFIED' || result.sslState !== 'VERIFIED') {
- await boss.send('immediateDomainVerification', { domainId }, { startAfter: new Date(Date.now() + 30 * 1000) })
- }
+
+ let startAfter = new Date(Date.now() + 30 * 1000)
+ if (result?.failedAttempts < 5) {
+ // every 30 seconds
+ startAfter = new Date(Date.now() + 30 * 1000)
+ } else {
+ // every 10 minutes
+ startAfter = new Date(Date.now() + 10 * 60 * 1000)
+ }
+ if (result?.status !== 'HOLD') {
+ await boss.send('immediateDomainVerification', { domainId }, { startAfter })
}
}
@@ -67,15 +75,15 @@ async function verifyDomain (domain, models) {
if (data.dnsState === 'FAILED' || data.sslState === 'FAILED') {
data.failedAttempts += 1
- if (data.failedAttempts >= 5) {
- return await models.customDomain.delete({ where: { id: domain.id } })
+ data.updatedAt = new Date()
+ const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
+ // todo: change this
+ if (data.failedAttempts > 10 && data.updatedAt < oneDayAgo) {
+ data.status = 'HOLD'
}
- } else {
- data.failedAttempts = 0
}
- await models.customDomain.update({ where: { id: domain.id }, data })
- return data
+ return await models.customDomain.update({ where: { id: domain.id }, data })
}
async function verifyDNS (data) {
From 207e314350bd98e6a70474f7079caffb9c2aeea4 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Fri, 4 Apr 2025 03:35:07 -0500
Subject: [PATCH 53/74] switch to JSON verification model, use general status
for custom domain recognition, adjust verification states to JSON
---
api/resolvers/domain.js | 36 +++++++--
api/typeDefs/domain.js | 25 +++++--
components/territory-domains.js | 28 ++++---
fragments/domains.js | 34 +++++++--
fragments/subs.js | 19 +++--
pages/api/auth/sync.js | 2 +-
pages/api/domains/index.js | 3 +-
.../migration.sql | 44 +++++------
prisma/schema.prisma | 11 +--
worker/domainVerification.js | 75 ++++++-------------
worker/index.js | 5 +-
11 files changed, 150 insertions(+), 132 deletions(-)
diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js
index b7a139e5b..a83778afa 100644
--- a/api/resolvers/domain.js
+++ b/api/resolvers/domain.js
@@ -29,24 +29,44 @@ export default {
if (domain) {
const existing = await models.customDomain.findUnique({ where: { subName } })
- if (existing && existing.domain === domain) {
+ if (existing && existing.domain === domain && existing.status !== 'HOLD') {
throw new GqlInputError('domain already set')
}
const updatedDomain = await models.customDomain.upsert({
where: { subName },
update: {
domain,
- dnsState: 'PENDING',
- sslState: 'WAITING',
status: 'PENDING',
- certificateArn: null
+ verification: {
+ dns: {
+ state: 'PENDING',
+ cname: 'stacker.news',
+ txt: randomBytes(32).toString('base64')
+ },
+ ssl: {
+ state: 'WAITING',
+ arn: null,
+ cname: null,
+ value: null
+ }
+ }
},
create: {
domain,
- dnsState: 'PENDING',
- sslState: 'WAITING',
status: 'PENDING',
- verificationTxt: randomBytes(32).toString('base64'),
+ verification: {
+ dns: {
+ state: 'PENDING',
+ cname: 'stacker.news',
+ txt: randomBytes(32).toString('base64')
+ },
+ ssl: {
+ state: 'WAITING',
+ arn: null,
+ cname: null,
+ value: null
+ }
+ },
sub: {
connect: { name: subName }
}
@@ -55,7 +75,7 @@ export default {
// schedule domain verification in 5 seconds, worker will do the rest
await models.$executeRaw`INSERT INTO pgboss.job (name, data)
- VALUES ('immediateDomainVerification', jsonb_build_object('domainId', ${updatedDomain.id}::INTEGER))`
+ VALUES ('domainVerification', jsonb_build_object('domainId', ${updatedDomain.id}::INTEGER))`
return updatedDomain
} else {
try {
diff --git a/api/typeDefs/domain.js b/api/typeDefs/domain.js
index 6d60b7770..9a0cdfca2 100644
--- a/api/typeDefs/domain.js
+++ b/api/typeDefs/domain.js
@@ -14,14 +14,27 @@ export default gql`
updatedAt: Date!
domain: String!
subName: String!
- dnsState: String
- sslState: String
- certificateArn: String
lastVerifiedAt: Date
- verificationCname: String
- verificationCnameValue: String
- verificationTxt: String
failedAttempts: Int
status: String
+ verification: CustomDomainVerification
+ }
+
+ 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/components/territory-domains.js b/components/territory-domains.js
index 1427cd4fd..5ca99ac90 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -87,15 +87,19 @@ const getSSLStatusBadge = (status) => {
}
export function DomainLabel ({ customDomain, isPolling }) {
- const { domain, dnsState, sslState, lastVerifiedAt } = customDomain || {}
+ const { domain, status, verification, lastVerifiedAt } = customDomain || {}
return (
custom domain
{domain && (
- {getStatusBadge(dnsState)}
- {getSSLStatusBadge(sslState)}
+ {status !== 'HOLD' && (
+ <>
+ {getStatusBadge(verification?.dns?.state)}
+ {getSSLStatusBadge(verification?.ssl?.state)}
+ >
+ )}
{isPolling && }
@@ -105,10 +109,10 @@ export function DomainLabel ({ customDomain, isPolling }) {
}
export function DomainGuidelines ({ customDomain }) {
- const { domain, dnsState, sslState, verificationTxt, verificationCname, verificationCnameValue } = customDomain || {}
+ const { domain, verification } = customDomain || {}
return (
<>
- {(dnsState && dnsState !== 'VERIFIED') && (
+ {(verification?.dns?.state && verification?.dns?.state !== 'VERIFIED') && (
<>
Step 1: Verify your domain
Add the following DNS records to verify ownership of your domain:
@@ -120,18 +124,18 @@ export function DomainGuidelines ({ customDomain }) {
TXT
Host:
{domain || 'www'}
- Value:
{verificationTxt}
+ Value:
{verification?.dns?.txt}
>
)}
- {sslState === 'PENDING' && (
+ {verification?.ssl?.state === 'PENDING' && (
<>
Step 2: Prepare your domain for SSL
We issued an SSL certificate for your domain. To validate it, add the following CNAME record:
CNAME
- Host:
{verificationCname || 'waiting for SSL certificate'}
- Value:
{verificationCnameValue || 'waiting for SSL certificate'}
+ Host:
{verification?.ssl?.cname || 'waiting for SSL certificate'}
+ Value:
{verification?.ssl?.value || 'waiting for SSL certificate'}
>
)}
@@ -150,11 +154,11 @@ export default function CustomDomainForm ({ sub }) {
: { variables: { subName: sub.name } })
const toaster = useToast()
- const { domain, sslState, dnsState } = data?.customDomain || {}
+ const { domain, status } = data?.customDomain || {}
// Stop polling when the domain is verified
useEffect(() => {
- if (sslState === 'VERIFIED' && dnsState === 'VERIFIED') {
+ if (status !== 'PENDING') {
stopPolling()
setIsPolling(false)
} else {
@@ -202,7 +206,7 @@ export default function CustomDomainForm ({ sub }) {
- {dnsState === 'VERIFIED' && sslState === 'VERIFIED' &&
+ {status === 'ACTIVE' &&
}
>
)
diff --git a/fragments/domains.js b/fragments/domains.js
index 79e45ceab..7c2556df7 100644
--- a/fragments/domains.js
+++ b/fragments/domains.js
@@ -4,13 +4,21 @@ export const GET_CUSTOM_DOMAIN = gql`
query CustomDomain($subName: String!) {
customDomain(subName: $subName) {
domain
- dnsState
- sslState
- verificationCname
- verificationCnameValue
- verificationTxt
status
lastVerifiedAt
+ verification {
+ dns {
+ state
+ cname
+ txt
+ }
+ ssl {
+ state
+ arn
+ cname
+ value
+ }
+ }
}
}
`
@@ -19,7 +27,6 @@ export const GET_CUSTOM_DOMAIN_FULL = gql`
${GET_CUSTOM_DOMAIN}
fragment CustomDomainFull on CustomDomain {
...CustomDomainFields
- certificateArn
failedAttempts
}
`
@@ -28,8 +35,19 @@ export const SET_CUSTOM_DOMAIN = gql`
mutation SetCustomDomain($subName: String!, $domain: String!) {
setCustomDomain(subName: $subName, domain: $domain) {
domain
- dnsState
- sslState
+ verification {
+ dns {
+ state
+ cname
+ txt
+ }
+ ssl {
+ state
+ arn
+ cname
+ value
+ }
+ }
}
}
`
diff --git a/fragments/subs.js b/fragments/subs.js
index edef32325..14a6f200b 100644
--- a/fragments/subs.js
+++ b/fragments/subs.js
@@ -40,14 +40,21 @@ export const SUB_FIELDS = gql`
updatedAt
domain
subName
- dnsState
- sslState
- certificateArn
lastVerifiedAt
- verificationCname
- verificationCnameValue
- verificationTxt
status
+ verification {
+ dns {
+ state
+ cname
+ txt
+ }
+ ssl {
+ state
+ arn
+ cname
+ value
+ }
+ }
}
customBranding {
title
diff --git a/pages/api/auth/sync.js b/pages/api/auth/sync.js
index 4743b7f7e..1c71fbd69 100644
--- a/pages/api/auth/sync.js
+++ b/pages/api/auth/sync.js
@@ -13,7 +13,7 @@ export default async function handler (req, res) {
let customDomain
try {
customDomain = new URL(redirectUrl)
- const domain = await models.customDomain.findUnique({ where: { domain: customDomain.host, sslState: 'VERIFIED' } })
+ const domain = await models.customDomain.findUnique({ where: { domain: customDomain.host, status: 'ACTIVE' } })
if (!domain) {
return res.status(400).json({ status: 'ERROR', reason: 'custom domain not found' })
}
diff --git a/pages/api/domains/index.js b/pages/api/domains/index.js
index 3c89fbe47..11eac4f08 100644
--- a/pages/api/domains/index.js
+++ b/pages/api/domains/index.js
@@ -20,8 +20,7 @@ export default async function handler (req, res) {
subName: true
},
where: {
- dnsState: 'VERIFIED',
- sslState: 'VERIFIED'
+ status: 'ACTIVE'
}
})
diff --git a/prisma/migrations/20250304121322_custom_domains/migration.sql b/prisma/migrations/20250304121322_custom_domains/migration.sql
index c292dd613..ac3badae8 100644
--- a/prisma/migrations/20250304121322_custom_domains/migration.sql
+++ b/prisma/migrations/20250304121322_custom_domains/migration.sql
@@ -5,19 +5,29 @@ CREATE TABLE "CustomDomain" (
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"domain" TEXT NOT NULL,
"subName" CITEXT NOT NULL,
- "dnsState" TEXT,
- "sslState" TEXT,
- "certificateArn" TEXT,
- "lastVerifiedAt" TIMESTAMP(3),
- "verificationCname" TEXT,
- "verificationCnameValue" TEXT,
- "verificationTxt" TEXT,
+ "status" TEXT NOT NULL DEFAULT 'PENDING',
"failedAttempts" INTEGER NOT NULL DEFAULT 0,
- "status" TEXT,
+ "lastVerifiedAt" TIMESTAMP(3),
+ "verification" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "CustomDomain_pkey" PRIMARY KEY ("id")
);
+-- verification jsonb schema
+-- {
+-- "dns": {
+-- "state": "VERIFIED",
+-- "cname": "stacker.news",
+-- "txt": b64 encoded txt value
+-- },
+-- "ssl": {
+-- "state": "VERIFIED",
+-- "cname": acm issued cname,
+-- "value": acm issued cname value,
+-- "arn": "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"
+-- }
+-- }
+
-- CreateIndex
CREATE UNIQUE INDEX "CustomDomain_domain_key" ON "CustomDomain"("domain");
@@ -32,21 +42,3 @@ CREATE INDEX "CustomDomain_created_at_idx" ON "CustomDomain"("created_at");
-- AddForeignKey
ALTER TABLE "CustomDomain" ADD CONSTRAINT "CustomDomain_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
-
-CREATE OR REPLACE FUNCTION schedule_domain_verification_job()
-RETURNS INTEGER
-LANGUAGE plpgsql
-AS $$
-DECLARE
-BEGIN
- -- every 10 minutes
- INSERT INTO pgboss.schedule (name, cron, timezone)
- VALUES ('routineDomainVerification', '*/10 * * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
- return 0;
-EXCEPTION WHEN OTHERS THEN
- return 0;
-END;
-$$;
-
-SELECT schedule_domain_verification_job();
-DROP FUNCTION IF EXISTS schedule_domain_verification_job;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 54a7f3d19..d61adcec5 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1248,15 +1248,10 @@ model CustomDomain {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
domain String @unique
subName String @unique @db.Citext
- dnsState String?
- sslState String?
- certificateArn String?
- lastVerifiedAt DateTime?
- verificationCname String?
- verificationCnameValue String?
- verificationTxt String?
+ status String @default("PENDING")
failedAttempts Int @default(0)
- status String?
+ lastVerifiedAt DateTime?
+ verification Json @default("{}")
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
@@index([domain])
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 4b9f0c046..f30731ce7 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -2,49 +2,14 @@ import createPrisma from '@/lib/create-prisma'
import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus, getValidationValues } from '@/lib/domains'
// This worker verifies the DNS and SSL certificates for domains that are pending or failed
-// It will also delete domains that have failed to verify 5 times
-export async function routineDomainVerification () {
+export async function domainVerification ({ data: { domainId }, boss }) {
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
-
- try {
- const domains = await models.customDomain.findMany({
- where: {
- NOT: {
- AND: [{ dnsState: 'VERIFIED' }, { sslState: 'VERIFIED' }, { status: 'HOLD' }]
- }
- },
- orderBy: {
- failedAttempts: 'asc' // process domains with less failed attempts first
- }
- })
-
- await Promise.all(domains.map(async (domain) => {
- try {
- await verifyDomain(domain, models)
- } catch (error) {
- console.error(`Failed to verify domain ${domain.domain}:`, error)
- domain.failedAttempts += 1
- const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
- if (domain.failedAttempts >= 5 && domain.updatedAt < oneDayAgo) {
- await models.customDomain.update({ where: { id: domain.id }, data: { status: 'HOLD' } })
- }
- }
- }))
- } catch (error) {
- console.error('cannot verify domains:', error)
- } finally {
- await models.$disconnect()
- }
-}
-
-export async function immediateDomainVerification ({ data: { domainId }, boss }) {
- const models = createPrisma({ connectionParams: { connection_limit: 1 } })
- console.log('immediateDomainVerification', domainId)
+ console.log('domainVerification', domainId)
const domain = await models.customDomain.findUnique({ where: { id: domainId } })
console.log('domain', domain)
const result = await verifyDomain(domain, models)
- let startAfter = new Date(Date.now() + 30 * 1000)
+ let startAfter = null
if (result?.failedAttempts < 5) {
// every 30 seconds
startAfter = new Date(Date.now() + 30 * 1000)
@@ -52,28 +17,29 @@ export async function immediateDomainVerification ({ data: { domainId }, boss })
// every 10 minutes
startAfter = new Date(Date.now() + 10 * 60 * 1000)
}
- if (result?.status !== 'HOLD') {
- await boss.send('immediateDomainVerification', { domainId }, { startAfter })
+ if (result?.status === 'PENDING') {
+ await boss.send('domainVerification', { domainId }, { startAfter })
}
}
async function verifyDomain (domain, models) {
// track verification
const data = { ...domain, lastVerifiedAt: new Date() }
+ data.verification = data.verification || { dns: {}, ssl: {} }
- if (data.dnsState !== 'VERIFIED') {
+ if (data.verification?.dns?.state !== 'VERIFIED') {
await verifyDNS(data)
}
- if (data.dnsState === 'VERIFIED' && (!data.certificateArn || data.sslState === 'FAILED')) {
+ if (data.verification?.dns?.state === 'VERIFIED' && (!data.verification?.ssl?.arn || data.verification?.ssl?.state === 'FAILED')) {
await issueCertificate(data)
}
- if (data.dnsState === 'VERIFIED' && data.sslState !== 'VERIFIED') {
+ if (data.verification?.dns?.state === 'VERIFIED' && data.verification?.ssl?.state !== 'VERIFIED' && data.verification?.ssl?.arn) {
await updateCertificateStatus(data)
}
- if (data.dnsState === 'FAILED' || data.sslState === 'FAILED') {
+ if (data.verification?.dns?.state === 'FAILED' || data.verification?.ssl?.state === 'FAILED') {
data.failedAttempts += 1
data.updatedAt = new Date()
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
@@ -83,14 +49,17 @@ async function verifyDomain (domain, models) {
}
}
+ if (data.verification?.dns?.state === 'VERIFIED' && data.verification?.ssl?.state === 'VERIFIED') {
+ data.status = 'ACTIVE'
+ }
return await models.customDomain.update({ where: { id: domain.id }, data })
}
async function verifyDNS (data) {
- const { txtValid, cnameValid } = await verifyDomainDNS(data.domain, data.verificationTxt)
+ const { txtValid, cnameValid } = await verifyDomainDNS(data.domain, data.verification.dns.txt)
console.log(`${data.domain}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
- data.dnsState = txtValid && cnameValid ? 'VERIFIED' : 'FAILED'
+ data.verification.dns.state = txtValid && cnameValid ? 'VERIFIED' : 'FAILED'
return data
}
@@ -101,19 +70,21 @@ async function issueCertificate (data) {
if (certificateArn) {
const sslState = await checkCertificateStatus(certificateArn)
console.log(`${data.domain}: Issued certificate status: ${sslState}`)
+
+ if (sslState) data.verification.ssl.state = sslState
+ data.verification.ssl.arn = certificateArn
+
if (sslState !== 'VERIFIED') {
try {
const { cname, value } = await getValidationValues(certificateArn)
- data.verificationCname = cname
- data.verificationCnameValue = value
+ data.verification.ssl.cname = cname
+ data.verification.ssl.value = value
} catch (error) {
console.error(`Failed to get validation values for domain ${data.domain}:`, error)
}
}
- if (sslState) data.sslState = sslState
- data.certificateArn = certificateArn
} else {
- data.sslState = 'FAILED'
+ data.verification.ssl.state = 'FAILED'
}
return data
@@ -122,6 +93,6 @@ async function issueCertificate (data) {
async function updateCertificateStatus (data) {
const sslState = await checkCertificateStatus(data.certificateArn)
console.log(`${data.domain}: Certificate status: ${sslState}`)
- if (sslState) data.sslState = sslState
+ if (sslState) data.verification.ssl.state = sslState
return data
}
diff --git a/worker/index.js b/worker/index.js
index 888e27fc2..769b80ab6 100644
--- a/worker/index.js
+++ b/worker/index.js
@@ -38,7 +38,7 @@ import { expireBoost } from './expireBoost'
import { payingActionConfirmed, payingActionFailed } from './payingAction'
import { autoDropBolt11s } from './autoDropBolt11'
import { postToSocial } from './socialPoster'
-import { routineDomainVerification, immediateDomainVerification } from './domainVerification'
+import { domainVerification } from './domainVerification'
// WebSocket polyfill
import ws from 'isomorphic-ws'
@@ -124,8 +124,7 @@ async function work () {
await boss.work('imgproxy', jobWrapper(imgproxy))
await boss.work('deleteUnusedImages', jobWrapper(deleteUnusedImages))
}
- await boss.work('routineDomainVerification', jobWrapper(routineDomainVerification))
- await boss.work('immediateDomainVerification', jobWrapper(immediateDomainVerification))
+ await boss.work('domainVerification', jobWrapper(domainVerification))
await boss.work('expireBoost', jobWrapper(expireBoost))
await boss.work('weeklyPost-*', jobWrapper(weeklyPost))
await boss.work('payWeeklyPostBounty', jobWrapper(payWeeklyPostBounty))
From 1e63e4bcc0cc279803aa67b596dd1163c2539451 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Fri, 4 Apr 2025 11:49:55 -0500
Subject: [PATCH 54/74] fix typo domainVerification, better polling control
---
components/territory-domains.js | 36 ++++++++++++++-------------------
worker/domainVerification.js | 2 +-
2 files changed, 16 insertions(+), 22 deletions(-)
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 5ca99ac90..1eac77de3 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -86,7 +86,7 @@ const getSSLStatusBadge = (status) => {
}
}
-export function DomainLabel ({ customDomain, isPolling }) {
+export function DomainLabel ({ customDomain, polling }) {
const { domain, status, verification, lastVerifiedAt } = customDomain || {}
return (
@@ -100,7 +100,7 @@ export function DomainLabel ({ customDomain, isPolling }) {
{getSSLStatusBadge(verification?.ssl?.state)}
>
)}
- {isPolling && }
+ {polling && }
)}
@@ -145,33 +145,29 @@ export function DomainGuidelines ({ customDomain }) {
// TODO: clean this up, might not need all this refreshing, plus all this polling is not done correctly
export default function CustomDomainForm ({ sub }) {
- const [isPolling, setIsPolling] = useState(false)
const [setCustomDomain] = useMutation(SET_CUSTOM_DOMAIN)
// Get the custom domain and poll for changes
- const { data, startPolling, stopPolling, refetch } = useQuery(GET_CUSTOM_DOMAIN, SSR
+ const { data, refetch } = useQuery(GET_CUSTOM_DOMAIN, SSR
? {}
- : { variables: { subName: sub.name } })
+ : {
+ variables: { subName: sub.name },
+ pollInterval: NORMAL_POLL_INTERVAL,
+ nextFetchPolicy: 'cache-and-network',
+ onCompleted: ({ customDomain }) => {
+ if (customDomain?.status !== 'PENDING') {
+ return { pollInterval: 0 }
+ }
+ }
+ })
const toaster = useToast()
const { domain, status } = data?.customDomain || {}
-
- // Stop polling when the domain is verified
- useEffect(() => {
- if (status !== 'PENDING') {
- stopPolling()
- setIsPolling(false)
- } else {
- setIsPolling(true)
- startPolling(NORMAL_POLL_INTERVAL)
- }
- }, [data, stopPolling])
+ const polling = status === 'PENDING'
// Update the custom domain
const onSubmit = async ({ domain }) => {
try {
- stopPolling()
- setIsPolling(false)
await setCustomDomain({
variables: {
subName: sub.name,
@@ -179,8 +175,6 @@ export default function CustomDomainForm ({ sub }) {
}
})
refetch()
- setIsPolling(true)
- startPolling(NORMAL_POLL_INTERVAL)
toaster.success('domain updated successfully')
} catch (error) {
toaster.danger('failed to update domain', { error })
@@ -198,7 +192,7 @@ export default function CustomDomainForm ({ sub }) {
{/* TODO: too many flexes */}
}
+ label={
}
name='domain'
placeholder='www.example.com'
/>
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index f30731ce7..91580c110 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -91,7 +91,7 @@ async function issueCertificate (data) {
}
async function updateCertificateStatus (data) {
- const sslState = await checkCertificateStatus(data.certificateArn)
+ const sslState = await checkCertificateStatus(data.verification.ssl.arn)
console.log(`${data.domain}: Certificate status: ${sslState}`)
if (sslState) data.verification.ssl.state = sslState
return data
From 1ba661fb261dca7ce7e2e6f67af484af578eac93 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 8 Apr 2025 03:36:41 -0500
Subject: [PATCH 55/74] refactor, cleanup: domain mapper in resolver, can get
mappings from gql, only the custom domain's sub can be selected during post,
middleware cleanup, test domain logger
---
.env.development | 5 +-
api/resolvers/domain.js | 5 +
api/typeDefs/domain.js | 6 ++
components/sub-select.js | 3 +
components/territory-branding-form.js | 13 +--
components/territory-domains.js | 2 +-
fragments/domains.js | 9 ++
lib/domain-verification.js | 106 +++++++++++++++++++++
lib/domains.js | 127 ++++++--------------------
middleware.js | 100 ++------------------
worker/domainVerification.js | 2 +-
11 files changed, 181 insertions(+), 197 deletions(-)
create mode 100644 lib/domain-verification.js
diff --git a/.env.development b/.env.development
index 3c3899612..21867b457 100644
--- a/.env.development
+++ b/.env.development
@@ -193,4 +193,7 @@ CPU_SHARES_LOW=256
NEXT_TELEMETRY_DISABLED=1
# DNS resolver for custom domain verification
-DNS_RESOLVER=1.1.1.1
\ No newline at end of file
+DNS_RESOLVER=1.1.1.1
+
+# domain debug
+DOMAIN_DEBUG=true
\ No newline at end of file
diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js
index a83778afa..e261bd4f9 100644
--- a/api/resolvers/domain.js
+++ b/api/resolvers/domain.js
@@ -1,11 +1,16 @@
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: {
diff --git a/api/typeDefs/domain.js b/api/typeDefs/domain.js
index 9a0cdfca2..d7c02c1e2 100644
--- a/api/typeDefs/domain.js
+++ b/api/typeDefs/domain.js
@@ -3,6 +3,7 @@ import { gql } from 'graphql-tag'
export default gql`
extend type Query {
customDomain(subName: String!): CustomDomain
+ domainMapping(domain: String!): DomainMapping
}
extend type Mutation {
@@ -19,6 +20,11 @@ export default gql`
status: String
verification: CustomDomainVerification
}
+
+ type DomainMapping {
+ domain: String!
+ subName: String!
+ }
type CustomDomainVerification {
dns: CustomDomainVerificationDNS
diff --git a/components/sub-select.js b/components/sub-select.js
index c6a136dd9..c484e9284 100644
--- a/components/sub-select.js
+++ b/components/sub-select.js
@@ -6,6 +6,7 @@ import { SUBS } from '@/fragments/subs'
import { useQuery } from '@apollo/client'
import styles from './sub-select.module.css'
import { useMe } from './me'
+import { useDomain } from './territory-domains'
export function SubSelectInitial ({ sub }) {
const router = useRouter()
@@ -21,6 +22,8 @@ const DEFAULT_APPEND_SUBS = []
const DEFAULT_FILTER_SUBS = () => true
export function useSubs ({ prependSubs = DEFAULT_PREPEND_SUBS, sub, filterSubs = DEFAULT_FILTER_SUBS, appendSubs = DEFAULT_APPEND_SUBS }) {
+ const { customDomain } = useDomain()
+ if (customDomain) return [customDomain.subName]
const { data, refetch } = useQuery(SUBS, SSR
? {}
: {
diff --git a/components/territory-branding-form.js b/components/territory-branding-form.js
index 21b092b60..908271fd8 100644
--- a/components/territory-branding-form.js
+++ b/components/territory-branding-form.js
@@ -11,7 +11,6 @@ export default function BrandingForm ({ sub }) {
const toaster = useToast()
const onSubmit = async (values) => {
- console.log(values)
try {
await setCustomBranding({
variables: {
@@ -37,13 +36,15 @@ export default function BrandingForm ({ sub }) {
}
}
+ const subColors = sub?.customBranding?.colors || {}
+
const initialValues = {
title: sub?.customBranding?.title || sub?.subName,
- primary: sub?.customBranding?.colors?.primary || '#FADA5E',
- secondary: sub?.customBranding?.colors?.secondary || '#F6911D',
- info: sub?.customBranding?.colors?.info || '#007cbe',
- success: sub?.customBranding?.colors?.success || '#5c8001',
- danger: sub?.customBranding?.colors?.danger || '#c03221',
+ primary: subColors?.primary || '#FADA5E',
+ secondary: subColors?.secondary || '#F6911D',
+ info: subColors?.info || '#007cbe',
+ success: subColors?.success || '#5c8001',
+ danger: subColors?.danger || '#c03221',
logoId: sub?.customBranding?.logoId || null,
faviconId: sub?.customBranding?.faviconId || null
}
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 1eac77de3..4a7d31a20 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -43,7 +43,7 @@ export const DomainProvider = ({ customDomain: ssrCustomDomain, children }) => {
const branding = customDomain?.branding || null
const colors = branding?.colors || null
- console.log('colors', colors)
+
return (
{branding && (
diff --git a/fragments/domains.js b/fragments/domains.js
index 7c2556df7..d5060bc59 100644
--- a/fragments/domains.js
+++ b/fragments/domains.js
@@ -23,6 +23,15 @@ export const GET_CUSTOM_DOMAIN = gql`
}
`
+export const GET_DOMAIN_MAPPING = gql`
+ query DomainMapping($domain: String!) {
+ domainMapping(domain: $domain) {
+ domain
+ subName
+ }
+ }
+`
+
export const GET_CUSTOM_DOMAIN_FULL = gql`
${GET_CUSTOM_DOMAIN}
fragment CustomDomainFull on CustomDomain {
diff --git a/lib/domain-verification.js b/lib/domain-verification.js
new file mode 100644
index 000000000..964577ecf
--- /dev/null
+++ b/lib/domain-verification.js
@@ -0,0 +1,106 @@
+import { requestCertificate, getCertificateStatus, describeCertificate } from '@/api/acm'
+import { promises as dnsPromises } from 'node:dns'
+
+// Issue a certificate for a custom domain
+export async function issueDomainCertificate (domainName) {
+ try {
+ const certificateArn = await requestCertificate(domainName)
+ return certificateArn
+ } catch (error) {
+ console.error(`Failed to issue certificate for domain ${domainName}:`, error)
+ return null
+ }
+}
+
+// Check the status of a certificate for a custom domain
+export async function checkCertificateStatus (certificateArn) {
+ let certStatus
+ try {
+ certStatus = await getCertificateStatus(certificateArn)
+ } catch (error) {
+ console.error(`Certificate status check failed: ${error.message}`)
+ return 'FAILED'
+ }
+
+ // map ACM statuses
+ switch (certStatus) {
+ case 'ISSUED':
+ return 'VERIFIED'
+ case 'PENDING_VALIDATION':
+ return 'PENDING'
+ case 'VALIDATION_TIMED_OUT':
+ case 'FAILED':
+ return 'FAILED'
+ default:
+ return 'PENDING'
+ }
+}
+
+// Get the details of a certificate for a custom domain
+export async function certDetails (certificateArn) {
+ try {
+ const certificate = await describeCertificate(certificateArn)
+ return certificate
+ } catch (error) {
+ console.error(`Certificate description failed: ${error.message}`)
+ return null
+ }
+}
+
+// Get the validation values for a certificate for a custom domain
+// TODO: Test with real values, localstack don't have this info until the certificate is issued
+export async function getValidationValues (certificateArn) {
+ const certificate = await certDetails(certificateArn)
+ console.log(certificate.DomainValidationOptions)
+ return {
+ cname: certificate.DomainValidationOptions[0].ResourceRecord.Name,
+ value: certificate.DomainValidationOptions[0].ResourceRecord.Value
+ }
+}
+
+// Verify the DNS records for a custom domain
+export async function verifyDomainDNS (domainName, verificationTxt, verificationCname) {
+ const cname = verificationCname || process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
+ const result = {
+ txtValid: false,
+ cnameValid: false,
+ error: null
+ }
+
+ // by default use cloudflare DNS resolver
+ dnsPromises.setServers([process.env.DNS_RESOLVER || '1.1.1.1'])
+
+ // TXT Records checking
+ try {
+ const txtRecords = await dnsPromises.resolve(domainName, 'TXT')
+ const txtText = txtRecords.flat().join(' ')
+
+ // the TXT record should include the verificationTxt that we have in the database
+ result.txtValid = txtText.includes(verificationTxt)
+ } catch (error) {
+ if (error.code === 'ENODATA' || error.code === 'ENOTFOUND') {
+ result.error = `TXT record not found: ${error.code}`
+ } else {
+ result.error = `TXT error: ${error.message}`
+ }
+ }
+
+ // CNAME Records checking
+ try {
+ const cnameRecords = await dnsPromises.resolve(domainName, 'CNAME')
+
+ // the CNAME record should include the cname that we have in the database
+ result.cnameValid = cnameRecords.some(record =>
+ record.includes(cname)
+ )
+ } catch (error) {
+ if (!result.error) { // this is to avoid overriding the error from the TXT check
+ if (error.code === 'ENODATA' || error.code === 'ENOTFOUND') {
+ result.error = `CNAME record not found: ${error.code}`
+ } else {
+ result.error = `CNAME error: ${error.message}`
+ }
+ }
+ }
+ return result
+}
diff --git a/lib/domains.js b/lib/domains.js
index 964577ecf..cb23600c7 100644
--- a/lib/domains.js
+++ b/lib/domains.js
@@ -1,106 +1,37 @@
-import { requestCertificate, getCertificateStatus, describeCertificate } from '@/api/acm'
-import { promises as dnsPromises } from 'node:dns'
-
-// Issue a certificate for a custom domain
-export async function issueDomainCertificate (domainName) {
- try {
- const certificateArn = await requestCertificate(domainName)
- return certificateArn
- } catch (error) {
- console.error(`Failed to issue certificate for domain ${domainName}:`, error)
- return null
- }
-}
-
-// Check the status of a certificate for a custom domain
-export async function checkCertificateStatus (certificateArn) {
- let certStatus
- try {
- certStatus = await getCertificateStatus(certificateArn)
- } catch (error) {
- console.error(`Certificate status check failed: ${error.message}`)
- return 'FAILED'
- }
-
- // map ACM statuses
- switch (certStatus) {
- case 'ISSUED':
- return 'VERIFIED'
- case 'PENDING_VALIDATION':
- return 'PENDING'
- case 'VALIDATION_TIMED_OUT':
- case 'FAILED':
- return 'FAILED'
- default:
- return 'PENDING'
- }
-}
-
-// Get the details of a certificate for a custom domain
-export async function certDetails (certificateArn) {
- try {
- const certificate = await describeCertificate(certificateArn)
- return certificate
- } catch (error) {
- console.error(`Certificate description failed: ${error.message}`)
- return null
- }
-}
-
-// Get the validation values for a certificate for a custom domain
-// TODO: Test with real values, localstack don't have this info until the certificate is issued
-export async function getValidationValues (certificateArn) {
- const certificate = await certDetails(certificateArn)
- console.log(certificate.DomainValidationOptions)
- return {
- cname: certificate.DomainValidationOptions[0].ResourceRecord.Name,
- value: certificate.DomainValidationOptions[0].ResourceRecord.Value
+import { cachedFetcher } from '@/lib/fetch'
+
+export const domainLogger = () => {
+ if (process.env.DOMAIN_DEBUG === 'true') {
+ return {
+ log: (message, ...args) => {
+ console.log(message, ...args)
+ },
+ error: (message, ...args) => {
+ console.error(message, ...args)
+ }
+ }
}
}
-// Verify the DNS records for a custom domain
-export async function verifyDomainDNS (domainName, verificationTxt, verificationCname) {
- const cname = verificationCname || process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
- const result = {
- txtValid: false,
- cnameValid: false,
- error: null
- }
-
- // by default use cloudflare DNS resolver
- dnsPromises.setServers([process.env.DNS_RESOLVER || '1.1.1.1'])
-
- // TXT Records checking
+// fetch custom domain mappings from our API, caching it for 5 minutes
+export const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings () {
+ const url = `${process.env.NEXT_PUBLIC_URL}/api/domains`
+ domainLogger().log('fetching domain mappings from', url) // TEST
try {
- const txtRecords = await dnsPromises.resolve(domainName, 'TXT')
- const txtText = txtRecords.flat().join(' ')
-
- // the TXT record should include the verificationTxt that we have in the database
- result.txtValid = txtText.includes(verificationTxt)
- } catch (error) {
- if (error.code === 'ENODATA' || error.code === 'ENOTFOUND') {
- result.error = `TXT record not found: ${error.code}`
- } else {
- result.error = `TXT error: ${error.message}`
+ const response = await fetch(url)
+ if (!response.ok) {
+ domainLogger().error(`Cannot fetch domain mappings: ${response.status} ${response.statusText}`)
+ return null
}
- }
-
- // CNAME Records checking
- try {
- const cnameRecords = await dnsPromises.resolve(domainName, 'CNAME')
- // the CNAME record should include the cname that we have in the database
- result.cnameValid = cnameRecords.some(record =>
- record.includes(cname)
- )
+ const data = await response.json()
+ return Object.keys(data).length > 0 ? data : null
} catch (error) {
- if (!result.error) { // this is to avoid overriding the error from the TXT check
- if (error.code === 'ENODATA' || error.code === 'ENOTFOUND') {
- result.error = `CNAME record not found: ${error.code}`
- } else {
- result.error = `CNAME error: ${error.message}`
- }
- }
+ domainLogger().error('Cannot fetch domain mappings:', error)
+ return null
}
- return result
-}
+}, {
+ cacheExpiry: 300000, // 5 minutes cache
+ forceRefreshThreshold: 600000, // 10 minutes before force refresh
+ keyGenerator: () => 'domain_mappings'
+})
diff --git a/middleware.js b/middleware.js
index 76601fb2e..9c14b3fe0 100644
--- a/middleware.js
+++ b/middleware.js
@@ -1,5 +1,5 @@
import { NextResponse, URLPattern } from 'next/server'
-import { cachedFetcher } from '@/lib/fetch'
+import { domainLogger, getDomainMappingsCache } from '@/lib/domains'
const referrerPattern = new URLPattern({ pathname: ':pathname(*)/r/:referrer([\\w_]+)' })
const itemPattern = new URLPattern({ pathname: '/items/:id(\\d+){/:other(\\w+)}?' })
@@ -15,30 +15,6 @@ const SN_REFEREE_LANDING = 'sn_referee_landing'
// rewrite to ~subname paths
const TERRITORY_PATHS = ['/~', '/recent', '/random', '/top', '/post', '/edit']
-// TODO: move this to a separate file
-// fetch custom domain mappings from our API, caching it for 5 minutes
-export const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings () {
- const url = `${process.env.NEXT_PUBLIC_URL}/api/domains`
- console.log('fetching domain mappings from', url) // TEST
- try {
- const response = await fetch(url)
- if (!response.ok) {
- console.error(`Cannot fetch domain mappings: ${response.status} ${response.statusText}`)
- return null
- }
-
- const data = await response.json()
- return Object.keys(data).length > 0 ? data : null
- } catch (error) {
- console.error('Cannot fetch domain mappings:', error)
- return null
- }
-}, {
- cacheExpiry: 300000, // 5 minutes cache
- forceRefreshThreshold: 600000, // 10 minutes before force refresh
- keyGenerator: () => 'domain_mappings'
-})
-
// get a domain mapping from the cache
export async function getDomainMapping (domain) {
const domainMappings = await getDomainMappingsCache()
@@ -48,17 +24,15 @@ export async function getDomainMapping (domain) {
// Redirects and rewrites for custom domains
export async function customDomainMiddleware (request, referrerResp, domain) {
const host = request.headers.get('host')
- const referer = request.headers.get('referer')
const url = request.nextUrl.clone()
const pathname = url.pathname
- const mainDomain = process.env.NEXT_PUBLIC_URL + '/'
+ const mainDomain = process.env.NEXT_PUBLIC_URL
// TEST
- console.log('host', host)
- console.log('mainDomain', mainDomain)
- console.log('referer', referer)
- console.log('pathname', pathname)
- console.log('query', url.searchParams)
+ domainLogger().log('host', host)
+ domainLogger().log('mainDomain', mainDomain)
+ domainLogger().log('pathname', pathname)
+ domainLogger().log('query', url.searchParams)
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-stacker-news-subname', domain.subName)
@@ -83,29 +57,18 @@ export async function customDomainMiddleware (request, referrerResp, domain) {
if (pathname.startsWith(`/~${domain.subName}`)) {
// remove the territory prefix from the path
const cleanPath = pathname.replace(`/~${domain.subName}`, '') || '/'
- // TEST
- console.log('Redirecting to clean path:', cleanPath)
+ domainLogger().log('Redirecting to clean path:', cleanPath)
const redirectResp = NextResponse.redirect(new URL(cleanPath + url.search, url.origin), {
headers: requestHeaders
})
return applyReferrerCookies(redirectResp, referrerResp) // apply referrer cookies to the redirect
}
- // if coming from main domain, handle auth automatically
- // TODO: uncomment and work on this
-
- /* if (referer && referer === mainDomain) {
- const authResp = customDomainAuthMiddleware(request, url)
- if (authResp && authResp.status !== 200) {
- return applyReferrerCookies(authResp, referrerResp)
- }
- } */
-
// If we're at the root or a territory path, rewrite to the territory path
if (pathname === '/' || TERRITORY_PATHS.some(p => pathname.startsWith(p))) {
const internalUrl = new URL(url)
internalUrl.pathname = `/~${domain.subName}${pathname === '/' ? '' : pathname}`
- console.log('Rewrite to:', internalUrl.pathname)
+ domainLogger().log('Rewrite to:', internalUrl.pathname)
// rewrite to the territory path
const resp = NextResponse.rewrite(internalUrl, {
headers: requestHeaders
@@ -120,48 +83,6 @@ export async function customDomainMiddleware (request, referrerResp, domain) {
})
}
-// UNUSED
-// TODO: dirty of previous iterations, refactor
-// UNSAFE UNSAFE UNSAFE tokens are visible in the URL
-// Redirect to Auth Sync if user is not logged in or has no multi_auth sessions
-export function customDomainAuthMiddleware (request, url) {
- const host = request.headers.get('host')
- const mainDomain = process.env.NEXT_PUBLIC_URL
- const pathname = url.pathname
-
- // check for session both in session token and in multi_auth cookie
- const secure = process.env.NODE_ENV === 'development' // TODO: change this to production
- const sessionCookieName = secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
- const multiAuthUserId = request.cookies.get('multi_auth.user-id')?.value
-
- // 1. We have a session token directly, or
- // 2. We have a multi_auth user ID and the corresponding multi_auth cookie
- const hasActiveSession = !!request.cookies.get(sessionCookieName)?.value
- const hasMultiAuthSession = multiAuthUserId && !!request.cookies.get(`multi_auth.${multiAuthUserId}`)?.value
-
- const hasSession = hasActiveSession || hasMultiAuthSession
- const response = NextResponse.next()
-
- if (!hasSession) {
- // TODO: original request url points to localhost, this is a workaround atm
- const protocol = secure ? 'https' : 'http'
- const originalDomain = `${protocol}://${host}`
- const redirectTarget = `${originalDomain}${pathname}`
-
- // Create the auth sync URL with the correct original domain
- const syncUrl = new URL(`${mainDomain}/api/auth/sync`)
- syncUrl.searchParams.set('redirectUrl', redirectTarget)
-
- console.log('AUTH: Redirecting to:', syncUrl.toString())
- console.log('AUTH: With redirect back to:', redirectTarget)
- const redirectResponse = NextResponse.redirect(syncUrl)
- return redirectResponse
- }
-
- console.log('No redirect')
- return response
-}
-
function getContentReferrer (request, url) {
if (itemPattern.test(url)) {
let id = request.nextUrl.searchParams.get('commentId')
@@ -192,7 +113,7 @@ function applyReferrerCookies (response, referrer) {
}
)
}
- console.log('response.cookies', response.cookies)
+ domainLogger().log('response.cookies', response.cookies)
return response
}
@@ -309,12 +230,11 @@ export async function middleware (request) {
const host = request.headers.get('host')
const isAllowedDomain = await getDomainMapping(host?.toLowerCase())
if (isAllowedDomain) {
+ domainLogger().log('detected allowed custom domain', isAllowedDomain)
const customDomainResp = await customDomainMiddleware(request, referrerResp, isAllowedDomain)
return applySecurityHeaders(customDomainResp)
}
- console.log('applying security headers')
-
return applySecurityHeaders(referrerResp)
}
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 91580c110..2eb9e9427 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -1,5 +1,5 @@
import createPrisma from '@/lib/create-prisma'
-import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus, getValidationValues } from '@/lib/domains'
+import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus, getValidationValues } from '@/lib/domain-verification'
// This worker verifies the DNS and SSL certificates for domains that are pending or failed
export async function domainVerification ({ data: { domainId }, boss }) {
From a5a9c1050a3cfa1661eaaaa8aaf85b8790c8baac Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 8 Apr 2025 04:22:44 -0500
Subject: [PATCH 56/74] close modal on custom domain logout, validate branding
---
api/resolvers/branding.js | 14 ++++++++++++++
components/nav/common.js | 3 +++
2 files changed, 17 insertions(+)
diff --git a/api/resolvers/branding.js b/api/resolvers/branding.js
index f2d99905e..e9921350f 100644
--- a/api/resolvers/branding.js
+++ b/api/resolvers/branding.js
@@ -1,4 +1,5 @@
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
+import { validateSchema, customBrandingSchema } from '@/lib/validate'
export default {
Query: {
@@ -40,6 +41,19 @@ export default {
}
}
+ 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')
+ }
+
// TODO: validation, even of logo and favicon.
return await models.customBranding.upsert({
diff --git a/components/nav/common.js b/components/nav/common.js
index 0db0b165f..75fe39b6b 100644
--- a/components/nav/common.js
+++ b/components/nav/common.js
@@ -351,6 +351,9 @@ function LogoutObstacle ({ onClose }) {
removeLocalWallets()
await signOut({ callbackUrl: '/', redirect: !customDomain })
+ if (customDomain) {
+ onClose()
+ }
}}
>
logout
From 3264540369933408738a570f26574a4e95aec826 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 8 Apr 2025 07:07:02 -0500
Subject: [PATCH 57/74] territory branding ui/ux cleanup, general cleanup
---
api/acm/index.js | 13 +++--------
api/resolvers/branding.js | 2 --
components/form.js | 17 ++++++++++++---
components/territory-branding-form.js | 31 ++-------------------------
4 files changed, 19 insertions(+), 44 deletions(-)
diff --git a/api/acm/index.js b/api/acm/index.js
index b5294f936..aef7708f6 100644
--- a/api/acm/index.js
+++ b/api/acm/index.js
@@ -1,22 +1,17 @@
import AWS from 'aws-sdk'
-// TODO: boilerplate
AWS.config.update({
region: 'us-east-1'
})
-const config = {
- s3ForcePathStyle: process.env.NODE_ENV === 'development'
-}
+const config = {}
export async function requestCertificate (domain) {
- // for local development, we use the LOCALSTACK_ENDPOINT which
- // is reachable from the host machine
+ // for local development, we use the LOCALSTACK_ENDPOINT
if (process.env.NODE_ENV === 'development') {
config.endpoint = process.env.LOCALSTACK_ENDPOINT
}
- // TODO: Research real values
const acm = new AWS.ACM(config)
const params = {
DomainName: domain,
@@ -24,7 +19,7 @@ export async function requestCertificate (domain) {
Tags: [
{
Key: 'ManagedBy',
- Value: 'stackernews'
+ Value: 'stacker.news'
}
]
}
@@ -34,8 +29,6 @@ export async function requestCertificate (domain) {
}
export async function describeCertificate (certificateArn) {
- // for local development, we use the LOCALSTACK_ENDPOINT which
- // is reachable from the host machine
if (process.env.NODE_ENV === 'development') {
config.endpoint = process.env.LOCALSTACK_ENDPOINT
}
diff --git a/api/resolvers/branding.js b/api/resolvers/branding.js
index e9921350f..d48774e45 100644
--- a/api/resolvers/branding.js
+++ b/api/resolvers/branding.js
@@ -54,8 +54,6 @@ export default {
throw new GqlInputError('invalid branding')
}
- // TODO: validation, even of logo and favicon.
-
return await models.customBranding.upsert({
where: { subName },
update: {
diff --git a/components/form.js b/components/form.js
index 57ab6338f..f44facbf6 100644
--- a/components/form.js
+++ b/components/form.js
@@ -23,7 +23,7 @@ import textAreaCaret from 'textarea-caret'
import 'react-datepicker/dist/react-datepicker.css'
import useDebounceCallback, { debounce } from './use-debounce-callback'
import { FileUpload } from './file-upload'
-import { AWS_S3_URL_REGEXP } from '@/lib/constants'
+import { AWS_S3_URL_REGEXP, MEDIA_URL } from '@/lib/constants'
import { whenRange } from '@/lib/time'
import { useFeeButton } from './fee-button'
import Thumb from '@/svgs/thumb-up-fill.svg'
@@ -1411,12 +1411,23 @@ export function ColorPicker ({ label, groupClassName, name, ...props }) {
}
export function BrandingUpload ({ label, groupClassName, name, ...props }) {
- const [, , helpers] = useField({ ...props, name })
+ const [field, , helpers] = useField({ ...props, name })
+ const [tempId, setTempId] = useState(field.value)
+
+ const handleSuccess = useCallback((id) => {
+ setTempId(id)
+ helpers.setValue(id)
+ }, [helpers])
return (
+
helpers.setValue(id)}
+ onSuccess={handleSuccess}
/>
)
diff --git a/components/territory-branding-form.js b/components/territory-branding-form.js
index 908271fd8..039cd96e0 100644
--- a/components/territory-branding-form.js
+++ b/components/territory-branding-form.js
@@ -4,7 +4,6 @@ import { useToast } from './toast'
import { customBrandingSchema } from '@/lib/validate'
import { SET_CUSTOM_BRANDING } from '@/fragments/brandings'
import AccordianItem from './accordian-item'
-import SnIcon from '@/svgs/sn.svg'
export default function BrandingForm ({ sub }) {
const [setCustomBranding] = useMutation(SET_CUSTOM_BRANDING)
@@ -62,7 +61,7 @@ export default function BrandingForm ({ sub }) {
more colors }
+ header={
more colors
}
body={
@@ -72,44 +71,18 @@ export default function BrandingForm ({ sub }) {
}
/>
logo and favicon }
+ header={
logo and favicon
}
body={
logo
- {sub?.customBranding?.logoId
- ? (
-
- )
- : (
-
-
-
- )}
favicon
- {sub?.customBranding?.faviconId
- ? (
-
- )
- : (
-
-
-
- )}
From 333135304541c88a4f68dbca783f5ddad1472c76 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Tue, 8 Apr 2025 13:59:56 -0500
Subject: [PATCH 58/74] cleanup: middleware, custom branding, gql fragments,
adjust migration date
---
components/nav/common.js | 2 +-
components/territory-branding-form.js | 10 ++--
components/territory-domains.js | 9 ++--
components/territory-form.js | 1 -
components/territory-header.js | 2 +-
fragments/brandings.js | 25 ++++-----
fragments/domains.js | 54 ++++++++++---------
fragments/subs.js | 29 +++-------
.../migration.sql | 0
9 files changed, 58 insertions(+), 74 deletions(-)
rename prisma/migrations/{20250531003413_custom_brandings => 20250402003413_custom_brandings}/migration.sql (100%)
diff --git a/components/nav/common.js b/components/nav/common.js
index 75fe39b6b..c16285070 100644
--- a/components/nav/common.js
+++ b/components/nav/common.js
@@ -352,7 +352,7 @@ function LogoutObstacle ({ onClose }) {
await signOut({ callbackUrl: '/', redirect: !customDomain })
if (customDomain) {
- onClose()
+ router.push('/') // next auth redirect only supports the main domain
}
}}
>
diff --git a/components/territory-branding-form.js b/components/territory-branding-form.js
index 039cd96e0..4f592c7ab 100644
--- a/components/territory-branding-form.js
+++ b/components/territory-branding-form.js
@@ -35,20 +35,20 @@ export default function BrandingForm ({ sub }) {
}
}
- const subColors = sub?.customBranding?.colors || {}
+ const customBranding = sub?.customBranding || {}
+ const subColors = customBranding?.colors || {}
const initialValues = {
- title: sub?.customBranding?.title || sub?.subName,
+ title: customBranding?.title || sub?.name,
primary: subColors?.primary || '#FADA5E',
secondary: subColors?.secondary || '#F6911D',
info: subColors?.info || '#007cbe',
success: subColors?.success || '#5c8001',
danger: subColors?.danger || '#c03221',
- logoId: sub?.customBranding?.logoId || null,
- faviconId: sub?.customBranding?.faviconId || null
+ logoId: customBranding?.logoId || null,
+ faviconId: customBranding?.faviconId || null
}
- // TODO: cleanup
return (
{
const router = useRouter()
const [customDomain, setCustomDomain] = useState(ssrCustomDomain || null)
+ // maintain the custom domain state across re-renders
useEffect(() => {
if (ssrCustomDomain && !customDomain) {
setCustomDomain(ssrCustomDomain)
}
}, [ssrCustomDomain])
- // TODO: alternative to this, for test only
- // auth sync
+ // temporary auth sync
useEffect(() => {
if (router.query.type === 'sync') {
console.log('signing in with sync')
signIn('sync', { token: router.query.token, callbackUrl: router.query.callbackUrl, multiAuth: router.query.multiAuth, redirect: false })
+ router.push(router.query.callbackUrl) // next auth redirect only supports the main domain
}
}, [router.query.type])
@@ -143,7 +144,6 @@ export function DomainGuidelines ({ customDomain }) {
)
}
-// TODO: clean this up, might not need all this refreshing, plus all this polling is not done correctly
export default function CustomDomainForm ({ sub }) {
const [setCustomDomain] = useMutation(SET_CUSTOM_DOMAIN)
@@ -184,12 +184,11 @@ export default function CustomDomainForm ({ sub }) {
return (
<>
- {/* TODO: too many flexes */}
}
diff --git a/components/territory-form.js b/components/territory-form.js
index e1a61f08c..3306a0200 100644
--- a/components/territory-form.js
+++ b/components/territory-form.js
@@ -88,7 +88,6 @@ export default function TerritoryForm ({ sub }) {
}
}, [sub, billing])
- // TODO: Add a custom domain textbox and verification status; validation too
return (
{numWithUnits(sub.replyCost)}
- {sub.customDomain && (
+ {sub.customDomain?.status === 'ACTIVE' && (
website
{sub.customDomain.domain}
diff --git a/fragments/brandings.js b/fragments/brandings.js
index 1d49ffd39..380afd280 100644
--- a/fragments/brandings.js
+++ b/fragments/brandings.js
@@ -1,18 +1,6 @@
import { gql } from 'graphql-tag'
-export const GET_CUSTOM_BRANDING = gql`
- query CustomBranding($subName: String!) {
- customBranding(subName: $subName) {
- title
- colors
- logoId
- faviconId
- subName
- }
- }
-`
-
-export const GET_CUSTOM_BRANDING_FIELDS = gql`
+export const CUSTOM_BRANDING_FIELDS = gql`
fragment CustomBrandingFields on CustomBranding {
title
colors
@@ -22,8 +10,17 @@ export const GET_CUSTOM_BRANDING_FIELDS = gql`
}
`
+export const GET_CUSTOM_BRANDING = gql`
+ ${CUSTOM_BRANDING_FIELDS}
+ query CustomBranding($subName: String!) {
+ customBranding(subName: $subName) {
+ ...CustomBrandingFields
+ }
+ }
+`
+
export const SET_CUSTOM_BRANDING = gql`
- ${GET_CUSTOM_BRANDING_FIELDS}
+ ${CUSTOM_BRANDING_FIELDS}
mutation SetCustomBranding($subName: String!, $branding: CustomBrandingInput!) {
setCustomBranding(subName: $subName, branding: $branding) {
...CustomBrandingFields
diff --git a/fragments/domains.js b/fragments/domains.js
index d5060bc59..d6b4a05eb 100644
--- a/fragments/domains.js
+++ b/fragments/domains.js
@@ -1,24 +1,38 @@
import { gql } from 'graphql-tag'
+export const CUSTOM_DOMAIN_FIELDS = gql`
+ fragment CustomDomainFields on CustomDomain {
+ domain
+ status
+ }
+`
+
+export const CUSTOM_DOMAIN_FULL_FIELDS = gql`
+ ${CUSTOM_DOMAIN_FIELDS}
+ fragment CustomDomainFullFields on CustomDomain {
+ ...CustomDomainFields
+ lastVerifiedAt
+ verification {
+ dns {
+ state
+ cname
+ txt
+ }
+ ssl {
+ state
+ arn
+ cname
+ value
+ }
+ }
+ }
+`
+
export const GET_CUSTOM_DOMAIN = gql`
+ ${CUSTOM_DOMAIN_FULL_FIELDS}
query CustomDomain($subName: String!) {
customDomain(subName: $subName) {
- domain
- status
- lastVerifiedAt
- verification {
- dns {
- state
- cname
- txt
- }
- ssl {
- state
- arn
- cname
- value
- }
- }
+ ...CustomDomainFullFields
}
}
`
@@ -32,14 +46,6 @@ export const GET_DOMAIN_MAPPING = gql`
}
`
-export const GET_CUSTOM_DOMAIN_FULL = gql`
- ${GET_CUSTOM_DOMAIN}
- fragment CustomDomainFull on CustomDomain {
- ...CustomDomainFields
- failedAttempts
- }
-`
-
export const SET_CUSTOM_DOMAIN = gql`
mutation SetCustomDomain($subName: String!, $domain: String!) {
setCustomDomain(subName: $subName, domain: $domain) {
diff --git a/fragments/subs.js b/fragments/subs.js
index 14a6f200b..5085d685a 100644
--- a/fragments/subs.js
+++ b/fragments/subs.js
@@ -1,6 +1,8 @@
import { gql } from '@apollo/client'
import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items'
import { COMMENTS_ITEM_EXT_FIELDS } from './comments'
+import { CUSTOM_DOMAIN_FIELDS } from './domains'
+import { CUSTOM_BRANDING_FIELDS } from './brandings'
// we can't import from users because of circular dependency
const STREAK_FIELDS = gql`
@@ -15,6 +17,8 @@ const STREAK_FIELDS = gql`
// TODO: better place
export const SUB_FIELDS = gql`
+ ${CUSTOM_DOMAIN_FIELDS}
+ ${CUSTOM_BRANDING_FIELDS}
fragment SubFields on Sub {
name
createdAt
@@ -36,31 +40,10 @@ export const SUB_FIELDS = gql`
meSubscription
nsfw
customDomain {
- createdAt
- updatedAt
- domain
- subName
- lastVerifiedAt
- status
- verification {
- dns {
- state
- cname
- txt
- }
- ssl {
- state
- arn
- cname
- value
- }
- }
+ ...CustomDomainFields
}
customBranding {
- title
- colors
- logoId
- faviconId
+ ...CustomBrandingFields
}
}`
diff --git a/prisma/migrations/20250531003413_custom_brandings/migration.sql b/prisma/migrations/20250402003413_custom_brandings/migration.sql
similarity index 100%
rename from prisma/migrations/20250531003413_custom_brandings/migration.sql
rename to prisma/migrations/20250402003413_custom_brandings/migration.sql
From c9942fb7a4ba2ece51314c3d60c9d1ffa8161e68 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Wed, 9 Apr 2025 04:38:38 -0500
Subject: [PATCH 59/74] cleanup: auth sync, respect redirectUrl and callbackUrl
---
components/form.js | 4 +++-
components/login.js | 2 +-
components/territory-domains.js | 2 +-
pages/api/auth/sync.js | 9 +++++----
pages/login.js | 5 ++---
5 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/components/form.js b/components/form.js
index f44facbf6..d13f1761b 100644
--- a/components/form.js
+++ b/components/form.js
@@ -1424,7 +1424,9 @@ export function BrandingUpload ({ label, groupClassName, name, ...props }) {
)
- case 'Sync': // TODO: remove this
+ case 'Sync':
return null
default:
return (
diff --git a/components/territory-domains.js b/components/territory-domains.js
index c5eab57a9..2149ac03b 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -184,7 +184,7 @@ export default function CustomDomainForm ({ sub }) {
return (
<>
Date: Wed, 9 Apr 2025 04:58:16 -0500
Subject: [PATCH 60/74] cleanup: squash migrations, indentation,
custom_domain_logger env var
---
.env.development | 4 +--
components/territory-domains.js | 8 ++++--
lib/domains.js | 7 ++++-
pages/api/auth/sync.js | 1 +
pages/login.js | 6 ++---
.../migration.sql | 26 ++++++++++++++++++-
.../migration.sql | 23 ----------------
7 files changed, 42 insertions(+), 33 deletions(-)
delete mode 100644 prisma/migrations/20250402003413_custom_brandings/migration.sql
diff --git a/.env.development b/.env.development
index 21867b457..0b4185c9a 100644
--- a/.env.development
+++ b/.env.development
@@ -195,5 +195,5 @@ NEXT_TELEMETRY_DISABLED=1
# DNS resolver for custom domain verification
DNS_RESOLVER=1.1.1.1
-# domain debug
-DOMAIN_DEBUG=true
\ No newline at end of file
+# domain debug logger
+CUSTOM_DOMAIN_LOGGER=false
\ No newline at end of file
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 2149ac03b..7237f6269 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -36,8 +36,12 @@ export const DomainProvider = ({ customDomain: ssrCustomDomain, children }) => {
// temporary auth sync
useEffect(() => {
if (router.query.type === 'sync') {
- console.log('signing in with sync')
- signIn('sync', { token: router.query.token, callbackUrl: router.query.callbackUrl, multiAuth: router.query.multiAuth, redirect: false })
+ console.log('signing in with sync', router.query)
+ signIn('sync', {
+ token: router.query.token,
+ multiAuth: router.query.multiAuth,
+ redirect: false
+ })
router.push(router.query.callbackUrl) // next auth redirect only supports the main domain
}
}, [router.query.type])
diff --git a/lib/domains.js b/lib/domains.js
index cb23600c7..4440376fc 100644
--- a/lib/domains.js
+++ b/lib/domains.js
@@ -1,7 +1,7 @@
import { cachedFetcher } from '@/lib/fetch'
export const domainLogger = () => {
- if (process.env.DOMAIN_DEBUG === 'true') {
+ if (process.env.CUSTOM_DOMAIN_LOGGER === 'true') {
return {
log: (message, ...args) => {
console.log(message, ...args)
@@ -10,6 +10,11 @@ export const domainLogger = () => {
console.error(message, ...args)
}
}
+ } else {
+ return {
+ log: () => {},
+ error: () => {}
+ }
}
}
diff --git a/pages/api/auth/sync.js b/pages/api/auth/sync.js
index 1bd40e9ac..8268ca5e5 100644
--- a/pages/api/auth/sync.js
+++ b/pages/api/auth/sync.js
@@ -14,6 +14,7 @@ export default async function handler (req, res) {
let customDomain
try {
customDomain = new URL(decodedRedirectUrl)
+ // not cached because we're handling sensitive data
const domain = await models.customDomain.findUnique({ where: { domain: customDomain.host, status: 'ACTIVE' } })
if (!domain) {
return res.status(400).json({ status: 'ERROR', reason: 'custom domain not found' })
diff --git a/pages/login.js b/pages/login.js
index e7ca701f3..6566a4d62 100644
--- a/pages/login.js
+++ b/pages/login.js
@@ -70,10 +70,8 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, mult
}
function LoginFooter ({ callbackUrl, multiAuth }) {
- const query = {
- callbackUrl
- }
- if (multiAuth) { // multiAuth can be optional
+ const query = { callbackUrl }
+ if (multiAuth) {
query.multiAuth = multiAuth
}
return (
diff --git a/prisma/migrations/20250304121322_custom_domains/migration.sql b/prisma/migrations/20250304121322_custom_domains/migration.sql
index ac3badae8..3248e814d 100644
--- a/prisma/migrations/20250304121322_custom_domains/migration.sql
+++ b/prisma/migrations/20250304121322_custom_domains/migration.sql
@@ -1,4 +1,4 @@
--- CreateTable
+-- Custom Domain
CREATE TABLE "CustomDomain" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -42,3 +42,27 @@ CREATE INDEX "CustomDomain_created_at_idx" ON "CustomDomain"("created_at");
-- AddForeignKey
ALTER TABLE "CustomDomain" ADD CONSTRAINT "CustomDomain_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Custom Branding
+CREATE TABLE "CustomBranding" (
+ "id" SERIAL NOT NULL,
+ "title" TEXT,
+ "colors" JSONB DEFAULT '{}',
+ "logoId" INTEGER,
+ "faviconId" INTEGER,
+ "subName" CITEXT NOT NULL,
+
+ CONSTRAINT "CustomBranding_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CustomBranding_subName_key" ON "CustomBranding"("subName");
+
+-- AddForeignKey
+ALTER TABLE "CustomBranding" ADD CONSTRAINT "CustomBranding_logoId_fkey" FOREIGN KEY ("logoId") REFERENCES "Upload"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CustomBranding" ADD CONSTRAINT "CustomBranding_faviconId_fkey" FOREIGN KEY ("faviconId") REFERENCES "Upload"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CustomBranding" ADD CONSTRAINT "CustomBranding_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20250402003413_custom_brandings/migration.sql b/prisma/migrations/20250402003413_custom_brandings/migration.sql
deleted file mode 100644
index 884a34733..000000000
--- a/prisma/migrations/20250402003413_custom_brandings/migration.sql
+++ /dev/null
@@ -1,23 +0,0 @@
--- CreateTable
-CREATE TABLE "CustomBranding" (
- "id" SERIAL NOT NULL,
- "title" TEXT,
- "colors" JSONB DEFAULT '{}',
- "logoId" INTEGER,
- "faviconId" INTEGER,
- "subName" CITEXT NOT NULL,
-
- CONSTRAINT "CustomBranding_pkey" PRIMARY KEY ("id")
-);
-
--- CreateIndex
-CREATE UNIQUE INDEX "CustomBranding_subName_key" ON "CustomBranding"("subName");
-
--- AddForeignKey
-ALTER TABLE "CustomBranding" ADD CONSTRAINT "CustomBranding_logoId_fkey" FOREIGN KEY ("logoId") REFERENCES "Upload"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-
--- AddForeignKey
-ALTER TABLE "CustomBranding" ADD CONSTRAINT "CustomBranding_faviconId_fkey" FOREIGN KEY ("faviconId") REFERENCES "Upload"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-
--- AddForeignKey
-ALTER TABLE "CustomBranding" ADD CONSTRAINT "CustomBranding_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
From 8788e1016cb0d2c7e094b9d72797e3d826693fdc Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Wed, 9 Apr 2025 06:39:53 -0500
Subject: [PATCH 61/74] use pgboss exponential backoff for domainVerification
jobs
---
api/resolvers/domain.js | 13 ++++++++---
worker/domainVerification.js | 43 +++++++++++++++++++++++-------------
2 files changed, 38 insertions(+), 18 deletions(-)
diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js
index e261bd4f9..b0234f892 100644
--- a/api/resolvers/domain.js
+++ b/api/resolvers/domain.js
@@ -78,9 +78,16 @@ export default {
}
})
- // schedule domain verification in 5 seconds, worker will do the rest
- await models.$executeRaw`INSERT INTO pgboss.job (name, data)
- VALUES ('domainVerification', jsonb_build_object('domainId', ${updatedDomain.id}::INTEGER))`
+ // schedule domain verification in 5 seconds, apply exponential backoff and keep it for 2 days
+ await models.$executeRaw`INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, retrydelay, startafter, keepuntil)
+ VALUES ('domainVerification',
+ jsonb_build_object('domainId', ${updatedDomain.id}::INTEGER),
+ 21,
+ true,
+ '30', -- 30 seconds of delay between retries
+ now() + interval '5 seconds',
+ now() + interval '2 days')`
+
return updatedDomain
} else {
try {
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 2eb9e9427..1a6c29e27 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -5,20 +5,31 @@ import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus, getVal
export async function domainVerification ({ data: { domainId }, boss }) {
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
console.log('domainVerification', domainId)
- const domain = await models.customDomain.findUnique({ where: { id: domainId } })
- console.log('domain', domain)
- const result = await verifyDomain(domain, models)
-
- let startAfter = null
- if (result?.failedAttempts < 5) {
- // every 30 seconds
- startAfter = new Date(Date.now() + 30 * 1000)
- } else {
- // every 10 minutes
- startAfter = new Date(Date.now() + 10 * 60 * 1000)
- }
- if (result?.status === 'PENDING') {
- await boss.send('domainVerification', { domainId }, { startAfter })
+ try {
+ const domain = await models.customDomain.findUnique({ where: { id: domainId } })
+
+ if (!domain) {
+ throw new Error(`domain with ID ${domainId} not found`)
+ }
+
+ const result = await verifyDomain(domain, models)
+
+ if (result?.status === 'ACTIVE') {
+ console.log(`domain ${domain.domain} verified`)
+ return
+ }
+
+ if (result?.status === 'HOLD') {
+ console.log(`domain ${domain.domain} is on hold after too many failed attempts`)
+ return
+ }
+
+ if (result?.status === 'PENDING') {
+ throw new Error(`domain ${domain.domain} is still pending verification, will retry`)
+ }
+ } catch (error) {
+ console.error(`couldn't verify domain with ID ${domainId}: ${error.message}`)
+ throw error
}
}
@@ -26,6 +37,7 @@ async function verifyDomain (domain, models) {
// track verification
const data = { ...domain, lastVerifiedAt: new Date() }
data.verification = data.verification || { dns: {}, ssl: {} }
+ data.failedAttempts = data.failedAttempts || 0
if (data.verification?.dns?.state !== 'VERIFIED') {
await verifyDNS(data)
@@ -43,7 +55,7 @@ async function verifyDomain (domain, models) {
data.failedAttempts += 1
data.updatedAt = new Date()
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
- // todo: change this
+ // TODO: discussion
if (data.failedAttempts > 10 && data.updatedAt < oneDayAgo) {
data.status = 'HOLD'
}
@@ -52,6 +64,7 @@ async function verifyDomain (domain, models) {
if (data.verification?.dns?.state === 'VERIFIED' && data.verification?.ssl?.state === 'VERIFIED') {
data.status = 'ACTIVE'
}
+
return await models.customDomain.update({ where: { id: domain.id }, data })
}
From 2e65b107898977749095fb6e3fc841f11e142c62 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Wed, 9 Apr 2025 14:48:17 -0500
Subject: [PATCH 62/74] adjust exponential backoff to match 48 hours of retries
by starting at 42 seconds for 12 retries
---
api/resolvers/domain.js | 5 +++--
worker/domainVerification.js | 7 +++----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js
index b0234f892..44294e52b 100644
--- a/api/resolvers/domain.js
+++ b/api/resolvers/domain.js
@@ -79,12 +79,13 @@ export default {
})
// schedule domain verification in 5 seconds, apply exponential backoff and keep it for 2 days
+ // 12 retries, 42 seconds of delay between retries will fit 48 hours of trying for DNS propagation
await models.$executeRaw`INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, retrydelay, startafter, keepuntil)
VALUES ('domainVerification',
jsonb_build_object('domainId', ${updatedDomain.id}::INTEGER),
- 21,
+ 12,
true,
- '30', -- 30 seconds of delay between retries
+ '42', -- 42 seconds of delay between retries
now() + interval '5 seconds',
now() + interval '2 days')`
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 1a6c29e27..8f40af6c2 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -53,11 +53,10 @@ async function verifyDomain (domain, models) {
if (data.verification?.dns?.state === 'FAILED' || data.verification?.ssl?.state === 'FAILED') {
data.failedAttempts += 1
- data.updatedAt = new Date()
- const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
- // TODO: discussion
- if (data.failedAttempts > 10 && data.updatedAt < oneDayAgo) {
+ // exponential backoff at the 11th attempt is roughly 48 hours
+ if (data.failedAttempts > 11) {
data.status = 'HOLD'
+ data.failedAttempts = 0
}
}
From 8a672e2e2819042575d814dc7a533d42cbf9b9fe Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Wed, 9 Apr 2025 14:51:48 -0500
Subject: [PATCH 63/74] logger factory caching, remove later
---
lib/domains.js | 12 +++++-------
1 file changed, 5 insertions(+), 7 deletions(-)
diff --git a/lib/domains.js b/lib/domains.js
index 4440376fc..407842db7 100644
--- a/lib/domains.js
+++ b/lib/domains.js
@@ -1,8 +1,7 @@
import { cachedFetcher } from '@/lib/fetch'
-export const domainLogger = () => {
- if (process.env.CUSTOM_DOMAIN_LOGGER === 'true') {
- return {
+export const loggerInstance = process.env.CUSTOM_DOMAIN_LOGGER === 'true'
+ ? {
log: (message, ...args) => {
console.log(message, ...args)
},
@@ -10,13 +9,12 @@ export const domainLogger = () => {
console.error(message, ...args)
}
}
- } else {
- return {
+ : {
log: () => {},
error: () => {}
}
- }
-}
+
+export const domainLogger = () => loggerInstance
// fetch custom domain mappings from our API, caching it for 5 minutes
export const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings () {
From f405dd1cc0aa3a01d03e22d1ff2126810dbf5d8d Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Thu, 10 Apr 2025 13:44:29 -0500
Subject: [PATCH 64/74] hotfix: update MEDIA_URL_DOCKER to aws container
instead of s3
---
.env.development | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.env.development b/.env.development
index 0b4185c9a..58d9ed801 100644
--- a/.env.development
+++ b/.env.development
@@ -113,7 +113,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
From a63004bce668b9483da61bc1acb402712c47d1a8 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Wed, 23 Apr 2025 02:33:28 -0500
Subject: [PATCH 65/74] Middleware review changes
Domain mappings:
- use direct db calls with cachedFetcher to map domains
Middleware:
- pass main domain, custom domain, subName to the customDomainMiddleware;
- retrieve main domain's hostname from env vars;
- only get subName from domain mappings cache (domain is the key);
- only check cache if we're not on the main domain; correct function declarations;
- light cleanup
Schema:
- use Citext for domain names as they're case-insensitive
---
lib/domains.js | 34 +++++---
middleware.js | 78 ++++++++++---------
.../migration.sql | 2 +-
prisma/schema.prisma | 2 +-
4 files changed, 68 insertions(+), 48 deletions(-)
diff --git a/lib/domains.js b/lib/domains.js
index 407842db7..cfa3c63a4 100644
--- a/lib/domains.js
+++ b/lib/domains.js
@@ -1,4 +1,5 @@
import { cachedFetcher } from '@/lib/fetch'
+import prisma from '@/api/models'
export const loggerInstance = process.env.CUSTOM_DOMAIN_LOGGER === 'true'
? {
@@ -16,21 +17,32 @@ export const loggerInstance = process.env.CUSTOM_DOMAIN_LOGGER === 'true'
export const domainLogger = () => loggerInstance
-// fetch custom domain mappings from our API, caching it for 5 minutes
+// fetch custom domain mappings from database, caching it for 5 minutes
export const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings () {
- const url = `${process.env.NEXT_PUBLIC_URL}/api/domains`
- domainLogger().log('fetching domain mappings from', url) // TEST
+ domainLogger().log('fetching domain mappings from database') // TEST
try {
- const response = await fetch(url)
- if (!response.ok) {
- domainLogger().error(`Cannot fetch domain mappings: ${response.status} ${response.statusText}`)
- return null
- }
+ // fetch all VERIFIED custom domains from the database
+ const domains = await prisma.customDomain.findMany({
+ select: {
+ domain: true,
+ subName: true
+ },
+ where: {
+ status: 'ACTIVE'
+ }
+ })
+
+ // map domains to a key-value pair
+ const domainMappings = domains.reduce((acc, domain) => {
+ acc[domain.domain.toLowerCase()] = {
+ subName: domain.subName
+ }
+ return acc
+ }, {})
- const data = await response.json()
- return Object.keys(data).length > 0 ? data : null
+ return domainMappings
} catch (error) {
- domainLogger().error('Cannot fetch domain mappings:', error)
+ domainLogger().error('cannot fetch domain mappings from db:', error)
return null
}
}, {
diff --git a/middleware.js b/middleware.js
index 9c14b3fe0..da37e6e68 100644
--- a/middleware.js
+++ b/middleware.js
@@ -12,71 +12,73 @@ const SN_REFERRER = 'sn_referrer'
const SN_REFERRER_NONCE = 'sn_referrer_nonce'
// key for referred pages
const SN_REFEREE_LANDING = 'sn_referee_landing'
-// rewrite to ~subname paths
+// rewrite to ~subName paths
const TERRITORY_PATHS = ['/~', '/recent', '/random', '/top', '/post', '/edit']
-// get a domain mapping from the cache
-export async function getDomainMapping (domain) {
- const domainMappings = await getDomainMappingsCache()
- return domainMappings?.[domain]
-}
-
// Redirects and rewrites for custom domains
-export async function customDomainMiddleware (request, referrerResp, domain) {
- const host = request.headers.get('host')
- const url = request.nextUrl.clone()
+async function customDomainMiddleware (req, referrerResp, mainDomain, domain, subName) {
+ // clone the request url to build on top of it
+ const url = req.nextUrl.clone()
+ // get the pathname from the url
const pathname = url.pathname
- const mainDomain = process.env.NEXT_PUBLIC_URL
+ // get the query params from the url
+ const query = url.searchParams
// TEST
- domainLogger().log('host', host)
- domainLogger().log('mainDomain', mainDomain)
+ domainLogger().log('req.headers host', req.headers.get('host'))
+ domainLogger().log('main domain', mainDomain)
+ domainLogger().log('custom domain', domain)
+ domainLogger().log('subName', subName)
domainLogger().log('pathname', pathname)
- domainLogger().log('query', url.searchParams)
+ domainLogger().log('query', query)
- const requestHeaders = new Headers(request.headers)
- requestHeaders.set('x-stacker-news-subname', domain.subName)
+ const requestHeaders = new Headers(req.headers)
+ requestHeaders.set('x-stacker-news-subname', subName)
// Auth sync redirects with domain and optional callbackUrl and multiAuth params
if (pathname === '/login' || pathname === '/signup') {
const redirectUrl = new URL(pathname, mainDomain)
- redirectUrl.searchParams.set('domain', host)
- if (url.searchParams.get('callbackUrl')) {
- redirectUrl.searchParams.set('callbackUrl', url.searchParams.get('callbackUrl'))
+ redirectUrl.searchParams.set('domain', domain)
+ if (query.get('callbackUrl')) {
+ redirectUrl.searchParams.set('callbackUrl', query.get('callbackUrl'))
}
- if (url.searchParams.get('multiAuth')) {
- redirectUrl.searchParams.set('multiAuth', url.searchParams.get('multiAuth'))
+ if (query.get('multiAuth')) {
+ redirectUrl.searchParams.set('multiAuth', query.get('multiAuth'))
}
const redirectResp = NextResponse.redirect(redirectUrl, {
headers: requestHeaders
})
- return applyReferrerCookies(redirectResp, referrerResp) // apply referrer cookies to the redirect
+ // apply referrer cookies to the redirect
+ return applyReferrerCookies(redirectResp, referrerResp)
}
// If trying to access a ~subname path, rewrite to /
- if (pathname.startsWith(`/~${domain.subName}`)) {
+ if (pathname.startsWith(`/~${subName}`)) {
// remove the territory prefix from the path
- const cleanPath = pathname.replace(`/~${domain.subName}`, '') || '/'
+ const cleanPath = pathname.replace(`/~${subName}`, '') || '/'
domainLogger().log('Redirecting to clean path:', cleanPath)
const redirectResp = NextResponse.redirect(new URL(cleanPath + url.search, url.origin), {
headers: requestHeaders
})
- return applyReferrerCookies(redirectResp, referrerResp) // apply referrer cookies to the redirect
+ // apply referrer cookies to the redirect
+ return applyReferrerCookies(redirectResp, referrerResp)
}
// If we're at the root or a territory path, rewrite to the territory path
if (pathname === '/' || TERRITORY_PATHS.some(p => pathname.startsWith(p))) {
const internalUrl = new URL(url)
- internalUrl.pathname = `/~${domain.subName}${pathname === '/' ? '' : pathname}`
+ internalUrl.pathname = `/~${subName}${pathname === '/' ? '' : pathname}`
domainLogger().log('Rewrite to:', internalUrl.pathname)
// rewrite to the territory path
const resp = NextResponse.rewrite(internalUrl, {
headers: requestHeaders
})
- return applyReferrerCookies(resp, referrerResp) // apply referrer cookies to the rewrite
+ // apply referrer cookies to the rewrite
+ return applyReferrerCookies(resp, referrerResp)
}
- return NextResponse.next({ // continue if we don't need to rewrite or redirect
+ // continue if we don't need to rewrite or redirect
+ return NextResponse.next({
request: {
headers: requestHeaders
}
@@ -171,7 +173,7 @@ function referrerMiddleware (request) {
return response
}
-export function applySecurityHeaders (resp) {
+function applySecurityHeaders (resp) {
const isDev = process.env.NODE_ENV === 'development'
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
@@ -227,12 +229,18 @@ export async function middleware (request) {
}
// If we're on a custom domain, handle that next
- const host = request.headers.get('host')
- const isAllowedDomain = await getDomainMapping(host?.toLowerCase())
- if (isAllowedDomain) {
- domainLogger().log('detected allowed custom domain', isAllowedDomain)
- const customDomainResp = await customDomainMiddleware(request, referrerResp, isAllowedDomain)
- return applySecurityHeaders(customDomainResp)
+ const domain = request.headers.get('x-forwarded-host') || request.headers.get('host')
+ // get the main domain from the env vars
+ const mainDomain = new URL(process.env.NEXT_PUBLIC_URL).host
+ if (domain !== mainDomain) {
+ // get the subName from the domain mappings cache
+ const { subName } = await getDomainMappingsCache()?.[domain?.toLowerCase()]
+ if (subName) {
+ domainLogger().log('detected allowed custom domain for: ', subName)
+ // handle the custom domain
+ const customDomainResp = await customDomainMiddleware(request, referrerResp, mainDomain, domain, subName)
+ return applySecurityHeaders(customDomainResp)
+ }
}
return applySecurityHeaders(referrerResp)
diff --git a/prisma/migrations/20250304121322_custom_domains/migration.sql b/prisma/migrations/20250304121322_custom_domains/migration.sql
index 3248e814d..cc5995cfd 100644
--- a/prisma/migrations/20250304121322_custom_domains/migration.sql
+++ b/prisma/migrations/20250304121322_custom_domains/migration.sql
@@ -3,7 +3,7 @@ CREATE TABLE "CustomDomain" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "domain" TEXT NOT NULL,
+ "domain" CITEXT NOT NULL,
"subName" CITEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"failedAttempts" INTEGER NOT NULL DEFAULT 0,
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 7d18948fb..cf479fdca 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1250,7 +1250,7 @@ model CustomDomain {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
- domain String @unique
+ domain String @unique @db.Citext
subName String @unique @db.Citext
status String @default("PENDING")
failedAttempts Int @default(0)
From 4595b606670e0a2b051c6e32c3bf3f480f151833 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Wed, 23 Apr 2025 05:32:42 -0500
Subject: [PATCH 66/74] revert usage of direct prisma calls on domain mappings
---
lib/domains.js | 34 +++++++++++-----------------------
1 file changed, 11 insertions(+), 23 deletions(-)
diff --git a/lib/domains.js b/lib/domains.js
index cfa3c63a4..407842db7 100644
--- a/lib/domains.js
+++ b/lib/domains.js
@@ -1,5 +1,4 @@
import { cachedFetcher } from '@/lib/fetch'
-import prisma from '@/api/models'
export const loggerInstance = process.env.CUSTOM_DOMAIN_LOGGER === 'true'
? {
@@ -17,32 +16,21 @@ export const loggerInstance = process.env.CUSTOM_DOMAIN_LOGGER === 'true'
export const domainLogger = () => loggerInstance
-// fetch custom domain mappings from database, caching it for 5 minutes
+// fetch custom domain mappings from our API, caching it for 5 minutes
export const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings () {
- domainLogger().log('fetching domain mappings from database') // TEST
+ const url = `${process.env.NEXT_PUBLIC_URL}/api/domains`
+ domainLogger().log('fetching domain mappings from', url) // TEST
try {
- // fetch all VERIFIED custom domains from the database
- const domains = await prisma.customDomain.findMany({
- select: {
- domain: true,
- subName: true
- },
- where: {
- status: 'ACTIVE'
- }
- })
-
- // map domains to a key-value pair
- const domainMappings = domains.reduce((acc, domain) => {
- acc[domain.domain.toLowerCase()] = {
- subName: domain.subName
- }
- return acc
- }, {})
+ const response = await fetch(url)
+ if (!response.ok) {
+ domainLogger().error(`Cannot fetch domain mappings: ${response.status} ${response.statusText}`)
+ return null
+ }
- return domainMappings
+ const data = await response.json()
+ return Object.keys(data).length > 0 ? data : null
} catch (error) {
- domainLogger().error('cannot fetch domain mappings from db:', error)
+ domainLogger().error('Cannot fetch domain mappings:', error)
return null
}
}, {
From eba5d098c5a4a961002796ea4328670383879c7e Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Wed, 23 Apr 2025 05:49:28 -0500
Subject: [PATCH 67/74] hotfix: cachedFetcher's domain mappings have to be
called before accessing them
---
middleware.js | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/middleware.js b/middleware.js
index da37e6e68..8badffd9b 100644
--- a/middleware.js
+++ b/middleware.js
@@ -234,11 +234,12 @@ export async function middleware (request) {
const mainDomain = new URL(process.env.NEXT_PUBLIC_URL).host
if (domain !== mainDomain) {
// get the subName from the domain mappings cache
- const { subName } = await getDomainMappingsCache()?.[domain?.toLowerCase()]
- if (subName) {
- domainLogger().log('detected allowed custom domain for: ', subName)
+ const cachedDomainMappings = await getDomainMappingsCache()
+ const domainMapping = cachedDomainMappings?.[domain?.toLowerCase()]
+ if (domainMapping) {
+ domainLogger().log('detected allowed custom domain for: ', domainMapping.subName)
// handle the custom domain
- const customDomainResp = await customDomainMiddleware(request, referrerResp, mainDomain, domain, subName)
+ const customDomainResp = await customDomainMiddleware(request, referrerResp, mainDomain, domain, domainMapping.subName)
return applySecurityHeaders(customDomainResp)
}
}
From 21a15d9163de8c417dcdd9ddc18dbdaea4056763 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Wed, 23 Apr 2025 19:10:13 -0500
Subject: [PATCH 68/74] Territory Edit UI/UX changes
Copy
- Uses CopyButton from Form to copy DNS record values
Domain Verification UI/UX
- Can trigger a re-verification by re-submitting the domain
- slight cleanup
Validation hints
= Better validation hint
---
components/form.js | 10 +++-
components/item.module.css | 34 ++++++++++++
components/territory-domains.js | 97 ++++++++++++++++++++++++---------
lib/validate.js | 2 +-
4 files changed, 114 insertions(+), 29 deletions(-)
diff --git a/components/form.js b/components/form.js
index d13f1761b..16753aecf 100644
--- a/components/form.js
+++ b/components/form.js
@@ -78,7 +78,7 @@ export function SubmitButton ({
)
}
-function CopyButton ({ value, icon, ...props }) {
+export function CopyButton ({ value, icon, append, ...props }) {
const toaster = useToast()
const [copied, setCopied] = useState(false)
@@ -101,6 +101,14 @@ function CopyButton ({ value, icon, ...props }) {
)
}
+ if (append) {
+ return (
+
+ {append}
+
+ )
+ }
+
return (
{copied ? : 'copy'}
diff --git a/components/item.module.css b/components/item.module.css
index 8800900e5..f2815eff7 100644
--- a/components/item.module.css
+++ b/components/item.module.css
@@ -247,3 +247,37 @@ a.link:visited {
.skeleton .otherItemLonger {
width: 60px;
}
+
+.record {
+ display: flex;
+ flex-direction: column;
+ margin-right: 0.5rem;
+}
+
+.clipboard {
+ cursor: pointer;
+ fill: var(--theme-grey);
+ width: 1rem;
+ height: 1rem;
+ opacity: 0;
+ transition: opacity 0.1s ease, fill 0.1s ease;
+}
+
+.record:hover .clipboard {
+ opacity: 1;
+}
+
+.clipboard:hover {
+ fill: var(--bs-primary);
+}
+
+.refresh {
+ cursor: pointer;
+ fill: var(--theme-grey);
+ width: 1rem;
+ height: 1rem;
+}
+
+.refresh:hover {
+ fill: var(--bs-primary);
+}
\ No newline at end of file
diff --git a/components/territory-domains.js b/components/territory-domains.js
index 7237f6269..c8c49bd6d 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -1,5 +1,5 @@
import { Badge } from 'react-bootstrap'
-import { Form, Input, SubmitButton } from './form'
+import { Form, Input, SubmitButton, CopyButton } from './form'
import { useMutation, useQuery } from '@apollo/client'
import { customDomainSchema } from '@/lib/validate'
import ActionTooltip from './action-tooltip'
@@ -12,6 +12,9 @@ import { signIn } from 'next-auth/react'
import BrandingForm from '@/components/territory-branding-form'
import Head from 'next/head'
import Moon from '@/svgs/moon-fill.svg'
+import ClipboardLine from '@/svgs/clipboard-line.svg'
+import RefreshLine from '@/svgs/refresh-line.svg'
+import styles from './item.module.css'
// Domain context for custom domains
const DomainContext = createContext({
@@ -91,19 +94,27 @@ const getSSLStatusBadge = (status) => {
}
}
-export function DomainLabel ({ customDomain, polling }) {
+const DomainLabel = ({ customDomain, polling }) => {
const { domain, status, verification, lastVerifiedAt } = customDomain || {}
+
return (
custom domain
{domain && (
- {status !== 'HOLD' && (
- <>
- {getStatusBadge(verification?.dns?.state)}
- {getSSLStatusBadge(verification?.ssl?.state)}
- >
+ {status !== 'HOLD'
+ ? (
+ <>
+ {getStatusBadge(verification?.dns?.state)}
+ {getSSLStatusBadge(verification?.ssl?.state)}
+ >
+ )
+ : (HOLD )}
+ {status === 'HOLD' && (
+
+
+
)}
{polling && }
@@ -113,38 +124,66 @@ export function DomainLabel ({ customDomain, polling }) {
)
}
-export function DomainGuidelines ({ customDomain }) {
+const DomainGuidelines = ({ customDomain }) => {
const { domain, verification } = customDomain || {}
+
+ const dnsRecord = (host, value) => (
+
+
+
+ host
+
+ }
+ />
+
+ {host}
+
+
+
+ value
+
+ }
+ />
+
+ {value}
+
+
+ )
+
return (
- <>
+
{(verification?.dns?.state && verification?.dns?.state !== 'VERIFIED') && (
- <>
+
Step 1: Verify your domain
Add the following DNS records to verify ownership of your domain:
CNAME
-
- Host:
{domain || 'www'}
- Value:
stacker.news
-
+ {dnsRecord(domain || 'www', verification?.dns?.cname)}
+
TXT
-
- Host:
{domain || 'www'}
- Value:
{verification?.dns?.txt}
-
- >
+ {dnsRecord(domain || 'www', verification?.dns?.txt)}
+
)}
{verification?.ssl?.state === 'PENDING' && (
- <>
+
Step 2: Prepare your domain for SSL
We issued an SSL certificate for your domain. To validate it, add the following CNAME record:
CNAME
-
- Host:
{verification?.ssl?.cname || 'waiting for SSL certificate'}
- Value:
{verification?.ssl?.value || 'waiting for SSL certificate'}
-
- >
+ {dnsRecord(verification?.ssl?.cname || 'waiting for SSL certificate', verification?.ssl?.value || 'waiting for SSL certificate')}
+
)}
- >
+
)
}
@@ -179,7 +218,11 @@ export default function CustomDomainForm ({ sub }) {
}
})
refetch()
- toaster.success('domain updated successfully')
+ if (domain) {
+ toaster.success('started domain verification')
+ } else {
+ toaster.success('domain removed successfully')
+ }
} catch (error) {
toaster.danger('failed to update domain', { error })
}
diff --git a/lib/validate.js b/lib/validate.js
index 14bd5b8a1..b77c0b8c1 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -360,7 +360,7 @@ export function territoryTransferSchema ({ me, ...args }) {
export function customDomainSchema (args) {
return object({
domain: string().matches(/^(?:[a-z0-9-]+\.){2,}[a-z]{2,}$/, {
- message: 'enter a valid domain name (e.g., www.example.com)'
+ message: 'CNAME records only support subdomains (e.g., www.example.com, sub.example.com)'
}).nullable()
})
}
From 06608b12329d338ef00e05433a1ea5ddd6131df4 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Thu, 24 Apr 2025 04:14:02 -0500
Subject: [PATCH 69/74] keep verification state in PENDING instead of FAILED;
use _snverify as TXT record to be checked; light cleanup
---
api/resolvers/domain.js | 51 ++++++++++++++-------------------
components/territory-domains.js | 12 +++-----
lib/domain-verification.js | 3 +-
worker/domainVerification.js | 6 ++--
4 files changed, 30 insertions(+), 42 deletions(-)
diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js
index 44294e52b..cc3b692e5 100644
--- a/api/resolvers/domain.js
+++ b/api/resolvers/domain.js
@@ -37,41 +37,32 @@ export default {
if (existing && existing.domain === domain && existing.status !== 'HOLD') {
throw new GqlInputError('domain already set')
}
+
+ const initializeDomain = {
+ domain,
+ status: 'PENDING',
+ verification: {
+ dns: {
+ state: 'PENDING',
+ cname: 'stacker.news',
+ txt: randomBytes(32).toString('base64')
+ },
+ ssl: {
+ state: 'WAITING',
+ arn: null,
+ cname: null,
+ value: null
+ }
+ }
+ }
+
const updatedDomain = await models.customDomain.upsert({
where: { subName },
update: {
- domain,
- status: 'PENDING',
- verification: {
- dns: {
- state: 'PENDING',
- cname: 'stacker.news',
- txt: randomBytes(32).toString('base64')
- },
- ssl: {
- state: 'WAITING',
- arn: null,
- cname: null,
- value: null
- }
- }
+ ...initializeDomain
},
create: {
- domain,
- status: 'PENDING',
- verification: {
- dns: {
- state: 'PENDING',
- cname: 'stacker.news',
- txt: randomBytes(32).toString('base64')
- },
- ssl: {
- state: 'WAITING',
- arn: null,
- cname: null,
- value: null
- }
- },
+ ...initializeDomain,
sub: {
connect: { name: subName }
}
diff --git a/components/territory-domains.js b/components/territory-domains.js
index c8c49bd6d..cd846cbbe 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -74,10 +74,8 @@ const getStatusBadge = (status) => {
switch (status) {
case 'VERIFIED':
return DNS verified
- case 'PENDING':
+ default:
return DNS pending
- case 'FAILED':
- return DNS failed
}
}
@@ -85,12 +83,10 @@ const getSSLStatusBadge = (status) => {
switch (status) {
case 'VERIFIED':
return SSL verified
- case 'PENDING':
- return SSL pending
- case 'FAILED':
- return SSL failed
case 'WAITING':
return SSL waiting
+ default:
+ return SSL pending
}
}
@@ -172,7 +168,7 @@ const DomainGuidelines = ({ customDomain }) => {
{dnsRecord(domain || 'www', verification?.dns?.cname)}
TXT
- {dnsRecord(domain || 'www', verification?.dns?.txt)}
+ {dnsRecord(`_snverify.${domain}`, verification?.dns?.txt)}
)}
{verification?.ssl?.state === 'PENDING' && (
diff --git a/lib/domain-verification.js b/lib/domain-verification.js
index 964577ecf..cf257c36e 100644
--- a/lib/domain-verification.js
+++ b/lib/domain-verification.js
@@ -61,6 +61,7 @@ export async function getValidationValues (certificateArn) {
// Verify the DNS records for a custom domain
export async function verifyDomainDNS (domainName, verificationTxt, verificationCname) {
const cname = verificationCname || process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
+ const txtHost = `_snverify.${domainName}`
const result = {
txtValid: false,
cnameValid: false,
@@ -72,7 +73,7 @@ export async function verifyDomainDNS (domainName, verificationTxt, verification
// TXT Records checking
try {
- const txtRecords = await dnsPromises.resolve(domainName, 'TXT')
+ const txtRecords = await dnsPromises.resolve(txtHost, 'TXT')
const txtText = txtRecords.flat().join(' ')
// the TXT record should include the verificationTxt that we have in the database
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 8f40af6c2..9488c6b8b 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -51,7 +51,7 @@ async function verifyDomain (domain, models) {
await updateCertificateStatus(data)
}
- if (data.verification?.dns?.state === 'FAILED' || data.verification?.ssl?.state === 'FAILED') {
+ if (data.verification?.dns?.state === 'PENDING' || data.verification?.ssl?.state === 'PENDING') {
data.failedAttempts += 1
// exponential backoff at the 11th attempt is roughly 48 hours
if (data.failedAttempts > 11) {
@@ -71,7 +71,7 @@ async function verifyDNS (data) {
const { txtValid, cnameValid } = await verifyDomainDNS(data.domain, data.verification.dns.txt)
console.log(`${data.domain}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
- data.verification.dns.state = txtValid && cnameValid ? 'VERIFIED' : 'FAILED'
+ data.verification.dns.state = txtValid && cnameValid ? 'VERIFIED' : 'PENDING'
return data
}
@@ -96,7 +96,7 @@ async function issueCertificate (data) {
}
}
} else {
- data.verification.ssl.state = 'FAILED'
+ data.verification.ssl.state = 'PENDING'
}
return data
From d65f1705d45185139a2f3b3fc9bbadac79a3b323 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Fri, 25 Apr 2025 06:25:09 -0500
Subject: [PATCH 70/74] UI/UX: error messages, hover states, light cleanup
error: uses the error thrown by the domains resolver
hover: clipboards and verification time are shown by default instead of being hidden by hover
cleanup: clearer dnsRecord subcomponent
---
api/resolvers/domain.js | 10 ++-
components/item.module.css | 6 --
components/territory-domains.js | 119 +++++++++++++++++---------------
3 files changed, 70 insertions(+), 65 deletions(-)
diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js
index cc3b692e5..23aeef51a 100644
--- a/api/resolvers/domain.js
+++ b/api/resolvers/domain.js
@@ -44,8 +44,7 @@ export default {
verification: {
dns: {
state: 'PENDING',
- cname: 'stacker.news',
- txt: randomBytes(32).toString('base64')
+ cname: 'stacker.news'
},
ssl: {
state: 'WAITING',
@@ -63,6 +62,13 @@ export default {
},
create: {
...initializeDomain,
+ verification: {
+ ...initializeDomain.verification,
+ dns: {
+ ...initializeDomain.verification.dns,
+ txt: randomBytes(32).toString('base64')
+ }
+ },
sub: {
connect: { name: subName }
}
diff --git a/components/item.module.css b/components/item.module.css
index f2815eff7..1a98cbc3d 100644
--- a/components/item.module.css
+++ b/components/item.module.css
@@ -259,12 +259,6 @@ a.link:visited {
fill: var(--theme-grey);
width: 1rem;
height: 1rem;
- opacity: 0;
- transition: opacity 0.1s ease, fill 0.1s ease;
-}
-
-.record:hover .clipboard {
- opacity: 1;
}
.clipboard:hover {
diff --git a/components/territory-domains.js b/components/territory-domains.js
index cd846cbbe..8ec090f2f 100644
--- a/components/territory-domains.js
+++ b/components/territory-domains.js
@@ -2,7 +2,6 @@ import { Badge } from 'react-bootstrap'
import { Form, Input, SubmitButton, CopyButton } from './form'
import { useMutation, useQuery } from '@apollo/client'
import { customDomainSchema } from '@/lib/validate'
-import ActionTooltip from './action-tooltip'
import { useToast } from '@/components/toast'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
import { GET_CUSTOM_DOMAIN, SET_CUSTOM_DOMAIN } from '@/fragments/domains'
@@ -97,24 +96,27 @@ const DomainLabel = ({ customDomain, polling }) => {
custom domain
{domain && (
-
-
- {status !== 'HOLD'
- ? (
- <>
- {getStatusBadge(verification?.dns?.state)}
- {getSSLStatusBadge(verification?.ssl?.state)}
- >
- )
- : (HOLD )}
- {status === 'HOLD' && (
-
-
-
- )}
- {polling && }
-
-
+
+ {status !== 'HOLD'
+ ? (
+ <>
+ {getStatusBadge(verification?.dns?.state)}
+ {getSSLStatusBadge(verification?.ssl?.state)}
+ >
+ )
+ : (HOLD )}
+ {status === 'HOLD' && (
+
+
+
+ )}
+ {polling && }
+
+ )}
+ {lastVerifiedAt && status !== 'ACTIVE' && (
+
+ last verified {new Date(lastVerifiedAt).toLocaleString()}
+
)}
)
@@ -123,40 +125,42 @@ const DomainLabel = ({ customDomain, polling }) => {
const DomainGuidelines = ({ customDomain }) => {
const { domain, verification } = customDomain || {}
- const dnsRecord = (host, value) => (
-
-
-
- host
-
- }
- />
-
- {host}
-
-
-
- value
-
- }
- />
-
- {value}
-
-
- )
+ const dnsRecord = ({ host, value }) => {
+ return (
+
+
+
+ host
+
+ }
+ />
+
+ {host}
+
+
+
+ value
+
+ }
+ />
+
+ {value}
+
+
+ )
+ }
return (
@@ -165,10 +169,10 @@ const DomainGuidelines = ({ customDomain }) => {
Step 1: Verify your domain
Add the following DNS records to verify ownership of your domain:
CNAME
- {dnsRecord(domain || 'www', verification?.dns?.cname)}
+ {dnsRecord({ host: domain || 'www', value: verification?.dns?.cname })}
TXT
- {dnsRecord(`_snverify.${domain}`, verification?.dns?.txt)}
+ {dnsRecord({ host: `_snverify.${domain}`, value: verification?.dns?.txt })}
)}
{verification?.ssl?.state === 'PENDING' && (
@@ -176,7 +180,7 @@ const DomainGuidelines = ({ customDomain }) => {
Step 2: Prepare your domain for SSL
We issued an SSL certificate for your domain. To validate it, add the following CNAME record:
CNAME
- {dnsRecord(verification?.ssl?.cname || 'waiting for SSL certificate', verification?.ssl?.value || 'waiting for SSL certificate')}
+ {dnsRecord({ host: verification?.ssl?.cname || 'waiting for SSL certificate', value: verification?.ssl?.value || 'waiting for SSL certificate' })}
)}
@@ -220,7 +224,7 @@ export default function CustomDomainForm ({ sub }) {
toaster.success('domain removed successfully')
}
} catch (error) {
- toaster.danger('failed to update domain', { error })
+ toaster.danger(error.message)
}
}
@@ -234,6 +238,7 @@ export default function CustomDomainForm ({ sub }) {
>
}
name='domain'
placeholder='www.example.com'
From 07d90aba2dac115fade415e7ed1ef6ac6817066c Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Fri, 25 Apr 2025 06:35:20 -0500
Subject: [PATCH 71/74] use Resolver class of dns/promises, avoiding messing
with other network calls
---
lib/domain-verification.js | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/lib/domain-verification.js b/lib/domain-verification.js
index cf257c36e..ae0edff2b 100644
--- a/lib/domain-verification.js
+++ b/lib/domain-verification.js
@@ -1,5 +1,5 @@
import { requestCertificate, getCertificateStatus, describeCertificate } from '@/api/acm'
-import { promises as dnsPromises } from 'node:dns'
+import { Resolver } from 'node:dns/promises'
// Issue a certificate for a custom domain
export async function issueDomainCertificate (domainName) {
@@ -69,11 +69,12 @@ export async function verifyDomainDNS (domainName, verificationTxt, verification
}
// by default use cloudflare DNS resolver
- dnsPromises.setServers([process.env.DNS_RESOLVER || '1.1.1.1'])
+ const resolver = new Resolver()
+ resolver.setServers([process.env.DNS_RESOLVER || '1.1.1.1'])
// TXT Records checking
try {
- const txtRecords = await dnsPromises.resolve(txtHost, 'TXT')
+ const txtRecords = await resolver.resolveTxt(txtHost)
const txtText = txtRecords.flat().join(' ')
// the TXT record should include the verificationTxt that we have in the database
@@ -88,7 +89,7 @@ export async function verifyDomainDNS (domainName, verificationTxt, verification
// CNAME Records checking
try {
- const cnameRecords = await dnsPromises.resolve(domainName, 'CNAME')
+ const cnameRecords = await resolver.resolveCname(domainName)
// the CNAME record should include the cname that we have in the database
result.cnameValid = cnameRecords.some(record =>
From 9eb8d961c9f88e20a42afedfc1ec9d736f586405 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Sun, 27 Apr 2025 12:46:40 -0500
Subject: [PATCH 72/74] Domain verification changes
- job will be scheduled after 30 seconds and will be maintained for 48 hours
- step-by-step verification process (to be modularized)
- don't throw an error if it's not critical
- if verification process results in a still PENDING domain we schedule it again after 5 minutes
- if the status is still NOT ACTIVE (e.g. PENDING) we put the status on HOLD to avoid re-scheduling
---
api/resolvers/domain.js | 24 ++---
worker/domainVerification.js | 184 ++++++++++++++++++++---------------
2 files changed, 116 insertions(+), 92 deletions(-)
diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js
index 23aeef51a..a726b8140 100644
--- a/api/resolvers/domain.js
+++ b/api/resolvers/domain.js
@@ -27,9 +27,10 @@ export default {
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')
+ throw new GqlInputError('invalid domain format')
}
if (domain) {
@@ -44,7 +45,9 @@ export default {
verification: {
dns: {
state: 'PENDING',
- cname: 'stacker.news'
+ cname: 'stacker.news',
+ // generate a random txt record only if it's a new domain
+ txt: existing?.domain === domain ? existing.verification.dns.txt : randomBytes(32).toString('base64')
},
ssl: {
state: 'WAITING',
@@ -62,28 +65,17 @@ export default {
},
create: {
...initializeDomain,
- verification: {
- ...initializeDomain.verification,
- dns: {
- ...initializeDomain.verification.dns,
- txt: randomBytes(32).toString('base64')
- }
- },
sub: {
connect: { name: subName }
}
}
})
- // schedule domain verification in 5 seconds, apply exponential backoff and keep it for 2 days
- // 12 retries, 42 seconds of delay between retries will fit 48 hours of trying for DNS propagation
- await models.$executeRaw`INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, retrydelay, startafter, keepuntil)
+ // schedule domain verification in 30 seconds
+ await models.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, keepuntil)
VALUES ('domainVerification',
jsonb_build_object('domainId', ${updatedDomain.id}::INTEGER),
- 12,
- true,
- '42', -- 42 seconds of delay between retries
- now() + interval '5 seconds',
+ now() + interval '30 seconds',
now() + interval '2 days')`
return updatedDomain
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index 9488c6b8b..b9d911ea9 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -1,110 +1,142 @@
import createPrisma from '@/lib/create-prisma'
import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus, getValidationValues } from '@/lib/domain-verification'
-// This worker verifies the DNS and SSL certificates for domains that are pending or failed
export async function domainVerification ({ data: { domainId }, boss }) {
+ // establish connection to database
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
- console.log('domainVerification', domainId)
try {
+ // get domain from database
const domain = await models.customDomain.findUnique({ where: { id: domainId } })
-
+ // if we can't find the domain, bail without scheduling a retry
if (!domain) {
throw new Error(`domain with ID ${domainId} not found`)
}
- const result = await verifyDomain(domain, models)
-
- if (result?.status === 'ACTIVE') {
- console.log(`domain ${domain.domain} verified`)
- return
- }
-
- if (result?.status === 'HOLD') {
- console.log(`domain ${domain.domain} is on hold after too many failed attempts`)
- return
- }
-
- if (result?.status === 'PENDING') {
- throw new Error(`domain ${domain.domain} is still pending verification, will retry`)
+ // start verification process
+ const result = await verifyDomain(domain, boss)
+
+ // update the domain with the result
+ await models.customDomain.update({
+ where: { id: domainId },
+ data: { ...domain, ...result }
+ })
+
+ // if the result is PENDING it means we still have to verify the domain
+ // if it's not PENDING, we stop the verification process.
+ if (result.status === 'PENDING') {
+ // we still need to verify the domain, schedule the job to run again in 5 minutes
+ const jobId = await boss.send('domainVerification', { domainId }, {
+ startAfter: 1000 * 60 * 5 // start the job after 5 minutes
+ })
+ console.log(`domain ${domain.domain} is still pending verification, created job with ID ${jobId} to run in 5 minutes`)
}
} catch (error) {
console.error(`couldn't verify domain with ID ${domainId}: ${error.message}`)
- throw error
}
}
-async function verifyDomain (domain, models) {
- // track verification
- const data = { ...domain, lastVerifiedAt: new Date() }
- data.verification = data.verification || { dns: {}, ssl: {} }
- data.failedAttempts = data.failedAttempts || 0
-
- if (data.verification?.dns?.state !== 'VERIFIED') {
- await verifyDNS(data)
+async function verifyDomain (domain) {
+ const lastVerifiedAt = new Date()
+ const verification = domain.verification || { dns: {}, ssl: {} }
+ let status = domain.status || 'PENDING'
+
+ // step 1: verify DNS [CNAME and TXT]
+ // if DNS is not already verified
+ let dnsState = verification.dns.state || 'PENDING'
+ if (dnsState !== 'VERIFIED') {
+ dnsState = await verifyDNS(domain.domain, verification.dns.txt)
+
+ // log the result, throw an error if we couldn't verify the DNS
+ switch (dnsState) {
+ case 'VERIFIED':
+ console.log(`DNS verification for ${domain.domain} is ${dnsState}, proceeding to SSL verification`)
+ break
+ case 'PENDING':
+ console.log(`DNS verification for ${domain.domain} is ${dnsState}, will retry DNS verification in 5 minutes`)
+ break
+ default:
+ dnsState = 'PENDING'
+ console.log(`couldn't verify DNS for ${domain.domain}, will retry DNS verification in 5 minutes`)
+ }
}
- if (data.verification?.dns?.state === 'VERIFIED' && (!data.verification?.ssl?.arn || data.verification?.ssl?.state === 'FAILED')) {
- await issueCertificate(data)
+ // step 2: certificate issuance
+ // if DNS is verified and we don't have a SSL certificate, issue one
+ let sslArn = verification.ssl.arn || null
+ let sslState = verification.ssl.state || 'PENDING'
+ if (dnsState === 'VERIFIED' && !sslArn) {
+ sslArn = await issueDomainCertificate(domain.domain)
+ if (sslArn) {
+ console.log(`SSL certificate issued for ${domain.domain} with ARN ${sslArn}, will verify with ACM in 5 minutes`)
+ } else {
+ console.log(`couldn't issue SSL certificate for ${domain.domain}, will retry certificate issuance in 5 minutes`)
+ }
}
- if (data.verification?.dns?.state === 'VERIFIED' && data.verification?.ssl?.state !== 'VERIFIED' && data.verification?.ssl?.arn) {
- await updateCertificateStatus(data)
+ // step 3: get validation values from ACM
+ // if we have a certificate and we don't already have the validation values
+ let acmValidationCname = verification.ssl.cname || null
+ let acmValidationValue = verification.ssl.value || null
+ if (sslArn && !acmValidationCname && !acmValidationValue) {
+ const { cname, value } = await getValidationValues(sslArn)
+ acmValidationCname = cname
+ acmValidationValue = value
+ if (acmValidationCname && acmValidationValue) {
+ console.log(`Validation values retrieved for ${domain.domain}, will check ACM validation status`)
+ } else {
+ console.log(`couldn't retrieve validation values for ${domain.domain}, will retry to request validation values from ACM in 5 minutes`)
+ }
}
- if (data.verification?.dns?.state === 'PENDING' || data.verification?.ssl?.state === 'PENDING') {
- data.failedAttempts += 1
- // exponential backoff at the 11th attempt is roughly 48 hours
- if (data.failedAttempts > 11) {
- data.status = 'HOLD'
- data.failedAttempts = 0
+ // step 4: check if the certificate is validated by ACM
+ // if DNS is verified and we have a SSL certificate
+ // it can happen that we just issued the certificate and it's not yet validated by ACM
+ if (dnsState === 'VERIFIED' && sslArn && sslState !== 'VERIFIED') {
+ sslState = await checkCertificateStatus(sslArn)
+ switch (sslState) {
+ case 'VERIFIED':
+ console.log(`SSL certificate for ${domain.domain} is ${sslState}, verification routine complete`)
+ break
+ case 'PENDING':
+ console.log(`SSL certificate for ${domain.domain} is ${sslState}, will check again with ACM in 5 minutes`)
+ break
+ default:
+ sslState = 'PENDING'
+ console.log(`couldn't verify SSL certificate for ${domain.domain}, will retry certificate validation with ACM in 5 minutes`)
}
}
- if (data.verification?.dns?.state === 'VERIFIED' && data.verification?.ssl?.state === 'VERIFIED') {
- data.status = 'ACTIVE'
+ // step 5: update the status of the domain
+ // if the domain is fully verified, set the status to active
+ if (dnsState === 'VERIFIED' && sslState === 'VERIFIED') {
+ status = 'ACTIVE'
+ }
+ // if the domain has failed in some way and it's been 48 hours, put it on hold
+ if (status !== 'ACTIVE' && domain.createdAt < new Date(Date.now() - 1000 * 60 * 60 * 24 * 2)) {
+ status = 'HOLD'
}
- return await models.customDomain.update({ where: { id: domain.id }, data })
-}
-
-async function verifyDNS (data) {
- const { txtValid, cnameValid } = await verifyDomainDNS(data.domain, data.verification.dns.txt)
- console.log(`${data.domain}: TXT ${txtValid ? 'valid' : 'invalid'}, CNAME ${cnameValid ? 'valid' : 'invalid'}`)
-
- data.verification.dns.state = txtValid && cnameValid ? 'VERIFIED' : 'PENDING'
- return data
-}
-
-async function issueCertificate (data) {
- const certificateArn = await issueDomainCertificate(data.domain)
- console.log(`${data.domain}: Certificate issued: ${certificateArn}`)
-
- if (certificateArn) {
- const sslState = await checkCertificateStatus(certificateArn)
- console.log(`${data.domain}: Issued certificate status: ${sslState}`)
-
- if (sslState) data.verification.ssl.state = sslState
- data.verification.ssl.arn = certificateArn
-
- if (sslState !== 'VERIFIED') {
- try {
- const { cname, value } = await getValidationValues(certificateArn)
- data.verification.ssl.cname = cname
- data.verification.ssl.value = value
- } catch (error) {
- console.error(`Failed to get validation values for domain ${data.domain}:`, error)
+ return {
+ lastVerifiedAt,
+ status,
+ verification: {
+ dns: {
+ state: dnsState
+ },
+ ssl: {
+ arn: sslArn,
+ state: sslState,
+ cname: acmValidationCname,
+ value: acmValidationValue
}
}
- } else {
- data.verification.ssl.state = 'PENDING'
}
-
- return data
}
-async function updateCertificateStatus (data) {
- const sslState = await checkCertificateStatus(data.verification.ssl.arn)
- console.log(`${data.domain}: Certificate status: ${sslState}`)
- if (sslState) data.verification.ssl.state = sslState
- return data
+async function verifyDNS (cname, txt) {
+ const { cnameValid, txtValid } = await verifyDomainDNS(cname, txt)
+ console.log(`${cname}: CNAME ${cnameValid ? 'valid' : 'invalid'}, TXT ${txtValid ? 'valid' : 'invalid'}`)
+
+ const dnsState = cnameValid && txtValid ? 'VERIFIED' : 'PENDING'
+ return dnsState
}
From c547eaa9ae31126f7d28e78897e10858ca699235 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Sun, 27 Apr 2025 18:29:38 -0500
Subject: [PATCH 73/74] Better domain verification
- deletes certificates, if any, when a domain is in HOLD
- better initial domain structure
- also deletes related jobs on domain deletion
- fix ACM validation values gathering
- put domain in HOLD after 3 consecutive critical errors
- independent verification steps
- fix typo on domain update via domain verification
---
api/acm/index.js | 10 ++++++++
api/resolvers/domain.js | 27 +++++++++++++++------
lib/domain-verification.js | 10 +++++---
worker/domainVerification.js | 46 ++++++++++++++++++++++++++++--------
4 files changed, 73 insertions(+), 20 deletions(-)
diff --git a/api/acm/index.js b/api/acm/index.js
index aef7708f6..cf76f4160 100644
--- a/api/acm/index.js
+++ b/api/acm/index.js
@@ -41,3 +41,13 @@ 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
index a726b8140..2cb466edc 100644
--- a/api/resolvers/domain.js
+++ b/api/resolvers/domain.js
@@ -33,21 +33,25 @@ export default {
throw new GqlInputError('invalid domain format')
}
+ const existing = await models.customDomain.findUnique({ where: { subName } })
+
if (domain) {
- const existing = await models.customDomain.findUnique({ where: { subName } })
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 : randomBytes(32).toString('base64')
+ txt: existing?.domain === domain && existing.verification.dns.txt
+ ? existing.verification.dns.txt
+ : randomBytes(32).toString('base64')
},
ssl: {
state: 'WAITING',
@@ -72,15 +76,24 @@ export default {
})
// schedule domain verification in 30 seconds
- await models.$executeRaw`INSERT INTO pgboss.job (name, data, startafter, keepuntil)
- VALUES ('domainVerification',
- jsonb_build_object('domainId', ${updatedDomain.id}::INTEGER),
- now() + interval '30 seconds',
- now() + interval '2 days')`
+ 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)
diff --git a/lib/domain-verification.js b/lib/domain-verification.js
index ae0edff2b..eca43f7c9 100644
--- a/lib/domain-verification.js
+++ b/lib/domain-verification.js
@@ -51,10 +51,14 @@ export async function certDetails (certificateArn) {
// TODO: Test with real values, localstack don't have this info until the certificate is issued
export async function getValidationValues (certificateArn) {
const certificate = await certDetails(certificateArn)
- console.log(certificate.DomainValidationOptions)
+ if (!certificate || !certificate.Certificate || !certificate.Certificate.DomainValidationOptions) {
+ return { cname: null, value: null }
+ }
+
+ console.log(certificate.Certificate.DomainValidationOptions)
return {
- cname: certificate.DomainValidationOptions[0].ResourceRecord.Name,
- value: certificate.DomainValidationOptions[0].ResourceRecord.Value
+ cname: certificate.Certificate.DomainValidationOptions[0]?.ResourceRecord?.Name || null,
+ value: certificate.Certificate.DomainValidationOptions[0]?.ResourceRecord?.Value || null
}
}
diff --git a/worker/domainVerification.js b/worker/domainVerification.js
index b9d911ea9..74d8cb174 100644
--- a/worker/domainVerification.js
+++ b/worker/domainVerification.js
@@ -1,7 +1,7 @@
import createPrisma from '@/lib/create-prisma'
-import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus, getValidationValues } from '@/lib/domain-verification'
+import { verifyDomainDNS, issueDomainCertificate, checkCertificateStatus, getValidationValues, deleteCertificate } from '@/lib/domain-verification'
-export async function domainVerification ({ data: { domainId }, boss }) {
+export async function domainVerification ({ id: jobId, data: { domainId }, boss }) {
// establish connection to database
const models = createPrisma({ connectionParams: { connection_limit: 1 } })
try {
@@ -13,12 +13,13 @@ export async function domainVerification ({ data: { domainId }, boss }) {
}
// start verification process
- const result = await verifyDomain(domain, boss)
+ const result = await verifyDomain(domain)
+ console.log(`domain verification result: ${JSON.stringify(result)}`)
// update the domain with the result
await models.customDomain.update({
where: { id: domainId },
- data: { ...domain, ...result }
+ data: result
})
// if the result is PENDING it means we still have to verify the domain
@@ -26,12 +27,30 @@ export async function domainVerification ({ data: { domainId }, boss }) {
if (result.status === 'PENDING') {
// we still need to verify the domain, schedule the job to run again in 5 minutes
const jobId = await boss.send('domainVerification', { domainId }, {
- startAfter: 1000 * 60 * 5 // start the job after 5 minutes
+ startAfter: 60 * 5, // start the job after 5 minutes
+ retryLimit: 3,
+ retryDelay: 30 // on critical errors, retry every 5 minutes
})
console.log(`domain ${domain.domain} is still pending verification, created job with ID ${jobId} to run in 5 minutes`)
}
} catch (error) {
console.error(`couldn't verify domain with ID ${domainId}: ${error.message}`)
+
+ // get the job details to get the retry count
+ const jobDetails = await boss.getJobById(jobId)
+ console.log(`job details: ${JSON.stringify(jobDetails)}`)
+ // if we couldn't verify the domain, put it on hold if it exists and delete any related verification jobs
+ if (jobDetails?.retrycount >= 3) {
+ console.log(`couldn't verify domain with ID ${domainId} for the third time, putting it on hold if it exists and deleting any related domain verification jobs`)
+ await models.customDomain.update({ where: { id: domainId }, data: { status: 'HOLD' } })
+ // delete any related domain verification jobs
+ await models.$queryRaw`
+ DELETE FROM pgboss.job
+ WHERE name = 'domainVerification'
+ AND data->>'domainId' = ${domainId}::TEXT`
+ }
+
+ throw error
}
}
@@ -67,7 +86,7 @@ async function verifyDomain (domain) {
if (dnsState === 'VERIFIED' && !sslArn) {
sslArn = await issueDomainCertificate(domain.domain)
if (sslArn) {
- console.log(`SSL certificate issued for ${domain.domain} with ARN ${sslArn}, will verify with ACM in 5 minutes`)
+ console.log(`SSL certificate issued for ${domain.domain} with ARN ${sslArn}, will verify with ACM`)
} else {
console.log(`couldn't issue SSL certificate for ${domain.domain}, will retry certificate issuance in 5 minutes`)
}
@@ -78,9 +97,9 @@ async function verifyDomain (domain) {
let acmValidationCname = verification.ssl.cname || null
let acmValidationValue = verification.ssl.value || null
if (sslArn && !acmValidationCname && !acmValidationValue) {
- const { cname, value } = await getValidationValues(sslArn)
- acmValidationCname = cname
- acmValidationValue = value
+ const values = await getValidationValues(sslArn)
+ acmValidationCname = values?.cname || null
+ acmValidationValue = values?.value || null
if (acmValidationCname && acmValidationValue) {
console.log(`Validation values retrieved for ${domain.domain}, will check ACM validation status`)
} else {
@@ -91,7 +110,7 @@ async function verifyDomain (domain) {
// step 4: check if the certificate is validated by ACM
// if DNS is verified and we have a SSL certificate
// it can happen that we just issued the certificate and it's not yet validated by ACM
- if (dnsState === 'VERIFIED' && sslArn && sslState !== 'VERIFIED') {
+ if (sslArn && sslState !== 'VERIFIED') {
sslState = await checkCertificateStatus(sslArn)
switch (sslState) {
case 'VERIFIED':
@@ -114,6 +133,12 @@ async function verifyDomain (domain) {
// if the domain has failed in some way and it's been 48 hours, put it on hold
if (status !== 'ACTIVE' && domain.createdAt < new Date(Date.now() - 1000 * 60 * 60 * 24 * 2)) {
status = 'HOLD'
+ // we stopped domain verification, delete the certificate as it will expire after 72 hours anyway
+ if (sslArn) {
+ console.log(`domain ${domain.domain} is on hold, deleting certificate as it will expire after 72 hours`)
+ const result = await deleteCertificate(sslArn)
+ console.log(`delete certificate attempt for ${domain.domain}, result: ${JSON.stringify(result)}`)
+ }
}
return {
@@ -121,6 +146,7 @@ async function verifyDomain (domain) {
status,
verification: {
dns: {
+ ...verification.dns,
state: dnsState
},
ssl: {
From 4bfda81a15996995e81aa3762c994ca22e1c4af0 Mon Sep 17 00:00:00 2001
From: Soxasora
Date: Mon, 28 Apr 2025 17:28:12 -0500
Subject: [PATCH 74/74] README: Custom domains prerequisites
---
docs/dev/custom-domains.md | 123 +++++++++++++++++++++++++++++++++++++
1 file changed, 123 insertions(+)
create mode 100644 docs/dev/custom-domains.md
diff --git a/docs/dev/custom-domains.md b/docs/dev/custom-domains.md
new file mode 100644
index 000000000..34dea2751
--- /dev/null
+++ b/docs/dev/custom-domains.md
@@ -0,0 +1,123 @@
+# Custom Domains
+tbd
+
+### Content
+- [Let's go HTTPS](#prerequisites)
+
+## Let's go HTTPS with a reverse proxy
+
+To set custom domains correctly we need to have a domain and SSL certificates.
+
+We'll cover a basic **NGINX** configuration with **Let's Encrypt/certbot** on Linux-based systems, but you have the freedom to experiment with other methods and platforms.
+
+#### Prerequisites
+- a domain or a public hostname
+- install [nginx](https://docs.nginx.com/nginx/admin-guide/installing-nginx/installing-nginx-open-source/)
+- install [certbot](https://certbot.eff.org/instructions?ws=nginx&os=pip)
+- possibility to add `CNAME` and `TXT` records
+- domain with an `A` record at your nginx host
+
+
+### Step 1: Create a nginx site for your SN instance
+
+Start creating a new site by editing `/etc/nginx/sites-available/your-domain.tld` with your editor of choice.
+
+A sample nginx site configuration to prepare for certbot
+Edit this configuration to match your configuration, you can have more domains.
+
+```
+server {
+ listen 80;
+ listen [::]:80;
+ server_name your-domain.tld (sub.your-domain.tld, another.your-domain.tld);
+
+ # for Let's Encrypt SSL issuance
+ location /.well-known/acme-challenge/ {
+ root /var/www/letsencrypt;
+ try_files $uri =404;
+ }
+}
+```
+
+
+after editing, send `sudo systemctl restart nginx`
+
+### Step 2: Get a certificate for your domains
+We can now get a certificate for your domain from Let's Encrypt/certbot.
+
+Edit the `-d` section to match your configuration. Every domain, sub-domain needs to have its own certificate.
+
+```
+sudo certbot certonly \
+ --webroot -w /var/www/letsencrypt \
+ -d your-domain.tld (-d sub.your-domain.tld -d another.your-domain.tld) \
+ --email your@email.com \
+ --agree-tos --no-eff-email \
+ --deploy-hook "systemctl reload nginx"
+```
+
+If everything went smooth, we should now have a domain with a valid SSL certificate.
+
+### Step 3: Proxy everything to sndev!
+
+Let's go back to `/etc/nginx/sites-available/your-domain.tld` to add a SSL proxy for our sndev instance
+
+A sample nginx reverse proxy config
+Edit this configuration to match your configuration, you can have more domains.
+
+```
+server {
+ listen 80;
+ listen [::]:80;
+ server_name your-domain.tld (sub.your-domain.tld, another.your-domain.tld);
+
+ # for Let's Encrypt SSL issuance
+ location /.well-known/acme-challenge/ {
+ root /var/www/letsencrypt;
+ try_files $uri =404;
+ }
+
+ # 301 to HTTPS
+ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name your-domain.tld (sub.your-domain.tld, another.your-domain.tld);
+
+ ssl_certificate /etc/letsencrypt/live/your-domain.tld/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/your-domain.tld/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ # proxy everything to sndev
+ location / {
+ proxy_pass http://sndev-instance-ip:3000;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_cache_bypass $http_upgrade;
+ }
+
+ # optional security headers
+ add_header X-Frame-Options "SAMEORIGIN";
+ add_header X-Content-Type-Options "nosniff";
+ add_header Referrer-Policy "no-referrer-when-downgrade";
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
+}
+```
+
+
+### Step 4: Start sndev
+Make sure to change your environment variables such as `.env.local` from something like `http://localhost:3000` to `https://your-domain.tld`
+
+Start sndev with `./sndev start` and then navigate to your domain, you should see **Stacker News**!
+
+If not, go back and make sure that everything is correct, you can encounter any kind of errors and **Internet can be of help**.