Compare commits

...

14 Commits

Author SHA1 Message Date
pluja
01488b8b3b Release 202507010806 2025-07-01 08:06:36 +00:00
pluja
b456af9448 Release 202507010759 2025-07-01 07:59:22 +00:00
pluja
b7ae6dc22c Release 202507010740 2025-07-01 07:40:26 +00:00
pluja
e4a5fa8fa7 Release 202506241430 2025-06-24 14:30:07 +00:00
pluja
6ed07c8386 Release 202506171537 2025-06-17 15:37:10 +00:00
pluja
6a9f5f5e99 Release 202506151438 2025-06-15 14:38:24 +00:00
pluja
e6edee2dbe Release 202506151429 2025-06-15 14:29:34 +00:00
pluja
c7ee1606e4 Release 202506151416 2025-06-15 14:16:59 +00:00
pluja
f3c9b92ddb Release 202506151318 2025-06-15 13:18:22 +00:00
pluja
effb6689d7 Release 202506141856 2025-06-14 18:56:58 +00:00
pluja
cf5f3b3228 Release 202506131423 2025-06-13 14:23:14 +00:00
pluja
5a41816ac8 Release 202506131339 2025-06-13 13:39:12 +00:00
pluja
bf30a6cb2b Release 202506130639 2025-06-13 06:39:29 +00:00
pluja
4ca9b9a5c2 Release 202506112249 2025-06-11 22:49:54 +00:00
79 changed files with 1961 additions and 1217 deletions

View File

@@ -96,7 +96,7 @@ Tasks will run according to their configured cron schedules.
### Force Triggers Task
- Maintains database triggers by forcing them to run under certain conditions
- Currently handles updating the "isRecentlyListed" flag for services after 15 days
- Currently handles updating the "isRecentlyApproved" flag for services after 15 days
- Scheduled via `CRON_FORCE-TRIGGERS_TASK`
### Service Score Recalculation Task

View File

@@ -89,6 +89,17 @@ def parse_args(args: List[str]) -> argparse.Namespace:
score_recalc_parser.add_argument(
"--service-id", type=int, help="Specific service ID to process (optional)"
)
score_recalc_parser.add_argument(
"--all",
action="store_true",
help="Recalculate scores for all services (ignores --service-id)",
)
# Service Score Recalculation task for all services
subparsers.add_parser(
"service-score-recalc-all",
help="Recalculate service scores for all services",
)
return parser.parse_args(args)
@@ -295,12 +306,15 @@ def run_force_triggers_task() -> int:
close_db_pool()
def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
def run_service_score_recalc_task(
service_id: Optional[int] = None, all_services: bool = False
) -> int:
"""
Run the service score recalculation task.
Args:
service_id: Optional specific service ID to process.
all_services: Whether to recalculate scores for all services.
Returns:
Exit code.
@@ -310,7 +324,34 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
try:
# Initialize task and use as context manager
with ServiceScoreRecalculationTask() as task: # type: ignore
result = task.run(service_id) # type: ignore
if all_services:
queued = task.recalculate_all_services() # type: ignore
if not queued:
logger.warning(
"Failed to queue recalculation jobs for all services"
)
# Continuously process queued jobs in batches until none remain
while True:
_ = task.run() # type: ignore
# Check if there are still unprocessed jobs
remaining = 0
if task.conn:
with task.conn.cursor() as cursor:
cursor.execute(
'SELECT COUNT(*) FROM "ServiceScoreRecalculationJob" WHERE "processedAt" IS NULL'
)
remaining = cursor.fetchone()[0]
if remaining == 0:
break
result = True # All jobs processed successfully
else:
result = task.run(service_id) # type: ignore
if result:
logger.info("Successfully recalculated service scores")
else:
@@ -323,6 +364,13 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
close_db_pool()
def run_service_score_recalc_all_task() -> int:
"""
Run the service score recalculation task for all services.
"""
return run_service_score_recalc_task(all_services=True)
def run_worker_mode() -> int:
"""
Run in worker mode, scheduling tasks to run periodically.
@@ -361,6 +409,12 @@ def run_worker_mode() -> int:
scheduler.register_task(
task_name, cron_expression, run_service_score_recalc_task
)
elif task_name.lower() == "service_score_recalc_all":
scheduler.register_task(
task_name,
cron_expression,
run_service_score_recalc_all_task,
)
else:
logger.warning(f"Unknown task '{task_name}', skipping")
@@ -370,6 +424,12 @@ def run_worker_mode() -> int:
"*/5 * * * *",
run_service_score_recalc_task,
)
# Register daily service score recalculation for all services
scheduler.register_task(
"service_score_recalc_all",
"0 0 * * *",
run_service_score_recalc_all_task,
)
# Start the scheduler if tasks were registered
if scheduler.tasks:
@@ -419,7 +479,11 @@ def main() -> int:
elif args.task == "force-triggers":
return run_force_triggers_task()
elif args.task == "service-score-recalc":
return run_service_score_recalc_task(args.service_id)
return run_service_score_recalc_task(
args.service_id, getattr(args, "all", False)
)
elif args.task == "service-score-recalc-all":
return run_service_score_recalc_all_task()
elif args.task:
logger.error(f"Unknown task: {args.task}")
return 1

View File

@@ -62,25 +62,27 @@ class TaskScheduler:
cron_expression: Cron expression defining the schedule.
task_func: Function to execute.
*args: Arguments to pass to the task function.
**kwargs: Keyword arguments to pass to the task function.
**kwargs: Keyword arguments to pass to the task function. `instantiate` is a special kwarg.
"""
instantiate = kwargs.pop("instantiate", True)
# Declare task_instance variable with type annotation upfront
task_instance: Any = None
# Initialize the appropriate task class based on the task name
if task_name.lower() == "tosreview":
task_instance = TosReviewTask()
elif task_name.lower() == "user_sentiment":
task_instance = UserSentimentTask()
elif task_name.lower() == "comment_moderation":
task_instance = CommentModerationTask()
elif task_name.lower() == "force_triggers":
task_instance = ForceTriggersTask()
elif task_name.lower() == "service_score_recalc":
task_instance = ServiceScoreRecalculationTask()
else:
self.logger.warning(f"Unknown task '{task_name}', skipping")
return
if instantiate:
# Initialize the appropriate task class based on the task name
if task_name.lower() == "tosreview":
task_instance = TosReviewTask()
elif task_name.lower() == "user_sentiment":
task_instance = UserSentimentTask()
elif task_name.lower() == "comment_moderation":
task_instance = CommentModerationTask()
elif task_name.lower() == "force_triggers":
task_instance = ForceTriggersTask()
elif task_name.lower() == "service_score_recalc":
task_instance = ServiceScoreRecalculationTask()
else:
self.logger.warning(f"Unknown task '{task_name}', skipping")
return
self.tasks[task_name] = {
"cron": cron_expression,
@@ -126,8 +128,12 @@ class TaskScheduler:
self.logger.info(f"Running task '{task_name}'")
# Use task instance as a context manager to ensure
# a single database connection is used for the entire task
with task_info["instance"]:
# Execute the registered task function with its arguments
if task_info["instance"]:
with task_info["instance"]:
# Execute the registered task function with its arguments
task_info["func"](*task_info["args"], **task_info["kwargs"])
else:
# Execute the registered task function without a context manager
task_info["func"](*task_info["args"], **task_info["kwargs"])
self.logger.info(f"Task '{task_name}' completed")
except Exception as e:

View File

@@ -9,7 +9,7 @@ class ForceTriggersTask(Task):
Force triggers to run under certain conditions.
"""
RECENT_LISTED_INTERVAL_DAYS = 15
RECENT_APPROVED_INTERVAL_DAYS = 15
def __init__(self):
super().__init__("force_triggers")
@@ -24,10 +24,10 @@ class ForceTriggersTask(Task):
update_query = f"""
UPDATE "Service"
SET "isRecentlyListed" = FALSE, "updatedAt" = NOW()
WHERE "isRecentlyListed" = TRUE
AND "listedAt" IS NOT NULL
AND "listedAt" < NOW() - INTERVAL '{self.RECENT_LISTED_INTERVAL_DAYS} days'
SET "isRecentlyApproved" = FALSE, "updatedAt" = NOW()
WHERE "isRecentlyApproved" = TRUE
AND "approvedAt" IS NOT NULL
AND "approvedAt" < NOW() - INTERVAL '{self.RECENT_APPROVED_INTERVAL_DAYS} days'
"""
try:
with self.conn.cursor() as cursor:

View File

@@ -205,8 +205,7 @@ class ServiceScoreRecalculationTask(Task):
cursor.execute(
"""
SELECT id
FROM "Service"
WHERE "isActive" = TRUE
FROM "Service"
"""
)
services = cursor.fetchall()

View File

@@ -34,8 +34,8 @@ class TosReviewTask(Task):
service_name = service["name"]
verification_status = service.get("verificationStatus")
# Only process verified or approved services
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED"]:
# Only process verified, approved, or community contributed services
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED", "COMMUNITY_CONTRIBUTED"]:
self.logger.info(
f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}"
)

View File

@@ -92,7 +92,9 @@ def prompt_check_tos_review(content: str) -> TosReviewCheck:
{"role": "user", "content": content},
]
result_dict = query_openai_json(messages, model="openai/gpt-4.1-mini")
result_dict = query_openai_json(
messages, model="openai/gemini-2.5-flash-preview-05-20"
)
return cast(TosReviewCheck, result_dict)

View File

@@ -35,9 +35,11 @@ export default defineConfig({
registerType: 'autoUpdate',
manifest: {
name: 'KYCnot.me',
short_name: 'KYCnot.me',
description: 'Find services that respect your privacy',
theme_color: '#040505',
background_color: '#171c1b',
display: 'minimal-ui',
},
pwaAssets: {
image: './public/favicon.svg',
@@ -107,8 +109,9 @@ export default defineConfig({
'/service/[...slug]/proof': '/service/[...slug]/#verification',
'/attribute/[...slug]': '/attributes',
'/attr/[...slug]': '/attributes',
'/service/[...slug]/review': '/service/[...slug]#comments',
// #endregion
'/service/[...slug]/review': '/service/[...slug]#comments',
},
env: {
schema: {

883
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,26 +31,26 @@
"@astrojs/rss": "4.0.12",
"@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.8",
"@fontsource/inter": "5.2.5",
"@fontsource/inter": "5.2.6",
"@fontsource/space-grotesk": "5.2.8",
"@prisma/client": "6.9.0",
"@tailwindcss/vite": "4.1.8",
"@types/mime-types": "3.0.0",
"@prisma/client": "6.10.1",
"@tailwindcss/vite": "4.1.11",
"@types/mime-types": "3.0.1",
"@types/pg": "8.15.4",
"@vercel/og": "0.6.8",
"astro": "5.9.0",
"astro": "5.10.1",
"astro-loading-indicator": "0.7.0",
"astro-remote": "0.3.4",
"astro-seo-schema": "5.0.0",
"canvas": "3.1.0",
"canvas": "3.1.2",
"clsx": "2.1.1",
"htmx.org": "1.9.12",
"htmx.org": "2.0.6",
"javascript-time-ago": "2.5.11",
"libphonenumber-js": "1.12.9",
"lodash-es": "4.17.21",
"mime-types": "3.0.1",
"object-to-formdata": "4.5.1",
"pg": "8.16.0",
"pg": "8.16.3",
"qrcode": "1.5.4",
"react": "19.1.0",
"redis": "5.5.6",
@@ -58,52 +58,51 @@
"seedrandom": "3.0.5",
"sharp": "0.34.2",
"slugify": "1.6.6",
"tailwind-merge": "3.3.0",
"tailwind-merge": "3.3.1",
"tailwind-variants": "1.0.0",
"tailwindcss": "4.1.8",
"tailwindcss": "4.1.11",
"typescript": "5.8.3",
"unique-username-generator": "1.4.0",
"web-push": "3.6.7",
"zod-form-data": "2.0.7"
"web-push": "3.6.7"
},
"devDependencies": {
"@eslint/js": "9.28.0",
"@eslint/js": "9.30.0",
"@faker-js/faker": "9.8.0",
"@iconify-json/material-symbols": "1.2.24",
"@iconify-json/material-symbols": "1.2.28",
"@iconify-json/mdi": "1.2.3",
"@iconify-json/ri": "1.2.5",
"@stylistic/eslint-plugin": "4.4.1",
"@stylistic/eslint-plugin": "5.1.0",
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@types/eslint__js": "9.14.0",
"@types/lodash-es": "4.17.12",
"@types/qrcode": "1.5.5",
"@types/react": "19.1.6",
"@types/react": "19.1.8",
"@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.33.1",
"@typescript-eslint/parser": "8.35.1",
"@vite-pwa/assets-generator": "1.0.0",
"@vite-pwa/astro": "1.1.0",
"astro-icon": "1.1.5",
"date-fns": "4.1.0",
"esbuild": "0.25.5",
"eslint": "9.28.0",
"eslint-import-resolver-typescript": "4.4.3",
"eslint": "9.30.0",
"eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-astro": "1.3.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"globals": "16.2.0",
"prettier": "3.5.3",
"prettier": "3.6.2",
"prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "0.6.12",
"prisma": "6.9.0",
"prisma-json-types-generator": "3.4.2",
"prettier-plugin-tailwindcss": "0.6.13",
"prisma": "6.10.1",
"prisma-json-types-generator": "3.5.0",
"tailwind-htmx": "0.1.2",
"ts-essentials": "10.0.4",
"ts-essentials": "10.1.1",
"ts-toolbelt": "9.6.0",
"tsx": "4.19.4",
"typescript-eslint": "8.33.1",
"vite-plugin-devtools-json": "0.1.1",
"tsx": "4.20.3",
"typescript-eslint": "8.35.1",
"vite-plugin-devtools-json": "0.2.1",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
}

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "VerificationStepStatus" ADD VALUE 'WARNING';

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "approvedAt" TIMESTAMP(3),
ADD COLUMN "spamAt" TIMESTAMP(3);

View File

@@ -0,0 +1,21 @@
/*
Warnings:
- You are about to drop the column `isRecentlyListed` on the `Service` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Service" DROP COLUMN "isRecentlyListed",
ADD COLUMN "isRecentlyApproved" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE INDEX "Service_approvedAt_idx" ON "Service"("approvedAt");
-- CreateIndex
CREATE INDEX "Service_verifiedAt_idx" ON "Service"("verifiedAt");
-- CreateIndex
CREATE INDEX "Service_spamAt_idx" ON "Service"("spamAt");
-- CreateIndex
CREATE INDEX "Service_serviceVisibility_idx" ON "Service"("serviceVisibility");

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `userAgent` on the `PushSubscription` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "PushSubscription" DROP COLUMN "userAgent";

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "operatingSince" TIMESTAMP(3);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Service" ALTER COLUMN "operatingSince" SET DATA TYPE DATE;

View File

@@ -349,12 +349,12 @@ model Service {
categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4)
kycLevelClarification KycLevelClarification @default(NONE)
/// Date only, no time.
operatingSince DateTime? @db.Date
overallScore Int @default(0)
privacyScore Int @default(0)
trustScore Int @default(0)
/// Computed via trigger. Do not update through prisma.
isRecentlyListed Boolean @default(false)
/// Computed via trigger. Do not update through prisma.
averageUserRating Float?
serviceVisibility ServiceVisibility @default(PUBLIC)
serviceInfoBanner ServiceInfoBanner @default(NONE)
@@ -363,8 +363,6 @@ model Service {
verificationSummary String?
verificationRequests ServiceVerificationRequest[]
verificationProofMd String?
/// Computed via trigger when the service status is VERIFICATION_SUCCESS. Do not update through prisma.
verifiedAt DateTime?
/// [UserSentiment]
userSentiment Json?
userSentimentAt DateTime?
@@ -380,7 +378,16 @@ model Service {
tosReviewAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
/// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma.
listedAt DateTime?
/// Computed via trigger when the verification status is APPROVED. Do not update through prisma.
approvedAt DateTime?
/// Computed via trigger when the verification status is VERIFICATION_SUCCESS. Do not update through prisma.
verifiedAt DateTime?
/// Computed via trigger when the verification status is VERIFICATION_FAILED. Do not update through prisma.
spamAt DateTime?
/// Computed via trigger. Do not update through prisma.
isRecentlyApproved Boolean @default(false)
comments Comment[]
events Event[]
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
@@ -396,6 +403,9 @@ model Service {
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
@@index([listedAt])
@@index([approvedAt])
@@index([verifiedAt])
@@index([spamAt])
@@index([overallScore])
@@index([privacyScore])
@@index([trustScore])
@@ -407,6 +417,7 @@ model Service {
@@index([updatedAt])
@@index([slug])
@@index([previousSlugs])
@@index([serviceVisibility])
}
model ServiceContactMethod {
@@ -578,6 +589,7 @@ enum VerificationStepStatus {
IN_PROGRESS
PASSED
FAILED
WARNING
}
model VerificationStep {
@@ -677,8 +689,6 @@ model PushSubscription {
p256dh String
/// Authentication secret
auth String
/// To identify different devices
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -13,14 +13,15 @@ import {
PrismaClient,
ServiceSuggestionStatus,
ServiceUserRole,
VerificationStatus,
type Prisma,
type User,
type ServiceVisibility,
ServiceSuggestionType,
KycLevelClarification,
VerificationStepStatus,
type VerificationStatus,
} from '@prisma/client'
import { differenceInDays, isPast } from 'date-fns'
import { omit, uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator'
@@ -614,6 +615,14 @@ const generateFakeService = (users: User[]) => {
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
probability: 0.8,
})
const serviceVisibility = faker.helpers.weightedArrayElement<ServiceVisibility>([
{ weight: 80, value: 'PUBLIC' },
{ weight: 10, value: 'UNLISTED' },
{ weight: 5, value: 'HIDDEN' },
{ weight: 5, value: 'ARCHIVED' },
])
const approvedAt =
status === 'APPROVED' || status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null
return {
name,
@@ -629,12 +638,7 @@ const generateFakeService = (users: User[]) => {
overallScore: 0,
privacyScore: 0,
trustScore: 0,
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
{ weight: 80, value: 'PUBLIC' },
{ weight: 10, value: 'UNLISTED' },
{ weight: 5, value: 'HIDDEN' },
{ weight: 5, value: 'ARCHIVED' },
]),
serviceVisibility,
verificationStatus: status,
verificationSummary:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
@@ -677,8 +681,14 @@ const generateFakeService = (users: User[]) => {
{ count: { min: 0, max: 2 } }
),
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
listedAt: faker.date.past(),
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
listedAt:
serviceVisibility === 'PUBLIC' || serviceVisibility === 'ARCHIVED'
? faker.date.recent({ days: 30 })
: null,
verifiedAt: status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null,
spamAt: status === 'VERIFICATION_FAILED' ? faker.date.recent({ days: 30 }) : null,
approvedAt,
isRecentlyApproved: !!approvedAt && isPast(approvedAt) && differenceInDays(new Date(), approvedAt) < 15,
tosReview,
tosReviewAt: tosReview
? faker.date.recent()
@@ -908,7 +918,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
},
{
label: faker.lorem.word({ length: 2 }),
label: 'Custom label',
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
},
{
@@ -918,7 +928,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
value: faker.internet.url(),
},
{
label: faker.lorem.word({ length: 2 }),
label: 'Custom label',
value: faker.internet.url(),
},
{
@@ -1307,7 +1317,7 @@ async function main() {
const service = await prisma.service.create({
data: {
...serviceData,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
verificationStatus: 'COMMUNITY_CONTRIBUTED',
categories: {
connect: randomCategories.map((cat) => ({ id: cat.id })),
},

View File

@@ -65,18 +65,36 @@ CREATE OR REPLACE FUNCTION handle_comment_approval(
NEW RECORD,
OLD RECORD
) RETURNS VOID AS $$
DECLARE
is_user_related_to_service BOOLEAN;
is_user_admin_or_moderator BOOLEAN;
BEGIN
IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN
PERFORM insert_karma_transaction(
NEW."authorId",
1,
'COMMENT_APPROVED',
NEW.id,
format('Your comment #comment-%s in %s has been approved!',
NEW.id,
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
);
PERFORM update_user_karma(NEW."authorId", 1);
-- Check if the user is related to the service (e.g., owns/manages it)
SELECT EXISTS(
SELECT 1 FROM "ServiceUser"
WHERE "userId" = NEW."authorId" AND "serviceId" = NEW."serviceId"
) INTO is_user_related_to_service;
-- Check if the user is an admin or moderator
SELECT (admin = true OR moderator = true)
FROM "User"
WHERE id = NEW."authorId"
INTO is_user_admin_or_moderator;
-- Only award karma if the user is NOT related to the service AND is NOT an admin/moderator
IF NOT is_user_related_to_service AND NOT COALESCE(is_user_admin_or_moderator, false) THEN
PERFORM insert_karma_transaction(
NEW."authorId",
1,
'COMMENT_APPROVED',
NEW.id,
format('Your comment #comment-%s in %s has been approved!',
NEW.id,
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
);
PERFORM update_user_karma(NEW."authorId", 1);
END IF;
END IF;
END;
$$ LANGUAGE plpgsql;
@@ -86,18 +104,29 @@ CREATE OR REPLACE FUNCTION handle_comment_verification(
NEW RECORD,
OLD RECORD
) RETURNS VOID AS $$
DECLARE
is_user_admin_or_moderator BOOLEAN;
BEGIN
IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN
PERFORM insert_karma_transaction(
NEW."authorId",
5,
'COMMENT_VERIFIED',
NEW.id,
format('Your comment #comment-%s in %s has been verified!',
NEW.id,
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
);
PERFORM update_user_karma(NEW."authorId", 5);
-- Check if the comment author is an admin or moderator
SELECT (admin = true OR moderator = true)
FROM "User"
WHERE id = NEW."authorId"
INTO is_user_admin_or_moderator;
-- Only award karma if the user is NOT an admin/moderator
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
PERFORM insert_karma_transaction(
NEW."authorId",
5,
'COMMENT_VERIFIED',
NEW.id,
format('Your comment #comment-%s in %s has been verified!',
NEW.id,
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
);
PERFORM update_user_karma(NEW."authorId", 5);
END IF;
END IF;
END;
$$ LANGUAGE plpgsql;
@@ -146,12 +175,19 @@ DECLARE
comment_author_id INT;
service_name TEXT;
upvote_change INT := 0; -- Variable to track change in upvotes
is_author_admin_or_moderator BOOLEAN;
BEGIN
-- Get comment author and service info
SELECT c."authorId", s.name INTO comment_author_id, service_name
FROM "Comment" c
JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id;
-- Check if the comment author is an admin or moderator
SELECT (admin = true OR moderator = true)
FROM "User"
WHERE id = comment_author_id
INTO is_author_admin_or_moderator;
-- Calculate karma impact based on vote type
IF TG_OP = 'INSERT' THEN
-- New vote
@@ -181,16 +217,19 @@ BEGIN
upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote
END IF;
-- Record karma transaction and update user karma
PERFORM insert_karma_transaction(
comment_author_id,
karma_points,
vote_action,
COALESCE(NEW."commentId", OLD."commentId"),
vote_description
);
PERFORM update_user_karma(comment_author_id, karma_points);
-- Only award karma if the author is NOT an admin/moderator
IF NOT COALESCE(is_author_admin_or_moderator, false) THEN
-- Record karma transaction and update user karma
PERFORM insert_karma_transaction(
comment_author_id,
karma_points,
vote_action,
COALESCE(NEW."commentId", OLD."commentId"),
vote_description
);
PERFORM update_user_karma(comment_author_id, karma_points);
END IF;
-- Update comment's upvotes count incrementally
UPDATE "Comment"
@@ -236,26 +275,36 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change()
RETURNS TRIGGER AS $$
DECLARE
service_name TEXT;
is_user_admin_or_moderator BOOLEAN;
BEGIN
-- Award karma for first approval
-- Check that OLD.status is not NULL to handle the initial creation case if needed,
-- and ensure it wasn't already APPROVED.
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
-- Fetch service name for the description
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
-- Check if the user is an admin or moderator
SELECT (admin = true OR moderator = true)
FROM "User"
WHERE id = NEW."userId"
INTO is_user_admin_or_moderator;
-- Only award karma if the user is NOT an admin/moderator
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
-- Fetch service name for the description
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
-- Insert karma transaction, linking it to the suggestion
PERFORM insert_karma_transaction(
NEW."userId",
10,
'SUGGESTION_APPROVED',
NULL, -- p_comment_id (not applicable)
format('Your suggestion for service ''%s'' has been approved!', service_name),
NEW.id -- p_suggestion_id
);
-- Insert karma transaction, linking it to the suggestion
PERFORM insert_karma_transaction(
NEW."userId",
10,
'SUGGESTION_APPROVED',
NULL, -- p_comment_id (not applicable)
format('Your suggestion for service ''%s'' has been approved!', service_name),
NEW.id -- p_suggestion_id
);
-- Update user's total karma
PERFORM update_user_karma(NEW."userId", 10);
-- Update user's total karma
PERFORM update_user_karma(NEW."userId", 10);
END IF;
END IF;
RETURN NEW; -- Result is ignored since this is an AFTER trigger

View File

@@ -1,7 +1,7 @@
-- This script defines PostgreSQL functions and triggers for managing service scores:
-- 1. Automatically calculates and updates privacy, trust, and overall scores
-- for services when services or their attributes change.
-- 2. Updates the isRecentlyListed flag for services listed within the last 15 days.
-- 2. Updates the isRecentlyApproved flag for services approved within the last 15 days.
-- 3. Queues asynchronous score recalculation in "ServiceScoreRecalculationJob"
-- when an "Attribute" definition (e.g., points) is updated, ensuring
-- efficient handling of widespread score updates.
@@ -25,12 +25,8 @@ DECLARE
privacy_score INT := 0;
kyc_factor INT;
clarification_factor INT := 0;
onion_factor INT := 0;
i2p_factor INT := 0;
onion_or_i2p_factor INT := 0;
monero_factor INT := 0;
open_source_factor INT := 0;
p2p_factor INT := 0;
decentralized_factor INT := 0;
attributes_score INT := 0;
BEGIN
-- Get service data
@@ -57,20 +53,12 @@ BEGIN
FROM "Service"
WHERE "id" = service_id;
-- Check for onion URLs
-- Check for onion or i2p URLs
IF EXISTS (
SELECT 1 FROM "Service"
WHERE "id" = service_id AND array_length("onionUrls", 1) > 0
WHERE "id" = service_id AND (array_length("onionUrls", 1) > 0 OR array_length("i2pUrls", 1) > 0)
) THEN
onion_factor := 5;
END IF;
-- Check for i2p URLs
IF EXISTS (
SELECT 1 FROM "Service"
WHERE "id" = service_id AND array_length("i2pUrls", 1) > 0
) THEN
i2p_factor := 5;
onion_or_i2p_factor := 5;
END IF;
-- Check for Monero acceptance
@@ -86,10 +74,10 @@ BEGIN
INTO attributes_score
FROM "ServiceAttribute" sa
JOIN "Attribute" a ON sa."attributeId" = a."id"
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
WHERE sa."serviceId" = service_id;
-- Calculate final privacy score (base 100)
privacy_score := 50 + kyc_factor + clarification_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
privacy_score := 50 + kyc_factor + clarification_factor + onion_or_i2p_factor + monero_factor + attributes_score;
-- Ensure the score is in reasonable bounds (0-100)
privacy_score := GREATEST(0, LEAST(100, privacy_score));
@@ -105,8 +93,9 @@ DECLARE
trust_score INT := 0;
verification_factor INT;
attributes_score INT := 0;
recently_listed_factor INT := 0;
recently_approved_factor INT := 0;
tos_penalty_factor INT := 0;
operating_since_factor INT := 0;
BEGIN
-- Get verification status factor
SELECT
@@ -126,26 +115,26 @@ BEGIN
INTO attributes_score
FROM "ServiceAttribute" sa
JOIN "Attribute" a ON sa."attributeId" = a.id
WHERE sa."serviceId" = service_id AND a.category = 'TRUST';
WHERE sa."serviceId" = service_id;
-- Apply penalty if service was listed within the last 15 days
-- Apply penalty if service was approved within the last 15 days
IF EXISTS (
SELECT 1
FROM "Service"
WHERE id = service_id
AND "listedAt" IS NOT NULL
AND "approvedAt" IS NOT NULL
AND "verificationStatus" = 'APPROVED'
AND (NOW() - "listedAt") <= INTERVAL '15 days'
AND (NOW() - "approvedAt") <= INTERVAL '15 days'
) THEN
recently_listed_factor := -10;
-- Update the isRecentlyListed flag to true
recently_approved_factor := -10;
-- Update the isRecentlyApproved flag to true
UPDATE "Service"
SET "isRecentlyListed" = TRUE
SET "isRecentlyApproved" = TRUE
WHERE id = service_id;
ELSE
-- Update the isRecentlyListed flag to false
-- Update the isRecentlyApproved flag to false
UPDATE "Service"
SET "isRecentlyListed" = FALSE
SET "isRecentlyApproved" = FALSE
WHERE id = service_id;
END IF;
@@ -160,8 +149,20 @@ BEGIN
tos_penalty_factor := -3;
END IF;
-- Determine trust adjustment based on operatingSince
SELECT
CASE
WHEN "operatingSince" IS NULL THEN 0
WHEN AGE(NOW(), "operatingSince") < INTERVAL '1 year' THEN -4 -- New service penalty
WHEN AGE(NOW(), "operatingSince") >= INTERVAL '2 years' THEN 5 -- Mature service bonus
ELSE 0
END
INTO operating_since_factor
FROM "Service"
WHERE id = service_id;
-- Calculate final trust score (base 100)
trust_score := 50 + verification_factor + attributes_score + recently_listed_factor + tos_penalty_factor;
trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor + operating_since_factor;
-- Ensure the score is in reasonable bounds (0-100)
trust_score := GREATEST(0, LEAST(100, trust_score));
@@ -176,7 +177,7 @@ RETURNS INT AS $$
DECLARE
overall_score INT;
BEGIN
overall_score := CAST(ROUND(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0) AS INT);
overall_score := CAST(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0 AS INT);
RETURN GREATEST(0, LEAST(10, overall_score));
END;
$$ LANGUAGE plpgsql;

View File

@@ -1,48 +1,60 @@
-- This script manages the `listedAt`, `verifiedAt`, and `isRecentlyListed` timestamps
-- for services based on changes to their `verificationStatus`. It ensures these timestamps
-- are set or cleared appropriately when a service's verification status is updated.
CREATE OR REPLACE FUNCTION manage_service_timestamps()
CREATE OR REPLACE FUNCTION manage_service_visibility_timestamps()
RETURNS TRIGGER AS $$
BEGIN
-- Manage listedAt timestamp
IF NEW."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
-- Set listedAt only on the first time status becomes APPROVED or VERIFICATION_SUCCESS
IF NEW."serviceVisibility" = 'PUBLIC' OR NEW."serviceVisibility" = 'ARCHIVED' THEN
IF OLD."listedAt" IS NULL THEN
NEW."listedAt" := NOW();
NEW."isRecentlyListed" := TRUE;
END IF;
ELSIF OLD."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
-- Clear listedAt if the status changes FROM APPROVED or VERIFICATION_SUCCESS to something else
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
ELSE
NEW."listedAt" := NULL;
NEW."isRecentlyListed" := FALSE;
END IF;
-- Manage verifiedAt timestamp
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
-- Set verifiedAt when status changes TO VERIFICATION_SUCCESS
NEW."verifiedAt" := NOW();
NEW."isRecentlyListed" := FALSE;
ELSIF OLD."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
-- Clear verifiedAt when status changes FROM VERIFICATION_SUCCESS
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
NEW."verifiedAt" := NULL;
NEW."isRecentlyListed" := FALSE;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop the old trigger first if it exists under the old name
CREATE OR REPLACE FUNCTION manage_service_verification_timestamps()
RETURNS TRIGGER AS $$
BEGIN
IF (NEW."verificationStatus" = 'APPROVED' OR NEW."verificationStatus" = 'VERIFICATION_SUCCESS') THEN
IF OLD."approvedAt" IS NULL THEN
NEW."approvedAt" := NOW();
NEW."isRecentlyApproved" := TRUE;
END IF;
ELSE
NEW."approvedAt" := NULL;
NEW."isRecentlyApproved" := FALSE;
END IF;
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
NEW."verifiedAt" := NOW();
ELSE
NEW."verifiedAt" := NULL;
END IF;
IF NEW."verificationStatus" = 'VERIFICATION_FAILED' THEN
NEW."spamAt" := NOW();
ELSE
NEW."spamAt" := NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop the old triggers TODO: remove this some day
DROP TRIGGER IF EXISTS trigger_set_service_listed_at ON "Service";
-- Drop the trigger if it exists under the new name
DROP TRIGGER IF EXISTS trigger_manage_service_timestamps ON "Service";
CREATE TRIGGER trigger_manage_service_timestamps
DROP TRIGGER IF EXISTS trigger_manage_service_visibility_timestamps ON "Service";
DROP TRIGGER IF EXISTS trigger_manage_service_verification_timestamps ON "Service";
CREATE TRIGGER trigger_manage_service_visibility_timestamps
BEFORE UPDATE OF "serviceVisibility" ON "Service"
FOR EACH ROW
EXECUTE FUNCTION manage_service_visibility_timestamps();
CREATE TRIGGER trigger_manage_service_verification_timestamps
BEFORE UPDATE OF "verificationStatus" ON "Service"
FOR EACH ROW
-- Only execute the function if the verificationStatus value has actually changed
WHEN (OLD."verificationStatus" IS DISTINCT FROM NEW."verificationStatus")
EXECUTE FUNCTION manage_service_timestamps();
EXECUTE FUNCTION manage_service_verification_timestamps();

View File

@@ -1,5 +1,6 @@
import { ActionError } from 'astro:actions'
import { z } from 'astro:content'
import { pick } from 'lodash-es'
import { karmaUnlocksById } from '../constants/karmaUnlocks'
import { createAccount } from '../lib/accountCreate'
@@ -7,7 +8,7 @@ import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../li
import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage'
import { handleHoneypotTrap } from '../lib/honeypot'
import { startImpersonating } from '../lib/impersonation'
import { startImpersonating, stopImpersonating } from '../lib/impersonation'
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
import { prisma } from '../lib/prisma'
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
@@ -225,4 +226,36 @@ export const accountActions = {
return { user }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'user',
input: z
.object({
...captchaFormSchemaProperties,
})
.superRefine(captchaFormSchemaSuperRefine),
handler: async (_input, context) => {
if (context.locals.user.admin || context.locals.user.moderator) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Admins and moderators cannot delete their own accounts.',
})
}
await prisma.user.delete({
where: { id: context.locals.user.id },
})
const deletedUser = pick(context.locals.user, ['id', 'name', 'displayName', 'picture'])
if (context.locals.actualUser) {
await stopImpersonating(context)
} else {
await logout(context)
}
return { deletedUser }
},
}),
}

View File

@@ -58,6 +58,7 @@ const serviceSchemaBase = z.object({
.optional()
.nullable()
.default(null),
operatingSince: z.coerce.date().optional().nullable(),
imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
@@ -126,7 +127,8 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null,
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,
@@ -149,6 +151,7 @@ export const adminServiceActions = {
},
}
: undefined,
operatingSince: input.operatingSince,
},
select: {
id: true,
@@ -244,7 +247,8 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null,
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,
@@ -256,7 +260,6 @@ export const adminServiceActions = {
),
}
: undefined,
imageUrl,
categories: {
connect: categoriesToAdd.map((id) => ({ id })),
@@ -273,6 +276,7 @@ export const adminServiceActions = {
attributeId,
})),
},
operatingSince: input.operatingSince,
},
})

View File

@@ -65,13 +65,13 @@ export const apiServiceActions = {
tosUrls: true,
referral: true,
listedAt: true,
approvedAt: 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: [
@@ -92,7 +92,6 @@ export const apiServiceActions = {
if (!service && input.slug) {
service = await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
previousSlugs: { has: input.slug },
@@ -105,9 +104,7 @@ export const apiServiceActions = {
!service ||
(service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'ARCHIVED' &&
service.serviceVisibility !== 'UNLISTED') ||
!service.listedAt ||
service.listedAt > new Date()
service.serviceVisibility !== 'UNLISTED')
) {
throw new ActionError({
code: 'NOT_FOUND',
@@ -130,12 +127,13 @@ export const apiServiceActions = {
'description',
]),
verifiedAt: service.verifiedAt,
approvedAt: service.approvedAt,
kycLevel: service.kycLevel,
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
kycLevelClarification: service.kycLevelClarification,
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
'value',
'name',
'label',
'description',
]),
categories: service.categories,

View File

@@ -270,6 +270,18 @@ export const commentActions = {
}
}
const isRelatedToService = !!(await tx.serviceUser.findUnique({
where: {
userId_serviceId: {
userId: context.locals.user.id,
serviceId: input.serviceId,
},
},
select: {
id: true,
},
}))
// Prepare data object with proper type safety
const commentData: Prisma.CommentCreateInput = {
content: input.content,
@@ -277,7 +289,12 @@ export const commentActions = {
author: { connect: { id: context.locals.user.id } },
// Change status to HUMAN_PENDING if there's an issue report, this is so that the AI worker does not pick it up for review
status: context.locals.user.admin ? 'APPROVED' : isIssueReport ? 'HUMAN_PENDING' : 'PENDING',
status:
context.locals.user.admin || context.locals.user.moderator || isRelatedToService
? 'APPROVED'
: isIssueReport
? 'HUMAN_PENDING'
: 'PENDING',
requiresAdminReview,
orderId: input.orderId?.trim() ?? null,
kycRequested: input.issueKycRequested === true,

View File

@@ -32,7 +32,6 @@ export const notificationActions = {
endpoint: z.string(),
p256dhKey: z.string(),
authKey: z.string(),
userAgent: z.string().optional(),
}),
handler: async (input, context) => {
await prisma.pushSubscription.upsert({
@@ -43,14 +42,12 @@ export const notificationActions = {
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,
},
})
},
@@ -58,25 +55,17 @@ export const notificationActions = {
unsubscribe: defineProtectedAction({
accept: 'json',
permissions: 'user',
permissions: 'guest',
input: z.object({
endpoint: z.string().optional(),
endpoint: z.string(),
}),
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,
},
})
}
await prisma.pushSubscription.delete({
where: {
userId: context.locals.user?.id ?? undefined,
endpoint: input.endpoint,
},
})
},
}),
},

View File

@@ -36,7 +36,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
id: {
in: matches.map(({ id }) => id),
},
listedAt: { lte: new Date() },
serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
},
@@ -166,6 +165,7 @@ export const serviceSuggestionActions = {
message: 'You must accept the suggestion rules and process to continue',
}),
}),
operatingSince: z.coerce.date().optional(),
/** @deprecated Honey pot field, do not use */
message: z.unknown().optional(),
skipDuplicateCheck: z
@@ -240,6 +240,7 @@ export const serviceSuggestionActions = {
name: input.name,
slug: input.slug,
description: input.description,
operatingSince: input.operatingSince,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls,
@@ -252,7 +253,6 @@ export const serviceSuggestionActions = {
overallScore: 0,
privacyScore: 0,
trustScore: 0,
listedAt: new Date(),
serviceVisibility: 'UNLISTED',
categories: {
connect: input.categories.map((id) => ({ id })),

View File

@@ -0,0 +1,24 @@
---
if (!Astro.locals.user?.admin) return
---
<script>
/////////////////////////////////////////////////////////////////////////////////////////////
// Script that adds data-astro-reload to all admin links or all links if on an admin page. //
// This is a workaround to prevent the client router messing up inputs. //
/////////////////////////////////////////////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLAnchorElement | HTMLFormElement>('a,form').forEach((element) => {
const isAdminPage = window.location.pathname.startsWith('/admin')
if (isAdminPage) {
element.setAttribute('data-astro-reload', '')
}
const url = element.href ? new URL(element.href) : null
if (url?.pathname.startsWith('/admin')) {
element.setAttribute('data-astro-reload', '')
}
})
})
</script>

View File

@@ -6,8 +6,9 @@ import { pwaAssetsHead } from 'virtual:pwa-assets/head'
import { pwaInfo } from 'virtual:pwa-info'
import { isNotArray } from '../lib/arrays'
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
import AdminNavigationFixScript from './AdminNavigationFixScript.astro'
import DevToolsMessageScript from './DevToolsMessageScript.astro'
import DynamicFavicon from './DynamicFavicon.astro'
import HtmxScript from './HtmxScript.astro'
@@ -107,8 +108,9 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
<DynamicFavicon />
<!-- Components -->
<ClientRouter />
<AdminNavigationFixScript />
<LoadingIndicator color="green" />
<TailwindJsPluggin />
{htmx && <HtmxScript />}

View File

@@ -288,6 +288,7 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
aria-disabled={disabled}
aria-label={label}
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
{...htmlProps}
>

View File

@@ -15,6 +15,7 @@ import {
} from '../lib/commentsWithReplies'
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
import { formatDateShort } from '../lib/timeAgo'
import { urlDomain } from '../lib/urls'
import BadgeSmall from './BadgeSmall.astro'
import CommentModeration from './CommentModeration.astro'
@@ -170,7 +171,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
comment.author.admin || comment.author.moderator
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
: ''
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
}${comment.author.verifiedLink ? `Related to ${urlDomain(comment.author.verifiedLink)}` : ''}`}
>
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
</Tooltip>

View File

@@ -71,12 +71,13 @@ const [dbComments, pendingCommentsCount, activeRatingComment] = await Astro.loca
'Failed to fetch comments',
async () =>
await prisma.comment.findMany(
makeCommentsNestedQuery({
await makeCommentsNestedQuery({
depth: MAX_COMMENT_DEPTH,
user,
showPending: params.showPending,
serviceId: service.id,
sort: params.sort,
highlightedCommentId: params.comment,
})
),
[],

View File

@@ -29,7 +29,15 @@
font-family: cursive;
font-size: 2rem;
font-weight: bold;
background: repeating-linear-gradient(90deg, #14ffe9 0%, #ffc800 16.66666%, #ff00e0 33.33333%, #14ffe9 50.0%) -100%/ 200%;
background: repeating-linear-gradient(
90deg,
#d97706 0%,
#f59e0b 20%,
#f97316 40%,
#ea580c 60%,
#f97316 80%,
#f59e0b 100%
) -100%/ 200%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;

View File

@@ -1,5 +1,5 @@
---
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
import { prisma } from '../lib/prisma'
const user = Astro.locals.user

View File

@@ -3,8 +3,8 @@ import { Icon } from 'astro-icon/components'
import { sample } from 'lodash-es'
import { splashTexts } from '../constants/splashTexts'
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
import { cn } from '../lib/cn'
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
import AdminOnly from './AdminOnly.astro'
@@ -123,6 +123,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
transition:name="header-admin-link"
text="Admin Dashboard"
position="left"
aria-label="Admin Dashboard"
>
<Icon name="ri:home-gear-line" class="size-10" />
</Tooltip>

View File

@@ -20,7 +20,6 @@ type Props<Multiple extends boolean = false> = Omit<
iconClass?: string
description?: MarkdownString
disabled?: boolean
noTransitionPersist?: boolean
}[]
disabled?: boolean
selectedValue?: Multiple extends true ? string[] : string
@@ -70,7 +69,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
)}
>
<input
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
transition:persist
type={multiple ? 'checkbox' : 'radio'}
name={wrapperProps.name}
value={option.value}

View File

@@ -4,14 +4,17 @@
<script>
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
import { makeNotificationOptions } from '../lib/notificationOptions'
import {
makeBrowserNotificationOptions,
makeBrowserNotificationTitle,
} from '../lib/client/notificationOptions'
document.addEventListener('sse:new-notification', (event) => {
if (isBrowserNotificationsEnabled()) {
const payload = event.detail
const notification = showBrowserNotification(
payload.title,
makeNotificationOptions(payload, { removeActions: true })
makeBrowserNotificationTitle(payload.title),
makeBrowserNotificationOptions(payload, { removeActions: true })
)
// Handle notification click

View File

@@ -16,7 +16,6 @@ type Props = HTMLAttributes<'div'> & {
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
select: {
endpoint: true
userAgent: true
}
}>[]
}

View File

@@ -6,17 +6,29 @@ import { interpolate } from '../lib/numbers'
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
import { transformCase } from '../lib/strings'
import type { HTMLAttributes } from 'astro/types'
import type { HTMLTag, Polymorphic } from 'astro/types'
import type { Review, WithContext } from 'schema-dts'
export type Props = HTMLAttributes<'div'> & {
type Props = Polymorphic<{
as: HTMLTag
score: number
label: string
total?: number
itemReviewedId?: string
}
showInfo?: boolean
children?: never
}>
const { score, label, total = 100, class: className, itemReviewedId, ...htmlProps } = Astro.props
const {
as: Tag = 'div',
score,
label,
total = 100,
class: className,
itemReviewedId,
showInfo = false,
...htmlProps
} = Astro.props
const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1)
@@ -65,13 +77,13 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
)
}
<div
<Tag
{...htmlProps}
class={cn(
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
className
)}
role="group"
role={htmlProps.role ?? 'group'}
>
<div
class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', {
@@ -166,10 +178,14 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined}
class="stroke-night-700"></path>
<!-- Info icon -->
<!-- <path
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
fill="white"
fill-opacity="0.67"></path> -->
{
showInfo && (
<path
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
class="text-current/60"
fill="currentColor"
/>
)
}
</svg>
</div>
</Tag>

View File

@@ -6,27 +6,39 @@ import { makeOverallScoreInfo } from '../lib/overallScore'
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
import { transformCase } from '../lib/strings'
import type { HTMLAttributes } from 'astro/types'
import type { HTMLTag, Polymorphic } from 'astro/types'
export type Props = HTMLAttributes<'div'> & {
type Props = Polymorphic<{
as: HTMLTag
score: number
label: string
total?: number
itemReviewedId?: string
}
showInfo?: boolean
children?: never
}>
const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props
const {
as: Tag = 'div',
score,
label,
total = 10,
class: className,
itemReviewedId,
showInfo = false,
...htmlProps
} = Astro.props
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
---
<div
<Tag
{...htmlProps}
class={cn(
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
className
)}
role="group"
role={htmlProps.role ?? 'group'}
>
{
!!itemReviewedId && (
@@ -48,15 +60,17 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
/>
)
}
<!-- <svg
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
viewBox="0 0 12 12"
fill="currentColor"
>
<path
d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z"
></path>
</svg> -->
{
showInfo && (
<svg
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
viewBox="0 0 12 12"
fill="currentColor"
>
<path d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z" />
</svg>
)
}
<div
class={cn(
@@ -77,4 +91,4 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
</div>
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
</div>
</Tag>

View File

@@ -27,6 +27,12 @@ if (!Astro.locals.user) return
}
eventSource.onmessage = (event) => {
// NOTE: Disable sse: events when user is not logged in
if (!document.body.hasAttribute('data-is-logged-in')) {
stopServerEventsListener()
return
}
try {
const data = JSON.parse(event.data as string)

View File

@@ -6,7 +6,6 @@ import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { verificationStatusesByValue } from '../constants/verificationStatus'
import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore'
import { transformCase } from '../lib/strings'
import MyPicture from './MyPicture.astro'
import Tooltip from './Tooltip.astro'
@@ -23,6 +22,8 @@ type Props = HTMLAttributes<'a'> & {
slug: true
description: true
overallScore: true
privacyScore: true
trustScore: true
kycLevel: true
imageUrl: true
verificationStatus: true
@@ -45,6 +46,8 @@ const {
slug,
description,
overallScore,
privacyScore,
trustScore,
kycLevel,
imageUrl,
categories,
@@ -163,7 +166,7 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
overallScoreInfo.classNameBg
)}
text={`${transformCase(overallScoreInfo.text, 'sentence')} score (${overallScoreInfo.formattedScore}/10)`}
text={`${Math.round(privacyScore).toLocaleString()}% Privacy | ${Math.round(trustScore).toLocaleString()}% Trust`}
>
{overallScoreInfo.formattedScore}
</Tooltip>

View File

@@ -4,6 +4,7 @@
<script>
import { registerSW } from 'virtual:pwa-register'
import { unsubscribeFromPushNotifications } from '../lib/client/clientPushNotifications'
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
@@ -33,7 +34,7 @@
function shouldSkipAutoReload() {
const currentPath = window.location.pathname
const isErrorPage = document.querySelector('[data-is-error-page]') !== null
const isErrorPage = document.body.hasAttribute('data-is-error-page')
return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route)
}
@@ -44,4 +45,15 @@
void updateSW(true)
}
}
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault()
})
document.addEventListener('astro:page-load', async () => {
if (!document.body.hasAttribute('data-is-logged-in')) {
await unsubscribeFromPushNotifications()
window.__SW_REGISTRATION__?.unregister()
}
})
</script>

View File

@@ -1,23 +1,21 @@
---
import { Icon } from 'astro-icon/components'
import { differenceInDays, isPast } from 'date-fns'
import { verificationStatusesByValue } from '../constants/verificationStatus'
import { verificationStepStatusesByValue } from '../constants/verificationStepStatus'
import { cn } from '../lib/cn'
import TimeFormatted from './TimeFormatted.astro'
import { formatDaysAgo } from '../lib/timeAgo'
import type { Prisma } from '@prisma/client'
const RECENTLY_ADDED_DAYS = 7
type Props = {
service: Prisma.ServiceGetPayload<{
select: {
verificationStatus: true
verificationProofMd: true
verificationSummary: true
listedAt: true
approvedAt: true
isRecentlyApproved: true
createdAt: true
verificationSteps: {
select: {
@@ -29,9 +27,6 @@ type Props = {
}
const { service } = Astro.props
const listedDate = service.listedAt ?? service.createdAt
const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), listedDate) < RECENTLY_ADDED_DAYS
---
{
@@ -57,23 +52,23 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
<div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200">
<Icon name="ri:alert-line" class="size-5 text-yellow-400" />
<span>
Community-contributed. Information not reviewed.
Community contributed. Information not reviewed.
<a
href="/about#suggestion-review-process"
href="/about#community-contributed"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
>
Learn more
</a>
</span>
</div>
) : wasRecentlyAdded ? (
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
This service was {service.listedAt === null ? 'added ' : 'listed '}{' '}
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
) : service.isRecentlyApproved ? (
<div class="mb-3 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400">
This service was approved
{service.approvedAt ? formatDaysAgo(service.approvedAt) : 'less than 15 days ago'}
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
caution.
<a
href="/about#suggestion-review-process"
href="/about#approved"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
>
Learn more
@@ -83,10 +78,10 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
<div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200">
<Icon name="ri:information-line" class="size-5 text-blue-400" />
<span>
Basic checks passed, but not fully verified.
Basic checks passed, but service is not verified.
<a
href="/about#suggestion-review-process"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
href="/about#approved"
class="text-blue-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
>
Learn more
</a>
@@ -98,14 +93,29 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
{
service.verificationStatus !== 'VERIFICATION_FAILED' &&
service.verificationSteps.some((step) => step.status === 'FAILED') && (
<div class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
<a
href="#verification"
class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400 transition-colors hover:bg-red-900/60"
>
<Icon
name={verificationStatusesByValue.VERIFICATION_FAILED.icon}
class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)}
/>
<span>
This service has failed one or more verification steps. Review the verification details carefully.
</span>
</div>
<span>Some verification steps failed. Please review the details below.</span>
</a>
)
}
{
service.verificationStatus !== 'VERIFICATION_FAILED' &&
!service.verificationSteps.some((step) => step.status === 'FAILED') &&
service.verificationSteps.some((step) => step.status === 'WARNING') && (
<a
href="#verification"
class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200 transition-colors hover:bg-yellow-600/40"
>
<Icon name={verificationStepStatusesByValue.WARNING.icon} class={cn('size-5 text-yellow-400')} />
<span>Some verification steps are marked as warnings.</span>
</a>
)
}

View File

@@ -26,7 +26,7 @@ type VerificationStatusInfo<T extends string | null | undefined = string> = {
}
export const READ_MORE_SENTENCE_LINK =
'Read more about the [suggestion review process](/about#suggestion-review-process).' satisfies MarkdownString
'Read more about the [listing statuses](/about#listing-statuses).' satisfies MarkdownString
export const {
dataArray: verificationStatuses,

View File

@@ -42,6 +42,12 @@ export const {
icon: 'ri:alert-line',
color: 'red',
},
{
value: 'WARNING',
label: 'Warning',
icon: 'ri:alert-line',
color: 'yellow',
},
{
value: 'PENDING',
label: 'Pending',

View File

@@ -81,7 +81,8 @@ const announcement = await Astro.locals.banners.try(
</head>
<body
class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.body)}
data-is-error-page={isErrorPage}
data-is-error-page={isErrorPage ? '' : undefined}
data-is-logged-in={Astro.locals.user !== null ? '' : undefined}
>
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
<Header

View File

@@ -1,3 +1,4 @@
import { differenceInMonths, differenceInYears } from 'date-fns'
import { orderBy } from 'lodash-es'
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
@@ -7,7 +8,7 @@ import { kycLevels } from '../constants/kycLevels'
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
import { formatDateShort } from './timeAgo'
import { formatDaysAgo } from './timeAgo'
import type { Prisma } from '@prisma/client'
@@ -35,8 +36,8 @@ type NonDbAttributeFull = NonDbAttribute & {
select: {
verificationStatus: true
serviceVisibility: true
isRecentlyListed: true
listedAt: true
isRecentlyApproved: true
approvedAt: true
createdAt: true
tosReviewAt: true
tosReview: true
@@ -45,6 +46,7 @@ type NonDbAttributeFull = NonDbAttribute & {
acceptedCurrencies: true
kycLevel: true
kycLevelClarification: true
operatingSince: true
}
}>
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
@@ -189,17 +191,17 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
}),
},
{
slug: 'recently-listed',
title: 'Recently listed',
slug: 'recently-approved',
title: 'Recently approved',
type: 'WARNING',
category: 'TRUST',
description: 'Listed on KYCnot.me less than 15 days ago. Proceed with caution.',
description: 'Approved on KYCnot.me less than 15 days ago. Proceed with caution.',
privacyPoints: 0,
trustPoints: -5,
links: [],
customize: (service) => ({
show: service.isRecentlyListed,
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
show: service.isRecentlyApproved,
description: `Approved on KYCnot.me less than 15 days ago${service.approvedAt ? ` (${formatDaysAgo(service.approvedAt)})` : ''}. Proceed with caution.`,
}),
},
{
@@ -217,41 +219,22 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
}),
},
{
slug: 'has-onion-urls',
title: 'Has Onion URLs',
slug: 'has-onion-or-i2p-urls',
title: 'Has Onion or I2P URLs',
type: 'GOOD',
category: 'PRIVACY',
description: 'Onion (Tor) URLs enhance privacy and anonymity.',
description: 'Onion (Tor) and I2P URLs enhance privacy and anonymity.',
privacyPoints: 5,
trustPoints: 0,
links: [
{
url: '/?onion=true',
url: '/?networks=onion&networks=i2p',
label: 'Search with this',
icon: 'ri:search-line',
},
],
customize: (service) => ({
show: service.onionUrls.length > 0,
}),
},
{
slug: 'has-i2p-urls',
title: 'Has I2P URLs',
type: 'GOOD',
category: 'PRIVACY',
description: 'I2P URLs enhance privacy and anonymity.',
privacyPoints: 5,
trustPoints: 0,
links: [
{
url: '/?i2p=true',
label: 'Search with this',
icon: 'ri:search-line',
},
],
customize: (service) => ({
show: service.i2pUrls.length > 0,
show: service.onionUrls.length > 0 || service.i2pUrls.length > 0,
}),
},
{
@@ -274,6 +257,55 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
show: service.acceptedCurrencies.includes('MONERO'),
}),
},
{
slug: 'new-service',
title: 'New service',
type: 'WARNING',
category: 'TRUST',
description:
'The service started operations less than a year ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).',
privacyPoints: 0,
trustPoints: -4,
links: [],
customize: (service) => {
if (!service.operatingSince) return { show: false }
const yearsOperated = differenceInYears(new Date(), service.operatingSince)
if (yearsOperated >= 1) return { show: false }
const monthsOperated = differenceInMonths(new Date(), service.operatingSince)
return {
show: true,
description: `The service started operations ${
monthsOperated === 0
? 'less than a month'
: `${String(monthsOperated)} month${monthsOperated > 1 ? 's' : ''}`
} ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).`,
}
},
},
{
slug: 'mature-service',
title: 'Mature service',
type: 'GOOD',
category: 'TRUST',
description:
'This service has been operational for at least 2 years. While this indicates stability, it is not a future-proof guarantee.',
privacyPoints: 0,
trustPoints: 5,
links: [],
customize: (service) => {
if (!service.operatingSince) return { show: false }
const yearsOperated = differenceInYears(new Date(), service.operatingSince)
return {
show: yearsOperated >= 2,
description: `This service has been operational for **${String(
yearsOperated
)} years**. While this indicates stability, it is not a future-proof guarantee.`,
}
},
},
]
export function sortAttributes<

View File

@@ -7,11 +7,15 @@ export function supportsBrowserNotifications() {
}
export function isBrowserNotificationsEnabled() {
return (
supportsBrowserNotifications() &&
Notification.permission === 'granted' &&
typedLocalStorage.browserNotificationsEnabled.get()
)
const browserNotificationsEnabled = typedLocalStorage.browserNotificationsEnabled.get()
if (!browserNotificationsEnabled) return false
if (!document.body.hasAttribute('data-is-logged-in')) {
typedLocalStorage.browserNotificationsEnabled.set(false)
return false
}
return supportsBrowserNotifications() && Notification.permission === 'granted'
}
export async function enableBrowserNotifications(): Promise<SafeResult> {

View File

@@ -5,7 +5,6 @@ import type { actions } from 'astro:actions'
type ServerSubscription = {
endpoint: string
userAgent: string | null
}
export type SafeResult =
@@ -45,7 +44,6 @@ export async function subscribeToPushNotifications(vapidPublicKey: string): Prom
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>),
@@ -131,13 +129,7 @@ export async function isCurrentDeviceSubscribed(serverSubscriptions: ServerSubsc
const currentSubscription = await getCurrentSubscription()
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)
)
return serverSubscriptions.some((sub) => sub.endpoint === currentSubscription.endpoint)
}
function urlB64ToUint8Array(base64String: string) {
@@ -183,5 +175,5 @@ export function parsePushSubscriptions(subscriptionsAsString: string | undefined
function isServerSubscription(subscription: unknown): subscription is ServerSubscription {
if (typeof subscription !== 'object' || subscription === null) return false
const s = subscription as Record<string, unknown>
return typeof s.endpoint === 'string' && (typeof s.userAgent === 'string' || s.userAgent === null)
return typeof s.endpoint === 'string'
}

View File

@@ -0,0 +1,7 @@
export const DEPLOYMENT_MODE = import.meta.env.PROD
? import.meta.env.MODE === 'development' ||
import.meta.env.MODE === 'staging' ||
import.meta.env.MODE === 'production'
? import.meta.env.MODE
: 'development'
: 'development'

View File

@@ -1,4 +1,6 @@
import type { NotificationData, NotificationPayload } from './serverEventsTypes'
import { DEPLOYMENT_MODE } from './envVariables'
import type { NotificationData, NotificationPayload } from '../serverEventsTypes'
export type CustomNotificationOptions = NotificationOptions & {
actions?: { action: string; title: string; icon?: string }[]
@@ -6,14 +8,24 @@ export type CustomNotificationOptions = NotificationOptions & {
data: NotificationData
}
export function makeNotificationOptions(
export function makeBrowserNotificationTitle(title?: string | null) {
const prefix = DEPLOYMENT_MODE === 'development' ? '[DEV] ' : DEPLOYMENT_MODE === 'staging' ? '[PRE] ' : ''
return `${prefix}${title ?? 'New Notification'}`
}
export function makeBrowserNotificationOptions(
payload: NotificationPayload | null,
options: { removeActions?: boolean } = {}
) {
const defaultOptions: CustomNotificationOptions = {
body: 'You have a new notification',
lang: 'en-US',
icon: '/favicon.svg',
icon:
DEPLOYMENT_MODE === 'development'
? '/favicon-dev.svg'
: DEPLOYMENT_MODE === 'staging'
? '/favicon-stage.svg'
: '/favicon.svg',
badge: '/notification-icon.svg',
requireInteraction: false,
silent: false,

View File

@@ -1,5 +1,7 @@
import { z } from 'zod'
import { prisma } from './prisma'
import type { Prisma } from '@prisma/client'
export const MAX_COMMENT_DEPTH = 12
@@ -75,12 +77,13 @@ export type CommentWithRepliesPopulated = CommentWithReplies<{
export const commentSortSchema = z.enum(['newest', 'upvotes', 'status']).default('newest')
export type CommentSortOption = z.infer<typeof commentSortSchema>
export function makeCommentsNestedQuery({
export async function makeCommentsNestedQuery({
depth = 0,
user,
showPending,
serviceId,
sort,
highlightedCommentId,
}: {
depth?: number
user: Prisma.UserGetPayload<{
@@ -91,6 +94,7 @@ export function makeCommentsNestedQuery({
showPending?: boolean
serviceId: number
sort: CommentSortOption
highlightedCommentId?: number | null
}) {
const orderByClause: Prisma.CommentOrderByWithRelationInput[] = []
@@ -108,6 +112,8 @@ export function makeCommentsNestedQuery({
}
orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group
const highlightedBranchIds = highlightedCommentId ? await findAllParentIds(highlightedCommentId, depth) : []
const baseQuery = {
...commentReplyQuery,
orderBy: orderByClause,
@@ -121,6 +127,9 @@ export function makeCommentsNestedQuery({
: ({
status: { in: ['APPROVED', 'VERIFIED'] },
} as const satisfies Prisma.CommentWhereInput),
...(highlightedBranchIds.length > 0
? [{ id: { in: highlightedBranchIds } } as const satisfies Prisma.CommentWhereInput]
: []),
],
parentId: null,
serviceId,
@@ -161,6 +170,47 @@ export function makeRepliesQuery<T extends Prisma.CommentFindManyArgs>(
}
}
async function findAllParentIds(commentId: number, depth: number) {
const commentwithManyParents = await prisma.comment.findFirst({
where: { id: commentId },
select: makeParentQuerySelect(depth),
})
return extractParentIds(commentwithManyParents, [commentId])
}
type ParentQueryRecursive = {
parent: {
select: {
id: true
parent: false | { select: ParentQueryRecursive }
}
}
}
function makeParentQuerySelect(depth: number): ParentQueryRecursive {
return {
parent: {
select: {
id: true,
parent: depth <= 0 ? false : { select: makeParentQuerySelect(depth - 1) },
},
},
} as const satisfies Prisma.CommentSelect
}
function extractParentIds(
comment: Prisma.CommentGetPayload<{ select: ParentQueryRecursive }> | null,
acc: number[] = []
) {
if (!comment?.parent?.id) return acc
return extractParentIds(comment.parent as Prisma.CommentGetPayload<{ select: ParentQueryRecursive }>, [
...acc,
comment.parent.id,
])
}
export function makeCommentUrl({
serviceSlug,
commentId,

View File

@@ -1,5 +0,0 @@
import { z } from 'astro/zod'
const schema = z.enum(['development', 'staging', 'production'])
export const DEPLOYMENT_MODE = schema.parse(import.meta.env.PROD ? import.meta.env.MODE : 'development')

View File

@@ -39,7 +39,6 @@ export async function getService(slug: string | undefined): Promise<
const service =
(await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
slug,
},
@@ -47,7 +46,6 @@ export async function getService(slug: string | undefined): Promise<
})) ??
(await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
previousSlugs: { has: slug },
},
@@ -175,7 +173,6 @@ export async function getEvents(): Promise<
where: {
visible: true,
service: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
},
},

View File

@@ -29,7 +29,7 @@ export async function stopImpersonating(context: Pick<APIContext, 'cookies' | 'l
const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value
await redisImpersonationSessions.delete(sessionId)
context.cookies.delete(IMPERSONATION_SESSION_COOKIE)
context.locals.user = context.locals.actualUser
context.locals.user = context.locals.actualUser ?? context.locals.user
context.locals.actualUser = null
}

View File

@@ -45,7 +45,6 @@ function prismaClientSingleton() {
}
declare global {
// eslint-disable-next-line no-var
var prisma: ReturnType<typeof prismaClientSingleton> | undefined
}

View File

@@ -1,4 +1,4 @@
import { addDays, format, isBefore, isToday, isYesterday } from 'date-fns'
import { addDays, differenceInDays, format, isBefore, isToday, isYesterday } from 'date-fns'
import TimeAgo from 'javascript-time-ago'
import en from 'javascript-time-ago/locale/en'
@@ -47,3 +47,10 @@ export function formatDateShort(
return transformCase(text, caseType)
}
export function formatDaysAgo(approvedAt: Date) {
const days = differenceInDays(new Date(), approvedAt)
if (days === 0) return 'today'
if (days === 1) return 'yesterday'
return `${days.toLocaleString()} days ago`
}

View File

@@ -53,7 +53,7 @@ export async function removeUserSessionIdCookie(cookies: AstroCookies) {
cookies.delete(COOKIE_NAME, { path: '/' })
}
export async function logout(context: Pick<APIContext, 'cookies' | 'locals'>) {
export async function logout(context: Pick<APIContext, 'cookies' | 'locals' | 'request' | 'url'>) {
await stopImpersonating(context)
await removeUserSessionIdCookie(context.cookies)

View File

@@ -10,7 +10,7 @@ import {
} from '../constants/characters'
import { getRandom, typedJoin } from './arrays'
import { DEPLOYMENT_MODE } from './envVariables'
import { DEPLOYMENT_MODE } from './client/envVariables'
import { transformCase } from './strings'
const DIGEST = 'sha512'

View File

@@ -5,7 +5,7 @@ import { LOGS_UI_URL } from 'astro:env/server'
import { SUPPORT_EMAIL } from '../constants/project'
import BaseLayout from '../layouts/BaseLayout.astro'
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
type Props = {

View File

@@ -8,6 +8,21 @@ icon: 'ri:information-line'
import DonationAddress from '../components/DonationAddress.astro'
- [What is KYC?](#what-is-kyc)
- [Why does this site exist?](#why-does-this-site-exist)
- [Why only Bitcoin and Monero?](#why-only-bitcoin-and-monero)
- [User Accounts](#user-accounts)
- [Verified and Affiliated Users](#verified-and-affiliated-users)
- [Listings](#listings)
- [Suggesting a new listing](#suggesting-a-new-listing)
- [Listing statuses](#listing-statuses)
- [Reviews and Comments](#reviews-and-comments)
- [Moderation](#moderation)
- [API](#api)
- [Donate](#support)
- [Contact](#contact)
- [Downloads and Assets](#downloads-and-assets)
## What is this page?
KYCnot.me is a directory of trustworthy alternatives for buying, exchanging, trading, and using cryptocurrencies without having to disclose your identity, thus preserving your right to privacy.
@@ -52,9 +67,9 @@ Users earn karma by participating in the community. When your comments get appro
### Verified and Affiliated Users
Some users are **verified**, this means that the moderators have confirmed that the user is related to a specific link or service. The verification is directly linked to the URL, ensuring that the person behind the username has a legitimate connection to the service they claim to represent. This verification process is reserved for individuals with established reputation.
**Verified users** have proven their identity by linking their account to a specific website. This verification confirms they are legitimate representatives of that site, whether it's a personal website, blog, social media profile, or service page. You can request verification [in your profile](/account).
Users can also be **affiliated** with a service if they're related to it, such as being an owner or part of the team. If you own a service and want to get verified, just reach out to us.
**Affiliated users** are users who represent a service listed in the directory, such as owners, support staff, or team members. If you represent a service and want to become affiliated, you can request it [in your profile](/account).
## Listings
@@ -71,12 +86,13 @@ To list a new service, it must fulfill these requirements:
- Offer a service
- Publicly available website explaining what the service is about
- Terms of service or FAQ document
- The service must have been operating for at least 6 months.
Examples of non-valid services:
- Just a Telegram link
- A cryptocurrency project
- A cryptocurrency wallet
- A chat link without any information about the service, or review page.
#### Suggestion Review Process
@@ -145,7 +161,7 @@ These categories **directly influence** a service's Privacy and Trust scores, wh
#### Service Scores
Scores are calculated **automatically** using clear, fixed rules. We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements.
Scores are calculated **automatically** using clear, fixed rules based on the attributes of the service ([See all attributes](/attributes)). We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements.
##### Privacy Score
@@ -165,7 +181,7 @@ The trust score represents how reliable and trustworthy a service is, based on o
##### Overall Score
The overall score is calculated as `(privacy * 0.6) + (trust * 0.4)` and provides a combined measure of privacy and trust.
The overall score is calculated as `(privacy * 0.6) + (trust * 0.4)` truncated. This provides a combined measure of privacy and trust.
#### Terms of Service Reviews
@@ -184,6 +200,32 @@ There are two types of events:
You can also take a look at the [global timeline](/events) where you will find all the service's events sorted by date.
### Listing Statuses
#### **Unlisted**
Initial state after the service is submitted. The service will **not appear in the list or search results**. Only accessible with a **direct link**. An **initial review** is done by the team to ensure the service is **not spam or inappropriate**.
#### **Community Contributed**
The service is **listed** in the directory, but it has **not been reviewed** by our team. The information **may be inaccurate, incomplete, or fraudulent**. Users should use these services **with caution**.
#### **Approved**
The service is listed in the directory and has been **reviewed by our team**. The information is **accurate and complete**. Initial tests were **successful**, but there is **not enough trust** to be verified.
#### **Verified**
The service has been listed for a while and has **not been reported** as a scam, user reviews are **mostly positive**, and the service is **not under any investigation**. Further tests and checks have been **successfully completed**. Contact with support or admins was also successful.
#### **Scam**
The service is a **scam**. User reports, negative reviews, or **failed internal testing** and other red flags were found. Evidence is provided in the **verification section** of the service page.
#### **Archived**
The service is **no longer available**. It may have been **shut down, acquired, or otherwise discontinued**. Still **visible** in the directory **for reference**.
## Reviews and Comments
Reviews are comments with a one to five star rating for the service. Each user can leave only one review per service; new reviews replace the old one.
@@ -194,21 +236,38 @@ If you've used the service, you can add an **order ID** or proof—this is only
Some reviews may be spam or fake. Read comments carefully and **always do your own research before making decisions**.
### Note on moderation
### Moderation
**All comments are moderated.** First, an AI checks each comment. If nothing is flagged, the comment is published right away. If something seems off, the comment is held for a human to review. We only remove comments that are spam, nonsense, unsupported accusations, doxxing, or clear rule violations.
**All comments are moderated.** First, an **AI checks** each comment. If nothing is flagged, the comment is published right away. If something seems off, the comment is held for a **human review**. We only remove comments that do not follow the guidelines.
Comments from [**users affiliated with a service**](/about#verified-and-affiliated-users) are automatically approved on their own service page.
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.
#### Comment Guidelines
We welcome honest, constructive discussion. However, any comment or review that contains the following will be **rejected**:
- **Spam** promotional content, fake reviews, or coordinated campaigns.
- **Doxxing** harassment, threats, or disclosure of private information.
- **Illegal content** anything that encourages illegal activity.
- **AI-generated text** AI-written content is not allowed.
- **Unrelated content** content that is not related to the service.
- **Personal discussion** discussions not related to the service.
A review score may be **disabled** if it:
- Is not based on your own first-hand experience.
- Contains demonstrably false or unverified claims.
- Is not relevant to the service being reviewed.
## API
You can access basic service data via our public API.
See the [API page](/docs/api) for more details.
You can access basic service data through our [public API](/docs/api).
## Support
If you like this project, you can **support** it through these methods:
You can **support** our work through these methods:
<DonationAddress
cryptoName="Monero"
@@ -218,11 +277,11 @@ If you like this project, you can **support** it through these methods:
## Contact
You can contact via direct chat:
You can contact us through SimpleX Chat:
- [SimpleX Chat](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2F0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU%3D%40smp8.simplex.im%2FcgKHYUYnpAIVoGb9lxb0qEMEpvYIvc1O%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAIW_JSq8wOsLKG4Xv4O54uT2D_l8MJBYKQIFj1FjZpnU%253D%26srv%3Dbeccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion)
## Downloads and assets
## Downloads and Assets
For logos and brand assets, visit our [downloads page](/downloads).

View File

@@ -0,0 +1,141 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import Captcha from '../../components/Captcha.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import UserBadge from '../../components/UserBadge.astro'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls'
const deleteResult = Astro.getActionResult(actions.account.delete)
if (deleteResult && !deleteResult.error) {
Astro.locals.banners.addIfSuccess(deleteResult, 'Account deleted successfully')
return Astro.redirect('/')
}
const userId = Astro.locals.user?.id
if (!userId) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to delete your account' }))
}
const user = await Astro.locals.banners.try('Failed to load user profile', () =>
prisma.user.findUnique({
where: { id: userId },
select: {
admin: true,
moderator: true,
name: true,
displayName: true,
picture: true,
_count: {
select: {
comments: true,
commentVotes: true,
suggestions: true,
suggestionMessages: true,
verificationRequests: true,
},
},
},
})
)
if (!user) return Astro.rewrite('/404')
---
<MiniLayout
pageTitle="Delete account"
description="Delete your account"
ogImage={{
template: 'generic',
title: 'Delete account',
description: 'Delete your account',
icon: 'ri:delete-bin-line',
}}
layoutHeader={{
icon: 'ri:delete-bin-line',
title: 'Delete account',
subtitle: 'Are you sure? This is irreversible.',
}}
breadcrumbs={[
{
name: 'Accounts',
url: '/account',
},
{
name: 'Delete account',
},
]}
>
<h2 class="font-title text-day-200 mb-2 text-lg font-semibold">What will be deleted:</h2>
<a
href="/account"
class="group relative mb-2 flex flex-col items-center rounded-xl border border-red-500/10 bg-red-500/10 p-4"
>
<UserBadge user={user} size="lg" noLink classNames={{ text: 'group-hover:underline' }} />
<Icon
name="ri:forbid-line"
class="2xs:block absolute top-1/2 left-3 hidden size-8 -translate-y-1/2 text-red-300/10"
/>
<Icon
name="ri:forbid-2-line"
class="2xs:block absolute top-1/2 right-3 hidden size-8 -translate-y-1/2 text-red-300/10"
/>
</a>
<ul class="2xs:grid-cols-2 mb-8 grid grid-cols-1 gap-x-1 gap-y-0">
<li>
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold">1</span>
<span class="text-day-400 text-sm">User</span>
</li>
<li>
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
>{user._count.comments.toLocaleString()}</span
>
<span class="text-day-400 text-sm">Comments <span class="text-xs">(+ their replies)</span></span>
</li>
<li>
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
>{user._count.commentVotes.toLocaleString()}</span
>
<span class="text-day-400 text-sm">Comment Votes</span>
</li>
<li>
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
>{user._count.suggestions.toLocaleString()}</span
>
<span class="text-day-400 text-sm">Service Suggestions</span>
</li>
<li>
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
>{user._count.suggestionMessages.toLocaleString()}</span
>
<span class="text-day-400 text-sm">Suggestion Messages</span>
</li>
<li>
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
>{user._count.verificationRequests.toLocaleString()}</span
>
<span class="text-day-400 text-sm">Verification Requests</span>
</li>
</ul>
{
user.admin || user.moderator ? (
<p class="text-center text-balance text-red-300">Admins and moderators cannot be deleted.</p>
) : (
<form method="POST" action={actions.account.delete}>
<Captcha action={actions.account.delete} class="mb-6" />
<InputSubmitButton label="Delete account" color="danger" icon="ri:delete-bin-line" />
</form>
)
}
</MiniLayout>

View File

@@ -22,6 +22,7 @@ import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls'
import { formatDateShort } from '../../lib/timeAgo'
import { urlDomain } from '../../lib/urls'
const userId = Astro.locals.user?.id
if (!userId) {
@@ -423,7 +424,7 @@ if (!user) return Astro.rewrite('/404')
rel="noopener noreferrer"
class="text-blue-400 hover:underline"
>
{user.verifiedLink}
{urlDomain(user.verifiedLink)}
</a>
</div>
</li>
@@ -857,12 +858,7 @@ if (!user) return Astro.rewrite('/404')
</span>
</td>
<td class="px-4 py-3 text-center">
<span
class={cn(
'border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs',
statusInfo.iconClass
)}
>
<span class="border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs">
<Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label}
</span>
@@ -966,7 +962,7 @@ if (!user) return Astro.rewrite('/404')
<Icon name="ri:logout-box-r-line" class="size-4" /> Logout
</a>
<a
href={`mailto:${SUPPORT_EMAIL}`}
href="/account/delete"
class="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
>
<Icon name="ri:delete-bin-line" class="size-4" /> Delete account

View File

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

View File

@@ -692,12 +692,12 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
<div class="flex justify-end space-x-3">
<Button
as="button"
color="gray"
variant="faded"
size="sm"
label="Cancel"
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
data-cancel-button
data-cancel-form-id={`edit-form-${index}`}
/>
<Button
as="button"
@@ -721,3 +721,22 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div>
</div>
</BaseLayout>
<script>
////////////////////////////////////////////////////////////
// Optional script for cancel buttons in attribute forms. //
// Hides the edit form when cancel button is clicked. //
////////////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLButtonElement>('[data-cancel-button]').forEach((button) => {
button.addEventListener('click', (e) => {
e.preventDefault()
const formId = button.getAttribute('data-cancel-form-id')
if (!formId) throw new Error('Form ID not found')
const form = document.getElementById(formId)
if (!form) throw new Error('Form not found')
form.classList.add('hidden')
})
})
})
</script>

View File

@@ -35,7 +35,7 @@ import {
verificationStepStatuses,
} from '../../../../constants/verificationStepStatus'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables'
import { DEPLOYMENT_MODE } from '../../../../lib/client/envVariables'
import { listFiles } from '../../../../lib/fileStorage'
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
import { pluralize } from '../../../../lib/pluralize'
@@ -361,6 +361,17 @@ const apiCalls = await Astro.locals.banners.try(
value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls}
/>
<InputText
label="Operating since"
name="operatingSince"
description="Date the service started operating"
inputProps={{
type: 'date',
value: service.operatingSince?.toISOString().slice(0, 10),
max: new Date().toISOString().slice(0, 10),
}}
error={serviceInputErrors.operatingSince}
/>
<InputText
label="Referral link path"
name="referral"
@@ -441,7 +452,6 @@ const apiCalls = await Astro.locals.banners.try(
value: kycLevel.id.toString(),
icon: kycLevel.icon,
description: kycLevel.description,
noTransitionPersist: true,
}))}
selectedValue={service.kycLevel.toString()}
iconSize="md"
@@ -458,7 +468,6 @@ const apiCalls = await Astro.locals.banners.try(
value: clarification.value,
icon: clarification.icon,
description: clarification.description,
noTransitionPersist: true,
}))}
selectedValue={service.kycLevelClarification}
iconSize="sm"
@@ -475,7 +484,6 @@ const apiCalls = await Astro.locals.banners.try(
icon: status.icon,
iconClass: status.classNames.icon,
description: status.description,
noTransitionPersist: true,
}))}
selectedValue={service.verificationStatus}
error={serviceInputErrors.verificationStatus}
@@ -491,7 +499,6 @@ const apiCalls = await Astro.locals.banners.try(
label: currency.name,
value: currency.id,
icon: currency.icon,
noTransitionPersist: true,
}))}
selectedValue={service.acceptedCurrencies}
error={serviceInputErrors.acceptedCurrencies}
@@ -532,7 +539,6 @@ const apiCalls = await Astro.locals.banners.try(
icon: visibility.icon,
iconClass: visibility.iconClass,
description: visibility.description,
noTransitionPersist: true,
}))}
selectedValue={service.serviceVisibility}
error={serviceInputErrors.serviceVisibility}

View File

@@ -230,26 +230,22 @@ if (!user) return Astro.rewrite('/404')
label: 'Admin',
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',
value: 'verified',
icon: 'ri:verified-badge-fill',
disabled: true,
noTransitionPersist: true,
},
]}
selectedValue={[
@@ -434,7 +430,6 @@ if (!user) return Astro.rewrite('/404')
label: role.label,
value: role.value,
icon: role.icon,
noTransitionPersist: true,
}))}
required
cardSize="sm"
@@ -466,6 +461,25 @@ if (!user) return Astro.rewrite('/404')
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
</form>
</FormSection>
<FormSection title="Delete User">
{
user.admin || user.moderator ? (
<p class="text-center text-balance">This user can't be deleted</p>
) : (
<p class="text-center text-balance">
To delete this user,
<a
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account/delete`}
data-astro-prefetch="tap"
class="underline"
>
impersonate it and go to /account/delete
</a>
</p>
)
}
</FormSection>
</BaseLayout>
<script>

View File

@@ -319,7 +319,7 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
<span
class={`font-medium ${user.totalKarma >= 100 ? 'text-green-400' : user.totalKarma >= 0 ? 'text-zinc-300' : 'text-red-400'}`}
>
{user.totalKarma}
{user.totalKarma.toLocaleString()}
</span>
</td>
<td class="px-4 py-3 text-center text-sm">

View File

@@ -52,6 +52,7 @@ type ServiceResponse = {
description: string
}
verifiedAt: Date | null
approvedAt: Date | null
kycLevel: 0 | 1 | 2 | 3 | 4
kycLevelInfo: {
value: 0 | 1 | 2 | 3 | 4
@@ -61,7 +62,7 @@ type ServiceResponse = {
kycLevelClarification: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
kycLevelClarificationInfo: {
value: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
name: string
label: string
description: string
}
categories: {
@@ -134,8 +135,10 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
```json
{
"id": 123,
"name": "My Example Service",
"description": "This is a description of my example service",
"slug": "my-example-service",
"serviceVisibility": "PUBLIC",
"verificationStatus": "VERIFICATION_SUCCESS",
"verificationStatusInfo": {
@@ -145,7 +148,8 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
"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",
"verifiedAt": "2025-06-14T11:02:39.294Z",
"approvedAt": "2025-05-31T19:09:18.043Z",
"kycLevel": 0,
"kycLevelInfo": {
"value": 0,
@@ -155,6 +159,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
"kycLevelClarification": "NONE",
"kycLevelClarificationInfo": {
"value": "NONE",
"label": "None",
"description": "No clarification needed."
},
"categories": [
@@ -163,7 +168,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
"slug": "exchange"
}
],
"listedAt": "2025-05-31T19:09:18.043Z",
"listedAt": "2025-04-20T07:12:29.393Z",
"serviceUrls": [
"https://example.com",
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"

View File

@@ -15,55 +15,53 @@ import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'
There are several ways to earn karma points:
1. **Comment Approval** (+1 point)
- When your comment moves from 'unmoderated' to 'approved' status
- This is the basic reward for contributing a valid comment
- When your comment moves from 'unmoderated' to 'approved' status.
- This is the basic reward for contributing a valid comment.
- Users related to the service (e.g. owners, admins, etc.) do not get karma for their comments.
2. **Comment Verification** (+5 points)
- When your comment is marked as 'verified'
- This is a significant reward for providing particularly valuable or verified information
- When your comment is marked as 'verified'.
- This is a significant reward for providing particularly valuable or verified information.
3. **Upvotes**
- Each upvote on your comment adds +1 to your karma
- Similarly, each downvote reduces your karma by -1
- This allows the community to reward helpful contributions
- Each upvote on your comment adds +1 to your karma.
- Similarly, each downvote reduces your karma by -1.
- This allows the community to reward helpful contributions.
## Karma Penalties
The system also includes penalties to discourage spam and low-quality content:
1. **Spam Detection** (-10 points)
- If your comment is marked as suspicious/spam
- This is a significant penalty to discourage spam behavior
- If the spam mark is removed, the 10 points are restored
- If your comment is marked as suspicious/spam.
- This is a significant penalty to discourage spam behavior.
- If the spam mark is removed, the 10 points are restored.
## Karma Tracking
The system maintains a detailed record of all karma changes through:
1. **Karma Transactions**
- Every karma change is recorded as a transaction
- Every karma change is recorded as a transaction.
- Each transaction includes:
- The action that triggered it
- The number of points awarded/deducted
- A description of why the karma changed
- The related comment (if applicable)
- The action that triggered it.
- The number of points awarded/deducted.
- A description of why the karma changed.
- The related comment (if applicable).
2. **Total Karma**
- Your total karma is displayed on your profile
- It's the sum of all your karma transactions
- This score helps establish your reputation in the community
- Your total karma is displayed on your profile.
- It's the sum of all your karma transactions.
- This score helps establish your reputation in the community.
## Impact of Karma
Your karma score is more than just a number - it's a reflection of your contributions to the community. Higher karma scores indicate:
- Active participation in the community
- History of providing valuable information
- Trustworthiness of your contributions
- Commitment to community standards
- Active participation in the community.
- History of providing valuable information.
- Trustworthiness of your contributions.
- Commitment to community standards.
The karma system helps maintain the quality of discussions and encourages meaningful contributions to the platform.

View File

@@ -13,6 +13,8 @@ Please, link back to [KYCnot.me](https://kycnot.me) when possible, and use respo
<PressAssets />
Review service link format: `https://kycnot.me/service/[slug]/review`
## Brand design
- Brand color: `#3bdb78`

View File

@@ -45,7 +45,6 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
async () =>
prisma.service.findMany({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
},
select: {
@@ -72,7 +71,6 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
},
service: {
slug: params.service ?? undefined,
listedAt: params.service ? undefined : { lte: new Date() },
serviceVisibility: {
in: params.service ? ['PUBLIC', 'ARCHIVED', 'UNLISTED'] : ['PUBLIC', 'ARCHIVED'],
},

View File

@@ -219,7 +219,6 @@ const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : n
const where = {
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
listedAt: { lte: new Date() },
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
verificationStatus: {
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
@@ -317,7 +316,6 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
services: {
where: {
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
listedAt: { lte: new Date() },
},
},
},

View File

@@ -163,7 +163,6 @@ const [dbNotifications, notificationPreferences, totalNotifications, pushSubscri
where: { userId: user.id },
select: {
endpoint: true,
userAgent: true,
},
}),
[],

View File

@@ -238,17 +238,29 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
/>
</div>
<InputTextArea
label="ToS URLs"
description="One per line. AI review uses the first working URL only."
name="tosUrls"
inputProps={{
placeholder: 'example.com/tos',
required: true,
class: 'min-h-10',
}}
error={inputErrors.tosUrls}
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputTextArea
label="ToS URLs"
description="One per line. AI review uses the first working URL only."
name="tosUrls"
inputProps={{
placeholder: 'example.com/tos',
required: true,
class: 'min-h-10',
}}
error={inputErrors.tosUrls}
/>
<InputText
label="Operating since"
name="operatingSince"
inputProps={{
type: 'date',
max: new Date().toISOString().slice(0, 10),
}}
error={inputErrors.operatingSince}
/>
</div>
<InputCardGroup
name="kycLevel"
@@ -311,6 +323,7 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
}))}
description="See list of [all attributes](/attributes) and their scoring."
error={inputErrors.attributes}
size="lg"
/>

View File

@@ -70,7 +70,6 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
where: {
slug,
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
listedAt: { lte: new Date() },
},
select: {
id: true,
@@ -93,14 +92,16 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
referral: true,
imageUrl: true,
listedAt: true,
approvedAt: true,
createdAt: true,
acceptedCurrencies: true,
operatingSince: true,
tosReview: true,
tosReviewAt: true,
userSentiment: true,
userSentimentAt: true,
averageUserRating: true,
isRecentlyListed: true,
isRecentlyApproved: true,
contactMethods: {
select: {
value: true,
@@ -230,7 +231,6 @@ if (!service) {
where: {
previousSlugs: { has: slug },
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
listedAt: { lte: new Date() },
},
select: { slug: true },
})
@@ -294,6 +294,8 @@ const statusIcon = {
APPROVED: undefined,
}[service.verificationStatus]
const isScam = service.verificationStatus === 'VERIFICATION_FAILED'
const shuffledLinks = {
clearnet: shuffle(service.serviceUrls),
onion: shuffle(service.onionUrls),
@@ -385,6 +387,13 @@ const getVerificationStepStatusInfo = (status: VerificationStepStatus) => {
color: 'red',
timelineIconClass: 'text-red-400',
} as const
case VerificationStepStatus.WARNING:
return {
text: 'Warning',
icon: 'ri:alert-line',
color: 'yellow',
timelineIconClass: 'text-yellow-400',
} as const
default:
return {
text: 'Unknown',
@@ -756,11 +765,18 @@ const activeEventToShow =
<ul aria-label="Service links" class="xs:justify-start mt-4 flex flex-wrap justify-center gap-2">
{shownLinks.map((url) => (
<li>
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
{isScam ? (
<span class="2xs:text-sm 2xs:h-8 2xs:gap-2 2xs:px-4 bg-day-800 inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs whitespace-nowrap text-red-400">
<Icon name="ri:alert-line" class="size-4 text-red-400" />
{urlDomain(url)}
</span>
) : (
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
)}
</li>
))}
@@ -784,11 +800,18 @@ const activeEventToShow =
{hiddenLinks.map((url) => (
<li class="hidden peer-checked:block">
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
{isScam ? (
<span class="2xs:text-sm 2xs:h-8 2xs:gap-2 2xs:px-4 bg-day-800 inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs whitespace-nowrap text-red-400">
<Icon name="ri:alert-line" class="size-4 text-red-400" />
{urlDomain(url)}
</span>
) : (
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
)}
</li>
))}
</ul>
@@ -879,13 +902,34 @@ const activeEventToShow =
</h2>
<div class="xs:gap-8 flex flex-row flex-wrap items-stretch justify-center gap-4 sm:justify-between">
<div class="flex max-w-84 flex-grow flex-row items-center justify-evenly gap-4">
<ScoreSquare score={service.overallScore} label="Overall" itemReviewedId={itemReviewedId} />
<ScoreSquare
as="a"
href="/about#service-scores"
score={service.overallScore}
label="Overall"
itemReviewedId={itemReviewedId}
showInfo
/>
<div class="bg-day-800 h-12 w-px flex-shrink-0 self-center"></div>
<ScoreGauge score={service.privacyScore} label="Privacy" itemReviewedId={itemReviewedId} />
<ScoreGauge
as="a"
href="/about#service-scores"
score={service.privacyScore}
label="Privacy"
itemReviewedId={itemReviewedId}
showInfo
/>
<ScoreGauge score={service.trustScore} label="Trust" itemReviewedId={itemReviewedId} />
<ScoreGauge
as="a"
href="/about#service-scores"
score={service.trustScore}
label="Trust"
itemReviewedId={itemReviewedId}
showInfo
/>
</div>
<div
class="@container flex max-w-112 flex-shrink-0 flex-grow-100 basis-64 flex-row items-start rounded-lg bg-white px-3 py-2 text-black"
@@ -982,15 +1026,23 @@ const activeEventToShow =
attribute.typeInfo.classNames.text
)}
>
<Icon
name={attribute.categoryInfo.icon}
class={cn('mr-2 size-4 flex-shrink-0', attribute.typeInfo.classNames.icon)}
/>
<span>{attribute.title}</span>
<span class="mr-auto ml-1">{attribute.title}</span>
<>
{weights.map((w) => (
<span
class={cn(
'ml-2 text-center text-sm',
w.value === 0 ? 'text-current/30' : 'text-current/60'
)}
>
{formatNumber(w.value, w.formatOptions)}
</span>
))}
</>
<Icon
name="ri:arrow-down-s-line"
class={cn(
'ml-auto size-5 group-open/attribute:rotate-180',
'ml-1 size-5 group-open/attribute:rotate-180',
attribute.typeInfo.classNames.icon
)}
/>
@@ -1057,19 +1109,16 @@ const activeEventToShow =
baseScoreType.classNames.text
)}
>
<Icon
name={baseScoreType.icon}
class={cn('mr-2 size-4 flex-shrink-0', baseScoreType.classNames.icon)}
/>
<span class="font-title">{baseScoreType.label}</span>
<span class={cn('mr-1 ml-auto', baseScoreType.classNames.icon)}>+50</span>
<span class="font-title mr-auto ml-1">{baseScoreType.label}</span>
<span class="mr-2 text-current/60">+50</span>
<span class="mr-1 text-current/60">+50</span>
</li>
</ul>
)
}
<p class="text-day-400 mt-3 text-center text-xs">
<span class="hover:text-day-200 transition-colors">Overall = 60% Privacy + 40% Trust (Rounded)</span>
<span class="hover:text-day-200 transition-colors">Overall = 60% Privacy + 40% Trust (Truncated)</span>
</p>
<div class="xs:gap-x-6 mt-2 flex flex-wrap justify-center gap-x-4 gap-y-2 text-xs">
<a

View File

@@ -25,6 +25,7 @@ import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
import { prisma } from '../../lib/prisma'
import { KYCNOTME_SCHEMA_MINI } from '../../lib/schema'
import { formatDateShort } from '../../lib/timeAgo'
import { urlDomain } from '../../lib/urls'
import type { ProfilePage, WithContext } from 'schema-dts'
@@ -96,9 +97,6 @@ const user = await Astro.locals.banners.try('user', async () => {
},
where: {
service: {
listedAt: {
lte: new Date(),
},
serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED'],
},
@@ -395,7 +393,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:award-line" class="size-4" /></span>
<div>
<p class="text-day-500 text-xs">Karma</p>
<p class="text-day-300">{user.totalKarma.toLocaleString()}</p>
<p class="text-day-300">
{!user.admin && !user.moderator ? user.totalKarma.toLocaleString() : '∞'}
</p>
</div>
</li>
</ul>
@@ -511,7 +511,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
rel="noopener noreferrer"
class="text-blue-400 hover:underline"
>
{user.verifiedLink}
{urlDomain(user.verifiedLink)}
</a>
</div>
</li>
@@ -577,187 +577,130 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</section>
</AdminOnly>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header>
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:building-4-line" class="mr-2 inline-block size-5" />
Service Affiliations
</h2>
</header>
{
!user.admin && !user.moderator && (
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header>
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:building-4-line" class="mr-2 inline-block size-5" />
Service Affiliations
</h2>
</header>
{
user.serviceAffiliations.length > 0 ? (
<ul class="2xs:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] grid gap-4">
{user.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
const statusIcon = {
...verificationStatusesByValue,
APPROVED: undefined,
}[affiliation.service.verificationStatus]
{user.serviceAffiliations.length > 0 ? (
<ul class="2xs:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] grid gap-4">
{user.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
const statusIcon = {
...verificationStatusesByValue,
APPROVED: undefined,
}[affiliation.service.verificationStatus]
return (
<li class="shrink-0">
<a
href={`/service/${affiliation.service.slug}`}
class="text-day-300 group flex min-w-32 items-center gap-2 text-sm"
>
<MyPicture
src={affiliation.service.imageUrl}
fallback="service"
alt={affiliation.service.name}
width={40}
height={40}
class="size-10 shrink-0 rounded-lg"
/>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<div class="flex items-center gap-1.5">
<BadgeSmall color={roleInfo.color} text={roleInfo.label} icon={roleInfo.icon} />
<span class="text-day-500">of</span>
return (
<li class="shrink-0">
<a
href={`/service/${affiliation.service.slug}`}
class="text-day-300 group flex min-w-32 items-center gap-2 text-sm"
>
<MyPicture
src={affiliation.service.imageUrl}
fallback="service"
alt={affiliation.service.name}
width={40}
height={40}
class="size-10 shrink-0 rounded-lg"
/>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<div class="flex items-center gap-1.5">
<BadgeSmall color={roleInfo.color} text={roleInfo.label} icon={roleInfo.icon} />
<span class="text-day-500">of</span>
</div>
<div class="text-day-300 flex items-center gap-1 font-semibold">
<span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap group-hover:underline">
{affiliation.service.name}
</span>
{statusIcon && (
<Tooltip text={statusIcon.label} position="right" class="-m-1 shrink-0">
<Icon
name={statusIcon.icon}
class={cn(
'inline-block size-6 shrink-0 rounded-lg p-1',
statusIcon.classNames.icon
)}
/>
</Tooltip>
)}
<Icon name="ri:external-link-line" class="size-4 shrink-0" />
</div>
</div>
</a>
</li>
)
})}
</ul>
) : (
<p class="text-day-400 mb-6">No service affiliations yet.</p>
)}
</section>
)
}
<div class="text-day-300 flex items-center gap-1 font-semibold">
<span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap group-hover:underline">
{affiliation.service.name}
</span>
{statusIcon && (
<Tooltip text={statusIcon.label} position="right" class="-m-1 shrink-0">
<Icon
name={statusIcon.icon}
class={cn(
'inline-block size-6 shrink-0 rounded-lg p-1',
statusIcon.classNames.icon
)}
/>
</Tooltip>
)}
<Icon name="ri:external-link-line" class="size-4 shrink-0" />
</div>
</div>
</a>
</li>
)
})}
</ul>
) : (
<p class="text-day-400 mb-6">No service affiliations yet.</p>
)
}
</section>
{
!user.admin && !user.moderator && (
<section
class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"
id="karma-unlocks"
>
<header>
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:lock-unlock-line" class="mr-2 inline-block size-5" />
Karma Unlocks
</h2>
<section
class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"
id="karma-unlocks"
>
<header>
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:lock-unlock-line" class="mr-2 inline-block size-5" />
Karma Unlocks
</h2>
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
<p class="text-day-300">
Earn karma to unlock features and privileges.{' '}
<a href="/docs/karma" class="text-day-200 hover:underline">
Learn about karma
</a>
</p>
</div>
</header>
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
<p class="text-day-300">
Earn karma to unlock features and privileges. <a
href="/docs/karma"
class="text-day-200 hover:underline">Learn about karma</a
>
</p>
</div>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-3">
<input type="checkbox" id="positive-unlocks-toggle" class="peer sr-only md:hidden" checked />
<label
for="positive-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-green-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-day-200 text-sm">Positive unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-3">
<input type="checkbox" id="positive-unlocks-toggle" class="peer sr-only md:hidden" checked />
<label
for="positive-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-green-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-day-200 text-sm">Positive unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma >= 0),
'karma'
).map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-green-500/30 bg-green-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn(
'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400'
)}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
</span>
)}
</div>
</div>
))
}
</div>
</div>
<div class="space-y-3">
<input type="checkbox" id="negative-unlocks-toggle" class="peer sr-only md:hidden" />
<label
for="negative-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-red-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-sm text-red-400">Negative unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
{
sortBy(
karmaUnlocks.filter((unlock) => unlock.karma < 0),
'karma'
)
.reverse()
.map((unlock) => (
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
{sortBy(
karmaUnlocks.filter((unlock) => unlock.karma >= 0),
'karma'
).map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-red-500/30 bg-red-500/10'
? 'border-green-500/30 bg-green-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}>
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-day-400' : 'text-day-500')}>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn(
'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400'
user.karmaUnlocks[unlock.id] ? 'text-day-300' : 'text-day-400'
)}
>
{unlock.name}
@@ -767,28 +710,89 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active
<span class="bg-day-500/20 text-day-300 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:check-line" class="mr-1 size-3" /> Unlocked
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided
<Icon name="ri:lock-line" class="mr-1 size-3" /> Locked
</span>
)}
</div>
</div>
))
}
))}
</div>
</div>
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs">
<Icon name="ri:information-line" class="inline-block size-4" />
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
avoid penalties.
</p>
<div class="space-y-3">
<input type="checkbox" id="negative-unlocks-toggle" class="peer sr-only md:hidden" />
<label
for="negative-unlocks-toggle"
class="flex cursor-pointer items-center justify-between border-b border-red-500/20 pb-2 md:cursor-default peer-checked:[&_[data-expand-arrow]]:rotate-180"
>
<h3 class="font-title text-sm text-red-400">Negative unlocks</h3>
<Icon name="ri:arrow-down-s-line" class="text-day-400 size-5 md:hidden" data-expand-arrow />
</label>
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
{sortBy(
karmaUnlocks.filter((unlock) => unlock.karma < 0),
'karma'
)
.reverse()
.map((unlock) => (
<div
class={cn(
'flex items-center justify-between rounded-md border p-3',
user.karmaUnlocks[unlock.id]
? 'border-red-500/30 bg-red-500/10'
: 'border-night-500 bg-night-800'
)}
>
<div class="flex items-center">
<span
class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}
>
<Icon name={unlock.icon} class="size-5" />
</span>
<div>
<p
class={cn(
'font-medium',
user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-400'
)}
>
{unlock.name}
</p>
<p class="text-day-500 text-sm">{unlock.karma.toLocaleString()} karma</p>
</div>
</div>
<div>
{user.karmaUnlocks[unlock.id] ? (
<span class="inline-flex items-center rounded-full bg-red-500/20 px-2 py-1 text-xs text-red-400">
<Icon name="ri:alert-line" class="mr-1 size-3" /> Active
</span>
) : (
<span class="bg-night-800 text-day-400 inline-flex items-center rounded-full px-2 py-1 text-xs">
<Icon name="ri:shield-check-line" class="mr-1 size-3" /> Avoided
</span>
)}
</div>
</div>
))}
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs">
<Icon name="ri:information-line" class="inline-block size-4" />
Negative karma leads to restrictions. <br class="hidden sm:block" />
Keep interactions positive to avoid penalties.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
</section>
)
}
<div class="space-y-6">
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
@@ -931,144 +935,143 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
)
}
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:lightbulb-line" class="mr-2 inline-block size-5" />
Recent Suggestions
</h2>
</header>
{
!user.admin && !user.moderator && (
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:lightbulb-line" class="mr-2 inline-block size-5" />
Recent Suggestions
</h2>
</header>
{
user.suggestions.length === 0 ? (
<p class="text-day-400">No suggestions yet.</p>
) : (
<div class="overflow-x-auto">
<table class="divide-night-400/20 w-full min-w-full divide-y">
<thead>
<tr>
<th class="text-day-400 px-4 py-3 text-left text-xs">Service</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Type</th>
<th class="text-day-400 px-4 py-3 text-center text-xs">Status</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.suggestions.map((suggestion) => {
const typeInfo = getServiceSuggestionTypeInfo(suggestion.type)
const statusInfo = getServiceSuggestionStatusInfo(suggestion.status)
{user.suggestions.length === 0 ? (
<p class="text-day-400">No suggestions yet.</p>
) : (
<div class="overflow-x-auto">
<table class="divide-night-400/20 w-full min-w-full divide-y">
<thead>
<tr>
<th class="text-day-400 px-4 py-3 text-left text-xs">Service</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Type</th>
<th class="text-day-400 px-4 py-3 text-center text-xs">Status</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.suggestions.map((suggestion) => {
const typeInfo = getServiceSuggestionTypeInfo(suggestion.type)
const statusInfo = getServiceSuggestionStatusInfo(suggestion.status)
return (
<tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<a
href={`/service-suggestion/${suggestion.id}`}
class="text-blue-400 hover:underline"
>
{suggestion.service.name}
</a>
</td>
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<span class="inline-flex items-center">
<Icon name={typeInfo.icon} class="mr-1 size-4" />
{typeInfo.label}
</span>
</td>
<td class="px-4 py-3 text-center">
<span
return (
<tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<a
href={`/service-suggestion/${suggestion.id}`}
class="text-blue-400 hover:underline"
>
{suggestion.service.name}
</a>
</td>
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<span class="inline-flex items-center">
<Icon name={typeInfo.icon} class="mr-1 size-4" />
{typeInfo.label}
</span>
</td>
<td class="px-4 py-3 text-center">
<span class="border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs">
<Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label}
</span>
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted
date={suggestion.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
)
}
{
!user.admin && !user.moderator && (
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:exchange-line" class="mr-2 inline-block size-5" />
Recent Karma Transactions
</h2>
<span class="text-day-500">{user.totalKarma.toLocaleString()} karma</span>
</header>
{user.karmaTransactions.length === 0 ? (
<p class="text-day-400">No karma transactions yet.</p>
) : (
<div class="overflow-x-auto">
<table class="divide-night-400/20 w-full min-w-full divide-y">
<thead>
<tr>
<th class="text-day-400 px-4 py-3 text-left text-xs">Action</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Description</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Points</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.karmaTransactions.map((transaction) => {
const actionInfo = getKarmaTransactionActionInfo(transaction.action)
return (
<tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<span class="inline-flex items-center gap-1">
<Icon name={actionInfo.icon} class="size-4" />
{actionInfo.label}
{transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && (
<>
<span class="text-day-500">from</span>
<UserBadge user={transaction.grantedBy} size="sm" class="text-day-500" />
</>
)}
</span>
</td>
<td class="text-day-300 px-4 py-3">{transaction.description}</td>
<td
class={cn(
'border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs',
statusInfo.iconClass
'px-4 py-3 text-right text-xs whitespace-nowrap',
transaction.points >= 0 ? 'text-green-400' : 'text-red-400'
)}
>
<Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label}
</span>
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted
date={suggestion.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
</section>
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
<header class="flex items-center justify-between">
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
<Icon name="ri:exchange-line" class="mr-2 inline-block size-5" />
Recent Karma Transactions
</h2>
<span class="text-day-500">{user.totalKarma.toLocaleString()} karma</span>
</header>
{
user.karmaTransactions.length === 0 ? (
<p class="text-day-400">No karma transactions yet.</p>
) : (
<div class="overflow-x-auto">
<table class="divide-night-400/20 w-full min-w-full divide-y">
<thead>
<tr>
<th class="text-day-400 px-4 py-3 text-left text-xs">Action</th>
<th class="text-day-400 px-4 py-3 text-left text-xs">Description</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Points</th>
<th class="text-day-400 px-4 py-3 text-right text-xs">Date</th>
</tr>
</thead>
<tbody class="divide-night-400/10 divide-y">
{user.karmaTransactions.map((transaction) => {
const actionInfo = getKarmaTransactionActionInfo(transaction.action)
return (
<tr class="hover:bg-night-500/5">
<td class="text-day-300 px-4 py-3 text-xs whitespace-nowrap">
<span class="inline-flex items-center gap-1">
<Icon name={actionInfo.icon} class="size-4" />
{actionInfo.label}
{transaction.action === 'MANUAL_ADJUSTMENT' && transaction.grantedBy && (
<>
<span class="text-day-500">from</span>
<UserBadge user={transaction.grantedBy} size="sm" class="text-day-500" />
</>
)}
</span>
</td>
<td class="text-day-300 px-4 py-3">{transaction.description}</td>
<td
class={cn(
'px-4 py-3 text-right text-xs whitespace-nowrap',
transaction.points >= 0 ? 'text-green-400' : 'text-red-400'
)}
>
{transaction.points >= 0 && '+'}
{transaction.points}
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted
date={transaction.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
</section>
{transaction.points >= 0 && '+'}
{transaction.points}
</td>
<td class="text-day-400 px-4 py-3 text-right text-xs whitespace-nowrap">
<TimeFormatted
date={transaction.createdAt}
prefix={false}
hourPrecision
caseType="sentence"
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
)
}
</div>
</BaseLayout>

View File

@@ -5,7 +5,10 @@
import { clientsClaim } from 'workbox-core'
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { makeNotificationOptions } from './lib/notificationOptions'
import {
makeBrowserNotificationOptions,
makeBrowserNotificationTitle,
} from './lib/client/notificationOptions'
import type { NotificationData, NotificationPayload } from './lib/serverEventsTypes'
@@ -59,8 +62,8 @@ async function handleNotificationClick(url: string) {
async function showPushNotification(payload: NotificationPayload | null) {
await self.registration.showNotification(
payload?.title ?? 'New Notification',
makeNotificationOptions(payload)
makeBrowserNotificationTitle(payload?.title),
makeBrowserNotificationOptions(payload)
)
}