Compare commits

...

6 Commits

Author SHA1 Message Date
pluja
e17bc8a521 Release 202505312236 2025-05-31 22:36:39 +00:00
pluja
ec1215f2ae Release 202505311848 2025-05-31 18:48:33 +00:00
pluja
3afa824c18 Release 202505311149 2025-05-31 11:49:38 +00:00
pluja
9a68112e24 Release 202505311113 2025-05-31 11:13:24 +00:00
pluja
0c40d8eec5 Release 202505311002 2025-05-31 10:02:50 +00:00
pluja
e16c9b64ed Release 202505311001 2025-05-31 10:01:35 +00:00
20 changed files with 322 additions and 82 deletions

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "previousSlugs" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- CreateIndex
CREATE INDEX "Service_previousSlugs_idx" ON "Service"("previousSlugs");

View File

@@ -336,6 +336,7 @@ model Service {
id Int @id @default(autoincrement())
name String
slug String @unique
previousSlugs String[] @default([])
description String
categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4)
@@ -396,6 +397,7 @@ model Service {
@@index([createdAt])
@@index([updatedAt])
@@index([slug])
@@index([previousSlugs])
}
model ServiceContactMethod {

View File

@@ -612,6 +612,7 @@ const generateFakeService = (users: User[]) => {
return {
name,
slug,
previousSlugs: faker.helpers.maybe(() => [`${slug}-old`], { probability: 0.5 }),
description: faker.helpers.arrayElement(serviceDescriptions),
kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)),
overallScore: 0,

View File

@@ -1,6 +1,7 @@
import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { uniq } from 'lodash-es'
import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
@@ -164,11 +165,22 @@ export const adminServiceActions = {
const existingService = await prisma.service.findUnique({
where: { id: input.id },
include: {
categories: true,
select: {
slug: true,
previousSlugs: true,
categories: {
select: {
id: true,
},
},
attributes: {
include: {
attribute: true,
select: {
attributeId: true,
attribute: {
select: {
id: true,
},
},
},
},
},
@@ -213,6 +225,14 @@ export const adminServiceActions = {
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,
previousSlugs:
existingService.slug !== input.slug
? {
set: uniq([...existingService.previousSlugs, existingService.slug]).filter(
(slug) => slug !== input.slug
),
}
: undefined,
imageUrl,
categories: {

View File

@@ -24,7 +24,7 @@ export const apiServiceActions = {
.optional(),
url: zodUrlOptionalProtocol.optional(),
}),
handler: async (input) => {
handler: async (input, context) => {
if (!input.id && !input.slug && !input.url) {
throw new ActionError({
code: 'BAD_REQUEST',
@@ -44,8 +44,34 @@ export const apiServiceActions = {
.flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`])
: undefined
const service = await prisma.service.findFirst({
const 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,
listedAt: true,
verifiedAt: true,
serviceVisibility: true,
} as const satisfies Prisma.ServiceSelect
let service = await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
OR: [
...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []),
...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []),
@@ -58,28 +84,29 @@ export const apiServiceActions = {
: []),
],
},
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,
},
select,
})
if (!service) {
if (!service && input.slug) {
service = await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
previousSlugs: { has: input.slug },
},
select,
})
}
if (
!service ||
(service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'ARCHIVED' &&
service.serviceVisibility !== 'UNLISTED') ||
!service.listedAt ||
service.listedAt > new Date()
) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
@@ -91,6 +118,7 @@ export const apiServiceActions = {
slug: service.slug,
name: service.name,
description: service.description,
serviceVisibility: service.serviceVisibility,
verificationStatus: service.verificationStatus,
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
'value',
@@ -99,14 +127,16 @@ export const apiServiceActions = {
'labelShort',
'description',
]),
verifiedAt: service.verifiedAt,
kycLevel: service.kycLevel,
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
categories: service.categories,
listedAt: service.listedAt,
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
(url) => url + (service.referral ?? '')
),
tosUrls: service.tosUrls,
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`,
kycnotmeUrl: new URL(`/service/${service.slug}`, context.url).href,
}
},
}),

View File

@@ -1,10 +1,4 @@
import {
Currency,
ServiceSuggestionStatus,
ServiceSuggestionType,
ServiceVisibility,
VerificationStatus,
} from '@prisma/client'
import { Currency } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { formatDistanceStrict } from 'date-fns'
@@ -118,9 +112,9 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await prisma.serviceSuggestion.create({
data: {
type: ServiceSuggestionType.EDIT_SERVICE,
type: 'EDIT_SERVICE',
notes: combinedNotes,
status: ServiceSuggestionStatus.PENDING,
status: 'PENDING',
userId: context.locals.user.id,
serviceId: service.id,
},
@@ -229,12 +223,12 @@ export const serviceSuggestionActions = {
kycLevel: input.kycLevel,
acceptedCurrencies: input.acceptedCurrencies,
imageUrl,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
verificationStatus: 'COMMUNITY_CONTRIBUTED',
overallScore: 0,
privacyScore: 0,
trustScore: 0,
listedAt: new Date(),
serviceVisibility: ServiceVisibility.UNLISTED,
serviceVisibility: 'UNLISTED',
categories: {
connect: input.categories.map((id) => ({ id })),
},
@@ -250,8 +244,8 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await tx.serviceSuggestion.create({
data: {
notes: input.notes,
type: ServiceSuggestionType.CREATE_SERVICE,
status: ServiceSuggestionStatus.PENDING,
type: 'CREATE_SERVICE',
status: 'PENDING',
userId: context.locals.user.id,
serviceId: service.id,
},

View File

@@ -102,7 +102,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{...htmlProps}
id={`comment-${comment.id.toString()}`}
class={cn([
'group',
'group bg-night-700',
depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4',
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
'bg-[#182a1f]',
@@ -270,12 +270,6 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall icon="ri:alert-fill" color="yellow" text="Reported" inlineIcon />
)
}
{
comment.rating !== null && !comment.parentId && (
<Tooltip
@@ -320,6 +314,19 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
color={commentStatusById.REJECTED.color}
text={commentStatusById.REJECTED.label}
inlineIcon
endIcon="ri:lock-line"
/>
)
}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall
icon="ri:alert-fill"
color="yellow"
text="Needs admin review"
inlineIcon
endIcon="ri:lock-line"
/>
)
}

View File

@@ -89,7 +89,7 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'}
{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
</button>
<button

View File

@@ -92,7 +92,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
<div class="flex flex-wrap gap-4">
<InputRating name="rating" label="Rating" />
<InputWrapper label="Tags" name="tags">
<InputWrapper label="I experienced..." name="tags">
<label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
<span class="flex items-center gap-1 text-xs text-red-400">

View File

@@ -27,6 +27,12 @@ const links = [
icon: 'i2p',
external: true,
},
{
href: '/docs/api',
label: 'API',
icon: 'ri:plug-line',
external: false,
},
{
href: '/about',
label: 'About',

View File

@@ -87,6 +87,25 @@ function makeLink(url: string, referral: string | null) {
}
}
const bitcointalkMatch = /^(?:https?:\/\/)?(?:www\.)?bitcointalk\.org$/.exec(hostname)
if (bitcointalkMatch) {
return {
type: 'clearnet' as const,
url: urlWithReferral,
textBits: [
{
style: 'normal',
text: 'BitcoinTalk ',
},
{
style: 'irrelevant',
text: 'thread',
},
],
icon: networksBySlug.clearnet.icon,
}
}
return {
type: 'clearnet' as const,
url: urlWithReferral,

View File

@@ -65,7 +65,7 @@ export const {
value: 'ARCHIVED',
slug: 'archived',
label: 'Archived',
description: 'No longer operational',
description: 'No longer operational.',
longDescription:
'Archived service, no longer exists or ceased operations. Information may be outdated.',
icon: 'ri:archive-line',

View File

@@ -114,7 +114,13 @@ export class ErrorBanners {
return result
} catch (error) {
this.handler(uiMessage)(error)
return fallback as F
return fallback as F extends never[]
? T extends [infer _First, ...infer _Rest]
? []
: T extends unknown[]
? T[number][]
: F
: F
}
}

View File

@@ -0,0 +1,50 @@
import type { Misc } from 'ts-toolbelt'
export async function makeAdminApiCallInfo<T extends Misc.JSON.Object>({
method,
path,
input,
baseUrl,
}: {
method: 'POST' | 'QUERY'
path: `/${string}`
input: T
baseUrl: URL | string
}) {
const fullPath = new URL(`/api/v1${path}`, baseUrl).href
const fetchProsmise = fetch(fullPath, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}).then((res) => {
try {
return res.json() as Promise<Misc.JSON.Value>
} catch (errJson: unknown) {
console.error(errJson)
try {
return res.text()
} catch (errText: unknown) {
console.error(errText)
return ''
}
}
})
let output: Misc.JSON.Value = ''
try {
output = await fetchProsmise
} catch (err: unknown) {
console.error(err)
output = err instanceof Error ? err.message : String(err)
}
return {
method,
path,
fullPath,
input,
output,
}
}

View File

@@ -1,8 +1,8 @@
---
import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server'
import TimeFormatted from '../../components/TimeFormatted.astro'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { timeAgo } from '../../lib/timeAgo'
const releaseDate =
RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined
@@ -37,7 +37,7 @@ const releaseDate =
{
!!releaseDate && (
<p class="text-day-500 mt-2">
(<TimeFormatted date={releaseDate} hourPrecision daysUntilDate={Infinity} />)
(<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>)
</p>
)
}

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { actions, isInputError } from 'astro:actions'
import { Code } from 'astro:components'
import { orderBy } from 'lodash-es'
import BadgeSmall from '../../../../components/BadgeSmall.astro'
@@ -33,6 +34,8 @@ import {
verificationStepStatuses,
} from '../../../../constants/verificationStepStatus'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables'
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
import { pluralize } from '../../../../lib/pluralize'
import { prisma } from '../../../../lib/prisma'
@@ -180,7 +183,35 @@ const [service, categories, attributes] = await Astro.locals.banners.tryMany([
],
])
if (!service) return Astro.rewrite('/404')
if (!service) {
try {
const serviceWithOldSlug = await prisma.service.findFirst({
where: { previousSlugs: { has: slug } },
select: { slug: true },
})
if (serviceWithOldSlug) {
return Astro.redirect(`/admin/services/${serviceWithOldSlug.slug}/edit`, 301)
}
} catch (error) {
console.error(error)
}
return Astro.rewrite('/404')
}
const apiCalls = await Astro.locals.banners.try(
'Error fetching api calls',
() =>
Promise.all([
makeAdminApiCallInfo({
method: 'QUERY',
path: '/service/get',
input: { slug: service.slug },
baseUrl: Astro.url,
}),
]),
[]
)
---
<BaseLayout pageTitle={`Edit Service: ${service.name}`}>
@@ -247,7 +278,9 @@ if (!service) return Astro.rewrite('/404')
<InputText
label="Slug"
description="Auto-generated if empty"
description={`Auto-generated if empty. ${
service.previousSlugs.length > 0 ? `Old slugs: ${service.previousSlugs.join(', ')}` : ''
}`}
name="slug"
inputProps={{
value: service.slug,
@@ -1100,5 +1133,27 @@ if (!service) return Astro.rewrite('/404')
</form>
</FormSubSection>
</FormSection>
<FormSection title="API">
{
DEPLOYMENT_MODE === 'staging' && (
<p class="rounded-lg bg-red-900/30 p-4 text-sm text-red-200">
<Icon name="ri:alert-line" class="inline-block size-4 align-[-0.2em] text-red-400" />
This endpoints section doesn't work in PRE. Use curl commands instead.
</p>
)
}
{
apiCalls.map((call) => (
<FormSubSection title={`${call.method} ${call.path}`}>
<p class="text-day-400 text-sm">Input:</p>
<Code code={JSON.stringify(call.input, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
<p class="text-day-400 text-sm">Output:</p>
<Code code={JSON.stringify(call.output, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
</FormSubSection>
))
}
</FormSection>
</div>
</BaseLayout>

View File

@@ -10,6 +10,7 @@ icon: 'ri:plug-line'
import { SOURCE_CODE_URL } from 'astro:env/server'
import { kycLevels } from '../../constants/kycLevels'
import { verificationStatuses } from '../../constants/verificationStatus'
import { serviceVisibilities } from '../../constants/serviceVisibility'
Access basic service data via our public API.
@@ -41,6 +42,7 @@ type ServiceResponse = {
slug: string
name: string
description: string
serviceVisibility: 'PUBLIC' | 'ARCHIVED' | 'UNLISTED'
verificationStatus: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
verificationStatusInfo: {
value: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
@@ -49,6 +51,7 @@ type ServiceResponse = {
labelShort: string
description: string
}
verifiedAt: Date | null
kycLevel: 0 | 1 | 2 | 3 | 4
kycLevelInfo: {
value: 0 | 1 | 2 | 3 | 4
@@ -59,13 +62,14 @@ type ServiceResponse = {
name: string
slug: string
}[]
listedAt: Date
serviceUrls: string[]
tosUrls: string[]
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`
}
```
### KYC Levels
#### KYC Levels
<ul>
{kycLevels.map((level) => (
@@ -75,7 +79,7 @@ type ServiceResponse = {
))}
</ul>
### Verification Status
#### Verification Status
<ul>
{verificationStatuses.map((status) => (
@@ -85,7 +89,19 @@ type ServiceResponse = {
))}
</ul>
### Example Request
#### Service Visibility
<ul>
{serviceVisibilities.filter((visibility) => visibility.value === 'PUBLIC' || visibility.value === 'ARCHIVED' || visibility.value === 'UNLISTED').map((visibility) => (
<li key={visibility.value}>
<strong>{visibility.value}</strong>: {visibility.longDescription}
</li>
))}
</ul>
### Examples
#### Request
```zsh
curl -X QUERY https://kycnot.me/api/v1/service/get \
@@ -93,12 +109,13 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
-d '{"slug": "my-example-service"}'
```
### Example Response
#### Response
```json
{
"name": "My Example Service",
"description": "This is a description of my example service",
"serviceVisibility": "PUBLIC",
"verificationStatus": "VERIFICATION_SUCCESS",
"verificationStatusInfo": {
"value": "VERIFICATION_SUCCESS",
@@ -107,6 +124,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
"labelShort": "Verified",
"description": "Thoroughly tested and verified by the team. But things might change, this is not a guarantee."
},
"verifiedAt": "2025-01-20T07:12:29.393Z",
"kycLevel": 0,
"kycLevelInfo": {
"value": 0,
@@ -119,6 +137,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
"slug": "exchange"
}
],
"listedAt": "2025-05-31T19:09:18.043Z",
"serviceUrls": [
"https://example.com",
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"
@@ -128,7 +147,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
}
```
### Error Responses
#### Error Responses
**404 Not Found**: Service not found

View File

@@ -218,16 +218,12 @@ const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : n
const where = {
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
listedAt: {
lte: new Date(),
},
listedAt: { lte: new Date() },
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
verificationStatus: {
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
},
serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED'],
},
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
overallScore: { gte: filters['min-score'] },
acceptedCurrencies: filters.currencies.length
? filters['currency-mode'] === 'and'
@@ -319,9 +315,8 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
select: {
services: {
where: {
serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED'],
},
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
listedAt: { lte: new Date() },
},
},
},

View File

@@ -67,7 +67,11 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
'Error fetching service',
async () =>
prisma.service.findUnique({
where: { slug },
where: {
slug,
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
listedAt: { lte: new Date() },
},
select: {
id: true,
slug: true,
@@ -219,6 +223,34 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
],
])
if (!service) {
try {
const serviceWithOldSlug = await prisma.service.findFirst({
where: {
previousSlugs: { has: slug },
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
listedAt: { lte: new Date() },
},
select: { slug: true },
})
if (serviceWithOldSlug) {
return Astro.redirect(`/service/${serviceWithOldSlug.slug}`, 301)
}
} catch (error) {
console.error(error)
}
return Astro.rewrite('/404')
}
if (
service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'UNLISTED' &&
service.serviceVisibility !== 'ARCHIVED'
) {
return Astro.rewrite('/404')
}
const makeWatchingDetails = (
dbNotificationPreferences: Prisma.NotificationPreferencesGetPayload<{
select: {
@@ -254,17 +286,7 @@ const makeWatchingDetails = (
} as const
}
const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service?.id)
if (!service) return Astro.rewrite('/404')
if (
service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'UNLISTED' &&
service.serviceVisibility !== 'ARCHIVED'
) {
return Astro.rewrite('/404')
}
const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service.id)
const statusIcon = {
...verificationStatusesByValue,

View File

@@ -94,7 +94,16 @@ const user = await Astro.locals.banners.try('user', async () => {
},
},
},
where: { service: { serviceVisibility: 'PUBLIC' } },
where: {
service: {
listedAt: {
lte: new Date(),
},
serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED'],
},
},
},
orderBy: { createdAt: 'desc' },
take: 5,
},