Compare commits

...

3 Commits

Author SHA1 Message Date
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
14 changed files with 150 additions and 60 deletions

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

@@ -173,12 +173,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

@@ -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,7 +150,7 @@ 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 scrollbar-w-none -mt-10 flex items-center gap-2 overflow-auto pt-10 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>
@@ -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

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

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

@@ -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'
@@ -1052,6 +1052,20 @@ 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>
)
}
@@ -1459,6 +1473,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]">