mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-08 20:08:05 -05:00
Merge pull request #184 from MAGICGrants/profanity-check
feat: hide potentially inappropriate donor names
This commit is contained in:
@@ -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=""
|
||||
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
4
env.mjs
4
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ async function handleDonationOrMembership(body: WebhookBody) {
|
||||
membershipTerm: metadata.membershipTerm || null,
|
||||
showDonorNameOnLeaderboard: metadata.showDonorNameOnLeaderboard === 'true',
|
||||
donorName: metadata.donorName,
|
||||
donorNameIsProfane: metadata.donorNameIsProfane === 'true',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ async function handle(
|
||||
const metadata: DonationMetadata = {
|
||||
userId: null,
|
||||
donorName: null,
|
||||
donorNameIsProfane: 'false',
|
||||
donorEmail: null,
|
||||
projectSlug: project.slug,
|
||||
projectName: project.title,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Donation" ADD COLUMN "donorNameIsProfane" BOOLEAN DEFAULT false;
|
||||
@@ -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
|
||||
|
||||
@@ -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\":{}}]}"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
46
server/utils/profanity.ts
Normal 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
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user