Compare commits

...

4 Commits

Author SHA1 Message Date
pluja
da12e8de79 add basic API plus minor updates and fixes 2025-05-30 08:17:23 +00:00
pluja
ea40f17d3c Release 202505281348 2025-05-28 13:48:27 +00:00
pluja
7e0d41cc7a Release 202505280851 2025-05-28 08:51:59 +00:00
pluja
70a097054b Release 202505271800 2025-05-27 18:00:08 +00:00
19 changed files with 475 additions and 188 deletions

View File

@@ -177,6 +177,12 @@ export default defineConfig({
url: true,
optional: false,
}),
LOGS_UI_URL: envField.string({
context: 'server',
access: 'secret',
url: true,
optional: true,
}),
RELEASE_NUMBER: envField.number({
context: 'server',

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

@@ -15,6 +15,8 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
}
icon: string
color: ComponentProps<typeof BadgeSmall>['color']
isSolved: boolean
showBanner: boolean
}
export const {
@@ -36,6 +38,8 @@ export const {
},
icon: 'ri:question-fill',
color: 'gray',
isSolved: false,
showBanner: false,
}),
[
{
@@ -46,8 +50,10 @@ export const {
classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
},
icon: 'ri:error-warning-fill',
icon: 'ri:alert-fill',
color: 'yellow',
isSolved: false,
showBanner: true,
},
{
id: 'WARNING_SOLVED',
@@ -55,10 +61,12 @@ export const {
label: 'Warning Solved',
description: 'A previously reported warning has been solved',
classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50',
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
},
icon: 'ri:check-fill',
icon: 'ri:alert-fill',
color: 'green',
isSolved: true,
showBanner: false,
},
{
id: 'ALERT',
@@ -68,8 +76,10 @@ export const {
classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50',
},
icon: 'ri:alert-fill',
icon: 'ri:spam-fill',
color: 'red',
isSolved: false,
showBanner: true,
},
{
id: 'ALERT_SOLVED',
@@ -77,10 +87,12 @@ export const {
label: 'Alert Solved',
description: 'A previously reported alert has been solved',
classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50',
dot: 'bg-red-900 text-red-300 ring-red-900/50',
},
icon: 'ri:check-fill',
icon: 'ri:spam-fill',
color: 'green',
isSolved: true,
showBanner: false,
},
{
id: 'INFO',
@@ -92,6 +104,8 @@ export const {
},
icon: 'ri:information-fill',
color: 'sky',
isSolved: false,
showBanner: false,
},
{
id: 'NORMAL',
@@ -103,6 +117,8 @@ export const {
},
icon: 'ri:notification-fill',
color: 'green',
isSolved: false,
showBanner: false,
},
{
id: 'UPDATE',
@@ -114,6 +130,8 @@ export const {
},
icon: 'ri:pencil-fill',
color: 'sky',
isSolved: false,
showBanner: false,
},
] as const satisfies EventTypeInfo<EventType>[]
)

View File

@@ -1 +1 @@
export const SUPPORT_EMAIL = 'support@kycnot.me'
export const SUPPORT_EMAIL = 'contact@kycnot.me'

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

@@ -201,6 +201,41 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
## API
Access basic service data via our public API.
**Attribution:** Please credit **KYCnot.me** if you use data from this API.
### `GET /api/v1/service/[id]`
Fetches details for a single service.
- **`[id]`**: Can be a service ID, slug, name, or any registered URL (including .onion/.i2p).
**Example Requests:**
```
/api/v1/service/bisq
/api/v1/service/https://bisq.network
```
**Example Response (200 OK):**
```json
{
"name": "Bisq",
"description": "Decentralized Bitcoin exchange network.",
"kycLevel": 0,
"categories": ["exchange"],
"serviceUrls": ["https://bisq.network/"],
"onionUrls": [],
"i2pUrls": [],
"tosUrls": ["https://bisq.network/terms-of-service/"],
"kycnotmeUrl": "https://kycnot.me/service/bisq"
}
```
## Support
If you like this project, you can **support** it through these methods:

View File

@@ -1,6 +1,6 @@
---
import { Icon } from 'astro-icon/components'
import { DATABASE_UI_URL } from 'astro:env/server'
import { DATABASE_UI_URL, LOGS_UI_URL } from 'astro:env/server'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn'
@@ -81,6 +81,18 @@ const adminLinks: AdminLink[] = [
base: 'text-gray-300',
},
},
...(LOGS_UI_URL
? [
{
icon: 'ri:menu-search-line',
title: 'Logs',
href: LOGS_UI_URL,
classNames: {
base: 'text-cyan-300',
},
},
]
: []),
]
---
@@ -93,25 +105,27 @@ const adminLinks: AdminLink[] = [
<nav>
<ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4">
{
adminLinks.map((link) => (
<li
class={cn(
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
link.classNames.base
)}
>
<a
href={link.href}
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
adminLinks
.filter((link) => link.href)
.map((link) => (
<li
class={cn(
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
link.classNames.base
)}
>
<Icon
name={link.icon}
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
/>
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
</a>
</li>
))
<a
href={link.href}
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
>
<Icon
name={link.icon}
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
/>
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
</a>
</li>
))
}
</ol>
</nav>

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"
@@ -685,9 +667,14 @@ if (!service) return Astro.rewrite('/404')
label="Started At"
name="startedAt"
inputProps={{
type: 'date',
type: 'datetime-local',
required: true,
value: new Date(event.startedAt).toISOString().split('T')[0],
value: new Date(
new Date(event.startedAt).getTime() -
new Date(event.startedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16),
}}
error={eventUpdateInputErrors.startedAt}
/>
@@ -696,7 +683,15 @@ if (!service) return Astro.rewrite('/404')
label="Ended At"
name="endedAt"
inputProps={{
value: event.endedAt ? new Date(event.endedAt).toISOString().split('T')[0] : '',
type: 'datetime-local',
value: event.endedAt
? new Date(
new Date(event.endedAt).getTime() -
new Date(event.endedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16)
: '',
}}
error={eventUpdateInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
@@ -756,9 +751,11 @@ if (!service) return Astro.rewrite('/404')
label="Started At"
name="startedAt"
inputProps={{
type: 'date',
type: 'datetime-local',
required: true,
value: new Date().toISOString().split('T')[0],
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}}
error={eventInputErrors.startedAt}
/>
@@ -767,7 +764,10 @@ if (!service) return Astro.rewrite('/404')
label="Ended At"
name="endedAt"
inputProps={{
value: new Date().toISOString().split('T')[0],
type: 'datetime-local',
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}}
error={eventInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."

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

@@ -19,7 +19,7 @@ import type { Prisma } from '@prisma/client'
const { data: filters } = zodParseQueryParamsStoringErrors(
{
'sort-by': z.enum(['name', 'role', 'createdAt', 'karma']).default('createdAt'),
'sort-by': z.enum(['name', 'role', 'lastLoginAt', 'karma', 'createdAt']).default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),
role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).optional(),
@@ -29,7 +29,10 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
// Set up Prisma orderBy with correct typing
const prismaOrderBy =
filters['sort-by'] === 'name' || filters['sort-by'] === 'createdAt' || filters['sort-by'] === 'karma'
filters['sort-by'] === 'name' ||
filters['sort-by'] === 'createdAt' ||
filters['sort-by'] === 'lastLoginAt' ||
filters['sort-by'] === 'karma'
? {
[filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]:
filters['sort-order'] === 'asc' ? 'asc' : 'desc',
@@ -86,6 +89,7 @@ const dbUsers = await prisma.user.findMany({
totalKarma: true,
createdAt: true,
updatedAt: true,
lastLoginAt: true,
internalNotes: {
select: {
id: true,
@@ -218,16 +222,29 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
<th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a
href={makeSortUrl('createdAt')}
class="flex items-center justify-center hover:text-zinc-200"
>
Joined <SortArrowIcon
active={filters['sort-by'] === 'createdAt'}
sortOrder={filters['sort-order']}
/>
</a>
<div class="flex flex-wrap items-center justify-center gap-1">
<a
href={makeSortUrl('lastLoginAt')}
class="flex items-center justify-center hover:text-zinc-200"
>
Login <SortArrowIcon
active={filters['sort-by'] === 'lastLoginAt'}
sortOrder={filters['sort-order']}
/>
</a>
<span class="text-zinc-600">/</span>
<a
href={makeSortUrl('createdAt')}
class="flex items-center justify-center hover:text-zinc-200"
>
Joined <SortArrowIcon
active={filters['sort-by'] === 'createdAt'}
sortOrder={filters['sort-order']}
/>
</a>
</div>
</th>
<th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
@@ -305,8 +322,24 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
{user.totalKarma}
</span>
</td>
<td class="px-4 py-3 text-center text-sm text-zinc-400">
<TimeFormatted date={user.createdAt} hourPrecision hoursShort prefix={false} />
<td class="px-4 py-3 text-center text-sm">
<div class="flex flex-wrap items-center justify-center gap-1 text-center">
<TimeFormatted
class="text-zinc-300"
date={user.lastLoginAt}
hourPrecision
hoursShort
prefix={false}
/>
<span class="text-zinc-600">/</span>
<TimeFormatted
class="text-zinc-400"
date={user.createdAt}
hourPrecision
hoursShort
prefix={false}
/>
</div>
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-3">

View File

@@ -0,0 +1,121 @@
import { prisma } from '../../../../lib/prisma'
import type { Prisma } from '@prisma/client'
import type { APIRoute } from 'astro'
const MAX_ID_LENGTH = 2048
export const GET: APIRoute = async ({ params }) => {
const { id } = params
if (!id) {
return new Response(JSON.stringify({ error: 'ID parameter is missing' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
})
}
if (id.length > MAX_ID_LENGTH) {
return new Response(JSON.stringify({ error: 'ID parameter is too long' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
})
}
const orConditions: Prisma.ServiceWhereInput[] = [
{ slug: id },
{ name: id },
{ serviceUrls: { has: id } },
{ onionUrls: { has: id } },
{ i2pUrls: { has: id } },
]
// Try direct ID lookup first
const numericId = parseInt(id, 10)
if (!isNaN(numericId)) {
orConditions.push({ id: numericId })
}
if (id.startsWith('http://') || id.startsWith('https://')) {
let alternativeId: string
if (id.endsWith('/')) {
alternativeId = id.slice(0, -1) // Remove trailing slash
} else {
alternativeId = id + '/' // Add trailing slash
}
orConditions.push({ serviceUrls: { has: alternativeId } })
orConditions.push({ onionUrls: { has: alternativeId } })
orConditions.push({ i2pUrls: { has: alternativeId } })
} else {
// For non-HTTP/S IDs, check as is (could be a direct onion/i2p address without protocol)
orConditions.push({ serviceUrls: { has: id } })
orConditions.push({ onionUrls: { has: id } })
orConditions.push({ i2pUrls: { has: id } })
}
try {
const service = await prisma.service.findFirst({
where: {
OR: orConditions,
},
select: {
name: true,
slug: true,
description: true,
kycLevel: true,
categories: {
select: {
name: true,
slug: true,
icon: true,
},
},
serviceUrls: true,
onionUrls: true,
i2pUrls: true,
tosUrls: true,
},
})
if (!service) {
return new Response(JSON.stringify({ error: 'Service not found' }), {
status: 404,
headers: {
'Content-Type': 'application/json',
},
})
}
const responseData = {
name: service.name,
description: service.description,
kycLevel: service.kycLevel,
categories: service.categories.map((category) => category.slug),
serviceUrls: service.serviceUrls,
onionUrls: service.onionUrls,
i2pUrls: service.i2pUrls,
tosUrls: service.tosUrls,
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`,
}
return new Response(JSON.stringify(responseData), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
})
} catch (error) {
console.error('Error fetching service:', error)
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
})
}
}

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"

View File

@@ -1,5 +1,5 @@
---
import { VerificationStepStatus } from '@prisma/client'
import { VerificationStepStatus, EventType } from '@prisma/client'
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { Schema } from 'astro-seo-schema'
@@ -380,6 +380,10 @@ const ogImageTemplateData = {
} satisfies OgImageAllTemplatesWithProps
const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility)
const activeAlertOrWarningEvents = service.events.filter(
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
)
---
<BaseLayout
@@ -480,6 +484,32 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
}),
]}
>
{
activeAlertOrWarningEvents.length > 0 && (
<a
href="#events"
class={cn(
'mb-4 block rounded-md px-3 py-2 text-sm font-medium',
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60'
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60'
)}
>
<Icon
name={
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'ri:alert-fill'
: 'ri:alarm-warning-fill'
}
class="me-1.5 inline-block size-4 align-[-0.15em]"
/>
{activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'There is an active alert for this service. Click to see details.'
: 'There is an active warning for this service. Click to see details.'}
</a>
)
}
{
(serviceVisibilityInfo.value === 'UNLISTED' || serviceVisibilityInfo.value === 'ARCHIVED') && (
<div class={cn('mb-4 rounded-md bg-yellow-900/50 px-3 py-2 text-sm text-yellow-400')}>
@@ -1182,6 +1212,7 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
<div class="mt-3 max-w-md pe-8">
<h3 class="font-title text-lg leading-tight font-semibold text-pretty text-white">
{typeInfo.isSolved && <BadgeSmall text="Solved" icon="ri:check-line" color="green" />}
{event.title}
</h3>

View File

@@ -48,6 +48,7 @@ const user = await Astro.locals.banners.try('user', async () => {
verifiedLink: true,
totalKarma: true,
createdAt: true,
lastLoginAt: true,
_count: {
select: {
comments: true,
@@ -469,6 +470,24 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</div>
</li>
<AdminOnly>
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
<div>
<p class="text-day-500 text-xs">Last login</p>
<p class="text-day-300">
{
formatDateShort(user.lastLoginAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
})
}
</p>
</div>
</li>
</AdminOnly>
{
user.verifiedLink && (
<li class="flex items-start">