From dacf73a8048a556dc6377f9fe2751251dae567f7 Mon Sep 17 00:00:00 2001
From: pluja
Date: Wed, 4 Jun 2025 16:41:32 +0000
Subject: [PATCH] Release 202506041641
---
.clinerules | 53 ----
justfile | 36 ---
web/.env.example | 2 +-
web/astro.config.mjs | 9 +-
web/package-lock.json | 101 +++++--
web/package.json | 2 +
.../migration.sql | 11 +
web/prisma/schema.prisma | 3 +-
.../triggers/12_notification_push_trigger.sql | 16 ++
web/src/actions/admin/service.ts | 70 ++++-
web/src/actions/api/service.ts | 8 +
web/src/actions/serviceSuggestion.ts | 13 +-
web/src/constants/contactMethods.ts | 68 ++++-
web/src/lib/assert.ts | 10 +
web/src/lib/fileStorage.ts | 47 ++++
web/src/lib/json.ts | 35 +++
web/src/lib/localstorage.ts | 30 +--
web/src/lib/postgresListenerIntegration.ts | 248 ++++++++++++++++++
web/src/lib/serverEnvVariables.ts | 14 +
web/src/lib/webPush.ts | 23 +-
web/src/lib/zodUtils.ts | 42 ++-
.../pages/admin/services/[slug]/edit.astro | 123 ++++++++-
web/src/pages/docs/api.mdx | 22 ++
web/src/pages/service-suggestion/new.astro | 37 ++-
24 files changed, 839 insertions(+), 184 deletions(-)
delete mode 100644 .clinerules
create mode 100644 web/prisma/migrations/20250603051814_default_clarification_to_none/migration.sql
create mode 100644 web/prisma/triggers/12_notification_push_trigger.sql
create mode 100644 web/src/lib/assert.ts
create mode 100644 web/src/lib/json.ts
create mode 100644 web/src/lib/postgresListenerIntegration.ts
create mode 100644 web/src/lib/serverEnvVariables.ts
diff --git a/.clinerules b/.clinerules
deleted file mode 100644
index 1063fcd..0000000
--- a/.clinerules
+++ /dev/null
@@ -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`, 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;
- } = {}
- );
- ```
diff --git a/justfile b/justfile
index a94a3ad..9f39b9f 100644
--- a/justfile
+++ b/justfile
@@ -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
diff --git a/web/.env.example b/web/.env.example
index 8638f8b..19bf19d 100644
--- a/web/.env.example
+++ b/web/.env.example
@@ -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
diff --git a/web/astro.config.mjs b/web/astro.config.mjs
index 1caf909..f037a9e 100644
--- a/web/astro.config.mjs
+++ b/web/astro.config.mjs
@@ -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({
diff --git a/web/package-lock.json b/web/package-lock.json
index 270f22a..075d664 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -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"
}
diff --git a/web/package.json b/web/package.json
index 35600d9..18137a7 100644
--- a/web/package.json
+++ b/web/package.json
@@ -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",
diff --git a/web/prisma/migrations/20250603051814_default_clarification_to_none/migration.sql b/web/prisma/migrations/20250603051814_default_clarification_to_none/migration.sql
new file mode 100644
index 0000000..e5d2c48
--- /dev/null
+++ b/web/prisma/migrations/20250603051814_default_clarification_to_none/migration.sql
@@ -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';
diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma
index e9be338..0e52ad8 100644
--- a/web/prisma/schema.prisma
+++ b/web/prisma/schema.prisma
@@ -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])
diff --git a/web/prisma/triggers/12_notification_push_trigger.sql b/web/prisma/triggers/12_notification_push_trigger.sql
new file mode 100644
index 0000000..73d16cd
--- /dev/null
+++ b/web/prisma/triggers/12_notification_push_trigger.sql
@@ -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();
\ No newline at end of file
diff --git a/web/src/actions/admin/service.ts b/web/src/actions/admin/service.ts
index 38bbde4..38ec6ae 100644
--- a/web/src/actions/admin/service.ts
+++ b/web/src/actions/admin/service.ts
@@ -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 }
+ },
+ }),
+ },
}
diff --git a/web/src/actions/api/service.ts b/web/src/actions/api/service.ts
index 5a21dd7..7942197 100644
--- a/web/src/actions/api/service.ts
+++ b/web/src/actions/api/service.ts
@@ -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(
diff --git a/web/src/actions/serviceSuggestion.ts b/web/src/actions/serviceSuggestion.ts
index 3a0dd2f..5452159 100644
--- a/web/src/actions/serviceSuggestion.ts
+++ b/web/src/actions/serviceSuggestion.ts
@@ -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,
})
diff --git a/web/src/constants/contactMethods.ts b/web/src/constants/contactMethods.ts
index 9ab1e83..0f9eeda 100644
--- a/web/src/constants/contactMethods.ts
+++ b/web/src/constants/contactMethods.ts
@@ -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 = {
type: T
label: string
@@ -10,6 +13,7 @@ type ContactMethodInfo = {
matcher: RegExp
formatter: (match: RegExpMatchArray) => string | null
icon: string
+ urlType: string
}
export const {
@@ -22,9 +26,10 @@ export const {
(type): ContactMethodInfo => ({
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 = {
+ value: T
+ labelPlural: string
+}
+
+export const {
+ dataArray: contactMethodUrlTypes,
+ dataObject: contactMethodUrlTypesById,
+ getFn: getContactMethodUrlTypeInfo,
+} = makeHelpersForOptions(
+ 'value',
+ (value): ContactMethodUrlTypeInfo => ({
+ 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>
+>
diff --git a/web/src/lib/assert.ts b/web/src/lib/assert.ts
new file mode 100644
index 0000000..1c494d7
--- /dev/null
+++ b/web/src/lib/assert.ts
@@ -0,0 +1,10 @@
+/**
+ * Gives an error if the type is not equal to 1.
+ *
+ * @example
+ * ```ts
+ * type _ExpectEquals = Assert> // Gives an error
+ * type _ExpectEquals = Assert> // No error
+ * ```
+ */
+export type Assert = T
diff --git a/web/src/lib/fileStorage.ts b/web/src/lib/fileStorage.ts
index 4b6e90e..8140a12 100644
--- a/web/src/lib/fileStorage.ts
+++ b/web/src/lib/fileStorage.ts
@@ -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 {
+ 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 {
+ // 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://)
diff --git a/web/src/lib/json.ts b/web/src/lib/json.ts
new file mode 100644
index 0000000..6cc7bad
--- /dev/null
+++ b/web/src/lib/json.ts
@@ -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
+
+export function zodParseJSON, D extends z.output | undefined = undefined>(
+ schema: T,
+ stringValue: string | null | undefined,
+ defaultValue?: D
+): D | z.output {
+ if (!stringValue) return defaultValue as D
+
+ let jsonValue: D | z.output = 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
+}
diff --git a/web/src/lib/localstorage.ts b/web/src/lib/localstorage.ts
index b73ab44..198b086 100644
--- a/web/src/lib/localstorage.ts
+++ b/web/src/lib/localstorage.ts
@@ -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>,
+ Schemas extends Record,
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 | 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) => {
diff --git a/web/src/lib/postgresListenerIntegration.ts b/web/src/lib/postgresListenerIntegration.ts
new file mode 100644
index 0000000..2e39a09
--- /dev/null
+++ b/web/src/lib/postgresListenerIntegration.ts
@@ -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)
+}
diff --git a/web/src/lib/serverEnvVariables.ts b/web/src/lib/serverEnvVariables.ts
new file mode 100644
index 0000000..4e4355a
--- /dev/null
+++ b/web/src/lib/serverEnvVariables.ts
@@ -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(
+ name: T
+): NonNullable<(typeof untypedServerEnvVariables)[T]> {
+ const value = untypedServerEnvVariables[name]
+ if (!value) throw new Error(`${name} environment variable is not set`)
+ return value
+}
diff --git a/web/src/lib/webPush.ts b/web/src/lib/webPush.ts
index 50f51ff..9ccade2 100644
--- a/web/src/lib/webPush.ts
+++ b/web/src/lib/webPush.ts
@@ -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(
diff --git a/web/src/lib/zodUtils.ts b/web/src/lib/zodUtils.ts
index a199022..fdf322d 100644
--- a/web/src/lib/zodUtils.ts
+++ b/web/src/lib/zodUtils.ts
@@ -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 (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 = [
diff --git a/web/src/pages/admin/services/[slug]/edit.astro b/web/src/pages/admin/services/[slug]/edit.astro
index 00a60f7..a29fac2 100644
--- a/web/src/pages/admin/services/[slug]/edit.astro
+++ b/web/src/pages/admin/services/[slug]/edit.astro
@@ -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}
/>
@@ -1142,12 +1178,13 @@ const apiCalls = await Astro.locals.banners.try(
type.labelPlural).join(', ')}`}
name="value"
inputProps={{
required: true,
- placeholder: 'mailto:contact@example.com',
+ placeholder: 'contact@example.com',
}}
+ error={contactMethodAddInputErrors.value}
/>
@@ -1164,6 +1201,16 @@ const apiCalls = await Astro.locals.banners.try(
)
}
+
+
+
{
apiCalls.map((call) => (
@@ -1176,5 +1223,73 @@ const apiCalls = await Astro.locals.banners.try(
))
}
+
+
+
+ {
+ evidenceImageUrls.length === 0 ? (
+
+ No evidence images yet.
+
+ ) : (
+
+ {evidenceImageUrls.map((imageUrl: string) => (
+
+
+
+
+
+
+ ))}
+
+ )
+ }
+
+
+
+
+
diff --git a/web/src/pages/docs/api.mdx b/web/src/pages/docs/api.mdx
index a9208d9..5149dea 100644
--- a/web/src/pages/docs/api.mdx
+++ b/web/src/pages/docs/api.mdx
@@ -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 = {
))}
+#### KYC Level Clarifications
+
+
+{kycLevelClarifications.map((clarification) => (
+ -
+ {clarification.value}: {clarification.description}
+
+))}
+
+
### 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",
diff --git a/web/src/pages/service-suggestion/new.astro b/web/src/pages/service-suggestion/new.astro
index cbb29a5..a8ebce5 100644
--- a/web/src/pages/service-suggestion/new.astro
+++ b/web/src/pages/service-suggestion/new.astro
@@ -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}
/>
+
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}
/>
+
+