From 22944fcdb33d58d704e1c3da307ada27909d211e Mon Sep 17 00:00:00 2001 From: pluja Date: Sat, 31 May 2025 09:21:32 +0000 Subject: [PATCH] Release 202505310921 --- web/src/actions/api/index.ts | 5 + web/src/actions/api/service.ts | 113 +++++++++++++++++ web/src/actions/index.ts | 2 + web/src/lib/endpoints.ts | 32 +++++ web/src/pages/about.mdx | 33 +---- web/src/pages/account/edit.astro | 6 +- web/src/pages/account/index.astro | 5 +- web/src/pages/api/[...catchAll].ts | 13 ++ web/src/pages/api/v1/service/[...id].ts | 121 ------------------ web/src/pages/api/v1/service/get.ts | 7 ++ web/src/pages/docs/api.mdx | 155 ++++++++++++++++++++++++ web/src/pages/{ => docs}/karma.mdx | 4 +- web/src/pages/u/[username].astro | 5 +- 13 files changed, 340 insertions(+), 161 deletions(-) create mode 100644 web/src/actions/api/index.ts create mode 100644 web/src/actions/api/service.ts create mode 100644 web/src/lib/endpoints.ts create mode 100644 web/src/pages/api/[...catchAll].ts delete mode 100644 web/src/pages/api/v1/service/[...id].ts create mode 100644 web/src/pages/api/v1/service/get.ts create mode 100644 web/src/pages/docs/api.mdx rename web/src/pages/{ => docs}/karma.mdx (95%) diff --git a/web/src/actions/api/index.ts b/web/src/actions/api/index.ts new file mode 100644 index 0000000..470b730 --- /dev/null +++ b/web/src/actions/api/index.ts @@ -0,0 +1,5 @@ +import { apiServiceActions } from './service' + +export const apiActions = { + service: apiServiceActions, +} diff --git a/web/src/actions/api/service.ts b/web/src/actions/api/service.ts new file mode 100644 index 0000000..ead6305 --- /dev/null +++ b/web/src/actions/api/service.ts @@ -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}`, + } + }, + }), +} diff --git a/web/src/actions/index.ts b/web/src/actions/index.ts index 7b40a69..3c343b8 100644 --- a/web/src/actions/index.ts +++ b/web/src/actions/index.ts @@ -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, diff --git a/web/src/lib/endpoints.ts b/web/src/lib/endpoints.ts new file mode 100644 index 0000000..5472c21 --- /dev/null +++ b/web/src/lib/endpoints.ts @@ -0,0 +1,32 @@ +import { type ActionClient } from 'astro:actions' + +import type { APIRoute } from 'astro' +import type { z } from 'astro/zod' + +export function makeEndpointFromAction & 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, + }) + } + } +} diff --git a/web/src/pages/about.mdx b/web/src/pages/about.mdx index aaf5ccc..07334f9 100644 --- a/web/src/pages/about.mdx +++ b/web/src/pages/about.mdx @@ -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 diff --git a/web/src/pages/account/edit.astro b/web/src/pages/account/edit.astro index 9a10c26..89930b0 100644 --- a/web/src/pages/account/edit.astro +++ b/web/src/pages/account/edit.astro @@ -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 ? { diff --git a/web/src/pages/account/index.astro b/web/src/pages/account/index.astro index 8c479d7..0deaf74 100644 --- a/web/src/pages/account/index.astro +++ b/web/src/pages/account/index.astro @@ -529,8 +529,9 @@ if (!user) return Astro.rewrite('/404')

- Earn karma to unlock features and privileges. Learn about karmaLearn about karma

diff --git a/web/src/pages/api/[...catchAll].ts b/web/src/pages/api/[...catchAll].ts new file mode 100644 index 0000000..8f5a9ae --- /dev/null +++ b/web/src/pages/api/[...catchAll].ts @@ -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, + } + ) +} diff --git a/web/src/pages/api/v1/service/[...id].ts b/web/src/pages/api/v1/service/[...id].ts deleted file mode 100644 index c84d910..0000000 --- a/web/src/pages/api/v1/service/[...id].ts +++ /dev/null @@ -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', - }, - }) - } -} diff --git a/web/src/pages/api/v1/service/get.ts b/web/src/pages/api/v1/service/get.ts new file mode 100644 index 0000000..dc30c0f --- /dev/null +++ b/web/src/pages/api/v1/service/get.ts @@ -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) diff --git a/web/src/pages/docs/api.mdx b/web/src/pages/docs/api.mdx new file mode 100644 index 0000000..debd5c6 --- /dev/null +++ b/web/src/pages/docs/api.mdx @@ -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 source code 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 + + + +### Verification Status + + + +### 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" +} +``` diff --git a/web/src/pages/karma.mdx b/web/src/pages/docs/karma.mdx similarity index 95% rename from web/src/pages/karma.mdx rename to web/src/pages/docs/karma.mdx index 602fc18..c016a52 100644 --- a/web/src/pages/karma.mdx +++ b/web/src/pages/docs/karma.mdx @@ -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. diff --git a/web/src/pages/u/[username].astro b/web/src/pages/u/[username].astro index ee797a4..6e489f2 100644 --- a/web/src/pages/u/[username].astro +++ b/web/src/pages/u/[username].astro @@ -647,8 +647,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id

- Earn karma to unlock features and privileges. Learn about karmaLearn about karma