Compare commits

...

16 Commits

Author SHA1 Message Date
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
pluja
03abdef4f1 Release 202506112228 2025-06-11 22:28:53 +00:00
pluja
d9880fd83d Release 202506112108 2025-06-11 21:08:32 +00:00
pluja
39afcad089 Release 202506111705 2025-06-11 17:05:58 +00:00
pluja
99cb730bc0 Release 202506111039 2025-06-11 10:39:20 +00:00
pluja
d43402e162 Release 202506111007 2025-06-11 10:07:51 +00:00
pluja
9bb316b85f Release 202506102027 2025-06-10 20:27:48 +00:00
pluja
4aea68ee58 Release 202506101914 2025-06-10 19:14:10 +00:00
86 changed files with 1112 additions and 445 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,11 @@ 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)",
)
return parser.parse_args(args)
@@ -295,12 +300,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 +318,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:
@@ -366,7 +401,7 @@ def run_worker_mode() -> int:
# Register service score recalculation task (every 5 minutes)
scheduler.register_task(
"service-score-recalc",
"service_score_recalc",
"*/5 * * * *",
run_service_score_recalc_task,
)
@@ -419,7 +454,9 @@ 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:
logger.error(f"Unknown task: {args.task}")
return 1

View File

@@ -333,28 +333,32 @@ def remove_service_attribute_by_slug(service_id: int, attribute_slug: str) -> bo
def save_tos_review(service_id: int, review: Optional[TosReviewType]):
"""
Save a TOS review for a specific service.
"""Persist a TOS review and/or update the timestamp for a service.
Args:
service_id: The ID of the service.
review: A TypedDict containing the review data.
If *review* is ``None`` the existing review (if any) is preserved while
only the ``tosReviewAt`` column is updated. This ensures we still track
when the review task last ran even if the review generation failed or
produced no changes.
"""
try:
# Only serialize to JSON if review is not None
review_json = json.dumps(review) if review is not None else None
with get_db_connection() as conn:
with conn.cursor(row_factory=dict_row) as cursor:
cursor.execute(
"""
UPDATE "Service"
SET "tosReview" = %s, "tosReviewAt" = NOW()
WHERE id = %s
""",
(review_json, service_id),
)
if review is None:
cursor.execute(
'UPDATE "Service" SET "tosReviewAt" = NOW() WHERE id = %s AND "tosReview" IS NULL',
(service_id,),
)
else:
review_json = json.dumps(review)
cursor.execute(
'UPDATE "Service" SET "tosReview" = %s, "tosReviewAt" = NOW() WHERE id = %s',
(review_json, service_id),
)
conn.commit()
logger.info(f"Successfully saved TOS review for service {service_id}")
logger.info(
f"Successfully saved TOS review (updated={review is not None}) for service {service_id}"
)
except Exception as e:
logger.error(f"Error saving TOS review for service {service_id}: {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

@@ -3,7 +3,9 @@ Task for retrieving Terms of Service (TOS) text.
"""
import hashlib
from typing import Any, Dict, Optional, Literal
from typing import Any, Dict, Optional
import requests
from pyworker.database import TosReviewType, save_tos_review, update_kyc_level
from pyworker.tasks.base import Task
@@ -32,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}"
)
@@ -53,13 +55,37 @@ class TosReviewTask(Task):
self.logger.info(f"TOS URLs: {tos_urls}")
review = self.get_tos_review(tos_urls, service.get("tosReview"))
# Always update the processed timestamp, even if review is None
save_tos_review(service_id, review)
# Update the KYC level based on the review
if review is None:
self.logger.warning(
f"TOS review could not be generated for service {service_name} (ID: {service_id})"
)
return None
# Update the KYC level based on the review, when present
if "kycLevel" in review:
kyc_level = review["kycLevel"]
self.logger.info(f"Updating KYC level to {kyc_level} for service {service_name}")
update_kyc_level(service_id, kyc_level)
new_level = review["kycLevel"]
old_level = service.get("kycLevel")
# Update DB
if update_kyc_level(service_id, new_level):
msg = f"{service.get('slug', service_name)}: kycLevel {old_level} -> {new_level}"
# Log to console
self.logger.info(msg)
# Send notification via ntfy
try:
requests.post(
"https://ntfy.sh/knm-kyc-lvl-changes-knm", data=msg.encode()
)
except requests.RequestException as e:
self.logger.error(
f"Failed to send ntfy notification for KYC level change: {e}"
)
return review
@@ -87,7 +113,9 @@ class TosReviewTask(Task):
content = fetch_markdown(api_url)
if not content:
self.logger.warning(f"Failed to retrieve TOS content for URL: {tos_url}")
self.logger.warning(
f"Failed to retrieve TOS content for URL: {tos_url}"
)
all_skipped = False
continue

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)
@@ -173,12 +175,12 @@ type TosReview = {
/** In regards to KYC, Privacy, Anonymity, Self-Sovereignity, etc. */
/** anything that could harm the user's privacy, identity, self-sovereignity or anonymity is negative, anything that otherwise helps is positive. else it is neutral. */
rating: 'negative' | 'neutral' | 'positive'
}[]
}[] // max 8 highlights, try to provide at least 3.
}
The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical.
Do not provide more than 8 highlights. Focus on the most important information for the user. Be concise but thorough, and make sure your output is properly formatted JSON.
Focus on the most important information for the user. Be concise and thorough, and make sure your output is properly formatted JSON.
"""
PROMPT_COMMENT_SENTIMENT_SUMMARY = """

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',
@@ -108,6 +110,8 @@ export default defineConfig({
'/attribute/[...slug]': '/attributes',
'/attr/[...slug]': '/attributes',
// #endregion
'/service/[...slug]/review': '/service/[...slug]#comments',
},
env: {
schema: {
@@ -121,7 +125,7 @@ export default defineConfig({
}),
// Public URLs (can be accessed from both server and client)
SOURCE_CODE_URL: envField.string({
context: 'server',
context: 'client',
access: 'public',
url: true,
optional: false,

48
web/package-lock.json generated
View File

@@ -2532,9 +2532,9 @@
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2603,9 +2603,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7196,9 +7196,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9325,9 +9325,9 @@
}
},
"node_modules/eslint-plugin-import/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9399,9 +9399,9 @@
}
},
"node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9476,9 +9476,9 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10379,9 +10379,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11671,9 +11671,9 @@
}
},
"node_modules/jake/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {

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

@@ -353,8 +353,6 @@ model Service {
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 +361,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 +376,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 +401,9 @@ model Service {
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
@@index([listedAt])
@@index([approvedAt])
@@index([verifiedAt])
@@index([spamAt])
@@index([overallScore])
@@index([privacyScore])
@@index([trustScore])
@@ -407,6 +415,7 @@ model Service {
@@index([updatedAt])
@@index([slug])
@@index([previousSlugs])
@@index([serviceVisibility])
}
model ServiceContactMethod {
@@ -578,6 +587,7 @@ enum VerificationStepStatus {
IN_PROGRESS
PASSED
FAILED
WARNING
}
model VerificationStep {
@@ -677,8 +687,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(),
},
{
@@ -1143,7 +1153,7 @@ async function main() {
}
let users = await Promise.all(
Array.from({ length: 10 }, async () => {
Array.from({ length: 570 }, async () => {
const { user } = await createAccount()
return user
})
@@ -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

@@ -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.
@@ -24,12 +24,9 @@ RETURNS INT AS $$
DECLARE
privacy_score INT := 0;
kyc_factor INT;
onion_factor INT := 0;
i2p_factor INT := 0;
clarification_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
@@ -46,20 +43,22 @@ BEGIN
FROM "Service"
WHERE "id" = service_id;
-- Check for onion URLs
IF EXISTS (
SELECT 1 FROM "Service"
WHERE "id" = service_id AND array_length("onionUrls", 1) > 0
) THEN
onion_factor := 5;
END IF;
-- Adjust score based on KYC level clarification modifiers
SELECT
CASE
WHEN "kycLevelClarification" = 'DEPENDS_ON_PARTNERS' THEN -5
ELSE 0 -- Default modifier when no clarification or unrecognized value
END
INTO clarification_factor
FROM "Service"
WHERE "id" = service_id;
-- Check for i2p URLs
-- Check for onion or i2p URLs
IF EXISTS (
SELECT 1 FROM "Service"
WHERE "id" = service_id AND array_length("i2pUrls", 1) > 0
WHERE "id" = service_id AND (array_length("onionUrls", 1) > 0 OR array_length("i2pUrls", 1) > 0)
) THEN
i2p_factor := 5;
onion_or_i2p_factor := 5;
END IF;
-- Check for Monero acceptance
@@ -75,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 + 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));
@@ -94,7 +93,7 @@ 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;
BEGIN
-- Get verification status factor
@@ -115,26 +114,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;
@@ -150,7 +149,7 @@ BEGIN
END IF;
-- 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;
-- Ensure the score is in reasonable bounds (0-100)
trust_score := GREATEST(0, LEAST(100, trust_score));
@@ -165,7 +164,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

@@ -126,7 +126,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,
@@ -244,7 +245,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,

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,6 +127,7 @@ 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,

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'],
},
@@ -252,7 +251,6 @@ export const serviceSuggestionActions = {
overallScore: 0,
privacyScore: 0,
trustScore: 0,
listedAt: new Date(),
serviceVisibility: 'UNLISTED',
categories: {
connect: input.categories.map((id) => ({ id })),

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
<rect width="431" height="63" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
<path fill="#3BDB78" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#BEBEBE"
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#3BDB78"
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
<rect width="431" height="63" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
<path fill="#28AE5B" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#3F3F3F"
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#28AE5B"
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
<rect width="255" height="95" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
<path fill="#3BDB78" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#BEBEBE"
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#3BDB78"
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
<rect width="255" height="95" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
<path fill="#28AE5B" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
<path fill="#3F3F3F"
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
<path fill="#28AE5B"
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

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 DevToolsMessageScript from './DevToolsMessageScript.astro'
import DynamicFavicon from './DynamicFavicon.astro'
import HtmxScript from './HtmxScript.astro'
import NotificationEventsScript from './NotificationEventsScript.astro'
@@ -106,8 +107,8 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
<DynamicFavicon />
<!-- Components -->
<ClientRouter />
<LoadingIndicator color="green" />
<TailwindJsPluggin />
{htmx && <HtmxScript />}
@@ -147,3 +148,5 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
</>
)
}
<DevToolsMessageScript />

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'
@@ -150,13 +151,13 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
checked={comment.suspicious}
/>
<div class="comment-header scrollbar-w-none flex items-center gap-2 overflow-auto text-sm">
<div class="comment-header flex items-center gap-2 text-sm">
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
<span class="collapse-symbol text-xs"></span>
<span class="sr-only">Toggle comment visibility</span>
</label>
<span class="flex items-center gap-1">
<span class="flex min-w-16 items-center gap-1">
<UserBadge
user={comment.author}
size="md"
@@ -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>
@@ -179,7 +180,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
</span>
{/* User badges - more compact but still with text */}
<div class="flex flex-wrap items-center gap-1">
<div class="flex w-min grow flex-wrap items-center gap-1">
{
comment.author.admin && (
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
@@ -240,15 +241,17 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
}
{
comment.author.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
{roleInfo.label} at
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
</BadgeSmall>
)
})
comment.author.serviceAffiliations
.filter((affiliation) => affiliation.service.slug === serviceSlug)
.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
{roleInfo.label} at
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
</BadgeSmall>
)
})
}
</div>
</div>

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

@@ -0,0 +1,51 @@
---
---
<script>
import { SOURCE_CODE_URL } from 'astro:env/client'
const logoStyle = `
padding: 0 119.5px;
display: block;
line-height: 64px;
background-size: auto 64px;
background-repeat: no-repeat;
background-position: 50% 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 240 64'%3E%3Crect width='239' height='63' x='.5' y='.5' fill='%23101413' stroke='%23292B2A' rx='7.5'/%3E%3Cpath fill='%233BDB78' d='M19 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1H19Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.9 10.8a1 1 0 0 0-.1.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L47 23.5a1 1 0 0 1 .8-1.5ZM75 22h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H74v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H75a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L186 37.2l-6.7-14.6a1 1 0 0 0-1-.6H175Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z'/%3E%3C/svg%3E");
`
setTimeout(
console.log.bind(
console,
`\n%c \n%c\n 👋%c Hi there! %c\n\n‣ We included source maps, so you can easily inspect the code. 🕵🏻‍♂️\n‣ Everything works with JavaScript disabled.\n‣ Source code: ${SOURCE_CODE_URL}`,
logoStyle,
`
font-family: cursive;
font-size: 2rem;
font-weight: bold;
`,
`
font-family: cursive;
font-size: 2rem;
font-weight: bold;
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;
`,
`
font-size: 1rem;
font-family: system-ui, -apple-system, sans-serif;
`
)
)
</script>

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

@@ -1,6 +1,7 @@
---
import { Icon } from 'astro-icon/components'
import { SOURCE_CODE_URL, I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
import { SOURCE_CODE_URL } from 'astro:env/client'
import { I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
import { cn } from '../lib/cn'

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

@@ -14,10 +14,11 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' |
value: string
disabled?: boolean
}[]
selectProps?: Omit<HTMLAttributes<'select'>, 'name'>
selectProps?: Omit<HTMLAttributes<'select'>, 'name' | 'value'>
selectedValue?: string[] | string
}
const { options, selectProps, ...wrapperProps } = Astro.props
const { options, selectProps, selectedValue, ...wrapperProps } = Astro.props
const inputId = selectProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
@@ -39,7 +40,15 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
>
{
options.map((option) => (
<option value={option.value} disabled={option.disabled}>
<option
value={option.value}
disabled={option.disabled}
selected={
Array.isArray(selectedValue)
? selectedValue.includes(option.value)
: selectedValue === option.value
}
>
{option.label}
</option>
))

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

@@ -0,0 +1,151 @@
---
import favicon from '../../public/favicon.svg'
import logoMiniFull from '../assets/logo/logo-mini-full.svg'
import logoNormal from '../assets/logo/logo-normal.svg'
import logoSmall from '../assets/logo/logo-small.svg'
import reviewBadgeLongBlack from '../assets/review-badge/long-black.svg'
import reviewBadgeLongWhite from '../assets/review-badge/long-white.svg'
import reviewBadgeShortBlack from '../assets/review-badge/short-black.svg'
import reviewBadgeShortWhite from '../assets/review-badge/short-white.svg'
import Button from './Button.astro'
import MyPicture from './MyPicture.astro'
const categories: {
title: string
assets: {
name: string
path: typeof logoNormal
alt: string
}[]
}[] = [
{
title: 'Logos',
assets: [
{
name: 'Logo',
path: logoNormal,
alt: 'KYCnot.me logo normal version',
},
{
name: 'Logo small',
path: logoSmall,
alt: 'KYCnot.me logo small version',
},
{
name: 'Logo mini',
path: logoMiniFull,
alt: 'KYCnot.me logo mini version',
},
{
name: 'Logo icon',
path: favicon,
alt: 'KYCnot.me logo icon version',
},
],
},
{
title: 'Review badges',
assets: [
{
name: 'Review badge (long black)',
path: reviewBadgeLongBlack,
alt: 'KYCnot.me review badge long black version',
},
{
name: 'Review badge (long white)',
path: reviewBadgeLongWhite,
alt: 'KYCnot.me review badge long white version',
},
{
name: 'Review badge (short black)',
path: reviewBadgeShortBlack,
alt: 'KYCnot.me review badge short black version',
},
{
name: 'Review badge (short white)',
path: reviewBadgeShortWhite,
alt: 'KYCnot.me review badge short white version',
},
],
},
]
---
<div class="not-prose mb-16 space-y-8">
{
categories.map((category) => {
return (
<>
<h3 class="font-title mb-2 text-center text-xl font-semibold text-white">{category.title}</h3>
<ul class="xs:grid-cols-2 grid grid-cols-1 gap-6">
{category.assets.map((asset) => (
<li>
<div
class="bg-transparency-grid mx-auto flex aspect-[3/1] max-w-sm items-center justify-center rounded-lg p-4"
style={{
'--transparency-grid-color-1': 'var(--color-night-600)',
'--transparency-grid-color-2': 'var(--color-night-500)',
'--transparency-grid-size': 'calc(var(--spacing) * 4)',
}}
>
<MyPicture
src={asset.path}
alt={asset.alt}
pictureAttributes={{
class: 'contents',
}}
class="max-h-full min-h-8 max-w-full min-w-8 object-contain"
/>
</div>
<div class="mt-2 text-center">
<Button
as="a"
href={asset.path.src}
download={asset.name}
label={asset.name}
size="sm"
color="white"
variant="faded"
icon="ri:download-line"
/>
</div>
</li>
))}
</ul>
</>
)
})
}
</div>
<style>
.bg-transparency-grid {
--transparency-grid-color-1: #fff;
--transparency-grid-color-2: #ccc;
--transparency-grid-size: calc(var(--spacing) * 8);
background-color: var(--transparency-grid-color-1);
background-image:
linear-gradient(
45deg,
var(--transparency-grid-color-2) 25%,
transparent 25%,
transparent 75%,
var(--transparency-grid-color-2) 75%,
var(--transparency-grid-color-2)
),
linear-gradient(
45deg,
var(--transparency-grid-color-2) 25%,
transparent 25%,
transparent 75%,
var(--transparency-grid-color-2) 75%,
var(--transparency-grid-color-2)
);
background-size: var(--transparency-grid-size) var(--transparency-grid-size);
background-position:
0 0,
calc(var(--transparency-grid-size) / 2) calc(var(--transparency-grid-size) / 2);
}
</style>

View File

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

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

@@ -4,13 +4,7 @@
<script>
import { registerSW } from 'virtual:pwa-register'
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Window {
__SW_REGISTRATION__?: ServiceWorkerRegistration
}
}
import { unsubscribeFromPushNotifications } from '../lib/client/clientPushNotifications'
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
@@ -40,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)
}
@@ -51,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
---
{
@@ -66,10 +61,10 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
</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
@@ -86,7 +81,7 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
Basic checks passed, but not fully verified.
<a
href="/about#suggestion-review-process"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
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

@@ -108,3 +108,19 @@ export const {
},
] as const satisfies AttributeTypeInfo<AttributeType>[]
)
export const baseScoreType = {
value: 'BASE_SCORE',
slug: 'base-score',
label: 'Base score',
icon: 'ri:information-line',
order: 5,
classNames: {
container: 'bg-night-500',
subcontainer: '',
text: 'text-day-200',
textLight: '',
icon: '',
button: '',
},
} as const satisfies AttributeTypeInfo

View File

@@ -106,7 +106,7 @@ export const {
type: 'matrix',
label: 'Matrix',
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
formatter: ([, value]) => value ?? 'Matrix',
icon: 'ri:hashtag',
urlType: 'url',
},
@@ -121,7 +121,7 @@ export const {
{
type: 'simplex',
label: 'SimpleX Chat',
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
matcher: /^https?:\/\/(?:www\.)?((?:simplex\.chat|smp\d+\.simplex\.im))\//,
formatter: () => 'SimpleX Chat',
icon: 'simplex',
urlType: 'url',

View File

@@ -11,6 +11,7 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
description: string
classNames: {
dot: string
banner?: string
}
icon: string
color: TailwindColor
@@ -34,6 +35,7 @@ export const {
description: '',
classNames: {
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
banner: 'bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800/60 focus-visible:bg-zinc-800/60',
},
icon: 'ri:question-fill',
color: 'gray',
@@ -48,6 +50,7 @@ export const {
description: 'Potential issues that users should be aware of',
classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
banner: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60',
},
icon: 'ri:alert-fill',
color: 'yellow',
@@ -61,6 +64,7 @@ export const {
description: 'A previously reported warning has been solved',
classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
banner: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60',
},
icon: 'ri:alert-fill',
color: 'green',
@@ -74,6 +78,7 @@ export const {
description: 'Critical issues affecting service functionality',
classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50',
banner: 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60',
},
icon: 'ri:spam-fill',
color: 'red',
@@ -87,6 +92,7 @@ export const {
description: 'A previously reported alert has been solved',
classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50',
banner: 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60',
},
icon: 'ri:spam-fill',
color: 'green',
@@ -100,6 +106,7 @@ export const {
description: 'General information about the service',
classNames: {
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
banner: 'bg-blue-900/50 text-blue-300 hover:bg-blue-800/60 focus-visible:bg-blue-800/60',
},
icon: 'ri:information-fill',
color: 'sky',
@@ -113,6 +120,7 @@ export const {
description: 'Regular service update or announcement',
classNames: {
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
banner: 'bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800/60 focus-visible:bg-zinc-800/60',
},
icon: 'ri:notification-fill',
color: 'green',
@@ -126,6 +134,7 @@ export const {
description: 'Service details were updated on kycnot.me',
classNames: {
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
banner: 'bg-sky-900/50 text-sky-300 hover:bg-sky-800/60 focus-visible:bg-sky-800/60',
},
icon: 'ri:pencil-fill',
color: 'sky',

View File

@@ -1,13 +1,16 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { KycLevelClarification } from '@prisma/client'
import type { AttributeType, KycLevelClarification } from '@prisma/client'
type KycLevelClarificationInfo<T extends string | null | undefined = string> = {
value: T
slug: string
label: string
description: string
icon: string
privacyPoints: number
attributeType: AttributeType
}
export const {
@@ -18,22 +21,31 @@ export const {
'value',
(value): KycLevelClarificationInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase().replace('_', '-') : '',
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
description: '',
icon: 'ri:question-line',
privacyPoints: 0,
attributeType: 'INFO',
}),
[
{
value: 'NONE',
slug: 'none',
label: 'None',
description: 'No clarification needed.',
icon: 'ri:file-copy-line',
privacyPoints: 0,
attributeType: 'INFO',
},
{
value: 'DEPENDS_ON_PARTNERS',
slug: 'depends-on-partners',
label: 'Depends on partners',
description: 'May vary across partners.',
icon: 'ri:share-forward-line',
privacyPoints: -5,
attributeType: 'WARNING',
},
] as const satisfies KycLevelClarificationInfo<KycLevelClarification>[]
)

View File

@@ -2,12 +2,16 @@ import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { parseIntWithFallback } from '../lib/numbers'
import { transformCase } from '../lib/strings'
import type { AttributeType } from '@prisma/client'
type KycLevelInfo<T extends string | null | undefined = string> = {
id: T
value: number
icon: string
name: string
description: string
privacyPoints: number
attributeType: AttributeType
}
export const {
@@ -22,6 +26,8 @@ export const {
icon: 'diamond-question',
name: `KYC ${id ? transformCase(id, 'title') : String(id)}`,
description: '',
privacyPoints: 0,
attributeType: 'INFO',
}),
[
{
@@ -30,6 +36,8 @@ export const {
icon: 'anonymous-mask',
name: 'Guaranteed no KYC',
description: 'Terms explicitly state KYC will never be requested.',
privacyPoints: 25,
attributeType: 'GOOD',
},
{
id: '1',
@@ -37,6 +45,8 @@ export const {
icon: 'diamond-question',
name: 'No KYC mention',
description: 'No mention of current or future KYC requirements.',
privacyPoints: 15,
attributeType: 'GOOD',
},
{
id: '2',
@@ -45,6 +55,8 @@ export const {
name: 'KYC on authorities request',
description:
'No routine KYC, but may cooperate with authorities, block funds or implement future KYC requirements.',
privacyPoints: -5,
attributeType: 'WARNING',
},
{
id: '3',
@@ -52,6 +64,8 @@ export const {
icon: 'gun',
name: 'Shotgun KYC',
description: 'May request KYC and block funds based on automated triggers.',
privacyPoints: -15,
attributeType: 'WARNING',
},
{
id: '4',
@@ -59,6 +73,8 @@ export const {
icon: 'fingerprint-detailed',
name: 'Mandatory KYC',
description: 'Required for key features and can be required arbitrarily at any time.',
privacyPoints: -25,
attributeType: 'BAD',
},
] as const satisfies KycLevelInfo<'0' | '1' | '2' | '3' | '4'>[]
)

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',

1
web/src/env.d.ts vendored
View File

@@ -17,6 +17,7 @@ declare global {
interface Window {
htmx?: typeof htmx
__SW_REGISTRATION__?: ServiceWorkerRegistration
}
namespace PrismaJson {

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,5 +1,6 @@
---
import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../components/OgImage'
import TimeFormatted from '../components/TimeFormatted.astro'
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
import BaseLayout from './BaseLayout.astro'
@@ -13,21 +14,19 @@ type Props = ComponentProps<typeof BaseLayout> &
MarkdownLayoutProps<{
children: AstroChildren
title: string
author: string
pubDate: string
updatedAt?: string
description: string
icon?: string
}>
const { frontmatter, schemas, ...baseLayoutProps } = Astro.props
const publishDate = frontmatter.pubDate ? new Date(frontmatter.pubDate) : null
const publishDate = frontmatter.updatedAt ? new Date(frontmatter.updatedAt) : null
const ogImageTemplateData = {
template: 'generic',
title: frontmatter.title,
description: frontmatter.description,
icon: frontmatter.icon,
} satisfies OgImageAllTemplatesWithProps
const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
---
<BaseLayout
@@ -44,14 +43,7 @@ const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
datePublished: publishDate?.toISOString(),
dateModified: publishDate?.toISOString(),
image: makeOgImageUrl(ogImageTemplateData, Astro.url),
author: frontmatter.author
? weAreAuthor
? KYCNOTME_SCHEMA_MINI
: {
'@type': 'Person',
name: frontmatter.author,
}
: undefined,
author: KYCNOTME_SCHEMA_MINI,
publisher: KYCNOTME_SCHEMA_MINI,
mainEntityOfPage: {
'@type': 'WebPage',
@@ -61,15 +53,26 @@ const weAreAuthor = frontmatter.author.toLowerCase().trim() === 'kycnot.me'
...(schemas ?? []),
]}
>
<div class="bg-dots-fade absolute inset-x-0 top-0 -z-1 h-128 opacity-15"></div>
<div
class="prose prose-invert prose-headings:text-green-400 prose-h1:text-[2.5rem] prose-h1:font-bold prose-h1:my-8 prose-h1:drop-shadow-[0_0_10px_rgba(0,255,0,0.3)] prose-h2:text-green-500 prose-h2:text-[1.8rem] prose-h2:font-semibold prose-h2:my-6 prose-h2:border-b prose-h2:border-green-900 prose-h2:pb-1 prose-h3:text-green-600 prose-h3:text-[1.4rem] prose-h3:font-semibold prose-h3:my-4 prose-h4:text-green-700 prose-h4:text-[1.2rem] prose-h4:font-semibold prose-h4:my-3 prose-strong:font-semibold prose-strong:drop-shadow-[0_0_5px_rgba(0,255,0,0.2)] prose-p:text-gray-300 prose-p:my-4 prose-p:leading-relaxed prose-a:text-green-400 prose-a:no-underline prose-a:transition-all prose-a:border-b prose-a:border-green-900 prose-a:hover:text-green-400 prose-a:hover:drop-shadow-[0_0_8px_rgba(0,255,0,0.4)] prose-a:hover:border-green-400 prose-ul:text-gray-300 prose-ol:text-gray-300 prose-li:my-2 prose-li:leading-relaxed mx-auto"
>
<h1>{frontmatter.title}</h1>
<p class="text-gray-500">
{frontmatter.author && `by ${frontmatter.author}`}
{frontmatter.pubDate && ` | ${new Date(frontmatter.pubDate).toLocaleDateString()}`}
<h1 class="mb-0!">{frontmatter.title}</h1>
<p class="mt-2! opacity-70">
Updated {frontmatter.updatedAt && <TimeFormatted date={new Date(frontmatter.updatedAt)} />}
</p>
<slot />
</div>
</BaseLayout>
<style>
.bg-dots-fade {
background:
radial-gradient(closest-side, #777, #fff) 0/ 1em 1em space,
linear-gradient(to bottom, #888, #fff);
background-blend-mode: multiply;
mix-blend-mode: multiply;
filter: contrast(21);
}
</style>

View File

@@ -2,10 +2,12 @@ import { orderBy } from 'lodash-es'
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { kycLevelClarifications } from '../constants/kycLevelClarifications'
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'
@@ -27,26 +29,30 @@ type NonDbAttribute = Prisma.AttributeGetPayload<{
}[]
}
export const nonDbAttributes: (NonDbAttribute & {
type NonDbAttributeFull = NonDbAttribute & {
customize: (
service: Prisma.ServiceGetPayload<{
select: {
verificationStatus: true
serviceVisibility: true
isRecentlyListed: true
listedAt: true
isRecentlyApproved: true
approvedAt: true
createdAt: true
tosReviewAt: true
tosReview: true
onionUrls: true
i2pUrls: true
acceptedCurrencies: true
kycLevel: true
kycLevelClarification: true
}
}>
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
show: boolean
}
})[] = [
}
export const nonDbAttributes: NonDbAttributeFull[] = [
{
slug: 'verification-verified',
title: 'Verified',
@@ -135,6 +141,40 @@ export const nonDbAttributes: (NonDbAttribute & {
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
}),
},
...kycLevels.map<NonDbAttributeFull>((kycLevel) => ({
slug: `kyc-level-${kycLevel.id}`,
title: kycLevel.name,
type: kycLevel.attributeType,
category: 'PRIVACY',
description: kycLevel.description,
privacyPoints: kycLevel.privacyPoints,
trustPoints: 0,
links: [
{
url: `/?max-kyc=${kycLevel.id}`,
label: 'With this or better',
icon: 'ri:search-line',
},
],
customize: (service) => ({
show: service.kycLevel === kycLevel.value,
}),
})),
...kycLevelClarifications
.filter((clarification) => clarification.value !== 'NONE')
.map<NonDbAttributeFull>((clarification) => ({
slug: `kyc-clarification-${clarification.slug}`,
title: `KYC ${clarification.label}`,
type: clarification.attributeType,
category: 'PRIVACY',
description: clarification.description,
privacyPoints: clarification.privacyPoints,
trustPoints: 0,
links: [],
customize: (service) => ({
show: service.kycLevelClarification === clarification.value,
}),
})),
{
slug: 'archived',
title: serviceVisibilitiesById.ARCHIVED.label,
@@ -149,17 +189,17 @@ export const nonDbAttributes: (NonDbAttribute & {
}),
},
{
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.`,
}),
},
{
@@ -177,41 +217,22 @@ export const nonDbAttributes: (NonDbAttribute & {
}),
},
{
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,
}),
},
{
@@ -261,20 +282,7 @@ export function sortAttributes<
}
export function makeNonDbAttributes(
service: Prisma.ServiceGetPayload<{
select: {
verificationStatus: true
serviceVisibility: true
isRecentlyListed: true
listedAt: true
createdAt: true
tosReviewAt: true
tosReview: true
onionUrls: true
i2pUrls: true
acceptedCurrencies: true
}
}>,
service: Parameters<NonDbAttributeFull['customize']>[0],
{ filter = false }: { filter?: boolean } = {}
) {
const attributes = nonDbAttributes.map(({ customize, ...attribute }) => ({

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

@@ -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

@@ -1,8 +1,7 @@
---
layout: ../layouts/MarkdownLayout.astro
title: About
author: KYCnot.me
pubDate: 2025-05-15
updatedAt: 2025-05-15
description: 'Learn how KYCnot.me website works and about our mission to protect privacy in cryptocurrency.'
icon: 'ri:information-line'
---
@@ -153,15 +152,6 @@ Scores are calculated **automatically** using clear, fixed rules. We do not chan
The privacy score measures how well a service protects user privacy, using a transparent, rules-based approach:
1. **Base Score:** Every service starts with a neutral score of 50 points.
1. **KYC Level:** Adjusts the score based on the level of identity verification required:
- KYC Level 0 (No KYC): **+25 points**
- KYC Level 1 (Minimal KYC): **+10 points**
- KYC Level 2 (Moderate KYC): **-5 points**
- KYC Level 3 (More KYC): **-15 points**
- KYC Level 4 (Full mandatory KYC): **-25 points**
1. **Onion URL:** **+5 points** if the service offers at least one Onion (Tor) URL.
1. **I2P URL:** **+5 points** if the service offers at least one I2P URL.
1. **Monero Acceptance:** **+5 points** if the service accepts Monero as a payment method.
1. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes).
1. **Final Score Range:** The final score is always kept between 0 and 100.
@@ -170,19 +160,12 @@ The privacy score measures how well a service protects user privacy, using a tra
The trust score represents how reliable and trustworthy a service is, based on objective, transparent criteria.
1. **Base Score:** Every service begins with a neutral score of 50 points.
1. **Verification Status:**
- **Verification Success:** +10 points
- **Approved:** +5 points
- **Community Contributed:** 0 points
- **Verification Failed (SCAM):** -50 points
1. **Recently Listed:** If a service was listed within the last 15 days and its status is `APPROVED`, a penalty of -10 points is applied to the trust score, and the service is flagged as recently listed.
1. **Can't Analyze ToS:** If a service's Terms of Service cannot be analyzed by our AI (usually due to captchas, client-side rendering, DDoS protections, or non-text format), a penalty of -3 points is applied to the trust score.
1. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. [See all attributes](/attributes).
1. **Final Score Range:** The final score is always kept between 0 and 100.
##### 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
@@ -239,6 +222,10 @@ You can contact via direct 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
For logos and brand assets, visit our [downloads page](/downloads).
## Disclaimer
This website is strictly for informational purposes regarding privacy technology in the cryptocurrency space. We unequivocally condemn and do not endorse, support, or facilitate money laundering, terrorist financing, or any other illegal financial activities. The use of any information or service mentioned herein for such purposes is strictly prohibited and contrary to the core principles of this project.

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>

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

@@ -173,7 +173,7 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
label: status.label,
value: status.value,
}))}
selectProps={{ value: serviceSuggestion.status }}
selectedValue={serviceSuggestion.status}
class="flex-1"
error={serviceSuggestionUpdateInputErrors.status}
/>

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'
@@ -441,7 +441,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 +457,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 +473,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 +488,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 +528,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}
@@ -801,7 +796,8 @@ const apiCalls = await Astro.locals.banners.try(
label: type.label,
value: type.id,
}))}
selectProps={{ required: true, value: event.type }}
selectedValue={event.type}
selectProps={{ required: true }}
error={eventUpdateInputErrors.type}
/>
</div>
@@ -982,7 +978,7 @@ const apiCalls = await Astro.locals.banners.try(
label: status.label,
value: status.value,
}))}
selectProps={{ value: step.status }}
selectedValue={step.status}
error={verificationStepUpdateInputErrors.status}
/>

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"

73
web/src/pages/assets.mdx Normal file
View File

@@ -0,0 +1,73 @@
---
layout: ../layouts/MarkdownLayout.astro
title: Assets & Downloads
author: KYCnot.me
pubDate: 2025-06-11
description: 'Download KYCnot.me logos and assets.'
icon: 'ri:image-line'
---
import PressAssets from '../components/PressAssets.astro'
Please, link back to [KYCnot.me](https://kycnot.me) when possible, and use responsibly.
<PressAssets
title="KYCnot.me Brand Kit"
description="Complete collection of our logos and 'Review on KYCnot.me' badges in various formats and styles."
zipPath="/kycnotme-press-assets.zip"
assets={[
{
name: 'Logo - Normal',
path: '/logo/logo-normal.svg',
alt: 'KYCnot.me logo normal version',
category: 'logo',
},
{
name: 'Logo - Small',
path: '/logo/logo-small.svg',
alt: 'KYCnot.me logo small version',
category: 'logo',
},
{
name: 'Logo - Mini',
path: '/logo/logo-mini.svg',
alt: 'KYCnot.me logo mini version',
category: 'logo',
},
{
name: 'Logo - Mini Full',
path: '/logo/logo-mini-full.svg',
alt: 'KYCnot.me logo mini full version',
category: 'logo',
},
{
name: 'Badge - Long Black',
path: '/review-on-kycnotme/long-black.svg',
alt: 'Review on KYCnot.me badge - long black version',
category: 'badge',
},
{
name: 'Badge - Long White',
path: '/review-on-kycnotme/long-white.svg',
alt: 'Review on KYCnot.me badge - long white version',
category: 'badge',
},
{
name: 'Badge - Short Black',
path: '/review-on-kycnotme/short-black.svg',
alt: 'Review on KYCnot.me badge - short black version',
category: 'badge',
},
{
name: 'Badge - Short White',
path: '/review-on-kycnotme/short-white.svg',
alt: 'Review on KYCnot.me badge - short white version',
category: 'badge',
},
]}
/>
## Brand design
- Brand color: `#3bdb78`
- Font: [Space Grotesk](https://floriankarsten.github.io/space-grotesk/) and [Inter](https://rsms.me/inter/).

View File

@@ -1,13 +1,12 @@
---
layout: ../../layouts/MarkdownLayout.astro
title: API
author: KYCnot.me
pubDate: 2025-05-31
updatedAt: 2025-05-31
description: 'Access basic service data via our public API.'
icon: 'ri:plug-line'
---
import { SOURCE_CODE_URL } from 'astro:env/server'
import { SOURCE_CODE_URL } from 'astro:env/client'
import { kycLevels } from '../../constants/kycLevels'
import { verificationStatuses } from '../../constants/verificationStatus'
import { serviceVisibilities } from '../../constants/serviceVisibility'
@@ -53,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
@@ -146,7 +146,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,
@@ -164,7 +165,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

@@ -3,8 +3,7 @@ layout: ../../layouts/MarkdownLayout.astro
title: How does karma work?
description: "KYCnot.me has a user karma system, here's how it works"
icon: 'ri:hearts-line'
author: KYCnot.me
pubDate: 2025-05-15
updatedAt: 2025-05-15
---
import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'

View File

@@ -0,0 +1,21 @@
---
layout: ../layouts/MarkdownLayout.astro
title: Downloads and assets
author: KYCnot.me
updatedAt: 2025-06-12
description: 'Download KYCnot.me logos and assets.'
icon: 'ri:image-line'
---
import PressAssets from '../components/PressAssets.astro'
Please, link back to [KYCnot.me](https://kycnot.me) when possible, and use responsibly.
<PressAssets />
Review service link format: `https://kycnot.me/service/[slug]/review`
## Brand design
- Brand color: `#3bdb78`
- Font: [Space Grotesk](https://floriankarsten.github.io/space-grotesk/) and [Inter](https://rsms.me/inter/).

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

@@ -27,7 +27,7 @@ import Tooltip from '../../components/Tooltip.astro'
import UserBadge from '../../components/UserBadge.astro'
import VerificationWarningBanner from '../../components/VerificationWarningBanner.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { baseScoreType, getAttributeTypeInfo } from '../../constants/attributeTypes'
import { formatContactMethod } from '../../constants/contactMethods'
import { currencies, getCurrencyInfo } from '../../constants/currencies'
import { getEventTypeInfo } from '../../constants/eventTypes'
@@ -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,6 +92,7 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
referral: true,
imageUrl: true,
listedAt: true,
approvedAt: true,
createdAt: true,
acceptedCurrencies: true,
tosReview: true,
@@ -100,7 +100,7 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
userSentiment: true,
userSentimentAt: true,
averageUserRating: true,
isRecentlyListed: true,
isRecentlyApproved: true,
contactMethods: {
select: {
value: true,
@@ -230,7 +230,6 @@ if (!service) {
where: {
previousSlugs: { has: slug },
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
listedAt: { lte: new Date() },
},
select: { slug: true },
})
@@ -294,6 +293,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 +386,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',
@@ -407,9 +415,12 @@ const ogImageTemplateData = {
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
const activeAlertOrWarningEvents = service.events.filter(
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
)
const activeAlertOrWarningEvents = service.events
.map((event) => ({
...event,
typeInfo: getEventTypeInfo(event.type),
}))
.filter((event) => event.typeInfo.showBanner && (event.endedAt === null || event.endedAt >= now))
const activeEventToShow =
activeAlertOrWarningEvents.find((event) => event.type === EventType.ALERT) ?? activeAlertOrWarningEvents[0]
---
@@ -518,15 +529,10 @@ const activeEventToShow =
href="#events"
class={cn(
'group mb-4 block rounded-md px-3 py-2 text-sm transition-colors duration-200',
activeEventToShow.type === EventType.ALERT
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60'
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60'
activeEventToShow.typeInfo.classNames.banner
)}
>
<Icon
name={activeEventToShow.type === EventType.ALERT ? 'ri:alert-fill' : 'ri:alarm-warning-fill'}
class="me-1.5 inline-block size-4 align-[-0.15em]"
/>
<Icon name={activeEventToShow.typeInfo.icon} class="me-1.5 inline-block size-4 align-[-0.15em]" />
<span class="font-bold">{activeEventToShow.title}</span> — {activeEventToShow.content}
{activeAlertOrWarningEvents.length >= 2 && <>+{activeAlertOrWarningEvents.length - 1} more events.</>}
<span class="underline">Go to events</span>
@@ -758,11 +764,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>
))}
@@ -786,11 +799,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>
@@ -984,15 +1004,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
)}
/>
@@ -1052,10 +1080,24 @@ const activeEventToShow =
</li>
)
})}
<li
class={cn(
'bg-night-400 flex items-center self-start rounded-md p-2 text-sm text-pretty select-none',
baseScoreType.classNames.container,
baseScoreType.classNames.text
)}
>
<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 (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
href="/about#service-scores"
@@ -1069,7 +1111,7 @@ const activeEventToShow =
class="text-day-400 hover:text-day-200 inline-flex items-center gap-1 transition-colors hover:underline"
>
<Icon name="ri:information-line" class="size-3" />
Attributes list
All attributes list
</a>
</div>
@@ -1180,15 +1222,15 @@ const activeEventToShow =
<p class="text-day-500 mt-1 text-sm text-balance">
Maybe due to captchas, client side rendering, DDoS protections, or non-text format.
</p>
{service.tosUrls.length > 0 && (
<p class="mt-2 text-xs">
{service.tosUrls.map((url) => (
<a href={url} class="hover:underline">
{urlDomain(url)}
</a>
))}
</p>
)}
<p class="mt-2 text-xs">
Reviewed <TimeFormatted date={service.tosReviewAt} hourPrecision />
{service.tosUrls.length > 0 && 'from'}
{service.tosUrls.map((url) => (
<a href={url} class="hover:underline">
{urlDomain(url)}
</a>
))}
</p>
</div>
)
) : (
@@ -1459,6 +1501,7 @@ const activeEventToShow =
Comments
</h2>
<div
id="discuss"
class='grid grid-cols-1 gap-8 [grid-template-areas:"about""rating""ai"] sm:grid-cols-[1fr_1fr] sm:gap-4 sm:[grid-template-areas:"about_ai""rating_ai"]'
>
<div class="relative rounded-md bg-orange-400/10 p-2 px-2.5 text-xs text-orange-100/70 [grid-area:about]">

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'],
},
@@ -511,7 +509,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>
@@ -975,12 +973,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</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>

View File

@@ -103,10 +103,6 @@
drop-shadow(0 0 4px color-mix(in oklab, currentColor 60%, transparent));
}
@utility scrollbar-w-none {
scrollbar-width: none;
}
@utility checkbox-force-checked {
&:not(:checked) {
@apply border-transparent! bg-current/50!;

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)
)
}