Compare commits

..

25 Commits

Author SHA1 Message Date
Keyan
a669ec832b
Update README.md 2025-03-20 13:28:26 -05:00
k00b
7dccd383c3 fix hot score on limited comment queries fix #1996 2025-03-20 12:21:20 -05:00
ekzyis
271563efbd
Fix space before question mark in delete prompt (#1995) 2025-03-20 10:48:17 -05:00
ekzyis
ada230597d
Fix anon dropdown button width (#1997) 2025-03-20 10:46:07 -05:00
Edward Kung
08501583df
reset nym editting state on page change (#1993) 2025-03-19 18:55:22 -05:00
ekzyis
74d99e9b74
Reset multi_auth cookies on error (#1957)
* multi_auth cookies check + reset

* multi_auth cookies refresh

* Expire cookies after 30 days

This is the actual default for next-auth.session-token.

* Collapse issues by default

* Only refresh session cookie manually as anon

* fix mangled merge

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-03-19 18:54:43 -05:00
soxa
71caa6d0fe
Prevent new account creation on login (#1976)
* Prevent account creation if we're not signin up

* remove cookie once logged in, 24 hours expiry, comment

* adjust error messages

* check signin instead of signup

* appendHeader to avoid overwrites, fix typo, use NodeNextRequest to handle cookies

* expire cookie if signup
2025-03-19 16:55:38 -05:00
k00b
4f17615291 fix expireBoost boolean condition 2025-03-19 11:48:36 -05:00
k00b
0e4b467b3c make expire boost exclude unpaid invoices 2025-03-19 11:44:35 -05:00
Edward Kung
9905e6eafe
Top cowboys territory selector fix (#1972)
* fix territory selector when in top/cowboys

* redirect /~sub/top/cowboys to /top/cowboys

* check if pathname ends with /top/cowboys

Co-authored-by: ekzyis <ek@stacker.news>

* fix territory selector in top/stackers and top/territories

* better routing logic

---------

Co-authored-by: ekzyis <ek@stacker.news>
2025-03-19 08:19:19 -05:00
github-actions[bot]
fc6cbba40c
Extending awards.csv (#1988)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-03-18 18:18:58 -05:00
ekzyis
60f628e77e
Fix no pw manager autofill for device sync password (#1953)
* Fix no pw manager autofill for device sync password

* Remove unused rows property
2025-03-18 18:17:23 -05:00
soxa
63704a5f0f
Hide pull-to-refresh when not pulling (#1986) 2025-03-18 18:16:36 -05:00
ekzyis
964cdc1d61
Fix warning about missing key for children (#1987) 2025-03-18 17:48:13 -05:00
Scroogey-SN
e31f8e9c69
Support .onion for phoenixd (#1975)
* fix #1964 support .onion urls like lnbits does

* fix lint: const hostname

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-03-18 15:39:53 -05:00
k00b
b7dfef41c0 make search query work with os2.17 and upgrade containers 2025-03-18 14:01:00 -05:00
k00b
344c23ed5c use max zap creation 2025-03-17 20:03:05 -05:00
Edward Kung
b71398a06c
Search improvements: Add relevance search and make recent searches less strict (#1962)
* reconfigured search pipeline

* remove console debug messages

* log1p for comments

* improve relevance of non-relevance sorted queries

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2025-03-17 19:25:20 -05:00
github-actions[bot]
1a52ff7784
Extending awards.csv (#1978)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-03-17 14:31:00 -05:00
Scroogey-SN
a3762f70b0
fix #1959 strip trailing slashes from url (#1973) 2025-03-17 13:16:31 -05:00
ekzyis
c492618d31
Fix weeks not support in reminder command (#1977) 2025-03-17 13:15:09 -05:00
k00b
4be7f12119 add another hot_score index without nulls clause 2025-03-15 15:29:01 -05:00
Keyan
b672d015e2
territory specific trust (#1965)
* territory specific trust

* functional parity with master

* revert back to materialized view for ranking

* update query for populating subWeightedVotes

* fix anon hot comments

* fix zap denormalization, change weightedComments to be for zaps, order updates of ancestors to prevent deadlocks

* reduce weight of comment zaps for hot score

* do zap ancestor updates together

* initialize trust in new/unpopular territories

* simplify denormalization of zap/downzaps

* recompute all scores
2025-03-15 08:11:33 -05:00
Keyan
53f6c34ee7
Merge pull request #1974 from stackernews/rename-to-next-account
Fix missing rename to nextAccount
2025-03-15 08:06:21 -05:00
ekzyis
54e7793668 Fix missing rename to nextAccount 2025-03-14 22:01:55 -05:00
37 changed files with 1402 additions and 594 deletions

View File

@ -5,7 +5,7 @@
</p> </p>
- Stacker News makes internet communities that pay you Bitcoin - Stacker News is trying to fix online communities with economics
- What You See is What We Ship (look ma, I invented an initialism) - What You See is What We Ship (look ma, I invented an initialism)
- 100% FOSS - 100% FOSS
- We pay bitcoin for PRs, issues, documentation, code reviews and more - We pay bitcoin for PRs, issues, documentation, code reviews and more

View File

@ -1,5 +1,6 @@
import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants' import { PAID_ACTION_PAYMENT_METHODS } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format' import { msatsToSats, satsToMsats } from '@/lib/format'
import { Prisma } from '@prisma/client'
export const anonable = false export const anonable = false
@ -48,9 +49,9 @@ export async function onPaid ({ invoice, actId }, { tx }) {
let itemAct let itemAct
if (invoice) { if (invoice) {
await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } }) await tx.itemAct.updateMany({ where: { invoiceId: invoice.id }, data: { invoiceActionState: 'PAID' } })
itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id } }) itemAct = await tx.itemAct.findFirst({ where: { invoiceId: invoice.id }, include: { item: true } })
} else if (actId) { } else if (actId) {
itemAct = await tx.itemAct.findUnique({ where: { id: actId } }) itemAct = await tx.itemAct.findUnique({ where: { id: actId }, include: { item: true } })
} else { } else {
throw new Error('No invoice or actId') throw new Error('No invoice or actId')
} }
@ -60,8 +61,22 @@ export async function onPaid ({ invoice, actId }, { tx }) {
// denormalize downzaps // denormalize downzaps
await tx.$executeRaw` await tx.$executeRaw`
WITH zapper AS ( WITH territory AS (
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId"
WHERE i.id = ${itemAct.itemId}::INTEGER
), zapper AS (
SELECT
COALESCE(${itemAct.item.parentId
? Prisma.sql`"zapCommentTrust"`
: Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust",
COALESCE(${itemAct.item.parentId
? Prisma.sql`"subZapCommentTrust"`
: Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust"
FROM territory
LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName"
AND ust."userId" = ${itemAct.userId}::INTEGER
), zap AS ( ), zap AS (
INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats") INSERT INTO "ItemUserAgg" ("userId", "itemId", "downZapSats")
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER) VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
@ -70,7 +85,8 @@ export async function onPaid ({ invoice, actId }, { tx }) {
RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats RETURNING LOG("downZapSats" / GREATEST("downZapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
) )
UPDATE "Item" UPDATE "Item"
SET "weightedDownVotes" = "weightedDownVotes" + (zapper.trust * zap.log_sats) SET "weightedDownVotes" = "weightedDownVotes" + zapper."zapTrust" * zap.log_sats,
"subWeightedDownVotes" = "subWeightedDownVotes" + zapper."subZapTrust" * zap.log_sats
FROM zap, zapper FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER` WHERE "Item".id = ${itemAct.itemId}::INTEGER`
} }

View File

@ -252,15 +252,18 @@ export async function onPaid ({ invoice, id }, context) {
JOIN users ON "Item"."userId" = users.id JOIN users ON "Item"."userId" = users.id
WHERE "Item".id = ${item.id}::INTEGER WHERE "Item".id = ${item.id}::INTEGER
), ancestors AS ( ), ancestors AS (
SELECT "Item".*
FROM "Item", comment
WHERE "Item".path @> comment.path AND "Item".id <> comment.id
ORDER BY "Item".id
), updated_ancestors AS (
UPDATE "Item" UPDATE "Item"
SET ncomments = "Item".ncomments + 1, SET ncomments = "Item".ncomments + 1,
"lastCommentAt" = GREATEST("Item"."lastCommentAt", comment.created_at), "lastCommentAt" = GREATEST("Item"."lastCommentAt", comment.created_at),
"weightedComments" = "Item"."weightedComments" +
CASE WHEN comment."userId" = "Item"."userId" THEN 0 ELSE comment.trust END,
"nDirectComments" = "Item"."nDirectComments" + "nDirectComments" = "Item"."nDirectComments" +
CASE WHEN comment."parentId" = "Item".id THEN 1 ELSE 0 END CASE WHEN comment."parentId" = "Item".id THEN 1 ELSE 0 END
FROM comment FROM comment, ancestors
WHERE "Item".path @> comment.path AND "Item".id <> comment.id WHERE "Item".id = ancestors.id
RETURNING "Item".* RETURNING "Item".*
) )
INSERT INTO "Reply" (created_at, updated_at, "ancestorId", "ancestorUserId", "itemId", "userId", level) INSERT INTO "Reply" (created_at, updated_at, "ancestorId", "ancestorUserId", "itemId", "userId", level)

View File

@ -0,0 +1,27 @@
import { USER_ID } from '@/lib/constants'
export const GLOBAL_SEEDS = [USER_ID.k00b, USER_ID.ek]
export function initialTrust ({ name, userId }) {
const results = GLOBAL_SEEDS.map(id => ({
subName: name,
userId: id,
zapPostTrust: 1,
subZapPostTrust: 1,
zapCommentTrust: 1,
subZapCommentTrust: 1
}))
if (!GLOBAL_SEEDS.includes(userId)) {
results.push({
subName: name,
userId,
zapPostTrust: 0,
subZapPostTrust: 1,
zapCommentTrust: 0,
subZapCommentTrust: 1
})
}
return results
}

View File

@ -1,6 +1,7 @@
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants' import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format' import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory' import { nextBilling } from '@/lib/territory'
import { initialTrust } from './lib/territory'
export const anonable = false export const anonable = false
@ -20,7 +21,7 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
const billedLastAt = new Date() const billedLastAt = new Date()
const billPaidUntil = nextBilling(billedLastAt, billingType) const billPaidUntil = nextBilling(billedLastAt, billingType)
return await tx.sub.create({ const sub = await tx.sub.create({
data: { data: {
...data, ...data,
billedLastAt, billedLastAt,
@ -42,6 +43,12 @@ export async function perform ({ invoiceId, ...data }, { me, cost, tx }) {
} }
} }
}) })
await tx.userSubTrust.createMany({
data: initialTrust({ name: sub.name, userId: sub.userId })
})
return sub
} }
export async function describe ({ name }) { export async function describe ({ name }) {

View File

@ -1,6 +1,7 @@
import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants' import { PAID_ACTION_PAYMENT_METHODS, TERRITORY_PERIOD_COST } from '@/lib/constants'
import { satsToMsats } from '@/lib/format' import { satsToMsats } from '@/lib/format'
import { nextBilling } from '@/lib/territory' import { nextBilling } from '@/lib/territory'
import { initialTrust } from './lib/territory'
export const anonable = false export const anonable = false
@ -65,7 +66,7 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
} }
}) })
return await tx.sub.update({ const updatedSub = await tx.sub.update({
data, data,
// optimistic concurrency control // optimistic concurrency control
// make sure none of the relevant fields have changed since we fetched the sub // make sure none of the relevant fields have changed since we fetched the sub
@ -76,6 +77,12 @@ export async function perform ({ name, invoiceId, ...data }, { me, cost, tx }) {
} }
} }
}) })
await tx.userSubTrust.createMany({
data: initialTrust({ name: updatedSub.name, userId: updatedSub.userId })
})
return updatedSub
} }
export async function describe ({ name }, context) { export async function describe ({ name }, context) {

View File

@ -2,6 +2,7 @@ import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format' import { msatsToSats, satsToMsats } from '@/lib/format'
import { notifyZapped } from '@/lib/webPush' import { notifyZapped } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server' import { getInvoiceableWallets } from '@/wallets/server'
import { Prisma } from '@prisma/client'
export const anonable = true export const anonable = true
@ -149,8 +150,22 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt // perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking // NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
await tx.$queryRaw` await tx.$queryRaw`
WITH zapper AS ( WITH territory AS (
SELECT trust FROM users WHERE id = ${itemAct.userId}::INTEGER SELECT COALESCE(r."subName", i."subName", 'meta')::TEXT as "subName"
FROM "Item" i
LEFT JOIN "Item" r ON r.id = i."rootId"
WHERE i.id = ${itemAct.itemId}::INTEGER
), zapper AS (
SELECT
COALESCE(${itemAct.item.parentId
? Prisma.sql`"zapCommentTrust"`
: Prisma.sql`"zapPostTrust"`}, 0) as "zapTrust",
COALESCE(${itemAct.item.parentId
? Prisma.sql`"subZapCommentTrust"`
: Prisma.sql`"subZapPostTrust"`}, 0) as "subZapTrust"
FROM territory
LEFT JOIN "UserSubTrust" ust ON ust."subName" = territory."subName"
AND ust."userId" = ${itemAct.userId}::INTEGER
), zap AS ( ), zap AS (
INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats") INSERT INTO "ItemUserAgg" ("userId", "itemId", "zapSats")
VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER) VALUES (${itemAct.userId}::INTEGER, ${itemAct.itemId}::INTEGER, ${sats}::INTEGER)
@ -158,17 +173,30 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
SET "zapSats" = "ItemUserAgg"."zapSats" + ${sats}::INTEGER, updated_at = now() SET "zapSats" = "ItemUserAgg"."zapSats" + ${sats}::INTEGER, updated_at = now()
RETURNING ("zapSats" = ${sats}::INTEGER)::INTEGER as first_vote, RETURNING ("zapSats" = ${sats}::INTEGER)::INTEGER as first_vote,
LOG("zapSats" / GREATEST("zapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats LOG("zapSats" / GREATEST("zapSats" - ${sats}::INTEGER, 1)::FLOAT) AS log_sats
) ), item_zapped AS (
UPDATE "Item" UPDATE "Item"
SET SET
"weightedVotes" = "weightedVotes" + (zapper.trust * zap.log_sats), "weightedVotes" = "weightedVotes" + zapper."zapTrust" * zap.log_sats,
"subWeightedVotes" = "subWeightedVotes" + zapper."subZapTrust" * zap.log_sats,
upvotes = upvotes + zap.first_vote, upvotes = upvotes + zap.first_vote,
msats = "Item".msats + ${msats}::BIGINT, msats = "Item".msats + ${msats}::BIGINT,
mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT, mcredits = "Item".mcredits + ${invoice?.invoiceForward ? 0n : msats}::BIGINT,
"lastZapAt" = now() "lastZapAt" = now()
FROM zap, zapper FROM zap, zapper
WHERE "Item".id = ${itemAct.itemId}::INTEGER WHERE "Item".id = ${itemAct.itemId}::INTEGER
RETURNING "Item".*` RETURNING "Item".*, zapper."zapTrust" * zap.log_sats as "weightedVote"
), ancestors AS (
SELECT "Item".*
FROM "Item", item_zapped
WHERE "Item".path @> item_zapped.path AND "Item".id <> item_zapped.id
ORDER BY "Item".id
)
UPDATE "Item"
SET "weightedComments" = "Item"."weightedComments" + item_zapped."weightedVote",
"commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT,
"commentMcredits" = "Item"."commentMcredits" + ${invoice?.invoiceForward ? 0n : msats}::BIGINT
FROM item_zapped, ancestors
WHERE "Item".id = ancestors.id`
// record potential bounty payment // record potential bounty payment
// NOTE: we are at least guaranteed that we see the update "ItemUserAgg" from our tx so we can trust // NOTE: we are at least guaranteed that we see the update "ItemUserAgg" from our tx so we can trust
@ -188,17 +216,6 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL) SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL)
FROM bounty FROM bounty
WHERE "Item".id = bounty.id AND bounty.paid` WHERE "Item".id = bounty.id AND bounty.paid`
// update commentMsats on ancestors
await tx.$executeRaw`
WITH zapped AS (
SELECT * FROM "Item" WHERE id = ${itemAct.itemId}::INTEGER
)
UPDATE "Item"
SET "commentMsats" = "Item"."commentMsats" + ${msats}::BIGINT,
"commentMcredits" = "Item"."commentMcredits" + ${invoice?.invoiceForward ? 0n : msats}::BIGINT
FROM zapped
WHERE "Item".path @> zapped.path AND "Item".id <> zapped.id`
} }
export async function nonCriticalSideEffects ({ invoice, actIds }, { models }) { export async function nonCriticalSideEffects ({ invoice, actIds }, { models }) {

View File

@ -39,16 +39,12 @@ function commentsOrderByClause (me, models, sort) {
COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC, "Item".id DESC` COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC, "Item".id DESC`
} }
if (me && sort === 'hot') { if (sort === 'hot') {
return `ORDER BY ${sharedSorts}, return `ORDER BY ${sharedSorts},
"personal_hot_score" DESC NULLS LAST, "hotScore" DESC NULLS LAST,
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC` "Item".msats DESC, "Item".id DESC`
} else { } else {
if (sort === 'top') { return `ORDER BY ${sharedSorts}, "Item"."weightedVotes" - "Item"."weightedDownVotes" DESC NULLS LAST, "Item".msats DESC, "Item".id DESC`
return `ORDER BY ${sharedSorts}, ${orderByNumerator({ models, commentScaler: 0 })} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
} else {
return `ORDER BY ${sharedSorts}, ${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
}
} }
} }
@ -138,14 +134,14 @@ export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { m
}, ...subArr))?.[0] || null }, ...subArr))?.[0] || null
} }
const orderByClause = (by, me, models, type) => { const orderByClause = (by, me, models, type, sub) => {
switch (by) { switch (by) {
case 'comments': case 'comments':
return 'ORDER BY "Item".ncomments DESC' return 'ORDER BY "Item".ncomments DESC'
case 'sats': case 'sats':
return 'ORDER BY "Item".msats DESC' return 'ORDER BY "Item".msats DESC'
case 'zaprank': case 'zaprank':
return topOrderByWeightedSats(me, models) return topOrderByWeightedSats(me, models, sub)
case 'boost': case 'boost':
return 'ORDER BY "Item".boost DESC' return 'ORDER BY "Item".boost DESC'
case 'random': case 'random':
@ -155,22 +151,8 @@ const orderByClause = (by, me, models, type) => {
} }
} }
export function orderByNumerator ({ models, commentScaler = 0.5, considerBoost = false }) { export function joinHotScoreView (me, models) {
return `((CASE WHEN "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0 THEN return ' JOIN hot_score_view g ON g.id = "Item".id '
GREATEST("Item"."weightedVotes" - "Item"."weightedDownVotes", POWER("Item"."weightedVotes" - "Item"."weightedDownVotes", 1.2))
ELSE
"Item"."weightedVotes" - "Item"."weightedDownVotes"
END + "Item"."weightedComments"*${commentScaler}) + ${considerBoost ? `("Item".boost / ${BOOST_MULT})` : 0})`
}
export function joinZapRankPersonalView (me, models) {
let join = ` JOIN zap_rank_personal_view g ON g.id = "Item".id AND g."viewerId" = ${GLOBAL_SEED} `
if (me) {
join += ` LEFT JOIN zap_rank_personal_view l ON l.id = g.id AND l."viewerId" = ${me.id} `
}
return join
} }
// this grabs all the stuff we need to display the item list and only // this grabs all the stuff we need to display the item list and only
@ -475,10 +457,10 @@ export default {
await filterClause(me, models, type), await filterClause(me, models, type),
by === 'boost' && '"Item".boost > 0', by === 'boost' && '"Item".boost > 0',
muteClause(me))} muteClause(me))}
${orderByClause(by || 'zaprank', me, models, type)} ${orderByClause(by || 'zaprank', me, models, type, sub)}
OFFSET $3 OFFSET $3
LIMIT $4`, LIMIT $4`,
orderBy: orderByClause(by || 'zaprank', me, models, type) orderBy: orderByClause(by || 'zaprank', me, models, type, sub)
}, ...whenRange(when, from, to || decodedCursor.time), decodedCursor.offset, limit, ...subArr) }, ...whenRange(when, from, to || decodedCursor.time), decodedCursor.offset, limit, ...subArr)
break break
case 'random': case 'random':
@ -571,10 +553,10 @@ export default {
me, me,
models, models,
query: ` query: `
${SELECT}, ${me ? 'GREATEST(g.tf_hot_score, l.tf_hot_score)' : 'g.tf_hot_score'} AS rank ${SELECT}, g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore"
FROM "Item" FROM "Item"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
${joinZapRankPersonalView(me, models)} ${joinHotScoreView(me, models)}
${whereClause( ${whereClause(
// in home (sub undefined), filter out global pinned items since we inject them later // in home (sub undefined), filter out global pinned items since we inject them later
sub ? '"Item"."pinId" IS NULL' : 'NOT ("Item"."pinId" IS NOT NULL AND "Item"."subName" IS NULL)', sub ? '"Item"."pinId" IS NULL' : 'NOT ("Item"."pinId" IS NOT NULL AND "Item"."subName" IS NULL)',
@ -587,40 +569,11 @@ export default {
await filterClause(me, models, type), await filterClause(me, models, type),
subClause(sub, 3, 'Item', me, showNsfw), subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me))} muteClause(me))}
ORDER BY rank DESC ORDER BY ${sub ? '"subHotScore"' : '"hotScore"'} DESC, "Item".msats DESC, "Item".id DESC
OFFSET $1 OFFSET $1
LIMIT $2`, LIMIT $2`,
orderBy: 'ORDER BY rank DESC' orderBy: `ORDER BY ${sub ? '"subHotScore"' : '"hotScore"'} DESC, "Item".msats DESC, "Item".id DESC`
}, decodedCursor.offset, limit, ...subArr) }, decodedCursor.offset, limit, ...subArr)
// XXX this is mostly for subs that are really empty
if (items.length < limit) {
items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
${whereClause(
subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me),
// in home (sub undefined), filter out global pinned items since we inject them later
sub ? '"Item"."pinId" IS NULL' : 'NOT ("Item"."pinId" IS NOT NULL AND "Item"."subName" IS NULL)',
'"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL',
'"Item".bio = false',
ad ? `"Item".id <> ${ad.id}` : '',
activeOrMine(me),
await filterClause(me, models, type))}
ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC
OFFSET $1
LIMIT $2`,
orderBy: `ORDER BY ${orderByNumerator({ models, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST,
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
}, decodedCursor.offset, limit, ...subArr)
}
break break
} }
break break
@ -1574,6 +1527,9 @@ export const SELECT =
`SELECT "Item".*, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", `SELECT "Item".*, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt",
ltree2text("Item"."path") AS "path"` ltree2text("Item"."path") AS "path"`
function topOrderByWeightedSats (me, models) { function topOrderByWeightedSats (me, models, sub) {
return `ORDER BY ${orderByNumerator({ models })} DESC NULLS LAST, "Item".id DESC` if (sub) {
return 'ORDER BY "Item"."subWeightedVotes" - "Item"."subWeightedDownVotes" DESC, "Item".msats DESC, "Item".id DESC'
}
return 'ORDER BY "Item"."weightedVotes" - "Item"."weightedDownVotes" DESC, "Item".msats DESC, "Item".id DESC'
} }

View File

@ -174,7 +174,6 @@ export default {
search: async (parent, { q, cursor, sort, what, when, from: whenFrom, to: whenTo }, { me, models, search }) => { search: async (parent, { q, cursor, sort, what, when, from: whenFrom, to: whenTo }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor) const decodedCursor = decodeCursor(cursor)
let sitems = null let sitems = null
let termQueries = []
// short circuit: return empty result if either: // short circuit: return empty result if either:
// 1. no query provided, or // 1. no query provided, or
@ -186,56 +185,116 @@ export default {
} }
} }
const whatArr = [] // build query in parts:
// filters: determine the universe of potential search candidates
// termQueries: queries related to the actual search terms
// functions: rank modifiers to boost by recency or popularity
const filters = []
const termQueries = []
const functions = []
// filters for item types
switch (what) { switch (what) {
case 'posts': case 'posts': // posts only
whatArr.push({ bool: { must_not: { exists: { field: 'parentId' } } } }) filters.push({ bool: { must_not: { exists: { field: 'parentId' } } } })
break break
case 'comments': case 'comments': // comments only
whatArr.push({ bool: { must: { exists: { field: 'parentId' } } } }) filters.push({ bool: { must: { exists: { field: 'parentId' } } } })
break break
case 'bookmarks': case 'bookmarks':
if (me?.id) { if (me?.id) {
whatArr.push({ match: { bookmarkedBy: me?.id } }) filters.push({ match: { bookmarkedBy: me?.id } })
} }
break break
default: default:
break break
} }
// filter for active posts
filters.push(
me
? {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } }
]
}
}
: {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
}
)
// filter for time range
const whenRange = when === 'custom'
? {
gte: whenFrom,
lte: new Date(Math.min(new Date(Number(whenTo)), decodedCursor.time))
}
: {
lte: decodedCursor.time,
gte: whenToFrom(when)
}
filters.push({ range: { createdAt: whenRange } })
// filter for non negative wvotes
filters.push({ range: { wvotes: { gte: 0 } } })
// decompose the search terms
const { query: _query, quotes, nym, url, territory } = queryParts(q) const { query: _query, quotes, nym, url, territory } = queryParts(q)
let query = _query const query = _query
const isUrlSearch = url && query.length === 0 // exclusively searching for an url
// if search contains a url term, modify the query text
if (url) { if (url) {
const isFQDN = url.startsWith('url:www.') const uri = url.slice(4)
const domain = isFQDN ? url.slice(8) : url.slice(4) let uriObj
const fqdn = `www.${domain}` try {
query = (isUrlSearch) ? `${domain} ${fqdn}` : `${query.trim()} ${domain}` uriObj = new URL(uri)
} } catch {
try {
if (nym) { uriObj = new URL(`https://${uri}`)
whatArr.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } }) } catch {}
}
if (territory) {
whatArr.push({ match: { 'sub.name': territory.slice(1) } })
} }
if (uriObj) {
termQueries.push({
wildcard: { url: `*${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}*` }
})
termQueries.push({
match: { text: `${uriObj?.hostname ?? uri}${uriObj?.pathname ?? ''}` }
})
}
}
// if nym, items must contain nym
if (nym) {
filters.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
}
// if territory, item must be from territory
if (territory) {
filters.push({ match: { 'sub.name': territory.slice(1) } })
}
// if quoted phrases, items must contain entire phrase
for (const quote of quotes) {
termQueries.push({ termQueries.push({
// all terms are matched in fields
multi_match: { multi_match: {
query, query: quote,
type: 'best_fields', type: 'phrase',
fields: ['title^100', 'text'], fields: ['title', 'text']
minimum_should_match: (isUrlSearch) ? 1 : '100%',
boost: 1000
} }
}) })
for (const quote of quotes) { // force the search to include the quoted phrase
whatArr.push({ filters.push({
multi_match: { multi_match: {
query: quote, query: quote,
type: 'phrase', type: 'phrase',
@ -244,84 +303,104 @@ export default {
}) })
} }
// if we search for an exact string only, everything must match // functions for boosting search rank by recency or popularity
// so score purely on sort field
let boostMode = query ? 'multiply' : 'replace'
let sortField
let sortMod = 'log1p'
switch (sort) { switch (sort) {
case 'comments': case 'comments':
sortField = 'ncomments' functions.push({
sortMod = 'square' field_value_factor: {
field: 'ncomments',
modifier: 'log1p'
}
})
break break
case 'sats': case 'sats':
sortField = 'sats' functions.push({
field_value_factor: {
field: 'sats',
modifier: 'log1p'
}
})
break break
case 'recent': case 'recent':
sortField = 'createdAt' functions.push({
sortMod = 'square' gauss: {
boostMode = 'replace' createdAt: {
origin: 'now',
scale: '7d',
decay: 0.5
}
}
})
break
case 'zaprank':
functions.push({
field_value_factor: {
field: 'wvotes',
modifier: 'log1p'
}
})
break break
default: default:
sortField = 'wvotes'
sortMod = 'none'
break break
} }
const functions = [ let osQuery = {
function_score: {
query: {
bool: {
filter: filters,
should: termQueries,
minimum_should_match: termQueries.length > 0 ? 1 : 0
}
},
functions,
score_mode: 'multiply',
boost_mode: 'multiply'
}
}
// query for search terms
if (query.length) {
// keyword based subquery, to be used on its own or in conjunction with a neural
// search
const subquery = [
{ {
field_value_factor: { multi_match: {
field: sortField, query,
modifier: sortMod, type: 'best_fields',
factor: 1.2 fields: ['title^10', 'text'],
fuzziness: 'AUTO',
minimum_should_match: 1
}
},
// all match matches higher
{
multi_match: {
query,
type: 'best_fields',
fields: ['title^10', 'text'],
minimum_should_match: '100%',
boost: 100
}
},
// phrase match matches higher
{
multi_match: {
query,
type: 'phrase',
fields: ['title^10', 'text'],
boost: 1000
} }
} }
] ]
if (sort === 'recent' && !isUrlSearch) { osQuery.function_score.query.bool.should = [...termQueries, ...subquery]
// prioritize exact matches osQuery.function_score.query.bool.minimum_should_match = 1
termQueries.push({
multi_match: {
query,
type: 'phrase',
fields: ['title^100', 'text'],
boost: 1000
}
})
} else {
// allow fuzzy matching with partial matches
termQueries.push({
multi_match: {
query,
type: 'most_fields',
fields: ['title^100', 'text'],
fuzziness: 'AUTO',
prefix_length: 3,
minimum_should_match: (isUrlSearch) ? 1 : '60%'
}
})
functions.push({
// small bias toward posts with comments
field_value_factor: {
field: 'ncomments',
modifier: 'ln1p',
factor: 1
}
},
{
// small bias toward recent posts
field_value_factor: {
field: 'createdAt',
modifier: 'log1p',
factor: 1
}
})
}
if (query.length) { // use hybrid neural search if model id is available, otherwise use only
// if we have a model id and we aren't sort by recent, use neural search // keyword search
if (process.env.OPENSEARCH_MODEL_ID && sort !== 'recent') { if (process.env.OPENSEARCH_MODEL_ID) {
termQueries = { osQuery = {
hybrid: { hybrid: {
queries: [ queries: [
{ {
@ -345,30 +424,16 @@ export default {
} }
} }
} }
] ],
filter: filters,
minimum_should_match: 1
} }
}, },
{ osQuery
bool: {
should: termQueries
}
}
] ]
} }
} }
} }
} else {
termQueries = []
}
const whenRange = when === 'custom'
? {
gte: whenFrom,
lte: new Date(Math.min(new Date(Number(whenTo)), decodedCursor.time))
}
: {
lte: decodedCursor.time,
gte: whenToFrom(when)
} }
try { try {
@ -384,45 +449,7 @@ export default {
}, },
from: decodedCursor.offset, from: decodedCursor.offset,
body: { body: {
query: { query: osQuery,
function_score: {
query: {
bool: {
must: termQueries,
filter: [
...whatArr,
me
? {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } },
{ match: { userId: me.id } }
]
}
}
: {
bool: {
should: [
{ match: { status: 'ACTIVE' } },
{ match: { status: 'NOSATS' } }
]
}
},
{
range:
{
createdAt: whenRange
}
},
{ range: { wvotes: { gte: 0 } } }
]
}
},
functions,
boost_mode: boostMode
}
},
highlight: { highlight: {
fields: { fields: {
title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] }, title: { number_of_fragments: 0, pre_tags: ['***'], post_tags: ['***'] },
@ -458,7 +485,7 @@ export default {
${SELECT}, rank ${SELECT}, rank
FROM "Item" FROM "Item"
JOIN r ON "Item".id = r.id`, JOIN r ON "Item".id = r.id`,
orderBy: 'ORDER BY rank ASC' orderBy: 'ORDER BY rank ASC, msats DESC'
})).map((item, i) => { })).map((item, i) => {
const e = sitems.body.hits.hits[i] const e = sitems.body.hits.hits[i]
item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title

View File

@ -189,3 +189,5 @@ Scroogey-SN,pr,#1948,#1849,medium,urgent,,,750k,Scroogey@coinos.io,2025-03-10
felipebueno,issue,#1947,#1945,good-first-issue,,,,2k,felipebueno@blink.sv,2025-03-10 felipebueno,issue,#1947,#1945,good-first-issue,,,,2k,felipebueno@blink.sv,2025-03-10
ed-kung,pr,#1952,#1951,easy,,,,100k,simplestacker@getalby.com,2025-03-10 ed-kung,pr,#1952,#1951,easy,,,,100k,simplestacker@getalby.com,2025-03-10
ed-kung,issue,#1952,#1951,easy,,,,10k,simplestacker@getalby.com,2025-03-10 ed-kung,issue,#1952,#1951,easy,,,,10k,simplestacker@getalby.com,2025-03-10
Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,???
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,???,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
189 felipebueno issue #1947 #1945 good-first-issue 2k felipebueno@blink.sv 2025-03-10
190 ed-kung pr #1952 #1951 easy 100k simplestacker@getalby.com 2025-03-10
191 ed-kung issue #1952 #1951 easy 10k simplestacker@getalby.com 2025-03-10
192 Scroogey-SN pr #1973 #1959 good-first-issue 20k Scroogey@coinos.io ???
193 benthecarman issue #1953 #1950 good-first-issue 2k ??? ???

View File

@ -8,40 +8,31 @@ import { useQuery } from '@apollo/client'
import { UserListRow } from '@/components/user-list' import { UserListRow } from '@/components/user-list'
import Link from 'next/link' import Link from 'next/link'
import AddIcon from '@/svgs/add-fill.svg' import AddIcon from '@/svgs/add-fill.svg'
import { MultiAuthErrorBanner } from '@/components/banners'
const AccountContext = createContext() const AccountContext = createContext()
const CHECK_ERRORS_INTERVAL_MS = 5_000
const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8') const b64Decode = str => Buffer.from(str, 'base64').toString('utf-8')
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
const maybeSecureCookie = cookie => { const maybeSecureCookie = cookie => {
return window.location.protocol === 'https:' ? cookie + '; Secure' : cookie return window.location.protocol === 'https:' ? cookie + '; Secure' : cookie
} }
export const AccountProvider = ({ children }) => { export const AccountProvider = ({ children }) => {
const { me } = useMe()
const [accounts, setAccounts] = useState([]) const [accounts, setAccounts] = useState([])
const [meAnon, setMeAnon] = useState(true) const [meAnon, setMeAnon] = useState(true)
const [errors, setErrors] = useState([])
const updateAccountsFromCookie = useCallback(() => { const updateAccountsFromCookie = useCallback(() => {
try {
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie) const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie)
const accounts = multiAuthCookie const accounts = multiAuthCookie
? JSON.parse(b64Decode(multiAuthCookie)) ? JSON.parse(b64Decode(multiAuthCookie))
: me ? [{ id: Number(me.id), name: me.name, photoId: me.photoId }] : [] : []
setAccounts(accounts) setAccounts(accounts)
// required for backwards compatibility: sync cookie with accounts if no multi auth cookie exists
// this is the case for sessions that existed before we deployed account switching
if (!multiAuthCookie && !!me) {
document.cookie = maybeSecureCookie(`multi_auth=${b64Encode(accounts)}; Path=/`)
}
} catch (err) {
console.error('error parsing cookies:', err)
}
}, []) }, [])
useEffect(updateAccountsFromCookie, [])
const addAccount = useCallback(user => { const addAccount = useCallback(user => {
setAccounts(accounts => [...accounts, user]) setAccounts(accounts => [...accounts, user])
}, []) }, [])
@ -50,7 +41,7 @@ export const AccountProvider = ({ children }) => {
setAccounts(accounts => accounts.filter(({ id }) => id !== userId)) setAccounts(accounts => accounts.filter(({ id }) => id !== userId))
}, []) }, [])
const multiAuthSignout = useCallback(async () => { const nextAccount = useCallback(async () => {
const { status } = await fetch('/api/next-account', { credentials: 'include' }) const { status } = await fetch('/api/next-account', { credentials: 'include' })
// if status is 302, this means the server was able to switch us to the next available account // if status is 302, this means the server was able to switch us to the next available account
// and the current account was simply removed from the list of available accounts including the corresponding JWT. // and the current account was simply removed from the list of available accounts including the corresponding JWT.
@ -59,15 +50,43 @@ export const AccountProvider = ({ children }) => {
return switchSuccess return switchSuccess
}, [updateAccountsFromCookie]) }, [updateAccountsFromCookie])
useEffect(() => { const checkErrors = useCallback(() => {
if (SSR) return const {
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie) multi_auth: multiAuthCookie,
setMeAnon(multiAuthUserIdCookie === 'anonymous') 'multi_auth.user-id': multiAuthUserIdCookie
} = cookie.parse(document.cookie)
const errors = []
if (!multiAuthCookie) errors.push('multi_auth cookie not found')
if (!multiAuthUserIdCookie) errors.push('multi_auth.user-id cookie not found')
setErrors(errors)
}, []) }, [])
useEffect(() => {
if (SSR) return
updateAccountsFromCookie()
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie)
setMeAnon(multiAuthUserIdCookie === 'anonymous')
const interval = setInterval(checkErrors, CHECK_ERRORS_INTERVAL_MS)
return () => clearInterval(interval)
}, [updateAccountsFromCookie, checkErrors])
const value = useMemo( const value = useMemo(
() => ({ accounts, addAccount, removeAccount, meAnon, setMeAnon, multiAuthSignout }), () => ({
[accounts, addAccount, removeAccount, meAnon, setMeAnon, multiAuthSignout]) accounts,
addAccount,
removeAccount,
meAnon,
setMeAnon,
nextAccount,
multiAuthErrors: errors
}),
[accounts, addAccount, removeAccount, meAnon, setMeAnon, nextAccount, errors])
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider> return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
} }
@ -129,9 +148,23 @@ const AccountListRow = ({ account, ...props }) => {
} }
export default function SwitchAccountList () { export default function SwitchAccountList () {
const { accounts } = useAccounts() const { accounts, multiAuthErrors } = useAccounts()
const router = useRouter() const router = useRouter()
const hasError = multiAuthErrors.length > 0
if (hasError) {
return (
<>
<div className='my-2'>
<div className='d-flex flex-column flex-wrap mt-2 mb-3'>
<MultiAuthErrorBanner errors={multiAuthErrors} />
</div>
</div>
</>
)
}
// can't show hat since the streak is not included in the JWT payload // can't show hat since the streak is not included in the JWT payload
return ( return (
<> <>

View File

@ -6,6 +6,7 @@ import { useMutation } from '@apollo/client'
import { WELCOME_BANNER_MUTATION } from '@/fragments/users' import { WELCOME_BANNER_MUTATION } from '@/fragments/users'
import { useToast } from '@/components/toast' import { useToast } from '@/components/toast'
import Link from 'next/link' import Link from 'next/link'
import AccordianItem from '@/components/accordian-item'
export function WelcomeBanner ({ Banner }) { export function WelcomeBanner ({ Banner }) {
const { me } = useMe() const { me } = useMe()
@ -123,3 +124,24 @@ export function AuthBanner () {
</Alert> </Alert>
) )
} }
export function MultiAuthErrorBanner ({ errors }) {
return (
<Alert className={styles.banner} key='info' variant='danger'>
<div className='fw-bold mb-3'>Account switching is currently unavailable</div>
<AccordianItem
className='my-3'
header='We have detected the following issues:'
headerColor='var(--bs-danger-text-emphasis)'
body={
<ul>
{errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
}
/>
<div className='mt-3'>To resolve these issues, please sign out and sign in again.</div>
</Alert>
)
}

View File

@ -1,7 +1,7 @@
import { signIn } from 'next-auth/react' import { signIn } from 'next-auth/react'
import styles from './login.module.css' import styles from './login.module.css'
import { Form, Input, SubmitButton } from '@/components/form' import { Form, Input, SubmitButton } from '@/components/form'
import { useState } from 'react' import { useState, useEffect } from 'react'
import Alert from 'react-bootstrap/Alert' import Alert from 'react-bootstrap/Alert'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { LightningAuthWithExplainer } from './lightning-auth' import { LightningAuthWithExplainer } from './lightning-auth'
@ -42,10 +42,10 @@ const authErrorMessages = {
OAuthCallback: 'Error handling OAuth response. Try again or choose a different method.', OAuthCallback: 'Error handling OAuth response. Try again or choose a different method.',
OAuthCreateAccount: 'Could not create OAuth account. Try again or choose a different method.', OAuthCreateAccount: 'Could not create OAuth account. Try again or choose a different method.',
EmailCreateAccount: 'Could not create Email account. Try again or choose a different method.', EmailCreateAccount: 'Could not create Email account. Try again or choose a different method.',
Callback: 'Try again or choose a different method.', Callback: 'Could not authenticate. Try again or choose a different method.',
OAuthAccountNotLinked: 'This auth method is linked to another account. To link to this account first unlink the other account.', OAuthAccountNotLinked: 'This auth method is linked to another account. To link to this account first unlink the other account.',
EmailSignin: 'Failed to send email. Make sure you entered your email address correctly.', EmailSignin: 'Failed to send email. Make sure you entered your email address correctly.',
CredentialsSignin: 'Auth failed. Try again or choose a different method.', CredentialsSignin: 'Could not authenticate. Try again or choose a different method.',
default: 'Auth failed. Try again or choose a different method.' default: 'Auth failed. Try again or choose a different method.'
} }
@ -53,10 +53,23 @@ export function authErrorMessage (error) {
return error && (authErrorMessages[error] ?? authErrorMessages.default) return error && (authErrorMessages[error] ?? authErrorMessages.default)
} }
export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer }) { export default function Login ({ providers, callbackUrl, multiAuth, error, text, Header, Footer, signin }) {
const [errorMessage, setErrorMessage] = useState(authErrorMessage(error)) const [errorMessage, setErrorMessage] = useState(authErrorMessage(error))
const router = useRouter() const router = useRouter()
// signup/signin awareness cookie
useEffect(() => {
const cookieOptions = [
`signin=${!!signin}`,
'path=/',
'max-age=' + (signin ? 60 * 60 * 24 : 0), // 24 hours if signin is true, expire the cookie otherwise
'SameSite=Lax',
process.env.NODE_ENV === 'production' ? 'Secure' : ''
].filter(Boolean).join(';')
document.cookie = cookieOptions
}, [signin])
if (router.query.type === 'lightning') { if (router.query.type === 'lightning') {
return <LightningAuthWithExplainer callbackUrl={callbackUrl} text={text} multiAuth={multiAuth} /> return <LightningAuthWithExplainer callbackUrl={callbackUrl} text={text} multiAuth={multiAuth} />
} }
@ -112,6 +125,7 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
default: default:
return ( return (
<OverlayTrigger <OverlayTrigger
key={provider.id}
placement='bottom' placement='bottom'
overlay={multiAuth ? <Tooltip>not available for account switching yet</Tooltip> : <></>} overlay={multiAuth ? <Tooltip>not available for account switching yet</Tooltip> : <></>}
trigger={['hover', 'focus']} trigger={['hover', 'focus']}
@ -119,7 +133,6 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
<div className='w-100'> <div className='w-100'>
<LoginButton <LoginButton
className={`mt-2 ${styles.providerButton}`} className={`mt-2 ${styles.providerButton}`}
key={provider.id}
type={provider.id.toLowerCase()} type={provider.id.toLowerCase()}
onClick={() => signIn(provider.id, { callbackUrl, multiAuth })} onClick={() => signIn(provider.id, { callbackUrl, multiAuth })}
text={`${text || 'Login'} with`} text={`${text || 'Login'} with`}

View File

@ -223,6 +223,9 @@ export function MeDropdown ({ me, dropNavKey }) {
) )
} }
// this is the width of the 'switch account' button if no width is given
const SWITCH_ACCOUNT_BUTTON_WIDTH = '162px'
export function SignUpButton ({ className = 'py-0', width }) { export function SignUpButton ({ className = 'py-0', width }) {
const router = useRouter() const router = useRouter()
const handleLogin = useCallback(async pathname => await router.push({ const handleLogin = useCallback(async pathname => await router.push({
@ -233,7 +236,8 @@ export function SignUpButton ({ className = 'py-0', width }) {
return ( return (
<Button <Button
className={classNames('align-items-center ps-2 pe-3', className)} className={classNames('align-items-center ps-2 pe-3', className)}
style={{ borderWidth: '2px', width: width || '150px' }} // 161px is the width of the 'switch account' button
style={{ borderWidth: '2px', width: width || SWITCH_ACCOUNT_BUTTON_WIDTH }}
id='signup' id='signup'
onClick={() => handleLogin('/signup')} onClick={() => handleLogin('/signup')}
> >
@ -257,7 +261,7 @@ export default function LoginButton () {
<Button <Button
className='align-items-center px-3 py-1' className='align-items-center px-3 py-1'
id='login' id='login'
style={{ borderWidth: '2px', width: '150px' }} style={{ borderWidth: '2px', width: SWITCH_ACCOUNT_BUTTON_WIDTH }}
variant='outline-grey-darkmode' variant='outline-grey-darkmode'
onClick={() => handleLogin('/login')} onClick={() => handleLogin('/login')}
> >
@ -269,7 +273,7 @@ export default function LoginButton () {
function LogoutObstacle ({ onClose }) { function LogoutObstacle ({ onClose }) {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker() const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const { removeLocalWallets } = useWallets() const { removeLocalWallets } = useWallets()
const { multiAuthSignout } = useAccounts() const { nextAccount } = useAccounts()
const router = useRouter() const router = useRouter()
return ( return (
@ -285,9 +289,9 @@ function LogoutObstacle ({ onClose }) {
</Button> </Button>
<Button <Button
onClick={async () => { onClick={async () => {
const switchSuccess = await multiAuthSignout() const next = await nextAccount()
// only signout if multiAuth did not find a next available account // only signout if we did not find a next account
if (switchSuccess) { if (next) {
onClose() onClose()
// reload whatever page we're on to avoid any bugs // reload whatever page we're on to avoid any bugs
router.reload() router.reload()
@ -344,7 +348,7 @@ function SwitchAccountButton ({ handleClose }) {
<Button <Button
className='align-items-center px-3 py-1' className='align-items-center px-3 py-1'
variant='outline-grey-darkmode' variant='outline-grey-darkmode'
style={{ borderWidth: '2px', width: '150px' }} style={{ borderWidth: '2px', width: SWITCH_ACCOUNT_BUTTON_WIDTH }}
onClick={() => { onClick={() => {
// login buttons rendered in offcanvas aren't wrapped inside <Dropdown> // login buttons rendered in offcanvas aren't wrapped inside <Dropdown>
// so we manually close the offcanvas in that case by passing down handleClose here // so we manually close the offcanvas in that case by passing down handleClose here

View File

@ -72,7 +72,10 @@ export default function PullToRefresh ({ children, className }) {
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
<p className={`${styles.pullMessage}`} style={{ top: `${Math.max(-20, Math.min(-20 + pullDistance / 2, 5))}px` }}> <p
className={`${styles.pullMessage}`}
style={{ opacity: pullDistance > 0 ? 1 : 0, top: `${Math.max(-20, Math.min(-20 + pullDistance / 2, 5))}px` }}
>
{pullMessage} {pullMessage}
</p> </p>
{children} {children}

View File

@ -36,7 +36,7 @@ export default function Search ({ sub }) {
} }
if (values.what === '' || values.what === 'all') delete values.what if (values.what === '' || values.what === 'all') delete values.what
if (values.sort === '' || values.sort === 'zaprank') delete values.sort if (values.sort === '' || values.sort === 'relevance') delete values.sort
if (values.when === '' || values.when === 'forever') delete values.when if (values.when === '' || values.when === 'forever') delete values.when
if (values.when !== 'custom') { delete values.from; delete values.to } if (values.when !== 'custom') { delete values.from; delete values.to }
if (values.from && !values.to) return if (values.from && !values.to) return
@ -50,7 +50,7 @@ export default function Search ({ sub }) {
const filter = sub !== 'jobs' const filter = sub !== 'jobs'
const what = router.pathname.startsWith('/stackers') ? 'stackers' : router.query.what || 'all' const what = router.pathname.startsWith('/stackers') ? 'stackers' : router.query.what || 'all'
const sort = router.query.sort || 'zaprank' const sort = router.query.sort || 'relevance'
const when = router.query.when || 'forever' const when = router.query.when || 'forever'
const whatItemOptions = useMemo(() => (['all', 'posts', 'comments', me ? 'bookmarks' : undefined, 'stackers'].filter(item => !!item)), [me]) const whatItemOptions = useMemo(() => (['all', 'posts', 'comments', me ? 'bookmarks' : undefined, 'stackers'].filter(item => !!item)), [me])
@ -100,7 +100,7 @@ export default function Search ({ sub }) {
name='sort' name='sort'
size='sm' size='sm'
overrideValue={sort} overrideValue={sort}
items={['zaprank', 'recent', 'comments', 'sats']} items={['relevance', 'zaprank', 'recent', 'comments', 'sats']}
/> />
for for
<Select <Select

View File

@ -94,8 +94,19 @@ export default function SubSelect ({ prependSubs, sub, onChange, size, appendSub
} }
} else { } else {
// we're currently on the home sub // we're currently on the home sub
// if in /top/cowboys, /top/territories, or /top/stackers
// and a territory is selected, go to /~sub/top/posts/day
if (router.pathname.startsWith('/~/top/cowboys')) {
router.push(sub ? `/~${sub}/top/posts/day` : '/top/cowboys')
return
} else if (router.pathname.startsWith('/~/top/stackers')) {
router.push(sub ? `/~${sub}/top/posts/day` : 'top/stackers/day')
return
} else if (router.pathname.startsWith('/~/top/territories')) {
router.push(sub ? `/~${sub}/top/posts/day` : '/top/territories/day')
return
} else if (router.pathname.startsWith('/~')) {
// are we in a sub aware route? // are we in a sub aware route?
if (router.pathname.startsWith('/~')) {
// if we are, go to the same path but in the sub // if we are, go to the same path but in the sub
asPath = `/~${sub}` + router.asPath asPath = `/~${sub}` + router.asPath
} else { } else {

View File

@ -4,7 +4,7 @@ import Image from 'react-bootstrap/Image'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import Nav from 'react-bootstrap/Nav' import Nav from 'react-bootstrap/Nav'
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Form, Input, SubmitButton } from './form' import { Form, Input, SubmitButton } from './form'
import { gql, useApolloClient, useMutation } from '@apollo/client' import { gql, useApolloClient, useMutation } from '@apollo/client'
import styles from './user-header.module.css' import styles from './user-header.module.css'
@ -199,8 +199,14 @@ export function NymActionDropdown ({ user, className = 'ms-2' }) {
} }
function HeaderNym ({ user, isMe }) { function HeaderNym ({ user, isMe }) {
const router = useRouter()
const [editting, setEditting] = useState(false) const [editting, setEditting] = useState(false)
// if route changes, reset editting state
useEffect(() => {
setEditting(false)
}, [router.asPath])
return editting return editting
? <NymEdit user={user} setEditting={setEditting} /> ? <NymEdit user={user} setEditting={setEditting} />
: <NymView user={user} isMe={isMe} setEditting={setEditting} /> : <NymView user={user} isMe={isMe} setEditting={setEditting} />

View File

@ -62,7 +62,11 @@ function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) {
const { deleteLogs } = useWalletLogManager(setLogs) const { deleteLogs } = useWalletLogManager(setLogs)
const toaster = useToast() const toaster = useToast()
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?` let prompt = 'Do you really want to delete all wallet logs?'
if (wallet) {
prompt = 'Do you really want to delete all logs of this wallet?'
}
return ( return (
<div className='text-center'> <div className='text-center'>
{prompt} {prompt}

View File

@ -163,7 +163,7 @@ services:
- "CONNECT=localhost:4566" - "CONNECT=localhost:4566"
cpu_shares: "${CPU_SHARES_LOW}" cpu_shares: "${CPU_SHARES_LOW}"
opensearch: opensearch:
image: opensearchproject/opensearch:2.12.0 image: opensearchproject/opensearch:2.17.0
container_name: opensearch container_name: opensearch
profiles: profiles:
- search - search
@ -203,7 +203,7 @@ services:
' '
cpu_shares: "${CPU_SHARES_LOW}" cpu_shares: "${CPU_SHARES_LOW}"
os-dashboard: os-dashboard:
image: opensearchproject/opensearch-dashboards:2.12.0 image: opensearchproject/opensearch-dashboards:2.17.0
container_name: os-dashboard container_name: os-dashboard
restart: unless-stopped restart: unless-stopped
profiles: profiles:

161
lib/auth.js Normal file
View File

@ -0,0 +1,161 @@
import { datePivot } from '@/lib/time'
import * as cookie from 'cookie'
import { NodeNextRequest } from 'next/dist/server/base-http/node'
import { encode as encodeJWT, decode as decodeJWT } from 'next-auth/jwt'
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
const userJwtRegexp = /^multi_auth\.\d+$/
const HTTPS = process.env.NODE_ENV === 'production'
const SESSION_COOKIE_NAME = HTTPS ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
const cookieOptions = (args) => ({
path: '/',
secure: process.env.NODE_ENV === 'production',
// httpOnly cookies by default
httpOnly: true,
sameSite: 'lax',
// default expiration for next-auth JWTs is in 30 days
expires: datePivot(new Date(), { days: 30 }),
...args
})
export function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) {
const httpOnlyOptions = cookieOptions()
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
// add JWT to **httpOnly** cookie
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${id}`, jwt, httpOnlyOptions))
// switch to user we just added
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', id, jsOptions))
let newMultiAuth = [{ id, name, photoId }]
if (req.cookies.multi_auth) {
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
// make sure we don't add duplicates
if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return
newMultiAuth = [...oldMultiAuth, ...newMultiAuth]
}
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', b64Encode(newMultiAuth), jsOptions))
}
export function switchSessionCookie (request) {
// switch next-auth session cookie with multi_auth cookie if cookie pointer present
// is there a cookie pointer?
const cookiePointerName = 'multi_auth.user-id'
const hasCookiePointer = !!request.cookies[cookiePointerName]
// is there a session?
const hasSession = !!request.cookies[SESSION_COOKIE_NAME]
if (!hasCookiePointer || !hasSession) {
// no session or no cookie pointer. do nothing.
return request
}
const userId = request.cookies[cookiePointerName]
if (userId === 'anonymous') {
// user switched to anon. only delete session cookie.
delete request.cookies[SESSION_COOKIE_NAME]
return request
}
const userJWT = request.cookies[`multi_auth.${userId}`]
if (!userJWT) {
// no JWT for account switching found
return request
}
if (userJWT) {
// use JWT found in cookie pointed to by cookie pointer
request.cookies[SESSION_COOKIE_NAME] = userJWT
return request
}
return request
}
export function checkMultiAuthCookies (req, res) {
if (!req.cookies.multi_auth || !req.cookies['multi_auth.user-id']) {
return false
}
const accounts = b64Decode(req.cookies.multi_auth)
for (const account of accounts) {
if (!req.cookies[`multi_auth.${account.id}`]) {
return false
}
}
return true
}
export function resetMultiAuthCookies (req, res) {
const httpOnlyOptions = cookieOptions({ expires: 0, maxAge: 0 })
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
if ('multi_auth' in req.cookies) res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', '', jsOptions))
if ('multi_auth.user-id' in req.cookies) res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', '', jsOptions))
for (const key of Object.keys(req.cookies)) {
// reset all user JWTs
if (userJwtRegexp.test(key)) {
res.appendHeader('Set-Cookie', cookie.serialize(key, '', httpOnlyOptions))
}
}
}
export async function refreshMultiAuthCookies (req, res) {
const httpOnlyOptions = cookieOptions()
const jsOptions = { ...httpOnlyOptions, httpOnly: false }
const refreshCookie = (name) => {
res.appendHeader('Set-Cookie', cookie.serialize(name, req.cookies[name], jsOptions))
}
const refreshToken = async (token) => {
const secret = process.env.NEXTAUTH_SECRET
return await encodeJWT({
token: await decodeJWT({ token, secret }),
secret
})
}
const isAnon = req.cookies['multi_auth.user-id'] === 'anonymous'
for (const [key, value] of Object.entries(req.cookies)) {
// only refresh session cookie manually if we switched to anon since else it's already handled by next-auth
if (key === SESSION_COOKIE_NAME && !isAnon) continue
if (!key.startsWith('multi_auth') && key !== SESSION_COOKIE_NAME) continue
if (userJwtRegexp.test(key) || key === SESSION_COOKIE_NAME) {
const oldToken = value
const newToken = await refreshToken(oldToken)
res.appendHeader('Set-Cookie', cookie.serialize(key, newToken, httpOnlyOptions))
continue
}
refreshCookie(key)
}
}
export async function multiAuthMiddleware (req, res) {
if (!req.cookies) {
// required to properly access parsed cookies via req.cookies and not unparsed via req.headers.cookie
req = new NodeNextRequest(req)
}
const ok = checkMultiAuthCookies(req, res)
if (!ok) {
resetMultiAuthCookies(req, res)
return switchSessionCookie(req)
}
await refreshMultiAuthCookies(req, res)
return switchSessionCookie(req)
}

View File

@ -23,11 +23,11 @@ export function timeSince (timeStamp) {
} }
export function datePivot (date, export function datePivot (date,
{ years = 0, months = 0, days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 }) { { years = 0, months = 0, weeks = 0, days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 }) {
return new Date( return new Date(
date.getFullYear() + years, date.getFullYear() + years,
date.getMonth() + months, date.getMonth() + months,
date.getDate() + days, date.getDate() + days + weeks * 7,
date.getHours() + hours, date.getHours() + hours,
date.getMinutes() + minutes, date.getMinutes() + minutes,
date.getSeconds() + seconds, date.getSeconds() + seconds,

View File

@ -194,6 +194,21 @@ module.exports = withPlausibleProxy()({
source: '/top/cowboys/:when', source: '/top/cowboys/:when',
destination: '/top/cowboys', destination: '/top/cowboys',
permanent: true permanent: true
},
{
source: '/~:sub/top/cowboys',
destination: '/top/cowboys',
permanent: true
},
{
source: '/~:sub/top/stackers/:when*',
destination: '/top/stackers/:when*',
permanent: true
},
{
source: '/~:sub/top/territories/:when*',
destination: '/top/territories/:when*',
permanent: true
} }
] ]
}, },

View File

@ -8,13 +8,13 @@ import prisma from '@/api/models'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { PrismaAdapter } from '@auth/prisma-adapter' import { PrismaAdapter } from '@auth/prisma-adapter'
import { getToken, encode as encodeJWT } from 'next-auth/jwt' import { getToken, encode as encodeJWT } from 'next-auth/jwt'
import { datePivot } from '@/lib/time'
import { schnorr } from '@noble/curves/secp256k1' import { schnorr } from '@noble/curves/secp256k1'
import { notifyReferral } from '@/lib/webPush' import { notifyReferral } from '@/lib/webPush'
import { hashEmail } from '@/lib/crypto' import { hashEmail } from '@/lib/crypto'
import * as cookie from 'cookie' import { multiAuthMiddleware, setMultiAuthCookies } from '@/lib/auth'
import { multiAuthMiddleware } from '@/pages/api/graphql'
import { BECH32_CHARSET } from '@/lib/constants' import { BECH32_CHARSET } from '@/lib/constants'
import { NodeNextRequest } from 'next/dist/server/base-http/node'
import * as cookie from 'cookie'
/** /**
* Stores userIds in user table * Stores userIds in user table
@ -94,6 +94,8 @@ function getCallbacks (req, res) {
*/ */
async jwt ({ token, user, account, profile, isNewUser }) { async jwt ({ token, user, account, profile, isNewUser }) {
if (user) { if (user) {
// reset signup cookie if any
res.appendHeader('Set-Cookie', cookie.serialize('signin', '', { path: '/', expires: 0, maxAge: 0 }))
// token won't have an id on it for new logins, we add it // token won't have an id on it for new logins, we add it
// note: token is what's kept in the jwt // note: token is what's kept in the jwt
token.id = Number(user.id) token.id = Number(user.id)
@ -124,8 +126,8 @@ function getCallbacks (req, res) {
token.sub = Number(token.id) token.sub = Number(token.id)
} }
// add multi_auth cookie for user that just logged in
if (user && req && res) { if (user && req && res) {
// add multi_auth cookie for user that just logged in
const secret = process.env.NEXTAUTH_SECRET const secret = process.env.NEXTAUTH_SECRET
const jwt = await encodeJWT({ token, secret }) const jwt = await encodeJWT({ token, secret })
const me = await prisma.user.findUnique({ where: { id: token.id } }) const me = await prisma.user.findUnique({ where: { id: token.id } })
@ -144,37 +146,6 @@ function getCallbacks (req, res) {
} }
} }
function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) {
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
// default expiration for next-auth JWTs is in 1 month
const expiresAt = datePivot(new Date(), { months: 1 })
const secure = process.env.NODE_ENV === 'production'
const cookieOptions = {
path: '/',
httpOnly: true,
secure,
sameSite: 'lax',
expires: expiresAt
}
// add JWT to **httpOnly** cookie
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${id}`, jwt, cookieOptions))
// switch to user we just added
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', id, { ...cookieOptions, httpOnly: false }))
let newMultiAuth = [{ id, name, photoId }]
if (req.cookies.multi_auth) {
const oldMultiAuth = b64Decode(req.cookies.multi_auth)
// make sure we don't add duplicates
if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return
newMultiAuth = [...oldMultiAuth, ...newMultiAuth]
}
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', b64Encode(newMultiAuth), { ...cookieOptions, httpOnly: false }))
}
async function pubkeyAuth (credentials, req, res, pubkeyColumnName) { async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
const { k1, pubkey } = credentials const { k1, pubkey } = credentials
@ -194,7 +165,7 @@ async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } }) let user = await prisma.user.findUnique({ where: { [pubkeyColumnName]: pubkey } })
// make following code aware of cookie pointer for account switching // make following code aware of cookie pointer for account switching
req = multiAuthMiddleware(req) req = await multiAuthMiddleware(req, res)
// token will be undefined if we're not logged in at all or if we switched to anon // token will be undefined if we're not logged in at all or if we switched to anon
const token = await getToken({ req }) const token = await getToken({ req })
if (!user) { if (!user) {
@ -205,7 +176,8 @@ async function pubkeyAuth (credentials, req, res, pubkeyColumnName) {
if (token?.id && !multiAuth) { if (token?.id && !multiAuth) {
user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } }) user = await prisma.user.update({ where: { id: token.id }, data: { [pubkeyColumnName]: pubkey } })
} else { } else {
// we're not logged in: create new user with that pubkey // create a new user only if we're trying to sign up
if (new NodeNextRequest(req).cookies.signin) return null
user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), [pubkeyColumnName]: pubkey } }) user = await prisma.user.create({ data: { name: pubkey.slice(0, 10), [pubkeyColumnName]: pubkey } })
} }
} }
@ -314,6 +286,7 @@ export const getAuthOptions = (req, res) => ({
adapter: { adapter: {
...PrismaAdapter(prisma), ...PrismaAdapter(prisma),
createUser: data => { createUser: data => {
if (req.cookies.signin) return null
// replace email with email hash in new user payload // replace email with email hash in new user payload
if (data.email) { if (data.email) {
const { email } = data const { email } = data
@ -754,7 +727,7 @@ const newUserHtml = ({ url, token, site, email }) => {
<tbody> <tbody>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Stacker News is like Reddit or Hacker News, but it <b>pays you Bitcoin</b>. Instead of giving posts or comments upvotes, Stacker News users (aka stackers) send you small amounts of Bitcoin called sats.</div> <div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">Stacker News is like Reddit or Hacker News, but it <b>pays you Bitcoin</b>. Instead of giving posts or comments "upvotes," Stacker News users (aka stackers) send you small amounts of Bitcoin called sats.</div>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -769,7 +742,7 @@ const newUserHtml = ({ url, token, site, email }) => {
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If youre not sure what to share, <a href="${dailyUrl}"><b><i>click here to introduce yourself to the community</i></b></a> with a comment on the daily discussion thread.</div> <div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If you're not sure what to share, <a href="${dailyUrl}"><b><i>click here to introduce yourself to the community</i></b></a> with a comment on the daily discussion thread.</div>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -779,7 +752,7 @@ const newUserHtml = ({ url, token, site, email }) => {
</tr> </tr>
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If anything isnt clear, comment on the FAQ post and well answer your question.</div> <div style="font-family:Helvetica, Arial, sans-serif;font-size:14px;line-height:20px;text-align:left;color:#000000;">If anything isn't clear, comment on the FAQ post and we'll answer your question.</div>
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@ -11,7 +11,7 @@ import {
ApolloServerPluginLandingPageLocalDefault, ApolloServerPluginLandingPageLocalDefault,
ApolloServerPluginLandingPageProductionDefault ApolloServerPluginLandingPageProductionDefault
} from '@apollo/server/plugin/landingPage/default' } from '@apollo/server/plugin/landingPage/default'
import { NodeNextRequest } from 'next/dist/server/base-http/node' import { multiAuthMiddleware } from '@/lib/auth'
const apolloServer = new ApolloServer({ const apolloServer = new ApolloServer({
typeDefs, typeDefs,
@ -68,7 +68,7 @@ export default startServerAndCreateNextHandler(apolloServer, {
session = { user: { ...sessionFields, apiKey: true } } session = { user: { ...sessionFields, apiKey: true } }
} }
} else { } else {
req = multiAuthMiddleware(req) req = await multiAuthMiddleware(req, res)
session = await getServerSession(req, res, getAuthOptions(req)) session = await getServerSession(req, res, getAuthOptions(req))
} }
return { return {
@ -82,49 +82,3 @@ export default startServerAndCreateNextHandler(apolloServer, {
} }
} }
}) })
export function multiAuthMiddleware (request) {
// switch next-auth session cookie with multi_auth cookie if cookie pointer present
if (!request.cookies) {
// required to properly access parsed cookies via request.cookies
// and not unparsed via request.headers.cookie
request = new NodeNextRequest(request)
}
// is there a cookie pointer?
const cookiePointerName = 'multi_auth.user-id'
const hasCookiePointer = !!request.cookies[cookiePointerName]
const secure = process.env.NODE_ENV === 'production'
// is there a session?
const sessionCookieName = secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
const hasSession = !!request.cookies[sessionCookieName]
if (!hasCookiePointer || !hasSession) {
// no session or no cookie pointer. do nothing.
return request
}
const userId = request.cookies[cookiePointerName]
if (userId === 'anonymous') {
// user switched to anon. only delete session cookie.
delete request.cookies[sessionCookieName]
return request
}
const userJWT = request.cookies[`multi_auth.${userId}`]
if (!userJWT) {
// no JWT for account switching found
return request
}
if (userJWT) {
// use JWT found in cookie pointed to by cookie pointer
request.cookies[sessionCookieName] = userJWT
return request
}
return request
}

View File

@ -81,6 +81,7 @@ export default function LoginPage (props) {
<Login <Login
Footer={() => <LoginFooter callbackUrl={props.callbackUrl} />} Footer={() => <LoginFooter callbackUrl={props.callbackUrl} />}
Header={() => <LoginHeader />} Header={() => <LoginHeader />}
signin
{...props} {...props}
/> />
</StaticLayout> </StaticLayout>

View File

@ -131,8 +131,6 @@ function Enabled ({ setVaultKey, clearVault }) {
placeholder='' placeholder=''
required required
autoFocus autoFocus
as='textarea'
rows={3}
qr qr
/> />
<div className='mt-3'> <div className='mt-3'>

View File

@ -0,0 +1,109 @@
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "subWeightedDownVotes" FLOAT NOT NULL DEFAULT 0,
ADD COLUMN "subWeightedVotes" FLOAT NOT NULL DEFAULT 0;
CREATE INDEX "Item.sumSubVotes_index" ON "Item"(("subWeightedVotes" - "subWeightedDownVotes"));
-- CreateTable
CREATE TABLE "UserSubTrust" (
"subName" CITEXT NOT NULL,
"userId" INTEGER NOT NULL,
"zapPostTrust" DOUBLE PRECISION NOT NULL DEFAULT 0,
"subZapPostTrust" DOUBLE PRECISION NOT NULL DEFAULT 0,
"zapCommentTrust" DOUBLE PRECISION NOT NULL DEFAULT 0,
"subZapCommentTrust" DOUBLE PRECISION NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserSubTrust_pkey" PRIMARY KEY ("userId","subName")
);
-- AddForeignKey
ALTER TABLE "UserSubTrust" ADD CONSTRAINT "UserSubTrust_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserSubTrust" ADD CONSTRAINT "UserSubTrust_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
-- UserSubTrust is NOT populated, so this is a no-op ... but useful having it written out for migrating manually on deployment
UPDATE "Item"
SET "subWeightedVotes" = subquery."subWeightedVotes",
"subWeightedDownVotes" = subquery."subWeightedDownVotes",
"weightedVotes" = subquery."weightedVotes",
"weightedDownVotes" = subquery."weightedDownVotes"
FROM (
WITH sub_votes AS (
SELECT "ItemAct"."itemId",
CASE WHEN (SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('TIP', 'FEE')))::FLOAT > 0
THEN COALESCE(
LOG(
(SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('TIP', 'FEE')))::FLOAT / 1000
) * CASE
WHEN "Item"."parentId" IS NULL
THEN "UserSubTrust"."subZapPostTrust"
ELSE "UserSubTrust"."subZapCommentTrust"
END, 0)
ELSE 0
END AS "subWeightedVotes",
CASE WHEN (SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('TIP', 'FEE')))::FLOAT > 0
THEN COALESCE(
LOG(
(SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('TIP', 'FEE')))::FLOAT / 1000
) * CASE
WHEN "Item"."parentId" IS NULL
THEN "UserSubTrust"."zapPostTrust"
ELSE "UserSubTrust"."zapCommentTrust"
END, 0)
ELSE 0
END AS "weightedVotes",
CASE WHEN (SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('DONT_LIKE_THIS')))::FLOAT > 0
THEN COALESCE(
LOG(
(SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('DONT_LIKE_THIS')))::FLOAT / 1000
) * CASE
WHEN "Item"."parentId" IS NULL
THEN "UserSubTrust"."subZapPostTrust"
ELSE "UserSubTrust"."subZapCommentTrust"
END, 0)
ELSE 0
END AS "subWeightedDownVotes",
CASE WHEN (SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('DONT_LIKE_THIS')))::FLOAT > 0
THEN COALESCE(
LOG(
(SUM("ItemAct"."msats") FILTER (WHERE "ItemAct"."act" IN ('DONT_LIKE_THIS')))::FLOAT / 1000
) * CASE
WHEN "Item"."parentId" IS NULL
THEN "UserSubTrust"."zapPostTrust"
ELSE "UserSubTrust"."zapCommentTrust"
END, 0)
ELSE 0
END AS "weightedDownVotes"
FROM "ItemAct"
JOIN "UserSubTrust" ON "ItemAct"."userId" = "UserSubTrust"."userId"
JOIN "Item" ON "Item".id = "ItemAct"."itemId"
AND "UserSubTrust"."subName" = "Item"."subName"
AND "Item"."userId" <> "ItemAct"."userId"
WHERE "ItemAct".act IN ('TIP', 'FEE', 'DONT_LIKE_THIS')
GROUP BY "ItemAct"."itemId", "ItemAct"."userId", "UserSubTrust"."subZapPostTrust",
"UserSubTrust"."subZapCommentTrust", "UserSubTrust"."zapPostTrust", "UserSubTrust"."zapCommentTrust",
"Item"."parentId"
)
SELECT "itemId", SUM("subWeightedVotes") AS "subWeightedVotes", SUM("subWeightedDownVotes") AS "subWeightedDownVotes",
SUM("weightedVotes") AS "weightedVotes", SUM("weightedDownVotes") AS "weightedDownVotes"
FROM sub_votes
GROUP BY "itemId"
) AS subquery
WHERE "Item".id = subquery."itemId";
CREATE MATERIALIZED VIEW IF NOT EXISTS hot_score_view AS
SELECT id,
("Item"."weightedVotes" - "Item"."weightedDownVotes" + ("Item"."weightedComments"*0.25) + ("Item".boost / 5000))
/ POWER(GREATEST(3, EXTRACT(EPOCH FROM (now() - "Item".created_at))/3600), 1.1) AS hot_score,
("Item"."subWeightedVotes" - "Item"."subWeightedDownVotes" + ("Item"."weightedComments"*0.25) + ("Item".boost / 5000))
/ POWER(GREATEST(3, EXTRACT(EPOCH FROM (now() - "Item".created_at))/3600), 1.1) AS sub_hot_score
FROM "Item"
WHERE "Item"."weightedVotes" > 0 OR "Item"."weightedDownVotes" > 0 OR "Item"."subWeightedVotes" > 0
OR "Item"."subWeightedDownVotes" > 0 OR "Item"."weightedComments" > 0 OR "Item".boost > 0;
CREATE UNIQUE INDEX IF NOT EXISTS hot_score_view_id_idx ON hot_score_view(id);
CREATE INDEX IF NOT EXISTS hot_score_view_hot_score_idx ON hot_score_view(hot_score DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS hot_score_view_sub_hot_score_idx ON hot_score_view(sub_hot_score DESC NULLS LAST);

View File

@ -0,0 +1,224 @@
-- add limit and offset
CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me_limited(
_item_id int, _global_seed int, _me_id int, _limit int, _offset int, _grandchild_limit 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 '
|| 'WITH RECURSIVE base AS ( '
|| ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn '
|| ' FROM "Item" '
|| ' WHERE "Item"."parentId" = $1 '
|| _order_by || ' '
|| ' LIMIT $4 '
|| ' OFFSET $5) '
|| ' UNION ALL '
|| ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') as rn '
|| ' FROM "Item" '
|| ' JOIN base b ON "Item"."parentId" = b.id '
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|| ' WHERE b.level < $7 AND (b.level = 1 OR b.rn <= $6)) '
|| ') '
|| '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.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, '
|| ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", '
|| ' COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats", '
|| ' COALESCE("ItemAct"."meDontLikeMsats", 0) AS "meDontLikeMsats", '
|| ' COALESCE("ItemAct"."meMcredits", 0) AS "meMcredits", '
|| ' COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits", '
|| ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", '
|| ' "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", '
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|| 'FROM base "Item" '
|| 'JOIN users ON users.id = "Item"."userId" '
|| ' LEFT JOIN "Mute" ON "Mute"."muterId" = $3 AND "Mute"."mutedId" = "Item"."userId" '
|| ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $3 AND "Bookmark"."itemId" = "Item".id '
|| ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $3 AND "ThreadSubscription"."itemId" = "Item".id '
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|| 'LEFT JOIN LATERAL ( '
|| ' SELECT "itemId", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMsats", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMcredits", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMsats", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMcredits", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
|| ' FROM "ItemAct" '
|| ' LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId" '
|| ' LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id" '
|| ' WHERE "ItemAct"."userId" = $3 '
|| ' AND "ItemAct"."itemId" = "Item".id '
|| ' GROUP BY "ItemAct"."itemId" '
|| ') "ItemAct" ON true '
|| 'WHERE ("Item".level = 1 OR "Item".rn <= $6 - "Item".level + 2) ' || _where || ' '
USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
EXECUTE ''
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|| 'FROM ( '
|| ' SELECT "Item".*, item_comments_zaprank_with_me_limited("Item".id, $2, $3, $4, $5, $6, $7 - 1, $8, $9) AS comments '
|| ' FROM t_item "Item" '
|| ' WHERE "Item"."parentId" = $1 '
|| _order_by
|| ' ) sub'
INTO result USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
RETURN result;
END
$$;
-- add cowboy credits
CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me(_item_id int, _global_seed int, _me_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.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, '
|| ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats", COALESCE("ItemAct"."meDontLikeMsats", 0) AS "meDontLikeMsats", '
|| ' COALESCE("ItemAct"."meMcredits", 0) AS "meMcredits", COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits", '
|| ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", '
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|| ' FROM "Item" '
|| ' JOIN users ON users.id = "Item"."userId" '
|| ' LEFT JOIN "Mute" ON "Mute"."muterId" = $5 AND "Mute"."mutedId" = "Item"."userId"'
|| ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $5 AND "Bookmark"."itemId" = "Item".id '
|| ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $5 AND "ThreadSubscription"."itemId" = "Item".id '
|| ' LEFT JOIN LATERAL ( '
|| ' SELECT "itemId", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMsats", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMcredits", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMsats", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMcredits", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
|| ' FROM "ItemAct" '
|| ' LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId" '
|| ' LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id" '
|| ' WHERE "ItemAct"."userId" = $5 '
|| ' AND "ItemAct"."itemId" = "Item".id '
|| ' GROUP BY "ItemAct"."itemId" '
|| ' ) "ItemAct" ON true '
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|| ' WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) ' || _where || ' '
USING _item_id, _level, _where, _order_by, _me_id, _global_seed;
EXECUTE ''
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|| 'FROM ( '
|| ' SELECT "Item".*, item_comments_zaprank_with_me("Item".id, $6, $5, $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, _me_id, _global_seed;
RETURN result;
END
$$;
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, '
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|| ' FROM "Item" '
|| ' JOIN users ON users.id = "Item"."userId" '
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|| ' 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
$$;
-- add limit and offset
CREATE OR REPLACE FUNCTION item_comments_limited(
_item_id int, _limit int, _offset int, _grandchild_limit 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 '
|| 'WITH RECURSIVE base AS ( '
|| ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn '
|| ' FROM "Item" '
|| ' WHERE "Item"."parentId" = $1 '
|| _order_by || ' '
|| ' LIMIT $2 '
|| ' OFFSET $3) '
|| ' UNION ALL '
|| ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') '
|| ' FROM "Item" '
|| ' JOIN base b ON "Item"."parentId" = b.id '
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|| ' WHERE b.level < $5 AND (b.level = 1 OR b.rn <= $4)) '
|| ') '
|| '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, '
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|| 'FROM base "Item" '
|| 'JOIN users ON users.id = "Item"."userId" '
|| 'LEFT JOIN hot_score_view g ON g.id = "Item".id '
|| 'WHERE ("Item".level = 1 OR "Item".rn <= $4 - "Item".level + 2) ' || _where
USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
EXECUTE ''
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|| 'FROM ( '
|| ' SELECT "Item".*, item_comments_limited("Item".id, $2, $3, $4, $5 - 1, $6, $7) AS comments '
|| ' FROM t_item "Item" '
|| ' WHERE "Item"."parentId" = $1 '
|| _order_by
|| ' ) sub'
INTO result USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
RETURN result;
END
$$;

View File

@ -0,0 +1,2 @@
CREATE INDEX IF NOT EXISTS hot_score_view_hot_score_no_nulls_idx ON hot_score_view(hot_score DESC);
CREATE INDEX IF NOT EXISTS hot_score_view_sub_hot_score_no_nulls_idx ON hot_score_view(sub_hot_score DESC);

View File

@ -0,0 +1,132 @@
-- add limit and offset
CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me_limited(
_item_id int, _global_seed int, _me_id int, _limit int, _offset int, _grandchild_limit 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 '
|| 'WITH RECURSIVE base AS ( '
|| ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn '
|| ' FROM "Item" '
|| ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id '
|| ' WHERE "Item"."parentId" = $1 '
|| _order_by || ' '
|| ' LIMIT $4 '
|| ' OFFSET $5) '
|| ' UNION ALL '
|| ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') as rn '
|| ' FROM "Item" '
|| ' JOIN base b ON "Item"."parentId" = b.id '
|| ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id '
|| ' WHERE b.level < $7 AND (b.level = 1 OR b.rn <= $6)) '
|| ') '
|| '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.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, '
|| ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", '
|| ' COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats", '
|| ' COALESCE("ItemAct"."meDontLikeMsats", 0) AS "meDontLikeMsats", '
|| ' COALESCE("ItemAct"."meMcredits", 0) AS "meMcredits", '
|| ' COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits", '
|| ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", '
|| ' "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", '
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|| 'FROM base "Item" '
|| 'JOIN users ON users.id = "Item"."userId" '
|| ' LEFT JOIN "Mute" ON "Mute"."muterId" = $3 AND "Mute"."mutedId" = "Item"."userId" '
|| ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $3 AND "Bookmark"."itemId" = "Item".id '
|| ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $3 AND "ThreadSubscription"."itemId" = "Item".id '
|| ' LEFT JOIN hot_score_view g ON g.id = "Item".id '
|| 'LEFT JOIN LATERAL ( '
|| ' SELECT "itemId", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMsats", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMcredits", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMsats", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMcredits", '
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
|| ' FROM "ItemAct" '
|| ' LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId" '
|| ' LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id" '
|| ' WHERE "ItemAct"."userId" = $3 '
|| ' AND "ItemAct"."itemId" = "Item".id '
|| ' GROUP BY "ItemAct"."itemId" '
|| ') "ItemAct" ON true '
|| 'WHERE ("Item".level = 1 OR "Item".rn <= $6 - "Item".level + 2) ' || _where || ' '
USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
EXECUTE ''
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|| 'FROM ( '
|| ' SELECT "Item".*, item_comments_zaprank_with_me_limited("Item".id, $2, $3, $4, $5, $6, $7 - 1, $8, $9) AS comments '
|| ' FROM t_item "Item" '
|| ' WHERE "Item"."parentId" = $1 '
|| _order_by
|| ' ) sub'
INTO result USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
RETURN result;
END
$$;
CREATE OR REPLACE FUNCTION item_comments_limited(
_item_id int, _limit int, _offset int, _grandchild_limit 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 '
|| 'WITH RECURSIVE base AS ( '
|| ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn '
|| ' FROM "Item" '
|| ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id '
|| ' WHERE "Item"."parentId" = $1 '
|| _order_by || ' '
|| ' LIMIT $2 '
|| ' OFFSET $3) '
|| ' UNION ALL '
|| ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') '
|| ' FROM "Item" '
|| ' JOIN base b ON "Item"."parentId" = b.id '
|| ' LEFT JOIN hot_score_view g(id, "hotScore", "subHotScore") ON g.id = "Item".id '
|| ' WHERE b.level < $5 AND (b.level = 1 OR b.rn <= $4)) '
|| ') '
|| '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, '
|| ' g.hot_score AS "hotScore", g.sub_hot_score AS "subHotScore" '
|| 'FROM base "Item" '
|| 'JOIN users ON users.id = "Item"."userId" '
|| 'LEFT JOIN hot_score_view g ON g.id = "Item".id '
|| 'WHERE ("Item".level = 1 OR "Item".rn <= $4 - "Item".level + 2) ' || _where
USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
EXECUTE ''
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|| 'FROM ( '
|| ' SELECT "Item".*, item_comments_limited("Item".id, $2, $3, $4, $5 - 1, $6, $7) AS comments '
|| ' FROM t_item "Item" '
|| ' WHERE "Item"."parentId" = $1 '
|| _order_by
|| ' ) sub'
INTO result USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
RETURN result;
END
$$;

View File

@ -148,6 +148,7 @@ model User {
directReceive Boolean @default(true) directReceive Boolean @default(true)
DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived") DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived")
DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent") DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent")
UserSubTrust UserSubTrust[]
@@index([photoId]) @@index([photoId])
@@index([createdAt], map: "users.created_at_index") @@index([createdAt], map: "users.created_at_index")
@ -184,6 +185,21 @@ model OneDayReferral {
@@index([type, typeId]) @@index([type, typeId])
} }
model UserSubTrust {
subName String @db.Citext
userId Int
zapPostTrust Float @default(0)
subZapPostTrust Float @default(0)
zapCommentTrust Float @default(0)
subZapCommentTrust Float @default(0)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade)
@@id([userId, subName])
}
enum WalletType { enum WalletType {
LIGHTNING_ADDRESS LIGHTNING_ADDRESS
LND LND
@ -520,6 +536,7 @@ model Item {
status Status @default(ACTIVE) status Status @default(ACTIVE)
company String? company String?
weightedVotes Float @default(0) weightedVotes Float @default(0)
subWeightedVotes Float @default(0)
boost Int @default(0) boost Int @default(0)
oldBoost Int @default(0) oldBoost Int @default(0)
pollCost Int? pollCost Int?
@ -534,6 +551,7 @@ model Item {
mcredits BigInt @default(0) mcredits BigInt @default(0)
cost Int @default(0) cost Int @default(0)
weightedDownVotes Float @default(0) weightedDownVotes Float @default(0)
subWeightedDownVotes Float @default(0)
bio Boolean @default(false) bio Boolean @default(false)
freebie Boolean @default(false) freebie Boolean @default(false)
deletedAt DateTime? deletedAt DateTime?
@ -760,6 +778,7 @@ model Sub {
MuteSub MuteSub[] MuteSub MuteSub[]
SubSubscription SubSubscription[] SubSubscription SubSubscription[]
TerritoryTransfer TerritoryTransfer[] TerritoryTransfer TerritoryTransfer[]
UserSubTrust UserSubTrust[]
@@index([parentName]) @@index([parentName])
@@index([createdAt]) @@index([createdAt])

View File

@ -33,7 +33,7 @@ export async function createInvoice (
out: false out: false
}) })
let hostname = url.replace(/^https?:\/\//, '') let hostname = url.replace(/^https?:\/\//, '').replace(/\/+$/, '')
const agent = getAgent({ hostname }) const agent = getAgent({ hostname })
if (process.env.NODE_ENV !== 'production' && hostname.startsWith('localhost:')) { if (process.env.NODE_ENV !== 'production' && hostname.startsWith('localhost:')) {

View File

@ -1,5 +1,6 @@
import { fetchWithTimeout } from '@/lib/fetch' import { fetchWithTimeout } from '@/lib/fetch'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
import { getAgent } from '@/lib/proxy'
import { assertContentTypeJson, assertResponseOk } from '@/lib/url' import { assertContentTypeJson, assertResponseOk } from '@/lib/url'
export * from '@/wallets/phoenixd' export * from '@/wallets/phoenixd'
@ -27,9 +28,13 @@ export async function createInvoice (
body.append('description', description) body.append('description', description)
body.append('amountSat', msatsToSats(msats)) body.append('amountSat', msatsToSats(msats))
const res = await fetchWithTimeout(url + path, { const hostname = url.replace(/^https?:\/\//, '').replace(/\/+$/, '')
const agent = getAgent({ hostname })
const res = await fetchWithTimeout(`${agent.protocol}//${hostname}${path}`, {
method: 'POST', method: 'POST',
headers, headers,
agent,
body, body,
signal signal
}) })

View File

@ -14,6 +14,7 @@ export async function expireBoost ({ data: { id }, models }) {
FROM "ItemAct" FROM "ItemAct"
WHERE act = 'BOOST' WHERE act = 'BOOST'
AND "itemId" = ${Number(id)}::INTEGER AND "itemId" = ${Number(id)}::INTEGER
AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID')
) )
UPDATE "Item" UPDATE "Item"
SET boost = COALESCE(boost.cur_msats, 0) / 1000, "oldBoost" = COALESCE(boost.old_msats, 0) / 1000 SET boost = COALESCE(boost.cur_msats, 0) / 1000, "oldBoost" = COALESCE(boost.old_msats, 0) / 1000

View File

@ -1,38 +1,89 @@
import * as math from 'mathjs' import * as math from 'mathjs'
import { USER_ID, SN_ADMIN_IDS } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
import { Prisma } from '@prisma/client'
import { initialTrust, GLOBAL_SEEDS } from '@/api/paidAction/lib/territory'
export async function trust ({ boss, models }) { const MAX_DEPTH = 40
try {
console.time('trust')
console.timeLog('trust', 'getting graph')
const graph = await getGraph(models)
console.timeLog('trust', 'computing trust')
const [vGlobal, mPersonal] = await trustGivenGraph(graph)
console.timeLog('trust', 'storing trust')
await storeTrust(models, graph, vGlobal, mPersonal)
} finally {
console.timeEnd('trust')
}
}
const MAX_DEPTH = 10
const MAX_TRUST = 1 const MAX_TRUST = 1
const MIN_SUCCESS = 1 const MIN_SUCCESS = 0
// https://en.wikipedia.org/wiki/Normal_distribution#Quantile_function // https://en.wikipedia.org/wiki/Normal_distribution#Quantile_function
const Z_CONFIDENCE = 6.109410204869 // 99.9999999% confidence const Z_CONFIDENCE = 6.109410204869 // 99.9999999% confidence
const GLOBAL_ROOT = 616 const SEED_WEIGHT = 0.83
const SEED_WEIGHT = 1.0
const AGAINST_MSAT_MIN = 1000 const AGAINST_MSAT_MIN = 1000
const MSAT_MIN = 20001 // 20001 is the minimum for a tip to be counted in trust const MSAT_MIN = 1001 // 20001 is the minimum for a tip to be counted in trust
const SIG_DIFF = 0.1 // need to differ by at least 10 percent const INDEPENDENCE_THRESHOLD = 50 // how many zappers are needed to consider a sub independent
const IRRELEVANT_CUMULATIVE_TRUST = 0.001 // if a user has less than this amount of cumulative trust, they are irrelevant
// for each subName, we'll need to get two graphs
// one for comments and one for posts
// then we'll need to do two trust calculations on each graph
// one with global seeds and one with subName seeds
export async function trust ({ boss, models }) {
console.time('trust')
const territories = await models.sub.findMany({
where: {
status: 'ACTIVE'
}
})
for (const territory of territories) {
const seeds = GLOBAL_SEEDS.includes(territory.userId) ? GLOBAL_SEEDS : GLOBAL_SEEDS.concat(territory.userId)
try {
console.timeLog('trust', `getting post graph for ${territory.name}`)
const postGraph = await getGraph(models, territory.name, true, seeds)
console.timeLog('trust', `getting comment graph for ${territory.name}`)
const commentGraph = await getGraph(models, territory.name, false, seeds)
console.timeLog('trust', `computing global post trust for ${territory.name}`)
const vGlobalPost = await trustGivenGraph(postGraph)
console.timeLog('trust', `computing global comment trust for ${territory.name}`)
const vGlobalComment = await trustGivenGraph(commentGraph)
console.timeLog('trust', `computing sub post trust for ${territory.name}`)
const vSubPost = await trustGivenGraph(postGraph, postGraph.length > INDEPENDENCE_THRESHOLD ? [territory.userId] : seeds)
console.timeLog('trust', `computing sub comment trust for ${territory.name}`)
const vSubComment = await trustGivenGraph(commentGraph, commentGraph.length > INDEPENDENCE_THRESHOLD ? [territory.userId] : seeds)
console.timeLog('trust', `storing trust for ${territory.name}`)
let results = reduceVectors(territory.name, {
zapPostTrust: {
graph: postGraph,
vector: vGlobalPost
},
subZapPostTrust: {
graph: postGraph,
vector: vSubPost
},
zapCommentTrust: {
graph: commentGraph,
vector: vGlobalComment
},
subZapCommentTrust: {
graph: commentGraph,
vector: vSubComment
}
})
if (results.length === 0) {
console.timeLog('trust', `no results for ${territory.name} - adding seeds`)
results = initialTrust({ name: territory.name, userId: territory.userId })
}
await storeTrust(models, territory.name, results)
} catch (e) {
console.error(`error computing trust for ${territory.name}:`, e)
} finally {
console.timeLog('trust', `finished computing trust for ${territory.name}`)
}
}
console.timeEnd('trust')
}
/* /*
Given a graph and start this function returns an object where Given a graph and start this function returns an object where
the keys are the node id and their value is the trust of that node the keys are the node id and their value is the trust of that node
*/ */
function trustGivenGraph (graph) { // I'm going to need to send subName, and multiply by a vector instead of a matrix
function trustGivenGraph (graph, seeds = GLOBAL_SEEDS) {
console.timeLog('trust', `creating matrix of size ${graph.length} x ${graph.length}`)
// empty matrix of proper size nstackers x nstackers // empty matrix of proper size nstackers x nstackers
let mat = math.zeros(graph.length, graph.length, 'sparse') const mat = math.zeros(graph.length, graph.length, 'sparse')
// create a map of user id to position in matrix // create a map of user id to position in matrix
const posByUserId = {} const posByUserId = {}
@ -54,54 +105,57 @@ function trustGivenGraph (graph) {
// perform random walk over trust matrix // perform random walk over trust matrix
// the resulting matrix columns represent the trust a user (col) has for each other user (rows) // the resulting matrix columns represent the trust a user (col) has for each other user (rows)
// XXX this scales N^3 and mathjs is slow const matT = math.transpose(mat)
let matT = math.transpose(mat) const vTrust = math.zeros(graph.length)
const original = matT.clone() for (const seed of seeds) {
for (let i = 0; i < MAX_DEPTH; i++) { vTrust.set([posByUserId[seed], 0], 1.0 / seeds.length)
console.timeLog('trust', `matrix multiply ${i}`)
matT = math.multiply(original, matT)
matT = math.add(math.multiply(1 - SEED_WEIGHT, matT), math.multiply(SEED_WEIGHT, original))
} }
let result = vTrust.clone()
console.timeLog('trust', 'matrix multiply')
for (let i = 0; i < MAX_DEPTH; i++) {
result = math.multiply(matT, result)
result = math.add(math.multiply(1 - SEED_WEIGHT, result), math.multiply(SEED_WEIGHT, vTrust))
}
result = math.squeeze(result)
console.timeLog('trust', 'transforming result') console.timeLog('trust', 'transforming result')
const seedIdxs = SN_ADMIN_IDS.map(id => posByUserId[id]) const seedIdxs = seeds.map(id => posByUserId[id])
const isOutlier = (fromIdx, idx) => [...seedIdxs, fromIdx].includes(idx) const filterZeroAndSeed = (val, idx) => {
const sqapply = (mat, fn) => { return val !== 0 && !seedIdxs.includes(idx[0])
let idx = 0 }
return math.squeeze(math.apply(mat, 1, d => { const filterSeed = (val, idx) => {
const filtered = math.filter(d, (val, fidx) => { return !seedIdxs.includes(idx[0])
return val !== 0 && !isOutlier(idx, fidx[0]) }
}) const sqapply = (vec, filterFn, fn) => {
idx++ // if the vector is smaller than the seeds, don't filter
if (filtered.length === 0) return 0 const filtered = vec.size()[0] > seeds.length ? math.filter(vec, filterFn) : vec
if (filtered.size()[0] === 0) return 0
return fn(filtered) return fn(filtered)
}))
} }
console.timeLog('trust', 'normalizing') console.timeLog('trust', 'normalizing')
console.timeLog('trust', 'stats') console.timeLog('trust', 'stats')
mat = math.transpose(matT) const std = sqapply(result, filterZeroAndSeed, math.std) // math.squeeze(math.std(mat, 1))
const std = sqapply(mat, math.std) // math.squeeze(math.std(mat, 1)) const mean = sqapply(result, filterZeroAndSeed, math.mean) // math.squeeze(math.mean(mat, 1))
const mean = sqapply(mat, math.mean) // math.squeeze(math.mean(mat, 1)) console.timeLog('trust', 'std', std)
const zscore = math.map(mat, (val, idx) => { console.timeLog('trust', 'mean', mean)
const zstd = math.subset(std, math.index(idx[0], 0)) const zscore = math.map(result, (val) => {
const zmean = math.subset(mean, math.index(idx[0], 0)) if (std === 0) return 0
return zstd ? (val - zmean) / zstd : 0 return (val - mean) / std
}) })
console.timeLog('trust', 'minmax') console.timeLog('trust', 'minmax')
const min = sqapply(zscore, math.min) // math.squeeze(math.min(zscore, 1)) const min = sqapply(zscore, filterSeed, math.min) // math.squeeze(math.min(zscore, 1))
const max = sqapply(zscore, math.max) // math.squeeze(math.max(zscore, 1)) const max = sqapply(zscore, filterSeed, math.max) // math.squeeze(math.max(zscore, 1))
const mPersonal = math.map(zscore, (val, idx) => { console.timeLog('trust', 'min', min)
const zmin = math.subset(min, math.index(idx[0], 0)) console.timeLog('trust', 'max', max)
const zmax = math.subset(max, math.index(idx[0], 0)) const normalized = math.map(zscore, (val) => {
const zrange = zmax - zmin const zrange = max - min
if (val > zmax) return MAX_TRUST if (val > max) return MAX_TRUST
return zrange ? (val - zmin) / zrange : 0 return zrange ? (val - min) / zrange : 0
}) })
const vGlobal = math.squeeze(math.row(mPersonal, posByUserId[GLOBAL_ROOT]))
return [vGlobal, mPersonal] return normalized
} }
/* /*
@ -111,23 +165,31 @@ function trustGivenGraph (graph) {
... ...
] ]
*/ */
async function getGraph (models) { // I'm going to want to send subName to this function
// and whether it's for comments or posts
async function getGraph (models, subName, postTrust = true, seeds = GLOBAL_SEEDS) {
return await models.$queryRaw` return await models.$queryRaw`
SELECT id, json_agg(json_build_object( SELECT id, json_agg(json_build_object(
'node', oid, 'node', oid,
'trust', CASE WHEN total_trust > 0 THEN trust / total_trust::float ELSE 0 END)) AS hops 'trust', CASE WHEN total_trust > 0 THEN trust / total_trust::float ELSE 0 END)) AS hops
FROM ( FROM (
WITH user_votes AS ( WITH user_votes AS (
SELECT "ItemAct"."userId" AS user_id, users.name AS name, "ItemAct"."itemId" AS item_id, min("ItemAct".created_at) AS act_at, SELECT "ItemAct"."userId" AS user_id, users.name AS name, "ItemAct"."itemId" AS item_id, max("ItemAct".created_at) AS act_at,
users.created_at AS user_at, "ItemAct".act = 'DONT_LIKE_THIS' AS against, users.created_at AS user_at, "ItemAct".act = 'DONT_LIKE_THIS' AS against,
count(*) OVER (partition by "ItemAct"."userId") AS user_vote_count, count(*) OVER (partition by "ItemAct"."userId") AS user_vote_count,
sum("ItemAct".msats) as user_msats sum("ItemAct".msats) as user_msats
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS') JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS')
AND "Item"."parentId" IS NULL AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId" AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId"
AND ${postTrust
? Prisma.sql`"Item"."parentId" IS NULL AND "Item"."subName" = ${subName}::TEXT`
: Prisma.sql`
"Item"."parentId" IS NOT NULL
JOIN "Item" root ON "Item"."rootId" = root.id AND root."subName" = ${subName}::TEXT`
}
JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${USER_ID.anon} JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${USER_ID.anon}
WHERE "ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID' WHERE ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
GROUP BY user_id, name, item_id, user_at, against GROUP BY user_id, users.name, item_id, user_at, against
HAVING CASE WHEN HAVING CASE WHEN
"ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN} "ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN}
ELSE sum("ItemAct".msats) > ${MSAT_MIN} END ELSE sum("ItemAct".msats) > ${MSAT_MIN} END
@ -136,7 +198,7 @@ async function getGraph (models) {
SELECT a.user_id AS a_id, b.user_id AS b_id, SELECT a.user_id AS a_id, b.user_id AS b_id,
sum(CASE WHEN b.user_msats > a.user_msats THEN a.user_msats / b.user_msats::FLOAT ELSE b.user_msats / a.user_msats::FLOAT END) FILTER(WHERE a.act_at > b.act_at AND a.against = b.against) AS before, sum(CASE WHEN b.user_msats > a.user_msats THEN a.user_msats / b.user_msats::FLOAT ELSE b.user_msats / a.user_msats::FLOAT END) FILTER(WHERE a.act_at > b.act_at AND a.against = b.against) AS before,
sum(CASE WHEN b.user_msats > a.user_msats THEN a.user_msats / b.user_msats::FLOAT ELSE b.user_msats / a.user_msats::FLOAT END) FILTER(WHERE b.act_at > a.act_at AND a.against = b.against) AS after, sum(CASE WHEN b.user_msats > a.user_msats THEN a.user_msats / b.user_msats::FLOAT ELSE b.user_msats / a.user_msats::FLOAT END) FILTER(WHERE b.act_at > a.act_at AND a.against = b.against) AS after,
sum(log(1 + a.user_msats / 10000::float) + log(1 + b.user_msats / 10000::float)) FILTER(WHERE a.against <> b.against) AS disagree, count(*) FILTER(WHERE a.against <> b.against) AS disagree,
b.user_vote_count AS b_total, a.user_vote_count AS a_total b.user_vote_count AS b_total, a.user_vote_count AS a_total
FROM user_votes a FROM user_votes a
JOIN user_votes b ON a.item_id = b.item_id JOIN user_votes b ON a.item_id = b.item_id
@ -149,14 +211,9 @@ async function getGraph (models) {
confidence(before - disagree, b_total - after, ${Z_CONFIDENCE}) confidence(before - disagree, b_total - after, ${Z_CONFIDENCE})
ELSE 0 END AS trust ELSE 0 END AS trust
FROM user_pair FROM user_pair
WHERE NOT (b_id = ANY (${SN_ADMIN_IDS}))
UNION ALL UNION ALL
SELECT a_id AS id, seed_id AS oid, ${MAX_TRUST}::numeric as trust SELECT seed_id AS id, seed_id AS oid, 0 AS trust
FROM user_pair, unnest(${SN_ADMIN_IDS}::int[]) seed_id FROM unnest(${seeds}::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
FROM user_pair
) )
SELECT id, oid, trust, sum(trust) OVER (PARTITION BY id) AS total_trust SELECT id, oid, trust, sum(trust) OVER (PARTITION BY id) AS total_trust
FROM trust_pairs FROM trust_pairs
@ -165,46 +222,45 @@ async function getGraph (models) {
ORDER BY id ASC` ORDER BY id ASC`
} }
async function storeTrust (models, graph, vGlobal, mPersonal) { function reduceVectors (subName, fieldGraphVectors) {
// convert nodeTrust into table literal string function reduceVector (field, graph, vector, result = {}) {
let globalValues = '' vector.forEach((val, [idx]) => {
let personalValues = '' if (isNaN(val) || val <= 0) return
vGlobal.forEach((val, [idx]) => { result[graph[idx].id] = {
if (isNaN(val)) return ...result[graph[idx].id],
if (globalValues) globalValues += ',' subName,
globalValues += `(${graph[idx].id}, ${val}::FLOAT)` userId: graph[idx].id,
if (personalValues) personalValues += ',' [field]: val
personalValues += `(${GLOBAL_ROOT}, ${graph[idx].id}, ${val}::FLOAT)` }
}) })
return result
}
math.forEach(mPersonal, (val, [fromIdx, toIdx]) => { let result = {}
const globalVal = vGlobal.get([toIdx, 0]) for (const field in fieldGraphVectors) {
if (isNaN(val) || val - globalVal <= SIG_DIFF) return result = reduceVector(field, fieldGraphVectors[field].graph, fieldGraphVectors[field].vector, result)
if (personalValues) personalValues += ',' }
personalValues += `(${graph[fromIdx].id}, ${graph[toIdx].id}, ${val}::FLOAT)`
})
// return only the users with trust > 0
return Object.values(result).filter(s =>
Object.keys(fieldGraphVectors).reduce(
(acc, key) => acc + (s[key] ?? 0),
0
) > IRRELEVANT_CUMULATIVE_TRUST
)
}
async function storeTrust (models, subName, results) {
console.timeLog('trust', `storing trust for ${subName} with ${results.length} users`)
// update the trust of each user in graph // update the trust of each user in graph
await models.$transaction([ await models.$transaction([
models.$executeRaw`UPDATE users SET trust = 0`, models.userSubTrust.deleteMany({
models.$executeRawUnsafe( where: {
`UPDATE users subName
SET trust = g.trust }
FROM (values ${globalValues}) g(id, trust) }),
WHERE users.id = g.id`), models.userSubTrust.createMany({
models.$executeRawUnsafe( data: results
`INSERT INTO "Arc" ("fromId", "toId", "zapTrust") })
SELECT id, oid, trust
FROM (values ${personalValues}) g(id, oid, trust)
ON CONFLICT ("fromId", "toId") DO UPDATE SET "zapTrust" = EXCLUDED."zapTrust"`
),
// select all arcs that don't exist in personalValues and delete them
models.$executeRawUnsafe(
`DELETE FROM "Arc"
WHERE ("fromId", "toId") NOT IN (
SELECT id, oid
FROM (values ${personalValues}) g(id, oid, trust)
)`
)
]) ])
} }

View File

@ -32,7 +32,7 @@ export async function rankViews () {
const models = createPrisma({ connectionParams: { connection_limit: 1 } }) const models = createPrisma({ connectionParams: { connection_limit: 1 } })
try { try {
for (const view of ['zap_rank_personal_view']) { for (const view of ['hot_score_view']) {
await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`) await models.$queryRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`)
} }
} finally { } finally {