Compare commits

...

19 Commits

Author SHA1 Message Date
Keyan
eaa15b3b43
Update awards.csv with all 3 search issue closes 2025-03-24 14:41:23 -05:00
Keyan
9b4f1643a7
Update awards.csv 2025-03-24 14:35:22 -05:00
Keyan
411c8317b0
Update awards.csv using failed dangling github actions 2025-03-24 14:25:54 -05:00
github-actions[bot]
c39bfcecb6
Extending awards.csv (#2013)
Co-authored-by: huumn <34140557+huumn@users.noreply.github.com>
2025-03-24 13:31:46 -05:00
Edward Kung
f20ebad772
allow sort for search queries with only filters (#2012) 2025-03-23 11:53:45 -05:00
ekzyis
3ff03960eb
Remove unused addAccount, removeAccount (#2009) 2025-03-23 11:53:04 -05:00
ekzyis
9b08988402
Refactor login cookie with cookieOptions function (#2003) 2025-03-22 19:36:04 -05:00
ekzyis
b54268a88f
normalized wallet logs (#1826)
* Add invoiceId, withdrawalId to wallet logs

* Truncate wallet logs

* Fix extra db dips per log line

* Fix leak of invoice for sender
2025-03-22 17:31:10 -05:00
ekzyis
e7eece744f
Use __Secure- cookie prefix (#1998) 2025-03-22 16:59:57 -05:00
M Ʌ R C
54d3b11fbc
Update faq.md (#2008)
Adding links to guides
2025-03-22 10:51:10 -05:00
k00b
06b877e3d3 bump nextjs 2025-03-22 10:13:32 -05:00
ekzyis
5ff1334722
Remove unnecessary exports (#2006) 2025-03-21 19:56:01 -05:00
ekzyis
5e2185c18f
Use cookieOptions for pointer cookie (#2005) 2025-03-21 19:53:49 -05:00
k00b
bce4053b72 update boost explainer 2025-03-21 19:49:53 -05:00
k00b
2d619438b1 make hot_score_view editable through parameters of another view 2025-03-21 19:47:12 -05:00
k00b
5164258df8 remove independence threshold 2025-03-20 19:18:56 -05:00
ekzyis
f96b3bf19a
Fix useQuery lifecycle anti-pattern (#2001) 2025-03-20 17:46:19 -05:00
ekzyis
a83783f008
Fix missing max-age cookie option (#2000) 2025-03-20 16:38:13 -05:00
soxa
dbbd9477fd
don't send a verification email during sign in if no match (#1999)
* don't send verification email on signin if there's no matching user, update email.js message

* conditional magic code message; signup/signin button

* unnecessary useCallback

* switch to cookie parsing lib

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-03-20 15:32:31 -05:00
23 changed files with 312 additions and 206 deletions

View File

@ -276,11 +276,15 @@ export default {
// if nym, items must contain nym // if nym, items must contain nym
if (nym) { if (nym) {
filters.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } }) filters.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
// push same requirement to termQueries to avoid empty should clause
termQueries.push({ wildcard: { 'user.name': `*${nym.slice(1).toLowerCase()}*` } })
} }
// if territory, item must be from territory // if territory, item must be from territory
if (territory) { if (territory) {
filters.push({ match: { 'sub.name': territory.slice(1) } }) filters.push({ match: { 'sub.name': territory.slice(1) } })
// push same requirement to termQueries to avoid empty should clause
termQueries.push({ match: { 'sub.name': territory.slice(1) } })
} }
// if quoted phrases, items must contain entire phrase // if quoted phrases, items must contain entire phrase

View File

@ -430,6 +430,10 @@ const resolvers = {
lte: to ? new Date(Number(to)) : undefined lte: to ? new Date(Number(to)) : undefined
} }
}, },
include: {
invoice: true,
withdrawal: true
},
orderBy: [ orderBy: [
{ createdAt: 'desc' }, { createdAt: 'desc' },
{ id: 'desc' } { id: 'desc' }
@ -445,6 +449,10 @@ const resolvers = {
lte: decodedCursor.time lte: decodedCursor.time
} }
}, },
include: {
invoice: true,
withdrawal: true
},
orderBy: [ orderBy: [
{ createdAt: 'desc' }, { createdAt: 'desc' },
{ id: 'desc' } { id: 'desc' }
@ -745,11 +753,42 @@ const resolvers = {
return item return item
}, },
sats: fact => msatsToSatsDecimal(fact.msats) sats: fact => msatsToSatsDecimal(fact.msats)
},
WalletLogEntry: {
context: async ({ level, context, invoice, withdrawal }, args, { models }) => {
const isError = ['error', 'warn'].includes(level.toLowerCase())
if (withdrawal) {
return {
...await logContextFromBolt11(withdrawal.bolt11),
...(withdrawal.preimage ? { preimage: withdrawal.preimage } : {}),
...(isError ? { max_fee: formatMsats(withdrawal.msatsFeePaying) } : {})
}
}
// XXX never return invoice as context because it might leak sensitive sender details
// if (invoice) { ... }
return context
}
} }
} }
export default injectResolvers(resolvers) export default injectResolvers(resolvers)
const logContextFromBolt11 = async (bolt11) => {
const decoded = await parsePaymentRequest({ request: bolt11 })
return {
bolt11,
amount: formatMsats(decoded.mtokens),
payment_hash: decoded.id,
created_at: decoded.created_at,
expires_at: decoded.expires_at,
description: decoded.description
}
}
export const walletLogger = ({ wallet, models }) => { export const walletLogger = ({ wallet, models }) => {
// no-op logger if wallet is not provided // no-op logger if wallet is not provided
if (!wallet) { if (!wallet) {
@ -762,23 +801,17 @@ export const walletLogger = ({ wallet, models }) => {
} }
// server implementation of wallet logger interface on client // server implementation of wallet logger interface on client
const log = (level) => async (message, context = {}) => { const log = (level) => async (message, ctx = {}) => {
try { try {
if (context?.bolt11) { let { invoiceId, withdrawalId, ...context } = ctx
if (context.bolt11) {
// automatically populate context from bolt11 to avoid duplicating this code // automatically populate context from bolt11 to avoid duplicating this code
const decoded = await parsePaymentRequest({ request: context.bolt11 })
context = { context = {
...context, ...context,
amount: formatMsats(decoded.mtokens), ...await logContextFromBolt11(context.bolt11)
payment_hash: decoded.id,
created_at: decoded.created_at,
expires_at: decoded.expires_at,
description: decoded.description,
// payments should affect wallet status
status: true
} }
} }
context.recv = true
await models.walletLog.create({ await models.walletLog.create({
data: { data: {
@ -786,7 +819,9 @@ export const walletLogger = ({ wallet, models }) => {
wallet: wallet.type, wallet: wallet.type,
level, level,
message, message,
context context,
invoiceId,
withdrawalId
} }
}) })
} catch (err) { } catch (err) {

View File

@ -15,6 +15,7 @@ import { getServerSession } from 'next-auth/next'
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]' import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
import { NOFOLLOW_LIMIT } from '@/lib/constants' import { NOFOLLOW_LIMIT } from '@/lib/constants'
import { satsToMsats } from '@/lib/format' import { satsToMsats } from '@/lib/format'
import { MULTI_AUTH_ANON, MULTI_AUTH_LIST } from '@/lib/auth'
export default async function getSSRApolloClient ({ req, res, me = null }) { export default async function getSSRApolloClient ({ req, res, me = null }) {
const session = req && await getServerSession(req, res, getAuthOptions(req)) const session = req && await getServerSession(req, res, getAuthOptions(req))
@ -155,7 +156,7 @@ export function getGetServerSideProps (
// required to redirect to /signup on page reload // required to redirect to /signup on page reload
// if we switched to anon and authentication is required // if we switched to anon and authentication is required
if (req.cookies['multi_auth.user-id'] === 'anonymous') { if (req.cookies[MULTI_AUTH_LIST] === MULTI_AUTH_ANON) {
me = null me = null
} }

View File

@ -191,3 +191,17 @@ 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,??? Scroogey-SN,pr,#1973,#1959,good-first-issue,,,,20k,Scroogey@coinos.io,???
benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,???,??? benthecarman,issue,#1953,#1950,good-first-issue,,,,2k,???,???
ed-kung,pr,#2012,#2004,easy,,,,100k,simplestacker@getalby.com,???
ed-kung,issue,#2012,#2004,easy,,,,10k,simplestacker@getalby.com,???
ed-kung,pr,#1993,#1982,good-first-issue,,,,20k,simplestacker@getalby.com,???
rideandslide,issue,#1993,#1982,good-first-issue,,,,2k,???,???
ed-kung,pr,#1972,#1254,good-first-issue,,,,20k,simplestacker@getalby.com,???
SatsAllDay,issue,#1972,#1254,good-first-issue,,,,2k,weareallsatoshi@getalby.com,???
ed-kung,pr,#1962,#1343,good-first-issue,,,,20k,simplestacker@getalby.com,???
ed-kung,pr,#1962,#1217,good-first-issue,,,,20k,simplestacker@getalby.com,???
ed-kung,pr,#1962,#866,easy,,,,100k,simplestacker@getalby.com,???
felipebueno,issue,#1962,#866,easy,,,,10k,felipebueno@blink.sv,???
cointastical,issue,#1962,#1217,good-first-issue,,,,2k,cointastical@stacker.news,???
Scroogey-SN,pr,#1975,#1964,good-first-issue,,,,20k,Scroogey@coinos.io,???
rideandslide,issue,#1986,#1985,good-first-issue,,,,2k,???,???
kristapsk,issue,#1976,#841,good-first-issue,,,,2k,???,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
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 ??? ???
194 ed-kung pr #2012 #2004 easy 100k simplestacker@getalby.com ???
195 ed-kung issue #2012 #2004 easy 10k simplestacker@getalby.com ???
196 ed-kung pr #1993 #1982 good-first-issue 20k simplestacker@getalby.com ???
197 rideandslide issue #1993 #1982 good-first-issue 2k ??? ???
198 ed-kung pr #1972 #1254 good-first-issue 20k simplestacker@getalby.com ???
199 SatsAllDay issue #1972 #1254 good-first-issue 2k weareallsatoshi@getalby.com ???
200 ed-kung pr #1962 #1343 good-first-issue 20k simplestacker@getalby.com ???
201 ed-kung pr #1962 #1217 good-first-issue 20k simplestacker@getalby.com ???
202 ed-kung pr #1962 #866 easy 100k simplestacker@getalby.com ???
203 felipebueno issue #1962 #866 easy 10k felipebueno@blink.sv ???
204 cointastical issue #1962 #1217 good-first-issue 2k cointastical@stacker.news ???
205 Scroogey-SN pr #1975 #1964 good-first-issue 20k Scroogey@coinos.io ???
206 rideandslide issue #1986 #1985 good-first-issue 2k ??? ???
207 kristapsk issue #1976 #841 good-first-issue 2k ??? ???

View File

@ -9,6 +9,7 @@ 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' import { MultiAuthErrorBanner } from '@/components/banners'
import { cookieOptions, MULTI_AUTH_ANON, MULTI_AUTH_LIST, MULTI_AUTH_POINTER } from '@/lib/auth'
const AccountContext = createContext() const AccountContext = createContext()
@ -16,31 +17,19 @@ 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 maybeSecureCookie = cookie => {
return window.location.protocol === 'https:' ? cookie + '; Secure' : cookie
}
export const AccountProvider = ({ children }) => { export const AccountProvider = ({ children }) => {
const [accounts, setAccounts] = useState([]) const [accounts, setAccounts] = useState([])
const [meAnon, setMeAnon] = useState(true) const [meAnon, setMeAnon] = useState(true)
const [errors, setErrors] = useState([]) const [errors, setErrors] = useState([])
const updateAccountsFromCookie = useCallback(() => { const updateAccountsFromCookie = useCallback(() => {
const { multi_auth: multiAuthCookie } = cookie.parse(document.cookie) const { [MULTI_AUTH_LIST]: listCookie } = cookie.parse(document.cookie)
const accounts = multiAuthCookie const accounts = listCookie
? JSON.parse(b64Decode(multiAuthCookie)) ? JSON.parse(b64Decode(listCookie))
: [] : []
setAccounts(accounts) setAccounts(accounts)
}, []) }, [])
const addAccount = useCallback(user => {
setAccounts(accounts => [...accounts, user])
}, [])
const removeAccount = useCallback(userId => {
setAccounts(accounts => accounts.filter(({ id }) => id !== userId))
}, [])
const nextAccount = 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
@ -52,14 +41,14 @@ export const AccountProvider = ({ children }) => {
const checkErrors = useCallback(() => { const checkErrors = useCallback(() => {
const { const {
multi_auth: multiAuthCookie, [MULTI_AUTH_LIST]: listCookie,
'multi_auth.user-id': multiAuthUserIdCookie [MULTI_AUTH_POINTER]: pointerCookie
} = cookie.parse(document.cookie) } = cookie.parse(document.cookie)
const errors = [] const errors = []
if (!multiAuthCookie) errors.push('multi_auth cookie not found') if (!listCookie) errors.push(`${MULTI_AUTH_LIST} cookie not found`)
if (!multiAuthUserIdCookie) errors.push('multi_auth.user-id cookie not found') if (!pointerCookie) errors.push(`${MULTI_AUTH_POINTER} cookie not found`)
setErrors(errors) setErrors(errors)
}, []) }, [])
@ -69,8 +58,8 @@ export const AccountProvider = ({ children }) => {
updateAccountsFromCookie() updateAccountsFromCookie()
const { 'multi_auth.user-id': multiAuthUserIdCookie } = cookie.parse(document.cookie) const { [MULTI_AUTH_POINTER]: pointerCookie } = cookie.parse(document.cookie)
setMeAnon(multiAuthUserIdCookie === 'anonymous') setMeAnon(pointerCookie === 'anonymous')
const interval = setInterval(checkErrors, CHECK_ERRORS_INTERVAL_MS) const interval = setInterval(checkErrors, CHECK_ERRORS_INTERVAL_MS)
return () => clearInterval(interval) return () => clearInterval(interval)
@ -79,14 +68,12 @@ export const AccountProvider = ({ children }) => {
const value = useMemo( const value = useMemo(
() => ({ () => ({
accounts, accounts,
addAccount,
removeAccount,
meAnon, meAnon,
setMeAnon, setMeAnon,
nextAccount, nextAccount,
multiAuthErrors: errors multiAuthErrors: errors
}), }),
[accounts, addAccount, removeAccount, meAnon, setMeAnon, nextAccount, errors]) [accounts, meAnon, setMeAnon, nextAccount])
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider> return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
} }
@ -100,24 +87,23 @@ const AccountListRow = ({ account, ...props }) => {
const router = useRouter() const router = useRouter()
// fetch updated names and photo ids since they might have changed since we were issued the JWTs // fetch updated names and photo ids since they might have changed since we were issued the JWTs
const [name, setName] = useState(account.name) const { data, error } = useQuery(USER,
const [photoId, setPhotoId] = useState(account.photoId)
useQuery(USER,
{ {
variables: { id: account.id }, variables: { id: account.id }
onCompleted ({ user: { name, photoId } }) {
if (photoId) setPhotoId(photoId)
if (name) setName(name)
}
} }
) )
if (error) console.error(`query for user ${account.id} failed:`, error)
const name = data?.user?.name || account.name
const photoId = data?.user?.photoId || account.photoId
const onClick = async (e) => { const onClick = async (e) => {
// prevent navigation // prevent navigation
e.preventDefault() e.preventDefault()
// update pointer cookie // update pointer cookie
document.cookie = maybeSecureCookie(`multi_auth.user-id=${anonRow ? 'anonymous' : account.id}; Path=/`) const options = cookieOptions({ httpOnly: false })
document.cookie = cookie.serialize(MULTI_AUTH_POINTER, anonRow ? MULTI_AUTH_ANON : account.id, options)
// update state // update state
if (anonRow) { if (anonRow) {

View File

@ -37,16 +37,11 @@ export function BoostHelp () {
<li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li> <li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li>
<li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li> <li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li>
<li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li> <li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li>
<li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker <li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker (very rare)
<ul> <ul>
<li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li> <li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li>
</ul> </ul>
</li> </li>
<li>The decay of boost "votes" increases at 1.25x the rate of organic votes
<ul>
<li>i.e. boost votes fall out of ranking faster</li>
</ul>
</li>
<li>boost can take a few minutes to show higher ranking in feed</li> <li>boost can take a few minutes to show higher ranking in feed</li>
<li>100% of boost goes to the territory founder and top stackers as rewards</li> <li>100% of boost goes to the territory founder and top stackers as rewards</li>
</ol> </ol>

View File

@ -9,6 +9,9 @@ import { NostrAuthWithExplainer } from './nostr-auth'
import LoginButton from './login-button' import LoginButton from './login-button'
import { emailSchema } from '@/lib/validate' import { emailSchema } from '@/lib/validate'
import { OverlayTrigger, Tooltip } from 'react-bootstrap' import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import { datePivot } from '@/lib/time'
import * as cookie from 'cookie'
import { cookieOptions } from '@/lib/auth'
export function EmailLoginForm ({ text, callbackUrl, multiAuth }) { export function EmailLoginForm ({ text, callbackUrl, multiAuth }) {
const disabled = multiAuth const disabled = multiAuth
@ -59,15 +62,14 @@ export default function Login ({ providers, callbackUrl, multiAuth, error, text,
// signup/signin awareness cookie // signup/signin awareness cookie
useEffect(() => { useEffect(() => {
const cookieOptions = [ // expire cookie if we're on /signup instead of /login
`signin=${!!signin}`, // since the server will only check if the cookie is set, not its value
'path=/', const options = cookieOptions({
'max-age=' + (signin ? 60 * 60 * 24 : 0), // 24 hours if signin is true, expire the cookie otherwise expires: signin ? datePivot(new Date(), { hours: 24 }) : 0,
'SameSite=Lax', maxAge: signin ? 86400 : 0,
process.env.NODE_ENV === 'production' ? 'Secure' : '' httpOnly: false
].filter(Boolean).join(';') })
document.cookie = cookie.serialize('signin', signin, options)
document.cookie = cookieOptions
}, [signin]) }, [signin])
if (router.query.type === 'lightning') { if (router.query.type === 'lightning') {

View File

@ -239,7 +239,13 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
const newLogs = data.walletLogs.entries.map(({ createdAt, wallet: walletType, ...log }) => ({ const newLogs = data.walletLogs.entries.map(({ createdAt, wallet: walletType, ...log }) => ({
ts: +new Date(createdAt), ts: +new Date(createdAt),
wallet: walletTag(getWalletByType(walletType)), wallet: walletTag(getWalletByType(walletType)),
...log ...log,
// required to resolve recv status
context: {
recv: true,
status: !!log.context?.bolt11 && ['warn', 'error', 'success'].includes(log.level.toLowerCase()),
...log.context
}
})) }))
const combinedLogs = uniqueSort([...result.data, ...newLogs]) const combinedLogs = uniqueSort([...result.data, ...newLogs])

View File

@ -89,16 +89,15 @@ Click [here](/wallets) or click on your name and select 'wallets'. You should th
We currently support the following wallets: We currently support the following wallets:
- [WebLN](https://www.webln.guide/ressources/webln-providers) - [WebLN](https://www.webln.guide/ressources/webln-providers)
- [Blink](https://www.blink.sv/) - [Blink](https://www.blink.sv/). Read the [guide](https://stacker.news/items/705629/r/supratic)
- [Core Lightning](https://docs.corelightning.org/) via [CLNRest](https://docs.corelightning.org/docs/rest) - [Core Lightning](https://docs.corelightning.org/) via [CLNRest](https://docs.corelightning.org/docs/rest). Read the [guide](https://stacker.news/items/545926/r/supratic)
- [Lightning Node Connect](https://docs.lightning.engineering/lightning-network-tools/lightning-terminal/lightning-node-connect) (LNC) - [Lightning Node Connect](https://docs.lightning.engineering/lightning-network-tools/lightning-terminal/lightning-node-connect) (LNC)
- [Lightning Network Daemon](https://github.com/lightningnetwork/lnd) (LND) via [gRPC](https://lightning.engineering/api-docs/api/lnd/) - [Lightning Network Daemon](https://github.com/lightningnetwork/lnd) (LND) via [gRPC](https://lightning.engineering/api-docs/api/lnd/). Read the [guide](https://stacker.news/items/704693/r/supratic)
- [LNbits](https://lnbits.com/) - [LNbits](https://lnbits.com/). Read the [guide](https://stacker.news/items/697132/r/supratic)
- [Nostr Wallet Connect](https://nwc.dev/) (NWC) - [Nostr Wallet Connect](https://nwc.dev/) (NWC). Read the [guide](https://stacker.news/items/698497/r/supratic)
- [lightning address](https://strike.me/learn/what-is-a-lightning-address/) - [lightning address](https://strike.me/learn/what-is-a-lightning-address/). Read the [guide](https://stacker.news/items/694593/r/supratic)
- [phoenixd](https://phoenix.acinq.co/server) - [phoenixd](https://phoenix.acinq.co/server). Read the [guide](https://stacker.news/items/695912/r/supratic)
Click on the wallet you want to attach and complete the form.
### I can't find my wallet. Can I not attach one? ### I can't find my wallet. Can I not attach one?

View File

@ -6,19 +6,32 @@ import { encode as encodeJWT, decode as decodeJWT } from 'next-auth/jwt'
const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64') const b64Encode = obj => Buffer.from(JSON.stringify(obj)).toString('base64')
const b64Decode = s => JSON.parse(Buffer.from(s, 'base64')) const b64Decode = s => JSON.parse(Buffer.from(s, 'base64'))
const userJwtRegexp = /^multi_auth\.\d+$/ export const HTTPS = process.env.NODE_ENV === 'production'
const HTTPS = process.env.NODE_ENV === 'production' const secureCookie = (name) =>
const SESSION_COOKIE_NAME = HTTPS ? '__Secure-next-auth.session-token' : 'next-auth.session-token' HTTPS
? `__Secure-${name}`
: name
const cookieOptions = (args) => ({ export const SESSION_COOKIE = secureCookie('next-auth.session-token')
export const MULTI_AUTH_LIST = secureCookie('multi_auth')
export const MULTI_AUTH_POINTER = secureCookie('multi_auth.user-id')
export const MULTI_AUTH_ANON = 'anonymous'
export const MULTI_AUTH_JWT = id => secureCookie(`multi_auth.${id}`)
const MULTI_AUTH_REGEXP = /^(__Secure-)?multi_auth/
const MULTI_AUTH_JWT_REGEXP = /^(__Secure-)?multi_auth\.\d+$/
export const cookieOptions = (args) => ({
path: '/', path: '/',
secure: process.env.NODE_ENV === 'production', secure: HTTPS,
// httpOnly cookies by default // httpOnly cookies by default
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
// default expiration for next-auth JWTs is in 30 days // default expiration for next-auth JWTs is in 30 days
expires: datePivot(new Date(), { days: 30 }), expires: datePivot(new Date(), { days: 30 }),
maxAge: 2592000, // 30 days in seconds
...args ...args
}) })
@ -27,44 +40,43 @@ export function setMultiAuthCookies (req, res, { id, jwt, name, photoId }) {
const jsOptions = { ...httpOnlyOptions, httpOnly: false } const jsOptions = { ...httpOnlyOptions, httpOnly: false }
// add JWT to **httpOnly** cookie // add JWT to **httpOnly** cookie
res.appendHeader('Set-Cookie', cookie.serialize(`multi_auth.${id}`, jwt, httpOnlyOptions)) res.appendHeader('Set-Cookie', cookie.serialize(MULTI_AUTH_JWT(id), jwt, httpOnlyOptions))
// switch to user we just added // switch to user we just added
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth.user-id', id, jsOptions)) res.appendHeader('Set-Cookie', cookie.serialize(MULTI_AUTH_POINTER, id, jsOptions))
let newMultiAuth = [{ id, name, photoId }] let newMultiAuth = [{ id, name, photoId }]
if (req.cookies.multi_auth) { if (req.cookies[MULTI_AUTH_LIST]) {
const oldMultiAuth = b64Decode(req.cookies.multi_auth) const oldMultiAuth = b64Decode(req.cookies[MULTI_AUTH_LIST])
// make sure we don't add duplicates // make sure we don't add duplicates
if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return if (oldMultiAuth.some(({ id: id_ }) => id_ === id)) return
newMultiAuth = [...oldMultiAuth, ...newMultiAuth] newMultiAuth = [...oldMultiAuth, ...newMultiAuth]
} }
res.appendHeader('Set-Cookie', cookie.serialize('multi_auth', b64Encode(newMultiAuth), jsOptions)) res.appendHeader('Set-Cookie', cookie.serialize(MULTI_AUTH_LIST, b64Encode(newMultiAuth), jsOptions))
} }
export function switchSessionCookie (request) { function switchSessionCookie (request) {
// switch next-auth session cookie with multi_auth cookie if cookie pointer present // switch next-auth session cookie with multi_auth cookie if cookie pointer present
// is there a cookie pointer? // is there a cookie pointer?
const cookiePointerName = 'multi_auth.user-id' const hasCookiePointer = !!request.cookies[MULTI_AUTH_POINTER]
const hasCookiePointer = !!request.cookies[cookiePointerName]
// is there a session? // is there a session?
const hasSession = !!request.cookies[SESSION_COOKIE_NAME] const hasSession = !!request.cookies[SESSION_COOKIE]
if (!hasCookiePointer || !hasSession) { if (!hasCookiePointer || !hasSession) {
// no session or no cookie pointer. do nothing. // no session or no cookie pointer. do nothing.
return request return request
} }
const userId = request.cookies[cookiePointerName] const userId = request.cookies[MULTI_AUTH_POINTER]
if (userId === 'anonymous') { if (userId === MULTI_AUTH_ANON) {
// user switched to anon. only delete session cookie. // user switched to anon. only delete session cookie.
delete request.cookies[SESSION_COOKIE_NAME] delete request.cookies[SESSION_COOKIE]
return request return request
} }
const userJWT = request.cookies[`multi_auth.${userId}`] const userJWT = request.cookies[MULTI_AUTH_JWT(userId)]
if (!userJWT) { if (!userJWT) {
// no JWT for account switching found // no JWT for account switching found
return request return request
@ -72,7 +84,7 @@ export function switchSessionCookie (request) {
if (userJWT) { if (userJWT) {
// use JWT found in cookie pointed to by cookie pointer // use JWT found in cookie pointed to by cookie pointer
request.cookies[SESSION_COOKIE_NAME] = userJWT request.cookies[SESSION_COOKIE] = userJWT
return request return request
} }
@ -80,13 +92,13 @@ export function switchSessionCookie (request) {
} }
export function checkMultiAuthCookies (req, res) { export function checkMultiAuthCookies (req, res) {
if (!req.cookies.multi_auth || !req.cookies['multi_auth.user-id']) { if (!req.cookies[MULTI_AUTH_LIST] || !req.cookies[MULTI_AUTH_POINTER]) {
return false return false
} }
const accounts = b64Decode(req.cookies.multi_auth) const accounts = b64Decode(req.cookies[MULTI_AUTH_LIST])
for (const account of accounts) { for (const account of accounts) {
if (!req.cookies[`multi_auth.${account.id}`]) { if (!req.cookies[MULTI_AUTH_JWT(account.id)]) {
return false return false
} }
} }
@ -94,22 +106,18 @@ export function checkMultiAuthCookies (req, res) {
return true return true
} }
export function resetMultiAuthCookies (req, res) { function resetMultiAuthCookies (req, res) {
const httpOnlyOptions = cookieOptions({ expires: 0, maxAge: 0 }) const httpOnlyOptions = cookieOptions({ expires: 0, maxAge: 0 })
const jsOptions = { ...httpOnlyOptions, httpOnly: false } 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)) { for (const key of Object.keys(req.cookies)) {
// reset all user JWTs if (!MULTI_AUTH_REGEXP.test(key)) continue
if (userJwtRegexp.test(key)) { const options = MULTI_AUTH_JWT_REGEXP.test(key) ? httpOnlyOptions : jsOptions
res.appendHeader('Set-Cookie', cookie.serialize(key, '', httpOnlyOptions)) res.appendHeader('Set-Cookie', cookie.serialize(key, '', options))
}
} }
} }
export async function refreshMultiAuthCookies (req, res) { async function refreshMultiAuthCookies (req, res) {
const httpOnlyOptions = cookieOptions() const httpOnlyOptions = cookieOptions()
const jsOptions = { ...httpOnlyOptions, httpOnly: false } const jsOptions = { ...httpOnlyOptions, httpOnly: false }
@ -125,15 +133,15 @@ export async function refreshMultiAuthCookies (req, res) {
}) })
} }
const isAnon = req.cookies['multi_auth.user-id'] === 'anonymous' const isAnon = req.cookies[MULTI_AUTH_POINTER] === MULTI_AUTH_ANON
for (const [key, value] of Object.entries(req.cookies)) { 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 // 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 === SESSION_COOKIE && !isAnon) continue
if (!key.startsWith('multi_auth') && key !== SESSION_COOKIE_NAME) continue if (!key.startsWith(MULTI_AUTH_LIST) && key !== SESSION_COOKIE) continue
if (userJwtRegexp.test(key) || key === SESSION_COOKIE_NAME) { if (MULTI_AUTH_JWT_REGEXP.test(key) || key === SESSION_COOKIE) {
const oldToken = value const oldToken = value
const newToken = await refreshToken(oldToken) const newToken = await refreshToken(oldToken)
res.appendHeader('Set-Cookie', cookie.serialize(key, newToken, httpOnlyOptions)) res.appendHeader('Set-Cookie', cookie.serialize(key, newToken, httpOnlyOptions))

88
package-lock.json generated
View File

@ -52,7 +52,7 @@
"mdast-util-gfm": "^3.0.0", "mdast-util-gfm": "^3.0.0",
"mdast-util-to-string": "^4.0.0", "mdast-util-to-string": "^4.0.0",
"micromark-extension-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0",
"next": "^14.2.16", "next": "^14.2.25",
"next-auth": "^4.24.8", "next-auth": "^4.24.8",
"next-plausible": "^3.12.2", "next-plausible": "^3.12.2",
"next-seo": "^6.6.0", "next-seo": "^6.6.0",
@ -4126,9 +4126,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "14.2.16", "version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.16.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.25.tgz",
"integrity": "sha512-fLrX5TfJzHCbnZ9YUSnGW63tMV3L4nSfhgOQ0iCcX21Pt+VSTDuaLsSuL8J/2XAiVA5AnzvXDpf6pMs60QxOag==" "integrity": "sha512-JnzQ2cExDeG7FxJwqAksZ3aqVJrHjFwZQAEJ9gQZSoEhIow7SNoKZzju/AwQ+PLIR4NY8V0rhcVozx/2izDO0w=="
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "14.2.15", "version": "14.2.15",
@ -4186,9 +4186,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "14.2.16", "version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.16.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.25.tgz",
"integrity": "sha512-uFT34QojYkf0+nn6MEZ4gIWQ5aqGF11uIZ1HSxG+cSbj+Mg3+tYm8qXYd3dKN5jqKUm5rBVvf1PBRO/MeQ6rxw==", "integrity": "sha512-09clWInF1YRd6le00vt750s3m7SEYNehz9C4PUcSu3bAdCTpjIV4aTYQZ25Ehrr83VR1rZeqtKUPWSI7GfuKZQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -4201,9 +4201,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "14.2.16", "version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.16.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.25.tgz",
"integrity": "sha512-mCecsFkYezem0QiZlg2bau3Xul77VxUD38b/auAjohMA22G9KTJneUYMv78vWoCCFkleFAhY1NIvbyjj1ncG9g==", "integrity": "sha512-V+iYM/QR+aYeJl3/FWWU/7Ix4b07ovsQ5IbkwgUK29pTHmq+5UxeDr7/dphvtXEq5pLB/PucfcBNh9KZ8vWbug==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -4216,9 +4216,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.16", "version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.16.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.25.tgz",
"integrity": "sha512-yhkNA36+ECTC91KSyZcgWgKrYIyDnXZj8PqtJ+c2pMvj45xf7y/HrgI17hLdrcYamLfVt7pBaJUMxADtPaczHA==", "integrity": "sha512-LFnV2899PJZAIEHQ4IMmZIgL0FBieh5keMnriMY1cK7ompR+JUd24xeTtKkcaw8QmxmEdhoE5Mu9dPSuDBgtTg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -4231,9 +4231,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.16", "version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.16.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.25.tgz",
"integrity": "sha512-X2YSyu5RMys8R2lA0yLMCOCtqFOoLxrq2YbazFvcPOE4i/isubYjkh+JCpRmqYfEuCVltvlo+oGfj/b5T2pKUA==", "integrity": "sha512-QC5y5PPTmtqFExcKWKYgUNkHeHE/z3lUsu83di488nyP0ZzQ3Yse2G6TCxz6nNsQwgAx1BehAJTZez+UQxzLfw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -4246,9 +4246,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.16", "version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.16.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.25.tgz",
"integrity": "sha512-9AGcX7VAkGbc5zTSa+bjQ757tkjr6C/pKS7OK8cX7QEiK6MHIIezBLcQ7gQqbDW2k5yaqba2aDtaBeyyZh1i6Q==", "integrity": "sha512-y6/ML4b9eQ2D/56wqatTJN5/JR8/xdObU2Fb1RBidnrr450HLCKr6IJZbPqbv7NXmje61UyxjF5kvSajvjye5w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -4261,9 +4261,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.16", "version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.16.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.25.tgz",
"integrity": "sha512-Klgeagrdun4WWDaOizdbtIIm8khUDQJ/5cRzdpXHfkbY91LxBXeejL4kbZBrpR/nmgRrQvmz4l3OtttNVkz2Sg==", "integrity": "sha512-sPX0TSXHGUOZFvv96GoBXpB3w4emMqKeMgemrSxI7A6l55VBJp/RKYLwZIB9JxSqYPApqiREaIIap+wWq0RU8w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -4276,9 +4276,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.16", "version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.16.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.25.tgz",
"integrity": "sha512-PwW8A1UC1Y0xIm83G3yFGPiOBftJK4zukTmk7DI1CebyMOoaVpd8aSy7K6GhobzhkjYvqS/QmzcfsWG2Dwizdg==", "integrity": "sha512-ReO9S5hkA1DU2cFCsGoOEp7WJkhFzNbU/3VUF6XxNGUCQChyug6hZdYL/istQgfT/GWE6PNIg9cm784OI4ddxQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -4291,9 +4291,9 @@
} }
}, },
"node_modules/@next/swc-win32-ia32-msvc": { "node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.16", "version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.16.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.25.tgz",
"integrity": "sha512-jhPl3nN0oKEshJBNDAo0etGMzv0j3q3VYorTSFqH1o3rwv1MQRdor27u1zhkgsHPNeY1jxcgyx1ZsCkDD1IHgg==", "integrity": "sha512-DZ/gc0o9neuCDyD5IumyTGHVun2dCox5TfPQI/BJTYwpSNYM3CZDI4i6TOdjeq1JMo+Ug4kPSMuZdwsycwFbAw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -4306,9 +4306,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.16", "version": "14.2.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.16.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.25.tgz",
"integrity": "sha512-OA7NtfxgirCjfqt+02BqxC3MIgM/JaGjw9tOe4fyZgPsqfseNiMPnCRP44Pfs+Gpo9zPN+SXaFsgP6vk8d571A==", "integrity": "sha512-KSznmS6eFjQ9RJ1nEc66kJvtGIL1iZMYmGEXsZPh2YtnLtqrgdVvKXJY2ScjjoFnG6nGLyPFR0UiEvDwVah4Tw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -15657,11 +15657,11 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
}, },
"node_modules/next": { "node_modules/next": {
"version": "14.2.16", "version": "14.2.25",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.16.tgz", "resolved": "https://registry.npmjs.org/next/-/next-14.2.25.tgz",
"integrity": "sha512-LcO7WnFu6lYSvCzZoo1dB+IO0xXz5uEv52HF1IUN0IqVTUIZGHuuR10I5efiLadGt+4oZqTcNZyVVEem/TM5nA==", "integrity": "sha512-N5M7xMc4wSb4IkPvEV5X2BRRXUmhVHNyaXwEM86+voXthSZz8ZiRyQW4p9mwAoAPIm6OzuVZtn7idgEJeAJN3Q==",
"dependencies": { "dependencies": {
"@next/env": "14.2.16", "@next/env": "14.2.25",
"@swc/helpers": "0.5.5", "@swc/helpers": "0.5.5",
"busboy": "1.6.0", "busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
@ -15676,15 +15676,15 @@
"node": ">=18.17.0" "node": ">=18.17.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.16", "@next/swc-darwin-arm64": "14.2.25",
"@next/swc-darwin-x64": "14.2.16", "@next/swc-darwin-x64": "14.2.25",
"@next/swc-linux-arm64-gnu": "14.2.16", "@next/swc-linux-arm64-gnu": "14.2.25",
"@next/swc-linux-arm64-musl": "14.2.16", "@next/swc-linux-arm64-musl": "14.2.25",
"@next/swc-linux-x64-gnu": "14.2.16", "@next/swc-linux-x64-gnu": "14.2.25",
"@next/swc-linux-x64-musl": "14.2.16", "@next/swc-linux-x64-musl": "14.2.25",
"@next/swc-win32-arm64-msvc": "14.2.16", "@next/swc-win32-arm64-msvc": "14.2.25",
"@next/swc-win32-ia32-msvc": "14.2.16", "@next/swc-win32-ia32-msvc": "14.2.25",
"@next/swc-win32-x64-msvc": "14.2.16" "@next/swc-win32-x64-msvc": "14.2.25"
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.1.0", "@opentelemetry/api": "^1.1.0",

View File

@ -57,7 +57,7 @@
"mdast-util-gfm": "^3.0.0", "mdast-util-gfm": "^3.0.0",
"mdast-util-to-string": "^4.0.0", "mdast-util-to-string": "^4.0.0",
"micromark-extension-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0",
"next": "^14.2.16", "next": "^14.2.25",
"next-auth": "^4.24.8", "next-auth": "^4.24.8",
"next-plausible": "^3.12.2", "next-plausible": "^3.12.2",
"next-seo": "^6.6.0", "next-seo": "^6.6.0",

View File

@ -223,7 +223,7 @@ async function nostrEventAuth (event) {
} }
/** @type {import('next-auth/providers').Provider[]} */ /** @type {import('next-auth/providers').Provider[]} */
const getProviders = res => [ const getProviders = (req, res) => [
CredentialsProvider({ CredentialsProvider({
id: 'lightning', id: 'lightning',
name: 'Lightning', name: 'Lightning',
@ -275,14 +275,14 @@ const getProviders = res => [
from: process.env.LOGIN_EMAIL_FROM, from: process.env.LOGIN_EMAIL_FROM,
maxAge: 5 * 60, // expires in 5 minutes maxAge: 5 * 60, // expires in 5 minutes
generateVerificationToken: generateRandomString, generateVerificationToken: generateRandomString,
sendVerificationRequest sendVerificationRequest: (...args) => sendVerificationRequest(...args, req)
}) })
] ]
/** @returns {import('next-auth').AuthOptions} */ /** @returns {import('next-auth').AuthOptions} */
export const getAuthOptions = (req, res) => ({ export const getAuthOptions = (req, res) => ({
callbacks: getCallbacks(req, res), callbacks: getCallbacks(req, res),
providers: getProviders(res), providers: getProviders(req, res),
adapter: { adapter: {
...PrismaAdapter(prisma), ...PrismaAdapter(prisma),
createUser: data => { createUser: data => {
@ -421,7 +421,7 @@ async function sendVerificationRequest ({
url, url,
token, token,
provider provider
}) { }, req) {
let user = await prisma.user.findUnique({ let user = await prisma.user.findUnique({
where: { where: {
// Look for the user by hashed email // Look for the user by hashed email
@ -443,6 +443,11 @@ async function sendVerificationRequest ({
const site = new URL(url).host const site = new URL(url).host
// if we're trying to sign in but no user was found, resolve the promise
if (req.cookies.signin && !user) {
return resolve()
}
nodemailer.createTransport(server).sendMail( nodemailer.createTransport(server).sendMail(
{ {
to: email, to: email,

View File

@ -1,5 +1,6 @@
import * as cookie from 'cookie' import * as cookie from 'cookie'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import { HTTPS, MULTI_AUTH_JWT, MULTI_AUTH_LIST, MULTI_AUTH_POINTER, SESSION_COOKIE } from '@/lib/auth'
/** /**
* @param {NextApiRequest} req * @param {NextApiRequest} req
@ -8,14 +9,10 @@ import { datePivot } from '@/lib/time'
*/ */
export default (req, res) => { export default (req, res) => {
// is there a cookie pointer? // is there a cookie pointer?
const cookiePointerName = 'multi_auth.user-id' const userId = req.cookies[MULTI_AUTH_POINTER]
const userId = req.cookies[cookiePointerName]
const secure = process.env.NODE_ENV === 'production'
// is there a session? // is there a session?
const sessionCookieName = secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token' const sessionJWT = req.cookies[SESSION_COOKIE]
const sessionJWT = req.cookies[sessionCookieName]
if (!userId && !sessionJWT) { if (!userId && !sessionJWT) {
// no cookie pointer and no session cookie present. nothing to do. // no cookie pointer and no session cookie present. nothing to do.
@ -27,33 +24,33 @@ export default (req, res) => {
const cookieOptions = { const cookieOptions = {
path: '/', path: '/',
secure, secure: HTTPS,
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
expires: datePivot(new Date(), { months: 1 }) expires: datePivot(new Date(), { months: 1 })
} }
// remove JWT pointed to by cookie pointer // remove JWT pointed to by cookie pointer
cookies.push(cookie.serialize(`multi_auth.${userId}`, '', { ...cookieOptions, expires: 0, maxAge: 0 })) cookies.push(cookie.serialize(MULTI_AUTH_JWT(userId), '', { ...cookieOptions, expires: 0, maxAge: 0 }))
// update multi_auth cookie and check if there are more accounts available // update multi_auth cookie and check if there are more accounts available
const oldMultiAuth = req.cookies.multi_auth ? b64Decode(req.cookies.multi_auth) : undefined const oldMultiAuth = req.cookies[MULTI_AUTH_LIST] ? b64Decode(req.cookies[MULTI_AUTH_LIST]) : undefined
const newMultiAuth = oldMultiAuth?.filter(({ id }) => id !== Number(userId)) const newMultiAuth = oldMultiAuth?.filter(({ id }) => id !== Number(userId))
if (!oldMultiAuth || newMultiAuth?.length === 0) { if (!oldMultiAuth || newMultiAuth?.length === 0) {
// no next account available. cleanup: remove multi_auth + pointer cookie // no next account available. cleanup: remove multi_auth + pointer cookie
cookies.push(cookie.serialize('multi_auth', '', { ...cookieOptions, httpOnly: false, expires: 0, maxAge: 0 })) cookies.push(cookie.serialize(MULTI_AUTH_LIST, '', { ...cookieOptions, httpOnly: false, expires: 0, maxAge: 0 }))
cookies.push(cookie.serialize('multi_auth.user-id', '', { ...cookieOptions, httpOnly: false, expires: 0, maxAge: 0 })) cookies.push(cookie.serialize(MULTI_AUTH_POINTER, '', { ...cookieOptions, httpOnly: false, expires: 0, maxAge: 0 }))
res.setHeader('Set-Cookie', cookies) res.setHeader('Set-Cookie', cookies)
res.status(204).end() res.status(204).end()
return return
} }
cookies.push(cookie.serialize('multi_auth', b64Encode(newMultiAuth), { ...cookieOptions, httpOnly: false })) cookies.push(cookie.serialize(MULTI_AUTH_LIST, b64Encode(newMultiAuth), { ...cookieOptions, httpOnly: false }))
const newUserId = newMultiAuth[0].id const newUserId = newMultiAuth[0].id
const newUserJWT = req.cookies[`multi_auth.${newUserId}`] const newUserJWT = req.cookies[MULTI_AUTH_JWT(newUserId)]
res.setHeader('Set-Cookie', [ res.setHeader('Set-Cookie', [
...cookies, ...cookies,
cookie.serialize(cookiePointerName, newUserId, { ...cookieOptions, httpOnly: false }), cookie.serialize(MULTI_AUTH_POINTER, newUserId, { ...cookieOptions, httpOnly: false }),
cookie.serialize(sessionCookieName, newUserJWT, cookieOptions) cookie.serialize(SESSION_COOKIE, newUserJWT, cookieOptions)
]) ])
res.status(302).end() res.status(302).end()

View File

@ -1,4 +1,5 @@
import Image from 'react-bootstrap/Image' import Image from 'react-bootstrap/Image'
import * as cookie from 'cookie'
import { StaticLayout } from '@/components/layout' import { StaticLayout } from '@/components/layout'
import { getGetServerSideProps } from '@/api/ssrApollo' import { getGetServerSideProps } from '@/api/ssrApollo'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
@ -12,8 +13,10 @@ export const getServerSideProps = getGetServerSideProps({ query: null })
export default function Email () { export default function Email () {
const router = useRouter() const router = useRouter()
const [callback, setCallback] = useState(null) // callback.email, callback.callbackUrl const [callback, setCallback] = useState(null) // callback.email, callback.callbackUrl
const [signin, setSignin] = useState(false)
useEffect(() => { useEffect(() => {
setSignin(!!cookie.parse(document.cookie).signin)
setCallback(JSON.parse(window.sessionStorage.getItem('callback'))) setCallback(JSON.parse(window.sessionStorage.getItem('callback')))
}, []) }, [])
@ -27,6 +30,13 @@ export default function Email () {
router.push(url) router.push(url)
}, [callback, router]) }, [callback, router])
const buildMessage = () => {
const email = callback?.email || 'your email address'
return signin
? `if there's a match, a magic code will be sent to ${email}`
: `a magic code has been sent to ${email}`
}
return ( return (
<StaticLayout> <StaticLayout>
<div className='p-4 text-center'> <div className='p-4 text-center'>
@ -35,14 +45,14 @@ export default function Email () {
<Image className='rounded-1 shadow-sm' width='640' height='302' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/cowboy-saloon.gif`} fluid /> <Image className='rounded-1 shadow-sm' width='640' height='302' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/cowboy-saloon.gif`} fluid />
</video> </video>
<h2 className='pt-4'>Check your email</h2> <h2 className='pt-4'>Check your email</h2>
<h4 className='text-muted pt-2 pb-4'>a magic code has been sent to {callback ? callback.email : 'your email address'}</h4> <h4 className='text-muted pt-2 pb-4'>{buildMessage()}</h4>
<MagicCodeForm onSubmit={(token) => pushCallback(token)} disabled={!callback} /> <MagicCodeForm onSubmit={(token) => pushCallback(token)} disabled={!callback} signin={signin} />
</div> </div>
</StaticLayout> </StaticLayout>
) )
} }
export const MagicCodeForm = ({ onSubmit, disabled }) => { export const MagicCodeForm = ({ onSubmit, disabled, signin }) => {
return ( return (
<Form <Form
initial={{ initial={{
@ -64,7 +74,7 @@ export const MagicCodeForm = ({ onSubmit, disabled }) => {
hideError // hide error message on every input, allow custom error message hideError // hide error message on every input, allow custom error message
disabled={disabled} // disable the form if no callback is provided disabled={disabled} // disable the form if no callback is provided
/> />
<SubmitButton variant='primary' className='px-4' disabled={disabled}>login</SubmitButton> <SubmitButton variant='primary' className='px-4' disabled={disabled}>{signin ? 'login' : 'signup'}</SubmitButton>
</Form> </Form>
) )
} }

View File

@ -5,6 +5,7 @@ import Link from 'next/link'
import { StaticLayout } from '@/components/layout' import { StaticLayout } from '@/components/layout'
import Login from '@/components/login' import Login from '@/components/login'
import { isExternal } from '@/lib/url' import { isExternal } from '@/lib/url'
import { MULTI_AUTH_ANON, MULTI_AUTH_POINTER } from '@/lib/auth'
export async function getServerSideProps ({ req, res, query: { callbackUrl, multiAuth = false, error = null } }) { export async function getServerSideProps ({ req, res, query: { callbackUrl, multiAuth = false, error = null } }) {
let session = await getServerSession(req, res, getAuthOptions(req)) let session = await getServerSession(req, res, getAuthOptions(req))
@ -12,7 +13,7 @@ export async function getServerSideProps ({ req, res, query: { callbackUrl, mult
// required to prevent infinite redirect loops if we switch to anon // required to prevent infinite redirect loops if we switch to anon
// but are on a page that would redirect us to /signup. // but are on a page that would redirect us to /signup.
// without this code, /signup would redirect us back to the callbackUrl. // without this code, /signup would redirect us back to the callbackUrl.
if (req.cookies['multi_auth.user-id'] === 'anonymous') { if (req.cookies[MULTI_AUTH_POINTER] === MULTI_AUTH_ANON) {
session = null session = null
} }

View File

@ -0,0 +1,8 @@
ALTER TABLE "WalletLog"
ADD COLUMN "invoiceId" INTEGER,
ADD COLUMN "withdrawalId" INTEGER;
ALTER TABLE "WalletLog" ADD CONSTRAINT "WalletLog_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "WalletLog" ADD CONSTRAINT "WalletLog_withdrawalId_fkey" FOREIGN KEY ("withdrawalId") REFERENCES "Withdrawl"("id") ON DELETE SET NULL ON UPDATE CASCADE;
TRUNCATE "WalletLog" RESTRICT;

View File

@ -0,0 +1,35 @@
CREATE OR REPLACE VIEW hot_score_constants AS
SELECT 10000.0 AS boost_per_vote,
1.0 AS vote_power,
1.1 AS vote_decay,
3 AS age_wait_hours,
0.25 AS comment_vote_scaler;
CREATE MATERIALIZED VIEW IF NOT EXISTS hot_score_view_temp AS
SELECT id,
CASE WHEN "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0
THEN (POWER("Item"."weightedVotes" - "Item"."weightedDownVotes", hot_score_constants.vote_power)
+ "Item"."weightedComments"*hot_score_constants.comment_vote_scaler
+ "Item".boost / hot_score_constants.boost_per_vote)
/ POWER(GREATEST(hot_score_constants.age_wait_hours, EXTRACT(EPOCH FROM (now() - "Item".created_at))/3600), hot_score_constants.vote_decay)
ELSE "Item"."weightedVotes" - "Item"."weightedDownVotes" END AS hot_score,
CASE WHEN "Item"."subWeightedVotes" - "Item"."subWeightedDownVotes" > 0
THEN (POWER("Item"."subWeightedVotes" - "Item"."subWeightedDownVotes", hot_score_constants.vote_power)
+ "Item"."weightedComments"*hot_score_constants.comment_vote_scaler
+ "Item".boost / hot_score_constants.boost_per_vote)
/ POWER(GREATEST(hot_score_constants.age_wait_hours, EXTRACT(EPOCH FROM (now() - "Item".created_at))/3600), hot_score_constants.vote_decay)
ELSE "Item"."subWeightedVotes" - "Item"."subWeightedDownVotes" END AS sub_hot_score
FROM "Item", hot_score_constants
WHERE "Item"."weightedVotes" > 0 OR "Item"."weightedDownVotes" > 0 OR "Item"."subWeightedVotes" > 0
OR "Item"."subWeightedDownVotes" > 0 OR "Item"."weightedComments" > 0 OR "Item".boost > 0;
DROP MATERIALIZED VIEW IF EXISTS hot_score_view;
ALTER MATERIALIZED VIEW hot_score_view_temp RENAME TO hot_score_view;
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);
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

@ -271,6 +271,10 @@ model WalletLog {
wallet WalletType wallet WalletType
level LogLevel level LogLevel
message String message String
invoiceId Int?
invoice Invoice? @relation(fields: [invoiceId], references: [id])
withdrawalId Int?
withdrawal Withdrawl? @relation(fields: [withdrawalId], references: [id])
context Json? @db.JsonB context Json? @db.JsonB
@@index([userId, createdAt]) @@index([userId, createdAt])
@ -971,6 +975,7 @@ model Invoice {
Upload Upload[] Upload Upload[]
PollVote PollVote[] PollVote PollVote[]
PollBlindVote PollBlindVote[] PollBlindVote PollBlindVote[]
WalletLog WalletLog[]
@@index([createdAt], map: "Invoice.created_at_index") @@index([createdAt], map: "Invoice.created_at_index")
@@index([userId], map: "Invoice.userId_index") @@index([userId], map: "Invoice.userId_index")
@ -1048,6 +1053,7 @@ model Withdrawl {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull) wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: SetNull)
invoiceForward InvoiceForward? invoiceForward InvoiceForward?
WalletLog WalletLog[]
@@index([createdAt], map: "Withdrawl.created_at_index") @@index([createdAt], map: "Withdrawl.created_at_index")
@@index([userId], map: "Withdrawl.userId_index") @@index([userId], map: "Withdrawl.userId_index")

View File

@ -39,8 +39,7 @@ export async function * createUserInvoice (userId, { msats, description, descrip
try { try {
logger.info( logger.info(
`↙ incoming payment: ${formatSats(msatsToSats(msats))}`, `↙ incoming payment: ${formatSats(msatsToSats(msats))}`, {
{
amount: formatMsats(msats) amount: formatMsats(msats)
}) })

View File

@ -2,7 +2,7 @@ import { getPaymentFailureStatus, hodlInvoiceCltvDetails, getPaymentOrNotSent }
import { paidActions } from '@/api/paidAction' import { paidActions } from '@/api/paidAction'
import { walletLogger } from '@/api/resolvers/wallet' import { walletLogger } from '@/api/resolvers/wallet'
import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' import { LND_PATHFINDING_TIME_PREF_PPM, LND_PATHFINDING_TIMEOUT_MS, PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
import { formatMsats, formatSats, msatsToSats, toPositiveNumber } from '@/lib/format' import { formatSats, msatsToSats, toPositiveNumber } from '@/lib/format'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { import {
@ -317,17 +317,13 @@ export async function paidActionForwarded ({ data: { invoiceId, withdrawal, ...a
}, { models, lnd, boss }) }, { models, lnd, boss })
if (transitionedInvoice) { if (transitionedInvoice) {
const { bolt11, msatsPaid } = transitionedInvoice.invoiceForward.withdrawl const withdrawal = transitionedInvoice.invoiceForward.withdrawl
const logger = walletLogger({ wallet: transitionedInvoice.invoiceForward.wallet, models }) const logger = walletLogger({ wallet: transitionedInvoice.invoiceForward.wallet, models })
logger.ok( logger.ok(
`↙ payment received: ${formatSats(msatsToSats(Number(msatsPaid)))}`, `↙ payment received: ${formatSats(msatsToSats(Number(withdrawal.msatsPaid)))}`, {
{ invoiceId: transitionedInvoice.id,
bolt11, withdrawalId: withdrawal.id
preimage: transitionedInvoice.preimage
// we could show the outgoing fee that we paid from the incoming amount to the receiver
// but we don't since it might look like the receiver paid the fee but that's not the case.
// fee: formatMsats(msatsFeePaid)
}) })
} }
@ -376,12 +372,11 @@ export async function paidActionFailedForward ({ data: { invoiceId, withdrawal:
}, { models, lnd, boss }) }, { models, lnd, boss })
if (transitionedInvoice) { if (transitionedInvoice) {
const { bolt11, msatsFeePaying } = transitionedInvoice.invoiceForward.withdrawl const fwd = transitionedInvoice.invoiceForward
const logger = walletLogger({ wallet: transitionedInvoice.invoiceForward.wallet, models }) const logger = walletLogger({ wallet: fwd.wallet, models })
logger.warn( logger.warn(
`incoming payment failed: ${message}`, { `incoming payment failed: ${message}`, {
bolt11, withdrawalId: fwd.withdrawl.id
max_fee: formatMsats(msatsFeePaying)
}) })
} }
@ -446,7 +441,11 @@ export async function paidActionCanceling ({ data: { invoiceId, ...args }, model
const { wallet, bolt11 } = transitionedInvoice.invoiceForward const { wallet, bolt11 } = transitionedInvoice.invoiceForward
const logger = walletLogger({ wallet, models }) const logger = walletLogger({ wallet, models })
const decoded = await parsePaymentRequest({ request: bolt11 }) const decoded = await parsePaymentRequest({ request: bolt11 })
logger.info(`invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`, { bolt11 }) logger.info(
`invoice for ${formatSats(msatsToSats(decoded.mtokens))} canceled by payer`, {
bolt11,
invoiceId: transitionedInvoice.id
})
} }
} }

View File

@ -125,11 +125,8 @@ export async function payingActionConfirmed ({ data: args, models, lnd, boss })
const logger = walletLogger({ models, wallet: transitionedWithdrawal.wallet }) const logger = walletLogger({ models, wallet: transitionedWithdrawal.wallet })
logger?.ok( logger?.ok(
`↙ payment received: ${formatSats(msatsToSats(transitionedWithdrawal.msatsPaid))}`, `↙ payment received: ${formatSats(msatsToSats(transitionedWithdrawal.msatsPaid))}`, {
{ withdrawalId: transitionedWithdrawal.id
bolt11: transitionedWithdrawal.bolt11,
preimage: transitionedWithdrawal.preimage,
fee: formatMsats(transitionedWithdrawal.msatsFeePaid)
}) })
} }
} }

View File

@ -11,7 +11,6 @@ const Z_CONFIDENCE = 6.109410204869 // 99.9999999% confidence
const SEED_WEIGHT = 0.83 const SEED_WEIGHT = 0.83
const AGAINST_MSAT_MIN = 1000 const AGAINST_MSAT_MIN = 1000
const MSAT_MIN = 1001 // 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 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 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 // for each subName, we'll need to get two graphs
@ -37,9 +36,9 @@ export async function trust ({ boss, models }) {
console.timeLog('trust', `computing global comment trust for ${territory.name}`) console.timeLog('trust', `computing global comment trust for ${territory.name}`)
const vGlobalComment = await trustGivenGraph(commentGraph) const vGlobalComment = await trustGivenGraph(commentGraph)
console.timeLog('trust', `computing sub post trust for ${territory.name}`) console.timeLog('trust', `computing sub post trust for ${territory.name}`)
const vSubPost = await trustGivenGraph(postGraph, postGraph.length > INDEPENDENCE_THRESHOLD ? [territory.userId] : seeds) const vSubPost = await trustGivenGraph(postGraph, [territory.userId])
console.timeLog('trust', `computing sub comment trust for ${territory.name}`) console.timeLog('trust', `computing sub comment trust for ${territory.name}`)
const vSubComment = await trustGivenGraph(commentGraph, commentGraph.length > INDEPENDENCE_THRESHOLD ? [territory.userId] : seeds) const vSubComment = await trustGivenGraph(commentGraph, [territory.userId])
console.timeLog('trust', `storing trust for ${territory.name}`) console.timeLog('trust', `storing trust for ${territory.name}`)
let results = reduceVectors(territory.name, { let results = reduceVectors(territory.name, {
zapPostTrust: { zapPostTrust: {