diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 00000000..23173a98
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,20 @@
+name: Eslint Check
+on: [pull_request]
+
+jobs:
+ eslint-run:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "18.17.0"
+
+ - name: Install
+ run: npm install
+
+ - name: Lint
+ run: npm run lint
diff --git a/README.md b/README.md
index 19b78651..bdfab7d5 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,14 @@ We're experimenting with providing an SN-like service on nostr in [Outer Space](
You should then be able to access the site at `localhost:3000` and any changes you make will hot reload. If you want to login locally or use lnd you'll need to modify `.env.sample` appropriately. More details [here](./docs/local-auth.md) and [here](./docs/local-lnd.md). If you have trouble please open an issue so I can help and update the README for everyone else.
+# web push
+
+To enable Web Push locally, you will need to set the `VAPID_*` env vars. `VAPID_MAILTO` needs to be a email address using the `mailto:` scheme. For `NEXT_PUBLIC_VAPID_KEY` and `VAPID_PRIVKEY`, you can run `npx web-push generate-vapid-keys`.
+
+# imgproxy
+
+To configure the image proxy, you will need to set the `IMGPROXY_` env vars. `NEXT_PUBLIC_IMGPROXY_URL` needs to point to the image proxy service. `IMGPROXY_KEY` and `IMGPROXY_SALT` can be set using `openssl rand -hex 64`.
+
# stack
The site is written in javascript using Next.js, a React framework. The backend API is provided via graphql. The database is postgresql modelled with prisma. The job queue is also maintained in postgresql. We use lnd for our lightning node. A customized Bootstrap theme is used for styling.
diff --git a/api/resolvers/imgproxy/index.js b/api/resolvers/imgproxy/index.js
index e631f307..1ff3723c 100644
--- a/api/resolvers/imgproxy/index.js
+++ b/api/resolvers/imgproxy/index.js
@@ -1,6 +1,13 @@
import { createHmac } from 'node:crypto'
import { extractUrls } from '../../../lib/md'
+const imgProxyEnabled = process.env.NODE_ENV === 'production' ||
+ (process.env.NEXT_PUBLIC_IMGPROXY_URL && process.env.IMGPROXY_SALT && process.env.IMGPROXY_KEY)
+
+if (!imgProxyEnabled) {
+ console.warn('IMGPROXY_* env vars not set, imgproxy calls are no-ops now')
+}
+
const IMGPROXY_URL = process.env.NEXT_PUBLIC_IMGPROXY_URL
const IMGPROXY_SALT = process.env.IMGPROXY_SALT
const IMGPROXY_KEY = process.env.IMGPROXY_KEY
@@ -36,6 +43,8 @@ const isImageURL = async url => {
}
export const proxyImages = async text => {
+ if (!imgProxyEnabled) return text
+
const urls = extractUrls(text)
for (const url of urls) {
if (url.startsWith(IMGPROXY_URL)) continue
diff --git a/api/resolvers/user.js b/api/resolvers/user.js
index 6f9d74fb..c187de9e 100644
--- a/api/resolvers/user.js
+++ b/api/resolvers/user.js
@@ -80,7 +80,8 @@ async function authMethods (user, args, { models, me }) {
email: user.emailVerified && user.email,
twitter: oauth.indexOf('twitter') >= 0,
github: oauth.indexOf('github') >= 0,
- slashtags: !!user.slashtagId
+ slashtags: !!user.slashtagId,
+ nostr: !!user.nostrAuthPubkey
}
}
@@ -527,6 +528,8 @@ export default {
user = await models.user.update({ where: { id: me.id }, data: { pubkey: null } })
} else if (authType === 'slashtags') {
user = await models.user.update({ where: { id: me.id }, data: { slashtagId: null } })
+ } else if (authType === 'nostr') {
+ user = await models.user.update({ where: { id: me.id }, data: { nostrAuthPubkey: null } })
} else if (authType === 'email') {
user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } })
} else {
diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js
index 61af6295..af0af24b 100644
--- a/api/typeDefs/user.js
+++ b/api/typeDefs/user.js
@@ -35,6 +35,7 @@ export default gql`
type AuthMethods {
lightning: Boolean!
slashtags: Boolean!
+ nostr: Boolean!
github: Boolean!
twitter: Boolean!
email: String
diff --git a/api/webPush/index.js b/api/webPush/index.js
index 6d82561f..3c2247f8 100644
--- a/api/webPush/index.js
+++ b/api/webPush/index.js
@@ -1,19 +1,29 @@
import webPush from 'web-push'
import models from '../models'
import { COMMENT_DEPTH_LIMIT } from '../../lib/constants'
+import removeMd from 'remove-markdown'
-webPush.setVapidDetails(
- process.env.VAPID_MAILTO,
- process.env.NEXT_PUBLIC_VAPID_PUBKEY,
- process.env.VAPID_PRIVKEY
-)
+const webPushEnabled = process.env.NODE_ENV === 'production' ||
+ (process.env.VAPID_MAILTO && process.env.NEXT_PUBLIC_VAPID_PUBKEY && process.env.VAPID_PRIVKEY)
+
+if (webPushEnabled) {
+ webPush.setVapidDetails(
+ process.env.VAPID_MAILTO,
+ process.env.NEXT_PUBLIC_VAPID_PUBKEY,
+ process.env.VAPID_PRIVKEY
+ )
+} else {
+ console.warn('VAPID_* env vars not set, skipping webPush setup')
+}
const createPayload = (notification) => {
// https://web.dev/push-notifications-display-a-notification/#visual-options
- const { title, ...options } = notification
+ let { title, body, ...options } = notification
+ if (body) body = removeMd(body)
return JSON.stringify({
title,
options: {
+ body,
timestamp: Date.now(),
icon: '/icons/icon_x96.png',
...options
@@ -41,12 +51,16 @@ const createItemUrl = async ({ id }) => {
}
const sendNotification = (subscription, payload) => {
+ if (!webPushEnabled) {
+ console.warn('webPush not configured. skipping notification')
+ return
+ }
const { id, endpoint, p256dh, auth } = subscription
return webPush.sendNotification({ endpoint, keys: { p256dh, auth } }, payload)
.catch((err) => {
if (err.statusCode === 400) {
console.log('[webPush] invalid request: ', err)
- } else if (err.statusCode === 403) {
+ } else if ([401, 403].includes(err.statusCode)) {
console.log('[webPush] auth error: ', err)
} else if (err.statusCode === 404 || err.statusCode === 410) {
console.log('[webPush] subscription has expired or is no longer valid: ', err)
diff --git a/components/lightning-auth.module.css b/components/lightning-auth.module.css
index 39867553..e4eb344b 100644
--- a/components/lightning-auth.module.css
+++ b/components/lightning-auth.module.css
@@ -1,8 +1,9 @@
.login {
- justify-content: center;
+ justify-content: start;
align-items: center;
display: flex;
flex-direction: column;
padding-top: 3rem;
padding-bottom: 3rem;
+ min-height: 600px;
}
\ No newline at end of file
diff --git a/components/login-button.js b/components/login-button.js
index 40951966..9fb65f50 100644
--- a/components/login-button.js
+++ b/components/login-button.js
@@ -2,6 +2,7 @@ import GithubIcon from '../svgs/github-fill.svg'
import TwitterIcon from '../svgs/twitter-fill.svg'
import LightningIcon from '../svgs/bolt.svg'
import SlashtagsIcon from '../svgs/slashtags.svg'
+import NostrIcon from '../svgs/nostr.svg'
import Button from 'react-bootstrap/Button'
export default function LoginButton ({ text, type, className, onClick }) {
@@ -19,6 +20,10 @@ export default function LoginButton ({ text, type, className, onClick }) {
Icon = SlashtagsIcon
variant = 'grey-medium'
break
+ case 'nostr':
+ Icon = NostrIcon
+ variant = 'nostr'
+ break
case 'lightning':
default:
Icon = LightningIcon
diff --git a/components/login.js b/components/login.js
index bd85815d..a2b83e5b 100644
--- a/components/login.js
+++ b/components/login.js
@@ -5,6 +5,7 @@ import { useState } from 'react'
import Alert from 'react-bootstrap/Alert'
import { useRouter } from 'next/router'
import { LightningAuthWithExplainer, SlashtagsAuth } from './lightning-auth'
+import NostrAuth from './nostr-auth'
import LoginButton from './login-button'
import { emailSchema } from '../lib/validate'
@@ -59,6 +60,10 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
return
}
+ if (router.query.type === 'nostr') {
+ return
+ }
+
return (
{Header &&
}
@@ -80,6 +85,7 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
)
case 'Lightning':
case 'Slashtags':
+ case 'Nostr':
return (
+ error: {message}
+ {details}
+ >
+ )
+}
+
+function NostrExplainer ({ text }) {
+ return (
+ <>
+
+
+
+
+
+
+ -
+ Alby
+ available for: chrome, firefox, and safari
+
+ -
+ Flamingo
+ available for: chrome
+
+ -
+ nos2x
+ available for: chrome
+
+ -
+ nos2x-fox
+ available for: firefox
+
+ -
+ horse
+ available for: chrome
+ supports hardware signing
+
+
+
+
+ >
+ }
+ />
+
+ >
+ )
+}
+
+export function NostrAuth ({ text, callbackUrl }) {
+ const [createAuth, { data, error }] = useMutation(gql`
+ mutation createAuth {
+ createAuth {
+ k1
+ }
+ }`)
+ const [hasExtension, setHasExtension] = useState(undefined)
+ const [extensionError, setExtensionError] = useState(null)
+
+ useEffect(() => {
+ createAuth()
+ }, [])
+
+ const k1 = data?.createAuth.k1
+
+ useEffect(() => {
+ if (!k1) return
+ setHasExtension(!!window.nostr)
+ if (!window.nostr) {
+ const err = { message: 'nostr extension not found' }
+ console.error(err.message)
+ return
+ }
+ console.info('nostr extension detected')
+ let mounted = true;
+ (async function () {
+ try {
+ // have them sign a message with the challenge
+ let event
+ try {
+ event = await window.nostr.signEvent({
+ kind: 22242,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [['challenge', k1]],
+ content: 'Stacker News Authentication'
+ })
+ if (!event) throw new Error('extension returned empty event')
+ } catch (e) {
+ if (e.message === 'window.nostr call already executing') return
+ setExtensionError({ message: 'nostr extension failed to sign event', details: e.message })
+ return
+ }
+
+ // sign them in
+ try {
+ const { error, ok } = await signIn('nostr', {
+ event: JSON.stringify(event),
+ callbackUrl
+ })
+
+ if (error) {
+ throw new Error(error)
+ }
+ if (!ok) {
+ throw new Error('auth failed')
+ }
+ } catch (e) {
+ throw new Error('authorization failed', e)
+ }
+ } catch (e) {
+ if (!mounted) return
+ setExtensionError({ message: `${text} failed`, details: e.message })
+ }
+ })()
+ return () => { mounted = false }
+ }, [k1, hasExtension])
+
+ if (error) return error
+
+ return (
+ <>
+ {hasExtension === false && }
+ {extensionError && }
+ {hasExtension && !extensionError &&
+ <>
+ nostr extension found
+ authorize event signature in extension
+ >}
+ >
+ )
+}
+
+export default function NostrAuthWithExplainer ({ text, callbackUrl }) {
+ const router = useRouter()
+ return (
+
+
+
router.back()}>
+
{text || 'Login'} with Nostr
+
+
+
+ )
+}
diff --git a/components/serviceworker.js b/components/serviceworker.js
index 08ab1840..6bb42e43 100644
--- a/components/serviceworker.js
+++ b/components/serviceworker.js
@@ -62,6 +62,13 @@ export const ServiceWorkerProvider = ({ children }) => {
let pushSubscription = await registration.pushManager.subscribe(subscribeOptions)
// convert keys from ArrayBuffer to string
pushSubscription = JSON.parse(JSON.stringify(pushSubscription))
+ // Send subscription to service worker to save it so we can use it later during `pushsubscriptionchange`
+ // see https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f
+ navigator.serviceWorker.controller.postMessage({
+ action: 'STORE_SUBSCRIPTION',
+ subscription: pushSubscription
+ })
+ // send subscription to server
const variables = {
endpoint: pushSubscription.endpoint,
p256dh: pushSubscription.keys.p256dh,
@@ -89,6 +96,10 @@ export const ServiceWorkerProvider = ({ children }) => {
pushManager: 'PushManager' in window
})
setPermission({ notification: 'Notification' in window ? window.Notification.permission : 'denied' })
+ // since (a lot of) browsers don't support the pushsubscriptionchange event,
+ // we sync with server manually by checking on every page reload if the push subscription changed.
+ // see https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f
+ navigator.serviceWorker.controller.postMessage({ action: 'SYNC_SUBSCRIPTION' })
}, [])
useEffect(() => {
diff --git a/fragments/users.js b/fragments/users.js
index e55b243f..c6960591 100644
--- a/fragments/users.js
+++ b/fragments/users.js
@@ -58,6 +58,7 @@ export const SETTINGS_FIELDS = gql`
authMethods {
lightning
slashtags
+ nostr
github
twitter
email
diff --git a/package-lock.json b/package-lock.json
index e8bdb320..426c0174 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -75,6 +75,7 @@
"remove-markdown": "^0.5.0",
"sass": "^1.64.1",
"tldts": "^6.0.13",
+ "serviceworker-storage": "^0.1.0",
"typescript": "^5.1.6",
"unist-util-visit": "^5.0.0",
"url-unshort": "^6.1.0",
@@ -16934,6 +16935,11 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/serviceworker-storage": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/serviceworker-storage/-/serviceworker-storage-0.1.0.tgz",
+ "integrity": "sha512-Vum11Npe8oiFYY05OIhD6obfVP3oCSfBj/NKQGzNLbn6Fr5424j1pv/SvPcbVrDIovdC3EmgGxLgfsLFXgZR1A=="
+ },
"node_modules/set-cookie-parser": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz",
@@ -31637,6 +31643,11 @@
"send": "0.18.0"
}
},
+ "serviceworker-storage": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/serviceworker-storage/-/serviceworker-storage-0.1.0.tgz",
+ "integrity": "sha512-Vum11Npe8oiFYY05OIhD6obfVP3oCSfBj/NKQGzNLbn6Fr5424j1pv/SvPcbVrDIovdC3EmgGxLgfsLFXgZR1A=="
+ },
"set-cookie-parser": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz",
diff --git a/package.json b/package.json
index 823a1536..df82cce8 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
"dev": "NODE_OPTIONS='--trace-warnings' next dev",
"build": "next build",
"migrate": "prisma migrate deploy",
- "start": "NODE_OPTIONS='--trace-warnings' next start -p $PORT --keepAliveTimeout 120000"
+ "start": "NODE_OPTIONS='--trace-warnings' next start -p $PORT --keepAliveTimeout 120000",
+ "lint": "standard"
},
"dependencies": {
"@apollo/client": "^3.7.17",
@@ -76,6 +77,7 @@
"remove-markdown": "^0.5.0",
"sass": "^1.64.1",
"tldts": "^6.0.13",
+ "serviceworker-storage": "^0.1.0",
"typescript": "^5.1.6",
"unist-util-visit": "^5.0.0",
"url-unshort": "^6.1.0",
@@ -101,7 +103,8 @@
],
"extends": [
"next"
- ]
+ ],
+ "ignore": ["**/spawn"]
},
"devDependencies": {
"@babel/core": "^7.22.9",
diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js
index 8cb6d663..404796e7 100644
--- a/pages/api/auth/[...nextauth].js
+++ b/pages/api/auth/[...nextauth].js
@@ -1,3 +1,4 @@
+import { createHash } from 'node:crypto'
import NextAuth from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import GitHubProvider from 'next-auth/providers/github'
@@ -9,6 +10,7 @@ import { PrismaAdapter } from '@auth/prisma-adapter'
import { decode, getToken } from 'next-auth/jwt'
import { NodeNextRequest } from 'next/dist/server/base-http/node'
import jose1 from 'jose1'
+import { schnorr } from '@noble/curves/secp256k1'
function getCallbacks (req) {
return {
@@ -102,6 +104,37 @@ async function pubkeyAuth (credentials, req, pubkeyColumnName) {
return null
}
+async function nostrEventAuth (event) {
+ // parse event
+ const e = JSON.parse(event)
+
+ // is the event id a hash of this event
+ const id = createHash('sha256').update(
+ JSON.stringify(
+ [0, e.pubkey, e.created_at, e.kind, e.tags, e.content]
+ )
+ ).digest('hex')
+ if (id !== e.id) {
+ throw new Error('invalid event id')
+ }
+
+ // is the signature valid
+ if (!(await schnorr.verify(e.sig, e.id, e.pubkey))) {
+ throw new Error('invalid signature')
+ }
+
+ // is the challenge present in the event
+ if (!(e.tags[0].length === 2 && e.tags[0][0] === 'challenge')) {
+ throw new Error('expected tags = [["challenge", ]]')
+ }
+
+ const pubkey = e.pubkey
+ const k1 = e.tags[0][1]
+ await prisma.lnAuth.update({ data: { pubkey }, where: { k1 } })
+
+ return { k1, pubkey }
+}
+
const providers = [
CredentialsProvider({
id: 'lightning',
@@ -112,6 +145,17 @@ const providers = [
},
authorize: async (credentials, req) => await pubkeyAuth(credentials, new NodeNextRequest(req), 'pubkey')
}),
+ CredentialsProvider({
+ id: 'nostr',
+ name: 'Nostr',
+ credentials: {
+ event: { label: 'event', type: 'text' }
+ },
+ authorize: async ({ event }, req) => {
+ const credentials = await nostrEventAuth(event)
+ return pubkeyAuth(credentials, new NodeNextRequest(req), 'nostrAuthPubkey')
+ }
+ }),
CredentialsProvider({
id: 'slashtags',
name: 'Slashtags',
diff --git a/pages/settings.js b/pages/settings.js
index cba53da3..ce2b1c40 100644
--- a/pages/settings.js
+++ b/pages/settings.js
@@ -21,6 +21,7 @@ import { SUPPORTED_CURRENCIES } from '../lib/currency'
import PageLoading from '../components/page-loading'
import { useShowModal } from '../components/modal'
import { authErrorMessage } from '../components/login'
+import { NostrAuth } from '../components/nostr-auth'
export const getServerSideProps = getGetServerSideProps(SETTINGS)
@@ -297,6 +298,23 @@ function QRLinkButton ({ provider, unlink, status }) {
)
}
+function NostrLinkButton ({ unlink, status }) {
+ const showModal = useShowModal()
+ const text = status ? 'Unlink' : 'Link'
+ const onClick = status
+ ? unlink
+ : () => showModal(onClose =>
+
+
+
)
+
+ return (
+
+ )
+}
+
function UnlinkObstacle ({ onClose, type, unlinkAuth }) {
const router = useRouter()
@@ -341,6 +359,7 @@ function AuthMethods ({ methods }) {
email
twitter
github
+ nostr
}
}`, {
update (cache, { data: { unlinkAuth } }) {
@@ -416,6 +435,8 @@ function AuthMethods ({ methods }) {
status={methods[provider]} unlink={async () => await unlink(provider)}
/>
)
+ } else if (provider === 'nostr') {
+ return await unlink(provider)} />
} else {
return (
+
+
\ No newline at end of file
diff --git a/sw/index.js b/sw/index.js
index c8796442..654caeb6 100644
--- a/sw/index.js
+++ b/sw/index.js
@@ -5,6 +5,11 @@ import { setDefaultHandler } from 'workbox-routing'
import { NetworkOnly } from 'workbox-strategies'
import { enable } from 'workbox-navigation-preload'
import manifest from './precache-manifest.json'
+import ServiceWorkerStorage from 'serviceworker-storage'
+
+self.__WB_DISABLE_DEV_LOGS = true
+
+const storage = new ServiceWorkerStorage('sw:storage', 1)
// preloading improves startup performance
// https://developer.chrome.com/docs/workbox/modules/workbox-navigation-preload/
@@ -80,35 +85,54 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close()
})
-self.addEventListener('pushsubscriptionchange', (event) => {
+// https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f
+self.addEventListener('message', (event) => {
+ if (event.data.action === 'STORE_SUBSCRIPTION') {
+ return event.waitUntil(storage.setItem('subscription', event.data.subscription))
+ }
+ if (event.data.action === 'SYNC_SUBSCRIPTION') {
+ return event.waitUntil(handlePushSubscriptionChange())
+ }
+})
+
+async function handlePushSubscriptionChange (oldSubscription, newSubscription) {
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event
+ // fallbacks since browser may not set oldSubscription and newSubscription
+ oldSubscription ??= await storage.getItem('subscription')
+ newSubscription ??= await self.registration.pushManager.getSubscription()
+ if (!newSubscription) {
+ // no subscription exists at the moment
+ return
+ }
+ if (oldSubscription?.endpoint === newSubscription.endpoint) {
+ // subscription did not change. no need to sync with server
+ return
+ }
+ // convert keys from ArrayBuffer to string
+ newSubscription = JSON.parse(JSON.stringify(newSubscription))
+ const variables = {
+ endpoint: newSubscription.endpoint,
+ p256dh: newSubscription.keys.p256dh,
+ auth: newSubscription.keys.auth,
+ oldEndpoint: oldSubscription?.endpoint
+ }
const query = `
mutation savePushSubscription($endpoint: String!, $p256dh: String!, $auth: String!, $oldEndpoint: String!) {
savePushSubscription(endpoint: $endpoint, p256dh: $p256dh, auth: $auth, oldEndpoint: $oldEndpoint) {
id
}
}`
+ const body = JSON.stringify({ query, variables })
+ await fetch('/api/graphql', {
+ method: 'POST',
+ headers: {
+ 'Content-type': 'application/json'
+ },
+ body
+ })
+ await storage.setItem('subscription', JSON.parse(JSON.stringify(newSubscription)))
+}
- const subscription = self.registration.pushManager
- .subscribe(event.oldSubscription.options)
- .then((subscription) => {
- // convert keys from ArrayBuffer to string
- subscription = JSON.parse(JSON.stringify(subscription))
- const variables = {
- endpoint: subscription.endpoint,
- p256dh: subscription.keys.p256dh,
- auth: subscription.keys.auth,
- oldEndpoint: event.oldSubscription.endpoint
- }
- const body = JSON.stringify({ query, variables })
- return fetch('/api/graphql', {
- method: 'POST',
- headers: {
- 'Content-type': 'application/json'
- },
- body
- })
- })
-
- event.waitUntil(subscription)
+self.addEventListener('pushsubscriptionchange', (event) => {
+ event.waitUntil(handlePushSubscriptionChange(event.oldSubscription, event.newSubscription))
}, false)