Compare commits

..

14 Commits

Author SHA1 Message Date
keyan
051cb69f5e fix sharing imgproxy link directly 2024-05-06 12:53:31 -05:00
keyan
ce9e146a06 fix missing embellishment in rewards leaderboard 2024-05-06 11:41:02 -05:00
keyan
111053006a fix 'startsWith' call on non-string 2024-05-06 11:26:19 -05:00
keyan
bc2155c7aa update awards.csv 2024-05-06 11:01:31 -05:00
keyan
1f2aa46319 give lnurlp invoices longer expirations for tor channels 2024-05-06 10:08:28 -05:00
keyan
1b5e513f5e update awards.csv 2024-05-06 09:07:13 -05:00
keyan
1e3042e536 update awards.csv 2024-05-04 19:17:04 -05:00
keyan
2e65bf9126 update awards.csv 2024-05-04 18:53:09 -05:00
keyan
30550e48be reorder mobile bottom nav to be more intuitive 2024-05-04 18:07:09 -05:00
SatsAllDay
15f9950477
Store hashed and salted email addresses (#1111)
* first pass of hashing user emails

* use salt

* add a salt to .env.development (prod salt needs to be kept a secret)
* move `hashEmail` util to a new util module

* trigger a one-time job to migrate existing emails via the worker

so we can use the salt from an env var

* move newsletter signup

move newsletter signup to prisma adapter create user with email code path
so we can still auto-enroll email accounts without having to persist the email address
in plaintext

* remove `email` from api key session lookup query

* drop user email index before dropping column

* restore email column, just null values instead

* fix function name

* fix salt and hash raw sql statement

* update auth methods email type in typedefs from str to bool

* remove todo comment

* lowercase email before hashing during migration

* check for emailHash and email to accommodate migration window

update our lookups to check for a matching emailHash, and then a matching
email, in that order, to accommodate the case that a user tries to login
via email while the migration is running, and their account has not yet been migrated

also update sndev to have a command `./sndev email` to launch the mailhog inbox in your browser

also update `./sndev login` to hash the generated email address and insert it into the db record

* update sndev help

* update awards.csv

* update the hack in next-auth to re-use the email supplied on input to `getUserByEmail`

* consolidate console.error logs

* create generic open command

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-05-04 18:06:15 -05:00
ekzyis
6220eb06ee
Use proxy agents for CLNRest over Tor (#1136) 2024-05-03 17:00:28 -05:00
itsrealfake
ed9fc5d3de
Add Footer to User Page (#1135)
* Add Footer to User Page

this closes #1016

* Hide Footer when no user.bio

* apply review-bot suggestion

* refine profile footer

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-05-03 16:55:07 -05:00
ekzyis
5c593ce280
Fix unintended sharing of wallets and logs (#1127)
* Suffix localStorage key for attached wallets with me.id

* Suffix IndexedDB database name with me.id

* Fix TypeError: Cannot destructure property of 'config' as it is null

* Detach wallet on logout

* Migrate to new storage keys

* Use Promise.catch for togglePushSubscription on logout

It's more concise

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-05-03 16:42:00 -05:00
Felipe Bueno
72c27e339c
UserPopover (#1094)
* WIP UserPopover

* Add show delay on UserPopover

* UserDetails -> StackingSince on UserPopover

* Make UserPopover hoverable

* Add felipe to contributors.txt

* Remove export from SocialLink

* Remove @ outside of UserPopover

* userQuery -> useLazyQuery + Handling user not found

* Move styles to user-popover.module.css

* Update components/user-popover.module.css

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Remove poll + SSR check from useLazyQuery

* USER_FULL -> USER (we are only using stacking since, for now)

* refine user popover

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-05-03 16:39:21 -05:00
34 changed files with 633 additions and 150 deletions

View File

@ -36,6 +36,10 @@ LNWITH_URL=
LOGIN_EMAIL_SERVER=smtp://mailhog:1025 LOGIN_EMAIL_SERVER=smtp://mailhog:1025
LOGIN_EMAIL_FROM=sndev@mailhog.dev LOGIN_EMAIL_FROM=sndev@mailhog.dev
# email salt
# openssl rand -hex 32
EMAIL_SALT=202c90943c313b829e65e3f29164fb5dd7ea3370d7262c4159691c2f6493bb8b
# static things # static things
NEXTAUTH_URL=http://localhost:3000/api/auth NEXTAUTH_URL=http://localhost:3000/api/auth
SELF_URL=http://app:3000 SELF_URL=http://app:3000

View File

@ -9,6 +9,7 @@ import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS }
import { viewGroup } from './growth' import { viewGroup } from './growth'
import { timeUnitForRange, whenRange } from '@/lib/time' import { timeUnitForRange, whenRange } from '@/lib/time'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
import { hashEmail } from '@/lib/crypto'
const contributors = new Set() const contributors = new Set()
@ -44,7 +45,7 @@ async function authMethods (user, args, { models, me }) {
return { return {
lightning: !!user.pubkey, lightning: !!user.pubkey,
email: user.emailVerified && user.email, email: !!(user.emailVerified && user.emailHash),
twitter: oauth.indexOf('twitter') >= 0, twitter: oauth.indexOf('twitter') >= 0,
github: oauth.indexOf('github') >= 0, github: oauth.indexOf('github') >= 0,
nostr: !!user.nostrAuthPubkey, nostr: !!user.nostrAuthPubkey,
@ -686,7 +687,7 @@ export default {
try { try {
await models.user.update({ await models.user.update({
where: { id: me.id }, where: { id: me.id },
data: { email: email.toLowerCase() } data: { emailHash: hashEmail({ email }) }
}) })
} catch (error) { } catch (error) {
if (error.code === 'P2002') { if (error.code === 'P2002') {

View File

@ -108,7 +108,7 @@ export default gql`
nostr: Boolean! nostr: Boolean!
github: Boolean! github: Boolean!
twitter: Boolean! twitter: Boolean!
email: String email: Boolean!
apiKey: Boolean apiKey: Boolean
} }

View File

@ -66,3 +66,17 @@ benalleng,pr,#1099,#794,medium-hard,,,refined in a commit,450k,benalleng@mutiny.
dillon-co,helpfulness,#1099,#794,medium-hard,,,#988 did much of the legwork,225k,bolt11,2024-04-29 dillon-co,helpfulness,#1099,#794,medium-hard,,,#988 did much of the legwork,225k,bolt11,2024-04-29
abhiShandy,pr,#1119,#1110,good-first-issue,,,,20k,abhishandy@stacker.news,2024-04-28 abhiShandy,pr,#1119,#1110,good-first-issue,,,,20k,abhishandy@stacker.news,2024-04-28
felipebueno,issue,#1119,#1110,good-first-issue,,,,2k,felipe@stacker.news,2024-04-28 felipebueno,issue,#1119,#1110,good-first-issue,,,,2k,felipe@stacker.news,2024-04-28
SatsAllDay,pr,#1111,#622,medium-hard,,,,500k,weareallsatoshi@getalby.com,2024-05-04
itsrealfake,pr,#1130,#622,good-first-issue,,,,20k,itsrealfake2@stacker.news,2024-05-06
Darth-Coin,issue,#1130,#622,easy,,,,2k,darthcoin@stacker.news,2024-05-04
benalleng,pr,#1137,#1125,good-first-issue,,,,20k,benalleng@mutiny.plus,2024-05-04
SatsAllDay,issue,#1137,#1125,good-first-issue,,,,2k,weareallsatoshi@getalby.com,2024-05-04
SatsAllDay,helpfulness,#1137,#1125,good-first-issue,,,,2k,weareallsatoshi@getalby.com,2024-05-04
itsrealfake,pr,#1138,#995,good-first-issue,,,,20k,itsrealfake2@stacker.news,2024-05-06
SouthKoreaLN,issue,#1138,#995,good-first-issue,,,,2k,south_korea_ln@stacker.news,2024-05-04
mateusdeap,helpfulness,#1138,#995,good-first-issue,,,,1k,???,???
felipebueno,pr,#1094,,,,2,,80k,felipebueno@getalby.com,2024-05-06
benalleng,helpfulness,#1127,#927,good-first-issue,,,,2k,benalleng@mutiny.plus,2024-05-04
itsrealfake,pr,#1135,#1016,good-first-issue,,,nonideal solution,10k,itsrealfake2@stacker.news,2024-05-06
SatsAllDay,issue,#1135,#1016,good-first-issue,,,,1k,weareallsatoshi@getalby.com,2024-05-04
s373nZ,issue,#1136,#1107,medium,high,,,50k,se7enz@minibits.cash,2024-05-05
1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
66 dillon-co helpfulness #1099 #794 medium-hard #988 did much of the legwork 225k bolt11 2024-04-29
67 abhiShandy pr #1119 #1110 good-first-issue 20k abhishandy@stacker.news 2024-04-28
68 felipebueno issue #1119 #1110 good-first-issue 2k felipe@stacker.news 2024-04-28
69 SatsAllDay pr #1111 #622 medium-hard 500k weareallsatoshi@getalby.com 2024-05-04
70 itsrealfake pr #1130 #622 good-first-issue 20k itsrealfake2@stacker.news 2024-05-06
71 Darth-Coin issue #1130 #622 easy 2k darthcoin@stacker.news 2024-05-04
72 benalleng pr #1137 #1125 good-first-issue 20k benalleng@mutiny.plus 2024-05-04
73 SatsAllDay issue #1137 #1125 good-first-issue 2k weareallsatoshi@getalby.com 2024-05-04
74 SatsAllDay helpfulness #1137 #1125 good-first-issue 2k weareallsatoshi@getalby.com 2024-05-04
75 itsrealfake pr #1138 #995 good-first-issue 20k itsrealfake2@stacker.news 2024-05-06
76 SouthKoreaLN issue #1138 #995 good-first-issue 2k south_korea_ln@stacker.news 2024-05-04
77 mateusdeap helpfulness #1138 #995 good-first-issue 1k ??? ???
78 felipebueno pr #1094 2 80k felipebueno@getalby.com 2024-05-06
79 benalleng helpfulness #1127 #927 good-first-issue 2k benalleng@mutiny.plus 2024-05-04
80 itsrealfake pr #1135 #1016 good-first-issue nonideal solution 10k itsrealfake2@stacker.news 2024-05-06
81 SatsAllDay issue #1135 #1016 good-first-issue 1k weareallsatoshi@getalby.com 2024-05-04
82 s373nZ issue #1136 #1107 medium high 50k se7enz@minibits.cash 2024-05-05

View File

@ -65,7 +65,7 @@ function ImageOriginal ({ src, topLevel, rel, tab, children, onClick, ...props }
function ImageProxy ({ src, srcSet: { dimensions, ...srcSetObj } = {}, onClick, topLevel, onError, ...props }) { function ImageProxy ({ src, srcSet: { dimensions, ...srcSetObj } = {}, onClick, topLevel, onError, ...props }) {
const srcSet = useMemo(() => { const srcSet = useMemo(() => {
if (!srcSetObj) return undefined if (Object.keys(srcSetObj).length === 0) return undefined
// srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... } // srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... }
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url], i, arr) => { return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url], i, arr) => {
// backwards compatibility: we used to replace image urls with imgproxy urls rather just storing paths // backwards compatibility: we used to replace image urls with imgproxy urls rather just storing paths
@ -79,7 +79,7 @@ function ImageProxy ({ src, srcSet: { dimensions, ...srcSetObj } = {}, onClick,
// get source url in best resolution // get source url in best resolution
const bestResSrc = useMemo(() => { const bestResSrc = useMemo(() => {
if (!srcSetObj) return src if (Object.keys(srcSetObj).length === 0) return src
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url]) => { return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url]) => {
if (!url.startsWith('http')) { if (!url.startsWith('http')) {
url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString() url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString()
@ -107,7 +107,7 @@ function ImageProxy ({ src, srcSet: { dimensions, ...srcSetObj } = {}, onClick,
) )
} }
const Image = memo(({ className, src, srcSet, sizes, width, height, bestResSrc, onClick, onError }) => { const Image = memo(({ className, src, srcSet, sizes, width, height, onClick, onError }) => {
const style = width && height const style = width && height
? { '--height': `${height}px`, '--width': `${width}px`, '--aspect-ratio': `${width} / ${height}` } ? { '--height': `${height}px`, '--width': `${width}px`, '--aspect-ratio': `${width} / ${height}` }
: undefined : undefined
@ -116,7 +116,7 @@ const Image = memo(({ className, src, srcSet, sizes, width, height, bestResSrc,
<img <img
className={className} className={className}
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case // browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
src={bestResSrc} src={src}
srcSet={srcSet} srcSet={srcSet}
sizes={sizes} sizes={sizes}
width={width} width={width}

View File

@ -21,6 +21,7 @@ import MuteDropdownItem from './mute'
import { DropdownItemUpVote } from './upvote' import { DropdownItemUpVote } from './upvote'
import { useRoot } from './root' import { useRoot } from './root'
import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header' import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header'
import UserPopover from './user-popover'
export default function ItemInfo ({ export default function ItemInfo ({
item, full, commentsText = 'comments', item, full, commentsText = 'comments',
@ -101,10 +102,12 @@ export default function ItemInfo ({
</Link> </Link>
<span> \ </span> <span> \ </span>
<span> <span>
<Link href={`/${item.user.name}`}> <UserPopover name={item.user.name}>
@{item.user.name}<span> </span><Hat className='fill-grey' user={item.user} height={12} width={12} /> <Link href={`/${item.user.name}`}>
{embellishUser} @{item.user.name}<span> </span><Hat className='fill-grey' user={item.user} height={12} width={12} />
</Link> {embellishUser}
</Link>
</UserPopover>
<span> </span> <span> </span>
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning> <Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
{timeSince(new Date(item.createdAt))} {timeSince(new Date(item.createdAt))}

View File

@ -126,14 +126,14 @@ export function useServiceWorkerLogger () {
const WalletLoggerContext = createContext() const WalletLoggerContext = createContext()
const WalletLogsContext = createContext() const WalletLogsContext = createContext()
const initIndexedDB = async (storeName) => { const initIndexedDB = async (dbName, storeName) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!window.indexedDB) { if (!window.indexedDB) {
return reject(new Error('IndexedDB not supported')) return reject(new Error('IndexedDB not supported'))
} }
// https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB // https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
const request = window.indexedDB.open('app:storage', 1) const request = window.indexedDB.open(dbName, 1)
let db let db
request.onupgradeneeded = () => { request.onupgradeneeded = () => {
@ -159,7 +159,12 @@ const initIndexedDB = async (storeName) => {
} }
const WalletLoggerProvider = ({ children }) => { const WalletLoggerProvider = ({ children }) => {
const me = useMe()
const [logs, setLogs] = useState([]) const [logs, setLogs] = useState([])
let dbName = 'app:storage'
if (me) {
dbName = `${dbName}:${me.id}`
}
const idbStoreName = 'wallet_logs' const idbStoreName = 'wallet_logs'
const idb = useRef() const idb = useRef()
const logQueue = useRef([]) const logQueue = useRef([])
@ -211,7 +216,7 @@ const WalletLoggerProvider = ({ children }) => {
}, []) }, [])
useEffect(() => { useEffect(() => {
initIndexedDB(idbStoreName) initIndexedDB(dbName, idbStoreName)
.then(db => { .then(db => {
idb.current = db idb.current = db

View File

@ -23,6 +23,7 @@ import classNames from 'classnames'
import SnIcon from '@/svgs/sn.svg' import SnIcon from '@/svgs/sn.svg'
import { useHasNewNotes } from '../use-has-new-notes' import { useHasNewNotes } from '../use-has-new-notes'
import { useWalletLogger } from '../logger' import { useWalletLogger } from '../logger'
import { useWebLNConfigurator } from '../webln'
export function Brand ({ className }) { export function Brand ({ className }) {
return ( return (
@ -162,8 +163,6 @@ export function NavWalletSummary ({ className }) {
export function MeDropdown ({ me, dropNavKey }) { export function MeDropdown ({ me, dropNavKey }) {
if (!me) return null if (!me) return null
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const { deleteLogs } = useWalletLogger()
return ( return (
<div className='position-relative'> <div className='position-relative'>
<Dropdown className={styles.dropdown} align='end'> <Dropdown className={styles.dropdown} align='end'>
@ -202,22 +201,7 @@ export function MeDropdown ({ me, dropNavKey }) {
</Link> </Link>
</div> </div>
<Dropdown.Divider /> <Dropdown.Divider />
<Dropdown.Item <LogoutDropdownItem />
onClick={async () => {
// order is important because we need to be logged in to delete push subscription on server
const pushSubscription = await swRegistration?.pushManager.getSubscription()
if (pushSubscription) {
// don't prevent signout because of an unsubscription error
await togglePushSubscription().catch(console.error)
}
// delete client wallet logs to prevent leak of private data if a shared device was used
await deleteLogs(Wallet.NWC).catch(console.error)
await deleteLogs(Wallet.LNbits).catch(console.error)
await deleteLogs(Wallet.LNC).catch(console.error)
await signOut({ callbackUrl: '/' })
}}
>logout
</Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
{!me.bioId && {!me.bioId &&
@ -271,6 +255,31 @@ export default function LoginButton ({ className }) {
) )
} }
export function LogoutDropdownItem () {
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const webLN = useWebLNConfigurator()
const { deleteLogs } = useWalletLogger()
return (
<Dropdown.Item
onClick={async () => {
// order is important because we need to be logged in to delete push subscription on server
const pushSubscription = await swRegistration?.pushManager.getSubscription()
if (pushSubscription) {
await togglePushSubscription().catch(console.error)
}
// detach wallets
await webLN.clearConfig().catch(console.error)
// delete client wallet logs to prevent leak of private data if a shared device was used
await deleteLogs(Wallet.NWC).catch(console.error)
await deleteLogs(Wallet.LNbits).catch(console.error)
await deleteLogs(Wallet.LNC).catch(console.error)
await signOut({ callbackUrl: '/' })
}}
>logout
</Dropdown.Item>
)
}
export function LoginButtons () { export function LoginButtons () {
return ( return (
<> <>

View File

@ -52,11 +52,11 @@ export default function BottomBar ({ sub }) {
<div className={classNames(styles.footer, styles.footerPadding)}> <div className={classNames(styles.footer, styles.footerPadding)}>
<Navbar className='container px-0'> <Navbar className='container px-0'>
<Nav className={styles.footerNav}> <Nav className={styles.footerNav}>
<Offcanvas me={me} {...props} />
<SearchItem {...props} />
<Brand /> <Brand />
<NavNotifications /> <SearchItem {...props} />
<PostItem {...props} className='btn-sm' /> <PostItem {...props} className='btn-sm' />
<NavNotifications />
<Offcanvas me={me} {...props} />
</Nav> </Nav>
</Navbar> </Navbar>
</div> </div>

View File

@ -2,9 +2,7 @@ import { useState } from 'react'
import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap' import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap'
import { MEDIA_URL } from '@/lib/constants' import { MEDIA_URL } from '@/lib/constants'
import Link from 'next/link' import Link from 'next/link'
import { useServiceWorker } from '@/components/serviceworker' import { LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
import { signOut } from 'next-auth/react'
import { LoginButtons, NavWalletSummary } from '../common'
import AnonIcon from '@/svgs/spy-fill.svg' import AnonIcon from '@/svgs/spy-fill.svg'
import styles from './footer.module.css' import styles from './footer.module.css'
import classNames from 'classnames' import classNames from 'classnames'
@ -14,7 +12,6 @@ export default function OffCanvas ({ me, dropNavKey }) {
const handleClose = () => setShow(false) const handleClose = () => setShow(false)
const handleShow = () => setShow(true) const handleShow = () => setShow(true)
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
const MeImage = ({ onClick }) => me const MeImage = ({ onClick }) => me
? ( ? (
@ -31,7 +28,7 @@ export default function OffCanvas ({ me, dropNavKey }) {
<> <>
<MeImage onClick={handleShow} /> <MeImage onClick={handleShow} />
<Offcanvas style={{ maxWidth: '250px', zIndex: '10000' }} show={show} onHide={handleClose} placement='start'> <Offcanvas style={{ maxWidth: '250px', zIndex: '10000' }} show={show} onHide={handleClose} placement='end'>
<Offcanvas.Header closeButton> <Offcanvas.Header closeButton>
<Offcanvas.Title><NavWalletSummary /></Offcanvas.Title> <Offcanvas.Title><NavWalletSummary /></Offcanvas.Title>
</Offcanvas.Header> </Offcanvas.Header>
@ -78,22 +75,7 @@ export default function OffCanvas ({ me, dropNavKey }) {
</Link> </Link>
</div> </div>
<Dropdown.Divider /> <Dropdown.Divider />
<Dropdown.Item <LogoutDropdownItem />
onClick={async () => {
try {
// order is important because we need to be logged in to delete push subscription on server
const pushSubscription = await swRegistration?.pushManager.getSubscription()
if (pushSubscription) {
await togglePushSubscription()
}
} catch (err) {
// don't prevent signout because of an unsubscription error
console.error(err)
}
await signOut({ callbackUrl: '/' })
}}
>logout
</Dropdown.Item>
</> </>
) )
: <LoginButtons />} : <LoginButtons />}

View File

@ -21,6 +21,7 @@ import { useRouter } from 'next/router'
import Link from 'next/link' import Link from 'next/link'
import { UNKNOWN_LINK_REL } from '@/lib/constants' import { UNKNOWN_LINK_REL } from '@/lib/constants'
import isEqual from 'lodash/isEqual' import isEqual from 'lodash/isEqual'
import UserPopover from './user-popover'
export function SearchText ({ text }) { export function SearchText ({ text }) {
return ( return (
@ -196,7 +197,18 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
</Link> </Link>
) )
} }
if (href.startsWith('/') || url?.origin === internalURL) { if (text.startsWith?.('@')) {
return (
<UserPopover name={text.replace('@', '')}>
<Link
id={props.id}
href={href}
>
{text}
</Link>
</UserPopover>
)
} else if (href.startsWith('/') || url?.origin === internalURL) {
return ( return (
<Link <Link
id={props.id} id={props.id}

View File

@ -11,6 +11,7 @@ import Hat from './hat'
import { useMe } from './me' import { useMe } from './me'
import { MEDIA_URL } from '@/lib/constants' import { MEDIA_URL } from '@/lib/constants'
import { NymActionDropdown } from '@/components/user-header' import { NymActionDropdown } from '@/components/user-header'
import classNames from 'classnames'
// all of this nonsense is to show the stat we are sorting by first // all of this nonsense is to show the stat we are sorting by first
const Stacked = ({ user }) => (user.optional.stacked !== null && <span>{abbrNum(user.optional.stacked)} stacked</span>) const Stacked = ({ user }) => (user.optional.stacked !== null && <span>{abbrNum(user.optional.stacked)} stacked</span>)
@ -39,7 +40,29 @@ function seperate (arr, seperator) {
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, seperator] : [x]) return arr.flatMap((x, i) => i < arr.length - 1 ? [x, seperator] : [x])
} }
function User ({ user, rank, statComps, Embellish, nymActionDropdown = false }) { export function UserBase ({ user, className, children, nymActionDropdown }) {
return (
<div className={classNames(styles.item, className)}>
<Link href={`/${user.name}`}>
<Image
src={user.photoId ? `${MEDIA_URL}/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
className={`${userStyles.userimg} me-2`}
/>
</Link>
<div className={styles.hunk}>
<div className='d-flex'>
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
@{user.name}<Hat className='ms-1 fill-grey' height={14} width={14} user={user} />
</Link>
{nymActionDropdown && <NymActionDropdown user={user} className='' />}
</div>
{children}
</div>
</div>
)
}
export function User ({ user, rank, statComps, className = 'mb-2', Embellish, nymActionDropdown = false }) {
const me = useMe() const me = useMe()
const showStatComps = statComps && statComps.length > 0 const showStatComps = statComps && statComps.length > 0
return ( return (
@ -50,27 +73,13 @@ function User ({ user, rank, statComps, Embellish, nymActionDropdown = false })
{rank} {rank}
</div>) </div>)
: <div />} : <div />}
<div className={`${styles.item} ${me?.id === user.id && me.privates?.hideFromTopUsers ? userStyles.hidden : 'mb-2'}`}> <UserBase user={user} nymActionDropdown={nymActionDropdown} className={(me?.id === user.id && me.privates?.hideFromTopUsers) ? userStyles.hidden : 'mb-2'}>
<Link href={`/${user.name}`}> {showStatComps &&
<Image <div className={styles.other}>
src={user.photoId ? `${MEDIA_URL}/${user.photoId}` : '/dorian400.jpg'} width='32' height='32' {statComps.map((Comp, i) => <Comp key={i} user={user} />)}
className={`${userStyles.userimg} me-2`} </div>}
/> {Embellish && <Embellish rank={rank} />}
</Link> </UserBase>
<div className={`${styles.hunk} ${!showStatComps && 'd-flex flex-column justify-content-around'}`}>
<div className='d-flex'>
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
@{user.name}<Hat className='ms-1 fill-grey' height={14} width={14} user={user} />
</Link>
{nymActionDropdown && <NymActionDropdown user={user} className='' />}
</div>
{showStatComps &&
<div className={styles.other}>
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
</div>}
{Embellish && <Embellish rank={rank} />}
</div>
</div>
</> </>
) )
} }
@ -152,23 +161,31 @@ export function UsersSkeleton () {
return ( return (
<div>{users.map((_, i) => ( <div>{users.map((_, i) => (
<div className={`${styles.item} ${styles.skeleton} mb-2`} key={i}> <UserSkeleton key={i} className='mb-2'>
<Image <div className={styles.other}>
src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/clouds.jpeg`} <span className={`${styles.otherItem} clouds`} />
width='32' height='32' <span className={`${styles.otherItem} clouds`} />
className={`${userStyles.userimg} clouds me-2`} <span className={`${styles.otherItem} ${styles.otherItemLonger} clouds`} />
/> <span className={`${styles.otherItem} ${styles.otherItemLonger} clouds`} />
<div className={styles.hunk}>
<div className={`${styles.name} clouds text-reset`} />
<div className={styles.other}>
<span className={`${styles.otherItem} clouds`} />
<span className={`${styles.otherItem} clouds`} />
<span className={`${styles.otherItem} ${styles.otherItemLonger} clouds`} />
<span className={`${styles.otherItem} ${styles.otherItemLonger} clouds`} />
</div>
</div> </div>
</div> </UserSkeleton>
))} ))}
</div> </div>
) )
} }
export function UserSkeleton ({ children, className }) {
return (
<div className={`${styles.item} ${styles.skeleton} ${className}`}>
<Image
src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/clouds.jpeg`}
width='32' height='32'
className={`${userStyles.userimg} clouds me-2`}
/>
<div className={styles.hunk}>
<div className={`${styles.name} clouds text-reset`} />
{children}
</div>
</div>
)
}

View File

@ -0,0 +1,82 @@
import { USER } from '@/fragments/users'
import errorStyles from '@/styles/error.module.css'
import { useLazyQuery } from '@apollo/client'
import Link from 'next/link'
import { useRef, useState } from 'react'
import { Popover } from 'react-bootstrap'
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
import { UserBase, UserSkeleton } from './user-list'
import styles from './user-popover.module.css'
import classNames from 'classnames'
function StackingSince ({ since }) {
return (
<small className='text-muted d-flex-inline'>
stacking since:{' '}
{since
? <Link href={`/items/${since}`}>#{since}</Link>
: <span>never</span>}
</small>
)
}
export default function UserPopover ({ name, children }) {
const [showOverlay, setShowOverlay] = useState(false)
const [getUser, { loading, data }] = useLazyQuery(
USER,
{
variables: { name },
fetchPolicy: 'cache-first'
}
)
const timeoutId = useRef(null)
const handleMouseEnter = () => {
clearTimeout(timeoutId.current)
getUser()
timeoutId.current = setTimeout(() => {
setShowOverlay(true)
}, 500)
}
const handleMouseLeave = () => {
clearTimeout(timeoutId.current)
timeoutId.current = setTimeout(() => setShowOverlay(false), 100)
}
return (
<OverlayTrigger
show={showOverlay}
placement='bottom'
onHide={handleMouseLeave}
overlay={
<Popover
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={styles.userPopover}
>
<Popover.Body className={styles.userPopBody}>
{!data || loading
? <UserSkeleton />
: !data.user
? <h1 className={classNames(errorStyles.status, errorStyles.describe)}>USER NOT FOUND</h1>
: (
<UserBase user={data.user} className='mb-0 pb-0'>
<StackingSince since={data.user.since} />
</UserBase>
)}
</Popover.Body>
</Popover>
}
>
<span
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
</span>
</OverlayTrigger>
)
}

View File

@ -0,0 +1,8 @@
.userPopover {
border: 1px solid var(--theme-toolbarActive)
}
.userPopBody {
font-weight: 500;
font-size: 0.9rem;
}

View File

@ -33,6 +33,15 @@ export const Status = {
Error: 'Error' Error: 'Error'
} }
export function migrateLocalStorage (oldStorageKey, newStorageKey) {
const item = window.localStorage.getItem(oldStorageKey)
if (item) {
window.localStorage.setItem(newStorageKey, item)
window.localStorage.removeItem(oldStorageKey)
}
return item
}
function RawWebLNProvider ({ children }) { function RawWebLNProvider ({ children }) {
const lnbits = useLNbits() const lnbits = useLNbits()
const nwc = useNWC() const nwc = useNWC()
@ -114,8 +123,14 @@ function RawWebLNProvider ({ children }) {
}) })
}, [setEnabledProviders]) }, [setEnabledProviders])
const clearConfig = useCallback(async () => {
lnbits.clearConfig()
nwc.clearConfig()
await lnc.clearConfig()
}, [])
return ( return (
<WebLNContext.Provider value={{ provider: isEnabled(provider) ? { sendPayment: sendPaymentWithToast } : null, enabledProviders, setProvider }}> <WebLNContext.Provider value={{ provider: isEnabled(provider) ? { sendPayment: sendPaymentWithToast } : null, enabledProviders, setProvider, clearConfig }}>
{children} {children}
</WebLNContext.Provider> </WebLNContext.Provider>
) )

View File

@ -1,8 +1,9 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { useWalletLogger } from '../logger' import { useWalletLogger } from '../logger'
import { Status } from '.' import { Status, migrateLocalStorage } from '.'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import { Wallet } from '@/lib/constants' import { Wallet } from '@/lib/constants'
import { useMe } from '../me'
// Reference: https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts // Reference: https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts
@ -65,13 +66,17 @@ const getPayment = async (baseUrl, adminKey, paymentHash) => {
} }
export function LNbitsProvider ({ children }) { export function LNbitsProvider ({ children }) {
const me = useMe()
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [adminKey, setAdminKey] = useState('') const [adminKey, setAdminKey] = useState('')
const [status, setStatus] = useState() const [status, setStatus] = useState()
const { logger } = useWalletLogger(Wallet.LNbits) const { logger } = useWalletLogger(Wallet.LNbits)
const name = 'LNbits' const name = 'LNbits'
const storageKey = 'webln:provider:lnbits' let storageKey = 'webln:provider:lnbits'
if (me) {
storageKey = `${storageKey}:${me.id}`
}
const getInfo = useCallback(async () => { const getInfo = useCallback(async () => {
const response = await getWallet(url, adminKey) const response = await getWallet(url, adminKey)
@ -110,11 +115,18 @@ export function LNbitsProvider ({ children }) {
}, [logger, url, adminKey]) }, [logger, url, adminKey])
const loadConfig = useCallback(async () => { const loadConfig = useCallback(async () => {
const configStr = window.localStorage.getItem(storageKey) let configStr = window.localStorage.getItem(storageKey)
setStatus(Status.Initialized) setStatus(Status.Initialized)
if (!configStr) { if (!configStr) {
logger.info('no existing config found') if (me) {
return // backwards compatibility: try old storageKey
const oldStorageKey = storageKey.split(':').slice(0, -1).join(':')
configStr = migrateLocalStorage(oldStorageKey, storageKey)
}
if (!configStr) {
logger.info('no existing config found')
return
}
} }
const config = JSON.parse(configStr) const config = JSON.parse(configStr)
@ -141,7 +153,7 @@ export function LNbitsProvider ({ children }) {
logger.info('wallet disabled') logger.info('wallet disabled')
throw err throw err
} }
}, [logger]) }, [me, logger])
const saveConfig = useCallback(async (config) => { const saveConfig = useCallback(async (config) => {
// immediately store config so it's not lost even if config is invalid // immediately store config so it's not lost even if config is invalid

View File

@ -1,20 +1,23 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { useWalletLogger } from '../logger' import { useWalletLogger } from '../logger'
import LNC from '@lightninglabs/lnc-web' import LNC from '@lightninglabs/lnc-web'
import { Status } from '.' import { Status, migrateLocalStorage } from '.'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import useModal from '../modal' import useModal from '../modal'
import { Form, PasswordInput, SubmitButton } from '../form' import { Form, PasswordInput, SubmitButton } from '../form'
import CancelButton from '../cancel-button' import CancelButton from '../cancel-button'
import { Mutex } from 'async-mutex' import { Mutex } from 'async-mutex'
import { Wallet } from '@/lib/constants' import { Wallet } from '@/lib/constants'
import { useMe } from '../me'
const LNCContext = createContext() const LNCContext = createContext()
const mutex = new Mutex() const mutex = new Mutex()
async function getLNC () { async function getLNC ({ me }) {
if (window.lnc) return window.lnc if (window.lnc) return window.lnc
window.lnc = new LNC({ }) // backwards compatibility: migrate to new storage key
if (me) migrateLocalStorage('lnc-web:default', `lnc-web:${me.id}`)
window.lnc = new LNC({ namespace: me?.id })
return window.lnc return window.lnc
} }
@ -33,6 +36,7 @@ function validateNarrowPerms (lnc) {
} }
export function LNCProvider ({ children }) { export function LNCProvider ({ children }) {
const me = useMe()
const { logger } = useWalletLogger(Wallet.LNC) const { logger } = useWalletLogger(Wallet.LNC)
const [config, setConfig] = useState({}) const [config, setConfig] = useState({})
const [lnc, setLNC] = useState() const [lnc, setLNC] = useState()
@ -165,7 +169,7 @@ export function LNCProvider ({ children }) {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const lnc = await getLNC() const lnc = await getLNC({ me })
setLNC(lnc) setLNC(lnc)
setStatus(Status.Initialized) setStatus(Status.Initialized)
if (lnc.credentials.isPaired) { if (lnc.credentials.isPaired) {
@ -185,7 +189,7 @@ export function LNCProvider ({ children }) {
setStatus(Status.Error) setStatus(Status.Error)
} }
})() })()
}, [setStatus, setConfig, logger]) }, [me, setStatus, setConfig, logger])
return ( return (
<LNCContext.Provider value={{ name: 'lnc', status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig }}> <LNCContext.Provider value={{ name: 'lnc', status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig }}>

View File

@ -4,13 +4,15 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import { Relay, finalizeEvent, nip04 } from 'nostr-tools' import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
import { parseNwcUrl } from '@/lib/url' import { parseNwcUrl } from '@/lib/url'
import { useWalletLogger } from '../logger' import { useWalletLogger } from '../logger'
import { Status } from '.' import { Status, migrateLocalStorage } from '.'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import { Wallet } from '@/lib/constants' import { Wallet } from '@/lib/constants'
import { useMe } from '../me'
const NWCContext = createContext() const NWCContext = createContext()
export function NWCProvider ({ children }) { export function NWCProvider ({ children }) {
const me = useMe()
const [nwcUrl, setNwcUrl] = useState('') const [nwcUrl, setNwcUrl] = useState('')
const [walletPubkey, setWalletPubkey] = useState() const [walletPubkey, setWalletPubkey] = useState()
const [relayUrl, setRelayUrl] = useState() const [relayUrl, setRelayUrl] = useState()
@ -19,7 +21,10 @@ export function NWCProvider ({ children }) {
const { logger } = useWalletLogger(Wallet.NWC) const { logger } = useWalletLogger(Wallet.NWC)
const name = 'NWC' const name = 'NWC'
const storageKey = 'webln:provider:nwc' let storageKey = 'webln:provider:nwc'
if (me) {
storageKey = `${storageKey}:${me.id}`
}
const getInfo = useCallback(async (relayUrl, walletPubkey) => { const getInfo = useCallback(async (relayUrl, walletPubkey) => {
logger.info(`requesting info event from ${relayUrl}`) logger.info(`requesting info event from ${relayUrl}`)
@ -97,11 +102,18 @@ export function NWCProvider ({ children }) {
}, [logger]) }, [logger])
const loadConfig = useCallback(async () => { const loadConfig = useCallback(async () => {
const configStr = window.localStorage.getItem(storageKey) let configStr = window.localStorage.getItem(storageKey)
setStatus(Status.Initialized) setStatus(Status.Initialized)
if (!configStr) { if (!configStr) {
logger.info('no existing config found') if (me) {
return // backwards compatibility: try old storageKey
const oldStorageKey = storageKey.split(':').slice(0, -1).join(':')
configStr = migrateLocalStorage(oldStorageKey, storageKey)
}
if (!configStr) {
logger.info('no existing config found')
return
}
} }
const config = JSON.parse(configStr) const config = JSON.parse(configStr)
@ -130,7 +142,7 @@ export function NWCProvider ({ children }) {
logger.info('wallet disabled') logger.info('wallet disabled')
throw err throw err
} }
}, [validateParams, logger]) }, [me, validateParams, logger])
const saveConfig = useCallback(async (config) => { const saveConfig = useCallback(async (config) => {
// immediately store config so it's not lost even if config is invalid // immediately store config so it's not lost even if config is invalid

View File

@ -7,4 +7,5 @@ bitcoinplebdev
benthecarman benthecarman
stargut stargut
mz mz
btcbagehot btcbagehot
felipe

View File

@ -397,6 +397,8 @@ services:
- '--autopilot.disable' - '--autopilot.disable'
- '--pool.auctionserver=test.pool.lightning.finance:12010' - '--pool.auctionserver=test.pool.lightning.finance:12010'
- '--loop.server.host=test.swap.lightning.today:11010' - '--loop.server.host=test.swap.lightning.today:11010'
labels:
CONNECT: "localhost:8443"
stacker_cln: stacker_cln:
build: build:
context: ./docker/cln context: ./docker/cln
@ -466,6 +468,8 @@ services:
- "1025:1025" - "1025:1025"
links: links:
- app - app
labels:
CONNECT: "localhost:8025"
volumes: volumes:
db: db:
os: os:

View File

@ -1,10 +1,27 @@
import fetch from 'cross-fetch' import fetch from 'cross-fetch'
import https from 'https' import https from 'https'
import crypto from 'crypto' import crypto from 'crypto'
import { HttpProxyAgent, HttpsProxyAgent } from '@/lib/proxy'
export const createInvoice = async ({ socket, rune, cert, label, description, msats, expiry }) => { export const createInvoice = async ({ socket, rune, cert, label, description, msats, expiry }) => {
const agent = cert ? new https.Agent({ ca: Buffer.from(cert, 'base64') }) : undefined let protocol, agent
const url = 'https://' + socket + '/v1/invoice' const httpsAgentOptions = { ca: cert ? Buffer.from(cert, 'base64') : undefined }
const isOnion = /\.onion(:[0-9]+)?$/.test(socket)
if (isOnion) {
// we support HTTP and HTTPS over Tor
protocol = cert ? 'https:' : 'http:'
// we need to use our Tor proxy to resolve onion addresses
const proxyOptions = { proxy: 'http://127.0.0.1:7050/' }
agent = protocol === 'https:'
? new HttpsProxyAgent({ ...proxyOptions, ...httpsAgentOptions })
: new HttpProxyAgent(proxyOptions)
} else {
// we only support HTTPS over clearnet
agent = new https.Agent(httpsAgentOptions)
protocol = 'https:'
}
const url = `${protocol}//${socket}/v1/invoice`
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {

9
lib/crypto.js Normal file
View File

@ -0,0 +1,9 @@
import { createHash } from 'node:crypto'
export function hashEmail ({
email,
salt = process.env.EMAIL_SALT
}) {
const saltedEmail = `${email.toLowerCase()}${salt}`
return createHash('sha256').update(saltedEmail).digest('hex')
}

120
lib/proxy.js Normal file
View File

@ -0,0 +1,120 @@
import http from 'http'
import https from 'https'
// from https://github.com/delvedor/hpagent
export class HttpProxyAgent extends http.Agent {
constructor (options) {
const { proxy, proxyRequestOptions, ...opts } = options
super(opts)
this.proxy = typeof proxy === 'string'
? new URL(proxy)
: proxy
this.proxyRequestOptions = proxyRequestOptions || {}
}
createConnection (options, callback) {
const requestOptions = {
...this.proxyRequestOptions,
method: 'CONNECT',
host: this.proxy.hostname,
port: this.proxy.port,
path: `${options.host}:${options.port}`,
setHost: false,
headers: { ...this.proxyRequestOptions.headers, connection: this.keepAlive ? 'keep-alive' : 'close', host: `${options.host}:${options.port}` },
agent: false,
timeout: options.timeout || 0
}
if (this.proxy.username || this.proxy.password) {
const base64 = Buffer.from(`${decodeURIComponent(this.proxy.username || '')}:${decodeURIComponent(this.proxy.password || '')}`).toString('base64')
requestOptions.headers['proxy-authorization'] = `Basic ${base64}`
}
if (this.proxy.protocol === 'https:') {
requestOptions.servername = this.proxy.hostname
}
const request = (this.proxy.protocol === 'http:' ? http : https).request(requestOptions)
request.once('connect', (response, socket, head) => {
request.removeAllListeners()
socket.removeAllListeners()
if (response.statusCode === 200) {
callback(null, socket)
} else {
socket.destroy()
callback(new Error(`Bad response: ${response.statusCode}`), null)
}
})
request.once('timeout', () => {
request.destroy(new Error('Proxy timeout'))
})
request.once('error', err => {
request.removeAllListeners()
callback(err, null)
})
request.end()
}
}
export class HttpsProxyAgent extends https.Agent {
constructor (options) {
const { proxy, proxyRequestOptions, ...opts } = options
super(opts)
this.proxy = typeof proxy === 'string'
? new URL(proxy)
: proxy
this.proxyRequestOptions = proxyRequestOptions || {}
}
createConnection (options, callback) {
const requestOptions = {
...this.proxyRequestOptions,
method: 'CONNECT',
host: this.proxy.hostname,
port: this.proxy.port,
path: `${options.host}:${options.port}`,
setHost: false,
headers: { ...this.proxyRequestOptions.headers, connection: this.keepAlive ? 'keep-alive' : 'close', host: `${options.host}:${options.port}` },
agent: false,
timeout: options.timeout || 0
}
if (this.proxy.username || this.proxy.password) {
const base64 = Buffer.from(`${decodeURIComponent(this.proxy.username || '')}:${decodeURIComponent(this.proxy.password || '')}`).toString('base64')
requestOptions.headers['proxy-authorization'] = `Basic ${base64}`
}
// Necessary for the TLS check with the proxy to succeed.
if (this.proxy.protocol === 'https:') {
requestOptions.servername = this.proxy.hostname
}
const request = (this.proxy.protocol === 'http:' ? http : https).request(requestOptions)
request.once('connect', (response, socket, head) => {
request.removeAllListeners()
socket.removeAllListeners()
if (response.statusCode === 200) {
const secureSocket = super.createConnection({ ...options, socket })
callback(null, secureSocket)
} else {
socket.destroy()
callback(new Error(`Bad response: ${response.statusCode}`), null)
}
})
request.once('timeout', () => {
request.destroy(new Error('Proxy timeout'))
})
request.once('error', err => {
request.removeAllListeners()
callback(err, null)
})
request.end()
}
}

View File

@ -77,7 +77,7 @@ export function BioForm ({ handleDone, bio }) {
export function UserLayout ({ user, children, containClassName }) { export function UserLayout ({ user, children, containClassName }) {
return ( return (
<Layout user={user} footer={false} containClassName={containClassName}> <Layout user={user} footer footerLinks={false} containClassName={containClassName}>
<UserHeader user={user} /> <UserHeader user={user} />
{children} {children}
</Layout> </Layout>

View File

@ -11,6 +11,7 @@ import { getToken } from 'next-auth/jwt'
import { NodeNextRequest } from 'next/dist/server/base-http/node' import { NodeNextRequest } from 'next/dist/server/base-http/node'
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'
/** /**
* Stores userIds in user table * Stores userIds in user table
@ -71,24 +72,6 @@ function getCallbacks (req) {
token.sub = Number(token.id) token.sub = Number(token.id)
} }
// sign them up for the newsletter
if (isNewUser && user?.email && process.env.LIST_MONK_URL && process.env.LIST_MONK_AUTH) {
fetch(process.env.LIST_MONK_URL + '/api/subscribers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64')
},
body: JSON.stringify({
email: user.email,
name: 'blank',
lists: [2],
status: 'enabled',
preconfirm_subscriptions: true
})
}).then(async r => console.log(await r.json())).catch(console.log)
}
return token return token
}, },
async session ({ session, token }) { async session ({ session, token }) {
@ -217,7 +200,49 @@ const providers = [
export const getAuthOptions = req => ({ export const getAuthOptions = req => ({
callbacks: getCallbacks(req), callbacks: getCallbacks(req),
providers, providers,
adapter: PrismaAdapter(prisma), adapter: {
...PrismaAdapter(prisma),
createUser: data => {
// replace email with email hash in new user payload
if (data.email) {
const { email } = data
data.emailHash = hashEmail({ email })
delete data.email
// data.email used to be used for name of new accounts. since it's missing, let's generate a new name
data.name = data.emailHash.substring(0, 10)
// sign them up for the newsletter
// don't await it, let it run async
enrollInNewsletter({ email })
}
return prisma.user.create({ data })
},
getUserByEmail: async email => {
const hashedEmail = hashEmail({ email })
let user = await prisma.user.findUnique({
where: {
// lookup by email hash since we don't store plaintext emails any more
emailHash: hashedEmail
}
})
if (!user) {
user = await prisma.user.findUnique({
where: {
// lookup by email as a fallback in case a user attempts to login by email during the migration
// and their email hasn't been migrated yet
email
}
})
}
// HACK! This is required to satisfy next-auth's check here:
// https://github.com/nextauthjs/next-auth/blob/5b647e1ac040250ad055e331ba97f8fa461b63cc/packages/next-auth/src/core/routes/callback.ts#L227
// since we are nulling `email`, but it expects it to be truthy there.
// Since we have the email from the input request, we can copy it here and pretend like we store user emails, even though we don't.
if (user) {
user.email = email
}
return user
}
},
session: { session: {
strategy: 'jwt' strategy: 'jwt'
}, },
@ -229,6 +254,34 @@ export const getAuthOptions = req => ({
events: getEventCallbacks() events: getEventCallbacks()
}) })
async function enrollInNewsletter ({ email }) {
if (process.env.LIST_MONK_URL && process.env.LIST_MONK_AUTH) {
try {
const response = await fetch(process.env.LIST_MONK_URL + '/api/subscribers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Basic ' + Buffer.from(process.env.LIST_MONK_AUTH).toString('base64')
},
body: JSON.stringify({
email,
name: 'blank',
lists: [2],
status: 'enabled',
preconfirm_subscriptions: true
})
})
const jsonResponse = await response.json()
console.log(jsonResponse)
} catch (err) {
console.log('error signing user up for newsletter')
console.log(err)
}
} else {
console.log('LIST MONK env vars not set, skipping newsletter enrollment')
}
}
export default async (req, res) => { export default async (req, res) => {
await NextAuth(req, res, getAuthOptions(req)) await NextAuth(req, res, getAuthOptions(req))
} }
@ -238,7 +291,21 @@ async function sendVerificationRequest ({
url, url,
provider provider
}) { }) {
const user = await prisma.user.findUnique({ where: { email } }) let user = await prisma.user.findUnique({
where: {
// Look for the user by hashed email
emailHash: hashEmail({ email })
}
})
if (!user) {
user = await prisma.user.findUnique({
where: {
// or plaintext email, in case a user tries to login via email during the migration
// before their particular record has been migrated
email
}
})
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const { server, from } = provider const { server, from } = provider

View File

@ -57,7 +57,7 @@ export default startServerAndCreateNextHandler(apolloServer, {
let session let session
if (apiKey) { if (apiKey) {
const [user] = await models.$queryRaw` const [user] = await models.$queryRaw`
SELECT id, name, email, "apiKeyEnabled" SELECT id, name, "apiKeyEnabled"
FROM users FROM users
WHERE "apiKeyHash" = encode(digest(${apiKey}, 'sha256'), 'hex') WHERE "apiKeyHash" = encode(digest(${apiKey}, 'sha256'), 'hex')
LIMIT 1` LIMIT 1`

View File

@ -71,7 +71,7 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
} }
// generate invoice // generate invoice
const expiresAt = datePivot(new Date(), { minutes: 1 }) const expiresAt = datePivot(new Date(), { minutes: 5 })
const invoice = await createInvoice({ const invoice = await createInvoice({
description, description,
description_hash: descriptionHash, description_hash: descriptionHash,

View File

@ -714,15 +714,8 @@ function AuthMethods ({ methods, apiKeyEnabled }) {
return methods.email return methods.email
? ( ? (
<div key={provider} className='mt-2 d-flex align-items-center'> <div key={provider} className='mt-2 d-flex align-items-center'>
<Input
name='email'
placeholder={methods.email}
groupClassName='mb-0'
readOnly
noForm
/>
<Button <Button
className='ms-2' variant='secondary' onClick={ variant='secondary' onClick={
async () => { async () => {
await unlink('email') await unlink('email')
} }

View File

@ -0,0 +1,43 @@
/*
Warnings:
- A unique constraint covering the columns `[emailHash]` on the table `users` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "users" ADD COLUMN "emailHash" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "users.email_hash_unique" ON "users"("emailHash");
-- hack ... prisma doesn't know about our other schemas (e.g. pgboss)
-- and this is only really a problem on their "shadow database"
-- so we catch the exception it throws and ignore it
CREATE OR REPLACE FUNCTION submit_migrate_existing_user_emails_job() RETURNS void AS $$
BEGIN
-- Submit a job to salt and hash emails after the updated worker has spun-up
INSERT INTO pgboss.job (name, data, priority, startafter, expirein)
SELECT 'saltAndHashEmails', jsonb_build_object(), -100, now() + interval '10 minutes', interval '1 day';
EXCEPTION WHEN OTHERS THEN
-- catch the exception for prisma dev execution, but do nothing with it
END;
$$ LANGUAGE plpgsql;
-- execute the function once to submit the one-time job
SELECT submit_migrate_existing_user_emails_job();
-- then drop it since we don't need it anymore
DROP FUNCTION submit_migrate_existing_user_emails_job();
-- function that accepts a salt and migrates all existing emails using the salt then hashing the salted email
CREATE OR REPLACE FUNCTION migrate_existing_user_emails(salt TEXT) RETURNS void AS $$
BEGIN
UPDATE "users"
SET "emailHash" = encode(digest(LOWER("email") || salt, 'sha256'), 'hex')
WHERE "email" IS NOT NULL;
-- then wipe the email values
UPDATE "users"
SET email = NULL;
END;
$$ LANGUAGE plpgsql;

View File

@ -19,6 +19,7 @@ model User {
name String? @unique(map: "users.name_unique") @db.Citext name String? @unique(map: "users.name_unique") @db.Citext
email String? @unique(map: "users.email_unique") email String? @unique(map: "users.email_unique")
emailVerified DateTime? @map("email_verified") emailVerified DateTime? @map("email_verified")
emailHash String? @unique(map: "users.email_hash_unique")
image String? image String?
msats BigInt @default(0) msats BigInt @default(0)
freeComments Int @default(5) freeComments Int @default(5)
@ -162,7 +163,7 @@ model Wallet {
} }
model WalletLog { model WalletLog {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
userId Int userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)

42
sndev
View File

@ -106,6 +106,39 @@ OPTIONS"
docker__compose down --help | awk '/Options:/{y=1;next}y' docker__compose down --help | awk '/Options:/{y=1;next}y'
} }
sndev__open() {
shift
service=$(docker__compose ps $1 --format '{{.Label "CONNECT"}}')
if [ -z "$service" ]; then
echo "no url found for $1"
exit 1
fi
service="http://$service"
echo "opening $1 ... $service"
if [ "$(uname)" = "Darwin" ]; then
open $service
elif [ "$(uname)" = "Linux" ]; then
xdg-open $service
elif [ "$(uname)" = "Windows_NT" ]; then
start $service
fi
}
sndev__help_open() {
help="
open a container's url if it has one
USAGE
$ sndev open SERVICE
OPTIONS
no options currently exist
"
echo "$help"
}
sndev__restart() { sndev__restart() {
shift shift
docker__compose restart "$@" docker__compose restart "$@"
@ -418,14 +451,16 @@ sndev__login() {
# "SNDEV-TOKEN3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI" # "SNDEV-TOKEN3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI"
# next-auth concats the token with the secret from env and then sha256's it # next-auth concats the token with the secret from env and then sha256's it
token="d5fce54babffcb070c39f78d947761fd9ec37647fafcecb9734a3085a78e5c5e" token="d5fce54babffcb070c39f78d947761fd9ec37647fafcecb9734a3085a78e5c5e"
salt="202c90943c313b829e65e3f29164fb5dd7ea3370d7262c4159691c2f6493bb8b"
# upsert user with nym and nym@sndev.team # upsert user with nym and nym@sndev.team
email="$1@sndev.team"
docker__exec db psql -U sn -d stackernews -q <<EOF docker__exec db psql -U sn -d stackernews -q <<EOF
INSERT INTO users (name) VALUES ('$1') ON CONFLICT DO NOTHING; INSERT INTO users (name) VALUES ('$1') ON CONFLICT DO NOTHING;
UPDATE users SET email = '$1@sndev.team' WHERE name = '$1'; UPDATE users SET email = '$email', "emailHash" = encode(digest(LOWER('$email')||'$salt', 'sha256'), 'hex') WHERE name = '$1';
INSERT INTO verification_requests (identifier, token, expires) INSERT INTO verification_requests (identifier, token, expires)
VALUES ('$1@sndev.team', '$token', NOW() + INTERVAL '1 day') VALUES ('$email', '$token', NOW() + INTERVAL '1 day')
ON CONFLICT (token) DO UPDATE ON CONFLICT (token) DO UPDATE
SET identifier = '$1@sndev.team', expires = NOW() + INTERVAL '1 day'; SET identifier = '$email', expires = NOW() + INTERVAL '1 day';
EOF EOF
echo echo
@ -496,6 +531,7 @@ COMMANDS
dev: dev:
pr fetch and checkout a pr pr fetch and checkout a pr
lint run linters lint run linters
open open container url in browser
other: other:
compose docker compose passthrough compose docker compose passthrough

View File

@ -9,6 +9,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
display: flex; display: flex;
padding: 2.5rem 0 1rem 0;
} }
.createFormContainer { .createFormContainer {

View File

@ -23,6 +23,7 @@ import { deleteUnusedImages } from './deleteUnusedImages.js'
import { territoryBilling, territoryRevenue } from './territory.js' import { territoryBilling, territoryRevenue } from './territory.js'
import { ofac } from './ofac.js' import { ofac } from './ofac.js'
import { autoWithdraw } from './autowithdraw.js' import { autoWithdraw } from './autowithdraw.js'
import { saltAndHashEmails } from './saltAndHashEmails.js'
const { loadEnvConfig } = nextEnv const { loadEnvConfig } = nextEnv
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
@ -100,6 +101,7 @@ async function work () {
await boss.work('territoryBilling', jobWrapper(territoryBilling)) await boss.work('territoryBilling', jobWrapper(territoryBilling))
await boss.work('territoryRevenue', jobWrapper(territoryRevenue)) await boss.work('territoryRevenue', jobWrapper(territoryRevenue))
await boss.work('ofac', jobWrapper(ofac)) await boss.work('ofac', jobWrapper(ofac))
await boss.work('saltAndHashEmails', jobWrapper(saltAndHashEmails))
console.log('working jobs') console.log('working jobs')
} }

View File

@ -0,0 +1,9 @@
export async function saltAndHashEmails ({ models }) {
try {
console.log('Migrating existing emails to salt and hash them...')
await models.$executeRaw`select migrate_existing_user_emails(${process.env.EMAIL_SALT})`
console.log('Successfully migrated existing emails to salt and hash them!')
} catch (err) {
console.error('Error occurred while salting and hashing existing emails:', err)
}
}