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>
This commit is contained in:
ekzyis 2024-09-13 17:11:19 +02:00 committed by GitHub
parent 4340a82a62
commit 8a4e67e9f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 142 additions and 54 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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