Release 202506041937

This commit is contained in:
pluja
2025-06-04 19:37:33 +00:00
parent dacf73a804
commit 02e52d7351
11 changed files with 298 additions and 278 deletions

View File

@@ -0,0 +1,46 @@
# Database
POSTGRES_USER=your_db_user
POSTGRES_PASSWORD=your_db_password
POSTGRES_DATABASE=your_db_name
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DATABASE}?schema=public"
DATABASE_UI_URL="https://db.example.com"
# Generic Config
UPLOAD_DIR=/app/uploads
SITE_URL="https://your-site.example.com"
SOURCE_CODE_URL="https://your-source-code.example.com"
TIME_TRAP_SECRET=your_time_trap_secret
LOGS_UI_URL="https://logs.example.com"
# Release Info
RELEASE_NUMBER=
RELEASE_DATE=
# Redis
REDIS_URL="redis://redis:6379"
# Crawl4AI
CRAWL4AI_BASE_URL="http://crawl4ai:11235"
CRAWL4AI_API_TOKEN=your_crawl4ai_token
# Tor and I2P
ONION_ADDRESS="http://youronionaddress.onion"
I2P_ADDRESS="http://youri2paddress.b32.i2p"
I2P_PASS=your_i2p_password
# Push Notifications
VAPID_PUBLIC_KEY=your_vapid_public_key
VAPID_PRIVATE_KEY=your_vapid_private_key
VAPID_SUBJECT="mailto:your-email@example.com"
# OpenAI
OPENAI_API_KEY=your_openai_api_key
OPENAI_BASE_URL="https://your-openai-base-url.example.com"
OPENAI_MODEL=your_openai_model
OPENAI_RETRY=3
# Pyworker Crons
CRON_TOSREVIEW_TASK="0 0 1 * *" # Every month
CRON_USER_SENTIMENT_TASK="0 0 * * *" # Every day
CRON_COMMENT_MODERATION_TASK="0 * * * *" # Every hour
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # Every day

View File

@@ -7,10 +7,8 @@ services:
volumes:
- database:/var/lib/postgresql/data:z
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
env_file:
- .env
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"]
interval: 10s
@@ -21,18 +19,15 @@ services:
build:
context: ./pyworker
restart: always
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
CRAWL4AI_BASE_URL: "http://crawl4ai:11235"
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing}
env_file:
- .env
crawl4ai:
image: unclecode/crawl4ai:basic-amd64
expose:
- "11235"
environment:
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing} # Optional API security
MAX_CONCURRENT_TASKS: 10
env_file:
- .env
volumes:
- /dev/shm:/dev/shm
deploy:
@@ -53,15 +48,9 @@ services:
astro:
build:
context: ./web
dockerfile: web/Dockerfile
image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest}
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
REDIS_URL: "redis://redis:6379"
env_file:
- .env
depends_on:

View File

@@ -1,17 +1,20 @@
FROM node:lts AS runtime
WORKDIR /app
COPY package.json package-lock.json ./
COPY .env .env
COPY web/package.json web/package-lock.json ./
COPY .npmrc .npmrc
COPY web/.npmrc .npmrc
RUN npm ci
COPY . .
COPY web/ .
ARG ASTRO_BUILD_MODE=production
# Generate Prisma client
RUN npx prisma generate
# Build the application
RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
@@ -20,7 +23,7 @@ ENV PORT=4321
EXPOSE 4321
# Add knm-migrate command script and make it executable
COPY migrate.sh /usr/local/bin/knm-migrate
COPY web/migrate.sh /usr/local/bin/knm-migrate
RUN chmod +x /usr/local/bin/knm-migrate
CMD ["node", "./dist/server/entry.mjs"]

View File

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

View File

@@ -128,6 +128,7 @@ enum AccountStatusChange {
}
enum NotificationType {
TEST
COMMENT_STATUS_CHANGE
REPLY_COMMENT_CREATED
COMMUNITY_NOTE_ADDED

View File

@@ -1,80 +1,36 @@
import { z } from 'astro/zod'
import { sumBy } from 'lodash-es'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import { sendPushNotification } from '../../lib/webPush'
import { sendNotifications } from '../../lib/sendNotifications'
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
export const adminNotificationActions = {
webPush: {
test: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userNames: stringListOfSlugsSchemaRequired,
title: z.string().min(1).nullable(),
body: z.string().nullable(),
url: z.string().url().optional(),
}),
handler: async (input) => {
const subscriptions = await prisma.pushSubscription.findMany({
where: { user: { name: { in: input.userNames } } },
select: {
id: true,
endpoint: true,
p256dh: true,
auth: true,
userAgent: true,
user: {
select: {
id: true,
name: true,
},
},
},
})
const results = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
{
title: input.title ?? 'Test Notification',
body: input.body ?? 'This is a test push notification from KYCNot.me',
url: input.url ?? '/',
}
)
// If subscription is invalid, remove it from database
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({
where: { id: subscription.id },
})
console.info(`Removed invalid subscription for user ${subscription.user.name}`)
}
return result.success
})
)
const successCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 1 : 0))
const failureCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 0 : 1))
const now = new Date()
return {
message: `Sent to ${successCount.toLocaleString()} devices, ${failureCount.toLocaleString()} failed. Sent at ${now.toLocaleString()}`,
totalSubscriptions: subscriptions.length,
successCount,
failureCount,
sentAt: now,
}
},
test: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userNames: stringListOfSlugsSchemaRequired,
}),
},
handler: async (input) => {
const users = await prisma.user.findMany({
where: { name: { in: input.userNames } },
select: { id: true },
})
const notifications = await prisma.notification.createManyAndReturn({
data: users.map((user) => ({
type: 'TEST',
userId: user.id,
})),
select: { id: true },
})
const results = await sendNotifications(notifications.map((notification) => notification.id))
return {
message: `Sent to ${results.subscriptions.success.toLocaleString()} devices, ${results.subscriptions.failure.toLocaleString()} failed.`,
}
},
}),
}

View File

@@ -20,6 +20,11 @@ export const {
icon: 'ri:notification-line',
}),
[
{
id: 'TEST',
label: 'Test notification',
icon: 'ri:flask-line',
},
{
id: 'COMMENT_STATUS_CHANGE',
label: 'Comment status changed',

View File

@@ -12,6 +12,7 @@ import type { Prisma } from '@prisma/client'
export function makeNotificationTitle(
notification: Prisma.NotificationGetPayload<{
select: {
id: true
type: true
aboutAccountStatusChange: true
aboutCommentStatusChange: true
@@ -87,6 +88,9 @@ export function makeNotificationTitle(
user: Prisma.UserGetPayload<{ select: { id: true } }> | null
): string {
switch (notification.type) {
case 'TEST': {
return `Test notification #${notification.id.toString()}`
}
case 'COMMENT_STATUS_CHANGE': {
if (!notification.aboutComment) return 'A comment you are watching had a status change'
@@ -178,6 +182,7 @@ export function makeNotificationTitle(
export function makeNotificationContent(
notification: Prisma.NotificationGetPayload<{
select: {
createdAt: true
type: true
aboutKarmaTransaction: {
select: {
@@ -204,6 +209,9 @@ export function makeNotificationContent(
}>
): string | null {
switch (notification.type) {
case 'TEST': {
return `Created on ${notification.createdAt.toLocaleString()}`
}
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// case 'KARMA_UNLOCK':
case 'KARMA_CHANGE': {
@@ -280,6 +288,9 @@ export function makeNotificationLink(
origin: string
): string | null {
switch (notification.type) {
case 'TEST': {
return `${origin}/notifications`
}
case 'COMMENT_STATUS_CHANGE':
case 'REPLY_COMMENT_CREATED':
case 'COMMUNITY_NOTE_ADDED':

View File

@@ -2,15 +2,12 @@ import { z } from 'astro/zod'
import { Client } from 'pg'
import { zodParseJSON } from './json'
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications'
import { prisma } from './prisma'
import { sendNotifications } from './sendNotifications'
import { getServerEnvVariable } from './serverEnvVariables'
import { sendPushNotification, type NotificationData } from './webPush'
import type { AstroIntegration, HookParameters } from 'astro'
const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
const SITE_URL = getServerEnvVariable('SITE_URL')
let pgClient: Client | null = null
@@ -24,155 +21,10 @@ async function handleNotificationCreated(
try {
logger.info(`Processing notification with ID: ${String(notificationId)}`)
const notification = await prisma.notification.findUnique({
where: { id: notificationId },
select: {
id: true,
type: true,
userId: true,
aboutAccountStatusChange: true,
aboutCommentStatusChange: true,
aboutServiceVerificationStatusChange: true,
aboutSuggestionStatusChange: true,
aboutComment: {
select: {
id: true,
author: { select: { id: true } },
status: true,
content: true,
communityNote: true,
parent: {
select: {
author: {
select: {
id: true,
},
},
},
},
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutServiceSuggestionId: true,
aboutServiceSuggestion: {
select: {
status: true,
service: {
select: {
name: true,
},
},
},
},
aboutServiceSuggestionMessage: {
select: {
id: true,
content: true,
suggestion: {
select: {
id: true,
service: {
select: {
name: true,
},
},
},
},
},
},
aboutEvent: {
select: {
title: true,
type: true,
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutService: {
select: {
slug: true,
name: true,
verificationStatus: true,
},
},
aboutKarmaTransaction: {
select: {
points: true,
action: true,
description: true,
},
},
user: {
select: {
id: true,
name: true,
},
},
},
})
if (!notification) {
logger.warn(`Notification with ID ${String(notificationId)} not found`)
return
}
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId: notification.userId },
select: {
id: true,
endpoint: true,
p256dh: true,
auth: true,
},
})
if (subscriptions.length === 0) {
logger.info(`No push subscriptions found for user ${notification.user.name}`)
return
}
const notificationData = {
title: makeNotificationTitle(notification, notification.user),
body: makeNotificationContent(notification) ?? undefined,
url: makeNotificationLink(notification, SITE_URL) ?? undefined,
} satisfies NotificationData
const results = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
notificationData
)
// Remove invalid subscriptions
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
}
return result.success
})
)
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length
const failureCount = results.filter((r) => !(r.status === 'fulfilled' && r.value)).length
const results = await sendNotifications([notificationId], logger)
logger.info(
`Push notification sent for notification ${String(notificationId)} to user ${notification.user.name}: ${String(successCount)} successful, ${String(failureCount)} failed`
`Sent push notifications for notification ${String(notificationId)} to ${String(results.subscriptions.success)} devices, ${String(results.subscriptions.failure)} failed`
)
} catch (error) {
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)

View File

@@ -0,0 +1,185 @@
import { sum } from 'lodash-es'
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications'
import { prisma } from './prisma'
import { getServerEnvVariable } from './serverEnvVariables'
import { type NotificationData, sendPushNotification } from './webPush'
import type { AstroIntegrationLogger } from 'astro'
const SITE_URL = getServerEnvVariable('SITE_URL')
export async function sendNotifications(
notificationIds: number[],
logger: AstroIntegrationLogger | Console = console
) {
const notifications = await prisma.notification.findMany({
where: { id: { in: notificationIds } },
select: {
id: true,
type: true,
userId: true,
createdAt: true,
aboutAccountStatusChange: true,
aboutCommentStatusChange: true,
aboutServiceVerificationStatusChange: true,
aboutSuggestionStatusChange: true,
aboutComment: {
select: {
id: true,
author: { select: { id: true } },
status: true,
content: true,
communityNote: true,
parent: {
select: {
author: {
select: {
id: true,
},
},
},
},
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutServiceSuggestionId: true,
aboutServiceSuggestion: {
select: {
status: true,
service: {
select: {
name: true,
},
},
},
},
aboutServiceSuggestionMessage: {
select: {
id: true,
content: true,
suggestion: {
select: {
id: true,
service: {
select: {
name: true,
},
},
},
},
},
},
aboutEvent: {
select: {
title: true,
type: true,
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutService: {
select: {
slug: true,
name: true,
verificationStatus: true,
},
},
aboutKarmaTransaction: {
select: {
points: true,
action: true,
description: true,
},
},
user: {
select: {
id: true,
name: true,
},
},
},
})
if (notifications.length < notificationIds.length) {
const missingNotificationIds = notificationIds.filter(
(id) => !notifications.some((notification) => notification.id === id)
)
logger.error(`Notifications with IDs ${missingNotificationIds.join(', ')} not found`)
}
const results = await Promise.allSettled(
notifications.map(async (notification) => {
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId: notification.userId },
select: {
id: true,
endpoint: true,
p256dh: true,
auth: true,
},
})
if (subscriptions.length === 0) {
logger.info(`No push subscriptions found for user ${notification.user.name}`)
return
}
const notificationData = {
title: makeNotificationTitle(notification, notification.user),
body: makeNotificationContent(notification) ?? undefined,
url: makeNotificationLink(notification, SITE_URL) ?? undefined,
} satisfies NotificationData
const subscriptionResults = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
notificationData
)
// Remove invalid subscriptions
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
}
return result.success
})
)
return {
success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length,
failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length,
total: subscriptionResults.length,
}
})
)
return {
subscriptions: {
success: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.success) ?? 0)),
failure: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.failure) ?? 0)),
total: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.total) ?? 0)),
},
notifications: {
success: results.filter((r) => r.status === 'fulfilled').length,
failure: results.filter((r) => r.status !== 'fulfilled').length,
total: results.length,
},
}
}

View File

@@ -3,8 +3,7 @@ import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import { groupBy, round, uniq } from 'lodash-es'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro'
import Button from '../../components/Button.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { cn } from '../../lib/cn'
@@ -15,7 +14,7 @@ if (!Astro.locals.user?.admin) {
return Astro.redirect('/access-denied')
}
const testResult = Astro.getActionResult(actions.admin.notification.webPush.test)
const testResult = Astro.getActionResult(actions.admin.notification.test)
const testInputErrors = isInputError(testResult?.error) ? testResult.error.fields : {}
Astro.locals.banners.addIfSuccess(testResult, (data) => data.message)
@@ -104,7 +103,7 @@ const stats = [
<h2 class="text-center text-lg font-semibold text-white">Send Test Notification</h2>
<form method="POST" action={actions.admin.notification.webPush.test} class="space-y-4">
<form method="POST" action={actions.admin.notification.test} class="space-y-4">
<InputTextArea
label="Users"
name="userNames"
@@ -121,35 +120,6 @@ const stats = [
error={testInputErrors.userNames}
/>
<InputText
label="Title"
name="title"
inputProps={{
value: 'Test Notification',
required: true,
}}
error={testInputErrors.title}
/>
<InputTextArea
label="Body"
name="body"
inputProps={{
value: 'This is a test push notification from KYCNot.me',
}}
error={testInputErrors.body}
/>
<InputText
label="Action URL"
name="url"
inputProps={{
placeholder: 'https://example.com/path',
}}
description="URL to open when the notification is clicked"
error={testInputErrors.url}
/>
<InputSubmitButton label="Send" icon="ri:send-plane-line" hideCancel color="danger" />
<Button type="submit" label="Send" icon="ri:send-plane-line" color="danger" class="w-full" />
</form>
</MiniLayout>