Merge branch 'master' into 266-zaps-without-account

This commit is contained in:
Keyan 2023-08-08 09:42:21 -05:00 committed by GitHub
commit 76b4156ccb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 407 additions and 34 deletions

20
.github/workflows/lint.yml vendored Normal file
View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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 {

View File

@ -35,6 +35,7 @@ export default gql`
type AuthMethods {
lightning: Boolean!
slashtags: Boolean!
nostr: Boolean!
github: Boolean!
twitter: Boolean!
email: String

View File

@ -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(
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)

View File

@ -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;
}

View File

@ -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

View File

@ -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 <SlashtagsAuth callbackUrl={callbackUrl} text={text} />
}
if (router.query.type === 'nostr') {
return <NostrAuth callbackUrl={callbackUrl} text={text} />
}
return (
<div className={styles.login}>
{Header && <Header />}
@ -80,6 +85,7 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
)
case 'Lightning':
case 'Slashtags':
case 'Nostr':
return (
<LoginButton
className={`mt-2 ${styles.providerButton}`}

160
components/nostr-auth.js Normal file
View File

@ -0,0 +1,160 @@
import { useEffect, useState } from 'react'
import { gql, useMutation } from '@apollo/client'
import { signIn } from 'next-auth/react'
import Container from 'react-bootstrap/Container'
import Col from 'react-bootstrap/Col'
import Row from 'react-bootstrap/Row'
import { useRouter } from 'next/router'
import AccordianItem from './accordian-item'
import BackIcon from '../svgs/arrow-left-line.svg'
import styles from './lightning-auth.module.css'
function ExtensionError ({ message, details }) {
return (
<>
<h4 className='fw-bold text-danger pb-1'>error: {message}</h4>
<div className='text-muted pb-4'>{details}</div>
</>
)
}
function NostrExplainer ({ text }) {
return (
<>
<ExtensionError message='nostr extension not found' details='Nostr extensions are the safest way to use your nostr identity on Stacker News.' />
<Row className='w-100 text-muted'>
<AccordianItem
header={`Which extensions can I use to ${(text || 'Login').toLowerCase()} with Nostr?`}
show
body={
<>
<Row>
<Col>
<ul>
<li>
<a href='https://getalby.com'>Alby</a><br />
available for: chrome, firefox, and safari
</li>
<li>
<a href='https://www.getflamingo.org/'>Flamingo</a><br />
available for: chrome
</li>
<li>
<a href='https://github.com/fiatjaf/nos2x'>nos2x</a><br />
available for: chrome
</li>
<li>
<a href='https://diegogurpegui.com/nos2x-fox/'>nos2x-fox</a><br />
available for: firefox
</li>
<li>
<a href='https://github.com/fiatjaf/horse'>horse</a><br />
available for: chrome<br />
supports hardware signing
</li>
</ul>
</Col>
</Row>
</>
}
/>
</Row>
</>
)
}
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 <div>error</div>
return (
<>
{hasExtension === false && <NostrExplainer text={text} />}
{extensionError && <ExtensionError {...extensionError} />}
{hasExtension && !extensionError &&
<>
<h4 className='fw-bold text-success pb-1'>nostr extension found</h4>
<h6 className='text-muted pb-4'>authorize event signature in extension</h6>
</>}
</>
)
}
export default function NostrAuthWithExplainer ({ text, callbackUrl }) {
const router = useRouter()
return (
<Container>
<div className={styles.login}>
<div className='w-100 mb-3 text-muted pointer' onClick={() => router.back()}><BackIcon /></div>
<h3 className='w-100 pb-2'>{text || 'Login'} with Nostr</h3>
<NostrAuth text={text} callbackUrl={callbackUrl} />
</div>
</Container>
)
}

View File

@ -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(() => {

View File

@ -58,6 +58,7 @@ export const SETTINGS_FIELDS = gql`
authMethods {
lightning
slashtags
nostr
github
twitter
email

11
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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", <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',

View File

@ -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 =>
<div className='d-flex flex-column align-items-center'>
<NostrAuth text='Unlink' />
</div>)
return (
<LoginButton
className='d-block mt-2' type='nostr' text={text} onClick={onClick}
/>
)
}
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 <NostrLinkButton key='nostr' status={methods[provider]} unlink={async () => await unlink(provider)} />
} else {
return (
<LoginButton

View File

@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[nostrAuthPubkey]` on the table `users` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "users" ADD COLUMN "nostrAuthPubkey" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "users.nostrAuthPubkey_unique" ON "users"("nostrAuthPubkey");

View File

@ -51,6 +51,7 @@ model User {
turboTipping Boolean @default(false)
referrerId Int?
nostrPubkey String?
nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique")
slashtagId String? @unique(map: "users.slashtagId_unique")
noteCowboyHat Boolean @default(true)
streak Int?

View File

@ -7,6 +7,7 @@ $twitter: #1da1f2;
$boost: #8c25f4;
$light: #f8f9fa;
$dark: #212529;
$nostr: #8d45dd;
$theme-colors: (
"primary" : #FADA5E,
@ -21,6 +22,7 @@ $theme-colors: (
"grey" : #e9ecef,
"grey-medium" : #d2d2d2,
"grey-darkmode": #8c8c8c,
"nostr": #8d45dd,
);
$body-bg: #fcfcff;
@ -227,6 +229,13 @@ mark {
color: #ffffff !important;
}
.btn-nostr, .btn-nostr:hover, .btn-nostr:active {
fill: #ffffff !important;
stroke: #ffffff !important;
stroke-miterlimit: 10;
stroke-width: 6px;
}
.btn-outline-grey-darkmode:hover, .btn-outline-grey-darkmode:active {
color: #ffffff !important;
}
@ -568,6 +577,13 @@ div[contenteditable]:focus,
fill: #ffffff;
}
.btn-nostr svg {
fill: #fff;
stroke: #fff;
stroke-miterlimit: 10;
stroke-width: 6px;
}
.btn-dark svg {
fill: #ffffff;
}

3
svgs/nostr.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 875 875">
<path class="cls-1" d="m684.72,485.57c.22,12.59-11.93,51.47-38.67,81.3-26.74,29.83-56.02,20.85-58.42,20.16s-3.09-4.46-7.89-3.77-9.6,6.17-18.86,7.2-17.49,1.71-26.06-1.37c-4.46.69-5.14.71-7.2,2.24s-17.83,10.79-21.6,11.47c0,7.2-1.37,44.57,0,55.89s3.77,25.71,7.54,36c3.77,10.29,2.74,10.63,7.54,9.94s13.37.34,15.77,4.11c2.4,3.77,1.37,6.51,5.49,8.23s60.69,17.14,99.43,19.2c26.74.69,42.86,2.74,52.12,19.54,1.37,7.89,7.54,13.03,11.31,14.06s8.23,2.06,12,5.83,1.03,8.23,5.49,11.66c4.46,3.43,14.74,8.57,25.37,13.71,10.63,5.14,15.09,13.37,15.77,16.11s1.71,10.97,1.71,10.97c0,0-8.91,0-10.97-2.06s-2.74-5.83-2.74-5.83c0,0-6.17,1.03-7.54,3.43s.69,2.74-7.89.69-11.66-3.77-18.17-8.57c-6.51-4.8-16.46-17.14-25.03-16.8,4.11,8.23,5.83,8.23,10.63,10.97s8.23,5.83,8.23,5.83l-7.2,4.46s-4.46,2.06-14.74-.69-11.66-4.46-12.69-10.63,0-9.26-2.74-14.4-4.11-15.77-22.29-21.26c-18.17-5.49-66.52-21.26-100.12-24.69s-22.63-2.74-28.11-1.37-15.77,4.46-26.4-1.37c-10.63-5.83-16.8-13.71-17.49-20.23s-1.71-10.97,0-19.2,3.43-19.89,1.71-26.74-14.06-55.89-19.89-64.12c-13.03,1.03-50.74-.69-50.74-.69,0,0-2.4-.69-17.49,5.83s-36.48,13.76-46.77,19.93-14.4,9.7-16.12,13.13c.12,3-1.23,7.72-2.79,9.06s-12.48,2.42-12.48,2.42c0,0-5.85,5.86-8.25,9.97-6.86,9.6-55.2,125.14-66.52,149.83-13.54,32.57-9.77,27.43-37.71,27.43s-8.06.3-8.06.3c0,0-12.34,5.88-16.8,5.88s-18.86-2.4-26.4,0-16.46,9.26-23.31,10.29-4.95-1.34-8.38-3.74c-4-.21-14.27-.12-14.27-.12,0,0,1.74-6.51,7.91-10.88,8.23-5.83,25.37-16.11,34.63-21.26s17.49-7.89,23.31-9.26,18.51-6.17,30.51-9.94,19.54-8.23,29.83-31.54c10.29-23.31,50.4-111.43,51.43-116.23.63-2.96,3.73-6.48,4.8-15.09.66-5.35-2.49-13.04,1.71-22.63,10.97-25.03,21.6-20.23,26.4-20.23s17.14.34,26.4-1.37,15.43-2.74,24.69-7.89,11.31-8.91,11.31-8.91l-19.89-3.43s-18.51.69-25.03-4.46-15.43-15.77-15.43-15.77l-7.54-7.2,1.03,8.57s-5.14-8.91-6.51-10.29-8.57-6.51-11.31-11.31-7.54-25.03-7.54-25.03l-6.17,13.03-1.71-18.86-5.14,7.2-2.74-16.11-4.8,8.23-3.43-14.4-5.83,4.46-2.4-10.29-5.83-3.43s-14.06-9.26-16.46-9.6-4.46,3.43-4.46,3.43l1.37,12-12.2-6.27-7-11.9s2.36,4.01-9.62,7.53c-20.55,0-21.89-2.28-24.93-3.94-1.31-6.56-5.57-10.11-5.57-10.11h-20.57l-.34-6.86-7.89,3.09.69-10.29h-14.06l1.03-11.31h-8.91s3.09-9.26,25.71-22.97,25.03-16.46,46.29-17.14c21.26-.69,32.91,2.74,46.29,8.23s38.74,13.71,43.89,17.49c11.31-9.94,28.46-19.89,34.29-19.89,1.03-2.4,6.19-12.33,17.96-17.6,35.31-15.81,108.13-34,131.53-35.54,31.2-2.06,7.89-1.37,39.09,2.06,31.2,3.43,54.17,7.54,69.6,12.69,12.58,4.19,25.03,9.6,34.29,2.06,4.33-1.81,11.81-1.34,17.83-5.14,30.69-25.09,34.72-32.35,43.63-41.95s20.14-24.91,22.54-45.14,4.46-58.29-10.63-88.12-28.8-45.26-34.63-69.26c-5.83-24-8.23-61.03-6.17-73.03,2.06-12,5.14-22.29,6.86-30.51s9.94-14.74,19.89-16.46c9.94-1.71,17.83,1.37,22.29,4.8,4.46,3.43,11.65,6.28,13.37,10.29.34,1.71-1.37,6.51,8.23,8.23,9.6,1.71,16.05,4.16,16.05,4.16,0,0,15.64,4.29,3.11,7.73-12.69,2.06-20.52-.71-24.29,1.69s-7.21,10.08-9.61,11.1-7.2.34-12,4.11-9.6,6.86-12.69,14.4-5.49,15.77-3.43,26.74,8.57,31.54,14.4,43.2c5.83,11.66,20.23,40.8,24.34,47.66s15.77,29.49,16.8,53.83,1.03,44.23,0,54.86-10.84,51.65-35.53,85.94c-8.16,14.14-23.21,31.9-24.67,35.03-1.45,3.13-3.02,4.88-1.61,7.65,4.62,9.05,12.87,22.13,14.71,29.22,2.29,6.64,6.99,16.13,7.22,28.72Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -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 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', {
await fetch('/api/graphql', {
method: 'POST',
headers: {
'Content-type': 'application/json'
},
body
})
})
await storage.setItem('subscription', JSON.parse(JSON.stringify(newSubscription)))
}
event.waitUntil(subscription)
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(handlePushSubscriptionChange(event.oldSubscription, event.newSubscription))
}, false)