diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx index 31995284..fc327b9f 100644 --- a/web/components/charity/charity-card.tsx +++ b/web/components/charity/charity-card.tsx @@ -6,10 +6,9 @@ import { Charity } from 'common/charity' import { useCharityTxns } from 'web/hooks/use-charity-txns' import { manaToUSD } from '../../../common/util/format' import { Row } from '../layout/row' -import { Col } from '../layout/col' export function CharityCard(props: { charity: Charity; match?: number }) { - const { charity, match } = props + const { charity } = props const { slug, photo, preview, id, tags } = charity const txns = useCharityTxns(id) @@ -36,18 +35,18 @@ export function CharityCard(props: { charity: Charity; match?: number }) { {raised > 0 && ( <> - + {formatUsd(raised)} - raised - - {match && ( + raised + + {/* {match && ( +{formatUsd(match)} match - )} + )} */} )} diff --git a/web/lib/firebase/auth.ts b/web/lib/firebase/auth.ts new file mode 100644 index 00000000..d1c440ec --- /dev/null +++ b/web/lib/firebase/auth.ts @@ -0,0 +1,54 @@ +import { PROJECT_ID } from 'common/envs/constants' +import { setCookie, getCookies } from '../util/cookie' +import { IncomingMessage, ServerResponse } from 'http' + +const TOKEN_KINDS = ['refresh', 'id'] as const +type TokenKind = typeof TOKEN_KINDS[number] + +const getAuthCookieName = (kind: TokenKind) => { + const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replaceAll('-', '_') + return `FIREBASE_TOKEN_${suffix}` +} + +const ID_COOKIE_NAME = getAuthCookieName('id') +const REFRESH_COOKIE_NAME = getAuthCookieName('refresh') + +export const getAuthCookies = (request?: IncomingMessage) => { + const data = request != null ? request.headers.cookie ?? '' : document.cookie + const cookies = getCookies(data) + return { + idToken: cookies[ID_COOKIE_NAME] as string | undefined, + refreshToken: cookies[REFRESH_COOKIE_NAME] as string | undefined, + } +} + +export const setAuthCookies = ( + idToken?: string, + refreshToken?: string, + response?: ServerResponse +) => { + // these tokens last an hour + const idMaxAge = idToken != null ? 60 * 60 : 0 + const idCookie = setCookie(ID_COOKIE_NAME, idToken ?? '', [ + ['path', '/'], + ['max-age', idMaxAge.toString()], + ['samesite', 'lax'], + ['secure'], + ]) + // these tokens don't expire + const refreshMaxAge = refreshToken != null ? 60 * 60 * 24 * 365 * 10 : 0 + const refreshCookie = setCookie(REFRESH_COOKIE_NAME, refreshToken ?? '', [ + ['path', '/'], + ['max-age', refreshMaxAge.toString()], + ['samesite', 'lax'], + ['secure'], + ]) + if (response != null) { + response.setHeader('Set-Cookie', [idCookie, refreshCookie]) + } else { + document.cookie = idCookie + document.cookie = refreshCookie + } +} + +export const deleteAuthCookies = () => setAuthCookies() diff --git a/web/lib/firebase/server-auth.ts b/web/lib/firebase/server-auth.ts new file mode 100644 index 00000000..5f828683 --- /dev/null +++ b/web/lib/firebase/server-auth.ts @@ -0,0 +1,86 @@ +import * as admin from 'firebase-admin' +import fetch from 'node-fetch' +import { IncomingMessage, ServerResponse } from 'http' +import { FIREBASE_CONFIG, PROJECT_ID } from 'common/envs/constants' +import { getAuthCookies, setAuthCookies } from './auth' +import { GetServerSideProps, GetServerSidePropsContext } from 'next' + +const ensureApp = async () => { + // Note: firebase-admin can only be imported from a server context, + // because it relies on Node standard library dependencies. + if (admin.apps.length === 0) { + // never initialize twice + return admin.initializeApp({ projectId: PROJECT_ID }) + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return admin.apps[0]! +} + +const requestFirebaseIdToken = async (refreshToken: string) => { + // See https://firebase.google.com/docs/reference/rest/auth/#section-refresh-token + const refreshUrl = new URL('https://securetoken.googleapis.com/v1/token') + refreshUrl.searchParams.append('key', FIREBASE_CONFIG.apiKey) + const result = await fetch(refreshUrl.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + }) + if (!result.ok) { + throw new Error(`Could not refresh ID token: ${await result.text()}`) + } + return (await result.json()) as any +} + +type RequestContext = { + req: IncomingMessage + res: ServerResponse +} + +export const getServerAuthenticatedUid = async (ctx: RequestContext) => { + const app = await ensureApp() + const auth = app.auth() + const { idToken, refreshToken } = getAuthCookies(ctx.req) + + // If we have a valid ID token, verify the user immediately with no network trips. + // If the ID token doesn't verify, we'll have to refresh it to see who they are. + // If they don't have any tokens, then we have no idea who they are. + if (idToken != null) { + try { + return (await auth.verifyIdToken(idToken))?.uid + } catch (e) { + if (refreshToken != null) { + const resp = await requestFirebaseIdToken(refreshToken) + setAuthCookies(resp.id_token, resp.refresh_token, ctx.res) + return (await auth.verifyIdToken(resp.id_token))?.uid + } + } + } + return undefined +} + +export const redirectIfLoggedIn = (dest: string, fn?: GetServerSideProps) => { + return async (ctx: GetServerSidePropsContext) => { + const uid = await getServerAuthenticatedUid(ctx) + if (uid == null) { + return fn != null ? await fn(ctx) : { props: {} } + } else { + return { redirect: { destination: dest, permanent: false } } + } + } +} + +export const redirectIfLoggedOut = (dest: string, fn?: GetServerSideProps) => { + return async (ctx: GetServerSidePropsContext) => { + const uid = await getServerAuthenticatedUid(ctx) + if (uid == null) { + return { redirect: { destination: dest, permanent: false } } + } else { + return fn != null ? await fn(ctx) : { props: {} } + } + } +} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index f3242a7e..77c5c48d 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -16,7 +16,7 @@ import { import { getAuth } from 'firebase/auth' import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage' import { - onAuthStateChanged, + onIdTokenChanged, GoogleAuthProvider, signInWithPopup, } from 'firebase/auth' @@ -43,6 +43,7 @@ import utc from 'dayjs/plugin/utc' dayjs.extend(utc) import { track } from '@amplitude/analytics-browser' +import { deleteAuthCookies, setAuthCookies } from './auth' export const users = coll('users') export const privateUsers = coll('private-users') @@ -188,10 +189,9 @@ export function listenForLogin(onUser: (user: User | null) => void) { const cachedUser = local?.getItem(CACHED_USER_KEY) onUser(cachedUser && JSON.parse(cachedUser)) - return onAuthStateChanged(auth, async (fbUser) => { + return onIdTokenChanged(auth, async (fbUser) => { if (fbUser) { let user: User | null = await getUser(fbUser.uid) - if (!user) { if (createUserPromise == null) { const local = safeLocalStorage() @@ -204,17 +204,19 @@ export function listenForLogin(onUser: (user: User | null) => void) { } user = await createUserPromise } - onUser(user) // Persist to local storage, to reduce login blink next time. // Note: Cap on localStorage size is ~5mb local?.setItem(CACHED_USER_KEY, JSON.stringify(user)) setCachedReferralInfoForUser(user) + setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken) } else { // User logged out; reset to null onUser(null) + createUserPromise = undefined local?.removeItem(CACHED_USER_KEY) + deleteAuthCookies() } }) } diff --git a/web/lib/util/cookie.ts b/web/lib/util/cookie.ts new file mode 100644 index 00000000..14999fd4 --- /dev/null +++ b/web/lib/util/cookie.ts @@ -0,0 +1,33 @@ +type CookieOptions = string[][] + +const encodeCookie = (name: string, val: string) => { + return `${name}=${encodeURIComponent(val)}` +} + +const decodeCookie = (cookie: string) => { + const parts = cookie.trim().split('=') + if (parts.length < 2) { + throw new Error(`Invalid cookie contents: ${cookie}`) + } + const rest = parts.slice(1).join('') // there may be more = in the value + return [parts[0], decodeURIComponent(rest)] as const +} + +export const setCookie = (name: string, val: string, opts?: CookieOptions) => { + const parts = [encodeCookie(name, val)] + if (opts != null) { + parts.push(...opts.map((opt) => opt.join('='))) + } + return parts.join('; ') +} + +// Note that this intentionally ignores the case where multiple cookies have +// the same name but different paths. Hopefully we never need to think about it. +export const getCookies = (cookies: string) => { + const data = cookies.trim() + if (!data) { + return {} + } else { + return Object.fromEntries(data.split(';').map(decodeCookie)) + } +} diff --git a/web/pages/add-funds.tsx b/web/pages/add-funds.tsx index f680d47b..ed25a21a 100644 --- a/web/pages/add-funds.tsx +++ b/web/pages/add-funds.tsx @@ -8,6 +8,9 @@ import { checkoutURL } from 'web/lib/service/stripe' import { Page } from 'web/components/page' import { useTracking } from 'web/hooks/use-tracking' import { trackCallback } from 'web/lib/service/analytics' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' + +export const getServerSideProps = redirectIfLoggedOut('/') export default function AddFundsPage() { const user = useUser() diff --git a/web/pages/admin.tsx b/web/pages/admin.tsx index e709e875..81f23ba9 100644 --- a/web/pages/admin.tsx +++ b/web/pages/admin.tsx @@ -9,6 +9,9 @@ import { useContracts } from 'web/hooks/use-contracts' import { mapKeys } from 'lodash' import { useAdmin } from 'web/hooks/use-admin' import { contractPath } from 'web/lib/firebase/contracts' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' + +export const getServerSideProps = redirectIfLoggedOut('/') function avatarHtml(avatarUrl: string) { return ` txn.fromId).length + const mostRecentDonor = await getUser(txns[txns.length - 1].fromId) return { props: { @@ -42,6 +45,7 @@ export async function getStaticProps() { matches, txns, numDonors, + mostRecentDonor, }, revalidate: 60, } @@ -50,22 +54,28 @@ export async function getStaticProps() { type Stat = { name: string stat: string + url?: string } function DonatedStats(props: { stats: Stat[] }) { const { stats } = props return (
- {stats.map((item) => ( + {stats.map((stat) => (
- {item.name} + {stat.name}
+
- {item.stat} + {stat.url ? ( + {stat.stat} + ) : ( + {stat.stat} + )}
))} @@ -79,8 +89,9 @@ export default function Charity(props: { matches: { [charityId: string]: number } txns: Txn[] numDonors: number + mostRecentDonor: User }) { - const { totalRaised, charities, matches, numDonors } = props + const { totalRaised, charities, matches, numDonors, mostRecentDonor } = props const [query, setQuery] = useState('') const debouncedQuery = debounce(setQuery, 50) @@ -106,7 +117,7 @@ export default function Charity(props: { - <span className="text-gray-600"> + {/* <span className="text-gray-600"> Through July 15, up to $25k of donations will be matched via{' '} <SiteLink href="https://wtfisqf.com/" className="font-bold"> quadratic funding @@ -116,7 +127,7 @@ export default function Charity(props: { the FTX Future Fund </SiteLink> ! - </span> + </span> */} <DonatedStats stats={[ { @@ -128,8 +139,9 @@ export default function Charity(props: { stat: `${numDonors}`, }, { - name: 'Matched via quadratic funding', - stat: manaToUSD(sum(Object.values(matches))), + name: 'Most recent donor', + stat: mostRecentDonor.name ?? 'Nobody', + url: `/${mostRecentDonor.username}`, }, ]} /> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index a3801223..45eb120f 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -28,6 +28,9 @@ import { GroupSelector } from 'web/components/groups/group-selector' import { User } from 'common/user' import { TextEditor, useTextEditor } from 'web/components/editor' import { Checkbox } from 'web/components/checkbox' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' + +export const getServerSideProps = redirectIfLoggedOut('/') type NewQuestionParams = { groupId?: string @@ -55,10 +58,6 @@ export default function Create() { }, [params.q]) const creator = useUser() - useEffect(() => { - if (creator === null) router.push('/') - }, [creator, router]) - if (!router.isReady || !creator) return <div /> return ( @@ -93,7 +92,7 @@ export default function Create() { // Allow user to create a new contract export function NewContract(props: { - creator: User + creator?: User | null question: string params?: NewQuestionParams }) { diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 98d5036e..6aa99a07 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -1,10 +1,9 @@ import React, { useEffect, useState } from 'react' -import Router, { useRouter } from 'next/router' +import { useRouter } from 'next/router' import { PlusSmIcon } from '@heroicons/react/solid' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' -import { useUser } from 'web/hooks/use-user' import { getSavedSort } from 'web/hooks/use-sort-and-query-params' import { ContractSearch } from 'web/components/contract-search' import { Contract } from 'common/contract' @@ -12,19 +11,16 @@ import { ContractPageContent } from './[username]/[contractSlug]' import { getContractFromSlug } from 'web/lib/firebase/contracts' import { useTracking } from 'web/hooks/use-tracking' import { track } from 'web/lib/service/analytics' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' + +export const getServerSideProps = redirectIfLoggedOut('/') const Home = () => { - const user = useUser() const [contract, setContract] = useContractPage() const router = useRouter() useTracking('view home') - if (user === null) { - Router.replace('/') - return <></> - } - return ( <> <Page suspend={!!contract}> diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 904fc014..44683a4f 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -1,14 +1,13 @@ import React from 'react' -import Router from 'next/router' import { Contract, getContractsBySlugs } from 'web/lib/firebase/contracts' import { Page } from 'web/components/page' import { LandingPagePanel } from 'web/components/landing-page-panel' import { Col } from 'web/components/layout/col' -import { useUser } from 'web/hooks/use-user' import { ManifoldLogo } from 'web/components/nav/manifold-logo' +import { redirectIfLoggedIn } from 'web/lib/firebase/server-auth' -export async function getStaticProps() { +export const getServerSideProps = redirectIfLoggedIn('/home', async (_) => { // These hardcoded markets will be shown in the frontpage for signed-out users: const hotContracts = await getContractsBySlugs([ 'will-max-go-to-prom-with-a-girl', @@ -22,23 +21,11 @@ export async function getStaticProps() { 'will-congress-hold-any-hearings-abo-e21f987033b3', 'will-at-least-10-world-cities-have', ]) + return { props: { hotContracts } } +}) - return { - props: { hotContracts }, - revalidate: 60, // regenerate after a minute - } -} - -const Home = (props: { hotContracts: Contract[] }) => { +export default function Home(props: { hotContracts: Contract[] }) { const { hotContracts } = props - - const user = useUser() - - if (user) { - Router.replace('/home') - return <></> - } - return ( <Page> <div className="px-4 pt-2 md:mt-0 lg:hidden"> @@ -58,5 +45,3 @@ const Home = (props: { hotContracts: Contract[] }) => { </Page> ) } - -export default Home diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 76c62978..490f1878 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -18,11 +18,14 @@ import { Avatar } from 'web/components/avatar' import { RelativeTimestamp } from 'web/components/relative-timestamp' import { UserLink } from 'web/components/user-page' import { CreateLinksButton } from 'web/components/manalinks/create-links-button' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' dayjs.extend(customParseFormat) +export const getServerSideProps = redirectIfLoggedOut('/') + export function getManalinkUrl(slug: string) { return `${location.protocol}//${location.host}/link/${slug}` } diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index b80698ae..541f5de9 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react' import { RefreshIcon } from '@heroicons/react/outline' -import Router from 'next/router' import { AddFundsButton } from 'web/components/add-funds-button' import { Page } from 'web/components/page' @@ -18,6 +17,9 @@ import { updateUser, updatePrivateUser } from 'web/lib/firebase/users' import { defaultBannerUrl } from 'web/components/user-page' import { SiteLink } from 'web/components/site-link' import Textarea from 'react-expanding-textarea' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' + +export const getServerSideProps = redirectIfLoggedOut('/') function EditUserField(props: { user: User @@ -134,8 +136,7 @@ export default function ProfilePage() { }) } - if (user === null) { - Router.replace('/') + if (user == null) { return <></> } diff --git a/web/pages/trades.tsx b/web/pages/trades.tsx index 55a08bc6..a29fb7f0 100644 --- a/web/pages/trades.tsx +++ b/web/pages/trades.tsx @@ -1,17 +1,10 @@ import Router from 'next/router' -import { useEffect } from 'react' +import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' -import { useUser } from 'web/hooks/use-user' +export const getServerSideProps = redirectIfLoggedOut('/') // Deprecated: redirects to /portfolio. // Eventually, this will be removed. export default function TradesPage() { - const user = useUser() - - useEffect(() => { - if (user === null) Router.replace('/') - else Router.replace('/portfolio') - }) - - return <></> + Router.replace('/portfolio') }