Release 202505281348

This commit is contained in:
pluja
2025-05-28 13:48:27 +00:00
parent 7e0d41cc7a
commit ea40f17d3c
10 changed files with 134 additions and 142 deletions

View File

@@ -916,7 +916,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
},
moderator: {
name: 'moderator_dev',
@@ -928,7 +928,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
},
verified: {
name: 'verified_dev',

View File

@@ -6,12 +6,8 @@ import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage'
import { prisma } from '../../lib/prisma'
import {
imageFileSchema,
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../../lib/zodUtils'
import { separateServiceUrlsByType } from '../../lib/urls'
import { imageFileSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../../lib/zodUtils'
const serviceSchemaBase = z.object({
id: z.number().int().positive(),
@@ -19,11 +15,10 @@ const serviceSchemaBase = z.object({
.string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
name: z.string().min(1).max(20),
name: z.string().min(1).max(40),
description: z.string().min(1),
serviceUrls: stringListOfUrlsSchemaRequired,
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: z.coerce.number().int().min(0).max(4),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
@@ -85,13 +80,20 @@ export const adminServiceActions = {
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.create({
data: {
name: input.name,
description: input.description,
serviceUrls: input.serviceUrls,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls: input.onionUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,
@@ -187,14 +189,21 @@ export const adminServiceActions = {
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.update({
where: { id: input.id },
data: {
name: input.name,
description: input.description,
serviceUrls: input.serviceUrls,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls: input.onionUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,

View File

@@ -14,12 +14,8 @@ import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage'
import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma'
import {
imageFileSchemaRequired,
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../lib/zodUtils'
import { separateServiceUrlsByType } from '../lib/urls'
import { imageFileSchemaRequired, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../lib/zodUtils'
import type { Prisma } from '@prisma/client'
@@ -161,9 +157,8 @@ export const serviceSuggestionActions = {
{ message: 'Slug must be unique, try a different one' }
),
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
serviceUrls: stringListOfUrlsSchemaRequired,
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
@@ -210,6 +205,12 @@ export const serviceSuggestionActions = {
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
const serviceSelect = {
id: true,
@@ -221,9 +222,10 @@ export const serviceSuggestionActions = {
name: input.name,
slug: input.slug,
description: input.description,
serviceUrls: input.serviceUrls,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls: input.onionUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
acceptedCurrencies: input.acceptedCurrencies,
imageUrl,

View File

@@ -21,7 +21,6 @@ type Props = HTMLAttributes<'div'> & {
pageSize: number
sortSeed?: string
filters: ServicesFiltersObject
includeScams: boolean
countCommunityOnly: number | null
inlineIcons?: boolean
}
@@ -35,15 +34,12 @@ const {
sortSeed,
class: className,
filters,
includeScams,
countCommunityOnly,
inlineIcons,
...divProps
} = Astro.props
const hasScams =
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
filters.verification.includes('VERIFICATION_FAILED') || includeScams
const hasScams = filters.verification.includes('VERIFICATION_FAILED')
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')

View File

@@ -113,3 +113,28 @@ export function urlDomain(url: URL | string) {
}
return url.origin
}
export function separateServiceUrlsByType(allServiceUrls: string[]) {
const result: {
web: string[]
onion: string[]
i2p: string[]
} = {
web: [],
onion: [],
i2p: [],
}
for (const url of allServiceUrls) {
const parsedUrl = new URL(url)
if (parsedUrl.origin.endsWith('.onion')) {
result.onion.push(url)
} else if (parsedUrl.origin.endsWith('.b32.i2p')) {
result.i2p.push(url)
} else {
result.web.push(url)
}
}
return result
}

View File

@@ -19,7 +19,7 @@ export const zodUrlOptionalProtocol = z.preprocess(
const cleanInput = input.trim().replace(/\/$/, '')
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
},
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
message: 'Invalid URL',
})
)

View File

@@ -233,16 +233,30 @@ if (!service) return Astro.rewrite('/404')
enctype="multipart/form-data"
>
<input type="hidden" name="id" value={service.id} />
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<InputText
label="Slug"
description="Auto-generated if empty"
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
</div>
<InputTextArea
label="Description"
name="description"
@@ -254,76 +268,44 @@ if (!service) return Astro.rewrite('/404')
error={serviceInputErrors.description}
/>
<InputText
label="Slug"
description="Auto-generated if empty"
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputTextArea
label="Service URLs"
description="One per line"
name="serviceUrls"
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="allServiceUrls"
inputProps={{
rows: 3,
placeholder: 'https://example1.com\nhttps://example2.com',
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
class: 'grow min-h-24',
required: true,
}}
value={service.serviceUrls.join('\n')}
error={serviceInputErrors.serviceUrls}
class="row-span-2 flex flex-col self-stretch"
value={[...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].join('\n\n')}
error={serviceInputErrors.allServiceUrls}
/>
<InputTextArea
label="ToS URLs"
description="One per line"
name="tosUrls"
inputProps={{
rows: 3,
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
required: true,
}}
value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls}
/>
<InputTextArea
label="Onion URLs"
description="One per line"
name="onionUrls"
<InputText
label="Referral link path"
name="referral"
inputProps={{
rows: 3,
placeholder: 'http://example1.onion\nhttp://example2.onion',
value: service.referral,
placeholder: 'e.g. ?ref=123 or /ref/123',
}}
value={service.onionUrls.join('\n')}
error={serviceInputErrors.onionUrls}
/>
<InputTextArea
label="I2P URLs"
description="One per line"
name="i2pUrls"
inputProps={{
rows: 3,
placeholder: 'http://example1.b32.i2p\nhttp://example2.b32.i2p',
}}
value={service.i2pUrls.join('\n')}
error={serviceInputErrors.referral}
class="self-end"
description="Will be appended to the service URL"
/>
</div>
<InputText
label="Referral link path"
name="referral"
inputProps={{
value: service.referral,
placeholder: 'e.g. ?ref=123 or /ref/123',
}}
error={serviceInputErrors.referral}
description="Will be appended to the service URL"
/>
<div class="flex items-center justify-between gap-2">
<InputImageFile
label="Image"

View File

@@ -74,19 +74,19 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
</div>
<div>
<label for="serviceUrls" class="font-title mb-2 block text-sm text-green-500">serviceUrls</label>
<label for="allServiceUrls" class="font-title mb-2 block text-sm text-green-500">Service URLs</label>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="serviceUrls"
id="serviceUrls"
name="allServiceUrls"
id="allServiceUrls"
rows={3}
placeholder="https://example1.com https://example2.com"
placeholder="https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p"
set:text=""
/>
{
inputErrors.serviceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p>
inputErrors.allServiceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.allServiceUrls.join(', ')}</p>
)
}
</div>
@@ -109,24 +109,6 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
}
</div>
<div>
<label for="onionUrls" class="font-title mb-2 block text-sm text-green-500">onionUrls</label>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="onionUrls"
id="onionUrls"
rows={3}
placeholder="http://example.onion"
set:text=""
/>
{
inputErrors.onionUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.onionUrls.join(', ')}</p>
)
}
</div>
<div>
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
<div class="space-y-2">

View File

@@ -712,7 +712,6 @@ const showFiltersId = 'show-filters'
pageSize={PAGE_SIZE}
sortSeed={filters['sort-seed']}
filters={filters}
includeScams={includeScams}
countCommunityOnly={countCommunityOnly}
inlineIcons
/>

View File

@@ -201,34 +201,31 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
error={inputErrors.description}
/>
<InputTextArea
label="Service URLs"
name="serviceUrls"
inputProps={{
required: true,
placeholder: 'https://example1.com\nhttps://example2.org',
}}
error={inputErrors.serviceUrls}
/>
<InputTextArea
label="Terms of Service URLs"
name="tosUrls"
inputProps={{
required: true,
placeholder: 'https://example1.com/tos\nhttps://example2.org/terms',
}}
error={inputErrors.tosUrls}
/>
<InputTextArea
label="Onion URLs"
name="onionUrls"
inputProps={{
placeholder: 'http://example1.onion\nhttp://example2.onion',
}}
error={inputErrors.onionUrls}
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputTextArea
label="Service URLs"
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="allServiceUrls"
inputProps={{
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
class: 'min-h-24',
required: true,
}}
class="row-span-2 flex flex-col self-stretch"
error={inputErrors.allServiceUrls}
/>
<InputTextArea
label="ToS URLs"
description="One per line"
name="tosUrls"
inputProps={{
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
class: 'md:min-h-24',
required: true,
}}
error={inputErrors.tosUrls}
/>
</div>
<InputCardGroup
name="kycLevel"