mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 01:54:47 +00:00
feat: hosting access tab (#5995)
* feat: implement access tab with dummy data * fix: spacing * feat: qa * feat: implement backend * qa: qa pass * feat: fix user "search" * fix: lint * feat: change to bitfield * feat: fix fields * fix: lint * fix: lint * feat: hook up api * feat: fix permissions * feat: audit log table event start * feat: better mobile mode for audit log table * feat: i18n * feat: qa * feat: enforce permissions * feat: email template start * feat: qa * fix: tooltip bug * feat: qa * impl: sse support in api-client * feat: sse impl * fix: desync path * feat: time frame picker from analytics * feat: QA * fix: spacing * fix: permisison audit log entries * fix: hosting manage page shared server detection * fix: lint * feat: qa + lint * feat: audit log table sort by time * feat: finish frontend panel stuff * fix: lint * fix: backend alignment * fix: lint * fix: supress friend errors * feat: qa * fix: qa * fix: lint * fix: utils barrel * fix: safari cookies in dev * fix: pin nuxt * feat: fixes + notif fix * fix: notifications * feat: qa * fix: notification sync not happening immediately * fix: qa * fix: qa * feat: qa * blog + prepr * feat: toast shit * blog images * thumbnail update one last time * prepr * feat: use reinvite route * update images * fix: reinvite stuff * fix: lint * fix: alignment of save bar * fix: notif sizing * fix: split up access * fix: lint * fix: lint * fix: link --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
export const useAffiliates = () => {
|
||||
const config = useRuntimeConfig()
|
||||
const affiliateCookie = useCookie('mrs_afl', {
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
secure: config.public.cookieSecure,
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
@@ -30,10 +30,11 @@ export const initAuth = async (oldToken = null) => {
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
const authCookie = useCookie('auth-token', {
|
||||
maxAge: 60 * 60 * 24 * 365 * 10,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
secure: config.public.cookieSecure,
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
@@ -54,6 +54,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
showViewProdRouteBanner: false,
|
||||
showModeratorProjectMemberUi: false,
|
||||
showModeratorPrivateMessageHighlight: true,
|
||||
archonApiStaging: false,
|
||||
showHostingAccessInstanceAuditLog: false,
|
||||
} as const)
|
||||
|
||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||
@@ -64,19 +66,20 @@ export type AllFeatureFlags = {
|
||||
|
||||
export type PartialFeatureFlags = Partial<AllFeatureFlags>
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
maxAge: 60 * 60 * 24 * 365 * 10,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
} satisfies CookieOptions<PartialFeatureFlags>
|
||||
const getCookieOptions = () =>
|
||||
({
|
||||
maxAge: 60 * 60 * 24 * 365 * 10,
|
||||
sameSite: 'lax',
|
||||
secure: useRuntimeConfig().public.cookieSecure,
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
}) satisfies CookieOptions<PartialFeatureFlags>
|
||||
|
||||
export const useFeatureFlags = () =>
|
||||
useState<AllFeatureFlags>('featureFlags', () => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const savedFlags = useCookie<PartialFeatureFlags>('featureFlags', COOKIE_OPTIONS)
|
||||
const savedFlags = useCookie<PartialFeatureFlags>('featureFlags', getCookieOptions())
|
||||
|
||||
if (!savedFlags.value) {
|
||||
savedFlags.value = {}
|
||||
@@ -106,6 +109,6 @@ export const useFeatureFlags = () =>
|
||||
|
||||
export const saveFeatureFlags = () => {
|
||||
const flags = useFeatureFlags()
|
||||
const cookie = useCookie<PartialFeatureFlags>('featureFlags', COOKIE_OPTIONS)
|
||||
const cookie = useCookie<PartialFeatureFlags>('featureFlags', getCookieOptions())
|
||||
cookie.value = flags.value
|
||||
}
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
/**
|
||||
* @deprecated Use `@modrinth/api-client` via `injectModrinthClient()` instead.
|
||||
* The api-client's archon modules (`client.archon.servers_v0`, etc.) handle auth,
|
||||
* retry, and circuit breaking automatically. This composable is kept for legacy
|
||||
* code that hasn't been migrated yet.
|
||||
*/
|
||||
|
||||
import { PANEL_VERSION } from '@modrinth/api-client'
|
||||
import type { V1ErrorInfo } from '@modrinth/utils'
|
||||
import { ModrinthServerError, ModrinthServersFetchError } from '@modrinth/utils'
|
||||
import { $fetch, FetchError } from 'ofetch'
|
||||
|
||||
export interface ServersFetchOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
contentType?: string
|
||||
body?: Record<string, any>
|
||||
version?: number | 'internal'
|
||||
override?: {
|
||||
url?: string
|
||||
token?: string
|
||||
}
|
||||
retry?: number | boolean
|
||||
bypassAuth?: boolean
|
||||
}
|
||||
|
||||
export async function useServersFetch<T>(
|
||||
path: string,
|
||||
options: ServersFetchOptions = {},
|
||||
module?: string,
|
||||
errorContext?: string,
|
||||
): Promise<T> {
|
||||
const config = useRuntimeConfig()
|
||||
const auth = await useAuth()
|
||||
const authToken = auth.value?.token
|
||||
|
||||
if (!authToken && !options.bypassAuth) {
|
||||
const error = new ModrinthServersFetchError(
|
||||
'[Modrinth Hosting] Cannot fetch without auth',
|
||||
10000,
|
||||
)
|
||||
throw new ModrinthServerError('Missing auth token', 401, error, module, undefined, undefined)
|
||||
}
|
||||
|
||||
const {
|
||||
method = 'GET',
|
||||
contentType = 'application/json',
|
||||
body,
|
||||
version = 0,
|
||||
override,
|
||||
retry = method === 'GET' ? 3 : 0,
|
||||
} = options
|
||||
|
||||
const circuitBreakerKey = `${module || 'default'}_${path}`
|
||||
const failureCount = useState<number>(`fetch_failures_${circuitBreakerKey}`, () => 0)
|
||||
const lastFailureTime = useState<number>(`last_failure_${circuitBreakerKey}`, () => 0)
|
||||
|
||||
const now = Date.now()
|
||||
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
|
||||
const error = new ModrinthServersFetchError(
|
||||
'[Modrinth Hosting] Circuit breaker open - too many recent failures',
|
||||
503,
|
||||
)
|
||||
throw new ModrinthServerError(
|
||||
'Service temporarily unavailable',
|
||||
503,
|
||||
error,
|
||||
module,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
|
||||
if (now - lastFailureTime.value > 30000) {
|
||||
failureCount.value = 0
|
||||
}
|
||||
|
||||
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
|
||||
/\/$/,
|
||||
'',
|
||||
)
|
||||
|
||||
if (!base) {
|
||||
const error = new ModrinthServersFetchError(
|
||||
'[Modrinth Hosting] Cannot fetch without base url. Make sure to set a PYRO_BASE_URL in environment variables',
|
||||
10001,
|
||||
)
|
||||
throw new ModrinthServerError(
|
||||
'Configuration error: Missing PYRO_BASE_URL',
|
||||
500,
|
||||
error,
|
||||
module,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
|
||||
const versionString = `v${version}`
|
||||
let newOverrideUrl = override?.url
|
||||
if (newOverrideUrl && newOverrideUrl.includes('v0') && version !== 0) {
|
||||
newOverrideUrl = newOverrideUrl.replace('v0', versionString)
|
||||
}
|
||||
|
||||
const fullUrl = newOverrideUrl
|
||||
? `https://${newOverrideUrl}/${path.replace(/^\//, '')}`
|
||||
: version === 0
|
||||
? `${base}/modrinth/v${version}/${path.replace(/^\//, '')}`
|
||||
: version === 'internal'
|
||||
? `${base}/_internal/${path.replace(/^\//, '')}`
|
||||
: `${base}/v${version}/${path.replace(/^\//, '')}`
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Modrinth/1.0 (https://modrinth.com)',
|
||||
'X-Archon-Request': 'true',
|
||||
'X-Panel-Version': String(PANEL_VERSION),
|
||||
Vary: 'Accept, Origin',
|
||||
}
|
||||
|
||||
if (!options.bypassAuth) {
|
||||
headers.Authorization = `Bearer ${override?.token ?? authToken}`
|
||||
headers['Access-Control-Allow-Headers'] = 'Authorization'
|
||||
}
|
||||
|
||||
if (contentType !== 'none') {
|
||||
headers['Content-Type'] = contentType
|
||||
}
|
||||
|
||||
if (import.meta.client && typeof window !== 'undefined') {
|
||||
headers.Origin = window.location.origin
|
||||
}
|
||||
|
||||
let attempts = 0
|
||||
const maxAttempts = (typeof retry === 'boolean' ? (retry ? 3 : 1) : retry) + 1
|
||||
let lastError: Error | null = null
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const response = await $fetch<T>(fullUrl, {
|
||||
method,
|
||||
headers,
|
||||
body:
|
||||
body && contentType === 'application/json' ? JSON.stringify(body) : (body ?? undefined),
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
failureCount.value = 0
|
||||
return response
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
attempts++
|
||||
|
||||
if (error instanceof FetchError) {
|
||||
const statusCode = error.response?.status
|
||||
const statusText = error.response?.statusText || 'Unknown error'
|
||||
|
||||
if (statusCode && statusCode >= 500) {
|
||||
failureCount.value++
|
||||
lastFailureTime.value = now
|
||||
}
|
||||
|
||||
let v1Error: V1ErrorInfo | undefined
|
||||
if (error.data?.error && error.data?.description) {
|
||||
v1Error = {
|
||||
context: errorContext,
|
||||
...error.data,
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessages: { [key: number]: string } = {
|
||||
400: 'Bad Request',
|
||||
401: 'Unauthorized',
|
||||
403: 'Forbidden',
|
||||
404: 'Not Found',
|
||||
405: 'Method Not Allowed',
|
||||
408: 'Request Timeout',
|
||||
429: "You're making requests too quickly. Please wait a moment and try again.",
|
||||
500: 'Internal Server Error',
|
||||
502: 'Bad Gateway',
|
||||
503: 'Service Unavailable',
|
||||
504: 'Gateway Timeout',
|
||||
}
|
||||
|
||||
const message =
|
||||
statusCode && statusCode in errorMessages
|
||||
? errorMessages[statusCode]
|
||||
: `HTTP Error: ${statusCode || 'unknown'} ${statusText}`
|
||||
|
||||
const isRetryable = statusCode ? [408, 429].includes(statusCode) : false
|
||||
const is5xxRetryable =
|
||||
statusCode && statusCode >= 500 && statusCode < 600 && method === 'GET' && attempts === 1
|
||||
|
||||
if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) {
|
||||
console.error('Fetch error:', error)
|
||||
|
||||
const fetchError = new ModrinthServersFetchError(
|
||||
`[Modrinth Hosting] ${error.message}`,
|
||||
statusCode,
|
||||
error,
|
||||
)
|
||||
throw new ModrinthServerError(
|
||||
`[Modrinth Hosting] ${message}`,
|
||||
statusCode,
|
||||
fetchError,
|
||||
module,
|
||||
v1Error,
|
||||
error.data,
|
||||
)
|
||||
}
|
||||
|
||||
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000
|
||||
const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000)
|
||||
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
continue
|
||||
}
|
||||
|
||||
console.error('Unexpected fetch error:', error)
|
||||
const fetchError = new ModrinthServersFetchError(
|
||||
'[Modrinth Hosting] An unexpected error occurred during the fetch operation.',
|
||||
undefined,
|
||||
error as Error,
|
||||
)
|
||||
throw new ModrinthServerError(
|
||||
'Unexpected error during fetch operation',
|
||||
undefined,
|
||||
fetchError,
|
||||
module,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.error('All retry attempts failed:', lastError)
|
||||
if (lastError instanceof FetchError) {
|
||||
const statusCode = lastError.response?.status
|
||||
const pyroError = new ModrinthServersFetchError(
|
||||
'Maximum retry attempts reached',
|
||||
statusCode,
|
||||
lastError,
|
||||
)
|
||||
throw new ModrinthServerError(
|
||||
'Maximum retry attempts reached',
|
||||
statusCode,
|
||||
pyroError,
|
||||
module,
|
||||
undefined,
|
||||
lastError.data,
|
||||
)
|
||||
}
|
||||
|
||||
const fetchError = new ModrinthServersFetchError(
|
||||
'Maximum retry attempts reached',
|
||||
undefined,
|
||||
lastError || undefined,
|
||||
)
|
||||
throw new ModrinthServerError(
|
||||
'Maximum retry attempts reached',
|
||||
undefined,
|
||||
fetchError,
|
||||
module,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,6 @@ export type FilterSelection = {
|
||||
const cookieDefaults = {
|
||||
maxAge: TEN_MINUTES,
|
||||
sameSite: 'lax' as const,
|
||||
secure: true,
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
}
|
||||
@@ -52,13 +51,19 @@ function newFilterSelection(
|
||||
}
|
||||
|
||||
export function useCdnDownloadContext() {
|
||||
const filterGameVersionCookie = useCookie<string | null>('mr_download_filter_game_version', {
|
||||
const config = useRuntimeConfig()
|
||||
const cookieOptions = {
|
||||
...cookieDefaults,
|
||||
secure: config.public.cookieSecure,
|
||||
}
|
||||
|
||||
const filterGameVersionCookie = useCookie<string | null>('mr_download_filter_game_version', {
|
||||
...cookieOptions,
|
||||
default: () => null,
|
||||
})
|
||||
|
||||
const filterLoaderCookie = useCookie<string | null>('mr_download_filter_loader', {
|
||||
...cookieDefaults,
|
||||
...cookieOptions,
|
||||
default: () => null,
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user