Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow zapping, posting and commenting without funds or an account #336

Merged
merged 59 commits into from
Aug 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
5415c6b
Add anon zaps
ekzyis Jul 13, 2023
74893b0
Add anon comments and posts (link, discussion, poll)
ekzyis Jul 19, 2023
fd8510d
Use payment hash instead of invoice id as proof of payment
ekzyis Jul 20, 2023
853a389
Allow pay per invoice for stackers
ekzyis Jul 20, 2023
7dda8a1
Fix onSuccess called twice
ekzyis Jul 22, 2023
6b4b502
Keep invoice modal open if focus is lost
ekzyis Jul 22, 2023
f0d0d07
Skip anon user during trust calculation
ekzyis Jul 22, 2023
85162b6
Add error handling
ekzyis Jul 22, 2023
28ea5ab
Skip 'invoice not found' errors
ekzyis Jul 22, 2023
773f658
Remove duplicate insufficient funds handling
ekzyis Jul 22, 2023
1cd9750
Fix insufficient funds error detection
ekzyis Jul 22, 2023
f2f09b2
Fix invoice amount for comments
ekzyis Jul 22, 2023
d186e86
Allow pay per invoice for bounty and job posts
ekzyis Jul 23, 2023
ba04e65
Also strike on payment after short press
ekzyis Jul 23, 2023
c975bd8
Fix unexpected token 'export'
ekzyis Jul 26, 2023
3d0bb4b
Merge branch 'master' into 266-zaps-without-account
huumn Aug 7, 2023
a0974e4
Merge branch 'master' into 266-zaps-without-account
huumn Aug 7, 2023
76b4156
Merge branch 'master' into 266-zaps-without-account
huumn Aug 8, 2023
7094f5b
Fix eslint
ekzyis Aug 9, 2023
118f591
Merge branch 'master' into 266-zaps-without-account
ekzyis Aug 9, 2023
bd59e39
Remove unused id param
ekzyis Aug 9, 2023
38dbbd5
Fix comment copy-paste error
ekzyis Aug 9, 2023
3180881
Rename to useInvoiceable
ekzyis Aug 9, 2023
9bc5138
Fix unexpected token 'export'
ekzyis Aug 10, 2023
4fe1d41
Fix onConfirmation called at every render
ekzyis Aug 10, 2023
bb2212d
Add invoice HMAC
ekzyis Aug 10, 2023
cbfd699
Merge branch 'master' into 266-zaps-without-account
huumn Aug 10, 2023
081c5fe
make anon posting less hidden, add anon info button explainer
huumn Aug 10, 2023
35760e1
Fix anon users can't zap other anon users
ekzyis Aug 10, 2023
49736e8
Always show repeat and contacts on action error
ekzyis Aug 10, 2023
2fbf1e4
Keep track of modal stack
ekzyis Aug 10, 2023
46274fb
give anon an icon
huumn Aug 10, 2023
26762ef
add generic date pivot helper
huumn Aug 10, 2023
2fa34ec
make anon user's invoices expire in 5 minutes
huumn Aug 10, 2023
53a6c94
fix forgotten find and replace
huumn Aug 10, 2023
e668b1f
use datePivot more places
huumn Aug 10, 2023
ea9c405
add sat amounts to invoices
huumn Aug 10, 2023
0f74893
reduce anon invoice expiration to 3 minutes
huumn Aug 10, 2023
d92701c
don't abbreviate
huumn Aug 11, 2023
28b4588
Fix [object Object] as error message
ekzyis Aug 11, 2023
41f46cf
Fix empty invoice creation attempts
ekzyis Aug 11, 2023
6ba1c3e
anon func mods, e.g. inv limits
huumn Aug 11, 2023
5302263
anon tips should be denormalized
huumn Aug 11, 2023
e4c2d11
remove redundant meTotalSats
huumn Aug 11, 2023
9c6ecf9
correct overlay zap text for anon
huumn Aug 11, 2023
6e69413
exclude anon from trust graph before algo runs
huumn Aug 11, 2023
d406ccc
remove balance limit on anon
huumn Aug 11, 2023
e995fd4
give anon a bio and remove cowboy hat/top stackers;
huumn Aug 11, 2023
b2508b7
make anon hat appear on profile
huumn Aug 11, 2023
38fddcf
concat hash and hmac and call it a token
huumn Aug 11, 2023
73aa0d2
Fix localStorage cleared because error were swallowed
ekzyis Aug 11, 2023
63dd5d4
fix qr layout shift
huumn Aug 11, 2023
39db6e0
restyle fund error modal
huumn Aug 11, 2023
9e4f9aa
Catch invoice errors in fund error modal
ekzyis Aug 11, 2023
a5eb7b5
invoice check backoff
huumn Aug 11, 2023
abb9ca5
anon info typo
huumn Aug 11, 2023
86239a2
make invoice expiration times have saner defaults
huumn Aug 11, 2023
705e21a
add comma to anon info
huumn Aug 11, 2023
5b82190
use builtin copy input label
huumn Aug 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ PUBLIC_URL=http://localhost:3000
LND_CONNECT_ADDRESS=03cc1d0932bb99b0697f5b5e5961b83ab7fd66f1efc4c9f5c7bad66c1bcbe78f02@xhlmkj7mfrl6ejnczfwl2vqik3xim6wzmurc2vlyfoqw2sasaocgpuad.onion:9735
NEXTAUTH_SECRET=3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI
JWT_SIGNING_PRIVATE_KEY={"kty":"oct","kid":"FvD__hmeKoKHu2fKjUrWbRKfhjimIM4IKshyrJG4KSM","alg":"HS512","k":"3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI"}
INVOICE_HMAC_KEY=a4c1d9c81edb87b79d28809876a18cf72293eadb39f92f3f4f2f1cfbdf907c91

# imgproxy
NEXT_PUBLIC_IMGPROXY_URL=
Expand Down
132 changes: 102 additions & 30 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import domino from 'domino'
import {
BOOST_MIN, ITEM_SPAM_INTERVAL,
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD,
DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY
DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
ANON_COMMENT_FEE, ANON_USER_ID, ANON_POST_FEE, ANON_ITEM_SPAM_INTERVAL
} from '../../lib/constants'
import { msatsToSats, numWithUnits } from '../../lib/format'
import { parse } from 'tldts'
Expand All @@ -16,6 +17,7 @@ import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema,
import { sendUserNotification } from '../webPush'
import { proxyImages } from './imgproxy'
import { defaultCommentSort } from '../../lib/item'
import { createHmac } from './wallet'

export async function commentFilterClause (me, models) {
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
Expand All @@ -36,6 +38,33 @@ export async function commentFilterClause (me, models) {
return clause
}

async function checkInvoice (models, hash, hmac, fee) {
if (!hmac) {
throw new GraphQLError('hmac required', { extensions: { code: 'BAD_INPUT' } })
}
const hmac2 = createHmac(hash)
if (hmac !== hmac2) {
throw new GraphQLError('bad hmac', { extensions: { code: 'FORBIDDEN' } })
}

const invoice = await models.invoice.findUnique({
where: { hash },
include: {
user: true
}
})
if (!invoice) {
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
}
if (!invoice.msatsReceived) {
throw new GraphQLError('invoice was not paid', { extensions: { code: 'BAD_INPUT' } })
}
if (msatsToSats(invoice.msatsReceived) < fee) {
throw new GraphQLError('invoice amount too low', { extensions: { code: 'BAD_INPUT' } })
}
return invoice
}

async function comments (me, models, id, sort) {
let orderBy
switch (sort) {
Expand Down Expand Up @@ -570,7 +599,7 @@ export default {
if (id) {
return await updateItem(parent, { id, data }, { me, models })
} else {
return await createItem(parent, data, { me, models })
return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash, invoiceHmac: args.invoiceHmac })
}
},
upsertDiscussion: async (parent, args, { me, models }) => {
Expand All @@ -581,7 +610,7 @@ export default {
if (id) {
return await updateItem(parent, { id, data }, { me, models })
} else {
return await createItem(parent, data, { me, models })
return await createItem(parent, data, { me, models, invoiceHash: args.invoiceHash, invoiceHmac: args.invoiceHmac })
}
},
upsertBounty: async (parent, args, { me, models }) => {
Expand All @@ -596,8 +625,18 @@ export default {
}
},
upsertPoll: async (parent, { id, ...data }, { me, models }) => {
const { forward, sub, boost, title, text, options } = data
if (!me) {
const { sub, forward, boost, title, text, options, invoiceHash, invoiceHmac } = data
let author = me
let spamInterval = ITEM_SPAM_INTERVAL
const trx = []
if (!me && invoiceHash) {
const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, ANON_POST_FEE)
author = invoice.user
spamInterval = ANON_ITEM_SPAM_INTERVAL
trx.push(models.invoice.delete({ where: { hash: invoiceHash } }))
}

if (!author) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}

Expand All @@ -621,7 +660,7 @@ export default {

if (id) {
const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) {
if (Number(old.userId) !== Number(author.id)) {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
}
const [item] = await serialize(models,
Expand All @@ -632,9 +671,11 @@ export default {
item.comments = []
return item
} else {
const [item] = await serialize(models,
models.$queryRawUnsafe(`${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::INTEGER, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(me.id), options, Number(fwdUser?.id)))
const [query] = await serialize(models,
models.$queryRawUnsafe(
`${SELECT} FROM create_poll($1, $2, $3, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7, $8::INTEGER, '${spamInterval}') AS "Item"`,
sub || 'bitcoin', title, text, 1, Number(boost || 0), Number(author.id), options, Number(fwdUser?.id)), ...trx)
const item = trx.length > 0 ? query[0] : query

await createMentions(item, models)
item.comments = []
Expand Down Expand Up @@ -678,13 +719,14 @@ export default {
},
createComment: async (parent, data, { me, models }) => {
await ssValidate(commentSchema, data)
const item = await createItem(parent, data, { me, models })
const item = await createItem(parent, data,
{ me, models, invoiceHash: data.invoiceHash, invoiceHmac: data.invoiceHmac })
// fetch user to get up-to-date name
const user = await models.user.findUnique({ where: { id: me.id } })
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } })

const parents = await models.$queryRawUnsafe(
'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2',
Number(item.parentId), Number(me.id))
Number(item.parentId), Number(user.id))
Promise.allSettled(
parents.map(({ userId }) => sendUserNotification(userId, {
title: `@${user.name} replied to you`,
Expand All @@ -711,27 +753,44 @@ export default {

return id
},
act: async (parent, { id, sats }, { me, models }) => {
act: async (parent, { id, sats, invoiceHash, invoiceHmac }, { me, models }) => {
// need to make sure we are logged in
if (!me) {
if (!me && !invoiceHash) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}

await ssValidate(amountSchema, { amount: sats })

// disallow self tips
const [item] = await models.$queryRawUnsafe(`
${SELECT}
FROM "Item"
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
if (item) {
throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } })
let user = me
let invoice
if (!me && invoiceHash) {
invoice = await checkInvoice(models, invoiceHash, invoiceHmac, sats)
user = invoice.user
}

const [{ item_act: vote }] = await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${me.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`)
// disallow self tips except anons
if (user.id !== ANON_USER_ID) {
const [item] = await models.$queryRawUnsafe(`
${SELECT}
FROM "Item"
WHERE id = $1 AND "userId" = $2`, Number(id), user.id)
if (item) {
throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } })
}
}

const calls = [
models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${user.id}::INTEGER, 'TIP', ${Number(sats)}::INTEGER)`
]
if (invoice) {
calls.push(models.invoice.delete({ where: { hash: invoice.hash } }))
}

const [{ item_act: vote }] = await serialize(models, ...calls)

const updatedItem = await models.item.findUnique({ where: { id: Number(id) } })
const title = `your ${updatedItem.title ? 'post' : 'reply'} ${updatedItem.fwdUser ? 'forwarded' : 'stacked'} ${numWithUnits(msatsToSats(updatedItem.msats))}${updatedItem.fwdUser ? ` to @${updatedItem.fwdUser.name}` : ''}`
const title = `your ${updatedItem.title ? 'post' : 'reply'} ${updatedItem.fwdUser ? 'forwarded' : 'stacked'} ${
numWithUnits(msatsToSats(updatedItem.msats))}${updatedItem.fwdUser ? ` to @${updatedItem.fwdUser.name}` : ''}`
sendUserNotification(updatedItem.userId, {
title,
body: updatedItem.title ? updatedItem.title : updatedItem.text,
Expand Down Expand Up @@ -759,7 +818,8 @@ export default {
throw new GraphQLError('cannot downvote your self', { extensions: { code: 'BAD_INPUT' } })
}

await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER, ${me.id}::INTEGER, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST}::INTEGER)`)
await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER,
${me.id}::INTEGER, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST}::INTEGER)`)

return true
}
Expand Down Expand Up @@ -1051,8 +1111,18 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo
return item
}

const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models }) => {
if (!me) {
const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models, invoiceHash, invoiceHmac }) => {
let author = me
let spamInterval = ITEM_SPAM_INTERVAL
const trx = []
if (!me && invoiceHash) {
const invoice = await checkInvoice(models, invoiceHash, invoiceHmac, parentId ? ANON_COMMENT_FEE : ANON_POST_FEE)
author = invoice.user
spamInterval = ANON_ITEM_SPAM_INTERVAL
trx.push(models.invoice.delete({ where: { hash: invoiceHash } }))
}

if (!author) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}

Expand All @@ -1075,19 +1145,21 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
url = await proxyImages(url)
text = await proxyImages(text)

const [item] = await serialize(
const [query] = await serialize(
models,
models.$queryRawUnsafe(
`${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::INTEGER, '${ITEM_SPAM_INTERVAL}') AS "Item"`,
`${SELECT} FROM create_item($1, $2, $3, $4, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8::INTEGER, $9::INTEGER, '${spamInterval}') AS "Item"`,
parentId ? null : sub || 'bitcoin',
title,
url,
text,
Number(boost || 0),
bounty ? Number(bounty) : null,
Number(parentId),
Number(me.id),
Number(fwdUser?.id)))
Number(author.id),
Number(fwdUser?.id)),
...trx)
const item = trx.length > 0 ? query[0] : query

await createMentions(item, models)

Expand Down
8 changes: 4 additions & 4 deletions api/resolvers/serial.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ const { GraphQLError } = require('graphql')
const retry = require('async-retry')
const Prisma = require('@prisma/client')

async function serialize (models, call) {
async function serialize (models, ...calls) {
return await retry(async bail => {
try {
const [, result] = await models.$transaction(
[models.$executeRaw`SELECT ASSERT_SERIALIZED()`, call],
const [, ...result] = await models.$transaction(
[models.$executeRaw`SELECT ASSERT_SERIALIZED()`, ...calls],
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable })
return result
return calls.length > 1 ? result : result[0]
} catch (error) {
console.log(error)
if (error.message.includes('SN_INSUFFICIENT_FUNDS')) {
Expand Down
10 changes: 5 additions & 5 deletions api/resolvers/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { msatsToSats } from '../../lib/format'
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate'
import { createMentions, getItem, SELECT, updateItem, filterClause } from './item'
import serialize from './serial'
import { dayPivot } from '../../lib/time'
import { datePivot } from '../../lib/time'

export function within (table, within) {
let interval = ' AND "' + table + '".created_at >= $1 - INTERVAL '
Expand Down Expand Up @@ -54,13 +54,13 @@ export function viewWithin (table, within) {
export function withinDate (within) {
switch (within) {
case 'day':
return dayPivot(new Date(), -1)
return datePivot(new Date(), { days: -1 })
case 'week':
return dayPivot(new Date(), -7)
return datePivot(new Date(), { days: -7 })
case 'month':
return dayPivot(new Date(), -30)
return datePivot(new Date(), { days: -30 })
case 'year':
return dayPivot(new Date(), -365)
return datePivot(new Date(), { days: -365 })
default:
return new Date(0)
}
Expand Down
Loading