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 &&
      {children}
    } +
    + ) +} 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 ( + + {/* Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE */} + + + ) +} 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 ( + + {/* Icon from Fluent UI System Icons by Microsoft Corporation - https://github.com/microsoft/fluentui-system-icons/blob/main/LICENSE */} + + + ) +} 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; + } + } }