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

@@ -332,7 +332,7 @@ def remove_service_attribute_by_slug(service_id: int, attribute_slug: str) -> bo
return remove_service_attribute(service_id, attribute_id) return remove_service_attribute(service_id, attribute_id)
def save_tos_review(service_id: int, review: TosReviewType): def save_tos_review(service_id: int, review: Optional[TosReviewType]):
""" """
Save a TOS review for a specific service. Save a TOS review for a specific service.
@@ -341,8 +341,8 @@ def save_tos_review(service_id: int, review: TosReviewType):
review: A TypedDict containing the review data. review: A TypedDict containing the review data.
""" """
try: try:
# Serialize the dictionary to a JSON string for the database # Only serialize to JSON if review is not None
review_json = json.dumps(review) review_json = json.dumps(review) if review is not None else None
with get_db_connection() as conn: with get_db_connection() as conn:
with conn.cursor(row_factory=dict_row) as cursor: with conn.cursor(row_factory=dict_row) as cursor:
cursor.execute( cursor.execute(

View File

@@ -3,7 +3,7 @@ Task for retrieving Terms of Service (TOS) text.
""" """
import hashlib import hashlib
from typing import Any, Dict, Optional from typing import Any, Dict, Optional, Literal
from pyworker.database import TosReviewType, save_tos_review, update_kyc_level from pyworker.database import TosReviewType, save_tos_review, update_kyc_level
from pyworker.tasks.base import Task from pyworker.tasks.base import Task
@@ -52,65 +52,71 @@ class TosReviewTask(Task):
) )
self.logger.info(f"TOS URLs: {tos_urls}") self.logger.info(f"TOS URLs: {tos_urls}")
review = self.get_tos_review(tos_urls, service.get("tosReview"))
save_tos_review(service_id, review)
# Update the KYC level based on the review
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)
return review
def get_tos_review(
self, tos_urls: list[str], current_review: Optional[TosReviewType]
) -> Optional[TosReviewType]:
"""
Get TOS review from a list of URLs.
Args:
tos_urls: List of TOS URLs to check
current_review: Current review data from the database
Returns:
Dict containing:
- status: Literal["skipped", "failed", "success"]
- review: Optional[TosReviewType] - The review data if successful
"""
all_skipped = True
for tos_url in tos_urls: for tos_url in tos_urls:
api_url = f"{tos_url}" api_url = f"{tos_url}"
self.logger.info(f"Fetching TOS from URL: {api_url}") self.logger.info(f"Fetching TOS from URL: {api_url}")
# Sleep for 1 second to avoid rate limiting
content = fetch_markdown(api_url) content = fetch_markdown(api_url)
if content: if not content:
# Hash the content to avoid repeating the same content self.logger.warning(f"Failed to retrieve TOS content for URL: {tos_url}")
content_hash = hashlib.sha256(content.encode()).hexdigest() all_skipped = False
self.logger.info(f"Content hash: {content_hash}") continue
# service.get("tosReview") can be None if the DB field is NULL. # Hash the content to avoid repeating the same content
# Default to an empty dict to prevent AttributeError on .get() content_hash = hashlib.sha256(content.encode()).hexdigest()
tos_review_data_from_service: Optional[Dict[str, Any]] = service.get( self.logger.info(f"Content hash: {content_hash}")
"tosReview"
) # Skip processing if we've seen this content before
tos_review: Dict[str, Any] = ( if current_review and current_review.get("contentHash") == content_hash:
tos_review_data_from_service self.logger.info(
if tos_review_data_from_service is not None f"Skipping already processed TOS content with hash: {content_hash}"
else {}
) )
continue
stored_hash = tos_review.get("contentHash") all_skipped = False
# Skip processing if we've seen this content before # Skip incomplete TOS content
if stored_hash == content_hash: check = prompt_check_tos_review(content)
self.logger.info( if not check or not check["isComplete"]:
f"Skipping already processed TOS content with hash: {content_hash}" continue
)
continue
# Skip incomplete TOS content # Query OpenAI to summarize the content
check = prompt_check_tos_review(content) review = prompt_tos_review(content)
if not check:
continue
elif not check["isComplete"]:
continue
# Query OpenAI to summarize the content
review = prompt_tos_review(content)
if review:
review["contentHash"] = content_hash
# Save the review to the database
save_tos_review(service_id, review)
# Update the KYC level based on the review
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)
# no need to check other TOS URLs
break
if review:
review["contentHash"] = content_hash
return review return review
else:
self.logger.warning( if all_skipped:
f"Failed to retrieve TOS content for URL: {tos_url}" return current_review
)
return None

View File

@@ -178,7 +178,7 @@ type TosReview = {
The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical. The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical.
Be concise but thorough, and make sure your output is properly formatted JSON. 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.
""" """
PROMPT_COMMENT_SENTIMENT_SUMMARY = """ PROMPT_COMMENT_SENTIMENT_SUMMARY = """

41
web/package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@astrojs/db": "0.15.0", "@astrojs/db": "0.15.0",
"@astrojs/mdx": "4.3.0", "@astrojs/mdx": "4.3.0",
"@astrojs/node": "9.2.2", "@astrojs/node": "9.2.2",
"@astrojs/rss": "4.0.12",
"@astrojs/sitemap": "3.4.1", "@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.8", "@fontsource-variable/space-grotesk": "5.2.8",
"@fontsource/inter": "5.2.5", "@fontsource/inter": "5.2.5",
@@ -320,6 +321,16 @@
"node": "18.20.8 || ^20.3.0 || >=22.0.0" "node": "18.20.8 || ^20.3.0 || >=22.0.0"
} }
}, },
"node_modules/@astrojs/rss": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@astrojs/rss/-/rss-4.0.12.tgz",
"integrity": "sha512-O5yyxHuDVb6DQ6VLOrbUVFSm+NpObulPxjs6XT9q3tC+RoKbN4HXMZLpv0LvXd1qdAjzVgJ1NFD+zKHJNDXikw==",
"license": "MIT",
"dependencies": {
"fast-xml-parser": "^5.2.0",
"kleur": "^4.1.5"
}
},
"node_modules/@astrojs/sitemap": { "node_modules/@astrojs/sitemap": {
"version": "3.4.1", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.4.1.tgz", "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.4.1.tgz",
@@ -9789,6 +9800,24 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/fast-xml-parser": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^2.1.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.18.0", "version": "1.18.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
@@ -16567,6 +16596,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/strnum": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
"integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/style-to-js": { "node_modules/style-to-js": {
"version": "1.1.16", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz",

View File

@@ -28,6 +28,7 @@
"@astrojs/db": "0.15.0", "@astrojs/db": "0.15.0",
"@astrojs/mdx": "4.3.0", "@astrojs/mdx": "4.3.0",
"@astrojs/node": "9.2.2", "@astrojs/node": "9.2.2",
"@astrojs/rss": "4.0.12",
"@astrojs/sitemap": "3.4.1", "@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.8", "@fontsource-variable/space-grotesk": "5.2.8",
"@fontsource/inter": "5.2.5", "@fontsource/inter": "5.2.5",

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'SUGGESTION_CREATED';

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[feedId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "feedId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "User_feedId_key" ON "User"("feedId");

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Made the column `feedId` on table `User` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "feedId" SET NOT NULL;

View File

@@ -135,6 +135,7 @@ enum NotificationType {
COMMUNITY_NOTE_ADDED COMMUNITY_NOTE_ADDED
/// Comment that is not a reply. May include a rating. /// Comment that is not a reply. May include a rating.
ROOT_COMMENT_CREATED ROOT_COMMENT_CREATED
SUGGESTION_CREATED
SUGGESTION_MESSAGE SUGGESTION_MESSAGE
SUGGESTION_STATUS_CHANGE SUGGESTION_STATUS_CHANGE
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code. // KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
@@ -497,6 +498,7 @@ model User {
moderator Boolean @default(false) moderator Boolean @default(false)
verifiedLink String? verifiedLink String?
secretTokenHash String @unique secretTokenHash String @unique
feedId String @unique @default(cuid(2))
/// Computed via trigger. Do not update through prisma. /// Computed via trigger. Do not update through prisma.
totalKarma Int @default(0) totalKarma Int @default(0)

View File

@@ -22,7 +22,7 @@ DROP FUNCTION IF EXISTS recalculate_scores_for_attribute();
CREATE OR REPLACE FUNCTION calculate_privacy_score(service_id INT) CREATE OR REPLACE FUNCTION calculate_privacy_score(service_id INT)
RETURNS INT AS $$ RETURNS INT AS $$
DECLARE DECLARE
privacy_score INT := 50; -- Start from middle value (50) privacy_score INT := 0;
kyc_factor INT; kyc_factor INT;
onion_factor INT := 0; onion_factor INT := 0;
i2p_factor INT := 0; i2p_factor INT := 0;
@@ -78,7 +78,7 @@ BEGIN
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY'; WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
-- Calculate final privacy score (base 100) -- Calculate final privacy score (base 100)
privacy_score := privacy_score + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score; privacy_score := 50 + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
-- Ensure the score is in reasonable bounds (0-100) -- Ensure the score is in reasonable bounds (0-100)
privacy_score := GREATEST(0, LEAST(100, privacy_score)); privacy_score := GREATEST(0, LEAST(100, privacy_score));
@@ -91,9 +91,11 @@ $$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION calculate_trust_score(service_id INT) CREATE OR REPLACE FUNCTION calculate_trust_score(service_id INT)
RETURNS INT AS $$ RETURNS INT AS $$
DECLARE DECLARE
trust_score INT := 50; -- Start from middle value (50) trust_score INT := 0;
verification_factor INT; verification_factor INT;
attributes_score INT := 0; attributes_score INT := 0;
recently_listed_factor INT := 0;
tos_penalty_factor INT := 0;
BEGIN BEGIN
-- Get verification status factor -- Get verification status factor
SELECT SELECT
@@ -124,7 +126,7 @@ BEGIN
AND "verificationStatus" = 'APPROVED' AND "verificationStatus" = 'APPROVED'
AND (NOW() - "listedAt") <= INTERVAL '15 days' AND (NOW() - "listedAt") <= INTERVAL '15 days'
) THEN ) THEN
trust_score := trust_score - 10; recently_listed_factor := -10;
-- Update the isRecentlyListed flag to true -- Update the isRecentlyListed flag to true
UPDATE "Service" UPDATE "Service"
SET "isRecentlyListed" = TRUE SET "isRecentlyListed" = TRUE
@@ -144,11 +146,11 @@ BEGIN
AND "tosReviewAt" IS NOT NULL AND "tosReviewAt" IS NOT NULL
AND "tosReview" IS NULL AND "tosReview" IS NULL
) THEN ) THEN
trust_score := trust_score - 3; tos_penalty_factor := -3;
END IF; END IF;
-- Calculate final trust score (base 100) -- Calculate final trust score (base 100)
trust_score := trust_score + verification_factor + attributes_score; trust_score := 50 + verification_factor + attributes_score + recently_listed_factor + tos_penalty_factor;
-- Ensure the score is in reasonable bounds (0-100) -- Ensure the score is in reasonable bounds (0-100)
trust_score := GREATEST(0, LEAST(100, trust_score)); trust_score := GREATEST(0, LEAST(100, trust_score));

View File

@@ -3,7 +3,20 @@ RETURNS TRIGGER AS $$
DECLARE DECLARE
suggestion_status_change "ServiceSuggestionStatusChange"; suggestion_status_change "ServiceSuggestionStatusChange";
BEGIN BEGIN
IF TG_OP = 'INSERT' THEN -- Corresponds to ServiceSuggestionMessage insert IF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestion' THEN -- Corresponds to ServiceSuggestion insert
-- Notify all admins when a new suggestion is created
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId")
SELECT u."id", 'SUGGESTION_CREATED', NEW."id"
FROM "User" u
WHERE u."admin" = true
AND NOT EXISTS (
SELECT 1 FROM "Notification" n
WHERE n."userId" = u."id"
AND n."type" = 'SUGGESTION_CREATED'
AND n."aboutServiceSuggestionId" = NEW."id"
);
ELSIF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestionMessage' THEN -- Corresponds to ServiceSuggestionMessage insert
-- Notify suggestion author (if not the sender) -- Notify suggestion author (if not the sender)
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId") INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId")
SELECT s."userId", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id" SELECT s."userId", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id"
@@ -55,6 +68,13 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- Trigger for new suggestions
DROP TRIGGER IF EXISTS service_suggestion_created_notifications_trigger ON "ServiceSuggestion";
CREATE TRIGGER service_suggestion_created_notifications_trigger
AFTER INSERT ON "ServiceSuggestion"
FOR EACH ROW
EXECUTE FUNCTION trigger_service_suggestion_notifications();
-- Trigger for new messages -- Trigger for new messages
DROP TRIGGER IF EXISTS service_suggestion_message_notifications_trigger ON "ServiceSuggestionMessage"; DROP TRIGGER IF EXISTS service_suggestion_message_notifications_trigger ON "ServiceSuggestionMessage";
CREATE TRIGGER service_suggestion_message_notifications_trigger CREATE TRIGGER service_suggestion_message_notifications_trigger

View File

@@ -36,6 +36,10 @@ const findPossibleDuplicates = async (input: { name: string }) => {
id: { id: {
in: matches.map(({ id }) => id), in: matches.map(({ id }) => id),
}, },
listedAt: { lte: new Date() },
serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
},
}, },
select: { select: {
id: true, id: true,
@@ -58,6 +62,8 @@ const serializeExtraNotes = <T extends Record<string, unknown>>(
serializedValue = value serializedValue = value
} else if (value === undefined || value === null) { } else if (value === undefined || value === null) {
serializedValue = '' serializedValue = ''
} else if (Array.isArray(value)) {
serializedValue = value.map((item) => String(item)).join(', ')
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') { } else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
// eslint-disable-next-line @typescript-eslint/no-base-to-string // eslint-disable-next-line @typescript-eslint/no-base-to-string
serializedValue = value.toString() serializedValue = value.toString()
@@ -144,17 +150,7 @@ export const serviceSuggestionActions = {
.max(SUGGESTION_SLUG_MAX_LENGTH) .max(SUGGESTION_SLUG_MAX_LENGTH)
.regex(/^[a-z0-9-]+$/, { .regex(/^[a-z0-9-]+$/, {
message: 'Slug must contain only lowercase letters, numbers, and hyphens', message: 'Slug must contain only lowercase letters, numbers, and hyphens',
}) }),
.refine(
async (slug) => {
const exists = await prisma.service.findUnique({
select: { id: true },
where: { slug },
})
return !exists
},
{ message: 'Slug must be unique, try a different one' }
),
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH), description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
allServiceUrls: stringListOfUrlsSchemaRequired, allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired, tosUrls: stringListOfUrlsSchemaRequired,
@@ -189,8 +185,16 @@ export const serviceSuggestionActions = {
location: 'serviceSuggestion.createService', location: 'serviceSuggestion.createService',
}) })
const serviceWithSameSlug = await prisma.service.findUnique({
select: { id: true, name: true, slug: true, description: true },
where: { slug: input.slug },
})
if (!input.skipDuplicateCheck) { if (!input.skipDuplicateCheck) {
const possibleDuplicates = await findPossibleDuplicates(input) const possibleDuplicates = [
...(serviceWithSameSlug ? [serviceWithSameSlug] : []),
...(await findPossibleDuplicates(input)),
]
if (possibleDuplicates.length > 0) { if (possibleDuplicates.length > 0) {
return { return {
@@ -208,6 +212,13 @@ export const serviceSuggestionActions = {
service: undefined, service: undefined,
} as const } as const
} }
} else {
if (serviceWithSameSlug) {
throw new ActionError({
message: 'Slug already in use, try a different one',
code: 'BAD_REQUEST',
})
}
} }
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name) const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)

View File

@@ -13,6 +13,7 @@ import HtmxScript from './HtmxScript.astro'
import NotificationEventsScript from './NotificationEventsScript.astro' import NotificationEventsScript from './NotificationEventsScript.astro'
import { makeOgImageUrl } from './OgImage' import { makeOgImageUrl } from './OgImage'
import ServerEventsScript from './ServerEventsScript.astro' import ServerEventsScript from './ServerEventsScript.astro'
import ServiceWorkerScript from './ServiceWorkerScript.astro'
import TailwindJsPluggin from './TailwindJsPluggin.astro' import TailwindJsPluggin from './TailwindJsPluggin.astro'
import type { ComponentProps } from 'astro/types' import type { ComponentProps } from 'astro/types'
@@ -137,10 +138,12 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
)) ))
} }
<!-- Server events --> {
<ServerEventsScript /> Astro.locals.user && (
<>
<!-- Push Notifications --> <ServerEventsScript />
<ServiceWorkerScript />
<script src="/src/pwa.ts"></script> <NotificationEventsScript />
<NotificationEventsScript /> </>
)
}

View File

@@ -66,7 +66,7 @@ function addBadgeIfUnread(href: string) {
} }
<script> <script>
document.addEventListener('sse-new-notification', () => { document.addEventListener('sse:new-notification', () => {
const links = document.querySelectorAll('link[rel="icon"]') const links = document.querySelectorAll('link[rel="icon"]')
links.forEach((link) => { links.forEach((link) => {
const href = link.getAttribute('href') const href = link.getAttribute('href')

View File

@@ -33,6 +33,12 @@ const links = [
icon: 'ri:plug-line', icon: 'ri:plug-line',
external: false, external: false,
}, },
{
href: '/feeds',
label: 'RSS',
icon: 'ri:rss-line',
external: false,
},
{ {
href: '/about', href: '/about',
label: 'About', label: 'About',
@@ -49,7 +55,10 @@ const links = [
const { class: className, ...htmlProps } = Astro.props const { class: className, ...htmlProps } = Astro.props
--- ---
<footer class={cn('flex items-center justify-center gap-6 p-4', className)} {...htmlProps}> <footer
class={cn('xs:gap-x-6 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 p-4', className)}
{...htmlProps}
>
{ {
links.map( links.map(
({ href, label, icon, external }) => ({ href, label, icon, external }) =>
@@ -58,9 +67,9 @@ const { class: className, ...htmlProps } = Astro.props
href={href} href={href}
target={external ? '_blank' : undefined} target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined} rel={external ? 'noopener noreferrer' : undefined}
class="text-day-500 flex items-center gap-1 text-sm transition-colors hover:text-gray-200 hover:underline" class="text-day-500 xs:gap-1 flex items-center gap-0.5 text-sm transition-colors hover:text-gray-200 hover:underline"
> >
<Icon name={icon} class="h-4 w-4" /> <Icon name={icon} class="xs:opacity-100 h-4 w-4 opacity-40" />
{label} {label}
</a> </a>
) )

View File

@@ -48,7 +48,7 @@ const count =
} }
<script> <script>
document.addEventListener('sse-new-notification', () => { document.addEventListener('sse:new-notification', () => {
document.querySelectorAll<HTMLElement>('[data-notification-count-link]').forEach((link) => { document.querySelectorAll<HTMLElement>('[data-notification-count-link]').forEach((link) => {
const currentCount = Number(link.getAttribute('data-current-count') || 0) const currentCount = Number(link.getAttribute('data-current-count') || 0)
const newCount = currentCount + 1 const newCount = currentCount + 1

View File

@@ -0,0 +1,61 @@
---
import { cn } from '../lib/cn'
import type InputWrapper from './InputWrapper.astro'
import type { AstroChildren } from '../lib/astro'
import type { ComponentProps } from 'astro/types'
type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & {
disabled?: boolean
id?: string
} & (
| {
label: string
children?: undefined
}
| {
label?: undefined
children: AstroChildren
}
)
const { disabled, name, required, error, id, label } = Astro.props
const hasError = !!error && error.length > 0
---
{}
<div>
<label
class={cn(
'inline-flex cursor-pointer items-center gap-2',
hasError && 'text-red-300',
disabled && 'cursor-not-allowed opacity-50'
)}
>
<input
transition:persist
type="checkbox"
id={id}
name={name}
required={required}
disabled={disabled}
class={cn(disabled && 'opacity-50')}
/>
<span class="text-sm leading-none text-pretty">{label ?? <slot />}</span>
</label>
{
hasError &&
(typeof error === 'string' ? (
<p class="text-sm text-red-500">{error}</p>
) : (
<ul class="text-sm text-red-500">
{error.map((e) => (
<li>{e}</li>
))}
</ul>
))
}
</div>

View File

@@ -27,6 +27,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
class={cn( class={cn(
baseInputClassNames.input, baseInputClassNames.input,
baseInputClassNames.textarea, baseInputClassNames.textarea,
!!inputProps?.rows && 'h-auto',
inputProps?.class, inputProps?.class,
hasError && baseInputClassNames.error, hasError && baseInputClassNames.error,
!!inputProps?.disabled && baseInputClassNames.disabled !!inputProps?.disabled && baseInputClassNames.disabled

View File

@@ -6,7 +6,7 @@
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications' import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
import { makeNotificationOptions } from '../lib/notificationOptions' import { makeNotificationOptions } from '../lib/notificationOptions'
document.addEventListener('sse-new-notification', (event) => { document.addEventListener('sse:new-notification', (event) => {
if (isBrowserNotificationsEnabled()) { if (isBrowserNotificationsEnabled()) {
const payload = event.detail const payload = event.detail
const notification = showBrowserNotification( const notification = showBrowserNotification(

View File

@@ -31,7 +31,7 @@ if (!Astro.locals.user) return
const data = JSON.parse(event.data as string) const data = JSON.parse(event.data as string)
if (isServerEventsEvent(data)) { if (isServerEventsEvent(data)) {
const eventType = `sse-${data.type}` as const const eventType = `sse:${data.type}` as const
document.dispatchEvent( document.dispatchEvent(
new CustomEvent(eventType, { detail: data.data }) as SSEEventMap[typeof eventType] new CustomEvent(eventType, { detail: data.data }) as SSEEventMap[typeof eventType]
) )

View File

@@ -0,0 +1,54 @@
---
---
<script>
import { registerSW } from 'virtual:pwa-register'
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Window {
__SW_REGISTRATION__?: ServiceWorkerRegistration
}
}
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
let hasPendingUpdate = false
const updateSW = registerSW({
immediate: true,
onRegisteredSW: (_swScriptUrl, registration) => {
if (registration) window.__SW_REGISTRATION__ = registration
document.addEventListener('astro:after-swap', checkAndApplyPendingUpdate, { passive: true })
window.addEventListener('popstate', checkAndApplyPendingUpdate, { passive: true })
},
onNeedRefresh: () => {
if (shouldSkipAutoReload()) {
void updateSW(false)
hasPendingUpdate = true
return
}
void updateSW(true)
},
onRegisterError: (error) => {
console.error('Service Worker registration error', error)
},
})
function shouldSkipAutoReload() {
const currentPath = window.location.pathname
const isErrorPage = document.querySelector('[data-is-error-page]') !== null
return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route)
}
function checkAndApplyPendingUpdate() {
if (hasPendingUpdate && !shouldSkipAutoReload()) {
hasPendingUpdate = false
void updateSW(true)
}
}
</script>

View File

@@ -40,6 +40,11 @@ export const {
label: 'New comment/rating', label: 'New comment/rating',
icon: 'ri:chat-4-line', icon: 'ri:chat-4-line',
}, },
{
id: 'SUGGESTION_CREATED',
label: 'New suggestion',
icon: 'ri:lightbulb-line',
},
{ {
id: 'SUGGESTION_MESSAGE', id: 'SUGGESTION_MESSAGE',
label: 'New message in suggestion', label: 'New message in suggestion',

View File

@@ -8,6 +8,7 @@ type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
value: T value: T
slug: string slug: string
label: string label: string
labelAlt: string
icon: string icon: string
order: number order: number
default: boolean default: boolean
@@ -33,12 +34,14 @@ export const {
order: Infinity, order: Infinity,
default: false, default: false,
color: 'zinc', color: 'zinc',
labelAlt: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
}), }),
[ [
{ {
value: 'CREATE_SERVICE', value: 'CREATE_SERVICE',
slug: 'create', slug: 'create',
label: 'Create', label: 'Create',
labelAlt: 'service',
icon: 'ri:add-line', icon: 'ri:add-line',
order: 1, order: 1,
default: true, default: true,
@@ -48,6 +51,7 @@ export const {
value: 'EDIT_SERVICE', value: 'EDIT_SERVICE',
slug: 'edit', slug: 'edit',
label: 'Edit', label: 'Edit',
labelAlt: 'edit',
icon: 'ri:pencil-line', icon: 'ri:pencil-line',
order: 2, order: 2,
default: false, default: false,

View File

@@ -16,7 +16,7 @@ type Props = ComponentProps<typeof BaseHead> & {
children: AstroChildren children: AstroChildren
errors?: string[] errors?: string[]
success?: string[] success?: string[]
className?: { classNames?: {
body?: string body?: string
main?: string main?: string
footer?: string footer?: string
@@ -31,14 +31,16 @@ type Props = ComponentProps<typeof BaseHead> & {
| 'max-w-screen-sm' | 'max-w-screen-sm'
| 'max-w-screen-xl' | 'max-w-screen-xl'
| 'max-w-screen-xs' | 'max-w-screen-xs'
isErrorPage?: boolean
} }
const { const {
errors = [], errors = [],
success = [], success = [],
className, classNames,
widthClassName = 'max-w-screen-2xl', widthClassName = 'max-w-screen-2xl',
showSplashText, showSplashText,
isErrorPage,
...baseHeadProps ...baseHeadProps
} = Astro.props } = Astro.props
@@ -77,7 +79,10 @@ const announcement = await Astro.locals.banners.try(
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<BaseHead {...baseHeadProps} /> <BaseHead {...baseHeadProps} />
</head> </head>
<body class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', className?.body)}> <body
class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.body)}
data-is-error-page={isErrorPage}
>
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />} {announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
<Header <Header
classNames={{ classNames={{
@@ -116,7 +121,7 @@ const announcement = await Astro.locals.banners.try(
<main <main
class={cn( class={cn(
'container mx-auto mt-4 mb-12 grow px-4', 'container mx-auto mt-4 mb-12 grow px-4',
className?.main, classNames?.main,
(widthClassName === 'max-w-none' || widthClassName === 'max-w-screen-2xl') && 'lg:px-8 2xl:px-12', (widthClassName === 'max-w-none' || widthClassName === 'max-w-screen-2xl') && 'lg:px-8 2xl:px-12',
widthClassName widthClassName
)} )}
@@ -124,6 +129,6 @@ const announcement = await Astro.locals.banners.try(
<slot /> <slot />
</main> </main>
<Footer class={className?.footer} /> <Footer class={classNames?.footer} />
</body> </body>
</html> </html>

View File

@@ -9,20 +9,30 @@ import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof BaseLayout>, 'widthClassName'> & { type Props = Omit<ComponentProps<typeof BaseLayout>, 'widthClassName'> & {
layoutHeader: { icon: string; title: string; subtitle?: string } layoutHeader: { icon: string; title: string; subtitle?: string }
size?: 'md' | 'xs'
} }
const { layoutHeader, ...baseLayoutProps } = Astro.props const { layoutHeader, size = 'xs', ...baseLayoutProps } = Astro.props
--- ---
<BaseLayout <BaseLayout
className={{ classNames={{
...baseLayoutProps.className, main: cn(
main: cn('xs:items-center-safe flex grow flex-col justify-center-safe', baseLayoutProps.className?.main), 'flex grow flex-col justify-center-safe',
{
'xs:items-center-safe': size === 'xs',
'md:items-center-safe': size === 'md',
},
baseLayoutProps.classNames?.main
),
}} }}
{...baseLayoutProps} {...baseLayoutProps}
> >
<div <div
class="bg-night-800 border-night-500 xs:block xs:max-w-screen-xs contents w-full rounded-xl border p-8" class={cn('bg-night-800 border-night-500 contents w-full rounded-xl border p-8', {
'xs:block xs:max-w-screen-xs': size === 'xs',
'md:block md:max-w-screen-md': size === 'md',
})}
> >
<div class="bg-day-200 mx-auto flex size-12 items-center justify-center rounded-lg"> <div class="bg-day-200 mx-auto flex size-12 items-center justify-center rounded-lg">
<Icon name={layoutHeader.icon} class="text-night-800 size-8" /> <Icon name={layoutHeader.icon} class="text-night-800 size-8" />
@@ -31,14 +41,24 @@ const { layoutHeader, ...baseLayoutProps } = Astro.props
<h1 <h1
class={cn( class={cn(
'font-title text-day-200 mt-1 text-center text-3xl font-semibold', 'font-title text-day-200 mt-1 text-center text-3xl font-semibold',
!layoutHeader.subtitle && 'xs:mb-8 mb-6' !layoutHeader.subtitle && {
'xs:mb-8 mb-6': size === 'xs',
'mb-6 md:mb-8': size === 'md',
}
)} )}
> >
{layoutHeader.title} {layoutHeader.title}
</h1> </h1>
{ {
!!layoutHeader.subtitle && ( !!layoutHeader.subtitle && (
<p class="text-day-500 xs:mb-8 mt-1 mb-6 text-center">{layoutHeader.subtitle}</p> <p
class={cn('text-day-500 mt-1 mb-6 text-center', {
'xs:mb-8': size === 'xs',
'md:mb-8': size === 'md',
})}
>
{layoutHeader.subtitle}
</p>
) )
} }

View File

@@ -9,6 +9,233 @@ import { formatDateShort } from './timeAgo'
import type { Prisma } from '@prisma/client' 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< export function sortAttributes<
T extends Prisma.AttributeGetPayload<{ T extends Prisma.AttributeGetPayload<{
select: { select: {
@@ -43,135 +270,19 @@ export function makeNonDbAttributes(
createdAt: true createdAt: true
tosReviewAt: true tosReviewAt: true
tosReview: true tosReview: true
onionUrls: true
i2pUrls: true
acceptedCurrencies: true
} }
}>, }>,
{ filter = false }: { filter?: boolean } = {} { filter = false }: { filter?: boolean } = {}
) { ) {
const nonDbAttributes: (Prisma.AttributeGetPayload<{ const attributes = nonDbAttributes.map(({ customize, ...attribute }) => ({
select: { ...attribute,
title: true ...customize(service),
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: [],
},
]
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 { eventTypesById } from '../constants/eventTypes'
import { getKarmaTransactionActionInfo } from '../constants/karmaTransactionActions' import { getKarmaTransactionActionInfo } from '../constants/karmaTransactionActions'
import { serviceVerificationStatusChangesById } from '../constants/serviceStatusChange' import { serviceVerificationStatusChangesById } from '../constants/serviceStatusChange'
import { getServiceSuggestionTypeInfo } from '../constants/serviceSuggestionType'
import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatusChange' import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatusChange'
import { makeCommentUrl } from './commentsWithReplies' import { makeCommentUrl } from './commentsWithReplies'
@@ -48,6 +49,7 @@ export function makeNotificationTitle(
aboutServiceSuggestion: { aboutServiceSuggestion: {
select: { select: {
status: true status: true
type: true
service: { service: {
select: { select: {
name: true name: true
@@ -131,6 +133,12 @@ export function makeNotificationTitle(
? `New unmoderated comment on ${service}` ? `New unmoderated comment on ${service}`
: `New 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': { case 'SUGGESTION_MESSAGE': {
if (!notification.aboutServiceSuggestionMessage) return 'New message on your suggestion' if (!notification.aboutServiceSuggestionMessage) return 'New message on your suggestion'
const service = notification.aboutServiceSuggestionMessage.suggestion.service.name const service = notification.aboutServiceSuggestionMessage.suggestion.service.name
@@ -219,6 +227,7 @@ export function makeNotificationContent(
if (!notification.aboutKarmaTransaction) return null if (!notification.aboutKarmaTransaction) return null
return notification.aboutKarmaTransaction.description return notification.aboutKarmaTransaction.description
} }
case 'SUGGESTION_CREATED':
case 'SUGGESTION_STATUS_CHANGE': case 'SUGGESTION_STATUS_CHANGE':
case 'ACCOUNT_STATUS_CHANGE': case 'ACCOUNT_STATUS_CHANGE':
case 'SERVICE_VERIFICATION_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': { case 'SUGGESTION_MESSAGE': {
if (!notification.aboutServiceSuggestionMessage) return [] if (!notification.aboutServiceSuggestionMessage) return []
return [ return [

View File

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

View File

@@ -45,7 +45,7 @@ export type ServerEventsEvent = {
}[keyof ServerEventsData] }[keyof ServerEventsData]
export type SSEEventMap = { 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 { declare global {

View File

@@ -21,7 +21,7 @@ const cleanUrl = (input: unknown) => {
export const zodUrlOptionalProtocol = z.preprocess( export const zodUrlOptionalProtocol = z.preprocess(
cleanUrl, 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', message: 'Invalid URL',
}) })
) )
@@ -42,7 +42,7 @@ export const zodContactMethod = z.preprocess(
.trim() .trim()
.refine( .refine(
(value) => (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 value
), ),
{ {

View File

@@ -7,11 +7,12 @@ import BaseLayout from '../layouts/BaseLayout.astro'
<BaseLayout <BaseLayout
pageTitle="404: Page Not Found" pageTitle="404: Page Not Found"
description="The page doesn't exist, double check the URL." description="The page doesn't exist, double check the URL."
className={{ classNames={{
main: 'm-0 -mt-16 flex max-w-none flex-col items-center justify-center bg-[#737373] pt-16 text-[#fafafa] [--speed:2s] perspective-distant [&_*]:[transform-style:preserve-3d]', main: 'm-0 -mt-16 flex max-w-none flex-col items-center justify-center bg-[#737373] pt-16 text-[#fafafa] [--speed:2s] perspective-distant [&_*]:[transform-style:preserve-3d]',
footer: 'bg-black', footer: 'bg-black',
}} }}
widthClassName="max-w-none" widthClassName="max-w-none"
isErrorPage
> >
<h1 <h1
data-text="404" data-text="404"

View File

@@ -1,6 +1,7 @@
--- ---
import { z } from 'astro/zod' import { z } from 'astro/zod'
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { LOGS_UI_URL } from 'astro:env/server'
import { SUPPORT_EMAIL } from '../constants/project' import { SUPPORT_EMAIL } from '../constants/project'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
@@ -21,9 +22,10 @@ const {
<BaseLayout <BaseLayout
pageTitle="500: Server Error" pageTitle="500: Server Error"
description="Sorry, something crashed on the server." description="Sorry, something crashed on the server."
className={{ classNames={{
main: 'relative my-0 flex flex-col items-center justify-center px-6 py-24 text-center sm:py-32 lg:px-8', main: 'relative my-0 flex flex-col items-center justify-center px-6 py-24 text-center sm:py-32 lg:px-8',
}} }}
isErrorPage
> >
<Icon <Icon
name="ri:bug-line" name="ri:bug-line"
@@ -93,6 +95,20 @@ const {
/> />
Contact support Contact support
</a> </a>
{
Astro.locals.user?.admin && !!LOGS_UI_URL && (
<a
href={LOGS_UI_URL}
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
>
<Icon
name="ri:menu-search-line"
class="size-5 transition-transform group-hover:-translate-y-1 group-active:translate-y-0"
/>
View logs <span class="text-xs text-gray-400">(admin only)</span>
</a>
)
}
</div> </div>
</BaseLayout> </BaseLayout>

View File

@@ -25,7 +25,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
<BaseLayout <BaseLayout
pageTitle="403: Access Denied" pageTitle="403: Access Denied"
description="You don't have permission to access this page." description="You don't have permission to access this page."
className={{ classNames={{
main: 'my-0 flex flex-col items-center justify-center px-6 py-24 text-center sm:py-32 lg:px-8', main: 'my-0 flex flex-col items-center justify-center px-6 py-24 text-center sm:py-32 lg:px-8',
body: 'cursor-not-allowed bg-[oklch(0.16_0.07_31.84)] text-red-50', body: 'cursor-not-allowed bg-[oklch(0.16_0.07_31.84)] text-red-50',
}} }}

View File

@@ -169,7 +169,7 @@ if (!user) return Astro.rewrite('/404')
icon: 'ri:user-3-line', icon: 'ri:user-3-line',
}} }}
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
className={{ classNames={{
main: 'space-y-6', main: 'space-y-6',
}} }}
breadcrumbs={[ breadcrumbs={[

View File

@@ -372,7 +372,7 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div> </div>
</div> </div>
<div class="scrollbar-thin max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<div class="min-w-[750px]"> <div class="min-w-[750px]">
<table class="w-full divide-y divide-zinc-700"> <table class="w-full divide-y divide-zinc-700">
<thead class="bg-zinc-900/30"> <thead class="bg-zinc-900/30">
@@ -721,51 +721,3 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div> </div>
</div> </div>
</BaseLayout> </BaseLayout>
<style>
@keyframes highlight {
0% {
background-color: rgba(59, 130, 246, 0.1);
}
50% {
background-color: rgba(59, 130, 246, 0.3);
}
100% {
background-color: transparent;
}
}
/* Base CSS text size utility */
.text-2xs {
font-size: 0.6875rem; /* 11px */
line-height: 1rem; /* 16px */
}
/* Scrollbar styling for better mobile experience */
.scrollbar-thin::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.2);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(75, 85, 99, 0.5);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.6);
}
@media (max-width: 768px) {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(30, 41, 59, 0.2); /* thumb track for firefox*/
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
}
}
</style>

View File

@@ -16,9 +16,6 @@ const releaseDate =
title: 'Releases', title: 'Releases',
subtitle: 'Current release', subtitle: 'Current release',
}} }}
className={{
main: 'flex flex-col items-center justify-center text-center',
}}
> >
<p class="text-day-200 font-title text-center text-6xl font-medium tracking-wider"> <p class="text-day-200 font-title text-center text-6xl font-medium tracking-wider">
{RELEASE_NUMBER ? `#${RELEASE_NUMBER}` : '???'} {RELEASE_NUMBER ? `#${RELEASE_NUMBER}` : '???'}
@@ -36,7 +33,7 @@ const releaseDate =
</time> </time>
{ {
!!releaseDate && ( !!releaseDate && (
<p class="text-day-500 mt-2"> <p class="text-day-500 mt-2 text-center">
(<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>) (<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>)
</p> </p>
) )

View File

@@ -352,7 +352,7 @@ const apiCalls = await Astro.locals.banners.try(
/> />
<InputTextArea <InputTextArea
label="ToS URLs" label="ToS URLs"
description="One per line" description="One per line. AI review uses the first working URL only."
name="tosUrls" name="tosUrls"
inputProps={{ inputProps={{
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos', placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',

View File

@@ -120,7 +120,7 @@ if (!user) return Astro.rewrite('/404')
<BaseLayout <BaseLayout
pageTitle={`${user.displayName ?? user.name} - User`} pageTitle={`${user.displayName ?? user.name} - User`}
widthClassName="max-w-screen-lg" widthClassName="max-w-screen-lg"
className={{ main: 'space-y-24' }} classNames={{ main: 'space-y-24' }}
> >
<div class="mt-12"> <div class="mt-12">
{ {

View File

@@ -11,7 +11,7 @@ import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes' import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { getVerificationStatusInfo } from '../constants/verificationStatus' import { getVerificationStatusInfo } from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import { sortAttributes } from '../lib/attributes' import { nonDbAttributes, sortAttributes } from '../lib/attributes'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { formatNumber } from '../lib/numbers' import { formatNumber } from '../lib/numbers'
import { makeOverallScoreInfo } from '../lib/overallScore' import { makeOverallScoreInfo } from '../lib/overallScore'
@@ -59,9 +59,14 @@ const attributes = await Astro.locals.banners.try(
) )
const sortBy = filters['sort-by'] const sortBy = filters['sort-by']
const mergedAttributes = [
...nonDbAttributes.map((attribute) => ({ ...attribute, services: [], id: attribute.slug })),
...attributes,
]
const sortedAttributes = sortBy const sortedAttributes = sortBy
? orderBy( ? orderBy(
sortAttributes(attributes), sortAttributes(mergedAttributes),
sortBy === 'type' sortBy === 'type'
? (attribute) => getAttributeTypeInfo(attribute.type).order ? (attribute) => getAttributeTypeInfo(attribute.type).order
: sortBy === 'category' : sortBy === 'category'
@@ -73,7 +78,7 @@ const sortedAttributes = sortBy
: 'trustPoints', : 'trustPoints',
filters['sort-order'] filters['sort-order']
) )
: sortAttributes(attributes) : sortAttributes(mergedAttributes)
const attributesWithInfo = sortedAttributes.map((attribute) => ({ const attributesWithInfo = sortedAttributes.map((attribute) => ({
...attribute, ...attribute,
@@ -292,7 +297,10 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
<label <label
for={`show-services-${attribute.id}`} for={`show-services-${attribute.id}`}
class="col-span-full grid cursor-pointer list-none grid-cols-subgrid items-center rounded-sm p-2 peer-checked/show-services:[&_[data-expand-icon]]:rotate-180" class={cn(
'col-span-full grid cursor-pointer list-none grid-cols-subgrid items-center rounded-sm p-2 peer-checked/show-services:[&_[data-expand-icon]]:rotate-180',
attribute.services.length === 0 && 'cursor-default'
)}
aria-label={`Show services for ${attribute.title}`} aria-label={`Show services for ${attribute.title}`}
> >
<h3 class={cn('text-lg font-bold', attribute.typeInfo.classNames.text)}>{attribute.title}</h3> <h3 class={cn('text-lg font-bold', attribute.typeInfo.classNames.text)}>{attribute.title}</h3>
@@ -339,7 +347,9 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div> </div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<Icon name="ri:arrow-down-s-line" class="size-6" data-expand-icon /> {attribute.services.length > 0 && (
<Icon name="ri:arrow-down-s-line" class="size-6" data-expand-icon />
)}
</div> </div>
</label> </label>

View File

@@ -47,11 +47,6 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
where: { where: {
listedAt: { lte: new Date() }, listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
events: {
some: {
visible: true,
},
},
}, },
select: { select: {
id: true, id: true,
@@ -162,7 +157,7 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
pageTitle="Events" pageTitle="Events"
description="Discover important events, updates, and news about KYC-free services in chronological order." description="Discover important events, updates, and news about KYC-free services in chronological order."
widthClassName="max-w-screen-lg" widthClassName="max-w-screen-lg"
className={{ main: 'sm:flex sm:items-start sm:gap-6' }} classNames={{ main: 'sm:flex sm:items-start sm:gap-6' }}
ogImage={{ ogImage={{
template: 'generic', template: 'generic',
title: 'Events', title: 'Events',

View File

@@ -0,0 +1,41 @@
import rss from '@astrojs/rss'
import { SITE_URL } from 'astro:env/client'
import { getEventTypeInfo } from '../../constants/eventTypes'
import { getEvents } from '../../lib/feeds'
import type { APIRoute } from 'astro'
export const GET: APIRoute = async (context) => {
try {
const origin = context.site?.origin ?? new URL(SITE_URL).origin
const result = await getEvents()
if (!result.success) return new Response(result.error.message, result.error.responseInit)
const { events } = result.data
return await rss({
title: 'KYCnot.me - Service Events',
description: 'Latest events and updates from privacy-focused services tracked on KYCnot.me',
site: origin,
xmlns: { atom: 'http://www.w3.org/2005/Atom' },
items: events.map((event) => {
const eventTypeInfo = getEventTypeInfo(event.type)
const isOngoing = !event.endedAt || event.endedAt > new Date()
const statusText = isOngoing ? 'Ongoing' : 'Resolved'
return {
title: `${event.service.name}: ${event.title}`,
pubDate: event.createdAt,
description: `${event.content}${event.source ? `\n\nSource: ${event.source}` : ''}`,
link: `/service/${event.service.slug}/#event-${String(event.id)}`,
categories: [eventTypeInfo.label, event.service.name, statusText],
}
}),
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
})
} catch (error) {
console.error('Error generating events RSS feed:', error)
return new Response('Error generating RSS feed', { status: 500 })
}
}

View File

@@ -0,0 +1,87 @@
---
import { Icon } from 'astro-icon/components'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { takeCounts } from '../../lib/feeds'
const user = Astro.locals.user
const feeds = [
{
title: user ? 'Your notifications' : 'User notifications',
description: `Last ${takeCounts.userNotifications.toLocaleString()} of your notifications`,
rss: user ? `/feeds/user/${user.feedId}/notifications.xml` : '/feeds/user/[feedId]/notifications.xml',
icon: 'ri:notification-line',
replacementDescription: user
? null
: "Replace [feedId] with the user feed ID, found in the user's notifications page",
},
{
title: 'Service comments',
description: `Last ${takeCounts.serviceComments.toLocaleString()} comments (and reviews) from a specific service`,
rss: '/feeds/service/[slug]/comments.xml',
icon: 'ri:chat-3-line',
replacementDescription: 'Replace [slug] with the actual service slug',
},
{
title: 'Service events',
description: `Last ${takeCounts.serviceEvents.toLocaleString()} events from a specific service`,
rss: '/feeds/service/[slug]/events.xml',
icon: 'ri:calendar-event-line',
replacementDescription: 'Replace [slug] with the actual service slug',
},
{
title: 'All events',
description: `Last ${takeCounts.allEvents.toLocaleString()} events from all listed services`,
rss: '/feeds/events.xml',
icon: 'ri:calendar-2-line',
replacementDescription: null,
},
] as const satisfies {
title: string
description: string
rss: `/feeds/${string}`
icon: string
replacementDescription: string | null
}[]
---
<MiniLayout
pageTitle="RSS Feeds"
description="Subscribe to RSS feeds to stay updated with the latest comments and events on KYCnot.me"
ogImage={{
template: 'generic',
title: 'RSS Feeds',
description: 'Subscribe to stay updated',
icon: 'ri:rss-line',
}}
layoutHeader={{
icon: 'ri:rss-line',
title: 'RSS Feeds',
subtitle: 'Copy the feed URL to your RSS reader',
}}
size="md"
>
<div class="space-y-8">
{
feeds.map((feed) => (
<div>
<div class="flex flex-row items-center justify-center gap-2">
<Icon name={feed.icon} class="inline-block size-6 shrink-0 text-white" />
<h2 class="text-left text-lg leading-tight font-bold text-white">{feed.title}</h2>
</div>
<p class="text-day-300 mt-1 text-center text-sm text-balance">{feed.description}</p>
<div
class="border-night-500 bg-night-600 relative mt-2 rounded-lg border px-4 py-2 font-mono break-all text-white"
set:text={`${Astro.url.origin}${feed.rss}`}
/>
{!!feed.replacementDescription && (
<p class="text-day-500 mt-1 text-center text-xs italic">{feed.replacementDescription}</p>
)}
</div>
))
}
</div>
</MiniLayout>

View File

@@ -0,0 +1,61 @@
import rss from '@astrojs/rss'
import { SITE_URL } from 'astro:env/client'
import { getServiceUserRoleInfo } from '../../../../constants/serviceUserRoles'
import { makeCommentUrl } from '../../../../lib/commentsWithReplies'
import { getCommentsForService } from '../../../../lib/feeds'
import type { APIRoute } from 'astro'
export const GET: APIRoute = async (context) => {
try {
const origin = context.site?.origin ?? new URL(SITE_URL).origin
const result = await getCommentsForService(context.params.slug)
if (!result.success) return new Response(result.error.message, result.error.responseInit)
const { service, comments } = result.data
return await rss({
title: `${service.name} - Comments & Reviews | KYCnot.me`,
description: `Latest comments and reviews about ${service.name} from KYCnot.me users`,
site: origin,
xmlns: { dc: 'http://purl.org/dc/elements/1.1/', atom: 'http://www.w3.org/2005/Atom' },
items: comments.map((comment) => {
const authorName = comment.author.displayName ?? comment.author.name
const isRating = comment.ratingActive && comment.rating
const title = isRating
? `${authorName} rated ${service.name} (${String(comment.rating)}/5 stars)`
: `${authorName} commented on ${service.name}`
const badges = [
comment.author.verified ? '✅' : null,
comment.author.spammer ? '(Spammer)' : null,
comment.author.admin ? '(Admin)' : null,
comment.author.moderator && !comment.author.admin ? '(Moderator)' : null,
...comment.author.serviceAffiliations.map(
(affiliation) =>
` (${getServiceUserRoleInfo(affiliation.role).label} at ${affiliation.service.name})`
),
].filter((badge) => badge !== null)
return {
title,
pubDate: comment.createdAt,
description: comment.content,
link: makeCommentUrl({
origin,
serviceSlug: service.slug,
commentId: comment.id,
}),
categories: isRating ? ['Rating'] : ['Comment'],
guid: `${service.slug}-comment-${String(comment.id)}`,
customData: `<dc:creator>${authorName}${badges.length > 0 ? ` ${badges.join(' ')}` : ''}</dc:creator>`,
}
}),
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
})
} catch (error) {
console.error('Error generating service comments RSS feed:', error)
return new Response('Error generating RSS feed', { status: 500 })
}
}

View File

@@ -0,0 +1,41 @@
import rss from '@astrojs/rss'
import { SITE_URL } from 'astro:env/client'
import { getEventTypeInfo } from '../../../../constants/eventTypes'
import { getEventsForService } from '../../../../lib/feeds'
import type { APIRoute } from 'astro'
export const GET: APIRoute = async (context) => {
try {
const origin = context.site?.origin ?? new URL(SITE_URL).origin
const result = await getEventsForService(context.params.slug)
if (!result.success) return new Response(result.error.message, result.error.responseInit)
const { service, events } = result.data
return await rss({
title: `${service.name} - Events & Updates | KYCnot.me`,
description: `Latest events and updates for ${service.name} tracked on KYCnot.me`,
site: origin,
xmlns: { atom: 'http://www.w3.org/2005/Atom' },
items: events.map((event) => {
const eventTypeInfo = getEventTypeInfo(event.type)
const isOngoing = !event.endedAt || event.endedAt > new Date()
const statusText = isOngoing ? 'Ongoing' : 'Resolved'
return {
title: `${service.name}: ${event.title}`,
pubDate: event.createdAt,
description: `${event.content}${event.source ? `\n\nSource: ${event.source}` : ''}`,
link: `/service/${service.slug}/#event-${String(event.id)}`,
categories: [eventTypeInfo.label, statusText],
}
}),
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
})
} catch (error) {
console.error('Error generating service events RSS feed:', error)
return new Response('Error generating RSS feed', { status: 500 })
}
}

View File

@@ -0,0 +1,52 @@
import rss from '@astrojs/rss'
import { SITE_URL } from 'astro:env/client'
import { getNotificationTypeInfo } from '../../../../constants/notificationTypes'
import { getUserNotifications } from '../../../../lib/feeds'
import {
makeNotificationActions,
makeNotificationContent,
makeNotificationTitle,
} from '../../../../lib/notifications'
import type { APIRoute } from 'astro'
export const GET: APIRoute = async (context) => {
try {
const origin = context.site?.origin ?? new URL(SITE_URL).origin
const feedId = context.params.feedId
const result = await getUserNotifications(feedId)
if (!result.success) return new Response(result.error.message, result.error.responseInit)
const { user, notifications } = result.data
const displayName = user.displayName ?? user.name
return await rss({
title: `${displayName}'s Notifications - KYCnot.me`,
description: `Notifications for ${displayName} on KYCnot.me - Privacy-focused service reviews and updates`,
site: origin,
xmlns: { atom: 'http://www.w3.org/2005/Atom' },
items: notifications.map((notification) => {
const typeInfo = getNotificationTypeInfo(notification.type)
const title = makeNotificationTitle(notification, user)
const description = makeNotificationContent(notification) ?? typeInfo.label
const actions = makeNotificationActions(notification, origin)
const link = actions[0]?.url ?? `${origin}/notifications`
return {
title,
pubDate: notification.createdAt,
description,
link,
categories: [typeInfo.label],
}
}),
customData: `<language>en-us</language><atom:link href="${context.url.href}" rel="self" type="application/rss+xml"/>`,
})
} catch (error) {
console.error('Error generating user notifications RSS feed:', error)
return new Response('Error generating RSS feed', { status: 500 })
}
}

View File

@@ -4,6 +4,7 @@ import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import Button from '../components/Button.astro' import Button from '../components/Button.astro'
import CopyButton from '../components/CopyButton.astro'
import PushNotificationBanner from '../components/PushNotificationBanner.astro' import PushNotificationBanner from '../components/PushNotificationBanner.astro'
import TimeFormatted from '../components/TimeFormatted.astro' import TimeFormatted from '../components/TimeFormatted.astro'
import Tooltip from '../components/Tooltip.astro' import Tooltip from '../components/Tooltip.astro'
@@ -84,6 +85,7 @@ const [dbNotifications, notificationPreferences, totalNotifications, pushSubscri
aboutServiceSuggestion: { aboutServiceSuggestion: {
select: { select: {
status: true, status: true,
type: true,
service: { service: {
select: { select: {
name: true, name: true,
@@ -256,7 +258,7 @@ const notifications = dbNotifications.map((notification) => ({
label="Reload" label="Reload"
icon="ri:refresh-line" icon="ri:refresh-line"
color="white" color="white"
class="ml-auto" class="no-js:hidden ml-auto"
onclick="window.location.reload()" onclick="window.location.reload()"
/> />
</div> </div>
@@ -404,11 +406,73 @@ const notifications = dbNotifications.map((notification) => ({
<Button type="submit" label="Save" icon="ri:save-line" color="success" /> <Button type="submit" label="Save" icon="ri:save-line" color="success" />
</div> </div>
</form> </form>
<div
class="relative isolate mt-3 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 p-6 shadow-sm"
>
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<div
class="absolute top-0 -left-16 h-full w-1/3 bg-gradient-to-r from-zinc-500/20 to-transparent opacity-50 blur-xl"
>
</div>
<div
class="absolute top-0 -right-16 h-full w-1/3 bg-gradient-to-l from-zinc-500/20 to-transparent opacity-50 blur-xl"
>
</div>
</div>
<div class="mb-4 flex items-center gap-3">
<div class="rounded-md bg-zinc-800 p-2">
<Icon name="ri:rss-line" class="size-6 text-zinc-300" />
</div>
<h3 class="font-title text-xl font-bold text-zinc-200">RSS feeds available</h3>
</div>
<div class="space-y-4">
<div>
<p class="mb-1 text-sm text-zinc-400">
Subscribe to receive your notifications in your favorite RSS reader.
</p>
<div class="flex items-center gap-2">
<input
type="text"
readonly
value={`${Astro.url.origin}/feeds/user/${user.feedId}/notifications.xml`}
class="flex-1 rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 select-all"
/>
<CopyButton
copyText={`${Astro.url.origin}/feeds/user/${user.feedId}/notifications.xml`}
color="white"
/>
</div>
</div>
<a
href="/feeds"
class="flex items-center justify-between rounded-lg border border-zinc-700/50 bg-zinc-800/30 p-3"
>
<div>
<h4 class="text-sm font-semibold text-zinc-300">Public RSS feeds</h4>
<p class="text-xs text-zinc-500">
Don't require an account to subscribe. Includes service comments and events.
</p>
</div>
<Button
as="span"
label="Browse all"
icon="ri:arrow-right-line"
variant="faded"
color="white"
class="pointer-events-none"
/>
</a>
</div>
</div>
</section> </section>
</BaseLayout> </BaseLayout>
<script> <script>
document.addEventListener('sse-new-notification', () => { document.addEventListener('sse:new-notification', () => {
document.querySelectorAll<HTMLElement>('[data-new-notification-banner]').forEach((banner) => { document.querySelectorAll<HTMLElement>('[data-new-notification-banner]').forEach((banner) => {
banner.style.display = '' banner.style.display = ''
}) })

View File

@@ -1,6 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions, isInputError } from 'astro:actions'
import { orderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
import { import {
@@ -11,6 +11,7 @@ import {
} from '../../actions/serviceSuggestion' } from '../../actions/serviceSuggestion'
import Captcha from '../../components/Captcha.astro' import Captcha from '../../components/Captcha.astro'
import InputCardGroup from '../../components/InputCardGroup.astro' import InputCardGroup from '../../components/InputCardGroup.astro'
import InputCheckbox from '../../components/InputCheckbox.astro'
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro' import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro' import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
import InputImageFile from '../../components/InputImageFile.astro' import InputImageFile from '../../components/InputImageFile.astro'
@@ -36,7 +37,7 @@ const result = Astro.getActionResult(actions.serviceSuggestion.createService)
if (result && !result.error && !result.data.hasDuplicates) { if (result && !result.error && !result.data.hasDuplicates) {
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`) return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
} }
const inputErrors = result?.error?.code === 'VALIDATION_ERROR' ? result.error.fields : {} const inputErrors = isInputError(result?.error) ? result.error.fields : {}
const [categories, attributes] = await Astro.locals.banners.tryMany([ const [categories, attributes] = await Astro.locals.banners.tryMany([
[ [
@@ -239,7 +240,7 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
<InputTextArea <InputTextArea
label="ToS URLs" label="ToS URLs"
description="One per line" description="One per line. AI review uses the first working URL only."
name="tosUrls" name="tosUrls"
inputProps={{ inputProps={{
placeholder: 'example.com/tos', placeholder: 'example.com/tos',
@@ -349,16 +350,10 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
<Captcha action={actions.serviceSuggestion.createService} /> <Captcha action={actions.serviceSuggestion.createService} />
<div> <InputCheckbox name="rulesConfirm" required error={inputErrors.rulesConfirm}>
<div class="flex items-center gap-2 text-lg"> I understand the
<input type="checkbox" name="rulesConfirm" id="rules-confirm" required /> <a class="underline" target="_blank" href="/about#listings">suggestion rules and process</a>
<label for="rules-confirm"> </InputCheckbox>
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" /> <InputHoneypotTrap name="message" />

View File

@@ -198,7 +198,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
icon: 'ri:user-3-line', icon: 'ri:user-3-line',
}} }}
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
className={{ classNames={{
main: 'space-y-6', main: 'space-y-6',
}} }}
htmx htmx

View File

@@ -1,21 +0,0 @@
import { registerSW } from 'virtual:pwa-register'
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Window {
__SW_REGISTRATION__?: ServiceWorkerRegistration
}
}
export const updateSW = registerSW({
immediate: true,
onRegisteredSW: (_swScriptUrl, registration) => {
if (registration) window.__SW_REGISTRATION__ = registration
},
onNeedRefresh: () => {
void updateSW(true)
},
onRegisterError: (error) => {
console.error('Service Worker registration error', error)
},
})