Merge pull request #184 from MAGICGrants/profanity-check

feat: hide potentially inappropriate donor names
This commit is contained in:
Artur
2025-05-28 12:51:15 -03:00
committed by GitHub
18 changed files with 154 additions and 98 deletions

View File

@@ -60,4 +60,5 @@ NEXT_PUBLIC_ATTESTATION_PUBLIC_KEY=""
COINBASE_COMMERCE_API_KEY=""
COINBASE_COMMERCE_WEBHOOK_SECRET=""
SENTRY_AUTH_TOKEN=""
SENTRY_AUTH_TOKEN=""
GEMINI_API_KEY=""

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<FundSlug, (props: SVGProps<SVGSVGElement>) => JS
const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) => {
const router = useRouter()
const fundSlug = useFundSlug()
const [leaderboardItemNamesToReveal, setLeaderboardItemNamesToReveal] = useState<number[]>([])
const { slug, title, summary, coverImage, content, nym, website, goal, isFunded } = project
@@ -70,6 +72,17 @@ const Project: NextPage<SingleProjectPageProps> = ({ 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 (
<>
<Head>
@@ -144,6 +157,11 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
<div className="w-full max-w-96 min-h-72 space-y-4 p-6 bg-white rounded-lg">
<h1 className="font-bold">Leaderboard</h1>
{hasProfaneNames && (
<span className="text-muted-foreground text-sm">
Hidden names are potentially inappropriate
</span>
)}
{leaderboardQuery.data?.length ? (
<Table>
@@ -156,13 +174,37 @@ const Project: NextPage<SingleProjectPageProps> = ({ project, donationStats }) =
<div
className={cn(
'w-8 h-8 flex font-bold text-primary rounded-full',
1 ? 'bg-primary/15' : ''
index < 3 ? 'bg-primary/15' : ''
)}
>
<span className="m-auto">{index + 1}</span>
</div>
</TableCell>
<TableCell className="w-full font-medium">{leaderboardItem.name}</TableCell>
<TableCell className="w-full font-medium">
<div className="w-full h-full flex flex-row items-center">
<span
className={
leaderboardItem.nameIsProfane &&
!leaderboardItemNamesToReveal.includes(index)
? 'max-w-36 truncate blur-sm'
: 'max-w-36 truncate'
}
>
Justin Ehrenhofer
</span>
{leaderboardItem.nameIsProfane && (
<Button
size="icon"
variant="ghost"
className="ml-2 text-primary hover:text-primary"
onClick={() => toggleLeaderboardItemNameVis(index)}
>
<EyeIcon size={20} />
</Button>
)}
</div>
</TableCell>
<TableCell className="font-bold text-green-500">
{formatUsd(leaderboardItem.amount)}
</TableCell>

View File

@@ -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',
},
})

View File

@@ -121,6 +121,7 @@ async function handleDonationOrMembership(body: WebhookBody) {
membershipTerm: metadata.membershipTerm || null,
showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true',
donorName: metadata.donorName,
donorNameIsProfane: metadata.donorNameIsProfane === 'true',
},
})

View File

@@ -124,6 +124,7 @@ async function handle(
const metadata: DonationMetadata = {
userId: null,
donorName: null,
donorNameIsProfane: 'false',
donorEmail: null,
projectSlug: project.slug,
projectName: project.title,

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Donation" ADD COLUMN "donorNameIsProfane" BOOLEAN DEFAULT false;

View File

@@ -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

View File

@@ -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\":{}}]}"
]
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
})
})

View File

@@ -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,
}

View File

@@ -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

46
server/utils/profanity.ts Normal file
View File

@@ -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
}

View File

@@ -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',
},
})