Compare commits

...

5 Commits

Author SHA1 Message Date
pluja
22944fcdb3 Release 202505310921 2025-05-31 09:21:32 +00:00
pluja
f7f380c591 Release 202505302056 2025-05-30 20:56:04 +00:00
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
26 changed files with 561 additions and 200 deletions

View File

@@ -0,0 +1,2 @@
-- Enable pg_trgm extension for similarity functions
CREATE EXTENSION IF NOT EXISTS pg_trgm;

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

@@ -0,0 +1,5 @@
import { apiServiceActions } from './service'
export const apiActions = {
service: apiServiceActions,
}

View File

@@ -0,0 +1,113 @@
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { pick } from 'lodash-es'
import { getKycLevelInfo } from '../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../constants/verificationStatus'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import { zodUrlOptionalProtocol } from '../../lib/zodUtils'
import type { Prisma } from '@prisma/client'
export const apiServiceActions = {
get: defineProtectedAction({
accept: 'json',
permissions: 'guest',
input: z.object({
id: z.coerce.number().int().positive().optional(),
slug: z
.string()
.min(1)
.max(2048)
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
url: zodUrlOptionalProtocol.optional(),
}),
handler: async (input) => {
if (!input.id && !input.slug && !input.url) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'At least one of the following parameters is required: id, slug, url',
})
}
const urlVariants = input.url
? [input.url]
.flatMap((url) =>
[
url,
url.startsWith('http://') ? url.replace('http://', 'https://') : undefined,
url.startsWith('https://') ? url.replace('https://', 'http://') : undefined,
].filter((url) => url !== undefined)
)
.flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`])
: undefined
const service = await prisma.service.findFirst({
where: {
OR: [
...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []),
...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []),
...(urlVariants
? ([
{ serviceUrls: { hasSome: urlVariants } },
{ onionUrls: { hasSome: urlVariants } },
{ i2pUrls: { hasSome: urlVariants } },
] satisfies Prisma.ServiceWhereInput[])
: []),
],
},
select: {
id: true,
name: true,
slug: true,
description: true,
kycLevel: true,
verificationStatus: true,
categories: {
select: {
name: true,
slug: true,
},
},
serviceUrls: true,
onionUrls: true,
i2pUrls: true,
tosUrls: true,
referral: true,
},
})
if (!service) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
})
}
return {
id: service.id,
slug: service.slug,
name: service.name,
description: service.description,
verificationStatus: service.verificationStatus,
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
'value',
'slug',
'label',
'labelShort',
'description',
]),
kycLevel: service.kycLevel,
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
categories: service.categories,
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
(url) => url + (service.referral ?? '')
),
tosUrls: service.tosUrls,
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`,
}
},
}),
}

View File

@@ -1,5 +1,6 @@
import { accountActions } from './account' import { accountActions } from './account'
import { adminActions } from './admin' import { adminActions } from './admin'
import { apiActions } from './api'
import { commentActions } from './comment' import { commentActions } from './comment'
import { notificationActions } from './notifications' import { notificationActions } from './notifications'
import { serviceActions } from './service' import { serviceActions } from './service'
@@ -19,6 +20,7 @@ import { serviceSuggestionActions } from './serviceSuggestion'
export const server = { export const server = {
account: accountActions, account: accountActions,
admin: adminActions, admin: adminActions,
api: apiActions,
comment: commentActions, comment: commentActions,
notification: notificationActions, notification: notificationActions,
service: serviceActions, service: serviceActions,

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}

32
web/src/lib/endpoints.ts Normal file
View File

@@ -0,0 +1,32 @@
import { type ActionClient } from 'astro:actions'
import type { APIRoute } from 'astro'
import type { z } from 'astro/zod'
export function makeEndpointFromAction<Action extends ActionClient<unknown, 'json', z.ZodType> & string>(
action: Action
): APIRoute {
return async (context) => {
try {
const input = await context.request.json()
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const result = await context.callAction(action, input)
if (result.error) {
console.error('Error on endpoint', result.error)
return new Response(JSON.stringify({ error: result.error.message }), {
status: result.error.status,
})
}
return new Response(JSON.stringify(result.data), {
status: 200,
})
} catch (error) {
console.error('Error on endpoint', error)
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
})
}
}
}

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,12 @@ 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
You can access basic service data via our public API.
See the [API page](/docs/api) for more details.
## 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

@@ -53,7 +53,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
disabled: !user.karmaUnlocks.displayName, disabled: !user.karmaUnlocks.displayName,
}} }}
description={!user.karmaUnlocks.displayName description={!user.karmaUnlocks.displayName
? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/karma)` ? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/docs/karma)`
: undefined} : undefined}
/> />
@@ -68,7 +68,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
disabled: !user.karmaUnlocks.websiteLink, disabled: !user.karmaUnlocks.websiteLink,
}} }}
description={!user.karmaUnlocks.websiteLink description={!user.karmaUnlocks.websiteLink
? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/karma)` ? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/docs/karma)`
: undefined} : undefined}
/> />
@@ -80,7 +80,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
square square
disabled={!user.karmaUnlocks.profilePicture} disabled={!user.karmaUnlocks.profilePicture}
description={!user.karmaUnlocks.profilePicture description={!user.karmaUnlocks.profilePicture
? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/karma)` ? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/docs/karma)`
: undefined} : undefined}
removeCheckbox={user.picture removeCheckbox={user.picture
? { ? {

View File

@@ -529,8 +529,9 @@ if (!user) return Astro.rewrite('/404')
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3"> <div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
<p class="text-day-300"> <p class="text-day-300">
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline" Earn karma to unlock features and privileges. <a
>Learn about karma</a href="/docs/karma"
class="text-day-200 hover:underline">Learn about karma</a
> >
</p> </p>
</div> </div>

View File

@@ -233,6 +233,8 @@ 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} />
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<InputText <InputText
label="Name" label="Name"
name="name" name="name"
@@ -243,17 +245,6 @@ if (!service) return Astro.rewrite('/404')
error={serviceInputErrors.name} error={serviceInputErrors.name}
/> />
<InputTextArea
label="Description"
name="description"
inputProps={{
required: true,
rows: 4,
}}
value={service.description}
error={serviceInputErrors.description}
/>
<InputText <InputText
label="Slug" label="Slug"
description="Auto-generated if empty" description="Auto-generated if empty"
@@ -265,54 +256,43 @@ if (!service) return Astro.rewrite('/404')
error={serviceInputErrors.slug} error={serviceInputErrors.slug}
class="font-title" class="font-title"
/> />
</div>
<InputTextArea
label="Description"
name="description"
inputProps={{
required: true,
rows: 4,
}}
value={service.description}
error={serviceInputErrors.description}
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 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
label="Onion URLs"
description="One per line"
name="onionUrls"
inputProps={{
rows: 3,
placeholder: 'http://example1.onion\nhttp://example2.onion',
}}
value={service.onionUrls.join('\n')}
error={serviceInputErrors.onionUrls}
/>
<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>
<InputText <InputText
label="Referral link path" label="Referral link path"
name="referral" name="referral"
@@ -321,8 +301,10 @@ if (!service) return Astro.rewrite('/404')
placeholder: 'e.g. ?ref=123 or /ref/123', placeholder: 'e.g. ?ref=123 or /ref/123',
}} }}
error={serviceInputErrors.referral} error={serviceInputErrors.referral}
class="self-end"
description="Will be appended to the service URL" description="Will be appended to the service URL"
/> />
</div>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<InputImageFile <InputImageFile

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,13 @@
import type { APIRoute } from 'astro'
export const ALL: APIRoute = (context) => {
console.error('Endpoint not found', { url: context.url.href, method: context.request.method })
return new Response(
JSON.stringify({
error: 'Endpoint not found',
}),
{
status: 404,
}
)
}

View File

@@ -0,0 +1,7 @@
import { actions } from 'astro:actions'
import { makeEndpointFromAction } from '../../../../lib/endpoints'
import type { APIRoute } from 'astro'
export const QUERY: APIRoute = makeEndpointFromAction(actions.api.service.get)

155
web/src/pages/docs/api.mdx Normal file
View File

@@ -0,0 +1,155 @@
---
layout: ../../layouts/MarkdownLayout.astro
title: API
author: KYCnot.me
pubDate: 2025-05-31
description: 'Access basic service data via our public API.'
icon: 'ri:plug-line'
---
import { SOURCE_CODE_URL } from 'astro:env/server'
import { kycLevels } from '../../constants/kycLevels'
import { verificationStatuses } from '../../constants/verificationStatus'
Access basic service data via our public API.
All endpoints should be prefixed with `/api/v1/`.
The endpoints <a href={SOURCE_CODE_URL}>source code</a> is available on the `/web/src/actions/api/index.ts` file.
**Attribution:** Please credit **KYCnot.me** if you use data from this API.
## `QUERY` `/service/get`
Fetches details for a single service by various lookup criteria.
### Request Parameters
| Parameter | Type | Required | Description |
| ------------ | ------ | -------- | ----------- |
| `id` | number | No* | Service ID |
| `slug` | string | No* | Service URL slug (lowercase letters, numbers, and hyphens only) |
| `serviceUrl` | string | No* | Service URL. May be web, onion, or i2p. May just be a domain or a full URL. |
\* At least one of the marked parameters is required.
### Response Format
```typescript
type ServiceResponse = {
id: number
slug: string
name: string
description: string
verificationStatus: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
verificationStatusInfo: {
value: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
slug: string
label: string
labelShort: string
description: string
}
kycLevel: 0 | 1 | 2 | 3 | 4
kycLevelInfo: {
value: 0 | 1 | 2 | 3 | 4
name: string
description: string
}
categories: {
name: string
slug: string
}[]
serviceUrls: string[]
tosUrls: string[]
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`
}
```
### KYC Levels
<ul>
{kycLevels.map((level) => (
<li key={level.id}>
<strong>{level.id}</strong>: {level.name} - {level.description}
</li>
))}
</ul>
### Verification Status
<ul>
{verificationStatuses.map((status) => (
<li key={status.value}>
<strong>{status.value}</strong>: {status.description}
</li>
))}
</ul>
### Example Request
```zsh
curl -X QUERY https://kycnot.me/api/v1/service/get \
-H "Content-Type: application/json" \
-d '{"slug": "my-example-service"}'
```
### Example Response
```json
{
"name": "My Example Service",
"description": "This is a description of my example service",
"verificationStatus": "VERIFICATION_SUCCESS",
"verificationStatusInfo": {
"value": "VERIFICATION_SUCCESS",
"slug": "verified",
"label": "Verified",
"labelShort": "Verified",
"description": "Thoroughly tested and verified by the team. But things might change, this is not a guarantee."
},
"kycLevel": 0,
"kycLevelInfo": {
"value": 0,
"name": "Guaranteed no KYC",
"description": "Terms explicitly state KYC will never be requested."
},
"categories": [
{
"name": "Exchange",
"slug": "exchange"
}
],
"serviceUrls": [
"https://example.com",
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"
],
"tosUrls": ["https://example.com/terms-of-service"],
"kycnotmeUrl": "https://kycnot.me/service/bisq"
}
```
### Error Responses
**404 Not Found**: Service not found
```json
{
"error": "Service not found"
}
```
**400 Bad Request**: Invalid input parameters
```json
{
"error": "Validation error message"
}
```
**500 Internal Server Error**: Server error
```json
{
"error": "Internal server error"
}
```

View File

@@ -1,5 +1,5 @@
--- ---
layout: ../layouts/MarkdownLayout.astro layout: ../../layouts/MarkdownLayout.astro
title: How does karma work? title: How does karma work?
description: "KYCnot.me has a user karma system, here's how it works" description: "KYCnot.me has a user karma system, here's how it works"
icon: 'ri:hearts-line' icon: 'ri:hearts-line'
@@ -7,7 +7,7 @@ author: KYCnot.me
pubDate: 2025-05-15 pubDate: 2025-05-15
--- ---
import KarmaUnlocksTable from '../components/KarmaUnlocksTable.astro' import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'
[KYCnot.me](https://kycnot.me) implements a karma system to encourage quality contributions and maintain community standards. Users can earn (or lose) karma points through various interactions on the platform, primarily through their comments on services. [KYCnot.me](https://kycnot.me) implements a karma system to encourage quality contributions and maintain community standards. Users can earn (or lose) karma points through various interactions on the platform, primarily through their comments on services.

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,7 +332,8 @@ 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, where,
select: { select: {
id: true, id: true,
@@ -347,24 +342,35 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
true 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}
/> />
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputTextArea <InputTextArea
label="Service URLs" label="Service URLs"
name="serviceUrls" description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="allServiceUrls"
inputProps={{ inputProps={{
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
class: 'min-h-24',
required: true, required: true,
placeholder: 'https://example1.com\nhttps://example2.org',
}} }}
error={inputErrors.serviceUrls} class="row-span-2 flex flex-col self-stretch"
error={inputErrors.allServiceUrls}
/> />
<InputTextArea <InputTextArea
label="Terms of Service URLs" label="ToS URLs"
description="One per line"
name="tosUrls" name="tosUrls"
inputProps={{ inputProps={{
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
class: 'md:min-h-24',
required: true, required: true,
placeholder: 'https://example1.com/tos\nhttps://example2.org/terms',
}} }}
error={inputErrors.tosUrls} error={inputErrors.tosUrls}
/> />
</div>
<InputTextArea
label="Onion URLs"
name="onionUrls"
inputProps={{
placeholder: 'http://example1.onion\nhttp://example2.onion',
}}
error={inputErrors.onionUrls}
/>
<InputCardGroup <InputCardGroup
name="kycLevel" name="kycLevel"

View File

@@ -647,8 +647,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3"> <div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
<p class="text-day-300"> <p class="text-day-300">
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline" Earn karma to unlock features and privileges. <a
>Learn about karma</a href="/docs/karma"
class="text-day-200 hover:underline">Learn about karma</a
> >
</p> </p>
</div> </div>