From ddb4a30c4b0cae36f940229960b7c900266bc383 Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 10 Aug 2022 10:06:31 -0500 Subject: [PATCH] spam fees --- api/resolvers/item.js | 31 ++++--- api/typeDefs/item.js | 1 + components/discussion-form.js | 19 ++++- components/fee-button.js | 64 ++++++++++++++ components/fee-button.module.css | 15 ++++ components/form.js | 10 ++- components/link-form.js | 13 ++- components/reply.js | 25 +++--- components/text.js | 5 ++ lib/constants.js | 1 + lib/md.js | 19 +++++ pages/[name]/index.js | 16 +++- .../20220810162813_item_spam/migration.sql | 5 ++ .../20220810203210_item_spam2/migration.sql | 83 +++++++++++++++++++ prisma/schema.prisma | 3 +- 15 files changed, 270 insertions(+), 40 deletions(-) create mode 100644 components/fee-button.js create mode 100644 components/fee-button.module.css create mode 100644 lib/md.js create mode 100644 prisma/migrations/20220810162813_item_spam/migration.sql create mode 100644 prisma/migrations/20220810203210_item_spam2/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 6dbfa555..5540ced2 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -4,7 +4,8 @@ import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import domino from 'domino' -import { BOOST_MIN } from '../../lib/constants' +import { BOOST_MIN, ITEM_SPAM_INTERVAL } from '../../lib/constants' +import { mdHas } from '../../lib/md' async function comments (models, id, sort) { let orderBy @@ -68,6 +69,13 @@ function topClause (within) { export default { Query: { + itemRepetition: async (parent, { parentId }, { me, models }) => { + if (!me) return 0 + // how many of the parents starting at parentId belong to me + const [{ item_spam: count }] = await models.$queryRaw(`SELECT item_spam($1, $2, '${ITEM_SPAM_INTERVAL}')`, Number(parentId), Number(me.id)) + + return count + }, items: async (parent, { sub, sort, cursor, name, within }, { me, models }) => { const decodedCursor = decodeCursor(cursor) let items; let user; let pins; let subFull @@ -851,6 +859,10 @@ const updateItem = async (parent, { id, data }, { me, models }) => { throw new UserInputError('item can no longer be editted') } + if (data?.text && !old.paidImgLink && mdHas(data.text, ['link', 'image'])) { + throw new UserInputError('adding links or images on edit is not allowed yet') + } + const item = await models.item.update({ where: { id: Number(id) }, data @@ -878,21 +890,16 @@ const createItem = async (parent, { title, url, text, boost, forward, parentId } } } + const hasImgLink = mdHas(text, ['link', 'image']) + const [item] = await serialize(models, - models.$queryRaw(`${SELECT} FROM create_item($1, $2, $3, $4, $5, $6) AS "Item"`, - title, url, text, Number(boost || 0), Number(parentId), Number(me.id))) + models.$queryRaw( + `${SELECT} FROM create_item($1, $2, $3, $4, $5, $6, $7, $8, '${ITEM_SPAM_INTERVAL}') AS "Item"`, + title, url, text, Number(boost || 0), Number(parentId), Number(me.id), + Number(fwdUser?.id), hasImgLink)) await createMentions(item, models) - if (fwdUser) { - await models.item.update({ - where: { id: item.id }, - data: { - fwdUserId: fwdUser.id - } - }) - } - item.comments = [] return item } diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 931435e3..9a743173 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -11,6 +11,7 @@ export default gql` allItems(cursor: String): Items search(q: String, sub: String, cursor: String): Items auctionPosition(sub: String, id: ID, bid: Int!): Int! + itemRepetition(parentId: ID): Int! } type ItemActResult { diff --git a/components/discussion-form.js b/components/discussion-form.js index 82a62374..43c1caf7 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -2,11 +2,12 @@ import { Form, Input, MarkdownInput, SubmitButton } from '../components/form' import { useRouter } from 'next/router' import * as Yup from 'yup' import { gql, useApolloClient, useMutation } from '@apollo/client' -import ActionTooltip from '../components/action-tooltip' import TextareaAutosize from 'react-textarea-autosize' import Countdown from './countdown' import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' import { MAX_TITLE_LENGTH } from '../lib/constants' +import { useState } from 'react' +import FeeButton from './fee-button' export function DiscussionForm ({ item, editThreshold, titleLabel = 'title', @@ -15,6 +16,8 @@ export function DiscussionForm ({ }) { const router = useRouter() const client = useApolloClient() + const [hasImgLink, setHasImgLink] = useState() + // const me = useMe() const [upsertDiscussion] = useMutation( gql` mutation upsertDiscussion($id: ID, $title: String!, $text: String, $boost: Int, $forward: String) { @@ -31,6 +34,8 @@ export function DiscussionForm ({ ...AdvPostSchema(client) }) + // const cost = linkOrImg ? 10 : me?.freePosts ? 0 : 1 + return (
: null} + setHasImgLink={setHasImgLink} /> {!item && adv && } - - {item ? 'save' : buttonText} - +
+ {item + ? save + : } +
) } diff --git a/components/fee-button.js b/components/fee-button.js new file mode 100644 index 00000000..07426df8 --- /dev/null +++ b/components/fee-button.js @@ -0,0 +1,64 @@ +import { Table } from 'react-bootstrap' +import ActionTooltip from './action-tooltip' +import Info from './info' +import styles from './fee-button.module.css' +import { gql, useQuery } from '@apollo/client' +import { useFormikContext } from 'formik' + +function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { + return ( + + + + + + + {hasImgLink && + + + + } + {repetition > 0 && + + + + } + {boost > 0 && + + + + } + + + + + + + +
{baseFee} sats{parentId ? 'reply' : 'post'} fee
x 10image/link fee
x 10{repetition}{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m
+ {boost} satsboost
{cost} satstotal fee
+ ) +} + +export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow }) { + const query = parentId + ? gql`{ itemRepetition(parentId: "${parentId}") }` + : gql`{ itemRepetition }` + const { data } = useQuery(query, { pollInterval: 1000 }) + const repetition = data?.itemRepetition || 0 + const formik = useFormikContext() + const boost = formik?.values?.boost || 0 + const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost) + + const show = alwaysShow || !formik?.isSubmitting + return ( +
+ + {text}{cost > baseFee && show && {cost} sats} + + {cost > baseFee && show && + + + } +
+ ) +} diff --git a/components/fee-button.module.css b/components/fee-button.module.css new file mode 100644 index 00000000..87c27ed9 --- /dev/null +++ b/components/fee-button.module.css @@ -0,0 +1,15 @@ +.receipt { + background-color: var(--theme-inputBg); + max-width: 250px; + margin: auto; + table-layout: auto; + width: 100%; +} + +.receipt td { + padding: .25rem .1rem; +} + +.receipt tfoot { + border-top: 2px solid var(--theme-borderColor); +} \ No newline at end of file diff --git a/components/form.js b/components/form.js index 7373bbc3..b2a5e0ea 100644 --- a/components/form.js +++ b/components/form.js @@ -11,6 +11,7 @@ import Markdown from '../svgs/markdown-line.svg' import styles from './form.module.css' import Text from '../components/text' import AddIcon from '../svgs/add-fill.svg' +import { mdHas } from '../lib/md' export function SubmitButton ({ children, variant, value, onClick, ...props @@ -72,7 +73,7 @@ export function InputSkeleton ({ label, hint }) { ) } -export function MarkdownInput ({ label, topLevel, groupClassName, ...props }) { +export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, ...props }) { const [tab, setTab] = useState('write') const [, meta] = useField(props) @@ -99,7 +100,12 @@ export function MarkdownInput ({ label, topLevel, groupClassName, ...props }) {
{ + if (onChange) onChange(formik, e) + if (setHasImgLink) { + setHasImgLink(mdHas(e.target.value, ['link', 'image'])) + } + }} />
diff --git a/components/link-form.js b/components/link-form.js index e6c2e296..867b27b2 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -2,13 +2,13 @@ import { Form, Input, SubmitButton } from '../components/form' import { useRouter } from 'next/router' import * as Yup from 'yup' import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client' -import ActionTooltip from '../components/action-tooltip' import Countdown from './countdown' import AdvPostForm, { AdvPostInitial, AdvPostSchema } from './adv-post-form' import { ITEM_FIELDS } from '../fragments/items' import Item from './item' import AccordianItem from './accordian-item' import { MAX_TITLE_LENGTH } from '../lib/constants' +import FeeButton from './fee-button' // eslint-disable-next-line const URL = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i @@ -99,9 +99,14 @@ export function LinkForm ({ item, editThreshold }) { }} /> {!item && } - - {item ? 'save' : 'post'} - +
+ {item + ? save + : } +
{dupesData?.dupes?.length > 0 &&
{ setReply(replyOpen || !!localStorage.getItem('reply-' + parentId + '-' + 'text')) @@ -65,7 +65,7 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) { } ) - const cost = me?.freeComments ? 0 : Math.pow(10, meComments) + // const cost = me?.freeComments ? 0 : Math.pow(10, meComments) return (
@@ -91,6 +91,7 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) { } resetForm({ text: '' }) setReply(replyOpen || false) + setHasImgLink(false) }} storageKeyPrefix={'reply-' + parentId} > @@ -100,18 +101,16 @@ export default function Reply ({ parentId, meComments, onSuccess, replyOpen }) { minRows={6} autoFocus={!replyOpen} required + setHasImgLink={setHasImgLink} hint={me?.freeComments ? {me.freeComments} free comments left : null} /> -
- - reply{cost > 1 && {cost} sats} - - {cost > 1 && ( - -
Multiple replies on the same level get pricier, but we still love your thoughts!
-
- )} -
+ {reply && +
+ +
}
diff --git a/components/text.js b/components/text.js index ba3f9c80..ec9cd7fb 100644 --- a/components/text.js +++ b/components/text.js @@ -82,6 +82,11 @@ export default function Text ({ topLevel, noFragments, nofollow, children }) { ) }, a: ({ node, href, children, ...props }) => { + if (children?.some(e => e?.props?.node?.tagName === 'img')) { + return <>{children} + } + + // map: fix any highlighted links children = children?.map(e => typeof e === 'string' ? reactStringReplace(e, /:high\[([^\]]+)\]/g, (match, i) => { diff --git a/lib/constants.js b/lib/constants.js index ae6e033e..f6fb514b 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -12,3 +12,4 @@ export const UPLOAD_TYPES_ALLOW = [ export const COMMENT_DEPTH_LIMIT = 10 export const MAX_TITLE_LENGTH = 80 export const MAX_POLL_CHOICE_LENGTH = 30 +export const ITEM_SPAM_INTERVAL = '10m' diff --git a/lib/md.js b/lib/md.js new file mode 100644 index 00000000..8c37bc64 --- /dev/null +++ b/lib/md.js @@ -0,0 +1,19 @@ +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { visit } from 'unist-util-visit' +import { gfm } from 'micromark-extension-gfm' + +export function mdHas (md, test) { + const tree = fromMarkdown(md, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()] + }) + + let found = false + visit(tree, test, () => { + found = true + return false + }) + + return found +} diff --git a/pages/[name]/index.js b/pages/[name]/index.js index 0a830a75..e88dafd2 100644 --- a/pages/[name]/index.js +++ b/pages/[name]/index.js @@ -8,12 +8,12 @@ import { useState } from 'react' import ItemFull from '../../components/item-full' import * as Yup from 'yup' import { Form, MarkdownInput, SubmitButton } from '../../components/form' -import ActionTooltip from '../../components/action-tooltip' import TextareaAutosize from 'react-textarea-autosize' import { useMe } from '../../components/me' import { USER_FULL } from '../../fragments/users' import { ITEM_FIELDS } from '../../fragments/items' import { getGetServerSideProps } from '../../api/ssrApollo' +import FeeButton from '../../components/fee-button' export const getServerSideProps = getGetServerSideProps(USER_FULL, null, data => !data.user) @@ -23,6 +23,8 @@ const BioSchema = Yup.object({ }) export function BioForm ({ handleSuccess, bio }) { + const [hasImgLink, setHasImgLink] = useState() + const [upsertBio] = useMutation( gql` ${ITEM_FIELDS} @@ -68,10 +70,16 @@ export function BioForm ({ handleSuccess, bio }) { name='bio' as={TextareaAutosize} minRows={6} + setHasImgLink={setHasImgLink} /> - - {bio?.text ? 'save' : 'create'} - +
+ {bio?.text + ? save + : } +
) diff --git a/prisma/migrations/20220810162813_item_spam/migration.sql b/prisma/migrations/20220810162813_item_spam/migration.sql new file mode 100644 index 00000000..1ebc30b7 --- /dev/null +++ b/prisma/migrations/20220810162813_item_spam/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "paidImgLink" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "users" ALTER COLUMN "freePosts" SET DEFAULT 0; \ No newline at end of file diff --git a/prisma/migrations/20220810203210_item_spam2/migration.sql b/prisma/migrations/20220810203210_item_spam2/migration.sql new file mode 100644 index 00000000..1b2652e6 --- /dev/null +++ b/prisma/migrations/20220810203210_item_spam2/migration.sql @@ -0,0 +1,83 @@ +CREATE OR REPLACE FUNCTION item_spam(parent_id INTEGER, user_id INTEGER, within INTERVAL) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + repeats INTEGER; + self_replies INTEGER; +BEGIN + SELECT count(*) INTO repeats + FROM "Item" + WHERE (parent_id IS NULL AND "parentId" IS NULL OR "parentId" = parent_id) + AND "userId" = user_id + AND created_at > now_utc() - within; + + IF parent_id IS NULL THEN + RETURN repeats; + END IF; + + WITH RECURSIVE base AS ( + SELECT "Item".id, "Item"."parentId", "Item"."userId" + FROM "Item" + WHERE id = parent_id AND "userId" = user_id AND created_at > now_utc() - within + UNION ALL + SELECT "Item".id, "Item"."parentId", "Item"."userId" + FROM base p + JOIN "Item" ON "Item".id = p."parentId" AND "Item"."userId" = p."userId" AND "Item".created_at > now_utc() - within) + SELECT count(*) INTO self_replies FROM base; + + RETURN repeats + self_replies; +END; +$$; + +CREATE OR REPLACE FUNCTION create_item( + title TEXT, url TEXT, text TEXT, boost INTEGER, + parent_id INTEGER, user_id INTEGER, fwd_user_id INTEGER, + has_img_link BOOLEAN, spam_within INTERVAL) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + cost INTEGER; + free_posts INTEGER; + free_comments INTEGER; + freebie BOOLEAN; + item "Item"; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT msats, "freePosts", "freeComments" + INTO user_msats, free_posts, free_comments + FROM users WHERE id = user_id; + + freebie := (parent_id IS NULL AND free_posts > 0) OR (parent_id IS NOT NULL AND free_comments > 0); + cost := 1000 * POWER(10, item_spam(parent_id, user_id, spam_within)) * CASE WHEN has_img_link THEN 10 ELSE 1 END; + + IF NOT freebie AND cost > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + INSERT INTO "Item" (title, url, text, "userId", "parentId", "fwdUserId", "paidImgLink", created_at, updated_at) + VALUES (title, url, text, user_id, parent_id, fwd_user_id, has_img_link, now_utc(), now_utc()) RETURNING * INTO item; + + IF freebie THEN + IF parent_id IS NULL THEN + UPDATE users SET "freePosts" = "freePosts" - 1 WHERE id = user_id; + ELSE + UPDATE users SET "freeComments" = "freeComments" - 1 WHERE id = user_id; + END IF; + ELSE + UPDATE users SET msats = msats - cost WHERE id = user_id; + + INSERT INTO "ItemAct" (sats, "itemId", "userId", act, created_at, updated_at) + VALUES (cost / 1000, item.id, user_id, 'VOTE', now_utc(), now_utc()); + END IF; + + IF boost > 0 THEN + PERFORM item_act(item.id, user_id, 'BOOST', boost); + END IF; + + RETURN item; +END; +$$; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c07c9b83..75e8c3a4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,7 +33,7 @@ model User { msats Int @default(0) stackedMsats Int @default(0) freeComments Int @default(0) - freePosts Int @default(2) + freePosts Int @default(0) checkedNotesAt DateTime? tipDefault Int @default(10) pubkey String? @unique @@ -166,6 +166,7 @@ model Item { boost Int @default(0) uploadId Int? upload Upload? + paidImgLink Boolean @default(false) // if sub is null, this is the main sub sub Sub? @relation(fields: [subName], references: [name])