From 00bb987a9ce7fdeba072a22b29288e03a847eaa3 Mon Sep 17 00:00:00 2001 From: Artur <33733651+Keeqler@users.noreply.github.com> Date: Wed, 28 May 2025 11:46:52 -0300 Subject: [PATCH] feat: hide potentially inappropriate donor names --- .env.example | 3 +- .github/workflows/deploy.yml | 1 + docker-compose.yml | 80 ------------------- env.mjs | 4 + pages/[fund]/projects/[slug].tsx | 48 ++++++++++- pages/api/btcpay/webhook.ts | 1 + pages/api/coinbasecommerce/webhook.ts | 1 + pages/api/funding-required.ts | 1 + .../migration.sql | 2 + prisma/schema.prisma | 1 + realm-export.json | 24 +++--- server/routers/auth.ts | 8 +- server/routers/donation.ts | 19 +++++ server/routers/leaderboard.ts | 5 +- server/services.ts | 5 ++ server/types.ts | 1 + server/utils/profanity.ts | 46 +++++++++++ server/utils/webhooks.ts | 2 + 18 files changed, 154 insertions(+), 98 deletions(-) create mode 100644 prisma/migrations/20250527194943_donor_name_is_profane/migration.sql create mode 100644 server/utils/profanity.ts diff --git a/.env.example b/.env.example index 6bb83d5..90dfaaf 100644 --- a/.env.example +++ b/.env.example @@ -60,4 +60,5 @@ NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY="" COINBASE_COMMERCE_API_KEY="" COINBASE_COMMERCE_WEBHOOK_SECRET="" -SENTRY_AUTH_TOKEN="" \ No newline at end of file +SENTRY_AUTH_TOKEN="" +GEMINI_API_KEY="" \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 88269e0..b6655c0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -60,5 +60,6 @@ jobs: COINBASE_COMMERCE_API_KEY=${{ secrets.COINBASE_COMMERCE_API_KEY }} \ COINBASE_COMMERCE_WEBHOOK_SECRET=${{ secrets.COINBASE_COMMERCE_WEBHOOK_SECRET }} \ SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \ + GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }} \ docker compose -f docker-compose.prod.yml up -d --build EOF diff --git a/docker-compose.yml b/docker-compose.yml index 86e795c..bd84cce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,86 +4,6 @@ services: image: redis:7-alpine restart: unless-stopped - magic-btcpayserver: - restart: unless-stopped - container_name: magic-btcpayserver - image: ${BTCPAY_IMAGE:-btcpayserver/btcpayserver:2.0.8} - expose: - - '49392' - environment: - BTCPAY_POSTGRES: User ID=postgres;Host=magic-btcpay-postgres;Port=5432;Application Name=btcpayserver;Database=btcpayserver${NBITCOIN_NETWORK:-mainnet} - BTCPAY_EXPLORERPOSTGRES: User ID=postgres;Host=magic-btcpay-postgres;Port=5432;Application Name=btcpayserver;MaxPoolSize=80;Database=nbxplorer${NBITCOIN_NETWORK:-mainnet} - BTCPAY_NETWORK: ${NBITCOIN_NETWORK:-mainnet} - BTCPAY_BIND: 0.0.0.0:49392 - BTCPAY_ROOTPATH: ${BTCPAY_ROOTPATH:-/} - BTCPAY_SSHCONNECTION: 'root@host.docker.internal' - BTCPAY_SSHTRUSTEDFINGERPRINTS: ${BTCPAY_SSHTRUSTEDFINGERPRINTS} - BTCPAY_SSHKEYFILE: ${BTCPAY_SSHKEYFILE} - BTCPAY_SSHAUTHORIZEDKEYS: ${BTCPAY_SSHAUTHORIZEDKEYS} - BTCPAY_DEBUGLOG: btcpay.log - BTCPAY_UPDATEURL: https://api.github.com/repos/btcpayserver/btcpayserver/releases/latest - BTCPAY_DOCKERDEPLOYMENT: 'true' - BTCPAY_CHAINS: 'xmr' - BTCPAY_XMR_DAEMON_URI: http://xmr-node.cakewallet.com:18081 - BTCPAY_XMR_WALLET_DAEMON_URI: http://magic-monerod-wallet:18088 - BTCPAY_XMR_WALLET_DAEMON_WALLETDIR: /root/xmr_wallet - labels: - traefik.enable: 'true' - traefik.http.routers.btcpayserver.rule: Host(`${BTCPAY_HOST}`) - extra_hosts: - - 'host.docker.internal:host-gateway' - links: - - magic-btcpay-postgres - volumes: - - 'btcpay_datadir:/datadir' - - 'nbxplorer_datadir:/root/.nbxplorer' - - 'btcpay_pluginsdir:/root/.btcpayserver/Plugins' - - 'xmr_wallet:/root/xmr_wallet' - - 'tor_servicesdir:/var/lib/tor/hidden_services' - - 'tor_torrcdir:/usr/local/etc/tor/' - ports: - - '49392:49392' - magic-monerod-wallet: - restart: unless-stopped - container_name: magic-monerod-wallet - image: btcpayserver/monero:0.18.3.3 - entrypoint: monero-wallet-rpc --rpc-bind-ip=0.0.0.0 --disable-rpc-login --confirm-external-bind --rpc-bind-port=18088 --non-interactive --trusted-daemon --daemon-address=xmr-node.cakewallet.com:18081 --wallet-file=/wallet/wallet --password-file /wallet/password --tx-notify="/bin/sh ./scripts/notifier.sh -X GET http://magic-btcpayserver:49392/monerolikedaemoncallback/tx?cryptoCode=xmr&hash=%s" - expose: - - '18088' - volumes: - - 'xmr_wallet:/wallet' - - magic-nbxplorer: - restart: unless-stopped - container_name: magic-nbxplorer - image: nicolasdorier/nbxplorer:2.5.2 - expose: - - '32838' - environment: - NBXPLORER_NETWORK: ${NBITCOIN_NETWORK:-mainnet} - NBXPLORER_BIND: 0.0.0.0:32838 - NBXPLORER_TRIMEVENTS: 10000 - NBXPLORER_SIGNALFILESDIR: /datadir - NBXPLORER_POSTGRES: User ID=postgres;Host=magic-btcpay-postgres;Port=5432;Application Name=nbxplorer;MaxPoolSize=20;Database=nbxplorer${NBITCOIN_NETWORK:-mainnet} - NBXPLORER_AUTOMIGRATE: 1 - NBXPLORER_NOMIGRATEEVTS: 1 - NBXPLORER_DELETEAFTERMIGRATION: 1 - links: - - magic-btcpay-postgres - volumes: - - 'nbxplorer_datadir:/datadir' - - magic-btcpay-postgres: - restart: unless-stopped - container_name: magic-btcpay-postgres - shm_size: 256mb - image: btcpayserver/postgres:13.13 - command: ['-c', 'random_page_cost=1.0', '-c', 'shared_preload_libraries=pg_stat_statements'] - environment: - POSTGRES_HOST_AUTH_METHOD: trust - volumes: - - 'btcpay_postgres_datadir:/var/lib/postgresql/data' - magic-postgres: image: postgres:16-alpine container_name: magic-postgres diff --git a/env.mjs b/env.mjs index 221e334..ba33d30 100644 --- a/env.mjs +++ b/env.mjs @@ -56,6 +56,8 @@ export const env = createEnv({ COINBASE_COMMERCE_API_KEY: z.string().min(1), COINBASE_COMMERCE_WEBHOOK_SECRET: z.string().min(1), + + GEMINI_API_KEY: z.string().min(1), }, /* * Environment variables available on the client (and server). @@ -142,6 +144,8 @@ export const env = createEnv({ COINBASE_COMMERCE_API_KEY: process.env.COINBASE_COMMERCE_API_KEY, COINBASE_COMMERCE_WEBHOOK_SECRET: process.env.COINBASE_COMMERCE_WEBHOOK_SECRET, + + GEMINI_API_KEY: process.env.GEMINI_API_KEY, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/pages/[fund]/projects/[slug].tsx b/pages/[fund]/projects/[slug].tsx index b9c51dc..5242103 100644 --- a/pages/[fund]/projects/[slug].tsx +++ b/pages/[fund]/projects/[slug].tsx @@ -1,4 +1,4 @@ -import { SVGProps } from 'react' +import { SVGProps, useState } from 'react' import { FundSlug } from '@prisma/client' import { useRouter } from 'next/router' import { GetServerSidePropsContext, NextPage } from 'next/types' @@ -26,6 +26,7 @@ import MagicLogo from '../../../components/MagicLogo' import MoneroLogo from '../../../components/MoneroLogo' import FiroLogo from '../../../components/FiroLogo' import PrivacyGuidesLogo from '../../../components/PrivacyGuidesLogo' +import { EyeIcon } from 'lucide-react' type SingleProjectPageProps = { project: ProjectItem @@ -43,6 +44,7 @@ const placeholderImages: Record) => JS const Project: NextPage = ({ project, donationStats }) => { const router = useRouter() const fundSlug = useFundSlug() + const [leaderboardItemNamesToReveal, setLeaderboardItemNamesToReveal] = useState([]) const { slug, title, summary, coverImage, content, nym, website, goal, isFunded } = project @@ -70,6 +72,17 @@ const Project: NextPage = ({ project, donationStats }) = donationStats.manual.count + donationStats.usd.count + const hasProfaneNames = !!leaderboardQuery.data?.find((item) => item.nameIsProfane) + + function toggleLeaderboardItemNameVis(itemIndex: number) { + console.log(leaderboardItemNamesToReveal, itemIndex) + if (leaderboardItemNamesToReveal.includes(itemIndex)) { + setLeaderboardItemNamesToReveal((state) => state.filter((index) => index !== itemIndex)) + } else { + setLeaderboardItemNamesToReveal((state) => [...state, itemIndex]) + } + } + return ( <> @@ -144,6 +157,11 @@ const Project: NextPage = ({ project, donationStats }) =

Leaderboard

+ {hasProfaneNames && ( + + Hidden names are potentially inappropriate + + )} {leaderboardQuery.data?.length ? ( @@ -156,13 +174,37 @@ const Project: NextPage = ({ project, donationStats }) =
{index + 1}
- {leaderboardItem.name} + +
+ + Justin Ehrenhofer + + + {leaderboardItem.nameIsProfane && ( + + )} +
+
{formatUsd(leaderboardItem.amount)} diff --git a/pages/api/btcpay/webhook.ts b/pages/api/btcpay/webhook.ts index 5f1e8af..0294cf9 100644 --- a/pages/api/btcpay/webhook.ts +++ b/pages/api/btcpay/webhook.ts @@ -202,6 +202,7 @@ async function handleDonationOrMembership(body: WebhookBody) { membershipTerm: body.metadata.membershipTerm || null, showDonorNameOnLeaderboard: body.metadata.showDonorNameOnLeaderboard === 'true', donorName: body.metadata.donorName, + donorNameIsProfane: body.metadata.donorNameIsProfane === 'true', }, }) diff --git a/pages/api/coinbasecommerce/webhook.ts b/pages/api/coinbasecommerce/webhook.ts index 993fc6d..c89bbc0 100644 --- a/pages/api/coinbasecommerce/webhook.ts +++ b/pages/api/coinbasecommerce/webhook.ts @@ -121,6 +121,7 @@ async function handleDonationOrMembership(body: WebhookBody) { membershipTerm: metadata.membershipTerm || null, showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true', donorName: metadata.donorName, + donorNameIsProfane: metadata.donorNameIsProfane === 'true', }, }) diff --git a/pages/api/funding-required.ts b/pages/api/funding-required.ts index 4113c6d..5b78366 100644 --- a/pages/api/funding-required.ts +++ b/pages/api/funding-required.ts @@ -124,6 +124,7 @@ async function handle( const metadata: DonationMetadata = { userId: null, donorName: null, + donorNameIsProfane: 'false', donorEmail: null, projectSlug: project.slug, projectName: project.title, diff --git a/prisma/migrations/20250527194943_donor_name_is_profane/migration.sql b/prisma/migrations/20250527194943_donor_name_is_profane/migration.sql new file mode 100644 index 0000000..370f975 --- /dev/null +++ b/prisma/migrations/20250527194943_donor_name_is_profane/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Donation" ADD COLUMN "donorNameIsProfane" BOOLEAN DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3671d1f..81c25ac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,6 +37,7 @@ model Donation { userId String? donorName String? + donorNameIsProfane Boolean? @default(false) btcPayInvoiceId String? @unique stripePaymentIntentId String? @unique // For donations and non-recurring memberships stripeInvoiceId String? @unique // For recurring memberships diff --git a/realm-export.json b/realm-export.json index 37176aa..f0125ef 100644 --- a/realm-export.json +++ b/realm-export.json @@ -641,7 +641,7 @@ "protocol": "openid-connect", "attributes": { "oidc.ciba.grant.enabled": "false", - "client.secret.creation.time": "1729548068", + "client.secret.creation.time": "1748390533", "backchannel.logout.session.required": "true", "post.logout.redirect.uris": "+", "oauth2.device.authorization.grant.enabled": "false", @@ -1474,14 +1474,14 @@ "subComponents": {}, "config": { "allowed-protocol-mapper-types": [ - "oidc-usermodel-attribute-mapper", + "oidc-address-mapper", "oidc-usermodel-property-mapper", "saml-user-property-mapper", - "saml-role-list-mapper", - "oidc-address-mapper", - "saml-user-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", - "oidc-full-name-mapper" + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper", + "saml-role-list-mapper", + "saml-user-attribute-mapper" ] } }, @@ -1528,14 +1528,14 @@ "subComponents": {}, "config": { "allowed-protocol-mapper-types": [ - "oidc-usermodel-property-mapper", "oidc-address-mapper", - "oidc-full-name-mapper", - "saml-user-attribute-mapper", - "oidc-usermodel-attribute-mapper", + "saml-user-property-mapper", "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-property-mapper", + "saml-user-attribute-mapper", "saml-role-list-mapper", - "saml-user-property-mapper" + "oidc-usermodel-attribute-mapper", + "oidc-full-name-mapper" ] } }, @@ -1559,7 +1559,7 @@ "subComponents": {}, "config": { "kc.user.profile.config": [ - "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"name\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"addressLine1\",\"displayName\":\"Address line 1\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressLine2\",\"displayName\":\"Address line 2\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressZip\",\"displayName\":\"Address zip\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressCity\",\"displayName\":\"Address city\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressState\",\"displayName\":\"Address state\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressCountry\",\"displayName\":\"Address country\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"emailVerifyTokenVersion\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"passwordResetTokenVersion\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripeMoneroCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripeFiroCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripePgCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripeGeneralCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"company\",\"displayName\":\"Company\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"},{\"name\":\"mailingAddress\",\"displayHeader\":\"Mailing address\",\"displayDescription\":\"\",\"annotations\":{}}]}" + "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"name\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"addressLine1\",\"displayName\":\"Address line 1\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressLine2\",\"displayName\":\"Address line 2\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressZip\",\"displayName\":\"Address zip\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressCity\",\"displayName\":\"Address city\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressState\",\"displayName\":\"Address state\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"addressCountry\",\"displayName\":\"Address country\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"group\":\"mailingAddress\",\"multivalued\":false},{\"name\":\"emailVerifyTokenVersion\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"passwordResetTokenVersion\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripeMoneroCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripeFiroCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripePgCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"stripeGeneralCustomerId\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"company\",\"displayName\":\"Company\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false},{\"name\":\"nameIsProfane\",\"displayName\":\"\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"},{\"name\":\"mailingAddress\",\"displayHeader\":\"Mailing address\",\"displayDescription\":\"\",\"annotations\":{}}]}" ] } } diff --git a/server/routers/auth.ts b/server/routers/auth.ts index 218eb2c..aac577f 100644 --- a/server/routers/auth.ts +++ b/server/routers/auth.ts @@ -9,6 +9,8 @@ import { env } from '../../env.mjs' import { fundSlugs } from '../../utils/funds' import { UserSettingsJwtPayload } from '../types' import { isTurnstileValid } from '../utils/turnstile' +import { isNameProfane } from '../utils/profanity' +import { log } from '../../utils/logging' export const authRouter = router({ register: publicProcedure @@ -102,6 +104,9 @@ export const authRouter = router({ let user: { id: string } + const name = `${input.firstName} ${input.lastName}` + const nameIsProfane = await isNameProfane(name) + try { user = await keycloak.users.create({ realm: env.KEYCLOAK_REALM_NAME, @@ -109,7 +114,8 @@ export const authRouter = router({ credentials: [{ type: 'password', value: input.password, temporary: false }], requiredActions: ['VERIFY_EMAIL'], attributes: { - name: `${input.firstName} ${input.lastName}`, + name, + nameIsProfane, passwordResetTokenVersion: 1, emailVerifyTokenVersion: 1, company: input.company, diff --git a/server/routers/donation.ts b/server/routers/donation.ts index 6a992fd..75a913d 100644 --- a/server/routers/donation.ts +++ b/server/routers/donation.ts @@ -20,6 +20,7 @@ import { funds, fundSlugs } from '../../utils/funds' import { fundSlugToCustomerIdAttr } from '../utils/funds' import { getDonationAttestation, getMembershipAttestation } from '../utils/attestation' import { createCoinbaseCharge } from '../utils/coinbase-commerce' +import { isNameProfane } from '../utils/profanity' export const donationRouter = router({ donateWithFiat: publicProcedure @@ -62,6 +63,7 @@ export const donationRouter = router({ let email = input.email let name = input.name + let nameIsProfane = false let stripeCustomerId: string | null = null let user: UserRepresentation | null = null @@ -70,9 +72,14 @@ export const donationRouter = router({ user = (await keycloak.users.findOne({ id: userId })!) || null email = user?.email! name = user?.attributes?.name?.[0] + nameIsProfane = user?.attributes?.nameIsProfane?.[0] === 'true' stripeCustomerId = user?.attributes?.[fundSlugToCustomerIdAttr[input.fundSlug]]?.[0] || null } + if (!userId) { + nameIsProfane = await isNameProfane(name!) + } + const stripe = _stripe[input.fundSlug] if (!stripeCustomerId && userId && user && email && name) { @@ -93,6 +100,7 @@ export const donationRouter = router({ userId, donorEmail: email, donorName: name, + donorNameIsProfane: nameIsProfane ? 'true' : 'false', projectSlug: input.projectSlug, projectName: input.projectName, fundSlug: input.fundSlug, @@ -153,17 +161,24 @@ export const donationRouter = router({ let email = input.email let name = input.name const userId = ctx.session?.user.sub || null + let nameIsProfane = false if (userId) { await authenticateKeycloakClient() const user = await keycloak.users.findOne({ id: userId }) email = user?.email! name = user?.attributes?.name?.[0] || null + nameIsProfane = user?.attributes?.nameIsProfane?.[0] === 'true' + } + + if (!userId) { + nameIsProfane = await isNameProfane(name!) } const metadata: DonationMetadata = { userId, donorName: name, + donorNameIsProfane: nameIsProfane ? 'true' : 'false', donorEmail: email, projectSlug: input.projectSlug, projectName: input.projectName, @@ -255,6 +270,7 @@ export const donationRouter = router({ const user = await keycloak.users.findOne({ id: userId }) const email = user?.email! const name = user?.attributes?.name?.[0]! + const nameIsProfane = user?.attributes?.nameIsProfane?.[0] === 'true' if (!user || !user.id) throw new TRPCError({ @@ -279,6 +295,7 @@ export const donationRouter = router({ const metadata: DonationMetadata = { userId, donorName: name, + donorNameIsProfane: nameIsProfane ? 'true' : 'false', donorEmail: email, projectSlug: input.fundSlug, projectName: funds[input.fundSlug].title, @@ -392,10 +409,12 @@ export const donationRouter = router({ const user = await keycloak.users.findOne({ id: userId }) const email = user?.email! const name = user?.attributes?.name?.[0]! + const nameIsProfane = user?.attributes?.nameIsProfane?.[0] === 'true' const metadata: DonationMetadata = { userId, donorName: name, + donorNameIsProfane: nameIsProfane ? 'true' : 'false', donorEmail: email, projectSlug: input.fundSlug, projectName: funds[input.fundSlug].title, diff --git a/server/routers/leaderboard.ts b/server/routers/leaderboard.ts index 64ac39a..414ed18 100644 --- a/server/routers/leaderboard.ts +++ b/server/routers/leaderboard.ts @@ -16,7 +16,7 @@ export const leaderboardRouter = router({ const leaderboardLimit = 10 const withUserDonationSums = await prisma.donation.groupBy({ - by: ['userId', 'showDonorNameOnLeaderboard', 'donorName'], + by: ['userId', 'showDonorNameOnLeaderboard', 'donorName', 'donorNameIsProfane'], where: { userId: { not: null }, fundSlug: input.fundSlug, projectSlug: input.projectSlug }, _sum: { grossFiatAmount: true }, orderBy: { _sum: { grossFiatAmount: 'desc' } }, @@ -31,6 +31,7 @@ export const leaderboardRouter = router({ type LeaderboardItem = { name: string + nameIsProfane: boolean amount: number } @@ -41,6 +42,7 @@ export const leaderboardRouter = router({ name: donationSum.showDonorNameOnLeaderboard ? donationSum.donorName || 'Anonymous' : 'Anonymous', + nameIsProfane: !!donationSum.donorNameIsProfane, amount: donationSum._sum.grossFiatAmount || 0, }) }) @@ -50,6 +52,7 @@ export const leaderboardRouter = router({ name: donation.showDonorNameOnLeaderboard ? donation.donorName || 'Anonymous' : 'Anonymous', + nameIsProfane: !!donation.donorNameIsProfane, amount: donation.grossFiatAmount || 0, }) }) diff --git a/server/services.ts b/server/services.ts index b317589..283649c 100644 --- a/server/services.ts +++ b/server/services.ts @@ -65,6 +65,10 @@ const coinbaseCommerceApi = axios.create({ headers: { 'X-CC-Api-Key': env.COINBASE_COMMERCE_API_KEY }, }) +const geminiApi = axios.create({ + baseURL: `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${env.GEMINI_API_KEY}`, +}) + export { prisma, keycloak, @@ -75,4 +79,5 @@ export { stripe, privacyGuidesDiscourseApi, coinbaseCommerceApi, + geminiApi, } diff --git a/server/types.ts b/server/types.ts index 1718c36..d031550 100644 --- a/server/types.ts +++ b/server/types.ts @@ -16,6 +16,7 @@ export type DonationMetadata = { userId: string | null donorEmail: string | null donorName: string | null + donorNameIsProfane: 'true' | 'false' projectSlug: string projectName: string fundSlug: FundSlug diff --git a/server/utils/profanity.ts b/server/utils/profanity.ts new file mode 100644 index 0000000..3011c9e --- /dev/null +++ b/server/utils/profanity.ts @@ -0,0 +1,46 @@ +import { AxiosResponse } from 'axios' +import { geminiApi } from '../services' +import { log } from '../../utils/logging' + +type GeminiGenerateContentBody = { contents: { parts: { text: string }[] }[] } +type GeminiGenerateContentRes = AxiosResponse<{ + candidates: { content: { parts: { text: string }[] } }[] +}> + +export async function isNameProfane(name: string) { + const prompt = `We need you to review the name that the user provided: '${name}'. + We need you to respond in json with a binary response of '0' (for no) or '1' (for yes) if you + think that it violates the criteria, and provide a reason. We want to filter profanity and + variants of it, such as misspellings (e.g. 'h4t3 sp33ch' as a misspelling for 'hate speech'). + First try to detect the language used then translate to english. Be careful with misdetecting + languages, words in spanish for example may be a offensive in portuguese. Do not filter very + mild profanity like 'stupid' and 'dumb'. We want to filter most family unfriendly content, + including sexual content. 'Beautiful woman' is not sexual enough to be filtered. Do not filter + names like 'gay cowboy', 'trans activist', 'devout catholic' or 'jewish guy'. We do not want to + filter polite or neutral advertising, such as the (non-offensive) name of a company. We do not + want to filter most political ideologies, such as 'taxation is theft', but we do want to filter + racist, sexist, and other offensive ideologies, such as 'women should be in the kitchen'. We do + not want to filter all names that might be the nickname of a drug but also have other common + uses. For example, do not filter 'speed' or 'methadone', but do filter 'meth'. You can be more + permissive for marijuana and alcohol so long as the name isn't otherwise offensive. Filter + terrorist organizations.` + + let isProfane = false + + try { + const { data } = await geminiApi.post<{}, GeminiGenerateContentRes, GeminiGenerateContentBody>( + '', + { contents: [{ parts: [{ text: prompt }] }] } + ) + + isProfane = !!parseInt(data.candidates[0].content.parts[0].text.match(/\d+/g)?.[0] || '0') + } catch (error) { + log( + 'warn', + "Could not ask Gemini if user's name is profane. Continuing assuming it's not. Cause:" + ) + console.error(error) + } + + return isProfane +} diff --git a/server/utils/webhooks.ts b/server/utils/webhooks.ts index 7063ad6..6a87cf4 100644 --- a/server/utils/webhooks.ts +++ b/server/utils/webhooks.ts @@ -77,6 +77,7 @@ async function handleDonationOrNonRecurringMembership(paymentIntent: Stripe.Paym membershipTerm: metadata.membershipTerm || null, showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true', donorName: metadata.donorName, + donorNameIsProfane: metadata.donorNameIsProfane === 'true', }, }) @@ -193,6 +194,7 @@ async function handleRecurringMembership(invoice: Stripe.Invoice) { membershipTerm: metadata.membershipTerm || null, showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true', donorName: metadata.donorName, + donorNameIsProfane: metadata.donorNameIsProfane === 'true', }, })