diff --git a/api.yaml b/api.yaml
index 830c5950..12ed1f29 100644
--- a/api.yaml
+++ b/api.yaml
@@ -284,6 +284,61 @@ paths:
schema:
$ref: "#/components/schemas/Summary"
+ /webpush/key:
+ get:
+ summary: Get the server's public key for Web Push. Note that this API is experimental.
+ responses:
+ "200":
+ description: Public key
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/WebPushServerKey"
+
+ /webpush/subscription:
+ post:
+ summary: Create a subscription. Note that this API is experimental.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/WebPushSubscription"
+ responses:
+ "201":
+ description: Created.
+ "409":
+ description: The subscription already exists.
+ /webpush/subscription/{digest}:
+ head:
+ summary: Check a subscription. Note that this API is experimental.
+ parameters:
+ - in: path
+ name: digest
+ description: Digest of the subscription.
+ schemas:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: Subscription exists.
+ "404":
+ description: Subscription not found.
+ delete:
+ summary: Delete a subscription. Note that this API is experimental.
+ parameters:
+ - in: path
+ name: digest
+ description: Digest of the subscription.
+ schemas:
+ type: string
+ required: true
+ responses:
+ "204":
+ description: Deleted.
+ "404":
+ description: Subscription not found.
+
components:
schemas:
ImagePage:
@@ -656,3 +711,30 @@ components:
description: Set if step failed.
required:
- result
+
+ WebPushServerKey:
+ type: object
+ properties:
+ key:
+ type: string
+ required:
+ - key
+
+ WebPushSubscription:
+ type: object
+ properties:
+ endpoint:
+ type: string
+ keys:
+ type: object
+ properties:
+ p256dh:
+ type: string
+ auth:
+ type: string
+ required:
+ - p256dh
+ - auth
+ required:
+ - endpoint
+ - keys
diff --git a/internal/api/server.go b/internal/api/server.go
index f1d61928..7c04de62 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -399,6 +399,59 @@ func NewServer(api *store.Store, hub *events.Hub[worker.Event], processQueue *wo
s.handleJSONResponse(w, r, response, err)
})
+ s.mux.HandleFunc("GET /api/v1/webpush/key", func(w http.ResponseWriter, r *http.Request) {
+ _, span := httputil.SpanFromRequest(r)
+ span.SetAttributes(semconv.HTTPRoute("/api/v1/webpush/key"))
+
+ // TODO
+ response := models.WebPushServerKey{
+ Key: "1234",
+ }
+ s.handleJSONResponse(w, r, response, nil)
+ })
+
+ s.mux.HandleFunc("POST /api/v1/webpush/subscription", func(w http.ResponseWriter, r *http.Request) {
+ _, span := httputil.SpanFromRequest(r)
+ span.SetAttributes(semconv.HTTPRoute("/api/v1/webpush/key"))
+
+ var subscription models.WebPushSubscription
+ if err := json.NewDecoder(r.Body).Decode(&subscription); err != nil {
+ s.handleGenericResponse(w, r, err)
+ return
+ }
+
+ // TODO
+ w.WriteHeader(http.StatusCreated)
+ })
+
+ s.mux.HandleFunc("HEAD /api/v1/webpush/subscription/{digest}", func(w http.ResponseWriter, r *http.Request) {
+ _, span := httputil.SpanFromRequest(r)
+ span.SetAttributes(semconv.HTTPRoute("/api/v1/webpush/key"))
+
+ algorithm, _, ok := strings.Cut(r.PathValue("digest"), "-")
+ if !ok || algorithm != "sha256" {
+ s.handleGenericResponse(w, r, ErrBadRequest)
+ return
+ }
+
+ // TODO
+ w.WriteHeader(http.StatusOK)
+ })
+
+ s.mux.HandleFunc("DELETE /api/v1/webpush/subscription/{digest}", func(w http.ResponseWriter, r *http.Request) {
+ _, span := httputil.SpanFromRequest(r)
+ span.SetAttributes(semconv.HTTPRoute("/api/v1/webpush/key"))
+
+ algorithm, _, ok := strings.Cut(r.PathValue("digest"), "-")
+ if !ok || algorithm != "sha256" {
+ s.handleGenericResponse(w, r, ErrBadRequest)
+ return
+ }
+
+ // TODO
+ w.WriteHeader(http.StatusNoContent)
+ })
+
return s
}
diff --git a/internal/models/models.go b/internal/models/models.go
index f7f4eeb0..37960c12 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -190,3 +190,15 @@ const (
EventTypeImageProcessed EventType = "imageProcessed"
EventTypeImageNewVersionAvailable EventType = "imageNewVersionAvailable"
)
+
+type WebPushServerKey struct {
+ Key string `json:"key"`
+}
+
+type WebPushSubscription struct {
+ Endpoint string `json:"endpoint"`
+ Keys struct {
+ P256DH string `json:"p256dh"`
+ Auth string `json:"auth"`
+ } `json:"key"`
+}
diff --git a/web/App.tsx b/web/App.tsx
index e1d4f683..d70900f4 100644
--- a/web/App.tsx
+++ b/web/App.tsx
@@ -1,9 +1,15 @@
-import { type JSX, useLayoutEffect } from 'react'
+import { type JSX, useCallback, useLayoutEffect } from 'react'
import { Link, Route, Routes, useLocation } from 'react-router-dom'
import { EventProvider } from './EventProvider'
+import { InfoTooltip } from './components/InfoTooltip'
+import { Menu } from './components/Menu'
+import { FluentAlert24Regular } from './components/icons/fluent-alert-24-regular'
+import { FluentAlertBadge24Regular } from './components/icons/fluent-alert-badge-24-regular'
import { FluentArrowLeft24Regular } from './components/icons/fluent-arrow-left-24-regular'
-import { SimpleIconsRss } from './components/icons/simple-icons-rss'
+import { FluentOpen16Regular } from './components/icons/fluent-open-16-regular'
+import { FluentWarning16Filled } from './components/icons/fluent-warning-16-filled'
+import { useWebPushSubscription } from './hooks'
import { DEFAULT_RSS_ENDPOINT } from './lib/api/api-client'
import { Dashboard } from './pages/Dashboard'
import { ImagePage } from './pages/ImagePage'
@@ -16,6 +22,30 @@ export function App(): JSX.Element {
document.documentElement.scrollTo({ top: 0, left: 0, behavior: 'instant' })
}, [location.pathname, location.search])
+ const [
+ webPushSupported,
+ webPushSubscription,
+ webPushSynced,
+ subscribe,
+ unsubscribe,
+ ] = useWebPushSubscription()
+
+ const webPushOk =
+ webPushSubscription.status !== 'rejected' &&
+ webPushSynced.status !== 'rejected'
+
+ const toggleWebPushSubscription = useCallback(() => {
+ if (webPushSubscription.status !== 'resolved') {
+ return
+ }
+
+ if (webPushSubscription.value) {
+ unsubscribe()
+ } else {
+ subscribe()
+ }
+ }, [webPushSubscription, subscribe, unsubscribe])
+
return (
<>
@@ -34,9 +64,46 @@ export function App(): JSX.Element {
-
-
-
+
+ ) : (
+
+ )
+ }
+ >
+ {webPushSupported && (
+
+ {webPushSubscription.status === 'resolved'
+ ? webPushSubscription.value !== null
+ ? 'Unsubscribe'
+ : 'Subscribe'
+ : ''}
+ {webPushSubscription.status === 'rejected' ? (
+ }
+ >
+ {webPushSubscription.error.toString()}
+
+ ) : webPushSynced.status === 'rejected' ? (
+ }
+ >
+ {webPushSynced.error.toString()}
+
+ ) : undefined}
+
+ )}
+
+
+ RSS feed
+
+
+
diff --git a/web/components/Menu.tsx b/web/components/Menu.tsx
new file mode 100644
index 00000000..013be15e
--- /dev/null
+++ b/web/components/Menu.tsx
@@ -0,0 +1,50 @@
+import {
+ type JSX,
+ type PropsWithChildren,
+ useEffect,
+ useRef,
+ useState,
+} from 'react'
+
+export type MenuProps = {
+ icon: JSX.Element
+}
+
+export function Menu({
+ icon,
+ children,
+}: PropsWithChildren): JSX.Element {
+ const openRef = useRef(null)
+ const [isOpen, setIsOpen] = useState(false)
+
+ useEffect(() => {
+ if (isOpen) {
+ const handle = (e: MouseEvent) => {
+ if (
+ e.target === openRef.current ||
+ openRef.current?.contains(e.target as Node)
+ ) {
+ return
+ }
+
+ setIsOpen(false)
+ }
+ document.addEventListener('click', handle)
+ return () => document.removeEventListener('click', handle)
+ }
+ }, [isOpen])
+
+ return (
+
+
+ {isOpen &&
}
+
+ )
+}
diff --git a/web/components/icons/fluent-alert-24-regular.tsx b/web/components/icons/fluent-alert-24-regular.tsx
new file mode 100644
index 00000000..c79dee04
--- /dev/null
+++ b/web/components/icons/fluent-alert-24-regular.tsx
@@ -0,0 +1,21 @@
+import type { SVGProps } from 'react'
+
+export function FluentAlert24Regular(props: SVGProps) {
+ return (
+
+ )
+}
diff --git a/web/components/icons/fluent-alert-badge-24-regular.tsx b/web/components/icons/fluent-alert-badge-24-regular.tsx
new file mode 100644
index 00000000..e8e529f8
--- /dev/null
+++ b/web/components/icons/fluent-alert-badge-24-regular.tsx
@@ -0,0 +1,21 @@
+import type { SVGProps } from 'react'
+
+export function FluentAlertBadge24Regular(props: SVGProps) {
+ return (
+
+ )
+}
diff --git a/web/components/icons/simple-icons-rss.tsx b/web/components/icons/simple-icons-rss.tsx
deleted file mode 100644
index 8a43392d..00000000
--- a/web/components/icons/simple-icons-rss.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { SVGProps } from 'react'
-
-export function SimpleIconsRss(props: SVGProps) {
- return (
-
- )
-}
diff --git a/web/hooks.ts b/web/hooks.ts
index 872c6528..aaf8d8ae 100644
--- a/web/hooks.ts
+++ b/web/hooks.ts
@@ -8,6 +8,9 @@ import {
useState,
} from 'react'
import { useSearchParams } from 'react-router-dom'
+import { useApiClient } from './lib/api/ApiProvider'
+import type { WebPushSubscription } from './lib/api/models'
+import { webPushSubscriptionDigest } from './lib/api/util'
export interface Filter {
tags: string[]
@@ -191,3 +194,143 @@ export function useLayout(): [
return [layout, setLayout]
}
+
+// Polyfill until proper typing support exists
+declare global {
+ interface Window {
+ pushManager?: PushManager
+ }
+}
+
+type Result =
+ | { status: 'loading' }
+ | { status: 'resolved'; value: T }
+ | { status: 'rejected'; error: E }
+
+export function useWebPushSubscription(): [
+ boolean,
+ Result,
+ Result,
+ () => void,
+ () => void,
+] {
+ const apiClient = useApiClient()
+
+ const supported = window.pushManager !== undefined
+
+ const [subscription, setSubscription] = useState<
+ Result
+ >({
+ status: 'loading',
+ })
+
+ const [synced, setSynced] = useState>({ status: 'loading' })
+
+ // Get the user agent's current subscription on load
+ useEffect(() => {
+ if (!window.pushManager) {
+ return
+ }
+
+ window.pushManager
+ .getSubscription()
+ .then((subscription) => {
+ setSubscription({
+ status: 'resolved',
+ value: subscription,
+ })
+ })
+ .catch((error) => {
+ setSubscription({ status: 'rejected', error })
+ console.error('failed to set subscription', error)
+ })
+ }, [])
+
+ // Keep synced state up-to-date
+ useEffect(() => {
+ if (subscription.status === 'loading') {
+ setSynced({ status: 'loading' })
+ return
+ } else if (subscription.status === 'rejected') {
+ setSynced({
+ status: 'rejected',
+ error: new Error('cannot check state when subscription status failed'),
+ })
+ return
+ }
+
+ if (subscription.value === null) {
+ // Assume synced as we have no way of knowing if the server has the
+ // subscription or not. In practice, the subscription will likely start
+ // bouncing when the user agent no longer has it - meaning the server
+ // should drop the subscription soon enough
+ setSynced({ status: 'resolved', value: true })
+ return
+ }
+
+ webPushSubscriptionDigest(subscription.value.toJSON())
+ .then((digest) => {
+ apiClient.checkWebPushSubscription(digest).then((ok) => {
+ setSynced({ status: 'resolved', value: ok })
+ })
+ })
+ .catch((error) => {
+ setSynced({ status: 'rejected', error })
+ console.error('failed to set sync status', error)
+ })
+ }, [subscription, apiClient])
+
+ const subscribe = useCallback(() => {
+ apiClient
+ .getWebPushServerKey()
+ .then((applicationServerKey) =>
+ window.pushManager
+ ?.subscribe({
+ applicationServerKey,
+ userVisibleOnly: true,
+ })
+ .then((subscription) =>
+ apiClient
+ .createWebPushSubscription(
+ subscription.toJSON() as WebPushSubscription
+ )
+ .then(() => subscription)
+ )
+ .then((subscription) => {
+ setSubscription({ status: 'resolved', value: subscription })
+ })
+ )
+ .catch((error) => {
+ console.error('failed to subscribe', error)
+ })
+ }, [apiClient])
+
+ const unsubscribe = useCallback(() => {
+ window.pushManager
+ ?.getSubscription()
+ .then((subscription) => {
+ if (!subscription) {
+ return
+ }
+
+ return webPushSubscriptionDigest(subscription.toJSON())
+ .then((digest) =>
+ subscription
+ .unsubscribe()
+ .then((ok) => [ok, digest] as [boolean, string])
+ )
+ .then(([ok, digest]) => {
+ if (!ok) {
+ throw new Error('Failed to unsubscribe')
+ }
+
+ return apiClient.deleteWebPushSubscription(digest)
+ })
+ })
+ .catch((error) => {
+ console.error('failed to unsubscribe', error)
+ })
+ }, [apiClient])
+
+ return [supported, subscription, synced, subscribe, unsubscribe]
+}
diff --git a/web/lib/api/api-client.ts b/web/lib/api/api-client.ts
index e30d0263..e0b6a77d 100644
--- a/web/lib/api/api-client.ts
+++ b/web/lib/api/api-client.ts
@@ -9,6 +9,7 @@ import type {
ImageReleaseNotes,
ImageSBOM,
ImageScorecard,
+ WebPushSubscription,
WorkflowRun,
} from './models'
@@ -68,7 +69,12 @@ export class ApiClient implements IApiClient {
}
const res = await fetch(
- `${this.#endpoint}/images?${searchParams.toString()}`
+ `${this.#endpoint}/images?${searchParams.toString()}`,
+ {
+ headers: {
+ accept: 'application/json',
+ },
+ }
)
if (res.status !== 200) {
@@ -82,7 +88,12 @@ export class ApiClient implements IApiClient {
const query = new URLSearchParams({ reference })
const res = await fetch(
- `${this.#endpoint}${path}${query === undefined ? '' : `?${query.toString()}`}`
+ `${this.#endpoint}${path}${query === undefined ? '' : `?${query.toString()}`}`,
+ {
+ headers: {
+ accept: 'application/json',
+ },
+ }
)
if (res.status === 404) {
@@ -133,6 +144,74 @@ export class ApiClient implements IApiClient {
return this.#getResource('/image/workflows/latest', reference)
}
+ async getWebPushServerKey(): Promise {
+ const res = await fetch(`${this.#endpoint}/webpush/key`, {
+ method: 'GET',
+ headers: {
+ accept: 'application/json',
+ },
+ })
+
+ if (res.status !== 200) {
+ throw new Error(`unexpected status code ${res.status}`)
+ }
+
+ const body = await res.json()
+ return body.key
+ }
+
+ async createWebPushSubscription(
+ subscription: WebPushSubscription
+ ): Promise {
+ const res = await fetch(`${this.#endpoint}/webpush/key`, {
+ method: 'POST',
+ headers: {
+ accept: 'application/json',
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify(subscription),
+ })
+
+ if (res.status === 409) {
+ throw new Error('subscription already exists')
+ } else if (res.status !== 201) {
+ throw new Error(`unexpected status code ${res.status}`)
+ }
+ }
+
+ async deleteWebPushSubscription(digest: string): Promise {
+ const res = await fetch(
+ `${this.#endpoint}/webpush/subscription/${encodeURIComponent(digest)}`,
+ {
+ method: 'DELETE',
+ }
+ )
+
+ if (res.status === 404) {
+ throw new Error('subscription does not exist')
+ } else if (res.status !== 201) {
+ throw new Error(`unexpected status code ${res.status}`)
+ }
+ }
+
+ async checkWebPushSubscription(digest: string): Promise {
+ const res = await fetch(
+ `${this.#endpoint}/webpush/subscription/${encodeURIComponent(digest)}`,
+ {
+ method: 'HEAD',
+ }
+ )
+
+ switch (res.status) {
+ case 200:
+ return true
+ case 404:
+ return false
+ default:
+ throw new Error(`unexpected status code ${res.status}`)
+ }
+ }
+
async scheduleImageScan(reference: string): Promise {
const query = new URLSearchParams({ reference })
diff --git a/web/lib/api/client.ts b/web/lib/api/client.ts
index b9d26479..320aa231 100644
--- a/web/lib/api/client.ts
+++ b/web/lib/api/client.ts
@@ -8,6 +8,7 @@ import type {
ImageReleaseNotes,
ImageSBOM,
ImageScorecard,
+ WebPushSubscription,
WorkflowRun,
} from './models'
@@ -29,6 +30,10 @@ export interface ApiClient {
getImageVulnerabilities(reference: string): Promise
getLatestImageWorkflow(reference: string): Promise
scheduleImageScan(reference: string): Promise
+ getWebPushServerKey(): Promise
+ createWebPushSubscription(subscription: WebPushSubscription): Promise
+ deleteWebPushSubscription(digest: string): Promise
+ checkWebPushSubscription(digest: string): Promise
}
export interface GetImagesOptions {
diff --git a/web/lib/api/demo-api-client.ts b/web/lib/api/demo-api-client.ts
index e3aa284f..3b6d5bdf 100644
--- a/web/lib/api/demo-api-client.ts
+++ b/web/lib/api/demo-api-client.ts
@@ -13,6 +13,7 @@ import type {
ImageSBOM,
ImageScorecard,
PaginationMetadata,
+ WebPushSubscription,
WorkflowRun,
} from './models'
@@ -190,4 +191,20 @@ export class DemoApiClient implements ApiClient {
async scheduleImageScan(reference: string): Promise {
return Promise.resolve()
}
+
+ getWebPushServerKey(): Promise {
+ return Promise.reject(new Error('not available in demo mode'))
+ }
+
+ createWebPushSubscription(subscription: WebPushSubscription): Promise {
+ return Promise.reject(new Error('not available in demo mode'))
+ }
+
+ deleteWebPushSubscription(digest: string): Promise {
+ return Promise.reject(new Error('not available in demo mode'))
+ }
+
+ checkWebPushSubscription(digest: string): Promise {
+ return Promise.reject(new Error('not available in demo mode'))
+ }
}
diff --git a/web/lib/api/models.ts b/web/lib/api/models.ts
index e110e209..da5dafba 100644
--- a/web/lib/api/models.ts
+++ b/web/lib/api/models.ts
@@ -125,3 +125,15 @@ export interface StepRun {
error?: string
duration?: number
}
+
+export interface WebPushServerKey {
+ key: string
+}
+
+export interface WebPushSubscription {
+ endpoint: string
+ keys: {
+ p256dh: string
+ auth: string
+ }
+}
diff --git a/web/lib/api/util.ts b/web/lib/api/util.ts
new file mode 100644
index 00000000..99883fd1
--- /dev/null
+++ b/web/lib/api/util.ts
@@ -0,0 +1,38 @@
+import type { WebPushSubscription } from './models'
+
+// Polyfill until proper typing support exists
+declare global {
+ interface Uint8Array {
+ toBase64?(options?: { alphabet?: 'base64url' }): string
+ }
+}
+
+/** Creates a digest uniquely identifying a {WebPushSubscription} */
+export async function webPushSubscriptionDigest(
+ subscription: WebPushSubscription | PushSubscriptionJSON
+): Promise {
+ // The content is the JSON-encoded subscription, with lexicographically sorted
+ // properties
+ const content = JSON.stringify({
+ endpoint: subscription.endpoint,
+ keys: { auth: subscription.keys?.auth, p256dh: subscription.keys?.p256dh },
+ })
+
+ const plaintext = new TextEncoder().encode(content)
+
+ const digest = await crypto.subtle.digest('sha256', plaintext)
+
+ const buffer = new Uint8Array(digest)
+ if (!buffer.toBase64) {
+ // I'm tired of implementing Base64-stuff in web browsers and now that the
+ // new functionality is basically supported everywhere (except for Chrome),
+ // let's just wait them out
+ throw new Error('unsupported browser')
+ }
+
+ const string = buffer.toBase64({
+ alphabet: 'base64url',
+ })
+
+ return `sha256-${string}`
+}
diff --git a/web/main.css b/web/main.css
index 7e22ebe3..2cd20fe6 100644
--- a/web/main.css
+++ b/web/main.css
@@ -89,4 +89,53 @@
.break-word {
word-break: break-word;
}
+
+ .menu-button {
+ anchor-name: --menu;
+ cursor: pointer;
+ }
+
+ .menu-container {
+ position: absolute;
+ /* TODO: Can we really use the same name for all instances of the component? */
+ position-anchor: --menu;
+ /* TODO: This doesn't seem to work reliably - we want it center, but the
+ * only place it's used now looks best being left */
+ position-area: bottom left;
+ z-index: 100;
+ box-sizing: border-box;
+ min-width: 138px;
+ background-color: light-dark(white, #1e1e1e);
+ border-radius: 4px;
+ padding: 4px;
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.22), 0 4px 8px rgba(0, 0, 0, 0.28);
+ animation-name: slide-in;
+ animation-duration: 400ms;
+ animation-fill-mode: forwards;
+ animation-timing-function: cubic-bezier(0, 0, 0, 1);
+ }
+
+ .menu-container li {
+ padding: 6px;
+ cursor: pointer;
+ border-radius: 4px;
+ font-size: 14px;
+ }
+
+ .menu-container li:hover {
+ padding: 6px;
+ cursor: pointer;
+ border-radius: 4px;
+ font-size: 14px;
+ background-color: light-dark(#f5f5f5, #262626);
+ }
+
+ @keyframes slide-in {
+ 0% {
+ transform: translate3d(0, -10px, 0);
+ }
+ 100% {
+ transform: none;
+ }
+ }
}