Compare commits

...

3 Commits

Author SHA1 Message Date
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
17 changed files with 167 additions and 101 deletions

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

@@ -107,6 +107,7 @@ export default defineConfig({
'/service/[...slug]/proof': '/service/[...slug]/#verification',
'/attribute/[...slug]': '/attributes',
'/attr/[...slug]': '/attributes',
'/service/[...slug]/review': '/service/[...slug]#comments',
// #endregion
},
env: {

View File

@@ -1143,7 +1143,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
})

View File

@@ -150,13 +150,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"
@@ -179,7 +179,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 +240,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

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

@@ -5,13 +5,6 @@
<script>
import { registerSW } from 'virtual:pwa-register'
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Window {
__SW_REGISTRATION__?: ServiceWorkerRegistration
}
}
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
let hasPendingUpdate = false

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

@@ -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'>[]
)

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

@@ -2,6 +2,8 @@ import { orderBy } from 'lodash-es'
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { getKycLevelClarificationInfo } from '../constants/kycLevelClarifications'
import { kycLevels } from '../constants/kycLevels'
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
@@ -27,7 +29,7 @@ type NonDbAttribute = Prisma.AttributeGetPayload<{
}[]
}
export const nonDbAttributes: (NonDbAttribute & {
type NonDbAttributeFull = NonDbAttribute & {
customize: (
service: Prisma.ServiceGetPayload<{
select: {
@@ -41,12 +43,16 @@ export const nonDbAttributes: (NonDbAttribute & {
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,31 @@ 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) => {
const clarification = getKycLevelClarificationInfo(service.kycLevelClarification)
return {
show: service.kycLevel === kycLevel.value,
title: kycLevel.name + (clarification.value !== 'NONE' ? ` (${clarification.label})` : ''),
description:
kycLevel.description + (clarification.value !== 'NONE' ? ` ${clarification.description}` : ''),
}
},
})),
{
slug: 'archived',
title: serviceVisibilitiesById.ARCHIVED.label,
@@ -261,20 +292,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

@@ -153,15 +153,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,13 +161,6 @@ 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.

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

@@ -801,7 +801,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 +983,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

@@ -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'
@@ -407,9 +407,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 +521,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>
@@ -1052,10 +1050,27 @@ 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
)}
>
<Icon
name={baseScoreType.icon}
class={cn('mr-2 size-4 flex-shrink-0', baseScoreType.classNames.icon)}
/>
<span class="font-title">{baseScoreType.label}</span>
<span class={cn('mr-1 ml-auto', baseScoreType.classNames.icon)}>+50</span>
</li>
</ul>
)
}
<p class="text-day-400 mt-3 text-center text-xs">
<span class="hover:text-day-200 transition-colors">Overall = 60% Privacy + 40% Trust (Rounded)</span>
</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 +1084,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 +1195,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 +1474,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

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