Skip to content

Commit bd72179

Browse files
committed
Middleware for Custom Domains; Sync auth if coming from main domain; placeholders
1 parent f72af08 commit bd72179

File tree

11 files changed

+344
-3
lines changed

11 files changed

+344
-3
lines changed

api/paidAction/territoryDomain.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// TODO: Custom domain will be a paid action

api/resolvers/customDomain.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Concept of interoperability with cache
2+
export default {
3+
Query: {
4+
customDomains: async (_, __, { models }) => {
5+
return models.customDomain.findMany()
6+
}
7+
},
8+
9+
Mutation: {
10+
upsertCustomDomain: async (_, { domain, subName, sslEnabled }, { models, boss }) => {
11+
const result = await models.customDomain.upsert({
12+
where: { domain },
13+
update: {
14+
subName,
15+
sslEnabled: sslEnabled ?? false
16+
},
17+
create: {
18+
domain,
19+
subName,
20+
sslEnabled: sslEnabled ?? false
21+
}
22+
})
23+
24+
// maybe a job?
25+
// BUT pgboss will be used
26+
await boss.send('invalidateDomainCache', {}, { priority: 'high' })
27+
28+
return result
29+
},
30+
31+
deleteCustomDomain: async (_, { domain }, { models, boss }) => {
32+
await models.customDomain.delete({
33+
where: { domain }
34+
})
35+
36+
// maybe a job? x2
37+
// BUT pgboss will be used
38+
await boss.send('invalidateDomainCache', {}, { priority: 'high' })
39+
40+
return true
41+
}
42+
}
43+
}

api/resolvers/sub.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,9 @@ export default {
310310

311311
return sub.SubSubscription?.length > 0
312312
},
313+
customDomain: async (sub, args, { models }) => {
314+
return models.customDomain.findUnique({ where: { subName: sub.name } })
315+
},
313316
createdAt: sub => sub.createdAt || sub.created_at
314317
}
315318
}

api/typeDefs/sub.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export default gql`
99
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
1010
}
1111
12+
type CustomDomain {
13+
domain: String!
14+
}
15+
1216
type Subs {
1317
cursor: String
1418
subs: [Sub!]!
@@ -55,7 +59,7 @@ export default gql`
5559
nposts(when: String, from: String, to: String): Int!
5660
ncomments(when: String, from: String, to: String): Int!
5761
meSubscription: Boolean!
58-
62+
customDomain: CustomDomain
5963
optional: SubOptional!
6064
}
6165

components/sub-select.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,24 @@ export default function SubSelect ({ prependSubs, sub, onChange, size, appendSub
7979
return
8080
}
8181

82+
// Check if we're on a custom domain
83+
84+
// TODO: main domain should be in the env
85+
// If we're on stacker.news and selecting a territory, redirect to territory subdomain
86+
const host = window.location.host
87+
console.log('host', host)
88+
if (host === 'sn.soxa.dev' && sub) {
89+
// Get the base domain (e.g., soxa.dev) from environment or config
90+
const protocol = window.location.protocol
91+
92+
// Create the territory subdomain URL
93+
const territoryUrl = `${protocol}//${sub}.soxa.dev/?source=stackernews`
94+
95+
// Redirect to the territory subdomain
96+
window.location.href = territoryUrl
97+
return
98+
}
99+
82100
let asPath
83101
// are we currently in a sub (ie not home)
84102
if (router.query.sub) {

components/territory-form.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,10 @@ export default function TerritoryForm ({ sub }) {
274274
name='nsfw'
275275
groupClassName='ms-1'
276276
/>
277+
<BootstrapForm.Label>personalized domains</BootstrapForm.Label>
278+
<div className='mb-3'>WIP {sub?.customDomain?.domain}</div>
279+
<BootstrapForm.Label>color scheme</BootstrapForm.Label>
280+
<div className='mb-3'>WIP</div>
277281
</>
278282

279283
}

fragments/subs.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const STREAK_FIELDS = gql`
1313
}
1414
`
1515

16+
// TODO: better place
1617
export const SUB_FIELDS = gql`
1718
fragment SubFields on Sub {
1819
name
@@ -34,6 +35,9 @@ export const SUB_FIELDS = gql`
3435
meMuteSub
3536
meSubscription
3637
nsfw
38+
customDomain {
39+
domain
40+
}
3741
}`
3842

3943
export const SUB_FULL_FIELDS = gql`

middleware.js

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { NextResponse, URLPattern } from 'next/server'
2-
32
const referrerPattern = new URLPattern({ pathname: ':pathname(*)/r/:referrer([\\w_]+)' })
43
const itemPattern = new URLPattern({ pathname: '/items/:id(\\d+){/:other(\\w+)}?' })
54
const profilePattern = new URLPattern({ pathname: '/:name([\\w_]+){/:type(\\w+)}?' })
@@ -12,6 +11,129 @@ const SN_REFERRER_NONCE = 'sn_referrer_nonce'
1211
// key for referred pages
1312
const SN_REFEREE_LANDING = 'sn_referee_landing'
1413

14+
const TERRITORY_PATHS = [
15+
'/',
16+
'/~',
17+
'/recent',
18+
'/random',
19+
'/top',
20+
'/items'
21+
]
22+
23+
function getDomainMapping () {
24+
// placeholder for cachedFetcher
25+
return {
26+
'forum.pizza.com': { subName: 'pizza' }
27+
// placeholder
28+
}
29+
}
30+
31+
export function customDomainMiddleware (request, referrerResp) {
32+
const host = request.headers.get('host')
33+
const referer = request.headers.get('referer')
34+
const url = request.nextUrl.clone()
35+
const pathname = url.pathname
36+
const mainDomain = process.env.NEXT_PUBLIC_URL
37+
38+
console.log('referer', referer)
39+
40+
const domainMapping = getDomainMapping() // placeholder
41+
const domainInfo = domainMapping[host.toLowerCase()]
42+
if (!domainInfo) {
43+
return NextResponse.redirect(new URL(pathname, mainDomain))
44+
}
45+
46+
// For territory paths, handle them directly on the custom domain
47+
if (TERRITORY_PATHS.includes(pathname)) {
48+
// Internally rewrite the request to the territory path without changing the URL
49+
const internalUrl = new URL(url)
50+
51+
// If we're at the root path, internally rewrite to the territory path
52+
if (pathname === '/' || pathname === '/~') {
53+
internalUrl.pathname = `/~${domainInfo.subName}`
54+
console.log('Internal rewrite to:', internalUrl.pathname)
55+
56+
// NextResponse.rewrite() keeps the URL the same for the user
57+
// but internally fetches from the rewritten path
58+
return NextResponse.rewrite(internalUrl)
59+
}
60+
61+
// For other territory paths like /recent, /top, etc.
62+
// We need to rewrite them to the territory-specific versions
63+
if (pathname === '/recent' || pathname === '/top' || pathname === '/random' || pathname === '/items') {
64+
internalUrl.pathname = `/~${domainInfo.subName}${pathname}`
65+
console.log('Internal rewrite to:', internalUrl.pathname)
66+
return NextResponse.rewrite(internalUrl)
67+
}
68+
69+
// Handle auth if needed
70+
if (!referer || referer !== mainDomain) {
71+
const authResp = customDomainAuthMiddleware(request, url)
72+
if (authResp && authResp.status !== 200) {
73+
// copy referrer cookies to auth redirect
74+
for (const [key, value] of referrerResp.cookies.getAll()) {
75+
authResp.cookies.set(key, value.value, value)
76+
}
77+
return authResp
78+
}
79+
}
80+
return referrerResp
81+
}
82+
83+
// redirect to main domain for non-territory paths
84+
// create redirect response but preserve referrer cookies
85+
const redirectResp = NextResponse.redirect(new URL(pathname, mainDomain))
86+
87+
// copy referrer cookies
88+
for (const [key, value] of referrerResp.cookies.getAll()) {
89+
redirectResp.cookies.set(key, value.value, value)
90+
}
91+
92+
return redirectResp
93+
}
94+
95+
// TODO: dirty of previous iterations, refactor
96+
// Not safe, tokens are visible in the URL
97+
export function customDomainAuthMiddleware (request, url) {
98+
const pathname = url.pathname
99+
const host = request.headers.get('host')
100+
const authDomain = process.env.NEXT_PUBLIC_URL
101+
const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
102+
const secure = process.env.NODE_ENV === 'development'
103+
104+
// check for session both in session token and in multi_auth cookie
105+
const sessionCookieName = secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
106+
const multiAuthUserId = request.cookies.get('multi_auth.user-id')?.value
107+
108+
// 1. We have a session token directly, or
109+
// 2. We have a multi_auth user ID and the corresponding multi_auth cookie
110+
const hasActiveSession = !!request.cookies.get(sessionCookieName)?.value
111+
const hasMultiAuthSession = multiAuthUserId && !!request.cookies.get(`multi_auth.${multiAuthUserId}`)?.value
112+
113+
const hasSession = hasActiveSession || hasMultiAuthSession
114+
const response = NextResponse.next()
115+
116+
if (!hasSession && isCustomDomain) {
117+
// Use the original request's host and protocol for the redirect URL
118+
// TODO: original request url points to localhost, this is a workaround atm
119+
const protocol = secure ? 'https' : 'http'
120+
const originalDomain = `${protocol}://${host}`
121+
const redirectTarget = `${originalDomain}${pathname}`
122+
123+
// Create the auth sync URL with the correct original domain
124+
const syncUrl = new URL(`${authDomain}/api/auth/sync`)
125+
syncUrl.searchParams.set('redirectUrl', redirectTarget)
126+
127+
console.log('AUTH: Redirecting to:', syncUrl.toString())
128+
console.log('AUTH: With redirect back to:', redirectTarget)
129+
const redirectResponse = NextResponse.redirect(syncUrl)
130+
return redirectResponse
131+
}
132+
133+
console.log('No redirect')
134+
return response
135+
}
136+
15137
function getContentReferrer (request, url) {
16138
if (itemPattern.test(url)) {
17139
let id = request.nextUrl.searchParams.get('commentId')
@@ -85,7 +207,20 @@ function referrerMiddleware (request) {
85207
}
86208

87209
export function middleware (request) {
88-
const resp = referrerMiddleware(request)
210+
const host = request.headers.get('host')
211+
const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
212+
213+
// First run referrer middleware to capture referrer data
214+
const referrerResp = referrerMiddleware(request)
215+
216+
// If we're on a custom domain, handle that next
217+
if (isCustomDomain) {
218+
return customDomainMiddleware(request, referrerResp)
219+
}
220+
221+
const resp = referrerResp
222+
223+
// TODO: This doesn't run for custom domains, need to support it
89224

90225
const isDev = process.env.NODE_ENV === 'development'
91226

pages/api/auth/sync.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { getServerSession } from 'next-auth/next'
2+
import { getAuthOptions } from './[...nextauth]'
3+
import { serialize } from 'cookie'
4+
import { datePivot } from '@/lib/time'
5+
6+
// TODO: not safe, tokens are visible in the URL
7+
export default async function handler (req, res) {
8+
console.log(req.query)
9+
if (req.query.token) {
10+
const session = JSON.parse(decodeURIComponent(req.query.token))
11+
return saveCookie(req, res, session)
12+
} else {
13+
const { redirectUrl } = req.query
14+
const session = await getServerSession(req, res, getAuthOptions(req))
15+
if (session) {
16+
console.log('session', session)
17+
console.log('req.cookies', req.cookies)
18+
19+
const userId = session.user.id
20+
const multiAuthCookieName = `multi_auth.${userId}`
21+
const multiAuthToken = req.cookies[multiAuthCookieName]
22+
23+
if (!multiAuthToken) {
24+
console.error('No multi_auth token found for user', userId)
25+
return res.status(400).json({ error: 'No multi_auth token found' })
26+
}
27+
28+
const transferData = {
29+
session,
30+
multiAuthToken,
31+
userId
32+
}
33+
34+
// redirect back to the custom domain with the token data
35+
const callbackUrl = new URL('/api/auth/sync', redirectUrl)
36+
callbackUrl.searchParams.set('token', encodeURIComponent(JSON.stringify(transferData)))
37+
callbackUrl.searchParams.set('redirectUrl', req.query.redirectUrl || '/')
38+
39+
return res.redirect(callbackUrl.toString())
40+
}
41+
return res.redirect(redirectUrl)
42+
}
43+
}
44+
45+
export async function saveCookie (req, res, tokenData) {
46+
const secure = process.env.NODE_ENV === 'development'
47+
if (!tokenData) {
48+
return res.status(400).json({ error: 'Missing token' })
49+
}
50+
51+
try {
52+
const expiresAt = datePivot(new Date(), { months: 1 })
53+
const cookieOptions = {
54+
path: '/',
55+
httpOnly: true,
56+
secure,
57+
sameSite: 'lax',
58+
expires: expiresAt
59+
}
60+
// extract the data from the token
61+
const { multiAuthToken, userId } = tokenData
62+
console.log('Received session and multi_auth token for user', userId)
63+
64+
// set the session cookie
65+
const sessionCookieName = secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
66+
// create cookies
67+
const sessionCookie = serialize(sessionCookieName, multiAuthToken, cookieOptions)
68+
// also set the multi_auth cookie on the custom domain
69+
const multiAuthCookie = serialize(`multi_auth.${userId}`, multiAuthToken, cookieOptions)
70+
// set the cookie pointer
71+
const pointerCookie = serialize('multi_auth.user-id', userId, cookieOptions)
72+
73+
// set the cookies in the response
74+
res.setHeader('Set-Cookie', [sessionCookie, multiAuthCookie, pointerCookie])
75+
76+
// redirect to the home page or a specified return URL
77+
const returnTo = req.query.redirectUrl || '/'
78+
return res.redirect(returnTo)
79+
} catch (error) {
80+
console.error('Error processing auth callback:', error)
81+
return res.status(500).json({ error: 'Failed to process authentication' })
82+
}
83+
}

0 commit comments

Comments
 (0)