Compare commits

...

16 Commits

Author SHA1 Message Date
pluja
6a6908518d Release 202506020353 2025-06-02 03:53:03 +00:00
pluja
d065910ff3 Release 202506011533 2025-06-01 15:33:44 +00:00
pluja
490433b002 Release 202506011511 2025-06-01 15:11:37 +00:00
pluja
e17bc8a521 Release 202505312236 2025-05-31 22:36:39 +00:00
pluja
ec1215f2ae Release 202505311848 2025-05-31 18:48:33 +00:00
pluja
3afa824c18 Release 202505311149 2025-05-31 11:49:38 +00:00
pluja
9a68112e24 Release 202505311113 2025-05-31 11:13:24 +00:00
pluja
0c40d8eec5 Release 202505311002 2025-05-31 10:02:50 +00:00
pluja
e16c9b64ed Release 202505311001 2025-05-31 10:01:35 +00:00
pluja
22944fcdb3 Release 202505310921 2025-05-31 09:21:32 +00:00
pluja
f7f380c591 Release 202505302056 2025-05-30 20:56:04 +00:00
pluja
577c524ca2 Release 202505302029 2025-05-30 20:29:01 +00:00
pluja
da12e8de79 add basic API plus minor updates and fixes 2025-05-30 08:17:23 +00:00
pluja
ea40f17d3c Release 202505281348 2025-05-28 13:48:27 +00:00
pluja
7e0d41cc7a Release 202505280851 2025-05-28 08:51:59 +00:00
pluja
70a097054b Release 202505271800 2025-05-27 18:00:08 +00:00
68 changed files with 2605 additions and 542 deletions

View File

@@ -7,3 +7,7 @@ ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.o
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p" I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
RELEASE_NUMBER=123 RELEASE_NUMBER=123
RELEASE_DATE="2025-05-23T19:00:00.000Z" RELEASE_DATE="2025-05-23T19:00:00.000Z"
# Generated with `npx web-push generate-vapid-keys`
VAPID_PUBLIC_KEY="<vapid-public-key-placeholder>"
VAPID_PRIVATE_KEY="<vapid-private-key-placeholder>"
VAPID_SUBJECT="mailto:no-reply@kycnot.me"

View File

@@ -177,6 +177,12 @@ export default defineConfig({
url: true, url: true,
optional: false, optional: false,
}), }),
LOGS_UI_URL: envField.string({
context: 'server',
access: 'secret',
url: true,
optional: true,
}),
RELEASE_NUMBER: envField.number({ RELEASE_NUMBER: envField.number({
context: 'server', context: 'server',
@@ -189,6 +195,27 @@ export default defineConfig({
access: 'public', access: 'public',
optional: true, optional: true,
}), }),
// Generated with `npx web-push generate-vapid-keys`
VAPID_PUBLIC_KEY: envField.string({
context: 'server',
access: 'public',
min: 1,
optional: false,
}),
// Generated with `npx web-push generate-vapid-keys`
VAPID_PRIVATE_KEY: envField.string({
context: 'server',
access: 'secret',
min: 1,
optional: false,
}),
VAPID_SUBJECT: envField.string({
context: 'server',
access: 'secret',
min: 1,
optional: false,
}),
}, },
}, },
}) })

123
web/package-lock.json generated
View File

@@ -44,6 +44,7 @@
"tailwindcss": "4.1.7", "tailwindcss": "4.1.7",
"typescript": "5.8.3", "typescript": "5.8.3",
"unique-username-generator": "1.4.0", "unique-username-generator": "1.4.0",
"web-push": "3.6.7",
"zod-form-data": "2.0.7" "zod-form-data": "2.0.7"
}, },
"devDependencies": { "devDependencies": {
@@ -60,6 +61,7 @@
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/react": "19.1.4", "@types/react": "19.1.4",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.32.1", "@typescript-eslint/parser": "8.32.1",
"astro-icon": "1.1.5", "astro-icon": "1.1.5",
"date-fns": "4.1.0", "date-fns": "4.1.0",
@@ -3215,6 +3217,16 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.5.13", "version": "8.5.13",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
@@ -3797,6 +3809,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4063,6 +4084,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/ast-types-flow": { "node_modules/ast-types-flow": {
"version": "0.0.8", "version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -4822,6 +4855,12 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"license": "MIT"
},
"node_modules/boolbase": { "node_modules/boolbase": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -4916,6 +4955,12 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bundle-name": { "node_modules/bundle-name": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -6157,6 +6202,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -8181,6 +8235,15 @@
"integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==", "integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-cache-semantics": { "node_modules/http-cache-semantics": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
@@ -8203,6 +8266,19 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -8935,6 +9011,27 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.0",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -10556,6 +10653,12 @@
"mini-svg-data-uri": "cli.js" "mini-svg-data-uri": "cli.js"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -12696,7 +12799,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass-formatter": { "node_modules/sass-formatter": {
@@ -14675,6 +14777,25 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/web-streams-polyfill": { "node_modules/web-streams-polyfill": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",

View File

@@ -58,6 +58,7 @@
"tailwindcss": "4.1.7", "tailwindcss": "4.1.7",
"typescript": "5.8.3", "typescript": "5.8.3",
"unique-username-generator": "1.4.0", "unique-username-generator": "1.4.0",
"web-push": "3.6.7",
"zod-form-data": "2.0.7" "zod-form-data": "2.0.7"
}, },
"devDependencies": { "devDependencies": {
@@ -74,6 +75,7 @@
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/react": "19.1.4", "@types/react": "19.1.4",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.32.1", "@typescript-eslint/parser": "8.32.1",
"astro-icon": "1.1.5", "astro-icon": "1.1.5",
"date-fns": "4.1.0", "date-fns": "4.1.0",

View File

@@ -0,0 +1,2 @@
-- Enable pg_trgm extension for similarity functions
CREATE EXTENSION IF NOT EXISTS pg_trgm;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "previousSlugs" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- CreateIndex
CREATE INDEX "Service_previousSlugs_idx" ON "Service"("previousSlugs");

View File

@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "KycLevelClarification" AS ENUM ('NONE', 'DEPENDS_ON_PARTNERS');
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "kycLevelClarification" "KycLevelClarification",
ADD COLUMN "kycLevelDetailsId" INTEGER;

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "PushSubscription" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
-- CreateIndex
CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId");
-- CreateIndex
CREATE INDEX "PushSubscription_endpoint_idx" ON "PushSubscription"("endpoint");
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -297,6 +297,11 @@ enum ServiceSuggestionType {
EDIT_SERVICE EDIT_SERVICE
} }
enum KycLevelClarification {
NONE
DEPENDS_ON_PARTNERS
}
model ServiceSuggestion { model ServiceSuggestion {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
type ServiceSuggestionType type ServiceSuggestionType
@@ -336,9 +341,11 @@ model Service {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
slug String @unique slug String @unique
previousSlugs String[] @default([])
description String description String
categories Category[] @relation("ServiceToCategory") categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4) kycLevel Int @default(4)
kycLevelClarification KycLevelClarification?
overallScore Int @default(0) overallScore Int @default(0)
privacyScore Int @default(0) privacyScore Int @default(0)
trustScore Int @default(0) trustScore Int @default(0)
@@ -384,6 +391,7 @@ model Service {
onVerificationChangeForServices NotificationPreferences[] @relation("onVerificationChangeForServices") onVerificationChangeForServices NotificationPreferences[] @relation("onVerificationChangeForServices")
Notification Notification[] Notification Notification[]
affiliatedUsers ServiceUser[] @relation("ServiceUsers") affiliatedUsers ServiceUser[] @relation("ServiceUsers")
kycLevelDetailsId Int?
@@index([listedAt]) @@index([listedAt])
@@index([overallScore]) @@index([overallScore])
@@ -396,6 +404,7 @@ model Service {
@@index([createdAt]) @@index([createdAt])
@@index([updatedAt]) @@index([updatedAt])
@@index([slug]) @@index([slug])
@@index([previousSlugs])
} }
model ServiceContactMethod { model ServiceContactMethod {
@@ -506,6 +515,7 @@ model User {
notifications Notification[] @relation("NotificationOwner") notifications Notification[] @relation("NotificationOwner")
notificationPreferences NotificationPreferences? notificationPreferences NotificationPreferences?
serviceAffiliations ServiceUser[] @relation("UserServices") serviceAffiliations ServiceUser[] @relation("UserServices")
pushSubscriptions PushSubscription[]
@@index([createdAt]) @@index([createdAt])
@@index([totalKarma]) @@index([totalKarma])
@@ -654,3 +664,21 @@ model Announcement {
@@index([isActive, startDate, endDate]) @@index([isActive, startDate, endDate])
} }
model PushSubscription {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
endpoint String @unique
/// Public key for encryption
p256dh String
/// Authentication secret
auth String
/// To identify different devices
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([endpoint])
}

View File

@@ -18,8 +18,9 @@ import {
type User, type User,
type ServiceVisibility, type ServiceVisibility,
ServiceSuggestionType, ServiceSuggestionType,
KycLevelClarification,
} from '@prisma/client' } from '@prisma/client'
import { uniqBy } from 'lodash-es' import { omit, uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator' import { generateUsername } from 'unique-username-generator'
import { kycLevels } from '../src/constants/kycLevels' import { kycLevels } from '../src/constants/kycLevels'
@@ -612,8 +613,14 @@ const generateFakeService = (users: User[]) => {
return { return {
name, name,
slug, slug,
previousSlugs: faker.helpers.maybe(() => [`${slug}-old`], { probability: 0.5 }),
description: faker.helpers.arrayElement(serviceDescriptions), description: faker.helpers.arrayElement(serviceDescriptions),
kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)), kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)),
kycLevelClarification: faker.helpers.maybe(
() =>
faker.helpers.arrayElement(omit(Object.values(KycLevelClarification), [KycLevelClarification.NONE])),
{ probability: 0.25 }
),
overallScore: 0, overallScore: 0,
privacyScore: 0, privacyScore: 0,
trustScore: 0, trustScore: 0,
@@ -916,7 +923,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
link: 'https://kycnot.me', link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L', picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
}, },
moderator: { moderator: {
name: 'moderator_dev', name: 'moderator_dev',
@@ -928,7 +935,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me', verifiedLink: 'https://kycnot.me',
totalKarma: 1001, totalKarma: 1001,
link: 'https://kycnot.me', link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L', picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
}, },
verified: { verified: {
name: 'verified_dev', name: 'verified_dev',
@@ -1134,7 +1141,7 @@ async function main() {
// ---- Create services ---- // ---- Create services ----
const services = await Promise.all( const services = await Promise.all(
Array.from({ length: numServices }, async () => { Array.from({ length: numServices }, async () => {
const serviceData = generateFakeService(users) const serviceData = generateFakeService([...users, ...Object.values(specialUsers)])
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 }) const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
const service = await prisma.service.create({ const service = await prisma.service.create({
@@ -1274,7 +1281,7 @@ async function main() {
// ---- Create service suggestions for normal_dev user ---- // ---- Create service suggestions for normal_dev user ----
// First create 3 CREATE_SERVICE suggestions with their services // First create 3 CREATE_SERVICE suggestions with their services
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const serviceData = generateFakeService(users) const serviceData = generateFakeService([...users, ...Object.values(specialUsers)])
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 }) const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
const service = await prisma.service.create({ const service = await prisma.service.create({

83
web/public/sw.js Normal file
View File

@@ -0,0 +1,83 @@
// @ts-check
/// <reference lib="webworker" />
/** @type {ServiceWorkerGlobalScope} */
// @ts-expect-error
const typedSelf = self
const CACHE_NAME = 'kycnot-sw-push-notifications-v1'
typedSelf.addEventListener('install', (event) => {
console.log('Service Worker installing')
typedSelf.skipWaiting()
})
typedSelf.addEventListener('activate', (event) => {
console.log('Service Worker activating')
event.waitUntil(typedSelf.clients.claim())
})
typedSelf.addEventListener('push', (event) => {
console.log('Push event received:', event)
if (!event.data) {
console.log('Push event but no data')
return
}
let notificationData
try {
notificationData = event.data.json()
} catch (error) {
console.error('Error parsing push data:', error)
notificationData = {
title: 'New Notification',
options: {
body: event.data.text() || 'You have a new notification',
},
}
}
const { title, options } = notificationData
const notificationOptions = {
body: options.body || '',
icon: options.icon || '/favicon.svg',
badge: options.badge || '/favicon.svg',
data: options.data || {},
requireInteraction: false,
silent: false,
...options,
}
event.waitUntil(typedSelf.registration.showNotification(title, notificationOptions))
})
typedSelf.addEventListener('notificationclick', (event) => {
console.log('Notification clicked:', event)
event.notification.close()
const url = event.notification.data?.url || '/'
event.waitUntil(
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {
// If a window is already open, focus it
for (const client of clientList) {
if (client.url === url && 'focus' in client) {
return client.focus()
}
}
// Otherwise, open a new window
if (typedSelf.clients.openWindow) {
return typedSelf.clients.openWindow(url)
}
})
)
})
typedSelf.addEventListener('notificationclose', (event) => {
console.log('Notification closed:', event)
})

View File

@@ -1,11 +1,9 @@
import { type Prisma, type PrismaClient } from '@prisma/client' import { type Prisma } from '@prisma/client'
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { z } from 'zod' import { z } from 'zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction' import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma as prismaInstance } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
const prisma = prismaInstance as PrismaClient
const selectAnnouncementReturnFields = { const selectAnnouncementReturnFields = {
id: true, id: true,

View File

@@ -1,15 +1,17 @@
import { adminAnnouncementActions } from './announcement' import { adminAnnouncementActions } from './announcement'
import { adminAttributeActions } from './attribute' import { adminAttributeActions } from './attribute'
import { adminEventActions } from './event' import { adminEventActions } from './event'
import { adminNotificationActions } from './notification'
import { adminServiceActions } from './service' import { adminServiceActions } from './service'
import { adminServiceSuggestionActions } from './serviceSuggestion' import { adminServiceSuggestionActions } from './serviceSuggestion'
import { adminUserActions } from './user' import { adminUserActions } from './user'
import { verificationStep } from './verificationStep' import { verificationStep } from './verificationStep'
export const adminActions = { export const adminActions = {
attribute: adminAttributeActions,
announcement: adminAnnouncementActions, announcement: adminAnnouncementActions,
attribute: adminAttributeActions,
event: adminEventActions, event: adminEventActions,
notification: adminNotificationActions,
service: adminServiceActions, service: adminServiceActions,
serviceSuggestions: adminServiceSuggestionActions, serviceSuggestions: adminServiceSuggestionActions,
user: adminUserActions, user: adminUserActions,

View File

@@ -0,0 +1,80 @@
import { z } from 'astro/zod'
import { sumBy } from 'lodash-es'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import { sendPushNotification } from '../../lib/webPush'
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
export const adminNotificationActions = {
webPush: {
test: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userNames: stringListOfSlugsSchemaRequired,
title: z.string().min(1).nullable(),
body: z.string().nullable(),
url: z.string().url().optional(),
}),
handler: async (input) => {
const subscriptions = await prisma.pushSubscription.findMany({
where: { user: { name: { in: input.userNames } } },
select: {
id: true,
endpoint: true,
p256dh: true,
auth: true,
userAgent: true,
user: {
select: {
id: true,
name: true,
},
},
},
})
const results = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
{
title: input.title ?? 'Test Notification',
body: input.body ?? 'This is a test push notification from KYCNot.me',
url: input.url ?? '/',
}
)
// If subscription is invalid, remove it from database
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({
where: { id: subscription.id },
})
console.info(`Removed invalid subscription for user ${subscription.user.name}`)
}
return result.success
})
)
const successCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 1 : 0))
const failureCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 0 : 1))
const now = new Date()
return {
message: `Sent to ${successCount.toLocaleString()} devices, ${failureCount.toLocaleString()} failed. Sent at ${now.toLocaleString()}`,
totalSubscriptions: subscriptions.length,
successCount,
failureCount,
sentAt: now,
}
},
}),
},
}

View File

@@ -1,47 +1,14 @@
import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client' import { Currency, ServiceVisibility, VerificationStatus, KycLevelClarification } from '@prisma/client'
import { z } from 'astro/zod' import { z } from 'astro/zod'
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { uniq } from 'lodash-es'
import slugify from 'slugify' import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction' import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage' import { saveFileLocally } from '../../lib/fileStorage'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { import { separateServiceUrlsByType } from '../../lib/urls'
imageFileSchema, import { imageFileSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../../lib/zodUtils'
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../../lib/zodUtils'
const serviceSchemaBase = z.object({
id: z.number().int().positive(),
slug: z
.string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
name: z.string().min(1).max(20),
description: z.string().min(1),
serviceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: z.coerce.number().int().min(0).max(4),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
verificationStatus: z.nativeEnum(VerificationStatus),
verificationSummary: z.string().optional().nullable().default(null),
verificationProofMd: z.string().optional().nullable().default(null),
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
referral: z
.string()
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
.optional()
.nullable()
.default(null),
imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
internalNote: z.string().optional(),
})
const addSlugIfMissing = < const addSlugIfMissing = <
T extends { T extends {
@@ -62,12 +29,52 @@ const addSlugIfMissing = <
}), }),
}) })
const serviceSchemaBase = z.object({
id: z.number().int().positive(),
slug: z
.string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
name: z.string().min(1).max(40),
description: z.string().min(1),
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
kycLevel: z.coerce.number().int().min(0).max(4),
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
verificationStatus: z.nativeEnum(VerificationStatus),
verificationSummary: z.string().optional().nullable().default(null),
verificationProofMd: z.string().optional().nullable().default(null),
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
referral: z
.string()
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
.optional()
.nullable()
.default(null),
imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
internalNote: z.string().optional(),
})
// Define schema for the create action input
const createServiceInputSchema = serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing)
// Define schema for the update action input
const updateServiceInputSchema = serviceSchemaBase
.extend({
removeImage: z.boolean().optional(),
})
.transform(addSlugIfMissing)
export const adminServiceActions = { export const adminServiceActions = {
create: defineProtectedAction({ create: defineProtectedAction({
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing), input: createServiceInputSchema,
handler: async (input, context) => { handler: async (input: z.infer<typeof createServiceInputSchema>, context) => {
const existing = await prisma.service.findUnique({ const existing = await prisma.service.findUnique({
where: { where: {
slug: input.slug, slug: input.slug,
@@ -85,14 +92,22 @@ export const adminServiceActions = {
? await saveFileLocally(input.imageFile, input.imageFile.name) ? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined : undefined
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.create({ const service = await prisma.service.create({
data: { data: {
name: input.name, name: input.name,
description: input.description, description: input.description,
serviceUrls: input.serviceUrls, serviceUrls,
tosUrls: input.tosUrls, tosUrls: input.tosUrls,
onionUrls: input.onionUrls, onionUrls,
i2pUrls,
kycLevel: input.kycLevel, kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification,
verificationStatus: input.verificationStatus, verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary, verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd, verificationProofMd: input.verificationProofMd,
@@ -134,12 +149,8 @@ export const adminServiceActions = {
update: defineProtectedAction({ update: defineProtectedAction({
accept: 'form', accept: 'form',
permissions: 'admin', permissions: 'admin',
input: serviceSchemaBase input: updateServiceInputSchema,
.extend({ handler: async (input: z.infer<typeof updateServiceInputSchema>) => {
removeImage: z.boolean().optional(),
})
.transform(addSlugIfMissing),
handler: async (input) => {
const anotherServiceWithNewSlug = await prisma.service.findUnique({ const anotherServiceWithNewSlug = await prisma.service.findUnique({
where: { where: {
slug: input.slug, slug: input.slug,
@@ -154,19 +165,24 @@ export const adminServiceActions = {
}) })
} }
const imageUrl = input.removeImage
? null
: input.imageFile
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const existingService = await prisma.service.findUnique({ const existingService = await prisma.service.findUnique({
where: { id: input.id }, where: { id: input.id },
include: { select: {
categories: true, slug: true,
previousSlugs: true,
categories: {
select: {
id: true,
},
},
attributes: { attributes: {
include: { select: {
attribute: true, attributeId: true,
attribute: {
select: {
id: true,
},
},
}, },
}, },
}, },
@@ -187,15 +203,29 @@ export const adminServiceActions = {
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId)) const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId)) const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
const imageUrl = input.removeImage
? null
: input.imageFile
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.update({ const service = await prisma.service.update({
where: { id: input.id }, where: { id: input.id },
data: { data: {
name: input.name, name: input.name,
description: input.description, description: input.description,
serviceUrls: input.serviceUrls, serviceUrls,
tosUrls: input.tosUrls, tosUrls: input.tosUrls,
onionUrls: input.onionUrls, onionUrls,
i2pUrls,
kycLevel: input.kycLevel, kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification,
verificationStatus: input.verificationStatus, verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary, verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd, verificationProofMd: input.verificationProofMd,
@@ -204,6 +234,14 @@ export const adminServiceActions = {
serviceVisibility: input.serviceVisibility, serviceVisibility: input.serviceVisibility,
slug: input.slug, slug: input.slug,
overallScore: input.overallScore, overallScore: input.overallScore,
previousSlugs:
existingService.slug !== input.slug
? {
set: uniq([...existingService.previousSlugs, existingService.slug]).filter(
(slug) => slug !== input.slug
),
}
: undefined,
imageUrl, imageUrl,
categories: { categories: {

View File

@@ -0,0 +1,5 @@
import { apiServiceActions } from './service'
export const apiActions = {
service: apiServiceActions,
}

View File

@@ -0,0 +1,143 @@
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { pick } from 'lodash-es'
import { getKycLevelInfo } from '../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../constants/verificationStatus'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import { zodUrlOptionalProtocol } from '../../lib/zodUtils'
import type { Prisma } from '@prisma/client'
export const apiServiceActions = {
get: defineProtectedAction({
accept: 'json',
permissions: 'guest',
input: z.object({
id: z.coerce.number().int().positive().optional(),
slug: z
.string()
.min(1)
.max(2048)
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
url: zodUrlOptionalProtocol.optional(),
}),
handler: async (input, context) => {
if (!input.id && !input.slug && !input.url) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'At least one of the following parameters is required: id, slug, url',
})
}
const urlVariants = input.url
? [input.url]
.flatMap((url) =>
[
url,
url.startsWith('http://') ? url.replace('http://', 'https://') : undefined,
url.startsWith('https://') ? url.replace('https://', 'http://') : undefined,
].filter((url) => url !== undefined)
)
.flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`])
: undefined
const select = {
id: true,
name: true,
slug: true,
description: true,
kycLevel: true,
verificationStatus: true,
categories: {
select: {
name: true,
slug: true,
},
},
serviceUrls: true,
onionUrls: true,
i2pUrls: true,
tosUrls: true,
referral: true,
listedAt: true,
verifiedAt: true,
serviceVisibility: true,
} as const satisfies Prisma.ServiceSelect
let service = await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
OR: [
...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []),
...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []),
...(urlVariants
? ([
{ serviceUrls: { hasSome: urlVariants } },
{ onionUrls: { hasSome: urlVariants } },
{ i2pUrls: { hasSome: urlVariants } },
] satisfies Prisma.ServiceWhereInput[])
: []),
],
},
select,
})
if (!service && input.slug) {
service = await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
previousSlugs: { has: input.slug },
},
select,
})
}
if (
!service ||
(service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'ARCHIVED' &&
service.serviceVisibility !== 'UNLISTED') ||
!service.listedAt ||
service.listedAt > new Date()
) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
})
}
return {
id: service.id,
slug: service.slug,
name: service.name,
description: service.description,
serviceVisibility: service.serviceVisibility,
verificationStatus: service.verificationStatus,
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
'value',
'slug',
'label',
'labelShort',
'description',
]),
verifiedAt: service.verifiedAt,
kycLevel: service.kycLevel,
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
categories: service.categories,
listedAt: service.listedAt,
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
(url) => url + (service.referral ?? '')
),
tosUrls: service.tosUrls,
kycnotmeUrl: new URL(`/service/${service.slug}`, context.url).href,
}
},
}),
}

View File

@@ -1,5 +1,6 @@
import { accountActions } from './account' import { accountActions } from './account'
import { adminActions } from './admin' import { adminActions } from './admin'
import { apiActions } from './api'
import { commentActions } from './comment' import { commentActions } from './comment'
import { notificationActions } from './notifications' import { notificationActions } from './notifications'
import { serviceActions } from './service' import { serviceActions } from './service'
@@ -19,6 +20,7 @@ import { serviceSuggestionActions } from './serviceSuggestion'
export const server = { export const server = {
account: accountActions, account: accountActions,
admin: adminActions, admin: adminActions,
api: apiActions,
comment: commentActions, comment: commentActions,
notification: notificationActions, notification: notificationActions,
service: serviceActions, service: serviceActions,

View File

@@ -23,6 +23,63 @@ export const notificationActions = {
}) })
}, },
}), }),
webPush: {
subscribe: defineProtectedAction({
accept: 'json',
permissions: 'user',
input: z.object({
endpoint: z.string(),
p256dhKey: z.string(),
authKey: z.string(),
userAgent: z.string().optional(),
}),
handler: async (input, context) => {
await prisma.pushSubscription.upsert({
where: {
userId: context.locals.user.id,
endpoint: input.endpoint,
},
update: {
p256dh: input.p256dhKey,
auth: input.authKey,
userAgent: input.userAgent,
},
create: {
userId: context.locals.user.id,
endpoint: input.endpoint,
p256dh: input.p256dhKey,
auth: input.authKey,
userAgent: input.userAgent,
},
})
},
}),
unsubscribe: defineProtectedAction({
accept: 'json',
permissions: 'user',
input: z.object({
endpoint: z.string().optional(),
}),
handler: async (input, context) => {
if (input.endpoint) {
await prisma.pushSubscription.deleteMany({
where: {
userId: context.locals.user.id,
endpoint: input.endpoint,
},
})
} else {
await prisma.pushSubscription.deleteMany({
where: {
userId: context.locals.user.id,
},
})
}
},
}),
},
preferences: { preferences: {
update: defineProtectedAction({ update: defineProtectedAction({
accept: 'form', accept: 'form',
@@ -31,7 +88,7 @@ export const notificationActions = {
enableOnMyCommentStatusChange: z.coerce.boolean().optional(), enableOnMyCommentStatusChange: z.coerce.boolean().optional(),
enableAutowatchMyComments: z.coerce.boolean().optional(), enableAutowatchMyComments: z.coerce.boolean().optional(),
enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(), enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(),
karmaNotificationThreshold: z.coerce.number().int().min(1).optional(), karmaNotificationThreshold: z.coerce.number().int().min(1).max(1_000_000).optional(),
}), }),
handler: async (input, context) => { handler: async (input, context) => {
await prisma.notificationPreferences.upsert({ await prisma.notificationPreferences.upsert({

View File

@@ -1,10 +1,4 @@
import { import { Currency, KycLevelClarification } from '@prisma/client'
Currency,
ServiceSuggestionStatus,
ServiceSuggestionType,
ServiceVisibility,
VerificationStatus,
} from '@prisma/client'
import { z } from 'astro/zod' import { z } from 'astro/zod'
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { formatDistanceStrict } from 'date-fns' import { formatDistanceStrict } from 'date-fns'
@@ -12,14 +6,11 @@ import { formatDistanceStrict } from 'date-fns'
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation' import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
import { defineProtectedAction } from '../lib/defineProtectedAction' import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage' import { saveFileLocally } from '../lib/fileStorage'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { handleHoneypotTrap } from '../lib/honeypot' import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { import { separateServiceUrlsByType } from '../lib/urls'
imageFileSchemaRequired, import { imageFileSchemaRequired, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../lib/zodUtils'
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../lib/zodUtils'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
@@ -33,11 +24,12 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000 export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
const findPossibleDuplicates = async (input: { name: string }) => { const findPossibleDuplicates = async (input: { name: string }) => {
const possibleDuplicates = await prisma.service.findMany({ const matches = await findServicesBySimilarity(input.name, 0.3)
return await prisma.service.findMany({
where: { where: {
name: { id: {
contains: input.name, in: matches.map(({ id }) => id),
mode: 'insensitive',
}, },
}, },
select: { select: {
@@ -47,8 +39,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
description: true, description: true,
}, },
}) })
return possibleDuplicates
} }
const serializeExtraNotes = <T extends Record<string, unknown>>( const serializeExtraNotes = <T extends Record<string, unknown>>(
@@ -122,9 +112,9 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await prisma.serviceSuggestion.create({ const serviceSuggestion = await prisma.serviceSuggestion.create({
data: { data: {
type: ServiceSuggestionType.EDIT_SERVICE, type: 'EDIT_SERVICE',
notes: combinedNotes, notes: combinedNotes,
status: ServiceSuggestionStatus.PENDING, status: 'PENDING',
userId: context.locals.user.id, userId: context.locals.user.id,
serviceId: service.id, serviceId: service.id,
}, },
@@ -161,10 +151,10 @@ export const serviceSuggestionActions = {
{ message: 'Slug must be unique, try a different one' } { message: 'Slug must be unique, try a different one' }
), ),
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH), description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
serviceUrls: stringListOfUrlsSchemaRequired, allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired, tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)), kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
kycLevelClarification: z.nativeEnum(KycLevelClarification),
attributes: z.array(z.coerce.number().int().positive()), attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1), categories: z.array(z.coerce.number().int().positive()).min(1),
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1), acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
@@ -210,6 +200,12 @@ export const serviceSuggestionActions = {
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name) const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => { const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
const serviceSelect = { const serviceSelect = {
id: true, id: true,
@@ -221,18 +217,20 @@ export const serviceSuggestionActions = {
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
description: input.description, description: input.description,
serviceUrls: input.serviceUrls, serviceUrls,
tosUrls: input.tosUrls, tosUrls: input.tosUrls,
onionUrls: input.onionUrls, onionUrls,
i2pUrls,
kycLevel: input.kycLevel, kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
imageUrl, imageUrl,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED, verificationStatus: 'COMMUNITY_CONTRIBUTED',
overallScore: 0, overallScore: 0,
privacyScore: 0, privacyScore: 0,
trustScore: 0, trustScore: 0,
listedAt: new Date(), listedAt: new Date(),
serviceVisibility: ServiceVisibility.UNLISTED, serviceVisibility: 'UNLISTED',
categories: { categories: {
connect: input.categories.map((id) => ({ id })), connect: input.categories.map((id) => ({ id })),
}, },
@@ -248,8 +246,8 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await tx.serviceSuggestion.create({ const serviceSuggestion = await tx.serviceSuggestion.create({
data: { data: {
notes: input.notes, notes: input.notes,
type: ServiceSuggestionType.CREATE_SERVICE, type: 'CREATE_SERVICE',
status: ServiceSuggestionStatus.PENDING, status: 'PENDING',
userId: context.locals.user.id, userId: context.locals.user.id,
serviceId: service.id, serviceId: service.id,
}, },

View File

@@ -6,7 +6,7 @@ import { cn } from '../lib/cn'
import type { HTMLAttributes, Polymorphic } from 'astro/types' import type { HTMLAttributes, Polymorphic } from 'astro/types'
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic< type Props<Tag extends 'a' | 'button' | 'label' | 'span' = 'button'> = Polymorphic<
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> & Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
VariantProps<typeof button> & { VariantProps<typeof button> & {
as: Tag as: Tag
@@ -249,7 +249,7 @@ const button = tv({
}) })
const { const {
as: Tag = 'button' as 'a' | 'button' | 'label', as: Tag = 'button' as 'a' | 'button' | 'label' | 'span',
label, label,
icon, icon,
endIcon, endIcon,
@@ -286,8 +286,7 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
<ActualTag <ActualTag
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })} class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
role={role ?? role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
aria-disabled={disabled} aria-disabled={disabled}
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }} {...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
{...htmlProps} {...htmlProps}

View File

@@ -102,7 +102,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{...htmlProps} {...htmlProps}
id={`comment-${comment.id.toString()}`} id={`comment-${comment.id.toString()}`}
class={cn([ class={cn([
'group', 'group bg-night-700',
depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4', depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4',
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) && comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
'bg-[#182a1f]', 'bg-[#182a1f]',
@@ -270,12 +270,6 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />} {comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall icon="ri:alert-fill" color="yellow" text="Reported" inlineIcon />
)
}
{ {
comment.rating !== null && !comment.parentId && ( comment.rating !== null && !comment.parentId && (
<Tooltip <Tooltip
@@ -320,6 +314,19 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
color={commentStatusById.REJECTED.color} color={commentStatusById.REJECTED.color}
text={commentStatusById.REJECTED.label} text={commentStatusById.REJECTED.label}
inlineIcon inlineIcon
endIcon="ri:lock-line"
/>
)
}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall
icon="ri:alert-fill"
color="yellow"
text="Needs admin review"
inlineIcon
endIcon="ri:lock-line"
/> />
) )
} }

View File

@@ -89,7 +89,7 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id} data-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'} {comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
</button> </button>
<button <button

View File

@@ -92,7 +92,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<InputRating name="rating" label="Rating" /> <InputRating name="rating" label="Rating" />
<InputWrapper label="Tags" name="tags"> <InputWrapper label="I experienced..." name="tags">
<label class="flex cursor-pointer items-center gap-2"> <label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueKycRequested" class="text-red-400" /> <input type="checkbox" name="issueKycRequested" class="text-red-400" />
<span class="flex items-center gap-1 text-xs text-red-400"> <span class="flex items-center gap-1 text-xs text-red-400">

View File

@@ -27,6 +27,12 @@ const links = [
icon: 'i2p', icon: 'i2p',
external: true, external: true,
}, },
{
href: '/docs/api',
label: 'API',
icon: 'ri:plug-line',
external: false,
},
{ {
href: '/about', href: '/about',
label: 'About', label: 'About',

View File

@@ -3,24 +3,28 @@ import { cn } from '../lib/cn'
import Button from './Button.astro' import Button from './Button.astro'
import type { HTMLAttributes } from 'astro/types' import type { ComponentProps, HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & { type Props = HTMLAttributes<'div'> & {
hideCancel?: boolean hideCancel?: boolean
icon?: string icon?: string
label?: string label?: string
disabled?: boolean
color?: ComponentProps<typeof Button>['color']
} }
const { const {
hideCancel = false, hideCancel = false,
icon = 'ri:send-plane-2-line', icon = 'ri:send-plane-2-line',
label = 'Submit', label = 'Submit',
disabled = false,
class: className, class: className,
color = 'success',
...htmlProps ...htmlProps
} = Astro.props } = Astro.props
--- ---
<div class={cn('flex justify-between gap-2', className)} {...htmlProps}> <div class={cn('flex justify-between gap-2', className)} {...htmlProps}>
{!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />} {!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />}
<Button type="submit" label={label} icon={icon} class="ml-auto" color="success" /> <Button type="submit" label={label} icon={icon} class="ml-auto" color={color} disabled={disabled} />
</div> </div>

View File

@@ -0,0 +1,374 @@
---
import { Icon } from 'astro-icon/components'
import { VAPID_PUBLIC_KEY } from 'astro:env/server'
import { cn } from '../lib/cn'
import Button from './Button.astro'
import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
dismissable?: boolean
hideIfEnabled?: boolean
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
select: {
endpoint: true
userAgent: true
}
}>[]
}
const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props
// TODO: Feature flag, enabled only for admins
if (!Astro.locals.user?.admin) {
return null
}
---
<div
data-push-notification-banner
data-dismissed={undefined /* Updated by client script */}
data-supports-push-notifications={undefined /* Updated by client script */}
data-push-subscriptions={JSON.stringify(pushSubscriptions)}
data-is-enabled={undefined /* Updated by client script */}
class={cn(
'no-js:hidden relative isolate flex items-center justify-between gap-x-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4',
'data-dismissed:hidden',
hideIfEnabled && 'data-is-enabled:hidden',
'not-data-supports-push-notifications:hidden',
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
className
)}
{...props}
>
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<div
class="absolute top-0 -left-16 h-full w-1/3 bg-gradient-to-r from-blue-500/20 to-transparent opacity-50 blur-xl"
>
</div>
<div
class="absolute top-0 -right-16 h-full w-1/3 bg-gradient-to-l from-blue-500/20 to-transparent opacity-50 blur-xl"
>
</div>
</div>
<div class="flex items-center gap-x-3">
<div class="rounded-md bg-blue-800/30 p-2">
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
</div>
<div>
<h3 class="font-medium text-blue-100">
<span data-show-if-enabled>Push notifications enabled</span>
<span data-show-if-disabled>Turn on push notifications?</span>
</h3>
<p class="text-sm text-blue-200/80">
<span data-show-if-enabled>Turn notifications off for this device?</span>
<span data-show-if-disabled>Get notifications on this device.</span>
</p>
</div>
</div>
<div class="flex items-center gap-2">
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
<Button
as="span"
label="Yes, notify me"
color="white"
data-push-action="subscribe"
data-vapid-public-key={VAPID_PUBLIC_KEY}
data-show-if-disabled
/>
<Button
as="span"
label="Stop notifications"
color="white"
variant="faded"
data-push-action="unsubscribe"
data-vapid-public-key={VAPID_PUBLIC_KEY}
data-show-if-enabled
/>
</div>
</div>
<script>
/////////////////////////////////////////////////////////////
// Script to handle push notification banner dismissal. //
/////////////////////////////////////////////////////////////
import { typedLocalStorage } from '../lib/localstorage'
document.addEventListener('astro:page-load', () => {
let pushNotificationsBannerDismissedAt = typedLocalStorage.pushNotificationsBannerDismissedAt.get()
if (
pushNotificationsBannerDismissedAt &&
pushNotificationsBannerDismissedAt < new Date(Date.now() - 1000 * 60 * 60 * 24 * 365) // 1 year
) {
typedLocalStorage.pushNotificationsBannerDismissedAt.remove()
pushNotificationsBannerDismissedAt = undefined
}
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((banner) => {
const skipButton = banner.querySelector<HTMLElement>('[data-dismiss-button]')
if (!skipButton) return
if (pushNotificationsBannerDismissedAt) {
banner.dataset.dismissed = ''
}
skipButton.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
banner.dataset.dismissed = ''
const now = new Date()
typedLocalStorage.pushNotificationsBannerDismissedAt.set(now)
pushNotificationsBannerDismissedAt = now
})
})
})
</script>
<script>
/////////////////////////////////////////////////////////
// Script to style when notifications enabled. //
////////////////////////////////////////////////////////
type ServerSubscription = {
endpoint: string
userAgent: string | null
}
/** Parse push subscriptions from string */
function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
try {
if (typeof subscriptionsAsString !== 'string') throw new Error('Push subscriptions must be a string')
const subscriptions = JSON.parse(subscriptionsAsString)
if (!Array.isArray(subscriptions)) throw new Error('Push subscriptions must be an array')
if (!subscriptions.every((s) => typeof s === 'object' && s !== null)) {
throw new Error('Push subscriptions must be an array of objects')
}
if (!subscriptions.every((s) => typeof s.endpoint === 'string')) {
throw new Error('Push subscriptions must be an array of objects with endpoint property')
}
if (!subscriptions.every((s) => typeof s.userAgent === 'string' || s.userAgent === null)) {
throw new Error('Push subscriptions must be an array of objects with userAgent property')
}
return subscriptions as ServerSubscription[]
} catch (error) {
console.error('Failed to parse push subscriptions:', error)
return []
}
}
/** Check if current device has an active push subscription */
async function getCurrentPushSubscription() {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) return null
return await registration.pushManager.getSubscription()
} catch (error) {
console.error('Error getting current push subscription:', error)
return null
}
}
/** Check if current subscription matches any server subscription */
function isCurrentDeviceSubscribed(
currentSubscription: PushSubscription | null,
serverSubscriptions: ServerSubscription[]
) {
if (!currentSubscription || serverSubscriptions.length === 0) return false
const currentEndpoint = currentSubscription.endpoint
const currentUserAgent = navigator.userAgent
return serverSubscriptions.some(
(sub) =>
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
)
}
document.addEventListener('astro:page-load', async () => {
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
const currentSubscription = await getCurrentPushSubscription()
const isSubscribed = isCurrentDeviceSubscribed(currentSubscription, serverSubscriptions)
if (isSubscribed) banner.dataset.isEnabled = ''
})
})
</script>
<script>
/////////////////////////////////////////////////////////////
// Script to handle push notification subscription. //
/////////////////////////////////////////////////////////////
import type { actions } from 'astro:actions'
import type { ActionInput } from '../lib/astroActions'
/** Utility function to convert VAPID key */
function urlB64ToUint8Array(base64String: string) {
const cleaned = base64String.trim().replace(/\s+/g, '').replace(/\-/g, '+').replace(/_/g, '/')
const padding = '='.repeat((4 - (cleaned.length % 4)) % 4)
const base64 = cleaned + padding
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
/** Check for browser support */
function checkSupport() {
const isSecure =
window.isSecureContext ||
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1'
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
}
async function registerServiceWorker() {
try {
const registration = await navigator.serviceWorker.register('/sw.js')
console.log('Service Worker registered:', registration)
const readyRegistration = await navigator.serviceWorker.ready
console.log('Service Worker is active and ready:', readyRegistration)
return readyRegistration
} catch (error) {
console.error('Service Worker registration failed:', error)
throw error
}
}
async function subscribeToPush(vapidPublicKey: string) {
try {
if (!checkSupport()) return
// Request notification permission
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
alert('Push notifications permission denied')
return
}
const registration = await registerServiceWorker()
// Subscribe to push manager
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
})
const p256dh = subscription.getKey('p256dh')
const auth = subscription.getKey('auth')
// Send subscription to server
const response = await fetch('/internal-api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subscription.endpoint,
userAgent: navigator.userAgent,
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
console.log('Push subscription successful')
// Reload page to update UI
window.location.reload()
} catch (error) {
console.error('Push subscription failed:', error)
alert('Error enabling push notifications. This may be due to browser settings or other restrictions.')
}
}
async function unsubscribeFromPush() {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) {
console.log('No service worker registration found')
return
}
const subscription = await registration.pushManager.getSubscription()
if (!subscription) {
console.log('No push subscription found')
return
}
// Unsubscribe from browser
await subscription.unsubscribe()
// Remove from server
const response = await fetch('/internal-api/notifications/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subscription.endpoint,
} satisfies ActionInput<typeof actions.notification.webPush.unsubscribe>),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
console.log('Push unsubscription successful')
// Reload page to update UI
window.location.reload()
} catch (error) {
console.error('Push unsubscription failed:', error)
alert('Failed to unsubscribe from push notifications')
}
}
document.addEventListener('astro:page-load', () => {
const supportsPushNotifications = checkSupport()
if (supportsPushNotifications) {
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((element) => {
element.dataset.supportsPushNotifications = ''
})
}
document.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
const vapidPublicKey = button.dataset.vapidPublicKey
if (!vapidPublicKey) {
console.error('Environment variable VAPID_PUBLIC_KEY is not set')
return
}
button.addEventListener('click', async (event) => {
event.preventDefault()
event.stopPropagation()
const action = button.dataset.pushAction
if (action === 'subscribe') {
await subscribeToPush(vapidPublicKey)
} else if (action === 'unsubscribe') {
await unsubscribeFromPush()
}
})
})
})
</script>

View File

@@ -87,6 +87,25 @@ function makeLink(url: string, referral: string | null) {
} }
} }
const bitcointalkMatch = /^(?:https?:\/\/)?(?:www\.)?bitcointalk\.org$/.exec(hostname)
if (bitcointalkMatch) {
return {
type: 'clearnet' as const,
url: urlWithReferral,
textBits: [
{
style: 'normal',
text: 'BitcoinTalk ',
},
{
style: 'irrelevant',
text: 'thread',
},
],
icon: networksBySlug.clearnet.icon,
}
}
return { return {
type: 'clearnet' as const, type: 'clearnet' as const,
url: urlWithReferral, url: urlWithReferral,

View File

@@ -49,6 +49,7 @@ const {
class={cn( class={cn(
// Check the scam filter when there is a text quey and the user has checked verified and approved // Check the scam filter when there is a text quey and the user has checked verified and approved
'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked', 'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked',
'has-[input[name=q]:placeholder-shown]:[&_[data-hide-if-q-is-empty]]:hidden has-[input[name=q]:not(:placeholder-shown)]:[&_[data-hide-if-q-is-filled]]:hidden',
className className
)} )}
> >
@@ -80,16 +81,20 @@ const {
)) ))
} }
</select> </select>
<p class="text-day-500 mt-1.5 text-center text-sm"> <p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-filled>
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" /> <Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
Ties randomly sorted Ties randomly sorted
</p> </p>
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-empty>
<Icon name="ri:seo-line" class="inline-block size-3.5 align-[-0.125em]" />
Sorted by match first
</p>
</fieldset> </fieldset>
<!-- Text Search --> <!-- Text Search -->
<fieldset class="mb-6"> <fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500"> <legend class="font-title mb-3 leading-none text-green-500">
<label for="q">Text</label> <label for="q">Name</label>
</legend> </legend>
<input <input
type="text" type="text"

View File

@@ -21,7 +21,6 @@ type Props = HTMLAttributes<'div'> & {
pageSize: number pageSize: number
sortSeed?: string sortSeed?: string
filters: ServicesFiltersObject filters: ServicesFiltersObject
includeScams: boolean
countCommunityOnly: number | null countCommunityOnly: number | null
inlineIcons?: boolean inlineIcons?: boolean
} }
@@ -35,15 +34,12 @@ const {
sortSeed, sortSeed,
class: className, class: className,
filters, filters,
includeScams,
countCommunityOnly, countCommunityOnly,
inlineIcons, inlineIcons,
...divProps ...divProps
} = Astro.props } = Astro.props
const hasScams = const hasScams = filters.verification.includes('VERIFICATION_FAILED')
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
filters.verification.includes('VERIFICATION_FAILED') || includeScams
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED')) const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED') const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')
@@ -75,7 +71,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
/> />
{ {
countCommunityOnly && ( !!countCommunityOnly && (
<> <>
<Button <Button
as="a" as="a"
@@ -196,7 +192,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
inlineIcon={inlineIcons} inlineIcon={inlineIcons}
/> />
)} )}
{countCommunityOnly && ( {!!countCommunityOnly && (
<Button <Button
as="a" as="a"
href={urlIfIncludingCommunity} href={urlIfIncludingCommunity}

View File

@@ -15,6 +15,8 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
} }
icon: string icon: string
color: ComponentProps<typeof BadgeSmall>['color'] color: ComponentProps<typeof BadgeSmall>['color']
isSolved: boolean
showBanner: boolean
} }
export const { export const {
@@ -36,6 +38,8 @@ export const {
}, },
icon: 'ri:question-fill', icon: 'ri:question-fill',
color: 'gray', color: 'gray',
isSolved: false,
showBanner: false,
}), }),
[ [
{ {
@@ -46,8 +50,10 @@ export const {
classNames: { classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50', dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
}, },
icon: 'ri:error-warning-fill', icon: 'ri:alert-fill',
color: 'yellow', color: 'yellow',
isSolved: false,
showBanner: true,
}, },
{ {
id: 'WARNING_SOLVED', id: 'WARNING_SOLVED',
@@ -55,10 +61,12 @@ export const {
label: 'Warning Solved', label: 'Warning Solved',
description: 'A previously reported warning has been solved', description: 'A previously reported warning has been solved',
classNames: { classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50', dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
}, },
icon: 'ri:check-fill', icon: 'ri:alert-fill',
color: 'green', color: 'green',
isSolved: true,
showBanner: false,
}, },
{ {
id: 'ALERT', id: 'ALERT',
@@ -68,8 +76,10 @@ export const {
classNames: { classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50', dot: 'bg-red-900 text-red-300 ring-red-900/50',
}, },
icon: 'ri:alert-fill', icon: 'ri:spam-fill',
color: 'red', color: 'red',
isSolved: false,
showBanner: true,
}, },
{ {
id: 'ALERT_SOLVED', id: 'ALERT_SOLVED',
@@ -77,10 +87,12 @@ export const {
label: 'Alert Solved', label: 'Alert Solved',
description: 'A previously reported alert has been solved', description: 'A previously reported alert has been solved',
classNames: { classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50', dot: 'bg-red-900 text-red-300 ring-red-900/50',
}, },
icon: 'ri:check-fill', icon: 'ri:spam-fill',
color: 'green', color: 'green',
isSolved: true,
showBanner: false,
}, },
{ {
id: 'INFO', id: 'INFO',
@@ -92,6 +104,8 @@ export const {
}, },
icon: 'ri:information-fill', icon: 'ri:information-fill',
color: 'sky', color: 'sky',
isSolved: false,
showBanner: false,
}, },
{ {
id: 'NORMAL', id: 'NORMAL',
@@ -103,6 +117,8 @@ export const {
}, },
icon: 'ri:notification-fill', icon: 'ri:notification-fill',
color: 'green', color: 'green',
isSolved: false,
showBanner: false,
}, },
{ {
id: 'UPDATE', id: 'UPDATE',
@@ -114,6 +130,8 @@ export const {
}, },
icon: 'ri:pencil-fill', icon: 'ri:pencil-fill',
color: 'sky', color: 'sky',
isSolved: false,
showBanner: false,
}, },
] as const satisfies EventTypeInfo<EventType>[] ] as const satisfies EventTypeInfo<EventType>[]
) )

View File

@@ -0,0 +1,39 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { KycLevelClarification } from '@prisma/client'
type KycLevelClarificationInfo<T extends string | null | undefined = string> = {
value: T
label: string
description: string
icon: string
}
export const {
dataArray: kycLevelClarifications,
dataObject: kycLevelClarificationsById,
getFn: getKycLevelClarificationInfo,
} = makeHelpersForOptions(
'value',
(value): KycLevelClarificationInfo<typeof value> => ({
value,
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
description: '',
icon: 'ri:question-line',
}),
[
{
value: 'NONE',
label: 'None',
description: 'No clarification needed.',
icon: 'ri:file-copy-line',
},
{
value: 'DEPENDS_ON_PARTNERS',
label: 'Depends on partners',
description: 'May vary across partners.',
icon: 'ri:share-forward-line',
},
] as const satisfies KycLevelClarificationInfo<KycLevelClarification>[]
)

View File

@@ -1 +1 @@
export const SUPPORT_EMAIL = 'support@kycnot.me' export const SUPPORT_EMAIL = 'contact@kycnot.me'

View File

@@ -65,7 +65,7 @@ export const {
value: 'ARCHIVED', value: 'ARCHIVED',
slug: 'archived', slug: 'archived',
label: 'Archived', label: 'Archived',
description: 'No longer operational', description: 'No longer operational.',
longDescription: longDescription:
'Archived service, no longer exists or ceased operations. Information may be outdated.', 'Archived service, no longer exists or ceased operations. Information may be outdated.',
icon: 'ri:archive-line', icon: 'ri:archive-line',

32
web/src/lib/endpoints.ts Normal file
View File

@@ -0,0 +1,32 @@
import { type ActionClient } from 'astro:actions'
import type { APIRoute } from 'astro'
import type { z } from 'astro/zod'
export function makeEndpointFromAction<Action extends ActionClient<unknown, 'json', z.ZodType> & string>(
action: Action
): APIRoute {
return async (context) => {
try {
const input = await context.request.json()
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const result = await context.callAction(action, input)
if (result.error) {
console.error('Error on endpoint', result.error)
return new Response(JSON.stringify({ error: result.error.message }), {
status: result.error.status,
})
}
return new Response(JSON.stringify(result.data), {
status: 200,
})
} catch (error) {
console.error('Error on endpoint', error)
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
})
}
}
}

View File

@@ -114,7 +114,13 @@ export class ErrorBanners {
return result return result
} catch (error) { } catch (error) {
this.handler(uiMessage)(error) this.handler(uiMessage)(error)
return fallback as F return fallback as F extends never[]
? T extends [infer _First, ...infer _Rest]
? []
: T extends unknown[]
? T[number][]
: F
: F
} }
} }

View File

@@ -0,0 +1,16 @@
import { z } from 'astro/zod'
import { prisma } from './prisma'
export async function findServicesBySimilarity(value: string, similarityThreshold = 0.01) {
const data = await prisma.$queryRaw`
SELECT id, similarity(name, ${value}) AS similarity_score
FROM "Service"
WHERE similarity(name, ${value}) >= ${similarityThreshold}
ORDER BY similarity(name, ${value}) desc`
const schema = z.array(z.object({ id: z.number(), similarity_score: z.number() }))
const parsedData = schema.parse(data)
return parsedData.map(({ id, similarity_score }) => ({ id, similarityScore: similarity_score }))
}

View File

@@ -0,0 +1,77 @@
import { z } from 'astro:schema'
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>>,
T extends {
[K in keyof Schemas]: {
schema: Schemas[K]
default?: z.output<Schemas[K]> | undefined
key?: string
}
},
>(options: T) {
return Object.fromEntries(
typedObjectEntries(options).map(([originalKey, option]) => {
const key = option.key ?? originalKey
return [
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
},
set: (value: z.input<typeof option.schema>) => {
localStorage.setItem(key, JSON.stringify(value))
},
remove: () => {
localStorage.removeItem(key)
},
default: option.default,
},
]
})
) as {
[K in keyof T]: {
get: () => z.output<T[K]['schema']> | (T[K] extends { default: infer D } ? D : undefined)
set: (value: z.input<T[K]['schema']>) => void
remove: () => void
default: z.output<T[K]['schema']> | undefined
}
}
}
export const typedLocalStorage = makeTypedLocalStorage({
pushNotificationsBannerDismissedAt: {
schema: z.coerce.date(),
},
})

View File

@@ -0,0 +1,50 @@
import type { Misc } from 'ts-toolbelt'
export async function makeAdminApiCallInfo<T extends Misc.JSON.Object>({
method,
path,
input,
baseUrl,
}: {
method: 'POST' | 'QUERY'
path: `/${string}`
input: T
baseUrl: URL | string
}) {
const fullPath = new URL(`/api/v1${path}`, baseUrl).href
const fetchProsmise = fetch(fullPath, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}).then((res) => {
try {
return res.json() as Promise<Misc.JSON.Value>
} catch (errJson: unknown) {
console.error(errJson)
try {
return res.text()
} catch (errText: unknown) {
console.error(errText)
return ''
}
}
})
let output: Misc.JSON.Value = ''
try {
output = await fetchProsmise
} catch (err: unknown) {
console.error(err)
output = err instanceof Error ? err.message : String(err)
}
return {
method,
path,
fullPath,
input,
output,
}
}

View File

@@ -162,3 +162,16 @@ export function areEqualObjectsWithoutOrder<T extends Record<string, unknown>>(
return undefined return undefined
}) })
} }
/**
* Same as {@link Object.entries}, but with proper typing.
* @example
* typedObjectEntries({ a: 1, b: 2 }) // [['a', 1], ['b', 2]]
*/
export function typedObjectEntries<T extends Record<string, unknown>>(obj: T) {
return Object.entries(obj) as Prettify<
{
[K in Extract<keyof T, string>]: [K, T[K]]
}[Extract<keyof T, string>]
>[]
}

View File

@@ -25,24 +25,23 @@ const findManyAndCount = {
}, },
} }
type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount // NOTE: This used to be necessary to cast the prismaClientSingleton return type, but it seems not anymore. I left it, just in case we need it again
// type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount
type ModelsWithCustomMethods = { // type ModelsWithCustomMethods = {
[Model in keyof PrismaClient]: PrismaClient[Model] extends { // [Model in keyof PrismaClient]: PrismaClient[Model] extends {
findMany: (...args: any[]) => Promise<any> // findMany: (...args: any[]) => Promise<any>
} // }
? PrismaClient[Model] & { // ? PrismaClient[Model] & {
findManyAndCount: FindManyAndCountType // findManyAndCount: FindManyAndCountType
} // }
: PrismaClient[Model] // : PrismaClient[Model]
} // }
type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient // type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
function prismaClientSingleton(): ExtendedPrismaClient { function prismaClientSingleton() {
const prisma = new PrismaClient().$extends(findManyAndCount) return new PrismaClient().$extends(findManyAndCount)
return prisma as unknown as ExtendedPrismaClient
} }
declare global { declare global {

View File

@@ -113,3 +113,28 @@ export function urlDomain(url: URL | string) {
} }
return url.origin return url.origin
} }
export function separateServiceUrlsByType(allServiceUrls: string[]) {
const result: {
web: string[]
onion: string[]
i2p: string[]
} = {
web: [],
onion: [],
i2p: [],
}
for (const url of allServiceUrls) {
const parsedUrl = new URL(url)
if (parsedUrl.origin.endsWith('.onion')) {
result.onion.push(url)
} else if (parsedUrl.origin.endsWith('.b32.i2p')) {
result.i2p.push(url)
} else {
result.web.push(url)
}
}
return result
}

52
web/src/lib/webPush.ts Normal file
View File

@@ -0,0 +1,52 @@
/* 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'
// Configure VAPID keys
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
export { webpush }
export async function sendPushNotification(
subscription: {
endpoint: string
keys: {
p256dh: string
auth: string
}
},
data: {
title: string
body?: string
icon?: string
badge?: string
url?: string
}
) {
try {
const result = await webpush.sendNotification(
subscription,
JSON.stringify({
title: data.title,
options: {
body: data.body,
icon: data.icon ?? '/favicon.svg',
badge: data.badge ?? '/favicon.svg',
data: {
url: data.url,
},
},
}),
{
TTL: 24 * 60 * 60, // 24 hours
}
)
return { success: true, result } as const
} catch (error) {
console.error('Error sending push notification:', error)
return {
success: false,
error: error instanceof WebPushError ? error : undefined,
} as const
}
}

View File

@@ -19,7 +19,7 @@ export const zodUrlOptionalProtocol = z.preprocess(
const cleanInput = input.trim().replace(/\/$/, '') const cleanInput = input.trim().replace(/\/$/, '')
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
}, },
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z]{2,})[^\s$.?#].[^\s]*$/i.test(value), { z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
message: 'Invalid URL', message: 'Invalid URL',
}) })
) )
@@ -34,6 +34,11 @@ const stringToArrayFactory = (delimiter: RegExp | string = ',') => {
.filter((item) => item !== '') .filter((item) => item !== '')
} }
export const stringListOfSlugsSchemaRequired = z.preprocess(
stringToArrayFactory(/[\s,\n]+/),
z.array(z.string().regex(/^[a-z0-9-_A-Z]+$/)).min(1)
)
export const stringListOfUrlsSchema = z.preprocess( export const stringListOfUrlsSchema = z.preprocess(
stringToArrayFactory(/[\s,\n]+/), stringToArrayFactory(/[\s,\n]+/),
z.array(zodUrlOptionalProtocol).default([]) z.array(zodUrlOptionalProtocol).default([])

View File

@@ -201,6 +201,12 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label. To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
## API
You can access basic service data via our public API.
See the [API page](/docs/api) for more details.
## Support ## Support
If you like this project, you can **support** it through these methods: If you like this project, you can **support** it through these methods:

View File

@@ -53,7 +53,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
disabled: !user.karmaUnlocks.displayName, disabled: !user.karmaUnlocks.displayName,
}} }}
description={!user.karmaUnlocks.displayName description={!user.karmaUnlocks.displayName
? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/karma)` ? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/docs/karma)`
: undefined} : undefined}
/> />
@@ -68,7 +68,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
disabled: !user.karmaUnlocks.websiteLink, disabled: !user.karmaUnlocks.websiteLink,
}} }}
description={!user.karmaUnlocks.websiteLink description={!user.karmaUnlocks.websiteLink
? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/karma)` ? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/docs/karma)`
: undefined} : undefined}
/> />
@@ -80,7 +80,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
square square
disabled={!user.karmaUnlocks.profilePicture} disabled={!user.karmaUnlocks.profilePicture}
description={!user.karmaUnlocks.profilePicture description={!user.karmaUnlocks.profilePicture
? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/karma)` ? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/docs/karma)`
: undefined} : undefined}
removeCheckbox={user.picture removeCheckbox={user.picture
? { ? {

View File

@@ -529,8 +529,9 @@ if (!user) return Astro.rewrite('/404')
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3"> <div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
<p class="text-day-300"> <p class="text-day-300">
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline" Earn karma to unlock features and privileges. <a
>Learn about karma</a href="/docs/karma"
class="text-day-200 hover:underline">Learn about karma</a
> >
</p> </p>
</div> </div>

View File

@@ -271,6 +271,7 @@ if (toggleResult?.error) {
label: type.label, label: type.label,
value: type.value, value: type.value,
icon: type.icon, icon: type.icon,
noTransitionPersist: false,
}))} }))}
cardSize="sm" cardSize="sm"
required required
@@ -305,8 +306,8 @@ if (toggleResult?.error) {
label="Status" label="Status"
error={createInputErrors.isActive} error={createInputErrors.isActive}
options={[ options={[
{ label: 'Active', value: 'true' }, { label: 'Active', value: 'true', noTransitionPersist: true },
{ label: 'Inactive', value: 'false' }, { label: 'Inactive', value: 'false', noTransitionPersist: true },
]} ]}
selectedValue={newAnnouncement.isActive ? 'true' : 'false'} selectedValue={newAnnouncement.isActive ? 'true' : 'false'}
cardSize="sm" cardSize="sm"
@@ -627,6 +628,7 @@ if (toggleResult?.error) {
label: type.label, label: type.label,
value: type.value, value: type.value,
icon: type.icon, icon: type.icon,
noTransitionPersist: true,
}))} }))}
cardSize="sm" cardSize="sm"
required required
@@ -659,8 +661,8 @@ if (toggleResult?.error) {
name="isActive" name="isActive"
label="Status" label="Status"
options={[ options={[
{ label: 'Active', value: 'true' }, { label: 'Active', value: 'true', noTransitionPersist: true },
{ label: 'Inactive', value: 'false' }, { label: 'Inactive', value: 'false', noTransitionPersist: true },
]} ]}
selectedValue={announcement.isActive ? 'true' : 'false'} selectedValue={announcement.isActive ? 'true' : 'false'}
cardSize="sm" cardSize="sm"

View File

@@ -1,6 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { DATABASE_UI_URL } from 'astro:env/server' import { DATABASE_UI_URL, LOGS_UI_URL } from 'astro:env/server'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
@@ -65,6 +65,14 @@ const adminLinks: AdminLink[] = [
base: 'text-pink-300', base: 'text-pink-300',
}, },
}, },
{
icon: 'ri:notification-3-line',
title: 'Notifications',
href: '/admin/notifications',
classNames: {
base: 'text-indigo-300',
},
},
{ {
icon: 'ri:rocket-2-line', icon: 'ri:rocket-2-line',
title: 'Releases', title: 'Releases',
@@ -81,6 +89,18 @@ const adminLinks: AdminLink[] = [
base: 'text-gray-300', base: 'text-gray-300',
}, },
}, },
...(LOGS_UI_URL
? [
{
icon: 'ri:menu-search-line',
title: 'Logs',
href: LOGS_UI_URL,
classNames: {
base: 'text-cyan-300',
},
},
]
: []),
] ]
--- ---
@@ -93,25 +113,27 @@ const adminLinks: AdminLink[] = [
<nav> <nav>
<ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4"> <ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4">
{ {
adminLinks.map((link) => ( adminLinks
<li .filter((link) => link.href)
class={cn( .map((link) => (
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105', <li
link.classNames.base class={cn(
)} 'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
> link.classNames.base
<a )}
href={link.href}
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
> >
<Icon <a
name={link.icon} href={link.href}
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100" class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
/> >
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span> <Icon
</a> name={link.icon}
</li> class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
)) />
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
</a>
</li>
))
} }
</ol> </ol>
</nav> </nav>

View File

@@ -0,0 +1,155 @@
---
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import { groupBy, round, uniq } from 'lodash-es'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { cn } from '../../lib/cn'
import { prisma } from '../../lib/prisma'
// Check if user is admin
if (!Astro.locals.user?.admin) {
return Astro.redirect('/access-denied')
}
const testResult = Astro.getActionResult(actions.admin.notification.webPush.test)
const testInputErrors = isInputError(testResult?.error) ? testResult.error.fields : {}
Astro.locals.banners.addIfSuccess(testResult, (data) => data.message)
const subscriptions = await Astro.locals.banners.try(
'Error while fetching subscriptions by user',
() =>
prisma.pushSubscription.findMany({
select: {
id: true,
user: {
select: {
id: true,
name: true,
},
},
},
}),
[] as []
)
const totalSubscriptions = subscriptions.length
const subscriptionsByUser = groupBy(subscriptions, 'user.id')
const totalUsers = Object.keys(subscriptionsByUser).length
const adminUsers = await prisma.user.findMany({
where: {
admin: true,
},
select: {
name: true,
},
})
const stats = [
{
icon: 'ri:notification-4-line',
iconClass: 'text-blue-400',
title: 'Total Subscriptions',
value: totalSubscriptions.toLocaleString(),
},
{
icon: 'ri:user-3-line',
iconClass: 'text-green-400',
title: 'Subscribed Users',
value: totalUsers.toLocaleString(),
},
{
icon: 'ri:smartphone-line',
iconClass: 'text-purple-400',
title: 'Avg Devices/User',
value: (totalUsers > 0 ? round(totalSubscriptions / totalUsers, 1) : 0).toLocaleString(),
},
] satisfies {
icon: string
iconClass: string
title: string
value: string
}[]
---
<MiniLayout
pageTitle="Push notifications"
description="Send test notifications"
layoutHeader={{
icon: 'ri:notification-3-line',
title: 'Push notifications',
subtitle: 'Send test notifications',
}}
>
<div class="mb-6 grid gap-4 sm:grid-cols-3">
{
stats.map((stat) => (
<div class="flex flex-col items-center gap-2 text-center">
<div class="flex items-end gap-1">
<span class="text-5xl leading-[0.8] font-bold text-white">{stat.value}</span>
<Icon name={stat.icon} class={cn('size-5 shrink-0', stat.iconClass)} />
</div>
<span class="text-day-200 flex grow flex-col justify-center text-sm leading-none font-medium text-balance">
{stat.title}
</span>
</div>
))
}
</div>
<h2 class="text-center text-lg font-semibold text-white">Send Test Notification</h2>
<form method="POST" action={actions.admin.notification.webPush.test} class="space-y-4">
<InputTextArea
label="Users"
name="userNames"
inputProps={{
placeholder: 'john-doe, jane-doe',
class: 'leading-tight min-h-24',
}}
value={uniq([Astro.locals.user, ...adminUsers].map((user) => user.name)).join('\n')}
description={[
'- Comma-separated list of user names.',
'- Minimum 1 user name.',
'- By default, all admin users are selected.',
].join('\n')}
error={testInputErrors.userNames}
/>
<InputText
label="Title"
name="title"
inputProps={{
value: 'Test Notification',
required: true,
}}
error={testInputErrors.title}
/>
<InputTextArea
label="Body"
name="body"
inputProps={{
value: 'This is a test push notification from KYCNot.me',
}}
error={testInputErrors.body}
/>
<InputText
label="Action URL"
name="url"
inputProps={{
placeholder: 'https://example.com/path',
}}
description="URL to open when the notification is clicked"
error={testInputErrors.url}
/>
<InputSubmitButton label="Send" icon="ri:send-plane-line" hideCancel color="danger" />
</form>
</MiniLayout>

View File

@@ -1,8 +1,8 @@
--- ---
import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server' import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server'
import TimeFormatted from '../../components/TimeFormatted.astro'
import MiniLayout from '../../layouts/MiniLayout.astro' import MiniLayout from '../../layouts/MiniLayout.astro'
import { timeAgo } from '../../lib/timeAgo'
const releaseDate = const releaseDate =
RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined
@@ -37,7 +37,7 @@ const releaseDate =
{ {
!!releaseDate && ( !!releaseDate && (
<p class="text-day-500 mt-2"> <p class="text-day-500 mt-2">
(<TimeFormatted date={releaseDate} hourPrecision daysUntilDate={Infinity} />) (<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>)
</p> </p>
) )
} }

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote' import { Markdown } from 'astro-remote'
import { actions, isInputError } from 'astro:actions' import { actions, isInputError } from 'astro:actions'
import { Code } from 'astro:components'
import { orderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
import BadgeSmall from '../../../../components/BadgeSmall.astro' import BadgeSmall from '../../../../components/BadgeSmall.astro'
@@ -25,6 +26,7 @@ import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
import { formatContactMethod } from '../../../../constants/contactMethods' import { formatContactMethod } from '../../../../constants/contactMethods'
import { currencies } from '../../../../constants/currencies' import { currencies } from '../../../../constants/currencies'
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes' import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
import { kycLevels } from '../../../../constants/kycLevels' import { kycLevels } from '../../../../constants/kycLevels'
import { serviceVisibilities } from '../../../../constants/serviceVisibility' import { serviceVisibilities } from '../../../../constants/serviceVisibility'
import { verificationStatuses } from '../../../../constants/verificationStatus' import { verificationStatuses } from '../../../../constants/verificationStatus'
@@ -33,6 +35,8 @@ import {
verificationStepStatuses, verificationStepStatuses,
} from '../../../../constants/verificationStepStatus' } from '../../../../constants/verificationStepStatus'
import BaseLayout from '../../../../layouts/BaseLayout.astro' import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables'
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
import { pluralize } from '../../../../lib/pluralize' import { pluralize } from '../../../../lib/pluralize'
import { prisma } from '../../../../lib/prisma' import { prisma } from '../../../../lib/prisma'
@@ -180,7 +184,35 @@ const [service, categories, attributes] = await Astro.locals.banners.tryMany([
], ],
]) ])
if (!service) return Astro.rewrite('/404') if (!service) {
try {
const serviceWithOldSlug = await prisma.service.findFirst({
where: { previousSlugs: { has: slug } },
select: { slug: true },
})
if (serviceWithOldSlug) {
return Astro.redirect(`/admin/services/${serviceWithOldSlug.slug}/edit`, 301)
}
} catch (error) {
console.error(error)
}
return Astro.rewrite('/404')
}
const apiCalls = await Astro.locals.banners.try(
'Error fetching api calls',
() =>
Promise.all([
makeAdminApiCallInfo({
method: 'QUERY',
path: '/service/get',
input: { slug: service.slug },
baseUrl: Astro.url,
}),
]),
[]
)
--- ---
<BaseLayout pageTitle={`Edit Service: ${service.name}`}> <BaseLayout pageTitle={`Edit Service: ${service.name}`}>
@@ -233,16 +265,32 @@ if (!service) return Astro.rewrite('/404')
enctype="multipart/form-data" enctype="multipart/form-data"
> >
<input type="hidden" name="id" value={service.id} /> <input type="hidden" name="id" value={service.id} />
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<InputText
label="Slug"
description={`Auto-generated if empty. ${
service.previousSlugs.length > 0 ? `Old slugs: ${service.previousSlugs.join(', ')}` : ''
}`}
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
</div>
<InputTextArea <InputTextArea
label="Description" label="Description"
name="description" name="description"
@@ -254,76 +302,44 @@ if (!service) return Astro.rewrite('/404')
error={serviceInputErrors.description} error={serviceInputErrors.description}
/> />
<InputText <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
label="Slug"
description="Auto-generated if empty"
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<InputTextArea <InputTextArea
label="Service URLs" label="Service URLs"
description="One per line" description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="serviceUrls" name="allServiceUrls"
inputProps={{ inputProps={{
rows: 3, placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
placeholder: 'https://example1.com\nhttps://example2.com', class: 'grow min-h-24',
required: true,
}} }}
value={service.serviceUrls.join('\n')} class="row-span-2 flex flex-col self-stretch"
error={serviceInputErrors.serviceUrls} value={[...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].join('\n\n')}
error={serviceInputErrors.allServiceUrls}
/> />
<InputTextArea <InputTextArea
label="ToS URLs" label="ToS URLs"
description="One per line" description="One per line"
name="tosUrls" name="tosUrls"
inputProps={{ inputProps={{
rows: 3,
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos', placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
required: true, required: true,
}} }}
value={service.tosUrls.join('\n')} value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls} error={serviceInputErrors.tosUrls}
/> />
<InputTextArea <InputText
label="Onion URLs" label="Referral link path"
description="One per line" name="referral"
name="onionUrls"
inputProps={{ inputProps={{
rows: 3, value: service.referral,
placeholder: 'http://example1.onion\nhttp://example2.onion', placeholder: 'e.g. ?ref=123 or /ref/123',
}} }}
value={service.onionUrls.join('\n')} error={serviceInputErrors.referral}
error={serviceInputErrors.onionUrls} class="self-end"
/> description="Will be appended to the service URL"
<InputTextArea
label="I2P URLs"
description="One per line"
name="i2pUrls"
inputProps={{
rows: 3,
placeholder: 'http://example1.b32.i2p\nhttp://example2.b32.i2p',
}}
value={service.i2pUrls.join('\n')}
/> />
</div> </div>
<InputText
label="Referral link path"
name="referral"
inputProps={{
value: service.referral,
placeholder: 'e.g. ?ref=123 or /ref/123',
}}
error={serviceInputErrors.referral}
description="Will be appended to the service URL"
/>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<InputImageFile <InputImageFile
label="Image" label="Image"
@@ -391,6 +407,7 @@ if (!service) return Astro.rewrite('/404')
value: kycLevel.id.toString(), value: kycLevel.id.toString(),
icon: kycLevel.icon, icon: kycLevel.icon,
description: kycLevel.description, description: kycLevel.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.kycLevel.toString()} selectedValue={service.kycLevel.toString()}
iconSize="md" iconSize="md"
@@ -399,6 +416,22 @@ if (!service) return Astro.rewrite('/404')
class="[&>div]:grid-cols-2 [&>div]:[--card-min-size:16rem]" class="[&>div]:grid-cols-2 [&>div]:[--card-min-size:16rem]"
/> />
<InputCardGroup
name="kycLevelClarification"
label="KYC Level Clarification"
options={kycLevelClarifications.map((clarification) => ({
label: clarification.label,
value: clarification.value,
icon: clarification.icon,
description: clarification.description,
noTransitionPersist: true,
}))}
selectedValue={service.kycLevelClarification ?? 'NONE'}
iconSize="sm"
cardSize="sm"
error={serviceInputErrors.kycLevelClarification}
/>
<InputCardGroup <InputCardGroup
name="verificationStatus" name="verificationStatus"
label="Verification Status" label="Verification Status"
@@ -408,6 +441,7 @@ if (!service) return Astro.rewrite('/404')
icon: status.icon, icon: status.icon,
iconClass: status.classNames.icon, iconClass: status.classNames.icon,
description: status.description, description: status.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.verificationStatus} selectedValue={service.verificationStatus}
error={serviceInputErrors.verificationStatus} error={serviceInputErrors.verificationStatus}
@@ -423,6 +457,7 @@ if (!service) return Astro.rewrite('/404')
label: currency.name, label: currency.name,
value: currency.id, value: currency.id,
icon: currency.icon, icon: currency.icon,
noTransitionPersist: true,
}))} }))}
selectedValue={service.acceptedCurrencies} selectedValue={service.acceptedCurrencies}
error={serviceInputErrors.acceptedCurrencies} error={serviceInputErrors.acceptedCurrencies}
@@ -463,6 +498,7 @@ if (!service) return Astro.rewrite('/404')
icon: visibility.icon, icon: visibility.icon,
iconClass: visibility.iconClass, iconClass: visibility.iconClass,
description: visibility.description, description: visibility.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.serviceVisibility} selectedValue={service.serviceVisibility}
error={serviceInputErrors.serviceVisibility} error={serviceInputErrors.serviceVisibility}
@@ -685,9 +721,14 @@ if (!service) return Astro.rewrite('/404')
label="Started At" label="Started At"
name="startedAt" name="startedAt"
inputProps={{ inputProps={{
type: 'date', type: 'datetime-local',
required: true, required: true,
value: new Date(event.startedAt).toISOString().split('T')[0], value: new Date(
new Date(event.startedAt).getTime() -
new Date(event.startedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16),
}} }}
error={eventUpdateInputErrors.startedAt} error={eventUpdateInputErrors.startedAt}
/> />
@@ -696,7 +737,15 @@ if (!service) return Astro.rewrite('/404')
label="Ended At" label="Ended At"
name="endedAt" name="endedAt"
inputProps={{ inputProps={{
value: event.endedAt ? new Date(event.endedAt).toISOString().split('T')[0] : '', type: 'datetime-local',
value: event.endedAt
? new Date(
new Date(event.endedAt).getTime() -
new Date(event.endedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16)
: '',
}} }}
error={eventUpdateInputErrors.endedAt} error={eventUpdateInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event." description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
@@ -756,9 +805,11 @@ if (!service) return Astro.rewrite('/404')
label="Started At" label="Started At"
name="startedAt" name="startedAt"
inputProps={{ inputProps={{
type: 'date', type: 'datetime-local',
required: true, required: true,
value: new Date().toISOString().split('T')[0], value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}} }}
error={eventInputErrors.startedAt} error={eventInputErrors.startedAt}
/> />
@@ -767,7 +818,10 @@ if (!service) return Astro.rewrite('/404')
label="Ended At" label="Ended At"
name="endedAt" name="endedAt"
inputProps={{ inputProps={{
value: new Date().toISOString().split('T')[0], type: 'datetime-local',
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}} }}
error={eventInputErrors.endedAt} error={eventInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event." description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
@@ -1100,5 +1154,27 @@ if (!service) return Astro.rewrite('/404')
</form> </form>
</FormSubSection> </FormSubSection>
</FormSection> </FormSection>
<FormSection title="API">
{
DEPLOYMENT_MODE === 'staging' && (
<p class="rounded-lg bg-red-900/30 p-4 text-sm text-red-200">
<Icon name="ri:alert-line" class="inline-block size-4 align-[-0.2em] text-red-400" />
This endpoints section doesn't work in PRE. Use curl commands instead.
</p>
)
}
{
apiCalls.map((call) => (
<FormSubSection title={`${call.method} ${call.path}`}>
<p class="text-day-400 text-sm">Input:</p>
<Code code={JSON.stringify(call.input, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
<p class="text-day-400 text-sm">Output:</p>
<Code code={JSON.stringify(call.output, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
</FormSubSection>
))
}
</FormSection>
</div> </div>
</BaseLayout> </BaseLayout>

View File

@@ -74,19 +74,19 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
</div> </div>
<div> <div>
<label for="serviceUrls" class="font-title mb-2 block text-sm text-green-500">serviceUrls</label> <label for="allServiceUrls" class="font-title mb-2 block text-sm text-green-500">Service URLs</label>
<textarea <textarea
transition:persist transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500" class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="serviceUrls" name="allServiceUrls"
id="serviceUrls" id="allServiceUrls"
rows={3} rows={3}
placeholder="https://example1.com https://example2.com" placeholder="https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p"
set:text="" set:text=""
/> />
{ {
inputErrors.serviceUrls && ( inputErrors.allServiceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p> <p class="font-title mt-1 text-sm text-red-500">{inputErrors.allServiceUrls.join(', ')}</p>
) )
} }
</div> </div>
@@ -109,24 +109,6 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
} }
</div> </div>
<div>
<label for="onionUrls" class="font-title mb-2 block text-sm text-green-500">onionUrls</label>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="onionUrls"
id="onionUrls"
rows={3}
placeholder="http://example.onion"
set:text=""
/>
{
inputErrors.onionUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.onionUrls.join(', ')}</p>
)
}
</div>
<div> <div>
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label> <label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
<div class="space-y-2"> <div class="space-y-2">

View File

@@ -226,9 +226,24 @@ if (!user) return Astro.rewrite('/404')
name="type" name="type"
label="Type" label="Type"
options={[ options={[
{ label: 'Admin', value: 'admin', icon: 'ri:shield-star-fill' }, {
{ label: 'Moderator', value: 'moderator', icon: 'ri:graduation-cap-fill' }, label: 'Admin',
{ label: 'Spammer', value: 'spammer', icon: 'ri:alert-fill' }, value: 'admin',
icon: 'ri:shield-star-fill',
noTransitionPersist: true,
},
{
label: 'Moderator',
value: 'moderator',
icon: 'ri:graduation-cap-fill',
noTransitionPersist: true,
},
{
label: 'Spammer',
value: 'spammer',
icon: 'ri:alert-fill',
noTransitionPersist: true,
},
{ {
label: 'Verified', label: 'Verified',
value: 'verified', value: 'verified',
@@ -419,6 +434,7 @@ if (!user) return Astro.rewrite('/404')
label: role.label, label: role.label,
value: role.value, value: role.value,
icon: role.icon, icon: role.icon,
noTransitionPersist: true,
}))} }))}
required required
cardSize="sm" cardSize="sm"

View File

@@ -19,7 +19,7 @@ import type { Prisma } from '@prisma/client'
const { data: filters } = zodParseQueryParamsStoringErrors( const { data: filters } = zodParseQueryParamsStoringErrors(
{ {
'sort-by': z.enum(['name', 'role', 'createdAt', 'karma']).default('createdAt'), 'sort-by': z.enum(['name', 'role', 'lastLoginAt', 'karma', 'createdAt']).default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'), 'sort-order': z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(), search: z.string().optional(),
role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).optional(), role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).optional(),
@@ -29,7 +29,10 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
// Set up Prisma orderBy with correct typing // Set up Prisma orderBy with correct typing
const prismaOrderBy = const prismaOrderBy =
filters['sort-by'] === 'name' || filters['sort-by'] === 'createdAt' || filters['sort-by'] === 'karma' filters['sort-by'] === 'name' ||
filters['sort-by'] === 'createdAt' ||
filters['sort-by'] === 'lastLoginAt' ||
filters['sort-by'] === 'karma'
? { ? {
[filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]: [filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]:
filters['sort-order'] === 'asc' ? 'asc' : 'desc', filters['sort-order'] === 'asc' ? 'asc' : 'desc',
@@ -86,6 +89,7 @@ const dbUsers = await prisma.user.findMany({
totalKarma: true, totalKarma: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
lastLoginAt: true,
internalNotes: { internalNotes: {
select: { select: {
id: true, id: true,
@@ -218,16 +222,29 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
<th <th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase" class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
> >
<a <div class="flex flex-wrap items-center justify-center gap-1">
href={makeSortUrl('createdAt')} <a
class="flex items-center justify-center hover:text-zinc-200" href={makeSortUrl('lastLoginAt')}
> class="flex items-center justify-center hover:text-zinc-200"
Joined <SortArrowIcon >
active={filters['sort-by'] === 'createdAt'} Login <SortArrowIcon
sortOrder={filters['sort-order']} active={filters['sort-by'] === 'lastLoginAt'}
/> sortOrder={filters['sort-order']}
</a> />
</a>
<span class="text-zinc-600">/</span>
<a
href={makeSortUrl('createdAt')}
class="flex items-center justify-center hover:text-zinc-200"
>
Joined <SortArrowIcon
active={filters['sort-by'] === 'createdAt'}
sortOrder={filters['sort-order']}
/>
</a>
</div>
</th> </th>
<th <th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase" class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
> >
@@ -305,8 +322,24 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
{user.totalKarma} {user.totalKarma}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center text-sm text-zinc-400"> <td class="px-4 py-3 text-center text-sm">
<TimeFormatted date={user.createdAt} hourPrecision hoursShort prefix={false} /> <div class="flex flex-wrap items-center justify-center gap-1 text-center">
<TimeFormatted
class="text-zinc-300"
date={user.lastLoginAt}
hourPrecision
hoursShort
prefix={false}
/>
<span class="text-zinc-600">/</span>
<TimeFormatted
class="text-zinc-400"
date={user.createdAt}
hourPrecision
hoursShort
prefix={false}
/>
</div>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex justify-center gap-3"> <div class="flex justify-center gap-3">

View File

@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro'
export const ALL: APIRoute = (context) => {
console.error('Endpoint not found', { url: context.url.href, method: context.request.method })
return new Response(
JSON.stringify({
error: 'Endpoint not found',
}),
{
status: 404,
}
)
}

View File

@@ -0,0 +1,7 @@
import { actions } from 'astro:actions'
import { makeEndpointFromAction } from '../../../../lib/endpoints'
import type { APIRoute } from 'astro'
export const QUERY: APIRoute = makeEndpointFromAction(actions.api.service.get)

174
web/src/pages/docs/api.mdx Normal file
View File

@@ -0,0 +1,174 @@
---
layout: ../../layouts/MarkdownLayout.astro
title: API
author: KYCnot.me
pubDate: 2025-05-31
description: 'Access basic service data via our public API.'
icon: 'ri:plug-line'
---
import { SOURCE_CODE_URL } from 'astro:env/server'
import { kycLevels } from '../../constants/kycLevels'
import { verificationStatuses } from '../../constants/verificationStatus'
import { serviceVisibilities } from '../../constants/serviceVisibility'
Access basic service data via our public API.
All endpoints should be prefixed with `/api/v1/`.
The endpoints <a href={SOURCE_CODE_URL}>source code</a> is available on the `/web/src/actions/api/index.ts` file.
**Attribution:** Please credit **KYCnot.me** if you use data from this API.
## `QUERY` `/service/get`
Fetches details for a single service by various lookup criteria.
### Request Parameters
| Parameter | Type | Required | Description |
| ------------ | ------ | -------- | ----------- |
| `id` | number | No* | Service ID |
| `slug` | string | No* | Service URL slug (lowercase letters, numbers, and hyphens only) |
| `serviceUrl` | string | No* | Service URL. May be web, onion, or i2p. May just be a domain or a full URL. |
\* At least one of the marked parameters is required.
### Response Format
```typescript
type ServiceResponse = {
id: number
slug: string
name: string
description: string
serviceVisibility: 'PUBLIC' | 'ARCHIVED' | 'UNLISTED'
verificationStatus: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
verificationStatusInfo: {
value: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
slug: string
label: string
labelShort: string
description: string
}
verifiedAt: Date | null
kycLevel: 0 | 1 | 2 | 3 | 4
kycLevelInfo: {
value: 0 | 1 | 2 | 3 | 4
name: string
description: string
}
categories: {
name: string
slug: string
}[]
listedAt: Date
serviceUrls: string[]
tosUrls: string[]
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`
}
```
#### KYC Levels
<ul>
{kycLevels.map((level) => (
<li key={level.id}>
<strong>{level.id}</strong>: {level.name} - {level.description}
</li>
))}
</ul>
#### Verification Status
<ul>
{verificationStatuses.map((status) => (
<li key={status.value}>
<strong>{status.value}</strong>: {status.description}
</li>
))}
</ul>
#### Service Visibility
<ul>
{serviceVisibilities.filter((visibility) => visibility.value === 'PUBLIC' || visibility.value === 'ARCHIVED' || visibility.value === 'UNLISTED').map((visibility) => (
<li key={visibility.value}>
<strong>{visibility.value}</strong>: {visibility.longDescription}
</li>
))}
</ul>
### Examples
#### Request
```zsh
curl -X QUERY https://kycnot.me/api/v1/service/get \
-H "Content-Type: application/json" \
-d '{"slug": "my-example-service"}'
```
#### Response
```json
{
"name": "My Example Service",
"description": "This is a description of my example service",
"serviceVisibility": "PUBLIC",
"verificationStatus": "VERIFICATION_SUCCESS",
"verificationStatusInfo": {
"value": "VERIFICATION_SUCCESS",
"slug": "verified",
"label": "Verified",
"labelShort": "Verified",
"description": "Thoroughly tested and verified by the team. But things might change, this is not a guarantee."
},
"verifiedAt": "2025-01-20T07:12:29.393Z",
"kycLevel": 0,
"kycLevelInfo": {
"value": 0,
"name": "Guaranteed no KYC",
"description": "Terms explicitly state KYC will never be requested."
},
"categories": [
{
"name": "Exchange",
"slug": "exchange"
}
],
"listedAt": "2025-05-31T19:09:18.043Z",
"serviceUrls": [
"https://example.com",
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"
],
"tosUrls": ["https://example.com/terms-of-service"],
"kycnotmeUrl": "https://kycnot.me/service/bisq"
}
```
#### Error Responses
**404 Not Found**: Service not found
```json
{
"error": "Service not found"
}
```
**400 Bad Request**: Invalid input parameters
```json
{
"error": "Validation error message"
}
```
**500 Internal Server Error**: Server error
```json
{
"error": "Internal server error"
}
```

View File

@@ -1,5 +1,5 @@
--- ---
layout: ../layouts/MarkdownLayout.astro layout: ../../layouts/MarkdownLayout.astro
title: How does karma work? title: How does karma work?
description: "KYCnot.me has a user karma system, here's how it works" description: "KYCnot.me has a user karma system, here's how it works"
icon: 'ri:hearts-line' icon: 'ri:hearts-line'
@@ -7,7 +7,7 @@ author: KYCnot.me
pubDate: 2025-05-15 pubDate: 2025-05-15
--- ---
import KarmaUnlocksTable from '../components/KarmaUnlocksTable.astro' import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'
[KYCnot.me](https://kycnot.me) implements a karma system to encourage quality contributions and maintain community standards. Users can earn (or lose) karma points through various interactions on the platform, primarily through their comments on services. [KYCnot.me](https://kycnot.me) implements a karma system to encourage quality contributions and maintain community standards. Users can earn (or lose) karma points through various interactions on the platform, primarily through their comments on services.

View File

@@ -13,6 +13,7 @@ import {
getEventTypeInfo, getEventTypeInfo,
getEventTypeInfoBySlug, getEventTypeInfoBySlug,
} from '../constants/eventTypes' } from '../constants/eventTypes'
import { getServiceVisibilityInfo } from '../constants/serviceVisibility'
import { getVerificationStatusInfo } from '../constants/verificationStatus' import { getVerificationStatusInfo } from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
@@ -44,6 +45,8 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
async () => async () =>
prisma.service.findMany({ prisma.service.findMany({
where: { where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
events: { events: {
some: { some: {
visible: true, visible: true,
@@ -72,8 +75,15 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
createdAt: { createdAt: {
lte: params.now, lte: params.now,
}, },
...(params.service ? { service: { slug: params.service } } : {}), service: {
...(params.type ? { type: getEventTypeInfoBySlug(params.type).id } : {}), slug: params.service ?? undefined,
listedAt: params.service ? undefined : { lte: new Date() },
serviceVisibility: {
in: params.service ? ['PUBLIC', 'ARCHIVED', 'UNLISTED'] : ['PUBLIC', 'ARCHIVED'],
},
},
type: params.type ? getEventTypeInfoBySlug(params.type).id : undefined,
...(params.from || params.to ...(params.from || params.to
? { ? {
OR: [ OR: [
@@ -105,6 +115,7 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
name: true, name: true,
imageUrl: true, imageUrl: true,
verificationStatus: true, verificationStatus: true,
serviceVisibility: true,
}, },
}, },
}, },
@@ -126,6 +137,7 @@ const events = orderBy(
service: { service: {
...event.service, ...event.service,
verificationStatusInfo: getVerificationStatusInfo(event.service.verificationStatus), verificationStatusInfo: getVerificationStatusInfo(event.service.verificationStatus),
serviceVisibilityInfo: getServiceVisibilityInfo(event.service.serviceVisibility),
}, },
})), })),
['actualEndedAt', 'startedAt'], ['actualEndedAt', 'startedAt'],
@@ -416,6 +428,16 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
)} )}
/> />
)} )}
{event.service.serviceVisibility === 'ARCHIVED' && (
<Icon
name={event.service.serviceVisibilityInfo.icon}
class={cn(
'ms-1 inline-block size-3 shrink-0',
event.service.serviceVisibilityInfo.iconClass
)}
/>
)}
</a> </a>
{event.source && ( {event.source && (

View File

@@ -1,6 +1,6 @@
--- ---
import { z } from 'astro:schema' import { z } from 'astro:schema'
import { groupBy, omit, orderBy, uniq } from 'lodash-es' import { groupBy, omit, orderBy, sortBy, uniq } from 'lodash-es'
import seedrandom from 'seedrandom' import seedrandom from 'seedrandom'
import Button from '../components/Button.astro' import Button from '../components/Button.astro'
@@ -26,6 +26,7 @@ import {
} from '../constants/verificationStatus' } from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays' import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { parseIntWithFallback } from '../lib/numbers' import { parseIntWithFallback } from '../lib/numbers'
import { areEqualObjectsWithoutOrder } from '../lib/objects' import { areEqualObjectsWithoutOrder } from '../lib/objects'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
@@ -181,6 +182,7 @@ const {
'min-score': { if: 'default' }, 'min-score': { if: 'default' },
'user-rating': { if: 'default' }, 'user-rating': { if: 'default' },
'max-kyc': { if: 'default' }, 'max-kyc': { if: 'default' },
'sort-seed': { if: 'another-is-unset', prop: 'page' },
}, },
}, },
} }
@@ -213,17 +215,16 @@ const groupedAttributes = groupBy(
'value' 'value'
) )
const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : null
const where = { const where = {
listedAt: { id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
lte: new Date(), listedAt: { lte: new Date() },
},
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined, categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
verificationStatus: { verificationStatus: {
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification, in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
}, },
serviceVisibility: { serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
in: ['PUBLIC', 'ARCHIVED'],
},
overallScore: { gte: filters['min-score'] }, overallScore: { gte: filters['min-score'] },
acceptedCurrencies: filters.currencies.length acceptedCurrencies: filters.currencies.length
? filters['currency-mode'] === 'and' ? filters['currency-mode'] === 'and'
@@ -243,16 +244,6 @@ const where = {
} satisfies Prisma.ServiceWhereInput, } satisfies Prisma.ServiceWhereInput,
] ]
: []), : []),
...(filters.q
? [
{
OR: [
{ name: { contains: filters.q, mode: 'insensitive' as const } },
{ description: { contains: filters.q, mode: 'insensitive' as const } },
],
} satisfies Prisma.ServiceWhereInput,
]
: []),
...(filters.networks.length ...(filters.networks.length
? [ ? [
{ {
@@ -325,9 +316,8 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
select: { select: {
services: { services: {
where: { where: {
serviceVisibility: { serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
in: ['PUBLIC', 'ARCHIVED'], listedAt: { lte: new Date() },
},
}, },
}, },
}, },
@@ -338,33 +328,46 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
[ [
'Unable to load services.', 'Unable to load services.',
async () => { async () => {
const [unsortedServices, totalServices] = await prisma.service.findManyAndCount({ const [unsortedServicesMissingSimilarityScore, totalServices] = await prisma.service.findManyAndCount(
where, {
select: { where,
id: true, select: {
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record< id: true,
(typeof sortOptions)[number]['orderBy']['key'], ...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
true (typeof sortOptions)[number]['orderBy']['key'],
>), true
}, >),
}) },
}
)
const unsortedServices = unsortedServicesMissingSimilarityScore.map((service) => ({
...service,
similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
}))
const rng = seedrandom(filters['sort-seed']) const rng = seedrandom(filters['sort-seed'])
const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption
const sortedServices = orderBy( const sortedServices = orderBy(
unsortedServices, // NOTE: We do a first sort by id to make the seeded sort deterministic
[selectedSort.orderBy.key, () => rng()], sortBy(unsortedServices, 'id'),
[selectedSort.orderBy.direction, 'asc'] [
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
selectedSort.orderBy.key,
() => rng(),
],
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE) ).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE)
const unsortedServicesWithInfo = await prisma.service.findMany({ const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({
where: { where: {
id: { id: {
in: sortedServices.map((service) => service.id), in: sortedServices.map((service) => service.id),
}, },
}, },
select: { select: {
id: true,
name: true, name: true,
slug: true, slug: true,
description: true, description: true,
@@ -398,14 +401,20 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
}, },
}) })
const unsortedServicesWithInfo = unsortedServicesWithInfoMissingSimilarityScore.map((service) => ({
...service,
similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
}))
const sortedServicesWithInfo = orderBy( const sortedServicesWithInfo = orderBy(
unsortedServicesWithInfo, unsortedServicesWithInfo,
[ [
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
selectedSort.orderBy.key, selectedSort.orderBy.key,
// Now we can shuffle indeternimistically, because the pagination was already applied // Now we can shuffle indeternimistically, because the pagination was already applied
() => Math.random(), () => Math.random(),
], ],
[selectedSort.orderBy.direction, 'asc'] [...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
) )
return [sortedServicesWithInfo, totalServices] as const return [sortedServicesWithInfo, totalServices] as const
@@ -712,7 +721,6 @@ const showFiltersId = 'show-filters'
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
sortSeed={filters['sort-seed']} sortSeed={filters['sort-seed']}
filters={filters} filters={filters}
includeScams={includeScams}
countCommunityOnly={countCommunityOnly} countCommunityOnly={countCommunityOnly}
inlineIcons inlineIcons
/> />

View File

@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro'
export const ALL: APIRoute = (context) => {
console.error('Endpoint not found', { url: context.url.href, method: context.request.method })
return new Response(
JSON.stringify({
error: 'Endpoint not found',
}),
{
status: 404,
}
)
}

View File

@@ -0,0 +1,7 @@
import { actions } from 'astro:actions'
import { makeEndpointFromAction } from '../../../lib/endpoints'
import type { APIRoute } from 'astro'
export const POST: APIRoute = makeEndpointFromAction(actions.notification.webPush.subscribe)

View File

@@ -0,0 +1,7 @@
import { actions } from 'astro:actions'
import { makeEndpointFromAction } from '../../../lib/endpoints'
import type { APIRoute } from 'astro'
export const POST: APIRoute = makeEndpointFromAction(actions.notification.webPush.unsubscribe)

View File

@@ -4,6 +4,7 @@ import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import Button from '../components/Button.astro' import Button from '../components/Button.astro'
import PushNotificationBanner from '../components/PushNotificationBanner.astro'
import TimeFormatted from '../components/TimeFormatted.astro' import TimeFormatted from '../components/TimeFormatted.astro'
import Tooltip from '../components/Tooltip.astro' import Tooltip from '../components/Tooltip.astro'
import { getNotificationTypeInfo } from '../constants/notificationTypes' import { getNotificationTypeInfo } from '../constants/notificationTypes'
@@ -28,131 +29,146 @@ const { data: params } = zodParseQueryParamsStoringErrors(
) )
const skip = (params.page - 1) * PAGE_SIZE const skip = (params.page - 1) * PAGE_SIZE
const [dbNotifications, notificationPreferences, totalNotifications] = await Astro.locals.banners.tryMany([ const [dbNotifications, notificationPreferences, totalNotifications, pushSubscriptions] =
[ await Astro.locals.banners.tryMany([
'Error while fetching notifications', [
() => 'Error while fetching notifications',
prisma.notification.findMany({ () =>
where: { prisma.notification.findMany({
userId: user.id, where: {
}, userId: user.id,
orderBy: { },
createdAt: 'desc', orderBy: {
}, createdAt: 'desc',
skip, },
take: PAGE_SIZE, skip,
select: { take: PAGE_SIZE,
select: {
id: true,
type: true,
createdAt: true,
read: true,
aboutAccountStatusChange: true,
aboutCommentStatusChange: true,
aboutServiceVerificationStatusChange: true,
aboutSuggestionStatusChange: true,
aboutComment: {
select: {
id: true,
author: {
select: {
id: true,
},
},
status: true,
content: true,
communityNote: true,
service: {
select: {
slug: true,
name: true,
},
},
parent: {
select: {
author: {
select: {
id: 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,
},
},
},
}),
[],
],
[
'Error while fetching notification preferences',
() =>
getOrCreateNotificationPreferences(user.id, {
id: true, id: true,
type: true, enableOnMyCommentStatusChange: true,
createdAt: true, enableAutowatchMyComments: true,
read: true, enableNotifyPendingRepliesOnWatch: true,
aboutAccountStatusChange: true, karmaNotificationThreshold: true,
aboutCommentStatusChange: true, }),
aboutServiceVerificationStatusChange: true, null,
aboutSuggestionStatusChange: true, ],
aboutComment: { [
select: { 'Error while fetching total notifications',
id: true, () => prisma.notification.count({ where: { userId: user.id } }),
author: { 0,
select: { ],
id: true, [
}, 'Error while fetching push subscriptions',
}, () =>
status: true, prisma.pushSubscription.findMany({
content: true, where: { userId: user.id },
communityNote: true, select: {
service: { endpoint: true,
select: { userAgent: true,
slug: true,
name: true,
},
},
parent: {
select: {
author: {
select: {
id: true,
},
},
},
},
},
}, },
aboutServiceSuggestionId: true, }),
aboutServiceSuggestion: { [],
select: { ],
status: true, ])
service: {
select: { if (!notificationPreferences) console.error('No notification preferences found')
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,
},
},
},
}),
[],
],
[
'Error while fetching notification preferences',
() =>
getOrCreateNotificationPreferences(user.id, {
id: true,
enableOnMyCommentStatusChange: true,
enableAutowatchMyComments: true,
enableNotifyPendingRepliesOnWatch: true,
karmaNotificationThreshold: true,
}),
null,
],
[
'Error while fetching total notifications',
() => prisma.notification.count({ where: { userId: user.id } }),
0,
],
])
const totalPages = Math.ceil(totalNotifications / PAGE_SIZE) const totalPages = Math.ceil(totalNotifications / PAGE_SIZE)
@@ -199,6 +215,17 @@ const notifications = dbNotifications.map((notification) => ({
}} }}
> >
<section class="mx-auto w-full"> <section class="mx-auto w-full">
{
notifications.length >= 5 && (
<PushNotificationBanner
class="mb-4"
dismissable
pushSubscriptions={pushSubscriptions}
hideIfEnabled
/>
)
}
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h1 class="font-title flex items-center text-2xl leading-tight font-bold tracking-wider"> <h1 class="font-title flex items-center text-2xl leading-tight font-bold tracking-wider">
<Icon name="ri:notification-line" class="mr-2 size-6 text-zinc-400" /> <Icon name="ri:notification-line" class="mr-2 size-6 text-zinc-400" />
@@ -306,57 +333,57 @@ const notifications = dbNotifications.map((notification) => ({
) )
} }
{ <h2 class="font-title mt-8 mb-3 flex items-center border-b border-zinc-800 text-lg font-bold">
!!notificationPreferences && ( <Icon name="ri:settings-3-line" class="mr-2 size-5 text-zinc-400" />
<div class="mt-8"> Notification Settings
<h2 class="font-title mb-3 flex items-center border-b border-zinc-800 text-lg font-bold"> </h2>
<Icon name="ri:settings-3-line" class="mr-2 size-5 text-zinc-400" />
Notification Settings
</h2>
<form <PushNotificationBanner class="mb-3" pushSubscriptions={pushSubscriptions} />
method="POST"
action={actions.notification.preferences.update}
class="rounded-lg border border-zinc-800 bg-zinc-900 p-4 shadow-sm"
>
{notificationPreferenceFields.map((field) => (
<label class="flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
<span class="flex items-center text-zinc-300">
<Icon name={field.icon} class="mr-2 size-5 text-zinc-400" />
{field.label}
</span>
<input
type="checkbox"
name={field.id}
checked={notificationPreferences[field.id]}
class="size-4 rounded border-zinc-700 bg-zinc-800 text-blue-600 focus:ring-blue-600"
/>
</label>
))}
<div class="mt-4 flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800"> <form
<span class="flex items-center text-zinc-300"> method="POST"
<Icon name="ri:award-line" class="mr-2 size-5 text-zinc-400" /> action={actions.notification.preferences.update}
Notify me when my karma changes by at least class="rounded-lg border border-zinc-800 bg-zinc-900 p-4 shadow-sm"
</span> >
<div class="flex items-center gap-2"> {
<input notificationPreferenceFields.map((field) => (
type="number" <label class="flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
name="karmaNotificationThreshold" <span class="flex items-center text-zinc-300">
value={notificationPreferences.karmaNotificationThreshold} <Icon name={field.icon} class="mr-2 size-5 text-zinc-400" />
min="1" {field.label}
class="w-20 rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-zinc-200 focus:border-blue-600 focus:ring-1 focus:ring-blue-600 focus:outline-none" </span>
/> <input
<span class="text-zinc-400">points</span> type="checkbox"
</div> name={field.id}
</div> checked={notificationPreferences?.[field.id]}
class="size-4 rounded border-zinc-700 bg-zinc-800 text-blue-600 focus:ring-blue-600"
/>
</label>
))
}
<div class="mt-4 flex justify-end"> <div
<Button type="submit" label="Save" icon="ri:save-line" color="success" /> class="mt-4 flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800"
</div> >
</form> <span class="flex items-center text-zinc-300">
<Icon name="ri:award-line" class="mr-2 size-5 text-zinc-400" />
Notify me when my karma changes by at least
</span>
<div class="flex items-center gap-2">
<input
type="number"
name="karmaNotificationThreshold"
value={notificationPreferences?.karmaNotificationThreshold}
min="1"
class="w-20 rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-zinc-200 focus:border-blue-600 focus:ring-1 focus:ring-blue-600 focus:outline-none"
/>
<span class="text-zinc-400">points</span>
</div> </div>
) </div>
}
<div class="mt-4 flex justify-end">
<Button type="submit" label="Save" icon="ri:save-line" color="success" />
</div>
</form>
</section> </section>
</BaseLayout> </BaseLayout>

View File

@@ -20,6 +20,7 @@ import InputTextArea from '../../components/InputTextArea.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories' import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes' import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { currencies } from '../../constants/currencies' import { currencies } from '../../constants/currencies'
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
import { kycLevels } from '../../constants/kycLevels' import { kycLevels } from '../../constants/kycLevels'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
@@ -201,34 +202,31 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
error={inputErrors.description} error={inputErrors.description}
/> />
<InputTextArea <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
label="Service URLs" <InputTextArea
name="serviceUrls" label="Service URLs"
inputProps={{ description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
required: true, name="allServiceUrls"
placeholder: 'https://example1.com\nhttps://example2.org', inputProps={{
}} placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
error={inputErrors.serviceUrls} class: 'min-h-24',
/> required: true,
}}
<InputTextArea class="row-span-2 flex flex-col self-stretch"
label="Terms of Service URLs" error={inputErrors.allServiceUrls}
name="tosUrls" />
inputProps={{ <InputTextArea
required: true, label="ToS URLs"
placeholder: 'https://example1.com/tos\nhttps://example2.org/terms', description="One per line"
}} name="tosUrls"
error={inputErrors.tosUrls} inputProps={{
/> placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
class: 'md:min-h-24',
<InputTextArea required: true,
label="Onion URLs" }}
name="onionUrls" error={inputErrors.tosUrls}
inputProps={{ />
placeholder: 'http://example1.onion\nhttp://example2.onion', </div>
}}
error={inputErrors.onionUrls}
/>
<InputCardGroup <InputCardGroup
name="kycLevel" name="kycLevel"
@@ -245,6 +243,21 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
error={inputErrors.kycLevel} error={inputErrors.kycLevel}
/> />
<InputCardGroup
name="kycLevelClarification"
label="KYC Level Clarification"
options={kycLevelClarifications.map((clarification) => ({
label: clarification.label,
value: clarification.value,
icon: clarification.icon,
description: clarification.description,
}))}
selectedValue="NONE"
iconSize="sm"
cardSize="sm"
error={inputErrors.kycLevelClarification}
/>
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6"> <div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
<InputCheckboxGroup <InputCheckboxGroup
name="categories" name="categories"

View File

@@ -1,5 +1,5 @@
--- ---
import { VerificationStepStatus } from '@prisma/client' import { VerificationStepStatus, EventType, KycLevelClarification } from '@prisma/client'
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote' import { Markdown } from 'astro-remote'
import { Schema } from 'astro-seo-schema' import { Schema } from 'astro-seo-schema'
@@ -32,6 +32,7 @@ import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { formatContactMethod } from '../../constants/contactMethods' import { formatContactMethod } from '../../constants/contactMethods'
import { currencies, getCurrencyInfo } from '../../constants/currencies' import { currencies, getCurrencyInfo } from '../../constants/currencies'
import { getEventTypeInfo } from '../../constants/eventTypes' import { getEventTypeInfo } from '../../constants/eventTypes'
import { getKycLevelClarificationInfo } from '../../constants/kycLevelClarifications'
import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels' import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels'
import { getServiceVisibilityInfo } from '../../constants/serviceVisibility' import { getServiceVisibilityInfo } from '../../constants/serviceVisibility'
import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating' import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating'
@@ -67,13 +68,18 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
'Error fetching service', 'Error fetching service',
async () => async () =>
prisma.service.findUnique({ prisma.service.findUnique({
where: { slug }, where: {
slug,
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
listedAt: { lte: new Date() },
},
select: { select: {
id: true, id: true,
slug: true, slug: true,
name: true, name: true,
description: true, description: true,
kycLevel: true, kycLevel: true,
kycLevelClarification: true,
overallScore: true, overallScore: true,
privacyScore: true, privacyScore: true,
trustScore: true, trustScore: true,
@@ -219,6 +225,34 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
], ],
]) ])
if (!service) {
try {
const serviceWithOldSlug = await prisma.service.findFirst({
where: {
previousSlugs: { has: slug },
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
listedAt: { lte: new Date() },
},
select: { slug: true },
})
if (serviceWithOldSlug) {
return Astro.redirect(`/service/${serviceWithOldSlug.slug}`, 301)
}
} catch (error) {
console.error(error)
}
return Astro.rewrite('/404')
}
if (
service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'UNLISTED' &&
service.serviceVisibility !== 'ARCHIVED'
) {
return Astro.rewrite('/404')
}
const makeWatchingDetails = ( const makeWatchingDetails = (
dbNotificationPreferences: Prisma.NotificationPreferencesGetPayload<{ dbNotificationPreferences: Prisma.NotificationPreferencesGetPayload<{
select: { select: {
@@ -254,17 +288,7 @@ const makeWatchingDetails = (
} as const } as const
} }
const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service?.id) const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service.id)
if (!service) return Astro.rewrite('/404')
if (
service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'UNLISTED' &&
service.serviceVisibility !== 'ARCHIVED'
) {
return Astro.rewrite('/404')
}
const statusIcon = { const statusIcon = {
...verificationStatusesByValue, ...verificationStatusesByValue,
@@ -286,6 +310,9 @@ const hiddenLinks = [
] ]
const kycLevelInfo = getKycLevelInfo(`${service.kycLevel}`) const kycLevelInfo = getKycLevelInfo(`${service.kycLevel}`)
const kycLevelClarificationInfo = getKycLevelClarificationInfo(service.kycLevelClarification)
const userSentiment = service.userSentiment const userSentiment = service.userSentiment
? { ...service.userSentiment, info: getUserSentimentInfo(service.userSentiment.sentiment) } ? { ...service.userSentiment, info: getUserSentimentInfo(service.userSentiment.sentiment) }
: null : null
@@ -380,6 +407,10 @@ const ogImageTemplateData = {
} satisfies OgImageAllTemplatesWithProps } satisfies OgImageAllTemplatesWithProps
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility) const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
const activeAlertOrWarningEvents = service.events.filter(
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
)
--- ---
<BaseLayout <BaseLayout
@@ -480,6 +511,32 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
}), }),
]} ]}
> >
{
activeAlertOrWarningEvents.length > 0 && (
<a
href="#events"
class={cn(
'mb-4 block rounded-md px-3 py-2 text-sm font-medium',
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60'
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60'
)}
>
<Icon
name={
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'ri:alert-fill'
: 'ri:alarm-warning-fill'
}
class="me-1.5 inline-block size-4 align-[-0.15em]"
/>
{activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'There is an active alert for this service. Click to see details.'
: 'There is an active warning for this service. Click to see details.'}
</a>
)
}
{ {
(serviceVisibilityInfo.value === 'UNLISTED' || serviceVisibilityInfo.value === 'ARCHIVED') && ( (serviceVisibilityInfo.value === 'UNLISTED' || serviceVisibilityInfo.value === 'ARCHIVED') && (
<div class={cn('mb-4 rounded-md bg-yellow-900/50 px-3 py-2 text-sm text-yellow-400')}> <div class={cn('mb-4 rounded-md bg-yellow-900/50 px-3 py-2 text-sm text-yellow-400')}>
@@ -844,8 +901,12 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
'@type': 'Review', '@type': 'Review',
itemReviewed: { '@id': itemReviewedId }, itemReviewed: { '@id': itemReviewedId },
reviewAspect: 'KYC Level', reviewAspect: 'KYC Level',
name: kycLevelInfo.name, name:
reviewBody: kycLevelInfo.description, kycLevelInfo.name +
(kycLevelClarificationInfo.value !== 'NONE' ? ` (${kycLevelClarificationInfo.label})` : ''),
reviewBody:
kycLevelInfo.description +
(kycLevelClarificationInfo.value !== 'NONE' ? ` ${kycLevelClarificationInfo.description}` : ''),
reviewRating: { reviewRating: {
'@type': 'Rating', '@type': 'Rating',
ratingValue: kycLevelInfo.value, ratingValue: kycLevelInfo.value,
@@ -866,9 +927,22 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
</div> </div>
<dl class="flex-grow-5 basis-0"> <dl class="flex-grow-5 basis-0">
<dt class="text-base font-bold text-pretty">{kycLevelInfo.name}</dt> <dt class="text-base font-bold text-pretty">
{kycLevelInfo.name}
{
kycLevelClarificationInfo.value !== 'NONE' && (
<>
<span class="text-day-400 mx-1">•</span>
<span class="text-blue-500">{kycLevelClarificationInfo.label}</span>
</>
)
}
</dt>
<dd class="text-day-700 mt-1 font-sans text-sm text-pretty"> <dd class="text-day-700 mt-1 font-sans text-sm text-pretty">
{kycLevelInfo.description} {kycLevelInfo.description}
<span class="font-medium text-blue-600">
{kycLevelClarificationInfo.value !== 'NONE' && ` ${kycLevelClarificationInfo.description}`}
</span>
</dd> </dd>
</dl> </dl>
</div> </div>
@@ -1182,6 +1256,7 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
<div class="mt-3 max-w-md pe-8"> <div class="mt-3 max-w-md pe-8">
<h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white"> <h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white">
{typeInfo.isSolved && <BadgeSmall text="Solved" icon="ri:check-line" color="green" />}
{event.title} {event.title}
</h3> </h3>

View File

@@ -48,6 +48,7 @@ const user = await Astro.locals.banners.try('user', async () => {
verifiedLink: true, verifiedLink: true,
totalKarma: true, totalKarma: true,
createdAt: true, createdAt: true,
lastLoginAt: true,
_count: { _count: {
select: { select: {
comments: true, comments: true,
@@ -93,7 +94,16 @@ const user = await Astro.locals.banners.try('user', async () => {
}, },
}, },
}, },
where: { service: { serviceVisibility: 'PUBLIC' } }, where: {
service: {
listedAt: {
lte: new Date(),
},
serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED'],
},
},
},
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 5, take: 5,
}, },
@@ -469,6 +479,24 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</div> </div>
</li> </li>
<AdminOnly>
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
<div>
<p class="text-day-500 text-xs">Last login</p>
<p class="text-day-300">
{
formatDateShort(user.lastLoginAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
})
}
</p>
</div>
</li>
</AdminOnly>
{ {
user.verifiedLink && ( user.verifiedLink && (
<li class="flex items-start"> <li class="flex items-start">
@@ -628,8 +656,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3"> <div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
<p class="text-day-300"> <p class="text-day-300">
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline" Earn karma to unlock features and privileges. <a
>Learn about karma</a href="/docs/karma"
class="text-day-200 hover:underline">Learn about karma</a
> >
</p> </p>
</div> </div>