Release 202506041641

This commit is contained in:
pluja
2025-06-04 16:41:32 +00:00
parent 5812399e29
commit dacf73a804
24 changed files with 839 additions and 184 deletions

View File

@@ -1,53 +0,0 @@
# Cursor Rules
- When merging tailwind classes, use the `cn` function.
- When using Tailwind and you need to merge classes use the `cn` function if avilable.
- We use Tailwind 4 (the latest version), make sure to not use outdated classes.
- Instead of using the syntax`Array<T>`, use `T[]`.
- Use TypeScript `type` over `interface`.
- You are forbiddent o add comments unless explicitly stated by the user.
- Avoid sending JavaScript to the client. The JS send should be optional.
- In prisma preffer `select` over `include` when making queries.
- Import the types from prisma instead of hardcoding duplicates.
- Avoid duplicating similar html code, and parametrize it when possible or create separate components.
- Remember to check the prisma schema when doing things related to the database.
- Avoid hardcoding enums from the database, import them from prisma.
- Avoid using client-side JavaScript as much as possible. And if it has to be done, make it optional.
- The admin pages can use client-side JavaScript.
- Keep README.md in sync with new capabilities.
- The package manager is npm.
- For icons use the `Icon` component from `astro-icon/components`.
- For icons use the Remix Icon library preferably.
- Use the `Image` component from `astro:assets` for images.
- Use the `zod` library for schema validation.
- In the astro actions return, don't return success: true, or similar, just return an object with the newly created/edited objects or nothing.
- When adding actions, don't create and export a new variable called actions. Notice that Astro already provides that variable from `import { actions } from 'astro:actions'`. So just add the new actions to the `server` variable in `web/src/actions/index.ts` and that's it.
- Don't forget that the astro files have thre dashes (`---`) at the begining of the file and where the server js ends. I noticed that sometimes you forget them.
- The admin actions go into a separate folder.
- In Actro actions when throwing errors use ActionError.
- @deprecated Don't import this object, use {@link actions} instead, like: `import { actions } from 'astro:actions'`. Example:
```ts
import { actions } from "astro:actions"; /* CORRECT */
import { server } from "~/actions"; /* WRONG!!!! DON'T DO THIS */
import { adminAttributeActions } from "~/actions/admin/attribute.ts"; /* WRONG!!!! DON'T DO THIS */
const result = Astro.getActionResult(actions.admin.attribute.create);
```
- Always use Astro actions instead of with API routes or `if (Astro.request.method === "POST")`.
- When adding clientside js do it with HTMX.
- When adding HTMX, the layout component BaseLayout accepts a prop htmx to load it in that page. No need to use a cdn.
- When redirecting to login use the `makeLoginUrl` function from web/src/lib/redirectUrls.ts
```ts
function makeLoginUrl(
currentUrl: URL,
options: {
redirect?: URL | string | null;
error?: string | null;
logout?: boolean;
message?: string | null;
} = {}
);
```

View File

@@ -51,42 +51,6 @@ import-db file="":
fi
fi
echo "Restoring database from $BACKUP_FILE..."
# First drop all connections to the database
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres
# Drop and recreate database
echo "Dropping and recreating the database..."
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "DROP DATABASE IF EXISTS ${POSTGRES_DATABASE:-kycnot};" postgres
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "CREATE DATABASE ${POSTGRES_DATABASE:-kycnot};" postgres
# Restore the database
cat "$BACKUP_FILE" | docker compose exec -T database pg_restore -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} --no-owner
echo "Database restored successfully!"
# Import triggers
echo "Importing triggers..."
just import-triggers
echo "Database import completed!"
# Check if migrations need to be run
cd web && npx prisma migrate status
#!/bin/bash
if [ -z "{{file}}" ]; then
BACKUP_FILE=$(find backups/ -name 'db_backup_*.dump' | sort -r | head -n 1)
if [ -z "$BACKUP_FILE" ]; then
echo "Error: No backup files found in the backups directory"
exit 1
fi
else
BACKUP_FILE="{{file}}"
if [ ! -f "$BACKUP_FILE" ]; then
echo "Error: Backup file '$BACKUP_FILE' not found"
exit 1
fi
fi
echo "=== STEP 1: PREPARING DATABASE ==="
# Drop all connections to the database
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres

View File

@@ -2,7 +2,7 @@ DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
REDIS_URL="redis://localhost:6379"
SOURCE_CODE_URL="https://github.com"
DATABASE_UI_URL="http://localhost:5555"
SITE_URL="https://localhost:4321"
SITE_URL="http://localhost:4321"
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
RELEASE_NUMBER=123

View File

@@ -6,11 +6,11 @@ import sitemap from '@astrojs/sitemap'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig, envField } from 'astro/config'
import icon from 'astro-icon'
import { loadEnv } from 'vite'
// @ts-expect-error process.env actually exists
const { SITE_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), '')
if (!SITE_URL) throw new Error('SITE_URL environment variable is not set')
import { postgresListener } from './src/lib/postgresListenerIntegration'
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
const SITE_URL = getServerEnvVariable('SITE_URL')
export default defineConfig({
site: SITE_URL,
@@ -22,6 +22,7 @@ export default defineConfig({
plugins: [tailwindcss()],
},
integrations: [
postgresListener(),
icon(),
mdx(),
sitemap({

101
web/package-lock.json generated
View File

@@ -19,6 +19,7 @@
"@prisma/client": "6.8.2",
"@tailwindcss/vite": "4.1.7",
"@types/mime-types": "2.1.4",
"@types/pg": "8.15.4",
"@vercel/og": "0.6.8",
"astro": "5.7.13",
"astro-loading-indicator": "0.7.0",
@@ -32,6 +33,7 @@
"lodash-es": "4.17.21",
"mime-types": "3.0.1",
"object-to-formdata": "4.5.1",
"pg": "8.16.0",
"qrcode": "1.5.4",
"react": "19.1.0",
"redis": "5.0.1",
@@ -3152,12 +3154,10 @@
}
},
"node_modules/@types/pg": {
"version": "8.6.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz",
"integrity": "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==",
"version": "8.15.4",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz",
"integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
@@ -11413,32 +11413,75 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz",
"integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.9.0",
"pg-pool": "^3.10.0",
"pg-protocol": "^1.10.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 8.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.5"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.5.tgz",
"integrity": "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.0.tgz",
"integrity": "sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"optional": true,
"peer": true,
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-protocol": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz",
"integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==",
"node_modules/pg-pool": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.0.tgz",
"integrity": "sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA==",
"license": "MIT",
"optional": true,
"peer": true
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.0.tgz",
"integrity": "sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
@@ -11450,6 +11493,15 @@
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -11559,8 +11611,6 @@
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=4"
}
@@ -11570,8 +11620,6 @@
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -11581,8 +11629,6 @@
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -11592,8 +11638,6 @@
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"xtend": "^4.0.0"
},
@@ -13251,6 +13295,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -15037,8 +15090,6 @@
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.4"
}

View File

@@ -33,6 +33,7 @@
"@prisma/client": "6.8.2",
"@tailwindcss/vite": "4.1.7",
"@types/mime-types": "2.1.4",
"@types/pg": "8.15.4",
"@vercel/og": "0.6.8",
"astro": "5.7.13",
"astro-loading-indicator": "0.7.0",
@@ -46,6 +47,7 @@
"lodash-es": "4.17.21",
"mime-types": "3.0.1",
"object-to-formdata": "4.5.1",
"pg": "8.16.0",
"qrcode": "1.5.4",
"react": "19.1.0",
"redis": "5.0.1",

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `kycLevelDetailsId` on the `Service` table. All the data in the column will be lost.
- Made the column `kycLevelClarification` on table `Service` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "Service" DROP COLUMN "kycLevelDetailsId",
ALTER COLUMN "kycLevelClarification" SET NOT NULL,
ALTER COLUMN "kycLevelClarification" SET DEFAULT 'NONE';

View File

@@ -345,7 +345,7 @@ model Service {
description String
categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4)
kycLevelClarification KycLevelClarification?
kycLevelClarification KycLevelClarification @default(NONE)
overallScore Int @default(0)
privacyScore Int @default(0)
trustScore Int @default(0)
@@ -391,7 +391,6 @@ model Service {
onVerificationChangeForServices NotificationPreferences[] @relation("onVerificationChangeForServices")
Notification Notification[]
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
kycLevelDetailsId Int?
@@index([listedAt])
@@index([overallScore])

View File

@@ -0,0 +1,16 @@
CREATE OR REPLACE FUNCTION trigger_notification_push()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('notification_created', json_build_object('id', NEW.id)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop the trigger if it exists to ensure a clean setup
DROP TRIGGER IF EXISTS notification_push_trigger ON "Notification";
-- Create the trigger to fire after inserts
CREATE TRIGGER notification_push_trigger
AFTER INSERT ON "Notification"
FOR EACH ROW
EXECUTE FUNCTION trigger_notification_push();

View File

@@ -5,10 +5,15 @@ import { uniq } from 'lodash-es'
import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage'
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
import { prisma } from '../../lib/prisma'
import { separateServiceUrlsByType } from '../../lib/urls'
import { imageFileSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../../lib/zodUtils'
import {
imageFileSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
zodContactMethod,
} from '../../lib/zodUtils'
const addSlugIfMissing = <
T extends {
@@ -69,6 +74,15 @@ const updateServiceInputSchema = serviceSchemaBase
})
.transform(addSlugIfMissing)
const evidenceImageAddSchema = z.object({
serviceId: z.number().int().positive(),
imageFile: imageFileSchema,
})
const evidenceImageDeleteSchema = z.object({
fileUrl: z.string().startsWith('/files/evidence/', 'Must be a valid evidence file URL'),
})
export const adminServiceActions = {
create: defineProtectedAction({
accept: 'form',
@@ -107,7 +121,7 @@ export const adminServiceActions = {
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification,
kycLevelClarification: input.kycLevelClarification ?? undefined,
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
@@ -225,7 +239,7 @@ export const adminServiceActions = {
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification,
kycLevelClarification: input.kycLevelClarification ?? undefined,
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
@@ -272,7 +286,7 @@ export const adminServiceActions = {
permissions: 'admin',
input: z.object({
label: z.string().min(1).max(50).nullable(),
value: z.string().url(),
value: zodContactMethod,
serviceId: z.number().int().positive(),
}),
handler: async (input) => {
@@ -404,4 +418,50 @@ export const adminServiceActions = {
},
}),
},
evidenceImage: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: evidenceImageAddSchema,
handler: async (input) => {
const service = await prisma.service.findUnique({
where: { id: input.serviceId },
select: { slug: true },
})
if (!service) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found to associate image with.',
})
}
if (!input.imageFile) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Image file is required.',
})
}
const imageUrl = await saveFileLocally(
input.imageFile,
input.imageFile.name,
`evidence/${service.slug}`
)
return { imageUrl }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: evidenceImageDeleteSchema,
handler: async (input) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await deleteFileLocally(input.fileUrl)
return { success: true }
},
}),
},
}

View File

@@ -2,6 +2,7 @@ import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { pick } from 'lodash-es'
import { getKycLevelClarificationInfo } from '../../constants/kycLevelClarifications'
import { getKycLevelInfo } from '../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../constants/verificationStatus'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
@@ -50,6 +51,7 @@ export const apiServiceActions = {
slug: true,
description: true,
kycLevel: true,
kycLevelClarification: true,
verificationStatus: true,
categories: {
select: {
@@ -130,6 +132,12 @@ export const apiServiceActions = {
verifiedAt: service.verifiedAt,
kycLevel: service.kycLevel,
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
kycLevelClarification: service.kycLevelClarification,
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
'value',
'name',
'description',
]),
categories: service.categories,
listedAt: service.listedAt,
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(

View File

@@ -10,7 +10,12 @@ import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma'
import { separateServiceUrlsByType } from '../lib/urls'
import { imageFileSchemaRequired, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../lib/zodUtils'
import {
imageFileSchemaRequired,
stringListOfContactMethodsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../lib/zodUtils'
import type { Prisma } from '@prisma/client'
@@ -153,6 +158,7 @@ export const serviceSuggestionActions = {
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
contactMethods: stringListOfContactMethodsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
kycLevelClarification: z.nativeEnum(KycLevelClarification),
attributes: z.array(z.coerce.number().int().positive()),
@@ -239,6 +245,11 @@ export const serviceSuggestionActions = {
attributeId: id,
})),
},
contactMethods: {
create: input.contactMethods.map((value) => ({
value,
})),
},
},
select: serviceSelect,
})

View File

@@ -3,6 +3,9 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js'
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { Assert } from '../lib/assert'
import type { Equals } from 'ts-toolbelt/out/Any/Equals'
type ContactMethodInfo<T extends string | null | undefined = string> = {
type: T
label: string
@@ -10,6 +13,7 @@ type ContactMethodInfo<T extends string | null | undefined = string> = {
matcher: RegExp
formatter: (match: RegExpMatchArray) => string | null
icon: string
urlType: string
}
export const {
@@ -22,9 +26,10 @@ export const {
(type): ContactMethodInfo<typeof type> => ({
type,
label: type ? transformCase(type, 'title') : String(type),
icon: 'ri:shield-fill',
icon: 'ri:link',
matcher: /(.*)/,
formatter: ([, value]) => value ?? String(value),
urlType: type ?? 'unknown',
}),
[
{
@@ -33,24 +38,37 @@ export const {
matcher: /mailto:(.+)/,
formatter: ([, value]) => value ?? 'Email',
icon: 'ri:mail-line',
urlType: 'email',
},
{
type: 'telephone',
label: 'Telephone',
matcher: /tel:(.+)/,
formatter: ([, value]) => {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
try {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
} catch (_error) {
console.error(`Invalid telephone number: ${value ?? 'undefined'}`, _error)
return value ?? 'Telephone'
}
},
icon: 'ri:phone-line',
urlType: 'telephone',
},
{
type: 'whatsapp',
label: 'WhatsApp',
matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
formatter: ([, value]) => {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
try {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
} catch (_error) {
console.error(`Invalid WhatsApp number: ${value ?? 'undefined'}`, _error)
return value ?? 'WhatsApp'
}
},
icon: 'ri:whatsapp-line',
urlType: 'url',
},
{
type: 'telegram',
@@ -58,6 +76,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
icon: 'ri:telegram-line',
urlType: 'url',
},
{
type: 'linkedin',
@@ -65,6 +84,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
icon: 'ri:linkedin-box-line',
urlType: 'url',
},
{
type: 'x',
@@ -72,6 +92,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
formatter: ([, value]) => (value ? `@${value}` : 'X'),
icon: 'ri:twitter-x-line',
urlType: 'url',
},
{
type: 'instagram',
@@ -79,6 +100,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
icon: 'ri:instagram-line',
urlType: 'url',
},
{
type: 'matrix',
@@ -86,6 +108,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
icon: 'ri:hashtag',
urlType: 'url',
},
{
type: 'bitcointalk',
@@ -93,6 +116,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
formatter: () => 'BitcoinTalk',
icon: 'ri:btc-line',
urlType: 'url',
},
{
type: 'simplex',
@@ -100,6 +124,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
formatter: () => 'SimpleX Chat',
icon: 'simplex',
urlType: 'url',
},
{
type: 'nostr',
@@ -107,6 +132,7 @@ export const {
matcher: /\b(npub1[a-zA-Z0-9]{58})\b/,
formatter: () => 'Nostr',
icon: 'nostr',
urlType: 'url',
},
{
// Website must go last because it's a catch-all
@@ -115,6 +141,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
formatter: ([, value]) => value ?? 'Website',
icon: 'ri:global-line',
urlType: 'url',
},
] as const satisfies ContactMethodInfo[]
)
@@ -135,3 +162,38 @@ export function formatContactMethod(url: string) {
return { ...getContactMethodInfo('unknown'), formattedValue: url } as const
}
type ContactMethodUrlTypeInfo<T extends string | null | undefined = string> = {
value: T
labelPlural: string
}
export const {
dataArray: contactMethodUrlTypes,
dataObject: contactMethodUrlTypesById,
getFn: getContactMethodUrlTypeInfo,
} = makeHelpersForOptions(
'value',
(value): ContactMethodUrlTypeInfo<typeof value> => ({
value,
labelPlural: value ? transformCase(value, 'title') : String(value),
}),
[
{
value: 'email',
labelPlural: 'emails',
},
{
value: 'telephone',
labelPlural: 'phone numbers',
},
{
value: 'url',
labelPlural: 'URLs',
},
] as const satisfies ContactMethodUrlTypeInfo<(typeof contactMethods)[number]['urlType']>[]
)
type _ExpectUrlTypesToHaveAllValues = Assert<
Equals<(typeof contactMethods)[number]['urlType'], keyof typeof contactMethodUrlTypesById>
>

10
web/src/lib/assert.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Gives an error if the type is not equal to 1.
*
* @example
* ```ts
* type _ExpectEquals = Assert<Equals<'a' | 'b', 'a'>> // Gives an error
* type _ExpectEquals = Assert<Equals<'a' | 'b', 'b' | 'a'>> // No error
* ```
*/
export type Assert<T extends 1> = T

View File

@@ -69,6 +69,53 @@ export async function saveFileLocally(
return url
}
/**
* List all files in a specific subdirectory of the upload directory.
* Returns an array of web-accessible URLs.
*/
export async function listFiles(subDir: string): Promise<string[]> {
const { fsPath: uploadDir, webPath: webUploadPath } = getUploadDir(subDir)
try {
const files = await fs.readdir(uploadDir)
return files.map((file) => sanitizePath(`${webUploadPath}/${file}`))
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException
if (err.code === 'ENOENT') {
return []
}
console.error(`Error listing files in ${uploadDir}:`, error)
throw error
}
}
/**
* Delete a file locally given its web-accessible URL path
*/
export async function deleteFileLocally(fileUrl: string): Promise<void> {
// Extract the subpath and filename from the webPath
// Example: /files/evidence/service-slug/image.jpg -> evidence/service-slug/image.jpg
const basePath = '/files'
if (!fileUrl.startsWith(basePath)) {
throw new Error('Invalid file URL for deletion. Must start with /files')
}
const subPathAndFile = fileUrl.substring(basePath.length).replace(/^\/+/, '') // Remove leading /files/ and any extra leading slashes
const { fsPath: uploadDirWithoutSubDir } = getUploadDir() // Get base upload directory
const filePath = path.join(uploadDirWithoutSubDir, subPathAndFile)
try {
await fs.unlink(filePath)
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException
if (err.code === 'ENOENT') {
console.warn(`File not found for deletion, but treating as success: ${filePath}`)
return
}
console.error(`Error deleting file ${filePath}:`, error)
throw error
}
}
function sanitizePath(inputPath: string): string {
let sanitized = inputPath.replace(/\\+/g, '/')
// Collapse multiple slashes, but preserve protocol (e.g., http://)

35
web/src/lib/json.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { z } from 'astro:content'
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface JSONObject {
[k: string]: JSONValue
}
type JSONList = JSONValue[]
type JSONPrimitive = boolean | number | string | null
type JSONValue = Date | JSONList | JSONObject | JSONPrimitive
export type ZodJSON = z.ZodType<JSONValue>
export function zodParseJSON<T extends z.ZodType<JSONValue>, D extends z.output<T> | undefined = undefined>(
schema: T,
stringValue: string | null | undefined,
defaultValue?: D
): D | z.output<T> {
if (!stringValue) return defaultValue as D
let jsonValue: D | z.output<typeof schema> = defaultValue as D
try {
jsonValue = JSON.parse(stringValue)
} catch (error) {
console.error(error)
return defaultValue as D
}
const parsedValue = schema.safeParse(jsonValue)
if (!parsedValue.success) {
console.error(parsedValue.error)
return defaultValue as D
}
return parsedValue.data
}

View File

@@ -1,17 +1,10 @@
import { z } from 'astro:schema'
import { zodParseJSON, type ZodJSON } from './json'
import { typedObjectEntries } from './objects'
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface JSONObject {
[k: string]: JSONValue
}
type JSONList = JSONValue[]
type JSONPrimitive = boolean | number | string | null
type JSONValue = Date | JSONList | JSONObject | JSONPrimitive
function makeTypedLocalStorage<
Schemas extends Record<string, z.ZodType<JSONValue>>,
Schemas extends Record<string, ZodJSON>,
T extends {
[K in keyof Schemas]: {
schema: Schemas[K]
@@ -28,24 +21,7 @@ function makeTypedLocalStorage<
key,
{
get: () => {
const stringValue = localStorage.getItem(key)
if (!stringValue) return option.default
let jsonValue: z.output<typeof option.schema> | undefined = option.default
try {
jsonValue = JSON.parse(stringValue)
} catch (error) {
console.error(error)
return option.default
}
const parsedValue = option.schema.safeParse(jsonValue)
if (!parsedValue.success) {
console.error(parsedValue.error)
return option.default
}
return parsedValue.data
return zodParseJSON(option.schema, localStorage.getItem(key), option.default)
},
set: (value: z.input<typeof option.schema>) => {

View File

@@ -0,0 +1,248 @@
import { z } from 'astro/zod'
import { Client } from 'pg'
import { zodParseJSON } from './json'
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications'
import { prisma } from './prisma'
import { getServerEnvVariable } from './serverEnvVariables'
import { sendPushNotification, type NotificationData } from './webPush'
import type { AstroIntegration, HookParameters } from 'astro'
const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
const SITE_URL = getServerEnvVariable('SITE_URL')
let pgClient: Client | null = null
const INTEGRATION_NAME = 'postgres-listener'
async function handleNotificationCreated(
notificationId: number,
options: HookParameters<'astro:server:start'>
) {
const logger = options.logger.fork(INTEGRATION_NAME)
try {
logger.info(`Processing notification with ID: ${String(notificationId)}`)
const notification = await prisma.notification.findUnique({
where: { id: notificationId },
select: {
id: true,
type: true,
userId: true,
aboutAccountStatusChange: true,
aboutCommentStatusChange: true,
aboutServiceVerificationStatusChange: true,
aboutSuggestionStatusChange: true,
aboutComment: {
select: {
id: true,
author: { select: { id: true } },
status: true,
content: true,
communityNote: true,
parent: {
select: {
author: {
select: {
id: true,
},
},
},
},
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutServiceSuggestionId: true,
aboutServiceSuggestion: {
select: {
status: true,
service: {
select: {
name: true,
},
},
},
},
aboutServiceSuggestionMessage: {
select: {
id: true,
content: true,
suggestion: {
select: {
id: true,
service: {
select: {
name: true,
},
},
},
},
},
},
aboutEvent: {
select: {
title: true,
type: true,
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutService: {
select: {
slug: true,
name: true,
verificationStatus: true,
},
},
aboutKarmaTransaction: {
select: {
points: true,
action: true,
description: true,
},
},
user: {
select: {
id: true,
name: true,
},
},
},
})
if (!notification) {
logger.warn(`Notification with ID ${String(notificationId)} not found`)
return
}
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId: notification.userId },
select: {
id: true,
endpoint: true,
p256dh: true,
auth: true,
},
})
if (subscriptions.length === 0) {
logger.info(`No push subscriptions found for user ${notification.user.name}`)
return
}
const notificationData = {
title: makeNotificationTitle(notification, notification.user),
body: makeNotificationContent(notification) ?? undefined,
url: makeNotificationLink(notification, SITE_URL) ?? undefined,
} satisfies NotificationData
const results = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
notificationData
)
// Remove invalid subscriptions
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
}
return result.success
})
)
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length
const failureCount = results.filter((r) => !(r.status === 'fulfilled' && r.value)).length
logger.info(
`Push notification sent for notification ${String(notificationId)} to user ${notification.user.name}: ${String(successCount)} successful, ${String(failureCount)} failed`
)
} catch (error) {
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
}
}
export function postgresListener(): AstroIntegration {
return {
name: 'postgres-listener',
hooks: {
'astro:server:start': async (options) => {
const logger = options.logger.fork(INTEGRATION_NAME)
try {
logger.info('Starting PostgreSQL notification listener...')
pgClient = new Client({ connectionString: DATABASE_URL })
await pgClient.connect()
logger.info('Connected to PostgreSQL for notifications')
await pgClient.query('LISTEN notification_created')
logger.info('Listening for notification_created events')
pgClient.on('notification', (msg) => {
if (msg.channel === 'notification_created') {
const payload = zodParseJSON(z.object({ id: z.number().int().positive() }), msg.payload)
if (!payload) {
logger.warn(`Invalid notification ID in payload: ${String(msg.payload)}`)
return
}
// NOTE: Don't await to avoid blocking
void handleNotificationCreated(payload.id, options)
}
})
pgClient.on('error', (error) => {
logger.error(`PostgreSQL client error: ${getErrorMessage(error)}`)
})
pgClient.on('end', () => {
logger.info('PostgreSQL client connection ended')
})
} catch (error) {
logger.error(`Failed to start PostgreSQL listener: ${getErrorMessage(error)}`)
}
},
'astro:server:done': async ({ logger: originalLogger }) => {
const logger = originalLogger.fork(INTEGRATION_NAME)
if (pgClient) {
try {
logger.info('Stopping PostgreSQL notification listener...')
await pgClient.end()
pgClient = null
logger.info('PostgreSQL listener stopped')
} catch (error) {
logger.error(`Error stopping PostgreSQL listener: ${getErrorMessage(error)}`)
}
}
},
},
}
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message
}
return String(error)
}

View File

@@ -0,0 +1,14 @@
import { loadEnv } from 'vite'
/** Only use when you can't import the variables from `astro:env/server` */
// @ts-expect-error process.env actually exists
const untypedServerEnvVariables = loadEnv(process.env.NODE_ENV, process.cwd(), '')
/** Only use when you can't import the variables from `astro:env/server` */
export function getServerEnvVariable<T extends keyof typeof untypedServerEnvVariables>(
name: T
): NonNullable<(typeof untypedServerEnvVariables)[T]> {
const value = untypedServerEnvVariables[name]
if (!value) throw new Error(`${name} environment variable is not set`)
return value
}

View File

@@ -1,12 +1,25 @@
/* eslint-disable import/no-named-as-default-member */
import { VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT } from 'astro:env/server'
import webpush, { WebPushError } from 'web-push'
import { getServerEnvVariable } from './serverEnvVariables'
const VAPID_PUBLIC_KEY = getServerEnvVariable('VAPID_PUBLIC_KEY')
const VAPID_PRIVATE_KEY = getServerEnvVariable('VAPID_PRIVATE_KEY')
const VAPID_SUBJECT = getServerEnvVariable('VAPID_SUBJECT')
// Configure VAPID keys
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
export { webpush }
export type NotificationData = {
title: string
body?: string
icon?: string
badge?: string
url?: string
}
export async function sendPushNotification(
subscription: {
endpoint: string
@@ -15,13 +28,7 @@ export async function sendPushNotification(
auth: string
}
},
data: {
title: string
body?: string
icon?: string
badge?: string
url?: string
}
data: NotificationData
) {
try {
const result = await webpush.sendNotification(

View File

@@ -13,17 +13,44 @@ const addZodPipe = (schema: ZodTypeAny, zodPipe?: ZodTypeAny) => {
export const zodCohercedNumber = (zodPipe?: ZodTypeAny) =>
addZodPipe(z.number().or(z.string().nonempty()), zodPipe)
const cleanUrl = (input: unknown) => {
if (typeof input !== 'string') return input
const cleanInput = input.trim().replace(/\/$/, '')
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
}
export const zodUrlOptionalProtocol = z.preprocess(
(input) => {
if (typeof input !== 'string') return input
const cleanInput = input.trim().replace(/\/$/, '')
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
},
cleanUrl,
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
message: 'Invalid URL',
})
)
export const zodContactMethod = z.preprocess(
(input) => {
if (typeof input !== 'string') return input
const cleanInput = input.trim()
if (/^([\d\s+\-_/()[\]*#.,]|ext|x){7,}$/i.test(cleanInput)) return `tel:${cleanInput}`
if (/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(cleanInput)) return `mailto:${cleanInput}`
return cleanUrl(cleanInput)
},
z
.string()
.trim()
.refine(
(value) =>
/^((https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]|([\d\s+\-_/()[\]*#.,]|ext|x){7,}|[0-9\s+-_\\/()[\]*#.]|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})*$/i.test(
value
),
{
message: 'Invalid contact method',
}
)
)
const stringToArrayFactory = (delimiter: RegExp | string = ',') => {
return <T>(input: T) =>
typeof input !== 'string'
@@ -49,6 +76,11 @@ export const stringListOfUrlsSchemaRequired = z.preprocess(
z.array(zodUrlOptionalProtocol).min(1)
)
export const stringListOfContactMethodsSchema = z.preprocess(
stringToArrayFactory(/[\s,\n]+/),
z.array(zodContactMethod).default([])
)
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5MB
export const ACCEPTED_IMAGE_TYPES = [

View File

@@ -23,7 +23,7 @@ import Tooltip from '../../../../components/Tooltip.astro'
import UserBadge from '../../../../components/UserBadge.astro'
import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
import { formatContactMethod } from '../../../../constants/contactMethods'
import { contactMethodUrlTypes, formatContactMethod } from '../../../../constants/contactMethods'
import { currencies } from '../../../../constants/currencies'
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
@@ -36,6 +36,7 @@ import {
} from '../../../../constants/verificationStepStatus'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables'
import { listFiles } from '../../../../lib/fileStorage'
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
import { pluralize } from '../../../../lib/pluralize'
import { prisma } from '../../../../lib/prisma'
@@ -87,9 +88,36 @@ const internalNoteInputErrors = isInputError(internalNoteCreateResult?.error)
? internalNoteCreateResult.error.fields
: {}
const contactMethodUpdateResult = Astro.getActionResult(actions.admin.service.contactMethod.update)
Astro.locals.banners.addIfSuccess(contactMethodUpdateResult, 'Contact method updated successfully')
const contactMethodUpdateInputErrors = isInputError(contactMethodUpdateResult?.error)
? contactMethodUpdateResult.error.fields
: {}
const contactMethodAddResult = Astro.getActionResult(actions.admin.service.contactMethod.add)
Astro.locals.banners.addIfSuccess(contactMethodAddResult, 'Contact method added successfully')
const contactMethodAddInputErrors = isInputError(contactMethodAddResult?.error)
? contactMethodAddResult.error.fields
: {}
const internalNoteDeleteResult = Astro.getActionResult(actions.admin.service.internalNote.delete)
Astro.locals.banners.addIfSuccess(internalNoteDeleteResult, 'Internal note deleted successfully')
const evidenceImageAddResult = Astro.getActionResult(actions.admin.service.evidenceImage.add)
if (evidenceImageAddResult?.data?.imageUrl) {
Astro.locals.banners.add({
uiMessage: 'Evidence image added successfully',
type: 'success',
origin: 'action',
})
}
const evidenceImageAddInputErrors = isInputError(evidenceImageAddResult?.error)
? evidenceImageAddResult.error.fields
: {}
const evidenceImageDeleteResult = Astro.getActionResult(actions.admin.service.evidenceImage.delete)
Astro.locals.banners.addIfSuccess(evidenceImageDeleteResult, 'Evidence image deleted successfully')
const [service, categories, attributes] = await Astro.locals.banners.tryMany([
[
'Error fetching service',
@@ -200,6 +228,12 @@ if (!service) {
return Astro.rewrite('/404')
}
const evidenceImageUrls = await Astro.locals.banners.try(
'Error listing evidence files',
() => listFiles(`evidence/${service.slug}`),
[] as string[]
)
const apiCalls = await Astro.locals.banners.try(
'Error fetching api calls',
() =>
@@ -426,7 +460,7 @@ const apiCalls = await Astro.locals.banners.try(
description: clarification.description,
noTransitionPersist: true,
}))}
selectedValue={service.kycLevelClarification ?? 'NONE'}
selectedValue={service.kycLevelClarification}
iconSize="sm"
cardSize="sm"
error={serviceInputErrors.kycLevelClarification}
@@ -1113,6 +1147,7 @@ const apiCalls = await Astro.locals.banners.try(
value: method.label,
placeholder: contactMethodInfo.formattedValue,
}}
error={contactMethodUpdateInputErrors.label}
/>
<InputText
@@ -1122,6 +1157,7 @@ const apiCalls = await Astro.locals.banners.try(
value: method.value,
placeholder: 'e.g., mailto:contact@example.com or https://t.me/example',
}}
error={contactMethodUpdateInputErrors.value}
/>
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
@@ -1142,12 +1178,13 @@ const apiCalls = await Astro.locals.banners.try(
<InputText
label="Value"
description="With protocol (e.g., `mailto:contact@example.com` or `https://t.me/example`)"
description={`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`}
name="value"
inputProps={{
required: true,
placeholder: 'mailto:contact@example.com',
placeholder: 'contact@example.com',
}}
error={contactMethodAddInputErrors.value}
/>
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
@@ -1164,6 +1201,16 @@ const apiCalls = await Astro.locals.banners.try(
</p>
)
}
<div class="mb-6 flex justify-center">
<Button
as="a"
href="/docs/api"
icon="ri:book-open-line"
color="gray"
size="sm"
label=" Documentation"
/>
</div>
{
apiCalls.map((call) => (
<FormSubSection title={`${call.method} ${call.path}`}>
@@ -1176,5 +1223,73 @@ const apiCalls = await Astro.locals.banners.try(
))
}
</FormSection>
<FormSection title="Evidence Images" id="evidence-images">
<FormSubSection title="Existing Evidence Images">
{
evidenceImageUrls.length === 0 ? (
<p class="border-night-600 bg-night-800 text-day-300 rounded-xl border p-6 text-center">
No evidence images yet.
</p>
) : (
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{evidenceImageUrls.map((imageUrl: string) => (
<div class="border-night-600 bg-night-800 group relative rounded-md border p-2">
<MyPicture
src={imageUrl}
alt="Evidence image"
class="aspect-square w-full rounded object-cover"
width={200}
height={200}
/>
<form
method="POST"
action={actions.admin.service.evidenceImage.delete}
class="absolute top-1 right-1"
>
<input type="hidden" name="fileUrl" value={imageUrl} />
<Button
type="submit"
variant="faded"
color="danger"
size="sm"
icon="ri:delete-bin-line"
iconOnly
label="Delete Image"
class="opacity-0 transition-opacity group-hover:opacity-100"
/>
</form>
<input
type="text"
readonly
value={`![Evidence](${imageUrl})`}
class="bg-night-700 text-day-200 mt-2 w-full cursor-text rounded border p-2 font-mono text-xs select-all"
/>
</div>
))}
</div>
)
}
</FormSubSection>
<FormSubSection title="Add New Evidence Image">
<form
method="POST"
action={actions.admin.service.evidenceImage.add}
class="space-y-4"
enctype="multipart/form-data"
>
<input type="hidden" name="serviceId" value={service.id} />
<InputImageFile
label="Upload Image"
name="imageFile"
description="Upload an evidence image."
error={evidenceImageAddInputErrors.imageFile}
required
/>
<InputSubmitButton label="Add Image" icon="ri:add-line" hideCancel />
</form>
</FormSubSection>
</FormSection>
</div>
</BaseLayout>

View File

@@ -11,6 +11,7 @@ import { SOURCE_CODE_URL } from 'astro:env/server'
import { kycLevels } from '../../constants/kycLevels'
import { verificationStatuses } from '../../constants/verificationStatus'
import { serviceVisibilities } from '../../constants/serviceVisibility'
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
Access basic service data via our public API.
@@ -58,6 +59,12 @@ type ServiceResponse = {
name: string
description: string
}
kycLevelClarification: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
kycLevelClarificationInfo: {
value: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
name: string
description: string
}
categories: {
name: string
slug: string
@@ -99,6 +106,16 @@ type ServiceResponse = {
))}
</ul>
#### KYC Level Clarifications
<ul>
{kycLevelClarifications.map((clarification) => (
<li key={clarification.value}>
<strong>{clarification.value}</strong>: {clarification.description}
</li>
))}
</ul>
### Examples
#### Request
@@ -131,6 +148,11 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
"name": "Guaranteed no KYC",
"description": "Terms explicitly state KYC will never be requested."
},
"kycLevelClarification": "NONE",
"kycLevelClarificationInfo": {
"value": "NONE",
"description": "No clarification needed."
},
"categories": [
{
"name": "Exchange",

View File

@@ -19,6 +19,7 @@ import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { contactMethodUrlTypes } from '../../constants/contactMethods'
import { currencies } from '../../constants/currencies'
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
import { kycLevels } from '../../constants/kycLevels'
@@ -208,26 +209,42 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="allServiceUrls"
inputProps={{
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
class: 'min-h-24',
placeholder: 'example1.com\nexample2.onion\nexample3.b32.i2p',
class: 'md:min-h-20 min-h-24 h-full',
required: true,
}}
class="row-span-2 flex flex-col self-stretch"
class="flex flex-col self-stretch"
error={inputErrors.allServiceUrls}
/>
<InputTextArea
label="ToS URLs"
description="One per line"
name="tosUrls"
label="Contact Methods"
description={[
'One per line.',
`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`,
].join('\n')}
name="contactMethods"
inputProps={{
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
class: 'md:min-h-24',
required: true,
placeholder: 'contact@example.com\nt.me/example\n+123 456 7890',
class: 'h-full',
}}
error={inputErrors.tosUrls}
class="flex flex-col self-stretch"
error={inputErrors.contactMethods}
/>
</div>
<InputTextArea
label="ToS URLs"
description="One per line"
name="tosUrls"
inputProps={{
placeholder: 'example.com/tos',
required: true,
class: 'min-h-10',
}}
error={inputErrors.tosUrls}
/>
<InputCardGroup
name="kycLevel"
label="KYC Level"