Compare commits

...

3 Commits

Author SHA1 Message Date
pluja
577c524ca2 Release 202505302029 2025-05-30 20:29:01 +00:00
pluja
da12e8de79 add basic API plus minor updates and fixes 2025-05-30 08:17:23 +00:00
pluja
ea40f17d3c Release 202505281348 2025-05-28 13:48:27 +00:00
15 changed files with 371 additions and 191 deletions

View File

@@ -916,7 +916,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
link: 'https://kycnot.me', link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L', picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
}, },
moderator: { moderator: {
name: 'moderator_dev', name: 'moderator_dev',
@@ -928,7 +928,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
link: 'https://kycnot.me', link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L', picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
}, },
verified: { verified: {
name: 'verified_dev', name: 'verified_dev',

View File

@@ -6,12 +6,8 @@ import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction' import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage' import { saveFileLocally } from '../../lib/fileStorage'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { import { separateServiceUrlsByType } from '../../lib/urls'
imageFileSchema, import { imageFileSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../../lib/zodUtils'
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../../lib/zodUtils'
const serviceSchemaBase = z.object({ const serviceSchemaBase = z.object({
id: z.number().int().positive(), id: z.number().int().positive(),
@@ -19,11 +15,10 @@ const serviceSchemaBase = z.object({
.string() .string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens') .regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(), .optional(),
name: z.string().min(1).max(20), name: z.string().min(1).max(40),
description: z.string().min(1), description: z.string().min(1),
serviceUrls: stringListOfUrlsSchemaRequired, allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired, tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: z.coerce.number().int().min(0).max(4), kycLevel: z.coerce.number().int().min(0).max(4),
attributes: z.array(z.coerce.number().int().positive()), attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1), categories: z.array(z.coerce.number().int().positive()).min(1),
@@ -85,13 +80,20 @@ export const adminServiceActions = {
? await saveFileLocally(input.imageFile, input.imageFile.name) ? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined : undefined
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.create({ const service = await prisma.service.create({
data: { data: {
name: input.name, name: input.name,
description: input.description, description: input.description,
serviceUrls: input.serviceUrls, serviceUrls,
tosUrls: input.tosUrls, tosUrls: input.tosUrls,
onionUrls: input.onionUrls, onionUrls,
i2pUrls,
kycLevel: input.kycLevel, kycLevel: input.kycLevel,
verificationStatus: input.verificationStatus, verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary, verificationSummary: input.verificationSummary,
@@ -187,14 +189,21 @@ export const adminServiceActions = {
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId)) const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId)) const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.update({ const service = await prisma.service.update({
where: { id: input.id }, where: { id: input.id },
data: { data: {
name: input.name, name: input.name,
description: input.description, description: input.description,
serviceUrls: input.serviceUrls, serviceUrls,
tosUrls: input.tosUrls, tosUrls: input.tosUrls,
onionUrls: input.onionUrls, onionUrls,
i2pUrls,
kycLevel: input.kycLevel, kycLevel: input.kycLevel,
verificationStatus: input.verificationStatus, verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary, verificationSummary: input.verificationSummary,

View File

@@ -12,14 +12,11 @@ import { formatDistanceStrict } from 'date-fns'
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation' import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
import { defineProtectedAction } from '../lib/defineProtectedAction' import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage' import { saveFileLocally } from '../lib/fileStorage'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { handleHoneypotTrap } from '../lib/honeypot' import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { import { separateServiceUrlsByType } from '../lib/urls'
imageFileSchemaRequired, import { imageFileSchemaRequired, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../lib/zodUtils'
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../lib/zodUtils'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
@@ -33,11 +30,12 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000 export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
const findPossibleDuplicates = async (input: { name: string }) => { 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: { where: {
name: { id: {
contains: input.name, in: matches.map(({ id }) => id),
mode: 'insensitive',
}, },
}, },
select: { select: {
@@ -47,8 +45,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
description: true, description: true,
}, },
}) })
return possibleDuplicates
} }
const serializeExtraNotes = <T extends Record<string, unknown>>( const serializeExtraNotes = <T extends Record<string, unknown>>(
@@ -161,9 +157,8 @@ export const serviceSuggestionActions = {
{ message: 'Slug must be unique, try a different one' } { message: 'Slug must be unique, try a different one' }
), ),
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH), description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
serviceUrls: stringListOfUrlsSchemaRequired, allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired, tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)), kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
attributes: z.array(z.coerce.number().int().positive()), attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1), categories: z.array(z.coerce.number().int().positive()).min(1),
@@ -210,6 +205,12 @@ export const serviceSuggestionActions = {
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name) const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => { const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
const serviceSelect = { const serviceSelect = {
id: true, id: true,
@@ -221,9 +222,10 @@ export const serviceSuggestionActions = {
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
description: input.description, description: input.description,
serviceUrls: input.serviceUrls, serviceUrls,
tosUrls: input.tosUrls, tosUrls: input.tosUrls,
onionUrls: input.onionUrls, onionUrls,
i2pUrls,
kycLevel: input.kycLevel, kycLevel: input.kycLevel,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
imageUrl, imageUrl,

View File

@@ -49,6 +49,7 @@ const {
class={cn( class={cn(
// Check the scam filter when there is a text quey and the user has checked verified and approved // 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]: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 className
)} )}
> >
@@ -80,16 +81,20 @@ const {
)) ))
} }
</select> </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]" /> <Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
Ties randomly sorted Ties randomly sorted
</p> </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> </fieldset>
<!-- Text Search --> <!-- Text Search -->
<fieldset class="mb-6"> <fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500"> <legend class="font-title mb-3 leading-none text-green-500">
<label for="q">Text</label> <label for="q">Name</label>
</legend> </legend>
<input <input
type="text" type="text"

View File

@@ -21,7 +21,6 @@ type Props = HTMLAttributes<'div'> & {
pageSize: number pageSize: number
sortSeed?: string sortSeed?: string
filters: ServicesFiltersObject filters: ServicesFiltersObject
includeScams: boolean
countCommunityOnly: number | null countCommunityOnly: number | null
inlineIcons?: boolean inlineIcons?: boolean
} }
@@ -35,15 +34,12 @@ const {
sortSeed, sortSeed,
class: className, class: className,
filters, filters,
includeScams,
countCommunityOnly, countCommunityOnly,
inlineIcons, inlineIcons,
...divProps ...divProps
} = Astro.props } = Astro.props
const hasScams = const hasScams = filters.verification.includes('VERIFICATION_FAILED')
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
filters.verification.includes('VERIFICATION_FAILED') || includeScams
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED')) const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED') const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')
@@ -75,7 +71,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
/> />
{ {
countCommunityOnly && ( !!countCommunityOnly && (
<> <>
<Button <Button
as="a" as="a"
@@ -196,7 +192,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
inlineIcon={inlineIcons} inlineIcon={inlineIcons}
/> />
)} )}
{countCommunityOnly && ( {!!countCommunityOnly && (
<Button <Button
as="a" as="a"
href={urlIfIncludingCommunity} href={urlIfIncludingCommunity}

View 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 }))
}

View File

@@ -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 = { // type ModelsWithCustomMethods = {
[Model in keyof PrismaClient]: PrismaClient[Model] extends { // [Model in keyof PrismaClient]: PrismaClient[Model] extends {
findMany: (...args: any[]) => Promise<any> // findMany: (...args: any[]) => Promise<any>
} // }
? PrismaClient[Model] & { // ? PrismaClient[Model] & {
findManyAndCount: FindManyAndCountType // findManyAndCount: FindManyAndCountType
} // }
: PrismaClient[Model] // : PrismaClient[Model]
} // }
type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient // type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
function prismaClientSingleton(): ExtendedPrismaClient { function prismaClientSingleton() {
const prisma = new PrismaClient().$extends(findManyAndCount) return new PrismaClient().$extends(findManyAndCount)
return prisma as unknown as ExtendedPrismaClient
} }
declare global { declare global {

View File

@@ -113,3 +113,28 @@ export function urlDomain(url: URL | string) {
} }
return url.origin return url.origin
} }
export function separateServiceUrlsByType(allServiceUrls: string[]) {
const result: {
web: string[]
onion: string[]
i2p: string[]
} = {
web: [],
onion: [],
i2p: [],
}
for (const url of allServiceUrls) {
const parsedUrl = new URL(url)
if (parsedUrl.origin.endsWith('.onion')) {
result.onion.push(url)
} else if (parsedUrl.origin.endsWith('.b32.i2p')) {
result.i2p.push(url)
} else {
result.web.push(url)
}
}
return result
}

View File

@@ -19,7 +19,7 @@ export const zodUrlOptionalProtocol = z.preprocess(
const cleanInput = input.trim().replace(/\/$/, '') const cleanInput = input.trim().replace(/\/$/, '')
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
}, },
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z]{2,})[^\s$.?#].[^\s]*$/i.test(value), { z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
message: 'Invalid URL', message: 'Invalid URL',
}) })
) )

View File

@@ -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. 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 ## Support
If you like this project, you can **support** it through these methods: If you like this project, you can **support** it through these methods:

View File

@@ -233,16 +233,30 @@ if (!service) return Astro.rewrite('/404')
enctype="multipart/form-data" enctype="multipart/form-data"
> >
<input type="hidden" name="id" value={service.id} /> <input type="hidden" name="id" value={service.id} />
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<InputText
label="Slug"
description="Auto-generated if empty"
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
</div>
<InputTextArea <InputTextArea
label="Description" label="Description"
name="description" name="description"
@@ -254,76 +268,44 @@ if (!service) return Astro.rewrite('/404')
error={serviceInputErrors.description} error={serviceInputErrors.description}
/> />
<InputText <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
label="Slug"
description="Auto-generated if empty"
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<InputTextArea <InputTextArea
label="Service URLs" label="Service URLs"
description="One per line" description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="serviceUrls" name="allServiceUrls"
inputProps={{ inputProps={{
rows: 3, placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
placeholder: 'https://example1.com\nhttps://example2.com', class: 'grow min-h-24',
required: true,
}} }}
value={service.serviceUrls.join('\n')} class="row-span-2 flex flex-col self-stretch"
error={serviceInputErrors.serviceUrls} value={[...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].join('\n\n')}
error={serviceInputErrors.allServiceUrls}
/> />
<InputTextArea <InputTextArea
label="ToS URLs" label="ToS URLs"
description="One per line" description="One per line"
name="tosUrls" name="tosUrls"
inputProps={{ inputProps={{
rows: 3,
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos', placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
required: true, required: true,
}} }}
value={service.tosUrls.join('\n')} value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls} error={serviceInputErrors.tosUrls}
/> />
<InputTextArea <InputText
label="Onion URLs" label="Referral link path"
description="One per line" name="referral"
name="onionUrls"
inputProps={{ inputProps={{
rows: 3, value: service.referral,
placeholder: 'http://example1.onion\nhttp://example2.onion', placeholder: 'e.g. ?ref=123 or /ref/123',
}} }}
value={service.onionUrls.join('\n')} error={serviceInputErrors.referral}
error={serviceInputErrors.onionUrls} class="self-end"
/> description="Will be appended to the service URL"
<InputTextArea
label="I2P URLs"
description="One per line"
name="i2pUrls"
inputProps={{
rows: 3,
placeholder: 'http://example1.b32.i2p\nhttp://example2.b32.i2p',
}}
value={service.i2pUrls.join('\n')}
/> />
</div> </div>
<InputText
label="Referral link path"
name="referral"
inputProps={{
value: service.referral,
placeholder: 'e.g. ?ref=123 or /ref/123',
}}
error={serviceInputErrors.referral}
description="Will be appended to the service URL"
/>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<InputImageFile <InputImageFile
label="Image" label="Image"

View File

@@ -74,19 +74,19 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
</div> </div>
<div> <div>
<label for="serviceUrls" class="font-title mb-2 block text-sm text-green-500">serviceUrls</label> <label for="allServiceUrls" class="font-title mb-2 block text-sm text-green-500">Service URLs</label>
<textarea <textarea
transition:persist transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="serviceUrls" name="allServiceUrls"
id="serviceUrls" id="allServiceUrls"
rows={3} rows={3}
placeholder="https://example1.com https://example2.com" placeholder="https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p"
set:text="" set:text=""
/> />
{ {
inputErrors.serviceUrls && ( inputErrors.allServiceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p> <p class="font-title mt-1 text-sm text-red-500">{inputErrors.allServiceUrls.join(', ')}</p>
) )
} }
</div> </div>
@@ -109,24 +109,6 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
} }
</div> </div>
<div>
<label for="onionUrls" class="font-title mb-2 block text-sm text-green-500">onionUrls</label>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="onionUrls"
id="onionUrls"
rows={3}
placeholder="http://example.onion"
set:text=""
/>
{
inputErrors.onionUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.onionUrls.join(', ')}</p>
)
}
</div>
<div> <div>
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label> <label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
<div class="space-y-2"> <div class="space-y-2">

View 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',
},
})
}
}

View File

@@ -26,6 +26,7 @@ import {
} from '../constants/verificationStatus' } from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays' import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { parseIntWithFallback } from '../lib/numbers' import { parseIntWithFallback } from '../lib/numbers'
import { areEqualObjectsWithoutOrder } from '../lib/objects' import { areEqualObjectsWithoutOrder } from '../lib/objects'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
@@ -213,7 +214,10 @@ const groupedAttributes = groupBy(
'value' 'value'
) )
const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : null
const where = { const where = {
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
listedAt: { listedAt: {
lte: new Date(), lte: new Date(),
}, },
@@ -243,16 +247,6 @@ const where = {
} satisfies Prisma.ServiceWhereInput, } 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 ...(filters.networks.length
? [ ? [
{ {
@@ -338,33 +332,45 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
[ [
'Unable to load services.', 'Unable to load services.',
async () => { async () => {
const [unsortedServices, totalServices] = await prisma.service.findManyAndCount({ const [unsortedServicesMissingSimilarityScore, totalServices] = await prisma.service.findManyAndCount(
where, {
select: { where,
id: true, select: {
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record< id: true,
(typeof sortOptions)[number]['orderBy']['key'], ...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
true (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 rng = seedrandom(filters['sort-seed'])
const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption
const sortedServices = orderBy( const sortedServices = orderBy(
unsortedServices, 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) ).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE)
const unsortedServicesWithInfo = await prisma.service.findMany({ const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({
where: { where: {
id: { id: {
in: sortedServices.map((service) => service.id), in: sortedServices.map((service) => service.id),
}, },
}, },
select: { select: {
id: true,
name: true, name: true,
slug: true, slug: true,
description: 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( const sortedServicesWithInfo = orderBy(
unsortedServicesWithInfo, unsortedServicesWithInfo,
[ [
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
selectedSort.orderBy.key, selectedSort.orderBy.key,
// Now we can shuffle indeternimistically, because the pagination was already applied // Now we can shuffle indeternimistically, because the pagination was already applied
() => Math.random(), () => Math.random(),
], ],
[selectedSort.orderBy.direction, 'asc'] [...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
) )
return [sortedServicesWithInfo, totalServices] as const return [sortedServicesWithInfo, totalServices] as const
@@ -712,7 +724,6 @@ const showFiltersId = 'show-filters'
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
sortSeed={filters['sort-seed']} sortSeed={filters['sort-seed']}
filters={filters} filters={filters}
includeScams={includeScams}
countCommunityOnly={countCommunityOnly} countCommunityOnly={countCommunityOnly}
inlineIcons inlineIcons
/> />

View File

@@ -201,34 +201,31 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
error={inputErrors.description} error={inputErrors.description}
/> />
<InputTextArea <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
label="Service URLs" <InputTextArea
name="serviceUrls" label="Service URLs"
inputProps={{ description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
required: true, name="allServiceUrls"
placeholder: 'https://example1.com\nhttps://example2.org', inputProps={{
}} placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
error={inputErrors.serviceUrls} class: 'min-h-24',
/> required: true,
}}
<InputTextArea class="row-span-2 flex flex-col self-stretch"
label="Terms of Service URLs" error={inputErrors.allServiceUrls}
name="tosUrls" />
inputProps={{ <InputTextArea
required: true, label="ToS URLs"
placeholder: 'https://example1.com/tos\nhttps://example2.org/terms', description="One per line"
}} name="tosUrls"
error={inputErrors.tosUrls} inputProps={{
/> placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
class: 'md:min-h-24',
<InputTextArea required: true,
label="Onion URLs" }}
name="onionUrls" error={inputErrors.tosUrls}
inputProps={{ />
placeholder: 'http://example1.onion\nhttp://example2.onion', </div>
}}
error={inputErrors.onionUrls}
/>
<InputCardGroup <InputCardGroup
name="kycLevel" name="kycLevel"