Compare commits
14 Commits
cdeaa35ff4
...
051cb69f5e
Author | SHA1 | Date | |
---|---|---|---|
|
051cb69f5e | ||
|
ce9e146a06 | ||
|
111053006a | ||
|
bc2155c7aa | ||
|
1f2aa46319 | ||
|
1b5e513f5e | ||
|
1e3042e536 | ||
|
2e65bf9126 | ||
|
30550e48be | ||
|
15f9950477 | ||
|
6220eb06ee | ||
|
ed9fc5d3de | ||
|
5c593ce280 | ||
|
72c27e339c |
@ -36,6 +36,10 @@ LNWITH_URL=
|
||||
LOGIN_EMAIL_SERVER=smtp://mailhog:1025
|
||||
LOGIN_EMAIL_FROM=sndev@mailhog.dev
|
||||
|
||||
# email salt
|
||||
# openssl rand -hex 32
|
||||
EMAIL_SALT=202c90943c313b829e65e3f29164fb5dd7ea3370d7262c4159691c2f6493bb8b
|
||||
|
||||
# static things
|
||||
NEXTAUTH_URL=http://localhost:3000/api/auth
|
||||
SELF_URL=http://app:3000
|
||||
|
@ -9,6 +9,7 @@ import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS }
|
||||
import { viewGroup } from './growth'
|
||||
import { timeUnitForRange, whenRange } from '@/lib/time'
|
||||
import assertApiKeyNotPermitted from './apiKey'
|
||||
import { hashEmail } from '@/lib/crypto'
|
||||
|
||||
const contributors = new Set()
|
||||
|
||||
@ -44,7 +45,7 @@ async function authMethods (user, args, { models, me }) {
|
||||
|
||||
return {
|
||||
lightning: !!user.pubkey,
|
||||
email: user.emailVerified && user.email,
|
||||
email: !!(user.emailVerified && user.emailHash),
|
||||
twitter: oauth.indexOf('twitter') >= 0,
|
||||
github: oauth.indexOf('github') >= 0,
|
||||
nostr: !!user.nostrAuthPubkey,
|
||||
@ -686,7 +687,7 @@ export default {
|
||||
try {
|
||||
await models.user.update({
|
||||
where: { id: me.id },
|
||||
data: { email: email.toLowerCase() }
|
||||
data: { emailHash: hashEmail({ email }) }
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.code === 'P2002') {
|
||||
|
@ -108,7 +108,7 @@ export default gql`
|
||||
nostr: Boolean!
|
||||
github: Boolean!
|
||||
twitter: Boolean!
|
||||
email: String
|
||||
email: Boolean!
|
||||
apiKey: Boolean
|
||||
}
|
||||
|
||||
|
14
awards.csv
14
awards.csv
@ -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
|
||||
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
|
||||
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
|
|
@ -65,7 +65,7 @@ function ImageOriginal ({ src, topLevel, rel, tab, children, onClick, ...props }
|
||||
|
||||
function ImageProxy ({ src, srcSet: { dimensions, ...srcSetObj } = {}, onClick, topLevel, onError, ...props }) {
|
||||
const srcSet = useMemo(() => {
|
||||
if (!srcSetObj) return undefined
|
||||
if (Object.keys(srcSetObj).length === 0) return undefined
|
||||
// srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... }
|
||||
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
|
||||
@ -79,7 +79,7 @@ function ImageProxy ({ src, srcSet: { dimensions, ...srcSetObj } = {}, onClick,
|
||||
|
||||
// get source url in best resolution
|
||||
const bestResSrc = useMemo(() => {
|
||||
if (!srcSetObj) return src
|
||||
if (Object.keys(srcSetObj).length === 0) return src
|
||||
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url]) => {
|
||||
if (!url.startsWith('http')) {
|
||||
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
|
||||
? { '--height': `${height}px`, '--width': `${width}px`, '--aspect-ratio': `${width} / ${height}` }
|
||||
: undefined
|
||||
@ -116,7 +116,7 @@ const Image = memo(({ className, src, srcSet, sizes, width, height, bestResSrc,
|
||||
<img
|
||||
className={className}
|
||||
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
|
||||
src={bestResSrc}
|
||||
src={src}
|
||||
srcSet={srcSet}
|
||||
sizes={sizes}
|
||||
width={width}
|
||||
|
@ -21,6 +21,7 @@ import MuteDropdownItem from './mute'
|
||||
import { DropdownItemUpVote } from './upvote'
|
||||
import { useRoot } from './root'
|
||||
import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header'
|
||||
import UserPopover from './user-popover'
|
||||
|
||||
export default function ItemInfo ({
|
||||
item, full, commentsText = 'comments',
|
||||
@ -101,10 +102,12 @@ export default function ItemInfo ({
|
||||
</Link>
|
||||
<span> \ </span>
|
||||
<span>
|
||||
<Link href={`/${item.user.name}`}>
|
||||
@{item.user.name}<span> </span><Hat className='fill-grey' user={item.user} height={12} width={12} />
|
||||
{embellishUser}
|
||||
</Link>
|
||||
<UserPopover name={item.user.name}>
|
||||
<Link href={`/${item.user.name}`}>
|
||||
@{item.user.name}<span> </span><Hat className='fill-grey' user={item.user} height={12} width={12} />
|
||||
{embellishUser}
|
||||
</Link>
|
||||
</UserPopover>
|
||||
<span> </span>
|
||||
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
|
||||
{timeSince(new Date(item.createdAt))}
|
||||
|
@ -126,14 +126,14 @@ export function useServiceWorkerLogger () {
|
||||
const WalletLoggerContext = createContext()
|
||||
const WalletLogsContext = createContext()
|
||||
|
||||
const initIndexedDB = async (storeName) => {
|
||||
const initIndexedDB = async (dbName, storeName) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.indexedDB) {
|
||||
return reject(new Error('IndexedDB not supported'))
|
||||
}
|
||||
|
||||
// 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
|
||||
request.onupgradeneeded = () => {
|
||||
@ -159,7 +159,12 @@ const initIndexedDB = async (storeName) => {
|
||||
}
|
||||
|
||||
const WalletLoggerProvider = ({ children }) => {
|
||||
const me = useMe()
|
||||
const [logs, setLogs] = useState([])
|
||||
let dbName = 'app:storage'
|
||||
if (me) {
|
||||
dbName = `${dbName}:${me.id}`
|
||||
}
|
||||
const idbStoreName = 'wallet_logs'
|
||||
const idb = useRef()
|
||||
const logQueue = useRef([])
|
||||
@ -211,7 +216,7 @@ const WalletLoggerProvider = ({ children }) => {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
initIndexedDB(idbStoreName)
|
||||
initIndexedDB(dbName, idbStoreName)
|
||||
.then(db => {
|
||||
idb.current = db
|
||||
|
||||
|
@ -23,6 +23,7 @@ import classNames from 'classnames'
|
||||
import SnIcon from '@/svgs/sn.svg'
|
||||
import { useHasNewNotes } from '../use-has-new-notes'
|
||||
import { useWalletLogger } from '../logger'
|
||||
import { useWebLNConfigurator } from '../webln'
|
||||
|
||||
export function Brand ({ className }) {
|
||||
return (
|
||||
@ -162,8 +163,6 @@ export function NavWalletSummary ({ className }) {
|
||||
|
||||
export function MeDropdown ({ me, dropNavKey }) {
|
||||
if (!me) return null
|
||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||
const { deleteLogs } = useWalletLogger()
|
||||
return (
|
||||
<div className='position-relative'>
|
||||
<Dropdown className={styles.dropdown} align='end'>
|
||||
@ -202,22 +201,7 @@ export function MeDropdown ({ me, dropNavKey }) {
|
||||
</Link>
|
||||
</div>
|
||||
<Dropdown.Divider />
|
||||
<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) {
|
||||
// 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>
|
||||
<LogoutDropdownItem />
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
{!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 () {
|
||||
return (
|
||||
<>
|
||||
|
@ -52,11 +52,11 @@ export default function BottomBar ({ sub }) {
|
||||
<div className={classNames(styles.footer, styles.footerPadding)}>
|
||||
<Navbar className='container px-0'>
|
||||
<Nav className={styles.footerNav}>
|
||||
<Offcanvas me={me} {...props} />
|
||||
<SearchItem {...props} />
|
||||
<Brand />
|
||||
<NavNotifications />
|
||||
<SearchItem {...props} />
|
||||
<PostItem {...props} className='btn-sm' />
|
||||
<NavNotifications />
|
||||
<Offcanvas me={me} {...props} />
|
||||
</Nav>
|
||||
</Navbar>
|
||||
</div>
|
||||
|
@ -2,9 +2,7 @@ import { useState } from 'react'
|
||||
import { Dropdown, Image, Nav, Navbar, Offcanvas } from 'react-bootstrap'
|
||||
import { MEDIA_URL } from '@/lib/constants'
|
||||
import Link from 'next/link'
|
||||
import { useServiceWorker } from '@/components/serviceworker'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { LoginButtons, NavWalletSummary } from '../common'
|
||||
import { LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
|
||||
import AnonIcon from '@/svgs/spy-fill.svg'
|
||||
import styles from './footer.module.css'
|
||||
import classNames from 'classnames'
|
||||
@ -14,7 +12,6 @@ export default function OffCanvas ({ me, dropNavKey }) {
|
||||
|
||||
const handleClose = () => setShow(false)
|
||||
const handleShow = () => setShow(true)
|
||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||
|
||||
const MeImage = ({ onClick }) => me
|
||||
? (
|
||||
@ -31,7 +28,7 @@ export default function OffCanvas ({ me, dropNavKey }) {
|
||||
<>
|
||||
<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.Title><NavWalletSummary /></Offcanvas.Title>
|
||||
</Offcanvas.Header>
|
||||
@ -78,22 +75,7 @@ export default function OffCanvas ({ me, dropNavKey }) {
|
||||
</Link>
|
||||
</div>
|
||||
<Dropdown.Divider />
|
||||
<Dropdown.Item
|
||||
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>
|
||||
<LogoutDropdownItem />
|
||||
</>
|
||||
)
|
||||
: <LoginButtons />}
|
||||
|
@ -21,6 +21,7 @@ import { useRouter } from 'next/router'
|
||||
import Link from 'next/link'
|
||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import UserPopover from './user-popover'
|
||||
|
||||
export function SearchText ({ text }) {
|
||||
return (
|
||||
@ -196,7 +197,18 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
||||
</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 (
|
||||
<Link
|
||||
id={props.id}
|
||||
|
@ -11,6 +11,7 @@ import Hat from './hat'
|
||||
import { useMe } from './me'
|
||||
import { MEDIA_URL } from '@/lib/constants'
|
||||
import { NymActionDropdown } from '@/components/user-header'
|
||||
import classNames from 'classnames'
|
||||
|
||||
// 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>)
|
||||
@ -39,7 +40,29 @@ function seperate (arr, seperator) {
|
||||
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 showStatComps = statComps && statComps.length > 0
|
||||
return (
|
||||
@ -50,27 +73,13 @@ function User ({ user, rank, statComps, Embellish, nymActionDropdown = false })
|
||||
{rank}
|
||||
</div>)
|
||||
: <div />}
|
||||
<div className={`${styles.item} ${me?.id === user.id && me.privates?.hideFromTopUsers ? userStyles.hidden : 'mb-2'}`}>
|
||||
<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} ${!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>
|
||||
<UserBase user={user} nymActionDropdown={nymActionDropdown} className={(me?.id === user.id && me.privates?.hideFromTopUsers) ? userStyles.hidden : 'mb-2'}>
|
||||
{showStatComps &&
|
||||
<div className={styles.other}>
|
||||
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
|
||||
</div>}
|
||||
{Embellish && <Embellish rank={rank} />}
|
||||
</UserBase>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -152,23 +161,31 @@ export function UsersSkeleton () {
|
||||
|
||||
return (
|
||||
<div>{users.map((_, i) => (
|
||||
<div className={`${styles.item} ${styles.skeleton} mb-2`} key={i}>
|
||||
<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`} />
|
||||
<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>
|
||||
<UserSkeleton key={i} className='mb-2'>
|
||||
<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>
|
||||
</UserSkeleton>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
82
components/user-popover.js
Normal file
82
components/user-popover.js
Normal 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>
|
||||
)
|
||||
}
|
8
components/user-popover.module.css
Normal file
8
components/user-popover.module.css
Normal file
@ -0,0 +1,8 @@
|
||||
.userPopover {
|
||||
border: 1px solid var(--theme-toolbarActive)
|
||||
}
|
||||
|
||||
.userPopBody {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
@ -33,6 +33,15 @@ export const Status = {
|
||||
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 }) {
|
||||
const lnbits = useLNbits()
|
||||
const nwc = useNWC()
|
||||
@ -114,8 +123,14 @@ function RawWebLNProvider ({ children }) {
|
||||
})
|
||||
}, [setEnabledProviders])
|
||||
|
||||
const clearConfig = useCallback(async () => {
|
||||
lnbits.clearConfig()
|
||||
nwc.clearConfig()
|
||||
await lnc.clearConfig()
|
||||
}, [])
|
||||
|
||||
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}
|
||||
</WebLNContext.Provider>
|
||||
)
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||
import { useWalletLogger } from '../logger'
|
||||
import { Status } from '.'
|
||||
import { Status, migrateLocalStorage } from '.'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
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
|
||||
|
||||
@ -65,13 +66,17 @@ const getPayment = async (baseUrl, adminKey, paymentHash) => {
|
||||
}
|
||||
|
||||
export function LNbitsProvider ({ children }) {
|
||||
const me = useMe()
|
||||
const [url, setUrl] = useState('')
|
||||
const [adminKey, setAdminKey] = useState('')
|
||||
const [status, setStatus] = useState()
|
||||
const { logger } = useWalletLogger(Wallet.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 response = await getWallet(url, adminKey)
|
||||
@ -110,11 +115,18 @@ export function LNbitsProvider ({ children }) {
|
||||
}, [logger, url, adminKey])
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
const configStr = window.localStorage.getItem(storageKey)
|
||||
let configStr = window.localStorage.getItem(storageKey)
|
||||
setStatus(Status.Initialized)
|
||||
if (!configStr) {
|
||||
logger.info('no existing config found')
|
||||
return
|
||||
if (me) {
|
||||
// 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)
|
||||
@ -141,7 +153,7 @@ export function LNbitsProvider ({ children }) {
|
||||
logger.info('wallet disabled')
|
||||
throw err
|
||||
}
|
||||
}, [logger])
|
||||
}, [me, logger])
|
||||
|
||||
const saveConfig = useCallback(async (config) => {
|
||||
// immediately store config so it's not lost even if config is invalid
|
||||
|
@ -1,20 +1,23 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||
import { useWalletLogger } from '../logger'
|
||||
import LNC from '@lightninglabs/lnc-web'
|
||||
import { Status } from '.'
|
||||
import { Status, migrateLocalStorage } from '.'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
import useModal from '../modal'
|
||||
import { Form, PasswordInput, SubmitButton } from '../form'
|
||||
import CancelButton from '../cancel-button'
|
||||
import { Mutex } from 'async-mutex'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
import { useMe } from '../me'
|
||||
|
||||
const LNCContext = createContext()
|
||||
const mutex = new Mutex()
|
||||
|
||||
async function getLNC () {
|
||||
async function getLNC ({ me }) {
|
||||
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
|
||||
}
|
||||
|
||||
@ -33,6 +36,7 @@ function validateNarrowPerms (lnc) {
|
||||
}
|
||||
|
||||
export function LNCProvider ({ children }) {
|
||||
const me = useMe()
|
||||
const { logger } = useWalletLogger(Wallet.LNC)
|
||||
const [config, setConfig] = useState({})
|
||||
const [lnc, setLNC] = useState()
|
||||
@ -165,7 +169,7 @@ export function LNCProvider ({ children }) {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const lnc = await getLNC()
|
||||
const lnc = await getLNC({ me })
|
||||
setLNC(lnc)
|
||||
setStatus(Status.Initialized)
|
||||
if (lnc.credentials.isPaired) {
|
||||
@ -185,7 +189,7 @@ export function LNCProvider ({ children }) {
|
||||
setStatus(Status.Error)
|
||||
}
|
||||
})()
|
||||
}, [setStatus, setConfig, logger])
|
||||
}, [me, setStatus, setConfig, logger])
|
||||
|
||||
return (
|
||||
<LNCContext.Provider value={{ name: 'lnc', status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig }}>
|
||||
|
@ -4,13 +4,15 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
||||
import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
|
||||
import { parseNwcUrl } from '@/lib/url'
|
||||
import { useWalletLogger } from '../logger'
|
||||
import { Status } from '.'
|
||||
import { Status, migrateLocalStorage } from '.'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
import { useMe } from '../me'
|
||||
|
||||
const NWCContext = createContext()
|
||||
|
||||
export function NWCProvider ({ children }) {
|
||||
const me = useMe()
|
||||
const [nwcUrl, setNwcUrl] = useState('')
|
||||
const [walletPubkey, setWalletPubkey] = useState()
|
||||
const [relayUrl, setRelayUrl] = useState()
|
||||
@ -19,7 +21,10 @@ export function NWCProvider ({ children }) {
|
||||
const { logger } = useWalletLogger(Wallet.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) => {
|
||||
logger.info(`requesting info event from ${relayUrl}`)
|
||||
@ -97,11 +102,18 @@ export function NWCProvider ({ children }) {
|
||||
}, [logger])
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
const configStr = window.localStorage.getItem(storageKey)
|
||||
let configStr = window.localStorage.getItem(storageKey)
|
||||
setStatus(Status.Initialized)
|
||||
if (!configStr) {
|
||||
logger.info('no existing config found')
|
||||
return
|
||||
if (me) {
|
||||
// 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)
|
||||
@ -130,7 +142,7 @@ export function NWCProvider ({ children }) {
|
||||
logger.info('wallet disabled')
|
||||
throw err
|
||||
}
|
||||
}, [validateParams, logger])
|
||||
}, [me, validateParams, logger])
|
||||
|
||||
const saveConfig = useCallback(async (config) => {
|
||||
// immediately store config so it's not lost even if config is invalid
|
||||
|
@ -8,3 +8,4 @@ benthecarman
|
||||
stargut
|
||||
mz
|
||||
btcbagehot
|
||||
felipe
|
||||
|
@ -397,6 +397,8 @@ services:
|
||||
- '--autopilot.disable'
|
||||
- '--pool.auctionserver=test.pool.lightning.finance:12010'
|
||||
- '--loop.server.host=test.swap.lightning.today:11010'
|
||||
labels:
|
||||
CONNECT: "localhost:8443"
|
||||
stacker_cln:
|
||||
build:
|
||||
context: ./docker/cln
|
||||
@ -466,6 +468,8 @@ services:
|
||||
- "1025:1025"
|
||||
links:
|
||||
- app
|
||||
labels:
|
||||
CONNECT: "localhost:8025"
|
||||
volumes:
|
||||
db:
|
||||
os:
|
||||
|
21
lib/cln.js
21
lib/cln.js
@ -1,10 +1,27 @@
|
||||
import fetch from 'cross-fetch'
|
||||
import https from 'https'
|
||||
import crypto from 'crypto'
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from '@/lib/proxy'
|
||||
|
||||
export const createInvoice = async ({ socket, rune, cert, label, description, msats, expiry }) => {
|
||||
const agent = cert ? new https.Agent({ ca: Buffer.from(cert, 'base64') }) : undefined
|
||||
const url = 'https://' + socket + '/v1/invoice'
|
||||
let protocol, agent
|
||||
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, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
9
lib/crypto.js
Normal file
9
lib/crypto.js
Normal 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
120
lib/proxy.js
Normal 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()
|
||||
}
|
||||
}
|
@ -77,7 +77,7 @@ export function BioForm ({ handleDone, bio }) {
|
||||
|
||||
export function UserLayout ({ user, children, containClassName }) {
|
||||
return (
|
||||
<Layout user={user} footer={false} containClassName={containClassName}>
|
||||
<Layout user={user} footer footerLinks={false} containClassName={containClassName}>
|
||||
<UserHeader user={user} />
|
||||
{children}
|
||||
</Layout>
|
||||
|
@ -11,6 +11,7 @@ import { getToken } from 'next-auth/jwt'
|
||||
import { NodeNextRequest } from 'next/dist/server/base-http/node'
|
||||
import { schnorr } from '@noble/curves/secp256k1'
|
||||
import { notifyReferral } from '@/lib/webPush'
|
||||
import { hashEmail } from '@/lib/crypto'
|
||||
|
||||
/**
|
||||
* Stores userIds in user table
|
||||
@ -71,24 +72,6 @@ function getCallbacks (req) {
|
||||
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
|
||||
},
|
||||
async session ({ session, token }) {
|
||||
@ -217,7 +200,49 @@ const providers = [
|
||||
export const getAuthOptions = req => ({
|
||||
callbacks: getCallbacks(req),
|
||||
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: {
|
||||
strategy: 'jwt'
|
||||
},
|
||||
@ -229,6 +254,34 @@ export const getAuthOptions = req => ({
|
||||
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) => {
|
||||
await NextAuth(req, res, getAuthOptions(req))
|
||||
}
|
||||
@ -238,7 +291,21 @@ async function sendVerificationRequest ({
|
||||
url,
|
||||
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) => {
|
||||
const { server, from } = provider
|
||||
|
@ -57,7 +57,7 @@ export default startServerAndCreateNextHandler(apolloServer, {
|
||||
let session
|
||||
if (apiKey) {
|
||||
const [user] = await models.$queryRaw`
|
||||
SELECT id, name, email, "apiKeyEnabled"
|
||||
SELECT id, name, "apiKeyEnabled"
|
||||
FROM users
|
||||
WHERE "apiKeyHash" = encode(digest(${apiKey}, 'sha256'), 'hex')
|
||||
LIMIT 1`
|
||||
|
@ -71,7 +71,7 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa
|
||||
}
|
||||
|
||||
// generate invoice
|
||||
const expiresAt = datePivot(new Date(), { minutes: 1 })
|
||||
const expiresAt = datePivot(new Date(), { minutes: 5 })
|
||||
const invoice = await createInvoice({
|
||||
description,
|
||||
description_hash: descriptionHash,
|
||||
|
@ -714,15 +714,8 @@ function AuthMethods ({ methods, apiKeyEnabled }) {
|
||||
return methods.email
|
||||
? (
|
||||
<div key={provider} className='mt-2 d-flex align-items-center'>
|
||||
<Input
|
||||
name='email'
|
||||
placeholder={methods.email}
|
||||
groupClassName='mb-0'
|
||||
readOnly
|
||||
noForm
|
||||
/>
|
||||
<Button
|
||||
className='ms-2' variant='secondary' onClick={
|
||||
variant='secondary' onClick={
|
||||
async () => {
|
||||
await unlink('email')
|
||||
}
|
||||
|
43
prisma/migrations/20240426144110_email_hash/migration.sql
Normal file
43
prisma/migrations/20240426144110_email_hash/migration.sql
Normal 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;
|
@ -19,6 +19,7 @@ model User {
|
||||
name String? @unique(map: "users.name_unique") @db.Citext
|
||||
email String? @unique(map: "users.email_unique")
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
emailHash String? @unique(map: "users.email_hash_unique")
|
||||
image String?
|
||||
msats BigInt @default(0)
|
||||
freeComments Int @default(5)
|
||||
@ -162,7 +163,7 @@ model Wallet {
|
||||
}
|
||||
|
||||
model WalletLog {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
42
sndev
42
sndev
@ -106,6 +106,39 @@ OPTIONS"
|
||||
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() {
|
||||
shift
|
||||
docker__compose restart "$@"
|
||||
@ -418,14 +451,16 @@ sndev__login() {
|
||||
# "SNDEV-TOKEN3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI"
|
||||
# next-auth concats the token with the secret from env and then sha256's it
|
||||
token="d5fce54babffcb070c39f78d947761fd9ec37647fafcecb9734a3085a78e5c5e"
|
||||
salt="202c90943c313b829e65e3f29164fb5dd7ea3370d7262c4159691c2f6493bb8b"
|
||||
# upsert user with nym and nym@sndev.team
|
||||
email="$1@sndev.team"
|
||||
docker__exec db psql -U sn -d stackernews -q <<EOF
|
||||
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)
|
||||
VALUES ('$1@sndev.team', '$token', NOW() + INTERVAL '1 day')
|
||||
VALUES ('$email', '$token', NOW() + INTERVAL '1 day')
|
||||
ON CONFLICT (token) DO UPDATE
|
||||
SET identifier = '$1@sndev.team', expires = NOW() + INTERVAL '1 day';
|
||||
SET identifier = '$email', expires = NOW() + INTERVAL '1 day';
|
||||
EOF
|
||||
|
||||
echo
|
||||
@ -496,6 +531,7 @@ COMMANDS
|
||||
dev:
|
||||
pr fetch and checkout a pr
|
||||
lint run linters
|
||||
open open container url in browser
|
||||
|
||||
other:
|
||||
compose docker compose passthrough
|
||||
|
@ -9,6 +9,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
padding: 2.5rem 0 1rem 0;
|
||||
}
|
||||
|
||||
.createFormContainer {
|
||||
|
@ -23,6 +23,7 @@ import { deleteUnusedImages } from './deleteUnusedImages.js'
|
||||
import { territoryBilling, territoryRevenue } from './territory.js'
|
||||
import { ofac } from './ofac.js'
|
||||
import { autoWithdraw } from './autowithdraw.js'
|
||||
import { saltAndHashEmails } from './saltAndHashEmails.js'
|
||||
|
||||
const { loadEnvConfig } = nextEnv
|
||||
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
|
||||
@ -100,6 +101,7 @@ async function work () {
|
||||
await boss.work('territoryBilling', jobWrapper(territoryBilling))
|
||||
await boss.work('territoryRevenue', jobWrapper(territoryRevenue))
|
||||
await boss.work('ofac', jobWrapper(ofac))
|
||||
await boss.work('saltAndHashEmails', jobWrapper(saltAndHashEmails))
|
||||
|
||||
console.log('working jobs')
|
||||
}
|
||||
|
9
worker/saltAndHashEmails.js
Normal file
9
worker/saltAndHashEmails.js
Normal 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)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user