Compare commits
2 Commits
release-39
...
release-41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
577c524ca2 | ||
|
|
da12e8de79 |
@@ -12,6 +12,7 @@ import { formatDistanceStrict } from 'date-fns'
|
||||
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../lib/fileStorage'
|
||||
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
|
||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { separateServiceUrlsByType } from '../lib/urls'
|
||||
@@ -29,11 +30,12 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
|
||||
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
|
||||
|
||||
const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
const possibleDuplicates = await prisma.service.findMany({
|
||||
const matches = await findServicesBySimilarity(input.name, 0.3)
|
||||
|
||||
return await prisma.service.findMany({
|
||||
where: {
|
||||
name: {
|
||||
contains: input.name,
|
||||
mode: 'insensitive',
|
||||
id: {
|
||||
in: matches.map(({ id }) => id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
@@ -43,8 +45,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
description: true,
|
||||
},
|
||||
})
|
||||
|
||||
return possibleDuplicates
|
||||
}
|
||||
|
||||
const serializeExtraNotes = <T extends Record<string, unknown>>(
|
||||
|
||||
@@ -49,6 +49,7 @@ const {
|
||||
class={cn(
|
||||
// Check the scam filter when there is a text quey and the user has checked verified and approved
|
||||
'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked',
|
||||
'has-[input[name=q]:placeholder-shown]:[&_[data-hide-if-q-is-empty]]:hidden has-[input[name=q]:not(:placeholder-shown)]:[&_[data-hide-if-q-is-filled]]:hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -80,16 +81,20 @@ const {
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<p class="text-day-500 mt-1.5 text-center text-sm">
|
||||
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-filled>
|
||||
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
|
||||
Ties randomly sorted
|
||||
</p>
|
||||
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-empty>
|
||||
<Icon name="ri:seo-line" class="inline-block size-3.5 align-[-0.125em]" />
|
||||
Sorted by match first
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<!-- Text Search -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="q">Text</label>
|
||||
<label for="q">Name</label>
|
||||
</legend>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -71,7 +71,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
/>
|
||||
|
||||
{
|
||||
countCommunityOnly && (
|
||||
!!countCommunityOnly && (
|
||||
<>
|
||||
<Button
|
||||
as="a"
|
||||
@@ -192,7 +192,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
inlineIcon={inlineIcons}
|
||||
/>
|
||||
)}
|
||||
{countCommunityOnly && (
|
||||
{!!countCommunityOnly && (
|
||||
<Button
|
||||
as="a"
|
||||
href={urlIfIncludingCommunity}
|
||||
|
||||
16
web/src/lib/findServicesBySimilarity.ts
Normal file
16
web/src/lib/findServicesBySimilarity.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'astro/zod'
|
||||
|
||||
import { prisma } from './prisma'
|
||||
|
||||
export async function findServicesBySimilarity(value: string, similarityThreshold = 0.01) {
|
||||
const data = await prisma.$queryRaw`
|
||||
SELECT id, similarity(name, ${value}) AS similarity_score
|
||||
FROM "Service"
|
||||
WHERE similarity(name, ${value}) >= ${similarityThreshold}
|
||||
ORDER BY similarity(name, ${value}) desc`
|
||||
|
||||
const schema = z.array(z.object({ id: z.number(), similarity_score: z.number() }))
|
||||
const parsedData = schema.parse(data)
|
||||
|
||||
return parsedData.map(({ id, similarity_score }) => ({ id, similarityScore: similarity_score }))
|
||||
}
|
||||
@@ -25,24 +25,23 @@ const findManyAndCount = {
|
||||
},
|
||||
}
|
||||
|
||||
type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount
|
||||
// NOTE: This used to be necessary to cast the prismaClientSingleton return type, but it seems not anymore. I left it, just in case we need it again
|
||||
// type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount
|
||||
|
||||
type ModelsWithCustomMethods = {
|
||||
[Model in keyof PrismaClient]: PrismaClient[Model] extends {
|
||||
findMany: (...args: any[]) => Promise<any>
|
||||
}
|
||||
? PrismaClient[Model] & {
|
||||
findManyAndCount: FindManyAndCountType
|
||||
}
|
||||
: PrismaClient[Model]
|
||||
}
|
||||
// type ModelsWithCustomMethods = {
|
||||
// [Model in keyof PrismaClient]: PrismaClient[Model] extends {
|
||||
// findMany: (...args: any[]) => Promise<any>
|
||||
// }
|
||||
// ? PrismaClient[Model] & {
|
||||
// findManyAndCount: FindManyAndCountType
|
||||
// }
|
||||
// : PrismaClient[Model]
|
||||
// }
|
||||
|
||||
type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
|
||||
// type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
|
||||
|
||||
function prismaClientSingleton(): ExtendedPrismaClient {
|
||||
const prisma = new PrismaClient().$extends(findManyAndCount)
|
||||
|
||||
return prisma as unknown as ExtendedPrismaClient
|
||||
function prismaClientSingleton() {
|
||||
return new PrismaClient().$extends(findManyAndCount)
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -201,6 +201,41 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
|
||||
|
||||
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
|
||||
|
||||
## API
|
||||
|
||||
Access basic service data via our public API.
|
||||
|
||||
**Attribution:** Please credit **KYCnot.me** if you use data from this API.
|
||||
|
||||
### `GET /api/v1/service/[id]`
|
||||
|
||||
Fetches details for a single service.
|
||||
|
||||
- **`[id]`**: Can be a service ID, slug, name, or any registered URL (including .onion/.i2p).
|
||||
|
||||
**Example Requests:**
|
||||
|
||||
```
|
||||
/api/v1/service/bisq
|
||||
/api/v1/service/https://bisq.network
|
||||
```
|
||||
|
||||
**Example Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Bisq",
|
||||
"description": "Decentralized Bitcoin exchange network.",
|
||||
"kycLevel": 0,
|
||||
"categories": ["exchange"],
|
||||
"serviceUrls": ["https://bisq.network/"],
|
||||
"onionUrls": [],
|
||||
"i2pUrls": [],
|
||||
"tosUrls": ["https://bisq.network/terms-of-service/"],
|
||||
"kycnotmeUrl": "https://kycnot.me/service/bisq"
|
||||
}
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
If you like this project, you can **support** it through these methods:
|
||||
|
||||
121
web/src/pages/api/v1/service/[...id].ts
Normal file
121
web/src/pages/api/v1/service/[...id].ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { prisma } from '../../../../lib/prisma'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
const MAX_ID_LENGTH = 2048
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const { id } = params
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: 'ID parameter is missing' }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (id.length > MAX_ID_LENGTH) {
|
||||
return new Response(JSON.stringify({ error: 'ID parameter is too long' }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const orConditions: Prisma.ServiceWhereInput[] = [
|
||||
{ slug: id },
|
||||
{ name: id },
|
||||
{ serviceUrls: { has: id } },
|
||||
{ onionUrls: { has: id } },
|
||||
{ i2pUrls: { has: id } },
|
||||
]
|
||||
|
||||
// Try direct ID lookup first
|
||||
const numericId = parseInt(id, 10)
|
||||
|
||||
if (!isNaN(numericId)) {
|
||||
orConditions.push({ id: numericId })
|
||||
}
|
||||
|
||||
if (id.startsWith('http://') || id.startsWith('https://')) {
|
||||
let alternativeId: string
|
||||
if (id.endsWith('/')) {
|
||||
alternativeId = id.slice(0, -1) // Remove trailing slash
|
||||
} else {
|
||||
alternativeId = id + '/' // Add trailing slash
|
||||
}
|
||||
orConditions.push({ serviceUrls: { has: alternativeId } })
|
||||
orConditions.push({ onionUrls: { has: alternativeId } })
|
||||
orConditions.push({ i2pUrls: { has: alternativeId } })
|
||||
} else {
|
||||
// For non-HTTP/S IDs, check as is (could be a direct onion/i2p address without protocol)
|
||||
orConditions.push({ serviceUrls: { has: id } })
|
||||
orConditions.push({ onionUrls: { has: id } })
|
||||
orConditions.push({ i2pUrls: { has: id } })
|
||||
}
|
||||
|
||||
try {
|
||||
const service = await prisma.service.findFirst({
|
||||
where: {
|
||||
OR: orConditions,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
kycLevel: true,
|
||||
categories: {
|
||||
select: {
|
||||
name: true,
|
||||
slug: true,
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
serviceUrls: true,
|
||||
onionUrls: true,
|
||||
i2pUrls: true,
|
||||
tosUrls: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
return new Response(JSON.stringify({ error: 'Service not found' }), {
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const responseData = {
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
kycLevel: service.kycLevel,
|
||||
categories: service.categories.map((category) => category.slug),
|
||||
serviceUrls: service.serviceUrls,
|
||||
onionUrls: service.onionUrls,
|
||||
i2pUrls: service.i2pUrls,
|
||||
tosUrls: service.tosUrls,
|
||||
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`,
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(responseData), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching service:', error)
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '../constants/verificationStatus'
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
|
||||
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
|
||||
import { parseIntWithFallback } from '../lib/numbers'
|
||||
import { areEqualObjectsWithoutOrder } from '../lib/objects'
|
||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||
@@ -213,7 +214,10 @@ const groupedAttributes = groupBy(
|
||||
'value'
|
||||
)
|
||||
|
||||
const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : null
|
||||
|
||||
const where = {
|
||||
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
|
||||
listedAt: {
|
||||
lte: new Date(),
|
||||
},
|
||||
@@ -243,16 +247,6 @@ const where = {
|
||||
} satisfies Prisma.ServiceWhereInput,
|
||||
]
|
||||
: []),
|
||||
...(filters.q
|
||||
? [
|
||||
{
|
||||
OR: [
|
||||
{ name: { contains: filters.q, mode: 'insensitive' as const } },
|
||||
{ description: { contains: filters.q, mode: 'insensitive' as const } },
|
||||
],
|
||||
} satisfies Prisma.ServiceWhereInput,
|
||||
]
|
||||
: []),
|
||||
...(filters.networks.length
|
||||
? [
|
||||
{
|
||||
@@ -338,33 +332,45 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
[
|
||||
'Unable to load services.',
|
||||
async () => {
|
||||
const [unsortedServices, totalServices] = await prisma.service.findManyAndCount({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
|
||||
(typeof sortOptions)[number]['orderBy']['key'],
|
||||
true
|
||||
>),
|
||||
},
|
||||
})
|
||||
const [unsortedServicesMissingSimilarityScore, totalServices] = await prisma.service.findManyAndCount(
|
||||
{
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
|
||||
(typeof sortOptions)[number]['orderBy']['key'],
|
||||
true
|
||||
>),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const unsortedServices = unsortedServicesMissingSimilarityScore.map((service) => ({
|
||||
...service,
|
||||
similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
|
||||
}))
|
||||
|
||||
const rng = seedrandom(filters['sort-seed'])
|
||||
const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption
|
||||
|
||||
const sortedServices = orderBy(
|
||||
unsortedServices,
|
||||
[selectedSort.orderBy.key, () => rng()],
|
||||
[selectedSort.orderBy.direction, 'asc']
|
||||
[
|
||||
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
|
||||
selectedSort.orderBy.key,
|
||||
() => rng(),
|
||||
],
|
||||
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
|
||||
).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE)
|
||||
|
||||
const unsortedServicesWithInfo = await prisma.service.findMany({
|
||||
const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: sortedServices.map((service) => service.id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
@@ -398,14 +404,20 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
},
|
||||
})
|
||||
|
||||
const unsortedServicesWithInfo = unsortedServicesWithInfoMissingSimilarityScore.map((service) => ({
|
||||
...service,
|
||||
similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
|
||||
}))
|
||||
|
||||
const sortedServicesWithInfo = orderBy(
|
||||
unsortedServicesWithInfo,
|
||||
[
|
||||
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
|
||||
selectedSort.orderBy.key,
|
||||
// Now we can shuffle indeternimistically, because the pagination was already applied
|
||||
() => Math.random(),
|
||||
],
|
||||
[selectedSort.orderBy.direction, 'asc']
|
||||
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
|
||||
)
|
||||
|
||||
return [sortedServicesWithInfo, totalServices] as const
|
||||
|
||||
Reference in New Issue
Block a user