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 + +
    +
  1. TODO Immediate infos on Custom Domains
  2. +
+
+ + } + 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 ( + +
+ + domain + {error && error} + {success && Domain settings updated successfully!} + {sub?.customDomain && ( +
+ {getStatusBadge(sub.customDomain.verificationState)} + + {sub.customDomain.lastVerifiedAt && + ` (Last checked: ${new Date(sub.customDomain.lastVerifiedAt).toLocaleString()})`} + + + {sub.customDomain.verificationState === 'PENDING' && ( + +
Verify your domain
+

Add the following DNS records to verify ownership of your domain:

+
+                        CNAME record:
+                        Host: @
+                        Value: stacker.news
+                      
+
+ )} +
+ )} +
+ } + name='domain' + placeholder='example.com' + /> + {/* TODO: toaster */} + save + + + ) +} 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 - -
    -
  1. TODO Immediate infos on Custom Domains
  2. -
-
- - } - 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 (
} - 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
- } - - } - /> +
+ advanced
} + 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 ( 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 (
custom domain - {sub?.customDomain && ( + {data?.customDomain && ( <>
- - {getStatusBadge(sub.customDomain.dnsState)} + + {getStatusBadge(data?.customDomain.dnsState)} - {getSSLStatusBadge(sub.customDomain.sslState)} + {getSSLStatusBadge(data?.customDomain.sslState)}
)} @@ -87,7 +115,7 @@ export default function CustomDomainForm ({ sub }) { {/* TODO: toaster */} save - {(sub.customDomain.dnsState === 'PENDING' || sub.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:

@@ -99,23 +127,18 @@ export default function CustomDomainForm ({ sub }) {
             TXT:
             Host: @
-            Value: ${sub.customDomain.verificationTxt}
+            Value: ${data?.customDomain.verificationTxt}
           
)} - {sub.customDomain.sslState === 'PENDING' && ( + {data?.customDomain.sslState === 'PENDING' && ( <>
SSL verification pending
-

We issued an SSL certificate for your domain.

+

We issued an SSL certificate for your domain. To validate it, add the following CNAME record:

             CNAME:
-            Host: ${sub.customDomain.verificationCname}
-            Value: ${sub.customDomain.verificationCnameValue}
-          
-
-            TXT:
-            Host: @
-            Value: ${sub.customDomain.verificationTxt}
+            Host: ${data?.customDomain.verificationCname}
+            Value: ${data?.customDomain.verificationCnameValue}
           
)} diff --git a/components/territory-form.js b/components/territory-form.js index 93927f450..0be3c3d46 100644 --- a/components/territory-form.js +++ b/components/territory-form.js @@ -288,23 +288,24 @@ export default function TerritoryForm ({ sub }) { />
-
- advanced
} - body={ - <> - - {sub?.customDomain?.dnsState === 'VERIFIED' && sub?.customDomain?.sslState === 'VERIFIED' && - <> - [NOT IMPLEMENTED] branding -
WIP
- [NOT IMPLEMENTED] color scheme -
WIP
- } - - } - /> - + {sub && +
+ advanced
} + 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: too many flexes */} + {/* TODO: too many flexes */}
custom domain {data?.customDomain && ( - <> +
- - {getStatusBadge(data?.customDomain.dnsState)} - + {getStatusBadge(data?.customDomain.dnsState)} {getSSLStatusBadge(data?.customDomain.sslState)}
- +
)}
} name='domain' placeholder='example.com' /> - {/* TODO: toaster */} save
+ {/* 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 ( 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 ( custom domain - {data?.customDomain && ( - + {domain && ( +
- {getStatusBadge(data?.customDomain.dnsState)} - {getSSLStatusBadge(data?.customDomain.sslState)} + {getStatusBadge(dnsState)} + {getSSLStatusBadge(sslState)}
)} @@ -113,7 +113,7 @@ export default function CustomDomainForm ({ sub }) { save {/* TODO: move this to a separate sub component */} - {(data?.customDomain?.dnsState === 'PENDING' || data?.customDomain?.dnsState === 'FAILED') && ( + {(dnsState && dnsState !== 'VERIFIED') && ( <>
Verify your domain

Add the following DNS records to verify ownership of your domain:

@@ -129,7 +129,7 @@ export default function CustomDomainForm ({ sub }) { )} - {data?.customDomain?.sslState === 'PENDING' && ( + {sslState === 'PENDING' && ( <>
SSL verification pending

We issued an SSL certificate for your domain. To validate it, add the following CNAME record:

From 7256701a59b6110f7ecda80896792e9928213cfd Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 25 Mar 2025 22:17:34 +0000 Subject: [PATCH 33/74] move auth sync out of Account Provider; remove faq todo --- components/account.js | 11 ----------- components/territory-domains.js | 14 ++++++++++++++ docs/user/faq.md | 4 ---- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/components/account.js b/components/account.js index 059dc1d61..ae319f0ac 100644 --- a/components/account.js +++ b/components/account.js @@ -8,7 +8,6 @@ import { UserListRow } from '@/components/user-list' import Link from 'next/link' import AddIcon from '@/svgs/add-fill.svg' import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_LIST, MULTI_AUTH_POINTER } from '@/lib/auth' -import { signIn } from 'next-auth/react' const AccountContext = createContext() @@ -17,16 +16,6 @@ const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8') export const AccountProvider = ({ children }) => { const [accounts, setAccounts] = useState([]) const [selected, setSelected] = useState(null) - 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_LIST]: listCookie } = cookie.parse(document.cookie) diff --git a/components/territory-domains.js b/components/territory-domains.js index bb8c25f59..23ce0a9b9 100644 --- a/components/territory-domains.js +++ b/components/territory-domains.js @@ -7,6 +7,8 @@ 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, createContext, useContext } from 'react' +import { useRouter } from 'next/router' +import { signIn } from 'next-auth/react' // Domain context for custom domains const DomainContext = createContext({ @@ -14,6 +16,18 @@ const DomainContext = createContext({ }) export const DomainProvider = ({ isCustomDomain, children }) => { + const router = useRouter() + + // TODO: alternative to this, for test only + // auth sync + 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 }) + } + }, [router.query.type]) + return ( {children} diff --git a/docs/user/faq.md b/docs/user/faq.md index aaf228421..1636199db 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -286,10 +286,6 @@ 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 From 5b314aa4090707ab4cb92d1f51de311812609322 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 26 Mar 2025 19:33:54 +0000 Subject: [PATCH 34/74] update isCustomDomain also on client side navigation --- components/territory-domains.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/components/territory-domains.js b/components/territory-domains.js index 23ce0a9b9..4c641437f 100644 --- a/components/territory-domains.js +++ b/components/territory-domains.js @@ -6,7 +6,7 @@ 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, createContext, useContext } from 'react' +import { useEffect, createContext, useContext, useState } from 'react' import { useRouter } from 'next/router' import { signIn } from 'next-auth/react' @@ -15,8 +15,18 @@ const DomainContext = createContext({ isCustomDomain: false }) -export const DomainProvider = ({ isCustomDomain, children }) => { +export const DomainProvider = ({ isCustomDomain: initialIsCustomDomain, children }) => { const router = useRouter() + const [isCustomDomain, setIsCustomDomain] = useState(initialIsCustomDomain) + + 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) + } + }, [router.asPath]) // TODO: alternative to this, for test only // auth sync From cc334f4c69a54b22b953d20bdae7d70eb87f16c2 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 26 Mar 2025 23:58:35 +0000 Subject: [PATCH 35/74] allow only www or subdomains --- components/territory-domains.js | 30 +++++++++++++++--------------- lib/validate.js | 4 ++-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/components/territory-domains.js b/components/territory-domains.js index 4c641437f..dff9c4de4 100644 --- a/components/territory-domains.js +++ b/components/territory-domains.js @@ -132,35 +132,35 @@ export default function CustomDomainForm ({ sub }) { } name='domain' - placeholder='example.com' + placeholder='www.example.com' /> save {/* TODO: move this to a separate sub component */} {(dnsState && dnsState !== 'VERIFIED') && ( <> -
Verify your domain
+
Step 1: Verify your domain

Add the following DNS records to verify ownership of your domain:

-
-            CNAME:
-            Host: @
-            Value: stacker.news
-          
-
-            TXT:
-            Host: @
-            Value: ${data?.customDomain.verificationTxt}
-          
+
CNAME
+

+ Host:

{domain || 'www'}
+ Value:
stacker.news
+

+
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 ( -
- {/* TODO: too many flexes */} -
- - custom domain - {domain && ( - -
- {getStatusBadge(dnsState)} - {getSSLStatusBadge(sslState)} -
-
- )} -
- } - name='domain' - placeholder='www.example.com' - /> - save - - {/* TODO: move this to a separate sub component */} - {(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:
{data?.customDomain.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:

{data?.customDomain.verificationCname || 'waiting for SSL certificate'}
- Value:
{data?.customDomain.verificationCnameValue || 'waiting for SSL certificate'}
-

- - )} -
+ <> +
+ {/* TODO: too many flexes */} +
+ } + name='domain' + placeholder='www.example.com' + /> + save +
+
+ + ) } 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 &&
advanced
} @@ -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 ( +
+ + + Save Branding + + ) +} 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 }) {
advanced
} - 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 (
diff --git a/components/domains/territory-domains.js b/components/territory-domains.js similarity index 68% rename from components/domains/territory-domains.js rename to components/territory-domains.js index 63b99a716..75dd22075 100644 --- a/components/domains/territory-domains.js +++ b/components/territory-domains.js @@ -1,20 +1,21 @@ 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' 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' +import BrandingForm from '@/components/territory-branding-form' +import Head from 'next/head' // Domain context for custom domains const DomainContext = createContext({ customDomain: { - isCustomDomain: false, + domain: null, subName: null } }) @@ -25,17 +26,15 @@ export const DomainProvider = ({ customDomain: initialCustomDomain, children }) useEffect(() => { // client side navigation - if (typeof window !== 'undefined') { + if (typeof window !== 'undefined' && !initialCustomDomain) { const hostname = window.location.hostname setCustomDomain({ - isCustomDomain: hostname !== new URL(process.env.NEXT_PUBLIC_URL).hostname, - subName: router.query.sub || initialCustomDomain?.subName + domain: hostname, + subName: router.query.sub || null }) } }, [router.asPath]) - console.log('customDomain', customDomain) - // TODO: alternative to this, for test only // auth sync useEffect(() => { @@ -46,8 +45,19 @@ export const DomainProvider = ({ customDomain: initialCustomDomain, children }) } }, [router.query.type]) + const branding = customDomain?.branding || null + return ( + {branding && ( + <> + + {branding?.title && {branding?.title}} + {branding?.favicon && } + + {branding?.primaryColor && } + + )} {children} ) @@ -194,3 +204,48 @@ 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 styleElement = document.createElement('style') + styleElement.textContent = ` + .btn-primary, .btn-secondary, + .bg-primary, .bg-secondary, + .text-primary, .text-secondary, + .border-primary, .border-secondary, + svg, + [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)) + 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') + } + } + }, [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/territory-form.js b/components/territory-form.js index 419bcd813..4b6ca4de1 100644 --- a/components/territory-form.js +++ b/components/territory-form.js @@ -14,14 +14,16 @@ 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 './domains/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 { customDomain: { isCustomDomain } } = useDomain() + const { customDomain: { domain } } = useDomain() + + const isCustomDomain = !!domain const [upsertSub] = usePaidMutation(UPSERT_SUB) const [unarchiveTerritory] = usePaidMutation(UNARCHIVE_TERRITORY) diff --git a/components/territory-header.js b/components/territory-header.js index 974ca4eda..0ed8b53df 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 './domains/territory-domains' +import { useDomain } from './territory-domains' export function TerritoryDetails ({ sub, children }) { return ( @@ -84,7 +84,9 @@ export function TerritoryInfo ({ sub }) { export default function TerritoryHeader ({ sub }) { const { me } = useMe() const toaster = useToast() - const { customDomain: { isCustomDomain } } = useDomain() + const { customDomain: { domain } } = useDomain() + + const isCustomDomain = !!domain const [toggleMuteSub] = useMutation( gql` diff --git a/pages/_app.js b/pages/_app.js index 4717bf8bc..659b1fa5f 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -22,8 +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/domains/territory-domains' -import { BrandingProvider } from '@/components/domains/branding' +import { DomainProvider } from '@/components/territory-domains' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) @@ -100,7 +99,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, branding, ...otherProps } = props + const { apollo, ssrData, me, price, blockHeight, chainFee, customDomain, ...otherProps } = props useEffect(() => { writeQuery(client, apollo, ssrData) }, [client, apollo, ssrData]) @@ -112,40 +111,38 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - - - - - - - - - - - {!router?.query?.disablePrompt && } - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + {!router?.query?.disablePrompt && } + + + + + + + + + + + + + + + From 2a147803130b0a701a8b6f57cab8e7a863927445 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 1 Apr 2025 16:24:36 -0500 Subject: [PATCH 47/74] safer auth sync, hard check domain's validity --- pages/api/auth/sync.js | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/pages/api/auth/sync.js b/pages/api/auth/sync.js index f1c632880..358aef174 100644 --- a/pages/api/auth/sync.js +++ b/pages/api/auth/sync.js @@ -1,12 +1,24 @@ import { getServerSession } from 'next-auth/next' import { getAuthOptions, generateRandomString } from './[...nextauth]' -import prisma from '@/api/models' +import models 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' }) + return res.status(400).json({ status: 'ERROR', reason: 'missing redirectUrl parameter' }) + } + + // redirectUrl parse + let customDomain + try { + customDomain = new URL(redirectUrl) + const domain = await models.customDomain.findUnique({ where: { domain: customDomain.host, sslState: 'verified' } }) + if (!domain) { + return res.status(400).json({ status: 'ERROR', reason: 'custom domain not found' }) + } + } catch (error) { + return res.status(400).json({ status: 'ERROR', reason: 'invalid redirectUrl parameter' }) } const mainDomain = process.env.NEXT_PUBLIC_MAIN_DOMAIN @@ -19,9 +31,9 @@ export default async function handler (req, res) { } try { - const token = generateRandomString() + const token = generateRandomString(32) // create a sync token - await prisma.verificationToken.create({ + await models.verificationToken.create({ data: { identifier: `sync:${session.user.id}`, token, @@ -29,7 +41,13 @@ export default async function handler (req, res) { } }) - // Account Provider will handle this sync request + if (process.env.NODE_ENV === 'production') { + res.setHeader('Set-Cookie', [ + 'SameSite=Lax; Secure; HttpOnly' + ]) + } + + // domain provider will handle this sync request const customDomainCallback = new URL('/?type=sync', redirectUrl) customDomainCallback.searchParams.set('token', token) customDomainCallback.searchParams.set('callbackUrl', redirectUrl) @@ -37,6 +55,8 @@ export default async function handler (req, res) { customDomainCallback.searchParams.set('multiAuth', multiAuth) } + // TODO: security headers? + // redirect to the custom domain callback return res.redirect(customDomainCallback.toString()) } catch (error) { From bc117ec64f29f6c1a255fe12c54a160fb31fcb55 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 1 Apr 2025 17:14:05 -0500 Subject: [PATCH 48/74] refactor isCustomDomain, fix typo on auth sync --- components/nav/common.js | 6 ++---- components/nav/desktop/second-bar.js | 9 ++++----- components/nav/index.js | 8 ++++---- components/nav/mobile/top-bar.js | 5 ++--- components/seo.js | 10 ++++++---- components/territory-domains.js | 19 +++++++------------ components/territory-form.js | 8 +++----- components/territory-header.js | 6 ++---- pages/api/auth/sync.js | 2 +- 9 files changed, 31 insertions(+), 42 deletions(-) diff --git a/components/nav/common.js b/components/nav/common.js index a765db56f..fe42dd4af 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -292,9 +292,7 @@ function LogoutObstacle ({ onClose }) { const { registration: swRegistration, togglePushSubscription } = useServiceWorker() const { removeLocalWallets } = useWallets() const router = useRouter() - const { customDomain: { domain } } = useDomain() - - const isCustomDomain = !!domain + const { customDomain } = useDomain() return (
@@ -326,7 +324,7 @@ function LogoutObstacle ({ onClose }) { removeLocalWallets() - await signOut({ callbackUrl: '/', redirect: !isCustomDomain }) + await signOut({ callbackUrl: '/', redirect: !customDomain }) }} > logout diff --git a/components/nav/desktop/second-bar.js b/components/nav/desktop/second-bar.js index 8e3c49f7d..c6f30a368 100644 --- a/components/nav/desktop/second-bar.js +++ b/components/nav/desktop/second-bar.js @@ -3,8 +3,7 @@ import { NavSelect, PostItem, Sorts, hasNavSelect } from '../common' import styles from '../../header.module.css' export default function SecondBar (props) { - const { prefix, topNavKey, domain, sub } = props - const isCustomDomain = !!domain + const { prefix, topNavKey, customDomain, sub } = props if (!hasNavSelect(props)) return null return ( @@ -12,9 +11,9 @@ export default function SecondBar (props) { className={styles.navbarNav} activeKey={topNavKey} > - {!isCustomDomain && } -
- + {!customDomain && } +
+
diff --git a/components/nav/index.js b/components/nav/index.js index 83e9c7e42..d58138b21 100644 --- a/components/nav/index.js +++ b/components/nav/index.js @@ -7,20 +7,20 @@ import { useDomain } from '@/components/territory-domains' export default function Navigation ({ sub }) { const router = useRouter() - const { customDomain: { domain } } = useDomain() + const { customDomain } = useDomain() const path = router.asPath.split('?')[0] const props = { prefix: sub ? `/~${sub}` : '', path, pathname: router.pathname, - topNavKey: domain + topNavKey: customDomain ? path.split('/')[1] ?? '' : path.split('/')[sub ? 2 : 1] ?? '', - dropNavKey: domain + dropNavKey: customDomain ? path.split('/').slice(1).join('/') : path.split('/').slice(sub ? 2 : 1).join('/'), - domain, + customDomain, sub } diff --git a/components/nav/mobile/top-bar.js b/components/nav/mobile/top-bar.js index 1f653b1d0..e496858b9 100644 --- a/components/nav/mobile/top-bar.js +++ b/components/nav/mobile/top-bar.js @@ -3,10 +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, domain }) { +export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey, customDomain }) { const { me } = useMe() - const isCustomDomain = !!domain - if (hasNavSelect({ path, pathname }) && isCustomDomain) return null + if (hasNavSelect({ path, pathname }) && customDomain) return null return (
- {sub && !isCustomDomain && + {sub && !customDomain &&
advanced
} body={} />
} - {sub && isCustomDomain && domain settings on stacker.news} + {sub && customDomain && domain settings on stacker.news} ) } diff --git a/components/territory-header.js b/components/territory-header.js index 0ed8b53df..4fcc3d5ef 100644 --- a/components/territory-header.js +++ b/components/territory-header.js @@ -84,9 +84,7 @@ export function TerritoryInfo ({ sub }) { export default function TerritoryHeader ({ sub }) { const { me } = useMe() const toaster = useToast() - const { customDomain: { domain } } = useDomain() - - const isCustomDomain = !!domain + const { customDomain } = useDomain() const [toggleMuteSub] = useMutation( gql` @@ -105,7 +103,7 @@ export default function TerritoryHeader ({ sub }) { ) const isMine = Number(sub.userId) === Number(me?.id) - if (isCustomDomain && !isMine) return null + if (customDomain && !isMine) return null return ( <> diff --git a/pages/api/auth/sync.js b/pages/api/auth/sync.js index 358aef174..4743b7f7e 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, sslState: 'VERIFIED' } }) if (!domain) { return res.status(400).json({ status: 'ERROR', reason: 'custom domain not found' }) } From 4147ed7717ad43c534c006d6dcd712289d4d28f2 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Tue, 1 Apr 2025 18:29:43 -0500 Subject: [PATCH 49/74] json colors, add more colors --- api/resolvers/branding.js | 8 ++-- api/typeDefs/branding.js | 6 +-- components/territory-branding-form.js | 43 +++++++++++++---- components/territory-domains.js | 47 +++++++++++-------- fragments/brandings.js | 6 +-- fragments/subs.js | 3 +- .../migration.sql | 3 +- prisma/schema.prisma | 3 +- styles/globals.scss | 45 +++++++++++++++--- 9 files changed, 112 insertions(+), 52 deletions(-) diff --git a/api/resolvers/branding.js b/api/resolvers/branding.js index c10454f2e..c6d66e484 100644 --- a/api/resolvers/branding.js +++ b/api/resolvers/branding.js @@ -21,7 +21,7 @@ export default { throw new GqlInputError('you do not own this sub') } - const { title, primaryColor, secondaryColor, logoId, faviconId } = branding + const { title, colors, logoId, faviconId } = branding if (logoId) { const logo = await models.upload.findUnique({ where: { id: logoId } }) @@ -43,15 +43,13 @@ export default { where: { subName }, update: { title: title || subName, - primaryColor: primaryColor || '#FADA5E', - secondaryColor: secondaryColor || '#F6911D', + colors, ...(logoId && { logo: { connect: { id: logoId } } }), ...(faviconId && { favicon: { connect: { id: faviconId } } }) }, create: { title: title || subName, - primaryColor: primaryColor || '#FADA5E', - secondaryColor: secondaryColor || '#F6911D', + colors, ...(logoId && { logo: { connect: { id: logoId } } }), ...(faviconId && { favicon: { connect: { id: faviconId } } }), sub: { connect: { name: subName } } diff --git a/api/typeDefs/branding.js b/api/typeDefs/branding.js index 98346c661..b6ef30ed6 100644 --- a/api/typeDefs/branding.js +++ b/api/typeDefs/branding.js @@ -11,8 +11,7 @@ export default gql` type CustomBranding { title: String - primaryColor: String - secondaryColor: String + colors: JSONObject logoId: Int faviconId: Int subName: String @@ -20,8 +19,7 @@ export default gql` input CustomBrandingInput { title: String - primaryColor: String - secondaryColor: String + colors: JSONObject logoId: Int faviconId: Int subName: String diff --git a/components/territory-branding-form.js b/components/territory-branding-form.js index 411d50147..8a4b991f8 100644 --- a/components/territory-branding-form.js +++ b/components/territory-branding-form.js @@ -3,6 +3,7 @@ import { useMutation } from '@apollo/client' import { useToast } from './toast' import { brandingSchema } from '@/lib/validate' import { SET_CUSTOM_BRANDING } from '@/fragments/brandings' +import AccordianItem from './accordian-item' export default function BrandingForm ({ sub }) { const [setCustomBranding] = useMutation(SET_CUSTOM_BRANDING) @@ -14,7 +15,18 @@ export default function BrandingForm ({ sub }) { await setCustomBranding({ variables: { subName: sub.name, - branding: values + branding: { + title: values.title, + colors: { + primary: values.primary, + secondary: values.secondary, + info: values.info, + success: values.success, + danger: values.danger + }, + logoId: values.logoId, + faviconId: values.faviconId + } } }) toaster.success('Branding updated successfully') @@ -26,14 +38,16 @@ export default function BrandingForm ({ sub }) { const initialValues = { title: sub?.customBranding?.title || sub?.subName, - primaryColor: sub?.customBranding?.primaryColor || '#FADA5E', - secondaryColor: sub?.customBranding?.secondaryColor || '#F6911D', + 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', logoId: sub?.customBranding?.logoId || null, faviconId: sub?.customBranding?.faviconId || null } // TODO: add logo and favicon upload - // TODO: color picker is too big return (
- - - {/* TODO: add logo and favicon upload */} - save branding +
+ + +
+ more colors
} + 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 + ? ( + Logo + ) + : ( + + )} ) @@ -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={ +
+
+ +
+ {sub?.customBranding?.logoId && ( + Logo + )} + +
+
+
+ +
+ {sub?.customBranding?.faviconId && ( + Favicon + )} + +
+
+
+ } + />
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 (
- {sub?.customBranding?.logoId && ( - Logo - )} + {sub?.customBranding?.logoId + ? ( + Logo + ) + : ( +
+ +
+ )}
- {sub?.customBranding?.faviconId && ( - Favicon - )} + {sub?.customBranding?.faviconId + ? ( + Favicon + ) + : ( +
+ Favicon +
+ )}
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 ( + {name} 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={
- {sub?.customBranding?.logoId - ? ( - Logo - ) - : ( -
- -
- )}
- {sub?.customBranding?.faviconId - ? ( - Favicon - ) - : ( -
- Favicon -
- )}
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 }) { {name} ) - 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 (
)}
@@ -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**.