Release 202505310921

This commit is contained in:
pluja
2025-05-31 09:21:32 +00:00
parent f7f380c591
commit 22944fcdb3
13 changed files with 340 additions and 161 deletions
+5
View File
@@ -0,0 +1,5 @@
import { apiServiceActions } from './service'
export const apiActions = {
service: apiServiceActions,
}
+113
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}`,
}
},
}),
}
+2
View File
@@ -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
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,
})
}
}
}
+2 -31
View File
@@ -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
+3 -3
View File
@@ -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
? {
+3 -2
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">
<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
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,
}
)
}
-121
View File
@@ -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
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
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"
}
```
@@ -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.
+3 -2
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">
<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>