From 8a4e67e9f008597931018d640fa04f35e13965ae Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 13 Sep 2024 17:11:19 +0200 Subject: [PATCH] Anon edits (#1393) * Rename vars around edit permission * Allow anon edits with hash+hmac * Fix missing time zone for invoice.confirmedAt of comments * Fix missing invoice update on item update --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> --- api/paidAction/index.js | 10 +++-- api/paidAction/itemUpdate.js | 4 +- api/resolvers/item.js | 38 +++++++++++-------- api/resolvers/wallet.js | 13 +++++-- api/typeDefs/item.js | 21 +++++++--- components/item-info.js | 8 ++-- components/use-item-submit.js | 23 +++++++++++ components/use-paid-mutation.js | 8 +++- fragments/paidAction.js | 21 ++++++---- lib/constants.js | 6 +-- pages/items/[id]/edit.js | 3 +- .../migration.sql | 33 ++++++++++++++++ worker/trust.js | 8 ++-- 13 files changed, 142 insertions(+), 54 deletions(-) create mode 100644 prisma/migrations/20240911231435_item_comments_fix_invoice_paid_at_utc/migration.sql diff --git a/api/paidAction/index.js b/api/paidAction/index.js index 98bf6a5b..7717ba40 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -48,11 +48,13 @@ export default async function performPaidAction (actionType, args, context) { throw new Error('You must be logged in to perform this action') } - console.log('we are anon so can only perform pessimistic action') - return await performPessimisticAction(actionType, args, context) + if (context.cost > 0) { + console.log('we are anon so can only perform pessimistic action that require payment') + return await performPessimisticAction(actionType, args, context) + } } - const isRich = context.cost <= context.me.msats + const isRich = context.cost <= (context.me?.msats ?? 0) if (isRich) { try { console.log('enough fee credits available, performing fee credit action') @@ -100,7 +102,7 @@ async function performFeeCreditAction (actionType, args, context) { await tx.user.update({ where: { - id: me.id + id: me?.id ?? USER_ID.anon }, data: { msats: { diff --git a/api/paidAction/itemUpdate.js b/api/paidAction/itemUpdate.js index 36335342..58f20631 100644 --- a/api/paidAction/itemUpdate.js +++ b/api/paidAction/itemUpdate.js @@ -4,7 +4,7 @@ import { getItemMentions, getMentions, performBotBehavior } from './lib/item' import { notifyItemMention, notifyMention } from '@/lib/webPush' import { satsToMsats } from '@/lib/format' -export const anonable = false +export const anonable = true export const supportsPessimism = true export const supportsOptimism = false @@ -17,7 +17,7 @@ export async function getCost ({ id, boost = 0, uploadIds }, { me, models }) { } export async function perform (args, context) { - const { id, boost = 0, uploadIds = [], options: pollOptions = [], forwardUsers: itemForwards = [], invoiceId, ...data } = args + const { id, boost = 0, uploadIds = [], options: pollOptions = [], forwardUsers: itemForwards = [], ...data } = args const { tx, me, models } = context const old = await tx.item.findUnique({ where: { id: parseInt(id) }, diff --git a/api/resolvers/item.js b/api/resolvers/item.js index c22236bd..7395c89a 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -7,7 +7,7 @@ import { ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, USER_ID, POLL_COST, - ITEM_ALLOW_EDITS, GLOBAL_SEED, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_USER_IDS + ADMIN_ITEMS, GLOBAL_SEED, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS } from '@/lib/constants' import { msatsToSats } from '@/lib/format' import { parse } from 'tldts' @@ -20,6 +20,7 @@ import assertGofacYourself from './ofac' import assertApiKeyNotPermitted from './apiKey' import performPaidAction from '../paidAction' import { GqlAuthenticationError, GqlInputError } from '@/lib/error' +import { verifyHmac } from './wallet' function commentsOrderByClause (me, models, sort) { if (sort === 'recent') { @@ -1257,9 +1258,9 @@ export default { } } -export const updateItem = async (parent, { sub: subName, forward, ...item }, { me, models, lnd }) => { +export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ...item }, { me, models, lnd }) => { // update iff this item belongs to me - const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { sub: true } }) + const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { invoice: true, sub: true } }) if (old.deletedAt) { throw new GqlInputError('item is deleted') @@ -1269,15 +1270,19 @@ export const updateItem = async (parent, { sub: subName, forward, ...item }, { m throw new GqlInputError('cannot edit unpaid item') } - // author can always edit their own item - const mid = Number(me?.id) - const isMine = Number(old.userId) === mid + // author can edit their own item (except anon) + const meId = Number(me?.id ?? USER_ID.anon) + const authorEdit = !!me && Number(old.userId) === meId + // admins can edit special items + const adminEdit = ADMIN_ITEMS.includes(old.id) && SN_ADMIN_IDS.includes(meId) + // anybody can edit with valid hash+hmac + let hmacEdit = false + if (old.invoice?.hash && hash && hmac) { + hmacEdit = old.invoice.hash === hash && verifyHmac(hash, hmac) + } - // allow admins to edit special items - const allowEdit = ITEM_ALLOW_EDITS.includes(old.id) - const adminEdit = SN_USER_IDS.includes(mid) && allowEdit - - if (!isMine && !adminEdit) { + // ownership permission check + if (!authorEdit && !adminEdit && !hmacEdit) { throw new GqlInputError('item does not belong to you') } @@ -1292,13 +1297,14 @@ export const updateItem = async (parent, { sub: subName, forward, ...item }, { m // in case they lied about their existing boost await ssValidate(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost }) - const user = await models.user.findUnique({ where: { id: me.id } }) + const user = await models.user.findUnique({ where: { id: meId } }) // prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes const myBio = user.bioId === old.id const timer = Date.now() < new Date(old.invoicePaidAt ?? old.createdAt).getTime() + 10 * 60_000 - if (!allowEdit && !myBio && !timer && !isJob(item)) { + // timer permission check + if (!adminEdit && !myBio && !timer && !isJob(item)) { throw new GqlInputError('item can no longer be edited') } @@ -1309,12 +1315,12 @@ export const updateItem = async (parent, { sub: subName, forward, ...item }, { m if (old.bio) { // prevent editing a bio like a regular item - item = { id: Number(item.id), text: item.text, title: `@${user.name}'s bio`, userId: me.id } + item = { id: Number(item.id), text: item.text, title: `@${user.name}'s bio`, userId: meId } } else if (old.parentId) { // prevent editing a comment like a post - item = { id: Number(item.id), text: item.text, userId: me.id } + item = { id: Number(item.id), text: item.text, userId: meId } } else { - item = { subName, userId: me.id, ...item } + item = { subName, userId: meId, ...item } item.forwardUsers = await getForwardUsers(models, forward) } item.uploadIds = uploadIdsFromText(item.text, { models }) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 057b40d8..d3315f12 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -114,6 +114,14 @@ export function createHmac (hash) { return crypto.createHmac('sha256', key).update(Buffer.from(hash, 'hex')).digest('hex') } +export function verifyHmac (hash, hmac) { + const hmac2 = createHmac(hash) + if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) { + throw new GqlAuthorizationError('bad hmac') + } + return true +} + const resolvers = { Query: { invoice: getInvoice, @@ -411,10 +419,7 @@ const resolvers = { createWithdrawl: createWithdrawal, sendToLnAddr, cancelInvoice: async (parent, { hash, hmac }, { models, lnd, boss }) => { - const hmac2 = createHmac(hash) - if (!timingSafeEqual(Buffer.from(hmac), Buffer.from(hmac2))) { - throw new GqlAuthorizationError('bad hmac') - } + verifyHmac(hash, hmac) await finalizeHodlInvoice({ data: { hash }, lnd, models, boss }) return await models.invoice.findFirst({ where: { hash } }) }, diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index a50b207a..67f5513a 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -35,14 +35,23 @@ export default gql` pinItem(id: ID): Item subscribeItem(id: ID): Item deleteItem(id: ID): Item - upsertLink(id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput]): ItemPaidAction! - upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput]): ItemPaidAction! - upsertBounty(id: ID, sub: String, title: String!, text: String, bounty: Int, boost: Int, forward: [ItemForwardInput]): ItemPaidAction! - upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, + upsertLink( + id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput], + hash: String, hmac: String): ItemPaidAction! + upsertDiscussion( + id: ID, sub: String, title: String!, text: String, boost: Int, forward: [ItemForwardInput], + hash: String, hmac: String): ItemPaidAction! + upsertBounty( + id: ID, sub: String, title: String!, text: String, bounty: Int, boost: Int, forward: [ItemForwardInput], + hash: String, hmac: String): ItemPaidAction! + upsertJob( + id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean, text: String!, url: String!, maxBid: Int!, status: String, logo: Int): ItemPaidAction! - upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date): ItemPaidAction! + upsertPoll( + id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], pollExpiresAt: Date, + hash: String, hmac: String): ItemPaidAction! updateNoteId(id: ID!, noteId: String!): Item! - upsertComment(id:ID, text: String!, parentId: ID): ItemPaidAction! + upsertComment(id: ID, text: String!, parentId: ID, hash: String, hmac: String): ItemPaidAction! act(id: ID!, sats: Int, act: String, idempotent: Boolean): ItemActPaidAction! pollVote(id: ID!): PollVotePaidAction! toggleOutlaw(id: ID!): Item! diff --git a/components/item-info.js b/components/item-info.js index 245a182e..b2854ddc 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -36,8 +36,7 @@ export default function ItemInfo ({ const { me } = useMe() const toaster = useToast() const router = useRouter() - const [canEdit, setCanEdit] = - useState(item.mine && (Date.now() < editThreshold)) + const [canEdit, setCanEdit] = useState(item.mine && (Date.now() < editThreshold)) const [hasNewComments, setHasNewComments] = useState(false) const root = useRoot() const retryCreateItem = useRetryCreateItem({ id: item.id }) @@ -50,8 +49,9 @@ export default function ItemInfo ({ }, [item]) useEffect(() => { - setCanEdit(item.mine && (Date.now() < editThreshold)) - }, [item.mine, editThreshold]) + const invoice = window.localStorage.getItem(`item:${item.id}:hash:hmac`) + setCanEdit((item.mine || invoice) && (Date.now() < editThreshold)) + }, [item.id, item.mine, editThreshold]) // territory founders can pin any post in their territory // and OPs can pin any root reply in their post diff --git a/components/use-item-submit.js b/components/use-item-submit.js index 2f4eda13..d85ddf6e 100644 --- a/components/use-item-submit.js +++ b/components/use-item-submit.js @@ -27,6 +27,15 @@ export default function useItemSubmit (mutation, options = options.slice(item?.poll?.options?.length || 0).filter(o => o.trim().length > 0) } + if (item?.id) { + const invoiceData = window.localStorage.getItem(`item:${item.id}:hash:hmac`) + if (invoiceData) { + const [hash, hmac] = invoiceData.split(':') + values.hash = hash + values.hmac = hmac + } + } + const { data, error, payError } = await upsertItem({ variables: { id: item?.id, @@ -55,6 +64,7 @@ export default function useItemSubmit (mutation, onCompleted: (data) => { onSuccessfulSubmit?.(data, { resetForm }) paidMutationOptions?.onCompleted?.(data) + saveItemInvoiceHmac(data) } }) @@ -114,3 +124,16 @@ export function useRetryCreateItem ({ id }) { return retryPaidAction } + +function saveItemInvoiceHmac (mutationData) { + const response = Object.values(mutationData)[0] + + if (!response?.invoice) return + + const id = response.result.id + const { hash, hmac } = response.invoice + + if (id && hash && hmac) { + window.localStorage.setItem(`item:${id}:hash:hmac`, `${hash}:${hmac}`) + } +} diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js index 83800bfb..d3ba7bd4 100644 --- a/components/use-paid-mutation.js +++ b/components/use-paid-mutation.js @@ -100,7 +100,13 @@ export function usePaidMutation (mutation, // if the mutation didn't return any data, ie pessimistic, we need to fetch it const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } }) // create new data object - data = { [Object.keys(data)[0]]: paidAction } + // ( hmac is only returned on invoice creation so we need to add it back to the data ) + data = { + [Object.keys(data)[0]]: { + ...paidAction, + invoice: { ...paidAction.invoice, hmac: invoice.hmac } + } + } // we need to run update functions on mutations now that we have the data update?.(client.cache, { data }) } diff --git a/fragments/paidAction.js b/fragments/paidAction.js index 3163d852..952d2f78 100644 --- a/fragments/paidAction.js +++ b/fragments/paidAction.js @@ -3,6 +3,9 @@ import { COMMENTS } from './comments' import { SUB_FULL_FIELDS } from './subs' import { INVOICE_FIELDS } from './wallet' +const HASH_HMAC_INPUT_1 = '$hash: String, $hmac: String' +const HASH_HMAC_INPUT_2 = 'hash: $hash, hmac: $hmac' + export const PAID_ACTION = gql` ${INVOICE_FIELDS} fragment PaidActionFields on PaidAction { @@ -115,9 +118,9 @@ export const ACT_MUTATION = gql` export const UPSERT_DISCUSSION = gql` ${PAID_ACTION} mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, - $boost: Int, $forward: [ItemForwardInput]) { + $boost: Int, $forward: [ItemForwardInput], ${HASH_HMAC_INPUT_1}) { upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, - forward: $forward) { + forward: $forward, ${HASH_HMAC_INPUT_2}) { result { id deleteScheduledAt @@ -147,9 +150,9 @@ export const UPSERT_JOB = gql` export const UPSERT_LINK = gql` ${PAID_ACTION} mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, - $text: String, $boost: Int, $forward: [ItemForwardInput]) { + $text: String, $boost: Int, $forward: [ItemForwardInput], ${HASH_HMAC_INPUT_1}) { upsertLink(sub: $sub, id: $id, title: $title, url: $url, text: $text, - boost: $boost, forward: $forward) { + boost: $boost, forward: $forward, ${HASH_HMAC_INPUT_2}) { result { id deleteScheduledAt @@ -162,9 +165,11 @@ export const UPSERT_LINK = gql` export const UPSERT_POLL = gql` ${PAID_ACTION} mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String, - $options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $pollExpiresAt: Date) { + $options: [String!]!, $boost: Int, $forward: [ItemForwardInput], $pollExpiresAt: Date, + ${HASH_HMAC_INPUT_1}) { upsertPoll(sub: $sub, id: $id, title: $title, text: $text, - options: $options, boost: $boost, forward: $forward, pollExpiresAt: $pollExpiresAt) { + options: $options, boost: $boost, forward: $forward, pollExpiresAt: $pollExpiresAt, + ${HASH_HMAC_INPUT_2}) { result { id deleteScheduledAt @@ -213,8 +218,8 @@ export const CREATE_COMMENT = gql` export const UPDATE_COMMENT = gql` ${ITEM_PAID_ACTION_FIELDS} ${PAID_ACTION} - mutation upsertComment($id: ID!, $text: String!) { - upsertComment(id: $id, text: $text) { + mutation upsertComment($id: ID!, $text: String!, ${HASH_HMAC_INPUT_1}) { + upsertComment(id: $id, text: $text, ${HASH_HMAC_INPUT_2}) { ...ItemPaidActionFields ...PaidActionFields } diff --git a/lib/constants.js b/lib/constants.js index 66cdbabe..ac5cd86a 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -50,7 +50,7 @@ export const USER_ID = { delete: 106, saloon: 17226 } -export const SN_USER_IDS = [USER_ID.k00b, USER_ID.ek, USER_ID.sn] +export const SN_ADMIN_IDS = [USER_ID.k00b, USER_ID.ek, USER_ID.sn] export const SN_NO_REWARDS_IDS = [USER_ID.anon, USER_ID.sn, USER_ID.saloon] export const ANON_INV_PENDING_LIMIT = 1000 export const ANON_BALANCE_LIMIT_MSATS = 0 // disable @@ -76,7 +76,7 @@ export const LNURLP_COMMENT_MAX_LENGTH = 1000 export const RESERVED_MAX_USER_ID = 615 export const GLOBAL_SEED = USER_ID.k00b export const FREEBIE_BASE_COST_THRESHOLD = 10 -export const USER_IDS_BALANCE_NO_LIMIT = [...SN_USER_IDS, USER_ID.anon, USER_ID.ad] +export const USER_IDS_BALANCE_NO_LIMIT = [...SN_ADMIN_IDS, USER_ID.anon, USER_ID.ad] // WIP ultimately subject to this list: https://ofac.treasury.gov/sanctions-programs-and-country-information // From lawyers: north korea, cuba, iran, ukraine, syria @@ -132,7 +132,7 @@ export const LOST_BLURBS = [ 'you lost your hat while crossing the river on your journey west. Maybe you can find a replacement hat in the next town.' ] -export const ITEM_ALLOW_EDITS = [ +export const ADMIN_ITEMS = [ // FAQ, old privacy policy, changelog, content guidelines, tos, new privacy policy, copyright policy 349, 76894, 78763, 81862, 338393, 338369, 338453 ] diff --git a/pages/items/[id]/edit.js b/pages/items/[id]/edit.js index 4fd37d7c..07f4b1c7 100644 --- a/pages/items/[id]/edit.js +++ b/pages/items/[id]/edit.js @@ -15,8 +15,7 @@ import SubSelect from '@/components/sub-select' export const getServerSideProps = getGetServerSideProps({ query: ITEM, - notFound: data => !data.item, - authRequired: true + notFound: data => !data.item }) export default function PostEdit ({ ssrData }) { diff --git a/prisma/migrations/20240911231435_item_comments_fix_invoice_paid_at_utc/migration.sql b/prisma/migrations/20240911231435_item_comments_fix_invoice_paid_at_utc/migration.sql new file mode 100644 index 00000000..ed8a9836 --- /dev/null +++ b/prisma/migrations/20240911231435_item_comments_fix_invoice_paid_at_utc/migration.sql @@ -0,0 +1,33 @@ +-- fix missing time zone cast for "Item"."invoicePaidAt" +CREATE OR REPLACE FUNCTION item_comments(_item_id int, _level int, _where text, _order_by text) + RETURNS jsonb + LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS +$$ +DECLARE + result jsonb; +BEGIN + IF _level < 1 THEN + RETURN '[]'::jsonb; + END IF; + + EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS' + || ' SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", ' + || ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", ' + || ' to_jsonb(users.*) as user ' + || ' FROM "Item" ' + || ' JOIN users ON users.id = "Item"."userId" ' + || ' WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) ' || _where + USING _item_id, _level, _where, _order_by; + + EXECUTE '' + || 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments ' + || 'FROM ( ' + || ' SELECT "Item".*, item_comments("Item".id, $2 - 1, $3, $4) AS comments ' + || ' FROM t_item "Item"' + || ' WHERE "Item"."parentId" = $1 ' + || _order_by + || ' ) sub' + INTO result USING _item_id, _level, _where, _order_by; + RETURN result; +END +$$; \ No newline at end of file diff --git a/worker/trust.js b/worker/trust.js index 485b5adb..2b13c837 100644 --- a/worker/trust.js +++ b/worker/trust.js @@ -1,5 +1,5 @@ import * as math from 'mathjs' -import { USER_ID, SN_USER_IDS } from '@/lib/constants.js' +import { USER_ID, SN_ADMIN_IDS } from '@/lib/constants.js' export async function trust ({ boss, models }) { try { @@ -68,7 +68,7 @@ function trustGivenGraph (graph) { console.timeLog('trust', 'transforming result') - const seedIdxs = SN_USER_IDS.map(id => posByUserId[id]) + const seedIdxs = SN_ADMIN_IDS.map(id => posByUserId[id]) const isOutlier = (fromIdx, idx) => [...seedIdxs, fromIdx].includes(idx) const sqapply = (mat, fn) => { let idx = 0 @@ -151,10 +151,10 @@ async function getGraph (models) { confidence(before - disagree, b_total - after, ${Z_CONFIDENCE}) ELSE 0 END AS trust FROM user_pair - WHERE NOT (b_id = ANY (${SN_USER_IDS})) + WHERE NOT (b_id = ANY (${SN_ADMIN_IDS})) UNION ALL SELECT a_id AS id, seed_id AS oid, ${MAX_TRUST}::numeric as trust - FROM user_pair, unnest(${SN_USER_IDS}::int[]) seed_id + FROM user_pair, unnest(${SN_ADMIN_IDS}::int[]) seed_id GROUP BY a_id, a_total, seed_id UNION ALL SELECT a_id AS id, a_id AS oid, ${MAX_TRUST}::float as trust