diff --git a/web/astro.config.mjs b/web/astro.config.mjs index 39b2259..855ed1a 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -14,6 +14,8 @@ import { postgresListener } from './src/lib/postgresListenerIntegration' import { getServerEnvVariable } from './src/lib/serverEnvVariables' const SITE_URL = getServerEnvVariable('SITE_URL') +const ONION_ADDRESS = getServerEnvVariable('ONION_ADDRESS') +const I2P_ADDRESS = getServerEnvVariable('I2P_ADDRESS') export default defineConfig({ site: SITE_URL, @@ -95,6 +97,18 @@ export default defineConfig({ server: { open: false, allowedHosts: [new URL(SITE_URL).hostname], + headers: { + 'Onion-Location': ONION_ADDRESS, + 'X-I2P-Location': I2P_ADDRESS, + 'X-Frame-Options': 'DENY', + // Astro is working on this feature, when it's stable use it instead of this. + // https://astro.build/blog/astro-590/#experimental-content-security-policy-support + 'Content-Security-Policy': + SITE_URL === 'http://localhost:4321' + ? "frame-ancestors 'none'; upgrade-insecure-requests" + : "default-src 'self'; img-src 'self' *; frame-ancestors 'none'; upgrade-insecure-requests", + 'Strict-Transport-Security': 'max-age=31536000; includeSubdomains; preload;', + }, }, image: { domains: [new URL(SITE_URL).hostname], diff --git a/web/package-lock.json b/web/package-lock.json index f6a65eb..04dd2ab 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -28,6 +28,7 @@ "astro-seo-schema": "5.0.0", "canvas": "3.1.2", "clsx": "2.1.1", + "he": "1.2.0", "htmx.org": "2.0.6", "javascript-time-ago": "2.5.11", "libphonenumber-js": "1.12.9", @@ -59,6 +60,7 @@ "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.16", "@types/eslint__js": "9.14.0", + "@types/he": "1.2.3", "@types/lodash-es": "4.17.12", "@types/qrcode": "1.5.5", "@types/react": "19.1.8", @@ -4763,6 +4765,13 @@ "@types/unist": "*" } }, + "node_modules/@types/he": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz", + "integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -10820,6 +10829,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/hex-rgb": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", diff --git a/web/package.json b/web/package.json index 85be03b..89995c6 100644 --- a/web/package.json +++ b/web/package.json @@ -44,6 +44,7 @@ "astro-seo-schema": "5.0.0", "canvas": "3.1.2", "clsx": "2.1.1", + "he": "1.2.0", "htmx.org": "2.0.6", "javascript-time-ago": "2.5.11", "libphonenumber-js": "1.12.9", @@ -75,6 +76,7 @@ "@tailwindcss/forms": "0.5.10", "@tailwindcss/typography": "0.5.16", "@types/eslint__js": "9.14.0", + "@types/he": "1.2.3", "@types/lodash-es": "4.17.12", "@types/qrcode": "1.5.5", "@types/react": "19.1.8", diff --git a/web/src/components/BaseHead.astro b/web/src/components/BaseHead.astro index e7955fe..a5d971f 100644 --- a/web/src/components/BaseHead.astro +++ b/web/src/components/BaseHead.astro @@ -1,6 +1,7 @@ --- import LoadingIndicator from 'astro-loading-indicator/component' import { Schema } from 'astro-seo-schema' +import { ONION_ADDRESS } from 'astro:env/server' import { ClientRouter } from 'astro:transitions' import { pwaAssetsHead } from 'virtual:pwa-assets/head' import { pwaInfo } from 'virtual:pwa-info' @@ -78,30 +79,32 @@ const fullTitle = `${pageTitle} | KYCnot.me ${modeName}` const ogImageUrl = makeOgImageUrl(ogImage, Astro.url) --- - +{/* Primary Meta Tags */} {fullTitle} - +{/* canonicalUrl && */} + - +{/* Open Graph / Facebook */} {!!ogImageUrl && } - +{/* Twitter */} {!!ogImageUrl && } - +{/* Other */} + - +{/* PWA */} {pwaAssetsHead.themeColor && } {pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => )} {pwaInfo && } @@ -115,10 +118,10 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url) {htmx && } - +{/* JSON-LD Schemas */} {schemas?.map((item) => )} - +{/* Breadcrumbs */} { breadcrumbLists.map((breadcrumbList) => ( & { @@ -111,7 +111,7 @@ const { Type
    { - options.categories?.map((category) => ( + options.categories.map((category) => (
  • - {attribute.title} ({attribute._count?.services}) + {attribute.title} ({attribute._count.services}) {attribute.title} - {attribute._count?.services} + {attribute._count.services}
  • diff --git a/web/src/components/ServicesSearchResults.astro b/web/src/components/ServicesSearchResults.astro index 66daee6..a53b795 100644 --- a/web/src/components/ServicesSearchResults.astro +++ b/web/src/components/ServicesSearchResults.astro @@ -15,7 +15,8 @@ import Button from './Button.astro' import ServiceCard from './ServiceCard.astro' import ServiceFiltersPillsRow from './ServiceFiltersPillsRow.astro' -import type { AttributeOption, ServicesFiltersObject, ServicesFiltersOptions } from '../pages/index.astro' +import type { ServicesFiltersOptions } from '../lib/searchFiltersOptions' +import type { AttributeOption, ServicesFiltersObject } from '../pages/index.astro' import type { Prisma } from '@prisma/client' import type { ComponentProps, HTMLAttributes } from 'astro/types' @@ -82,6 +83,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, { ]), }) +// NOTE: If you make changes to this function, remember to update the sitemap: src/pages/sitemaps/search.xml.ts const searchTitle = (() => { if (filters.q) { return `Search results for “${filters.q}”` diff --git a/web/src/lib/searchFiltersOptions.ts b/web/src/lib/searchFiltersOptions.ts new file mode 100644 index 0000000..7113f0f --- /dev/null +++ b/web/src/lib/searchFiltersOptions.ts @@ -0,0 +1,187 @@ +import { orderBy, groupBy } from 'lodash-es' + +import { getAttributeCategoryInfo } from '../constants/attributeCategories' +import { getAttributeTypeInfo } from '../constants/attributeTypes' +import { currencies } from '../constants/currencies' +import { kycLevels } from '../constants/kycLevels' +import { networks } from '../constants/networks' +import { verificationStatuses } from '../constants/verificationStatus' + +import type { Prisma } from '@prisma/client' + +const MIN_CATEGORIES_TO_SHOW = 8 +const MIN_ATTRIBUTES_TO_SHOW = 8 + +export const sortOptions = [ + { + value: 'score-desc', + label: 'Score (High → Low)', + orderBy: { + key: 'overallScore', + direction: 'desc', + }, + }, + { + value: 'score-asc', + label: 'Score (Low → High)', + orderBy: { + key: 'overallScore', + direction: 'asc', + }, + }, + { + value: 'name-asc', + label: 'Name (A → Z)', + orderBy: { + key: 'name', + direction: 'asc', + }, + }, + { + value: 'name-desc', + label: 'Name (Z → A)', + orderBy: { + key: 'name', + direction: 'desc', + }, + }, + { + value: 'recent', + label: 'Date listed (New → Old)', + orderBy: { + key: 'listedAt', + direction: 'desc', + }, + }, + { + value: 'oldest', + label: 'Date listed (Old → New)', + orderBy: { + key: 'listedAt', + direction: 'asc', + }, + }, +] as const satisfies { + value: string + label: string + orderBy: { + key: keyof Prisma.ServiceSelect + direction: 'asc' | 'desc' + } +}[] + +export const defaultSortOption = sortOptions[0] + +export const modeOptions = [ + { + value: 'or', + label: 'OR', + }, + { + value: 'and', + label: 'AND', + }, +] as const satisfies { + value: string + label: string +}[] + +export function makeSearchFiltersOptions({ + filters, + categories, + attributes, +}: { + filters: { + categories: string[] + attr: Record | undefined + } | null + categories: Prisma.CategoryGetPayload<{ + select: { + name: true + namePluralLong: true + slug: true + icon: true + _count: { + select: { + services: { + where: { + serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] } + } + } + } + } + } + }>[] + attributes: Prisma.AttributeGetPayload<{ + select: { + id: true + slug: true + title: true + category: true + type: true + _count: { + select: { + services: true + } + } + } + }>[] +}) { + const attributesByCategory = orderBy( + Object.entries( + groupBy( + attributes.map((attr) => { + return { + typeInfo: getAttributeTypeInfo(attr.type), + ...attr, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + value: filters?.attr?.[attr.id] || undefined, + } + }), + 'category' + ) + ).map(([category, attributes]) => ({ + category, + categoryInfo: getAttributeCategoryInfo(category), + attributes: orderBy( + attributes, + ['value', 'type', '_count.services', 'title'], + ['asc', 'asc', 'desc', 'asc'] + ).map((attr, i) => ({ + ...attr, + showAlways: i < MIN_ATTRIBUTES_TO_SHOW || attr.value !== undefined, + })), + })), + ['category'], + ['asc'] + ) + + const categoriesSorted = orderBy( + categories.map((category) => { + const checked = filters?.categories.includes(category.slug) ?? false + + return { + ...category, + checked, + } + }), + ['checked', '_count.services', 'name'], + ['desc', 'desc', 'asc'] + ).map((category, i) => ({ + ...category, + showAlways: i < MIN_CATEGORIES_TO_SHOW || category.checked, + })) + + return { + currencies, + categories: categoriesSorted, + sort: sortOptions, + modeOptions, + network: networks, + verification: verificationStatuses, + attributesByCategory, + kycLevels, + } as const +} + +export type ServicesFiltersOptions = ReturnType diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index b61ca77..d9dca47 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -9,9 +9,7 @@ import Pagination from '../components/Pagination.astro' import ServiceFiltersPillsRow from '../components/ServiceFiltersPillsRow.astro' import ServicesFilters from '../components/ServicesFilters.astro' import ServicesSearchResults from '../components/ServicesSearchResults.astro' -import { getAttributeCategoryInfo } from '../constants/attributeCategories' -import { getAttributeTypeInfo } from '../constants/attributeTypes' -import { currencies, currenciesZodEnumBySlug, currencySlugToId } from '../constants/currencies' +import { currenciesZodEnumBySlug, currencySlugToId } from '../constants/currencies' import { networks } from '../constants/networks' import { verificationStatuses, @@ -25,89 +23,18 @@ import { parseIntWithFallback } from '../lib/numbers' import { areEqualObjectsWithoutOrder } from '../lib/objects' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { prisma } from '../lib/prisma' +import { + defaultSortOption, + makeSearchFiltersOptions, + modeOptions, + sortOptions, +} from '../lib/searchFiltersOptions' import { makeSortSeed } from '../lib/sortSeed' import type { Prisma } from '@prisma/client' -const MIN_CATEGORIES_TO_SHOW = 8 -const MIN_ATTRIBUTES_TO_SHOW = 8 - const PAGE_SIZE = 30 -const sortOptions = [ - { - value: 'score-desc', - label: 'Score (High → Low)', - orderBy: { - key: 'overallScore', - direction: 'desc', - }, - }, - { - value: 'score-asc', - label: 'Score (Low → High)', - orderBy: { - key: 'overallScore', - direction: 'asc', - }, - }, - { - value: 'name-asc', - label: 'Name (A → Z)', - orderBy: { - key: 'name', - direction: 'asc', - }, - }, - { - value: 'name-desc', - label: 'Name (Z → A)', - orderBy: { - key: 'name', - direction: 'desc', - }, - }, - { - value: 'recent', - label: 'Date listed (New → Old)', - orderBy: { - key: 'listedAt', - direction: 'desc', - }, - }, - { - value: 'oldest', - label: 'Date listed (Old → New)', - orderBy: { - key: 'listedAt', - direction: 'asc', - }, - }, -] as const satisfies { - value: string - label: string - orderBy: { - key: keyof Prisma.ServiceSelect - direction: 'asc' | 'desc' - } -}[] - -const defaultSortOption = sortOptions[0] - -const modeOptions = [ - { - value: 'or', - label: 'OR', - }, - { - value: 'and', - label: 'AND', - }, -] as const satisfies { - value: string - label: string -}[] - export type AttributeOption = { value: string prefix: string @@ -478,62 +405,11 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] = ], ]) -const attributesByCategory = orderBy( - Object.entries( - groupBy( - attributes.map((attr) => { - return { - typeInfo: getAttributeTypeInfo(attr.type), - ...attr, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - value: filters.attr?.[attr.id] || undefined, - } - }), - 'category' - ) - ).map(([category, attributes]) => ({ - category, - categoryInfo: getAttributeCategoryInfo(category), - attributes: orderBy( - attributes, - ['value', 'type', '_count.services', 'title'], - ['asc', 'asc', 'desc', 'asc'] - ).map((attr, i) => ({ - ...attr, - showAlways: i < MIN_ATTRIBUTES_TO_SHOW || attr.value !== undefined, - })), - })), - ['category'], - ['asc'] -) - -const categoriesSorted = orderBy( - categories.map((category) => { - const checked = filters.categories.includes(category.slug) - - return { - ...category, - checked, - } - }), - ['checked', '_count.services', 'name'], - ['desc', 'desc', 'asc'] -).map((category, i) => ({ - ...category, - showAlways: i < MIN_CATEGORIES_TO_SHOW || category.checked, -})) - -const filtersOptions = { - currencies, - categories: categoriesSorted, - sort: sortOptions, - modeOptions, - network: networks, - verification: verificationStatuses, - attributesByCategory, -} as const - -export type ServicesFiltersOptions = typeof filtersOptions +const filtersOptions = makeSearchFiltersOptions({ + filters, + categories, + attributes, +}) const searchResultsId = 'search-results' const showFiltersId = 'show-filters' diff --git a/web/src/pages/sitemaps/search.xml.ts b/web/src/pages/sitemaps/search.xml.ts new file mode 100644 index 0000000..7acd90b --- /dev/null +++ b/web/src/pages/sitemaps/search.xml.ts @@ -0,0 +1,192 @@ +/* eslint-disable import/no-named-as-default-member */ +import he from 'he' + +import { prisma } from '../../lib/prisma' +import { makeSearchFiltersOptions } from '../../lib/searchFiltersOptions' + +import type { APIRoute } from 'astro' + +export const GET: APIRoute = async ({ site }) => { + if (!site) { + return new Response('Site URL not configured', { status: 500 }) + } + + const searchUrls = await generateSEOSitemapUrls(site.href) + + const result = ` + + + ${searchUrls.map((url) => `${he.encode(url)}`).join('\n')} + + `.trim() + + return new Response(result, { + headers: { + 'Content-Type': 'application/xml', + }, + }) +} + +async function generateSEOSitemapUrls(siteUrl: string) { + try { + const [categories, attributes] = await Promise.all([ + prisma.category.findMany({ + select: { + name: true, + namePluralLong: true, + slug: true, + icon: true, + _count: { + select: { + services: { + where: { + serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }, + }, + }, + }, + }, + }, + }), + prisma.attribute.findMany({ + select: { + id: true, + slug: true, + title: true, + category: true, + type: true, + _count: { + select: { + services: { + where: { + service: { + serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }, + }, + }, + }, + }, + }, + }, + orderBy: [{ category: 'asc' }, { type: 'asc' }, { title: 'asc' }], + }), + ]) + + const filtersOptions = makeSearchFiltersOptions({ + filters: null, + categories, + attributes, + }) + + const byCategory = filtersOptions.categories.map( + (category) => + new URLSearchParams({ + categories: category.slug, + }) + ) + + const byVerificationStatus = filtersOptions.verification.map( + (status) => + new URLSearchParams({ + verification: status.slug, + }) + ) + + const byKycLevel = filtersOptions.kycLevels.map( + (level) => + new URLSearchParams({ + 'max-kyc': level.id, + }) + ) + + const byCurrency = filtersOptions.currencies.map( + (currency) => + new URLSearchParams({ + currencies: currency.slug, + }) + ) + + const withOneAttribute = filtersOptions.attributesByCategory + .flatMap(({ attributes }) => attributes) + .map( + (attribute) => + new URLSearchParams({ + [`attr-${attribute.id.toString()}`]: 'yes', + }) + ) + const withoutOneAttribute = filtersOptions.attributesByCategory + .flatMap(({ attributes }) => attributes) + .map( + (attribute) => + new URLSearchParams({ + [`attr-${attribute.id.toString()}`]: 'no', + }) + ) + + const byCategoryAndCurrency = filtersOptions.categories.flatMap((category) => + filtersOptions.currencies.map( + (currency) => + new URLSearchParams({ + categories: category.slug, + currencies: currency.slug, + }) + ) + ) + + const byCategoryAndAttributes = filtersOptions.categories.flatMap((category) => + filtersOptions.attributesByCategory + .flatMap(({ attributes }) => attributes) + .flatMap((attribute) => [ + new URLSearchParams({ + categories: category.slug, + [`attr-${attribute.id.toString()}`]: 'yes', + }), + new URLSearchParams({ + categories: category.slug, + [`attr-${attribute.id.toString()}`]: 'no', + }), + ]) + ) + + const relevantCurrencies = [ + 'xmr', + 'btc', + ] as const satisfies (typeof filtersOptions.currencies)[number]['slug'][] + + const byCategoryAndAttributesAndRelevantCurrency = filtersOptions.categories.flatMap((category) => + filtersOptions.attributesByCategory + .flatMap(({ attributes }) => attributes) + .flatMap((attribute) => + relevantCurrencies.map( + (currency) => + new URLSearchParams({ + categories: category.slug, + currencies: currency, + [`attr-${attribute.id.toString()}`]: + attribute.type === 'GOOD' || attribute.type === 'INFO' ? 'yes' : 'no', + }) + ) + ) + ) + + const allQueryParams = [ + ...byCategory, + ...byVerificationStatus, + ...byKycLevel, + ...byCurrency, + ...withOneAttribute, + ...withoutOneAttribute, + + ...byCategoryAndCurrency, + ...byCategoryAndAttributes, + ...byCategoryAndAttributesAndRelevantCurrency, + ] satisfies URLSearchParams[] + + return allQueryParams.map((queryParams) => { + const url = new URL(siteUrl) + url.search = queryParams.toString() + return url.href + }) + } catch (error) { + console.error('Failed to generate SEO sitemap URLs:', error) + return [] + } +} diff --git a/web/src/robots.txt.ts b/web/src/robots.txt.ts index 5a1c5c0..00377a2 100644 --- a/web/src/robots.txt.ts +++ b/web/src/robots.txt.ts @@ -1,14 +1,15 @@ import type { APIRoute } from 'astro' -const getRobotsTxt = (sitemapURL: URL) => ` +const getRobotsTxt = (sitemaps: `/${string}`[], siteUrl: URL) => ` User-agent: * Allow: / Disallow: /admin/ -Sitemap: ${sitemapURL.href} +${sitemaps.map((sitemap) => `Sitemap: ${new URL(sitemap, siteUrl).href}`).join('\n')} ` export const GET: APIRoute = ({ site }) => { - const sitemapURL = new URL('sitemap-index.xml', site) - return new Response(getRobotsTxt(sitemapURL)) + if (!site) return new Response('Site URL not configured', { status: 500 }) + + return new Response(getRobotsTxt(['/sitemap-index.xml', '/sitemaps/search.xml'], site)) }