Skip to content

Commit 1d008c0

Browse files
authored
Fix cache invalidation (#1280)
* invalidate ancestor pages when adding/removing tags * invalidate area & climb cache by tag (uuid)
1 parent cc19b6f commit 1d008c0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+683
-477
lines changed

.github/workflows/nodejs.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
- name: Setup Node.js environment
2828
uses: actions/[email protected]
2929
with:
30-
node-version: '18'
30+
node-version: '20'
3131

3232
- name: 'Checkout Project'
3333
uses: 'actions/checkout@v2'

Dockerfile

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# syntax=docker.io/docker/dockerfile:1
2+
3+
FROM node:20-alpine AS base
4+
5+
# Install dependencies only when needed
6+
FROM base AS deps
7+
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
8+
RUN apk add --no-cache libc6-compat
9+
WORKDIR /app
10+
11+
# Install dependencies based on the preferred package manager
12+
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
13+
RUN \
14+
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
15+
elif [ -f package-lock.json ]; then npm ci; \
16+
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
17+
else echo "Lockfile not found." && exit 1; \
18+
fi
19+
20+
21+
# Rebuild the source code only when needed
22+
FROM base AS builder
23+
WORKDIR /app
24+
COPY --from=deps /app/node_modules ./node_modules
25+
COPY . .
26+
27+
# Next.js collects completely anonymous telemetry data about general usage.
28+
# Learn more here: https://nextjs.org/telemetry
29+
# Uncomment the following line in case you want to disable telemetry during the build.
30+
ENV NEXT_TELEMETRY_DISABLED=1
31+
32+
RUN \
33+
if [ -f yarn.lock ]; then yarn run build; \
34+
elif [ -f package-lock.json ]; then npm run build; \
35+
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
36+
else echo "Lockfile not found." && exit 1; \
37+
fi
38+
39+
# Production image, copy all the files and run next
40+
FROM base AS runner
41+
WORKDIR /app
42+
43+
ENV NODE_ENV=production
44+
# Uncomment the following line in case you want to disable telemetry during runtime.
45+
ENV NEXT_TELEMETRY_DISABLED=1
46+
47+
RUN addgroup --system --gid 1001 nodejs
48+
RUN adduser --system --uid 1001 nextjs
49+
50+
COPY --from=builder /app/public ./public
51+
52+
# Automatically leverage output traces to reduce image size
53+
# https://nextjs.org/docs/advanced-features/output-file-tracing
54+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
55+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
56+
57+
USER nextjs
58+
59+
EXPOSE 3000
60+
61+
ENV PORT=3000
62+
63+
# server.js is created by next build from the standalone output
64+
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
65+
ENV HOSTNAME="0.0.0.0"
66+
CMD ["node", "server.js"]

next.config.js

+3
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,8 @@ module.exports = {
6868
permanent: false
6969
}
7070
]
71+
},
72+
experimental: {
73+
optimizePackageImports: ['@phosphor-icons/react']
7174
}
7275
}

package.json

+5-5
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"@algolia/autocomplete-js": "1.7.1",
88
"@algolia/autocomplete-theme-classic": "1.7.1",
99
"@apollo/client": "^3.10.1",
10-
"@apollo/experimental-nextjs-app-support": "^0.10.0",
10+
"@apollo/experimental-nextjs-app-support": "^0.11.9",
1111
"@dnd-kit/core": "^6.1.0",
1212
"@dnd-kit/sortable": "^8.0.0",
1313
"@dnd-kit/utilities": "^3.2.2",
@@ -18,7 +18,7 @@
1818
"@lexical/react": "^0.7.5",
1919
"@math.gl/web-mercator": "3.6.2",
2020
"@openbeta/sandbag": "^0.0.51",
21-
"@phosphor-icons/react": "^2.1.5",
21+
"@phosphor-icons/react": "^2.1.7",
2222
"@radix-ui/react-alert-dialog": "^1.0.0",
2323
"@radix-ui/react-dialog": "^1.0.0",
2424
"@radix-ui/react-dropdown-menu": "^2.0.1",
@@ -50,8 +50,8 @@
5050
"maplibre-gl": "^4.3.2",
5151
"nanoid": "^4.0.0",
5252
"nanoid-dictionary": "^4.3.0",
53-
"next": "^13.5.6",
54-
"next-auth": "^4.22.1",
53+
"next": "^13.5.8",
54+
"next-auth": "^4.24.11",
5555
"nprogress": "^0.2.0",
5656
"paper": "^0.12.17",
5757
"paperjs-offset": "^1.0.8",
@@ -149,6 +149,6 @@
149149
}
150150
},
151151
"engines": {
152-
"node": "18"
152+
"node": "20"
153153
}
154154
}

src/app/(default)/area/[[...slug]]/page.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { MapPinLine, Lightbulb, ArrowRight } from '@phosphor-icons/react/dist/ss
55
import Markdown from 'react-markdown'
66

77
import PhotoMontage, { UploadPhotoCTA } from '@/components/media/PhotoMontage'
8-
import { getArea } from '@/js/graphql/getArea'
8+
import { getAreaRSC } from '@/js/graphql/getAreaRSC'
99
import { StickyHeaderContainer } from '@/app/(default)/components/ui/StickyHeaderContainer'
1010
import { AreaCrumbs } from '@/components/breadcrumbs/AreaCrumbs'
1111
import { ArticleLastUpdate } from '@/components/edit/ArticleLastUpdate'
@@ -22,14 +22,14 @@ import { PageWithCatchAllUuidProps, PageSlugType } from '@/js/types/pages'
2222
/**
2323
* Page cache settings
2424
*/
25-
export const revalidate = 3600 // 1 hr
25+
export const revalidate = 2592000 // 30 days
2626

2727
/**
2828
* Area/crag page
2929
*/
3030
export default async function Page ({ params }: PageWithCatchAllUuidProps): Promise<any> {
3131
const areaUuid = parseUuidAsFirstParam({ params })
32-
const pageData = await getArea(areaUuid)
32+
const pageData = await getAreaRSC(areaUuid)
3333
if (pageData == null || pageData.area == null) {
3434
notFound()
3535
}
@@ -182,7 +182,7 @@ export function generateStaticParams (): PageSlugType[] {
182182
// Page metadata
183183
export async function generateMetadata ({ params }: PageWithCatchAllUuidProps): Promise<Metadata> {
184184
const areaUuid = parseUuidAsFirstParam({ params })
185-
const area = await getArea(areaUuid, 'cache-first')
185+
const area = await getAreaRSC(areaUuid, 'cache-first')
186186

187187
if (area == null || area.area == null) {
188188
return {}

src/app/(default)/climb/[[...slug]]/page.tsx

+3-4
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import PhotoMontage, { UploadPhotoCTA } from '@/components/media/PhotoMontage'
66
import { StickyHeaderContainer } from '../../components/ui/StickyHeaderContainer'
77
import { parseUuidAsFirstParam, climbLeftRightIndexComparator, getFriendlySlug, getClimbPageFriendlyUrl } from '@/js/utils'
88
import { PageWithCatchAllUuidProps } from '@/js/types/pages'
9-
import { getClimbById } from '@/js/graphql/api'
9+
import { getClimbByIdRSC } from '@/js/graphql/getClimbRSC'
1010
import { ClimbData } from './components/ClimbData'
1111
import { ContentBlock } from './components/ContentBlock'
1212
import { Summary } from '../../components/ui/Summary'
@@ -19,15 +19,14 @@ import { PageAlert } from './components/PageAlert'
1919
/**
2020
* Page cache settings
2121
*/
22-
export const revalidate = 300 // 5 mins
23-
export const fetchCache = 'force-no-store' // opt out of Nextjs version of 'fetch'
22+
export const revalidate = 2592000 // 30 days
2423

2524
/**
2625
* Climb page
2726
*/
2827
export default async function Page ({ params }: PageWithCatchAllUuidProps): Promise<any> {
2928
const climbId = parseUuidAsFirstParam({ params })
30-
const climb = await getClimbById(climbId)
29+
const climb = await getClimbByIdRSC(climbId)
3130
if (climb == null) {
3231
notFound()
3332
}

src/app/(default)/edit/page.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const metadata: Metadata = {
99
description: 'Share your climbing adventure photos and contribute to the climbing route catalog.'
1010
}
1111

12-
export const revalidate = 600
12+
export const revalidate = 1800
1313

1414
export default async function Page (): Promise<ReactElement> {
1515
const history = await getChangeHistoryServerSide()

src/app/(default)/login/page.tsx

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
'use client'
2-
import { useEffect } from 'react'
2+
import { useEffect, Suspense } from 'react'
33
import { useSearchParams, redirect } from 'next/navigation'
44
import { signIn, useSession } from 'next-auth/react'
55

66
export default function Page (): any {
7+
return <Suspense><Login /></Suspense>
8+
}
9+
10+
function Login (): any {
711
const { status } = useSession()
812
const searchParams = useSearchParams()
913
useEffect(() => {
@@ -15,5 +19,9 @@ export default function Page (): any {
1519
void signIn('auth0')
1620
}
1721
}, [status])
18-
return <div className='h-screen w-screen'><div className='m-6 text-sm'>Please wait...</div></div>
22+
return (
23+
<div className='h-screen w-screen'>
24+
<div className='m-6 text-sm'>Please wait...</div>
25+
</div>
26+
)
1927
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import axios from 'axios'
2+
import type { NextAuthOptions } from 'next-auth'
3+
import Auth0Provider from 'next-auth/providers/auth0'
4+
5+
import { AUTH_CONFIG_SERVER } from '../../../../Config'
6+
import { IUserMetadata, UserRole } from '../../../../js/types/User'
7+
import { initializeUserInDB } from '@/js/auth/initializeUserInDb'
8+
9+
const CustomClaimsNS = 'https://tacos.openbeta.io/'
10+
const CustomClaimUserMetadata = CustomClaimsNS + 'user_metadata'
11+
const CustomClaimRoles = CustomClaimsNS + 'roles'
12+
13+
if (AUTH_CONFIG_SERVER == null) throw new Error('AUTH_CONFIG_SERVER not defined')
14+
const { clientSecret, clientId, issuer } = AUTH_CONFIG_SERVER
15+
16+
if (process.env.NODE_ENV === 'production' && clientSecret.length === 0) {
17+
throw new Error('AUTH0_CLIENT_SECRET is required in production')
18+
}
19+
20+
export const authOptions: NextAuthOptions = {
21+
providers: [
22+
Auth0Provider({
23+
clientId,
24+
clientSecret,
25+
issuer,
26+
authorization: {
27+
params: {
28+
audience: 'https://api.openbeta.io',
29+
scope: 'offline_access access_token_authz openid email profile read:current_user create:current_user_metadata update:current_user_metadata read:stats update:area_attrs'
30+
}
31+
},
32+
client: {
33+
token_endpoint_auth_method: clientSecret.length === 0 ? 'none' : 'client_secret_basic'
34+
}
35+
})
36+
],
37+
debug: false,
38+
events: {},
39+
pages: {
40+
verifyRequest: '/auth/verify-request',
41+
signIn: '/login'
42+
},
43+
theme: {
44+
colorScheme: 'light',
45+
brandColor: '#F15E40', // Hex color code
46+
logo: 'https://openbeta.io/_next/static/media/openbeta-logo-with-text.3621d038.svg', // Absolute URL to image
47+
buttonText: '#111826' // Hex color code
48+
},
49+
callbacks: {
50+
// See https://next-auth.js.org/configuration/callbacks#jwt-callback
51+
async jwt ({ token, account, profile, user }) {
52+
/**
53+
* `account` object is only populated once when the user first logged in.
54+
*/
55+
if (account?.access_token != null) {
56+
token.accessToken = account.access_token
57+
}
58+
59+
if (account?.refresh_token != null) {
60+
token.refreshToken = account.refresh_token
61+
}
62+
63+
/**
64+
* `account.expires_at` is set in Auth0 custom API
65+
* Applications -> API -> (OB Climb API) -> Access Token Settings -> Implicit/Hybrid Access Token Lifetime
66+
*/
67+
if (account?.expires_at != null) {
68+
token.expiresAt = account.expires_at
69+
}
70+
71+
if (profile?.sub != null) {
72+
token.id = profile.sub
73+
}
74+
75+
// @ts-expect-error
76+
if (profile?.[CustomClaimUserMetadata] != null) {
77+
// null guard needed because profile object is only available once
78+
// @ts-expect-error
79+
token.userMetadata = (profile?.[CustomClaimUserMetadata] as IUserMetadata)
80+
// @ts-expect-error
81+
const customClaimRoles = profile?.[CustomClaimRoles] as string[] ?? []
82+
token.userMetadata.roles = customClaimRoles.map((r: string) => {
83+
return UserRole[r.toUpperCase() as keyof typeof UserRole]
84+
})
85+
}
86+
87+
if (token?.refreshToken == null || token?.expiresAt == null) {
88+
throw new Error('Invalid auth data')
89+
}
90+
91+
if (!(token.userMetadata?.initializedDb ?? false)) {
92+
const { userMetadata, email, picture: avatar, id: auth0UserId } = token
93+
const { nick: username, uuid: userUuid } = userMetadata
94+
const { accessToken } = token
95+
96+
const success = await initializeUserInDB({ auth0UserId, accessToken, username, userUuid, avatar, email })
97+
if (success) {
98+
token.userMetadata.initializedDb = true
99+
}
100+
}
101+
102+
if ((token.expiresAt as number) < (Date.now() / 1000)) {
103+
const { accessToken, refreshToken, expiresAt } = await refreshAccessTokenSilently(token.refreshToken as string)
104+
token.accessToken = accessToken
105+
token.refreshToken = refreshToken
106+
token.expiresAt = expiresAt
107+
}
108+
109+
return token
110+
},
111+
112+
async session ({ session, user, token }) {
113+
if (token.userMetadata == null ||
114+
token?.userMetadata?.uuid == null || token?.userMetadata?.nick == null) {
115+
// we must have user uuid and nickname for everything to work
116+
throw new Error('Missing user uuid and nickname from Auth provider')
117+
}
118+
119+
session.user.metadata = token.userMetadata
120+
session.accessToken = token.accessToken
121+
session.id = token.id
122+
return session
123+
}
124+
}
125+
}
126+
127+
const refreshAccessTokenSilently = async (refreshToken: string): Promise<any> => {
128+
const response = await axios.request<{
129+
access_token: string
130+
refresh_token: string
131+
expires_in: number
132+
}>({
133+
method: 'POST',
134+
url: `${issuer}/oauth/token`,
135+
headers: { 'content-type': 'application/json' },
136+
data: JSON.stringify({
137+
grant_type: 'refresh_token',
138+
client_id: clientId,
139+
client_secret: clientSecret,
140+
refresh_token: refreshToken
141+
})
142+
})
143+
144+
/* eslint-disable @typescript-eslint/naming-convention */
145+
const {
146+
access_token, refresh_token, expires_in
147+
} = response.data
148+
149+
if (access_token == null || refresh_token == null || expires_in == null) {
150+
throw new Error('Missing data in refresh token flow')
151+
}
152+
153+
return {
154+
accessToken: access_token,
155+
refreshToken: refresh_token,
156+
expiresAt: Math.floor((Date.now() / 1000) + expires_in)
157+
}
158+
}

0 commit comments

Comments
 (0)