Release 202506101742

This commit is contained in:
pluja
2025-06-10 17:42:42 +00:00
parent 459d7c91f7
commit 812937d2c7
50 changed files with 1347 additions and 335 deletions

View File

@@ -9,6 +9,233 @@ import { formatDateShort } from './timeAgo'
import type { Prisma } from '@prisma/client'
type NonDbAttribute = Prisma.AttributeGetPayload<{
select: {
title: true
type: true
category: true
description: true
privacyPoints: true
trustPoints: true
}
}> & {
slug: string
links: {
url: string
label: string
icon: string
}[]
}
export const nonDbAttributes: (NonDbAttribute & {
customize: (
service: Prisma.ServiceGetPayload<{
select: {
verificationStatus: true
serviceVisibility: true
isRecentlyListed: true
listedAt: true
createdAt: true
tosReviewAt: true
tosReview: true
onionUrls: true
i2pUrls: true
acceptedCurrencies: true
}
}>
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
show: boolean
}
})[] = [
{
slug: 'verification-verified',
title: 'Verified',
type: 'GOOD',
category: 'TRUST',
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}`,
privacyPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.privacyPoints,
trustPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.trustPoints,
links: [
{
url: '/?verification=verified',
label: 'Search with this',
icon: 'ri:search-line',
},
],
customize: (service) => ({
show: service.verificationStatus === 'VERIFICATION_SUCCESS',
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
}),
},
{
slug: 'verification-approved',
title: 'Approved',
type: 'INFO',
category: 'TRUST',
description: `${verificationStatusesByValue.APPROVED.description} ${READ_MORE_SENTENCE_LINK}`,
privacyPoints: verificationStatusesByValue.APPROVED.privacyPoints,
trustPoints: verificationStatusesByValue.APPROVED.trustPoints,
links: [
{
url: '/?verification=verified&verification=approved',
label: 'Search with this',
icon: 'ri:search-line',
},
],
customize: (service) => ({
show: service.verificationStatus === 'APPROVED',
}),
},
{
slug: 'verification-community-contributed',
title: 'Community contributed',
type: 'WARNING',
category: 'TRUST',
description: `${verificationStatusesByValue.COMMUNITY_CONTRIBUTED.description} ${READ_MORE_SENTENCE_LINK}`,
privacyPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.privacyPoints,
trustPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.trustPoints,
links: [
{
url: '/?verification=community',
label: 'With this',
icon: 'ri:search-line',
},
{
url: '/?verification=verified&verification=approved',
label: 'Without this',
icon: 'ri:search-line',
},
],
customize: (service) => ({
show: service.verificationStatus === 'COMMUNITY_CONTRIBUTED',
}),
},
{
slug: 'verification-scam',
title: 'Is SCAM',
type: 'BAD',
category: 'TRUST',
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}`,
privacyPoints: verificationStatusesByValue.VERIFICATION_FAILED.privacyPoints,
trustPoints: verificationStatusesByValue.VERIFICATION_FAILED.trustPoints,
links: [
{
url: '/?verification=scam',
label: 'With this',
icon: 'ri:search-line',
},
{
url: '/?verification=verified&verification=approved',
label: 'Without this',
icon: 'ri:search-line',
},
],
customize: (service) => ({
show: service.verificationStatus === 'VERIFICATION_FAILED',
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
}),
},
{
slug: 'archived',
title: serviceVisibilitiesById.ARCHIVED.label,
type: 'WARNING',
category: 'TRUST',
description: serviceVisibilitiesById.ARCHIVED.longDescription,
privacyPoints: 0,
trustPoints: 0,
links: [],
customize: (service) => ({
show: service.serviceVisibility === 'ARCHIVED',
}),
},
{
slug: 'recently-listed',
title: 'Recently listed',
type: 'WARNING',
category: 'TRUST',
description: 'Listed 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.`,
}),
},
{
slug: 'cannot-analyse-tos',
title: "Can't analyse ToS",
type: 'WARNING',
category: 'TRUST',
description:
'The Terms of Service page is not analyable by our AI. Possible reasons may be: captchas, client side rendering, DDoS protections, or non-text format.',
privacyPoints: 0,
trustPoints: -3,
links: [],
customize: (service) => ({
show: service.tosReviewAt !== null && service.tosReview === null,
}),
},
{
slug: 'has-onion-urls',
title: 'Has Onion URLs',
type: 'GOOD',
category: 'PRIVACY',
description: 'Onion (Tor) URLs enhance privacy and anonymity.',
privacyPoints: 5,
trustPoints: 0,
links: [
{
url: '/?onion=true',
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,
}),
},
{
slug: 'monero-accepted',
title: 'Accepts Monero',
type: 'GOOD',
category: 'PRIVACY',
description:
'This service accepts Monero, a privacy-focused cryptocurrency that provides enhanced anonymity.',
privacyPoints: 5,
trustPoints: 0,
links: [
{
url: '/?currency=monero',
label: 'Search with this',
icon: 'ri:search-line',
},
],
customize: (service) => ({
show: service.acceptedCurrencies.includes('MONERO'),
}),
},
]
export function sortAttributes<
T extends Prisma.AttributeGetPayload<{
select: {
@@ -43,135 +270,19 @@ export function makeNonDbAttributes(
createdAt: true
tosReviewAt: true
tosReview: true
onionUrls: true
i2pUrls: true
acceptedCurrencies: true
}
}>,
{ filter = false }: { filter?: boolean } = {}
) {
const nonDbAttributes: (Prisma.AttributeGetPayload<{
select: {
title: true
type: true
category: true
description: true
privacyPoints: true
trustPoints: true
}
}> & {
show: boolean
links: {
url: string
label: string
icon: string
}[]
})[] = [
{
title: 'Verified',
show: service.verificationStatus === 'VERIFICATION_SUCCESS',
type: 'GOOD',
category: 'TRUST',
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
privacyPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.privacyPoints,
trustPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.trustPoints,
links: [
{
url: '/?verification=verified',
label: 'Search with this',
icon: 'ri:search-line',
},
],
},
{
title: 'Approved',
show: service.verificationStatus === 'APPROVED',
type: 'INFO',
category: 'TRUST',
description: `${verificationStatusesByValue.APPROVED.description} ${READ_MORE_SENTENCE_LINK}`,
privacyPoints: verificationStatusesByValue.APPROVED.privacyPoints,
trustPoints: verificationStatusesByValue.APPROVED.trustPoints,
links: [
{
url: '/?verification=verified&verification=approved',
label: 'Search with this',
icon: 'ri:search-line',
},
],
},
{
title: 'Community contributed',
show: service.verificationStatus === 'COMMUNITY_CONTRIBUTED',
type: 'WARNING',
category: 'TRUST',
description: `${verificationStatusesByValue.COMMUNITY_CONTRIBUTED.description} ${READ_MORE_SENTENCE_LINK}`,
privacyPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.privacyPoints,
trustPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.trustPoints,
links: [
{
url: '/?verification=community',
label: 'With this',
icon: 'ri:search-line',
},
{
url: '/?verification=verified&verification=approved',
label: 'Without this',
icon: 'ri:search-line',
},
],
},
{
title: 'Is SCAM',
show: service.verificationStatus === 'VERIFICATION_FAILED',
type: 'BAD',
category: 'TRUST',
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
privacyPoints: verificationStatusesByValue.VERIFICATION_FAILED.privacyPoints,
trustPoints: verificationStatusesByValue.VERIFICATION_FAILED.trustPoints,
links: [
{
url: '/?verification=scam',
label: 'With this',
icon: 'ri:search-line',
},
{
url: '/?verification=verified&verification=approved',
label: 'Without this',
icon: 'ri:search-line',
},
],
},
{
title: serviceVisibilitiesById.ARCHIVED.label,
show: service.serviceVisibility === 'ARCHIVED',
type: 'WARNING',
category: 'TRUST',
description: serviceVisibilitiesById.ARCHIVED.longDescription,
privacyPoints: 0,
trustPoints: 0,
links: [],
},
{
title: 'Recently listed',
show: service.isRecentlyListed,
type: 'WARNING',
category: 'TRUST',
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
privacyPoints: 0,
trustPoints: -5,
links: [],
},
{
title: "Can't analyse ToS",
show: service.tosReviewAt !== null && service.tosReview === null,
type: 'WARNING',
category: 'TRUST',
description:
'The Terms of Service page is not analyable by our AI. Possible reasons may be: captchas, client side rendering, DDoS protections, or non-text format.',
privacyPoints: 0,
trustPoints: -3,
links: [],
},
]
const attributes = nonDbAttributes.map(({ customize, ...attribute }) => ({
...attribute,
...customize(service),
}))
if (filter) return nonDbAttributes.filter(({ show }) => show)
if (filter) return attributes.filter(({ show }) => show)
return nonDbAttributes
return attributes
}

323
web/src/lib/feeds.ts Normal file
View File

@@ -0,0 +1,323 @@
import { prisma } from './prisma'
import type { Prisma } from '@prisma/client'
type SafeResult<T> =
| {
success: false
error: { message: string; responseInit: ResponseInit }
data?: undefined
}
| {
success: true
error?: undefined
data: T
}
export const takeCounts = {
serviceComments: 100,
serviceEvents: 100,
allEvents: 100,
userNotifications: 50,
} as const satisfies Record<string, number>
const serviceSelect = {
id: true,
name: true,
slug: true,
} as const satisfies Prisma.ServiceSelect
export async function getService(slug: string | undefined): Promise<
SafeResult<{
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
}>
> {
if (!slug || typeof slug !== 'string') {
return { success: false, error: { message: 'Invalid slug', responseInit: { status: 400 } } }
}
const service =
(await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
slug,
},
select: serviceSelect,
})) ??
(await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
previousSlugs: { has: slug },
},
select: serviceSelect,
}))
if (!service) {
return { success: false, error: { message: 'Service not found', responseInit: { status: 404 } } }
}
return { success: true, data: { service } }
}
const serviceCommentSelect = {
id: true,
content: true,
rating: true,
ratingActive: true,
createdAt: true,
updatedAt: true,
author: {
select: {
name: true,
displayName: true,
verified: true,
admin: true,
moderator: true,
spammer: true,
serviceAffiliations: {
select: {
role: true,
service: { select: { name: true, slug: true } },
},
},
},
},
} as const satisfies Prisma.CommentSelect
export async function getCommentsForService(slug: string | undefined): Promise<
SafeResult<{
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
comments: Prisma.CommentGetPayload<{ select: typeof serviceCommentSelect }>[]
}>
> {
const result = await getService(slug)
if (!result.success) return result
const { service } = result.data
const comments = await prisma.comment.findMany({
where: {
serviceId: service.id,
status: { in: ['APPROVED', 'VERIFIED'] },
suspicious: false,
parentId: null, // Only root comments for the main feed
},
select: serviceCommentSelect,
orderBy: {
createdAt: 'desc',
},
take: takeCounts.serviceComments,
})
return { success: true, data: { service, comments } }
}
const eventSelect = {
id: true,
title: true,
content: true,
type: true,
startedAt: true,
endedAt: true,
source: true,
createdAt: true,
service: {
select: {
name: true,
slug: true,
},
},
} as const satisfies Prisma.EventSelect
const serviceEventSelect = {
id: true,
title: true,
content: true,
type: true,
startedAt: true,
endedAt: true,
source: true,
createdAt: true,
} as const satisfies Prisma.EventSelect
export async function getEventsForService(slug: string | undefined): Promise<
SafeResult<{
service: Prisma.ServiceGetPayload<{ select: typeof serviceSelect }>
events: Prisma.EventGetPayload<{ select: typeof serviceEventSelect }>[]
}>
> {
const result = await getService(slug)
if (!result.success) return result
const { service } = result.data
const events = await prisma.event.findMany({
where: {
serviceId: service.id,
visible: true,
},
select: serviceEventSelect,
orderBy: {
createdAt: 'desc',
},
take: takeCounts.serviceEvents,
})
return { success: true, data: { service, events } }
}
export async function getEvents(): Promise<
SafeResult<{
events: Prisma.EventGetPayload<{ select: typeof eventSelect }>[]
}>
> {
const events = await prisma.event.findMany({
where: {
visible: true,
service: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
},
},
select: eventSelect,
orderBy: {
createdAt: 'desc',
},
take: takeCounts.allEvents,
})
return { success: true, data: { events } }
}
const userSelect = {
id: true,
name: true,
displayName: true,
} as const satisfies Prisma.UserSelect
const notificationSelect = {
id: true,
type: true,
createdAt: true,
aboutAccountStatusChange: true,
aboutCommentStatusChange: true,
aboutServiceVerificationStatusChange: true,
aboutSuggestionStatusChange: true,
aboutServiceSuggestionId: true,
aboutComment: {
select: {
id: true,
content: true,
communityNote: true,
status: true,
author: {
select: {
id: true,
},
},
parent: {
select: {
author: {
select: {
id: true,
},
},
},
},
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutEvent: {
select: {
title: true,
content: true,
type: true,
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutService: {
select: {
slug: true,
name: true,
verificationStatus: true,
},
},
aboutServiceSuggestion: {
select: {
id: true,
type: true,
status: true,
service: {
select: {
name: true,
},
},
},
},
aboutServiceSuggestionMessage: {
select: {
id: true,
content: true,
suggestion: {
select: {
id: true,
service: {
select: {
name: true,
},
},
},
},
},
},
aboutKarmaTransaction: {
select: {
points: true,
action: true,
description: true,
},
},
} as const satisfies Prisma.NotificationSelect
export async function getUserNotifications(feedId: string | undefined): Promise<
SafeResult<{
user: Prisma.UserGetPayload<{ select: typeof userSelect }>
notifications: Prisma.NotificationGetPayload<{ select: typeof notificationSelect }>[]
}>
> {
if (!feedId || typeof feedId !== 'string') {
return { success: false, error: { message: 'Invalid feed ID', responseInit: { status: 400 } } }
}
const user = await prisma.user.findFirst({
where: { feedId, spammer: false },
select: userSelect,
})
if (!user) {
return { success: false, error: { message: 'User not found', responseInit: { status: 404 } } }
}
const notifications = await prisma.notification.findMany({
where: {
userId: user.id,
},
select: notificationSelect,
orderBy: {
createdAt: 'desc',
},
take: takeCounts.userNotifications,
})
return { success: true, data: { user, notifications } }
}

View File

@@ -3,6 +3,7 @@ import { commentStatusChangesById } from '../constants/commentStatusChange'
import { eventTypesById } from '../constants/eventTypes'
import { getKarmaTransactionActionInfo } from '../constants/karmaTransactionActions'
import { serviceVerificationStatusChangesById } from '../constants/serviceStatusChange'
import { getServiceSuggestionTypeInfo } from '../constants/serviceSuggestionType'
import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatusChange'
import { makeCommentUrl } from './commentsWithReplies'
@@ -48,6 +49,7 @@ export function makeNotificationTitle(
aboutServiceSuggestion: {
select: {
status: true
type: true
service: {
select: {
name: true
@@ -131,6 +133,12 @@ export function makeNotificationTitle(
? `New unmoderated comment on ${service}`
: `New comment on ${service}`
}
case 'SUGGESTION_CREATED': {
if (!notification.aboutServiceSuggestion) return 'New service suggestion'
const typeInfo = getServiceSuggestionTypeInfo(notification.aboutServiceSuggestion.type)
const service = notification.aboutServiceSuggestion.service.name
return `New ${typeInfo.labelAlt} suggestion for ${service}`
}
case 'SUGGESTION_MESSAGE': {
if (!notification.aboutServiceSuggestionMessage) return 'New message on your suggestion'
const service = notification.aboutServiceSuggestionMessage.suggestion.service.name
@@ -219,6 +227,7 @@ export function makeNotificationContent(
if (!notification.aboutKarmaTransaction) return null
return notification.aboutKarmaTransaction.description
}
case 'SUGGESTION_CREATED':
case 'SUGGESTION_STATUS_CHANGE':
case 'ACCOUNT_STATUS_CHANGE':
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
@@ -323,6 +332,17 @@ export function makeNotificationActions(
},
]
}
case 'SUGGESTION_CREATED': {
if (!notification.aboutServiceSuggestionId) return []
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`,
},
]
}
case 'SUGGESTION_MESSAGE': {
if (!notification.aboutServiceSuggestionMessage) return []
return [

View File

@@ -53,6 +53,7 @@ export async function sendNotification(
aboutServiceSuggestion: {
select: {
status: true,
type: true,
service: {
select: {
name: true,

View File

@@ -45,7 +45,7 @@ export type ServerEventsEvent = {
}[keyof ServerEventsData]
export type SSEEventMap = {
[K in keyof ServerEventsData as `sse-${K}`]: CustomEvent<ServerEventsData[K]>
[K in keyof ServerEventsData as `sse:${K}`]: CustomEvent<ServerEventsData[K]>
}
declare global {

View File

@@ -21,7 +21,7 @@ const cleanUrl = (input: unknown) => {
export const zodUrlOptionalProtocol = z.preprocess(
cleanUrl,
z.string().refine((value) => /^(https?:\/\/)?[a-z0-9]+(\.[a-z0-9])*(\.[a-z0-9]{2,}).*$/i.test(value), {
z.string().refine((value) => /^(https?:\/\/)?[^\s$.?#]+(\.[^\s$.?#])*(\.[a-z0-9]{2,}).*$/i.test(value), {
message: 'Invalid URL',
})
)
@@ -42,7 +42,7 @@ export const zodContactMethod = z.preprocess(
.trim()
.refine(
(value) =>
/^((https?:\/\/)?[a-z0-9]+(\.[a-z0-9])*(\.[a-z0-9]{2,}).*|([\d\s+\-_/()[\]*#.,]|ext|x){7,}|[0-9\s+-_\\/()[\]*#.]|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})*$/i.test(
/^((https?:\/\/)?[^\s$.?#]+(\.[^\s$.?#])*(\.[a-z0-9]{2,}).*|([\d\s+\-_/()[\]*#.,]|ext|x){7,}|[0-9\s+-_\\/()[\]*#.]|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})*$/i.test(
value
),
{