Skip to content

Commit

Permalink
Prevent use of another user's server-config (#241)
Browse files Browse the repository at this point in the history
* Prevent use of another user's server-config

* do not read from localstorage on server
  • Loading branch information
matthieusieben authored Nov 22, 2024
1 parent c419db8 commit 6e7de29
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 87 deletions.
48 changes: 28 additions & 20 deletions components/shell/ConfigContext.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
'use client'

import { useQuery } from '@tanstack/react-query'
import { createContext, ReactNode, useContext, useEffect, useMemo } from 'react'
import { useLocalStorage } from 'react-use'
import { createContext, ReactNode, useContext, useMemo } from 'react'

import { Loading } from '@/common/Loader'
import { SetupModal } from '@/common/SetupModal'
import { getConfig, OzoneConfig } from '@/lib/client-config'
import { useStoredQuery } from '@/lib/useStoredQuery'
import { GLOBAL_QUERY_CONTEXT } from './QueryClient'

export type ConfigContextData = {
Expand All @@ -18,36 +17,45 @@ export type ConfigContextData = {
const ConfigContext = createContext<ConfigContextData | null>(null)

export const ConfigProvider = ({ children }: { children: ReactNode }) => {
const [cachedConfig, setCachedConfig] =
useLocalStorage<OzoneConfig>('labeler-config')

const { data, error, refetch } = useQuery<OzoneConfig, Error>({
const { data, error, refetch } = useStoredQuery({
// Use the global query client to avoid clearing the cache when the user
// changes.
context: GLOBAL_QUERY_CONTEXT,
retry: (failureCount: number, error: Error): boolean => {
// TODO: change getConfig() to throw a specific error when a network
// error occurs, so we can distinguish between network errors and
// configuration errors.
return false
},
// TODO: change getConfig() to throw a specific error when a network
// error occurs, so we can distinguish between network errors and
// configuration errors.
retry: false,
queryKey: ['labeler-config'],
queryFn: getConfig,
initialData: cachedConfig,
// Refetching will be handled manually
refetchOnWindowFocus: false,
// Initialize with data from the legacy key (can be removed in the future)
initialData:
typeof window === 'undefined'
? undefined
: ((legacyKey: string) => {
try {
const data = localStorage.getItem(legacyKey)
if (data) return JSON.parse(data)
} catch {
// Ignore
} finally {
localStorage.removeItem(legacyKey)
}
})('labeler-config'),
})

useEffect(() => {
if (data) setCachedConfig(data)
}, [data, setCachedConfig])

const value = useMemo(
const value = useMemo<ConfigContextData | null>(
() =>
data
? {
config: data,
configError: error,
configError:
error == null
? null
: error instanceof Error
? error
: new Error('Unknown error', { cause: error }),
refetchConfig: refetch,
}
: null,
Expand Down
17 changes: 7 additions & 10 deletions components/shell/ConfigurationContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,14 @@ export const ConfigurationProvider = ({
// Reset "skipRecord" on credential change
useEffect(() => setSkipRecord(false), [labelerAgent])

const accountDid = labelerAgent?.did
const isServiceAccount = labelerAgent.did === config.did

const state =
serverConfigError?.['status'] === 401
? ConfigurationState.Unauthorized
: config.needs.key ||
config.needs.service ||
(config.needs.record && config.did === accountDid && !skipRecord)
(config.needs.record && isServiceAccount && !skipRecord)
? ConfigurationState.Unconfigured
: !serverConfig
? isServerConfigLoading
Expand All @@ -100,13 +100,13 @@ export const ConfigurationProvider = ({
labelerAgent
? {
config,
isServiceAccount: accountDid === config.did,
isServiceAccount,
serverConfig,
labelerAgent,
reconfigure,
}
: null,
[state, accountDid, config, serverConfig, labelerAgent, reconfigure],
[state, config, isServiceAccount, serverConfig, labelerAgent, reconfigure],
)

if (!configurationContextData) {
Expand Down Expand Up @@ -144,7 +144,7 @@ export const ConfigurationProvider = ({
)
}

export const useConfigurationContext = () => {
export function useConfigurationContext() {
const value = useContext(ConfigurationContext)
if (value) return value

Expand All @@ -166,12 +166,9 @@ export function usePermission(name: PermissionName) {
}

export function useAppviewAgent() {
const { appview } = useConfigurationContext().serverConfig

const { appview } = useServerConfig()
return useMemo<Agent | null>(() => {
if (appview) {
return new Agent(new CredentialSession(new URL(appview)))
}
if (appview) return new Agent(appview)
return null
}, [appview])
}
120 changes: 63 additions & 57 deletions components/shell/ConfigurationContext/useServerConfigQuery.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,77 @@
import { Agent } from '@atproto/api'
import { ResponseType, XRPCError } from '@atproto/xrpc'
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useLocalStorage } from 'react-use'

import { parseServerConfig, ServerConfig } from '@/lib/server-config'
import { parseServerConfig } from '@/lib/server-config'
import { useStoredQuery } from '@/lib/useStoredQuery'

export function useServerConfigQuery(agent: Agent) {
const [cachedServerConfig, setCachedServerConfig] =
useLocalStorage<ServerConfig>('labeler-server-config')

const response = useQuery({
retry: (failureCount, error): boolean => {
if (error instanceof XRPCError) {
if (error.status === ResponseType.InternalServerError) {
// The server is misconfigured
return false
}

if (
error.status === ResponseType.InvalidRequest &&
error.message === 'could not resolve proxy did service url'
) {
// Labeler service not configured in the user's DID document (yet)
return false
}

if (error.status === ResponseType.AuthRequired) {
// User is logged in with a user that is not member of the labeler's
// group.
return false
}
}

return failureCount < 3
},
retryDelay: (attempt, error) => {
if (
error instanceof XRPCError &&
error.status === ResponseType.RateLimitExceeded &&
error.headers?.['ratelimit-remaining'] === '0' &&
error.headers?.['ratelimit-reset']
) {
// ratelimit-limit: 3000
// ratelimit-policy: 3000;w=300
// ratelimit-remaining: 2977
// ratelimit-reset: 1724927309

const reset = Number(error.headers['ratelimit-reset']) * 1e3
return reset - Date.now()
}

// Exponential backoff with a maximum of 30 seconds
return Math.min(1000 * 2 ** attempt, 30000)
},
queryKey: ['server-config', agent.assertDid, agent.proxy],
return useStoredQuery({
queryKey: ['server-config', agent.assertDid, agent.proxy ?? null],
queryFn: async ({ signal }) => {
const { data } = await agent.tools.ozone.server.getConfig({}, { signal })
return parseServerConfig(data)
},
initialData: cachedServerConfig,
retry,
retryDelay,
refetchOnWindowFocus: false,
// Initialize with data from the legacy key (can be removed in the future)
initialData:
typeof window === 'undefined'
? undefined
: ((legacyKey: string) => {
try {
const data = localStorage.getItem(legacyKey)
if (data) return JSON.parse(data)
} catch {
// Ignore
} finally {
localStorage.removeItem(legacyKey)
}
})('labeler-server-config'),
})
}

const retry = (failureCount: number, error: unknown): boolean => {
if (error instanceof XRPCError) {
if (error.status === ResponseType.InternalServerError) {
// The server is misconfigured
return false
}

if (
error.status === ResponseType.InvalidRequest &&
error.message === 'could not resolve proxy did service url'
) {
// Labeler service not configured in the user's DID document (yet)
return false
}

if (error.status === ResponseType.AuthRequired) {
// User is logged in with a user that is not member of the labeler's
// group.
return false
}
}

return failureCount < 3
}

const retryDelay = (attempt: number, error: unknown): number => {
if (
error instanceof XRPCError &&
error.status === ResponseType.RateLimitExceeded &&
error.headers?.['ratelimit-remaining'] === '0' &&
error.headers?.['ratelimit-reset']
) {
// ratelimit-limit: 3000
// ratelimit-policy: 3000;w=300
// ratelimit-remaining: 2977
// ratelimit-reset: 1724927309

useEffect(() => {
if (response.data) setCachedServerConfig(response.data)
}, [response.data, setCachedServerConfig])
const reset = Number(error.headers['ratelimit-reset']) * 1e3
return reset - Date.now()
}

return response
// Exponential backoff with a maximum of 30 seconds
return Math.min(1000 * 2 ** attempt, 30000)
}
34 changes: 34 additions & 0 deletions lib/useStoredQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {
useQuery,
UseQueryOptions,
UseQueryResult,
} from '@tanstack/react-query'
import { useEffect } from 'react'
import { useLocalStorage } from 'react-use'

export function useStoredQuery<
TData extends NonNullable<unknown> | null,
TError,
TQueryKey extends (string | number | boolean | null)[],
>({
initialData,
...options
}: Omit<
UseQueryOptions<TData, TError, TData, TQueryKey>,
'queryKey' | 'initialData'
> & {
queryKey: TQueryKey
initialData?: TData
}): UseQueryResult<TData, TError> {
const key = `storedQuery:${JSON.stringify(options.queryKey).slice(1, -1)}`

const [storedData, setStoredData] = useLocalStorage<TData>(key, initialData)

const response = useQuery({ ...options, initialData: storedData })

useEffect(() => {
setStoredData(response.data)
}, [response.data, setStoredData])

return response
}

0 comments on commit 6e7de29

Please sign in to comment.