diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index c905b2cd07..c961dda2a4 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -300,7 +300,7 @@ const { setModpackAlreadyInstalledModal, handleModpackDuplicateCreateAnyway, handleModpackDuplicateGoToInstance, -} = setupProviders(notificationManager, popupNotificationManager) +} = setupProviders(tauriApiClient, notificationManager, popupNotificationManager) const news = ref([]) const displayedServerInviteNotifications = new Set() diff --git a/apps/app-frontend/src/providers/setup.ts b/apps/app-frontend/src/providers/setup.ts index fc49dbcfe7..bbbf7da380 100644 --- a/apps/app-frontend/src/providers/setup.ts +++ b/apps/app-frontend/src/providers/setup.ts @@ -1,3 +1,4 @@ +import type { AbstractModrinthClient } from '@modrinth/api-client' import type { AbstractPopupNotificationManager, AbstractWebNotificationManager } from '@modrinth/ui' import { setupCreationModal } from './setup/creation-modal' @@ -5,11 +6,14 @@ import { setupFileDropProvider } from './setup/file-drop' import { setupFilePickerProvider } from './setup/file-picker' import { setupInstanceImportProvider } from './setup/instance-import' import { setupTagsProvider } from './setup/tags' +import { setupUserCountryProvider } from './setup/user-country' export function setupProviders( + client: AbstractModrinthClient, notificationManager: AbstractWebNotificationManager, _popupNotificationManager: AbstractPopupNotificationManager, ) { + setupUserCountryProvider(client) setupTagsProvider(notificationManager) setupFileDropProvider() setupFilePickerProvider() diff --git a/apps/app-frontend/src/providers/setup/user-country.ts b/apps/app-frontend/src/providers/setup/user-country.ts new file mode 100644 index 0000000000..2a88d4a850 --- /dev/null +++ b/apps/app-frontend/src/providers/setup/user-country.ts @@ -0,0 +1,16 @@ +import type { AbstractModrinthClient } from '@modrinth/api-client' +import { provideUserCountry } from '@modrinth/ui' +import { ref } from 'vue' + +export function setupUserCountryProvider(client: AbstractModrinthClient) { + const country = ref('US') + + void client.labrinth.geoip + .getCountry() + .then((detectedCountry) => { + country.value = detectedCountry ?? country.value + }) + .catch(() => {}) + + return provideUserCountry(country) +} diff --git a/apps/frontend/src/app.vue b/apps/frontend/src/app.vue index 18804d90cf..5397bd83a3 100644 --- a/apps/frontend/src/app.vue +++ b/apps/frontend/src/app.vue @@ -15,9 +15,12 @@ import { I18nDebugPanel, LoadingBar, NotificationPanel } from '@modrinth/ui' import AdsConsentNotification from '~/components/ui/AdsConsentNotification.vue' import { setupProviders } from '~/providers/setup.ts' +import { setupUserCountryProvider } from '~/providers/setup/user-country.ts' import { useAuth } from './composables/auth' +setupUserCountryProvider() + const auth = await useAuth() setupProviders(auth) diff --git a/apps/frontend/src/composables/country.ts b/apps/frontend/src/composables/country.ts index 8daaea9ccd..ce13609c1d 100644 --- a/apps/frontend/src/composables/country.ts +++ b/apps/frontend/src/composables/country.ts @@ -1,6 +1,6 @@ import type { ISO3166 } from '@modrinth/api-client' +import { useUserCountry as useInjectedUserCountry } from '@modrinth/ui' -import { useRequestHeaders, useState } from '#imports' import { countries, subdivisions } from '~/generated/state.json' export const useCountries = () => { @@ -35,38 +35,4 @@ export const useSubdivisions = (countryCode: ComputedRef | Ref | return computed(() => byCountry[unref(code)] ?? []) } -export const useUserCountry = () => { - const country = useState('userCountry', () => 'US') - const fromServer = useState('userCountryFromServer', () => false) - - if (import.meta.server) { - const headers = useRequestHeaders(['cf-ipcountry', 'accept-language']) - const cf = headers['cf-ipcountry'] - if (cf) { - country.value = cf.toUpperCase() - fromServer.value = true - } else { - const al = headers['accept-language'] || '' - const tag = al.split(',')[0] - const val = tag.split('-')[1]?.toLowerCase() - if (val) { - country.value = val - fromServer.value = true - } - } - } - - if (import.meta.client) { - onMounted(() => { - if (fromServer.value) return - // @ts-expect-error - ignore TS not knowing about navigator.userLanguage - const lang = navigator.language || navigator.userLanguage || '' - const region = lang.split('-')[1] - if (region) { - country.value = region.toUpperCase() - } - }) - } - - return country -} +export const useUserCountry = useInjectedUserCountry diff --git a/apps/frontend/src/providers/setup/user-country.ts b/apps/frontend/src/providers/setup/user-country.ts new file mode 100644 index 0000000000..457b521387 --- /dev/null +++ b/apps/frontend/src/providers/setup/user-country.ts @@ -0,0 +1,41 @@ +import { provideUserCountry } from '@modrinth/ui' +import { onMounted } from 'vue' + +import { useRequestHeaders, useState } from '#imports' + +function getCountryFromLocale(locale: string | undefined) { + if (!locale) return undefined + + try { + return new Intl.Locale(locale).region?.toUpperCase() + } catch { + return undefined + } +} + +export function setupUserCountryProvider() { + const country = useState('userCountry', () => 'US') + const fromServer = useState('userCountryFromServer', () => false) + + if (import.meta.server) { + const headers = useRequestHeaders(['cf-ipcountry', 'accept-language']) + const cloudflareCountry = headers['cf-ipcountry'] + const locale = headers['accept-language']?.split(',')[0]?.split(';')[0]?.trim() + const detectedCountry = cloudflareCountry?.toUpperCase() ?? getCountryFromLocale(locale) + + if (detectedCountry) { + country.value = detectedCountry + fromServer.value = true + } + } + + if (import.meta.client) { + onMounted(() => { + if (!fromServer.value) { + country.value = getCountryFromLocale(navigator.language) ?? country.value + } + }) + } + + return provideUserCountry(country) +} diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index 28b3c77e9a..a08a953259 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -32,6 +32,7 @@ import { LabrinthCollectionsModule } from './labrinth/collections' import { LabrinthContentV3Module } from './labrinth/content/v3' import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal' import { LabrinthFriendsV3Module } from './labrinth/friends/v3' +import { LabrinthGeoIpModule } from './labrinth/geoip' import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal' import { LabrinthImagesV3Module } from './labrinth/images/v3' import { LabrinthLimitsV3Module } from './labrinth/limits/v3' @@ -111,6 +112,7 @@ export const MODULE_REGISTRY = { labrinth_content_v3: LabrinthContentV3Module, labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule, labrinth_friends_v3: LabrinthFriendsV3Module, + labrinth_geoip: LabrinthGeoIpModule, labrinth_globals_internal: LabrinthGlobalsInternalModule, labrinth_images_v3: LabrinthImagesV3Module, labrinth_moderation_internal: LabrinthModerationInternalModule, diff --git a/packages/api-client/src/modules/labrinth/geoip.ts b/packages/api-client/src/modules/labrinth/geoip.ts new file mode 100644 index 0000000000..0ef7dd636a --- /dev/null +++ b/packages/api-client/src/modules/labrinth/geoip.ts @@ -0,0 +1,23 @@ +import { AbstractModule } from '../../core/abstract-module' + +export class LabrinthGeoIpModule extends AbstractModule { + public getModuleID(): string { + return 'labrinth_geoip' + } + + public async getCountry(): Promise { + const trace = await this.client.request('/trace', { + api: 'https://api.modrinth.com', + version: 'cdn-cgi', + method: 'GET', + skipAuth: true, + }) + const country = trace + .split('\n') + .find((line) => line.startsWith('loc=')) + ?.slice(4) + .toUpperCase() + + return country?.match(/^[A-Z]{2}$/) ? country : undefined + } +} diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts index 51cca73d32..ec38853f06 100644 --- a/packages/api-client/src/modules/labrinth/index.ts +++ b/packages/api-client/src/modules/labrinth/index.ts @@ -8,6 +8,7 @@ export * from './collections' export * from './content/v3' export * from './external-projects/internal' export * from './friends/v3' +export * from './geoip' export * from './globals/internal' export * from './images/v3' export * from './limits/v3' diff --git a/packages/ui/src/providers/index.ts b/packages/ui/src/providers/index.ts index 9f7801eaf0..d86d8310ff 100644 --- a/packages/ui/src/providers/index.ts +++ b/packages/ui/src/providers/index.ts @@ -18,4 +18,5 @@ export * from './project-page-new' export * from './server-context' export * from './server-settings-modal' export * from './tags' +export * from './user-country' export * from './web-notifications' diff --git a/packages/ui/src/providers/user-country.ts b/packages/ui/src/providers/user-country.ts new file mode 100644 index 0000000000..a0100e0dc8 --- /dev/null +++ b/packages/ui/src/providers/user-country.ts @@ -0,0 +1,28 @@ +import { setMarkdownUserCountryResolver } from '@modrinth/utils' +import type { Ref } from 'vue' +import { hasInjectionContext } from 'vue' + +import { createContext } from './create-context' + +const [injectUserCountryContext, provideUserCountryContext] = createContext>( + 'root', + 'userCountry', +) + +let clientCountry: Ref | null = null + +export const injectUserCountry = injectUserCountryContext +export const useUserCountry = injectUserCountryContext + +export function provideUserCountry(country: Ref) { + if (typeof window !== 'undefined') { + clientCountry = country + } + + return provideUserCountryContext(country) +} + +setMarkdownUserCountryResolver(() => { + const injectedCountry = hasInjectionContext() ? injectUserCountryContext(null) : null + return injectedCountry?.value ?? clientCountry?.value +}) diff --git a/packages/utils/parse.ts b/packages/utils/parse.ts index 5243824b24..e398d46b6a 100644 --- a/packages/utils/parse.ts +++ b/packages/utils/parse.ts @@ -4,6 +4,39 @@ import xss from 'xss' // @ts-expect-error xss types don't reflect CJS default export shape const { escapeAttrValue, FilterXSS, safeAttrValue, whiteList } = xss +// Imgur is blocked in UK and Indonesia +const imgurProxyCountries = new Set(['GB', 'ID']) + +type MarkdownUserCountryResolver = () => string | null | undefined + +let resolveMarkdownUserCountry: MarkdownUserCountryResolver = () => undefined + +export const setMarkdownUserCountryResolver = (resolver: MarkdownUserCountryResolver) => { + resolveMarkdownUserCountry = resolver +} + +const shouldProxyImgur = () => { + try { + const country = resolveMarkdownUserCountry() + return country ? imgurProxyCountries.has(country.toUpperCase()) : false + } catch { + return false + } +} + +const getImgurProxyUrl = (value: string) => { + try { + const url = new URL(value.replaceAll('&', '&')) + if (url.hostname !== 'imgur.com' && !url.hostname.endsWith('.imgur.com')) { + return undefined + } + + return `https://external-content.duckduckgo.com/iu/?u=${encodeURIComponent(url.toString())}.png` + } catch { + return undefined + } +} + export const configuredXss = new FilterXSS({ whiteList: { ...whiteList, @@ -84,6 +117,18 @@ export const configuredXss = new FilterXSS({ } }, safeAttrValue(tag, name, value, cssFilter) { + if ( + shouldProxyImgur() && + ((tag === 'a' && name === 'href') || + ((tag === 'img' || tag === 'video' || tag === 'audio' || tag === 'source') && + (name === 'src' || name === 'srcset' || name === 'poster'))) + ) { + const proxiedUrl = getImgurProxyUrl(value) + if (proxiedUrl) { + return safeAttrValue(tag, name, proxiedUrl, cssFilter) + } + } + if ( (tag === 'img' || tag === 'video' || tag === 'audio' || tag === 'source') && (name === 'src' || name === 'srcset' || name === 'poster') &&