Release 202506091000
This commit is contained in:
@@ -57,62 +57,77 @@ Some users are **verified**, this means that the moderators have confirmed that
|
||||
|
||||
Users can also be **affiliated** with a service if they're related to it, such as being an owner or part of the team. If you own a service and want to get verified, just reach out to us.
|
||||
|
||||
|
||||
## Listings
|
||||
|
||||
### Suggesting a new listing
|
||||
|
||||
To suggest a new listing, visit the [service suggestion form](/service-suggestion/new) and provide the most accurate information possible for higher chances to get approved.
|
||||
Suggest a new listing by visiting the [service suggestion form](/service-suggestion/new). Provide the most accurate information for higher chances of getting approved.
|
||||
|
||||
Once submitted, you get a unique tracking page where you can monitor its status and communicate directly with moderators.
|
||||
Once submitted, you will get a **unique tracking page** where you can monitor the suggestion status and communicate directly with moderators.
|
||||
|
||||
All new listings begin as **unlisted** — they're only accessible via direct link and won't appear in search results. After a brief admin review to confirm the request isn't spam or inappropriate, the listing will be marked as **Community Contributed**.
|
||||
|
||||
#### Requirements
|
||||
#### Listing Requirements
|
||||
|
||||
To list a new service, it must fulfill these requirements:
|
||||
|
||||
- Offer a service.
|
||||
- Offer a service
|
||||
- Publicly available website explaining what the service is about
|
||||
- Terms of service or FAQ document
|
||||
|
||||
For examples:
|
||||
Examples of non-valid services:
|
||||
|
||||
- Just a Telegram link or a criptocurrency itself is not a valid service.
|
||||
- Just a Telegram link
|
||||
- A cryptocurrency project
|
||||
- A cryptocurrency wallet
|
||||
|
||||
### Suggestion Review Process
|
||||
#### Suggestion Review Process
|
||||
|
||||
#### First Review
|
||||
When you submit a new service, it gets the `COMMUNITY_CONTRIBUTED` status and the `unlisted` visibility level. The service **will not appear in search results**, but it can be viewed with a direct link.
|
||||
|
||||
##### First Review (Unlisted -> Public)
|
||||
|
||||
- A member of the **KYCnot.me team** reviews the submission to ensure it isn't spam or inappropriate.
|
||||
- If the listing passes this initial check, it becomes **publicly visible** to all users.
|
||||
- At this stage, the listing is **Community Contributed** and will show a disclaimer.
|
||||
|
||||
#### Second Review (APPROVED)
|
||||
> Allow up to a week for the initial review. As a small team of two volunteers, we have to handle a large number of suggestions alongside our other life commitments.
|
||||
|
||||
- The service is tested and investigated again.
|
||||
- If it proves to be reliable, it is granted the `APPROVED` status, which means:
|
||||
- The information is accurate.
|
||||
- The service works as described (at the time of the testing).
|
||||
- Basic functionality has been tested.
|
||||
- Communication with the service's support was successful.
|
||||
- A brief investigation found no obvious red flags.
|
||||
##### Second Review (Community Contributed -> Approved)
|
||||
|
||||
#### Final Review (VERIFIED)
|
||||
The service will be tested and investigated more in depth. If:
|
||||
|
||||
- After a period of no reported issues, the service will undergo a **third, comprehensive review**.
|
||||
- The service is tested across different dates and under various conditions.
|
||||
- The service administrators and support teams will be contacted for additional verification.
|
||||
- If the service meets all requirements, it is granted the **`VERIFIED`** status.
|
||||
- Service information is accurate.
|
||||
- Works as described (at the time of the testing).
|
||||
- Basic functionality is tested successfully.
|
||||
- Communication with service's support was successful.
|
||||
- No obvious red flags.
|
||||
- Platform user reviews will be taken into account.
|
||||
- Terms of Service and other documents are briefly reviewed.
|
||||
- Other platforms are checked for the service reputation (Trustpilot, Reddit, Bitcointalk, etc.)
|
||||
|
||||
#### Failed Verifications
|
||||
The service will be granted the `APPROVED` status.
|
||||
|
||||
##### Final Review (Approved -> Verified)
|
||||
|
||||
After a period of no reported issues, we will do a **third review**.
|
||||
|
||||
- The service is tested across different dates and under various conditions.
|
||||
- The service administrators and support teams will be contacted for additional verification.
|
||||
- User reviews will be checked for any issues and taken into account.
|
||||
- Terms of Service and other documents will be reviewed again.
|
||||
- Other platforms are checked for the service reputation (Trustpilot, Reddit, Bitcointalk, etc.)
|
||||
|
||||
If the service meets all requirements, it is granted the **`VERIFIED`** status.
|
||||
|
||||
##### Failed Verifications (Scams)
|
||||
|
||||
If the data is not accurate, the service is a scam, or any other checks fail, the service will be rejected and will appear with a disclaimer.
|
||||
|
||||
### Verification Steps
|
||||
#### Verification Steps
|
||||
|
||||
Services will usually show the verification steps that the admins took to reach the verified (or not) status. Each step will have a description and some evidence attached.
|
||||
|
||||
### Service Attributes
|
||||
#### Service Attributes
|
||||
|
||||
An attribute is a feature of a service, categorized as:
|
||||
|
||||
@@ -130,11 +145,11 @@ Attributes are classified into two main types:
|
||||
|
||||
These categories **directly influence** a service's Privacy and Trust scores, which contribute to its **overall rating**.
|
||||
|
||||
### Service Scores
|
||||
#### Service Scores
|
||||
|
||||
Scores are calculated **automatically** using clear, fixed rules. We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements.
|
||||
|
||||
#### Privacy Score
|
||||
##### Privacy Score
|
||||
|
||||
The privacy score measures how well a service protects user privacy, using a transparent, rules-based approach:
|
||||
|
||||
@@ -151,7 +166,7 @@ The privacy score measures how well a service protects user privacy, using a tra
|
||||
6. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes).
|
||||
7. **Final Score Range:** The final score is always kept between 0 and 100.
|
||||
|
||||
#### Trust Score
|
||||
##### Trust Score
|
||||
|
||||
The trust score represents how reliable and trustworthy a service is, based on objective, transparent criteria.
|
||||
|
||||
@@ -165,11 +180,11 @@ The trust score represents how reliable and trustworthy a service is, based on o
|
||||
4. **Recently Listed Penalty & Flag:** 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.
|
||||
5. **Final Score Range:** The final score is always kept between 0 and 100.
|
||||
|
||||
#### Overall Score
|
||||
##### Overall Score
|
||||
|
||||
The overall score is calculated as `(privacy * 0.6) + (trust * 0.4)` and provides a combined measure of privacy and trust.
|
||||
|
||||
### Terms of Service Reviews
|
||||
#### Terms of Service Reviews
|
||||
|
||||
KYCnot.me automatically reviews and summarizes the Terms of Service (ToS) for every service monthly using AI. You get simple, clear summaries that highlight the most important points, so you can quickly see what matters.
|
||||
|
||||
@@ -177,7 +192,7 @@ We hash each ToS document and only review it again if it changes. Some services
|
||||
|
||||
We aim for accuracy, but the AI may sometimes miss details or highlight less relevant information. If you see any error, contact us.
|
||||
|
||||
### Events
|
||||
#### Events
|
||||
|
||||
There are two types of events:
|
||||
|
||||
@@ -186,7 +201,7 @@ There are two types of events:
|
||||
|
||||
You can also take a look at the [global timeline](/events) where you will find all the service's events sorted by date.
|
||||
|
||||
### Reviews and Comments
|
||||
## Reviews and Comments
|
||||
|
||||
Reviews are comments with a one to five star rating for the service. Each user can leave only one review per service; new reviews replace the old one.
|
||||
|
||||
@@ -196,7 +211,7 @@ If you've used the service, you can add an **order ID** or proof—this is only
|
||||
|
||||
Some reviews may be spam or fake. Read comments carefully and **always do your own research before making decisions**.
|
||||
|
||||
#### Note on moderation
|
||||
### Note on moderation
|
||||
|
||||
**All comments are moderated.** First, an AI checks each comment. If nothing is flagged, the comment is published right away. If something seems off, the comment is held for a human to review. We only remove comments that are spam, nonsense, unsupported accusations, doxxing, or clear rule violations.
|
||||
|
||||
|
||||
@@ -46,8 +46,6 @@ const createInputErrors = isInputError(createResult?.error) ? createResult.error
|
||||
const updateResult = Astro.getActionResult(actions.admin.attribute.update)
|
||||
Astro.locals.banners.addIfSuccess(updateResult, 'Attribute updated successfully')
|
||||
|
||||
const updatedAttributeId = updateResult?.data?.attribute.id ?? null
|
||||
|
||||
const sortBy = filters['sort-by']
|
||||
const sortOrder = filters['sort-order']
|
||||
|
||||
@@ -724,8 +722,6 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script define:vars={{ updatedAttributeId }}></script>
|
||||
|
||||
<style>
|
||||
@keyframes highlight {
|
||||
0% {
|
||||
|
||||
@@ -14,12 +14,10 @@ import {
|
||||
} from '../../../constants/serviceSuggestionStatus'
|
||||
import { getServiceSuggestionTypeInfo } from '../../../constants/serviceSuggestionType'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { cn } from '../../../lib/cn'
|
||||
import { parseIntWithFallback } from '../../../lib/numbers'
|
||||
import { prisma } from '../../../lib/prisma'
|
||||
import { makeLoginUrl } from '../../../lib/redirectUrls'
|
||||
import { formatDateShort } from '../../../lib/timeAgo'
|
||||
import BadgeStandard from '../../../components/BadgeStandard.astro'
|
||||
|
||||
const user = Astro.locals.user
|
||||
if (!user?.admin) {
|
||||
|
||||
@@ -37,12 +37,8 @@ if (!user?.admin) {
|
||||
const { data: filters } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
search: z.string().optional(),
|
||||
status: serviceSuggestionStatusesZodEnumBySlug
|
||||
.transform((slug) => serviceSuggestionStatusSlugToId(slug))
|
||||
.optional(),
|
||||
type: serviceSuggestionTypesZodEnumBySlug
|
||||
.transform((slug) => serviceSuggestionTypeSlugToId(slug))
|
||||
.optional(),
|
||||
status: serviceSuggestionStatusesZodEnumBySlug.or(z.literal('all')).default('pending'),
|
||||
type: serviceSuggestionTypesZodEnumBySlug.or(z.literal('all')).optional(),
|
||||
'sort-by': z
|
||||
.enum(['service', 'status', 'type', 'user', 'createdAt', 'messageCount'])
|
||||
.default('createdAt'),
|
||||
@@ -51,6 +47,11 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
|
||||
Astro
|
||||
)
|
||||
|
||||
const statusFilter = filters.status === 'all' ? undefined : serviceSuggestionStatusSlugToId(filters.status)
|
||||
|
||||
const typeFilter =
|
||||
!filters.type || filters.type === 'all' ? undefined : serviceSuggestionTypeSlugToId(filters.type)
|
||||
|
||||
let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' }
|
||||
if (filters['sort-by'] === 'createdAt') {
|
||||
prismaOrderBy = { createdAt: filters['sort-order'] }
|
||||
@@ -67,8 +68,8 @@ let suggestions = await prisma.serviceSuggestion.findMany({
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
status: filters.status,
|
||||
type: filters.type,
|
||||
status: statusFilter,
|
||||
type: typeFilter,
|
||||
},
|
||||
orderBy: prismaOrderBy,
|
||||
select: {
|
||||
@@ -198,10 +199,10 @@ const makeSortUrl = (slug: string) => {
|
||||
id="status-filter"
|
||||
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="all" selected={filters.status === 'all'}>All Statuses</option>
|
||||
{
|
||||
serviceSuggestionStatuses.map((status) => (
|
||||
<option value={status.slug} selected={filters.status === status.value}>
|
||||
<option value={status.slug} selected={filters.status === status.slug}>
|
||||
{status.label}
|
||||
</option>
|
||||
))
|
||||
@@ -215,10 +216,10 @@ const makeSortUrl = (slug: string) => {
|
||||
id="type-filter"
|
||||
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="all" selected={!filters.type || filters.type === 'all'}>All Types</option>
|
||||
{
|
||||
serviceSuggestionTypes.map((type) => (
|
||||
<option value={type.slug} selected={filters.type === type.value}>
|
||||
<option value={type.slug} selected={filters.type === type.slug}>
|
||||
{type.label}
|
||||
</option>
|
||||
))
|
||||
|
||||
@@ -9,6 +9,7 @@ export const GET: APIRoute = () => {
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
74
web/src/pages/internal-api/server-events.ts
Normal file
74
web/src/pages/internal-api/server-events.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { SITE_URL } from 'astro:env/client'
|
||||
|
||||
import { getRedisServerEvents } from '../../lib/redis/redisServerEvents'
|
||||
|
||||
import type { ServerEventsEvent } from '../../lib/serverEventsTypes'
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
const redisServerEvents = await getRedisServerEvents()
|
||||
|
||||
export const GET: APIRoute = ({ request, locals }) => {
|
||||
const user = locals.user
|
||||
|
||||
let cleanup: (() => Promise<void>) | null = null
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
function sendEvent(event: ServerEventsEvent) {
|
||||
try {
|
||||
controller.enqueue(encodeSSE(event))
|
||||
} catch (error) {
|
||||
console.error('Failed to send SSE event:', event.type, error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sendEvent({ type: 'new-connection', data: { timestamp: new Date().toISOString() } })
|
||||
|
||||
cleanup = user
|
||||
? await redisServerEvents.addListener('all', user.id, (event) => {
|
||||
sendEvent(event)
|
||||
})
|
||||
: null
|
||||
|
||||
async function abort() {
|
||||
try {
|
||||
await cleanup?.()
|
||||
controller.close()
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup SSE connection:', error)
|
||||
}
|
||||
}
|
||||
|
||||
request.signal.addEventListener('abort', () => {
|
||||
void abort()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to start SSE stream:', error)
|
||||
controller.error(error)
|
||||
}
|
||||
},
|
||||
|
||||
async cancel() {
|
||||
try {
|
||||
await cleanup?.()
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup on cancel:', error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': new URL(SITE_URL).origin,
|
||||
'Access-Control-Allow-Headers': 'Cache-Control',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function encodeSSE(event: ServerEventsEvent) {
|
||||
return new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
|
||||
}
|
||||
@@ -245,6 +245,22 @@ const notifications = dbNotifications.map((notification) => ({
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mb-4 flex items-center gap-2 rounded-lg border border-blue-500/20 bg-blue-500/10 p-3 text-blue-200"
|
||||
data-new-notification-banner
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
<span>You received a new notification</span>
|
||||
<Button
|
||||
type="button"
|
||||
label="Reload"
|
||||
icon="ri:refresh-line"
|
||||
color="white"
|
||||
class="ml-auto"
|
||||
onclick="window.location.reload()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
notifications.length === 0 ? (
|
||||
<div class="flex flex-col items-center justify-center rounded-lg border border-zinc-800 bg-zinc-900 p-10 text-center shadow-sm">
|
||||
@@ -390,3 +406,11 @@ const notifications = dbNotifications.map((notification) => ({
|
||||
</form>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
document.addEventListener('sse-new-notification', () => {
|
||||
document.querySelectorAll<HTMLElement>('[data-new-notification-banner]').forEach((banner) => {
|
||||
banner.style.display = ''
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -10,7 +10,6 @@ import ServiceCard from '../../components/ServiceCard.astro'
|
||||
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
|
||||
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import { cn } from '../../lib/cn'
|
||||
import { parseIntWithFallback } from '../../lib/numbers'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { makeLoginUrl } from '../../lib/redirectUrls'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
import { actions } from 'astro:actions'
|
||||
import { orderBy } from 'lodash-es'
|
||||
|
||||
import {
|
||||
@@ -36,7 +36,7 @@ const result = Astro.getActionResult(actions.serviceSuggestion.createService)
|
||||
if (result && !result.error && !result.data.hasDuplicates) {
|
||||
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
|
||||
}
|
||||
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
const inputErrors = result?.error?.code === 'VALIDATION_ERROR' ? result.error.fields : {}
|
||||
|
||||
const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
[
|
||||
@@ -89,7 +89,11 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
},
|
||||
]}
|
||||
>
|
||||
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Service suggestion</h1>
|
||||
<h1 class="font-title mt-12 text-center text-3xl font-bold">Service suggestion</h1>
|
||||
|
||||
<p class="text-day-400 mb-6 text-center text-sm">
|
||||
Suggestions are reviewed by moderators before being public.
|
||||
</p>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
@@ -345,6 +349,17 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
||||
|
||||
<Captcha action={actions.serviceSuggestion.createService} />
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-lg">
|
||||
<input type="checkbox" name="rulesConfirm" id="rules-confirm" required />
|
||||
<label for="rules-confirm">
|
||||
I understand the
|
||||
<a class="underline" target="_blank" href="/about#listings">suggestion rules and process</a>
|
||||
</label>
|
||||
</div>
|
||||
{inputErrors.rulesConfirm && <p class="mt-1 text-sm text-red-500">{inputErrors.rulesConfirm?.[0]}</p>}
|
||||
</div>
|
||||
|
||||
<InputHoneypotTrap name="message" />
|
||||
|
||||
<InputSubmitButton label={result?.data?.hasDuplicates ? 'Submit anyway' : 'Submit'} />
|
||||
|
||||
Reference in New Issue
Block a user