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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export const apiServiceActions = {
.optional(), .optional(),
url: zodUrlOptionalProtocol.optional(), url: zodUrlOptionalProtocol.optional(),
}), }),
handler: async (input) => { handler: async (input, context) => {
if (!input.id && !input.slug && !input.url) { if (!input.id && !input.slug && !input.url) {
throw new ActionError({ throw new ActionError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
@@ -44,8 +44,34 @@ export const apiServiceActions = {
.flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`]) .flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`])
: undefined : 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: { where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
OR: [ OR: [
...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []), ...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []),
...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []), ...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []),
@@ -58,28 +84,29 @@ export const apiServiceActions = {
: []), : []),
], ],
}, },
select: { 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) { 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({ throw new ActionError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'Service not found', message: 'Service not found',
@@ -91,6 +118,7 @@ export const apiServiceActions = {
slug: service.slug, slug: service.slug,
name: service.name, name: service.name,
description: service.description, description: service.description,
serviceVisibility: service.serviceVisibility,
verificationStatus: service.verificationStatus, verificationStatus: service.verificationStatus,
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [ verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
'value', 'value',
@@ -99,14 +127,16 @@ export const apiServiceActions = {
'labelShort', 'labelShort',
'description', 'description',
]), ]),
verifiedAt: service.verifiedAt,
kycLevel: service.kycLevel, kycLevel: service.kycLevel,
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']), kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
categories: service.categories, categories: service.categories,
listedAt: service.listedAt,
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map( serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
(url) => url + (service.referral ?? '') (url) => url + (service.referral ?? '')
), ),
tosUrls: service.tosUrls, 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 { import { Currency } from '@prisma/client'
Currency,
ServiceSuggestionStatus,
ServiceSuggestionType,
ServiceVisibility,
VerificationStatus,
} from '@prisma/client'
import { z } from 'astro/zod' import { z } from 'astro/zod'
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { formatDistanceStrict } from 'date-fns' import { formatDistanceStrict } from 'date-fns'
@@ -118,9 +112,9 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await prisma.serviceSuggestion.create({ const serviceSuggestion = await prisma.serviceSuggestion.create({
data: { data: {
type: ServiceSuggestionType.EDIT_SERVICE, type: 'EDIT_SERVICE',
notes: combinedNotes, notes: combinedNotes,
status: ServiceSuggestionStatus.PENDING, status: 'PENDING',
userId: context.locals.user.id, userId: context.locals.user.id,
serviceId: service.id, serviceId: service.id,
}, },
@@ -229,12 +223,12 @@ export const serviceSuggestionActions = {
kycLevel: input.kycLevel, kycLevel: input.kycLevel,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
imageUrl, imageUrl,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED, verificationStatus: 'COMMUNITY_CONTRIBUTED',
overallScore: 0, overallScore: 0,
privacyScore: 0, privacyScore: 0,
trustScore: 0, trustScore: 0,
listedAt: new Date(), listedAt: new Date(),
serviceVisibility: ServiceVisibility.UNLISTED, serviceVisibility: 'UNLISTED',
categories: { categories: {
connect: input.categories.map((id) => ({ id })), connect: input.categories.map((id) => ({ id })),
}, },
@@ -250,8 +244,8 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await tx.serviceSuggestion.create({ const serviceSuggestion = await tx.serviceSuggestion.create({
data: { data: {
notes: input.notes, notes: input.notes,
type: ServiceSuggestionType.CREATE_SERVICE, type: 'CREATE_SERVICE',
status: ServiceSuggestionStatus.PENDING, status: 'PENDING',
userId: context.locals.user.id, userId: context.locals.user.id,
serviceId: service.id, serviceId: service.id,
}, },

View File

@@ -102,7 +102,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{...htmlProps} {...htmlProps}
id={`comment-${comment.id.toString()}`} id={`comment-${comment.id.toString()}`}
class={cn([ 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', 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) && comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
'bg-[#182a1f]', '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.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 && ( comment.rating !== null && !comment.parentId && (
<Tooltip <Tooltip
@@ -320,6 +314,19 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
color={commentStatusById.REJECTED.color} color={commentStatusById.REJECTED.color}
text={commentStatusById.REJECTED.label} text={commentStatusById.REJECTED.label}
inlineIcon 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-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'} {comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
</button> </button>
<button <button

View File

@@ -92,7 +92,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<InputRating name="rating" label="Rating" /> <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"> <label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueKycRequested" class="text-red-400" /> <input type="checkbox" name="issueKycRequested" class="text-red-400" />
<span class="flex items-center gap-1 text-xs 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', icon: 'i2p',
external: true, external: true,
}, },
{
href: '/docs/api',
label: 'API',
icon: 'ri:plug-line',
external: false,
},
{ {
href: '/about', href: '/about',
label: '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 { return {
type: 'clearnet' as const, type: 'clearnet' as const,
url: urlWithReferral, url: urlWithReferral,

View File

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

View File

@@ -114,7 +114,13 @@ export class ErrorBanners {
return result return result
} catch (error) { } catch (error) {
this.handler(uiMessage)(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 { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server'
import TimeFormatted from '../../components/TimeFormatted.astro'
import MiniLayout from '../../layouts/MiniLayout.astro' import MiniLayout from '../../layouts/MiniLayout.astro'
import { timeAgo } from '../../lib/timeAgo'
const releaseDate = const releaseDate =
RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined
@@ -37,7 +37,7 @@ const releaseDate =
{ {
!!releaseDate && ( !!releaseDate && (
<p class="text-day-500 mt-2"> <p class="text-day-500 mt-2">
(<TimeFormatted date={releaseDate} hourPrecision daysUntilDate={Infinity} />) (<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>)
</p> </p>
) )
} }

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote' import { Markdown } from 'astro-remote'
import { actions, isInputError } from 'astro:actions' import { actions, isInputError } from 'astro:actions'
import { Code } from 'astro:components'
import { orderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
import BadgeSmall from '../../../../components/BadgeSmall.astro' import BadgeSmall from '../../../../components/BadgeSmall.astro'
@@ -33,6 +34,8 @@ import {
verificationStepStatuses, verificationStepStatuses,
} from '../../../../constants/verificationStepStatus' } from '../../../../constants/verificationStepStatus'
import BaseLayout from '../../../../layouts/BaseLayout.astro' import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables'
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
import { pluralize } from '../../../../lib/pluralize' import { pluralize } from '../../../../lib/pluralize'
import { prisma } from '../../../../lib/prisma' 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}`}> <BaseLayout pageTitle={`Edit Service: ${service.name}`}>
@@ -247,7 +278,9 @@ if (!service) return Astro.rewrite('/404')
<InputText <InputText
label="Slug" label="Slug"
description="Auto-generated if empty" description={`Auto-generated if empty. ${
service.previousSlugs.length > 0 ? `Old slugs: ${service.previousSlugs.join(', ')}` : ''
}`}
name="slug" name="slug"
inputProps={{ inputProps={{
value: service.slug, value: service.slug,
@@ -1100,5 +1133,27 @@ if (!service) return Astro.rewrite('/404')
</form> </form>
</FormSubSection> </FormSubSection>
</FormSection> </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> </div>
</BaseLayout> </BaseLayout>

View File

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

View File

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

View File

@@ -67,7 +67,11 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
'Error fetching service', 'Error fetching service',
async () => async () =>
prisma.service.findUnique({ prisma.service.findUnique({
where: { slug }, where: {
slug,
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
listedAt: { lte: new Date() },
},
select: { select: {
id: true, id: true,
slug: 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 = ( const makeWatchingDetails = (
dbNotificationPreferences: Prisma.NotificationPreferencesGetPayload<{ dbNotificationPreferences: Prisma.NotificationPreferencesGetPayload<{
select: { select: {
@@ -254,17 +286,7 @@ const makeWatchingDetails = (
} as const } as const
} }
const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service?.id) 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 statusIcon = { const statusIcon = {
...verificationStatusesByValue, ...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' }, orderBy: { createdAt: 'desc' },
take: 5, take: 5,
}, },