Release 202505310921
This commit is contained in:
5
web/src/actions/api/index.ts
Normal file
5
web/src/actions/api/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { apiServiceActions } from './service'
|
||||
|
||||
export const apiActions = {
|
||||
service: apiServiceActions,
|
||||
}
|
||||
113
web/src/actions/api/service.ts
Normal file
113
web/src/actions/api/service.ts
Normal 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}`,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { accountActions } from './account'
|
||||
import { adminActions } from './admin'
|
||||
import { apiActions } from './api'
|
||||
import { commentActions } from './comment'
|
||||
import { notificationActions } from './notifications'
|
||||
import { serviceActions } from './service'
|
||||
@@ -19,6 +20,7 @@ import { serviceSuggestionActions } from './serviceSuggestion'
|
||||
export const server = {
|
||||
account: accountActions,
|
||||
admin: adminActions,
|
||||
api: apiActions,
|
||||
comment: commentActions,
|
||||
notification: notificationActions,
|
||||
service: serviceActions,
|
||||
|
||||
32
web/src/lib/endpoints.ts
Normal file
32
web/src/lib/endpoints.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,38 +203,9 @@ To **see comments waiting for moderation**, toggle the switch in the comments se
|
||||
|
||||
## API
|
||||
|
||||
Access basic service data via our public API.
|
||||
You can 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"
|
||||
}
|
||||
```
|
||||
See the [API page](/docs/api) for more details.
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
disabled: !user.karmaUnlocks.displayName,
|
||||
}}
|
||||
description={!user.karmaUnlocks.displayName
|
||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/karma)`
|
||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/docs/karma)`
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
@@ -68,7 +68,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
disabled: !user.karmaUnlocks.websiteLink,
|
||||
}}
|
||||
description={!user.karmaUnlocks.websiteLink
|
||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/karma)`
|
||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/docs/karma)`
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
@@ -80,7 +80,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
square
|
||||
disabled={!user.karmaUnlocks.profilePicture}
|
||||
description={!user.karmaUnlocks.profilePicture
|
||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/karma)`
|
||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/docs/karma)`
|
||||
: undefined}
|
||||
removeCheckbox={user.picture
|
||||
? {
|
||||
|
||||
@@ -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">
|
||||
<p class="text-day-300">
|
||||
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline"
|
||||
>Learn about karma</a
|
||||
Earn karma to unlock features and privileges. <a
|
||||
href="/docs/karma"
|
||||
class="text-day-200 hover:underline">Learn about karma</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
13
web/src/pages/api/[...catchAll].ts
Normal file
13
web/src/pages/api/[...catchAll].ts
Normal 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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
7
web/src/pages/api/v1/service/get.ts
Normal file
7
web/src/pages/api/v1/service/get.ts
Normal 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
155
web/src/pages/docs/api.mdx
Normal 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"
|
||||
}
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
layout: ../layouts/MarkdownLayout.astro
|
||||
layout: ../../layouts/MarkdownLayout.astro
|
||||
title: How does karma work?
|
||||
description: "KYCnot.me has a user karma system, here's how it works"
|
||||
icon: 'ri:hearts-line'
|
||||
@@ -7,7 +7,7 @@ author: KYCnot.me
|
||||
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.
|
||||
|
||||
@@ -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">
|
||||
<p class="text-day-300">
|
||||
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline"
|
||||
>Learn about karma</a
|
||||
Earn karma to unlock features and privileges. <a
|
||||
href="/docs/karma"
|
||||
class="text-day-200 hover:underline">Learn about karma</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user