diff --git a/.env b/.env index 99108e92..de8517c7 100644 --- a/.env +++ b/.env @@ -17,6 +17,7 @@ PUBLIC_SITE_URL=https://openshock.app PUBLIC_SITE_SHORT_URL=https://openshock.app PUBLIC_BACKEND_API_URL=https://api.openshock.app PUBLIC_GATEWAY_CSP_WILDCARD=https://*.openshock.app +PUBLIC_FIRMWARE_REPO_URL=https://repo.openshock.org # Server-side only (Node adapter). When set to `true`, disables TLS certificate # validation for the server's own outgoing requests, allowing SSR/API calls to a @@ -41,4 +42,4 @@ PUBLIC_SIGNOZ_TRACE_PROPAGATION=false PUBLIC_SIGNOZ_DEPLOYMENT_ENVIRONMENT= # Extra OTel resource attributes, comma-separated key=value pairs (same format as the standard # OTEL_RESOURCE_ATTRIBUTES env var). Example: deployment.region=eu,team=frontend -PUBLIC_SIGNOZ_RESOURCE_ATTRIBUTES= \ No newline at end of file +PUBLIC_SIGNOZ_RESOURCE_ATTRIBUTES= diff --git a/.env.development b/.env.development index 15730ea9..3cbdbe48 100644 --- a/.env.development +++ b/.env.development @@ -2,6 +2,7 @@ PUBLIC_SITE_URL=https://openshock.dev PUBLIC_SITE_SHORT_URL=https://openshock.dev PUBLIC_BACKEND_API_URL=https://api.openshock.dev PUBLIC_GATEWAY_CSP_WILDCARD=https://*.openshock.dev +PUBLIC_FIRMWARE_REPO_URL=https://repo.openshock.dev PUBLIC_TURNSTILE_DEV_BYPASS_VALUE=dev-bypass PUBLIC_DEVELOPMENT_BANNER=true \ No newline at end of file diff --git a/src/lib/api/firmwareCDN.ts b/src/lib/api/firmwareCDN.ts deleted file mode 100644 index 23205eb6..00000000 --- a/src/lib/api/firmwareCDN.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { HashBuffer } from '@openshock/svelte-core/utils/crypto.js'; - -export const FirmwareChannels = ['stable', 'beta', 'develop'] as const; -export type FirmwareChannel = (typeof FirmwareChannels)[number]; - -const BASE_URL = 'https://firmware.openshock.org'; - -const versionUrl = (channel: string) => `${BASE_URL}/version-${channel}.txt`; -const boardsListUrl = (version: string) => `${BASE_URL}/${version}/boards.txt`; -const boardFileUrl = (version: string, board: string, fileName: string) => - `${BASE_URL}/${version}/${board}/${fileName}`; - -async function DownloadText(url: string) { - const response = await fetch(url); - if (!response.ok) - throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); - const text = await response.text(); - return text.trim(); -} -async function DownloadLines(url: string) { - const text = await DownloadText(url); - return text.split('\n').map((x) => x.trim()); -} -async function DownloadBinary(url: string) { - const response = await fetch(url); - if (!response.ok) - throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); - return await response.bytes(); -} - -export function FetchChannelVersion(channel: FirmwareChannel) { - return DownloadText(versionUrl(channel)); -} - -export function FetchVersionBoards(version: string) { - return DownloadLines(boardsListUrl(version)); -} - -export function DownloadBoardBinary(version: string, board: string, filename: string) { - return DownloadBinary(boardFileUrl(version, board, filename)); -} - -export async function GetBoardBinaryHashes( - version: string, - board: string, - hashType: 'md5' | 'sha256' = 'sha256' -) { - const lines = await DownloadLines(boardFileUrl(version, board, `hashes.${hashType}.txt`)); - - let hashLength: number; - switch (hashType) { - case 'md5': - hashLength = 32; - break; - case 'sha256': - hashLength = 64; - break; - default: - throw new Error(`Unsupported hash type: ${hashType}`); - } - - const hashes: Record = {}; - for (const line of lines) { - const parts = line.split(' '); - if (parts.length < 2) throw new Error(`Invalid hash line: ${line}`); - const hash = parts[0].trim(); - if (hash.length !== hashLength) throw new Error(`Invalid hash length in line: ${line}`); - if (!/^[a-f0-9]+$/i.test(hash)) throw new Error(`Invalid hash format in line: ${line}`); - - let filename = parts.slice(1).join(' ').trim(); // Join the rest in case filename has spaces - if (!filename) throw new Error(`Invalid filename in line: ${line}`); - - if (filename.startsWith('./')) { - // Remove leading './' if present - filename = filename.slice(2); - } - - hashes[filename] = hash; - } - - return hashes; -} -export async function GetBoardBinaryHash( - version: string, - board: string, - filename: string, - hashType: 'md5' | 'sha256' -) { - if (filename.startsWith('./')) { - filename = filename.slice(2); // Remove leading './' if present - } - - const hashes = await GetBoardBinaryHashes(version, board, hashType); - if (filename in hashes) { - return hashes[filename]; - } - - return null; -} - -export async function DownloadAndVerifyBoardBinary( - version: string, - board: string, - filename: string -) { - // Download the binary and its hash in parallel - const [binary, hash] = await Promise.all([ - DownloadBinary(boardFileUrl(version, board, filename)), - GetBoardBinaryHash(version, board, filename, 'sha256'), - ]); - - if (!hash) { - throw new Error(`No hash found for ${filename} in board ${board} version ${version}`); - } - - // Calculate the hash of the downloaded binary - const calculatedHash = await HashBuffer(binary, 'SHA-256'); - if (calculatedHash !== hash) { - throw new Error( - `Hash mismatch for ${filename} in board ${board} version ${version}: expected ${hash}, got ${calculatedHash}` - ); - } - - return binary; -} diff --git a/src/lib/api/firmwareRepo.ts b/src/lib/api/firmwareRepo.ts new file mode 100644 index 00000000..31e40b0e --- /dev/null +++ b/src/lib/api/firmwareRepo.ts @@ -0,0 +1,111 @@ +import { PUBLIC_FIRMWARE_REPO_URL } from '$env/static/public'; +import { HashBuffer } from '@openshock/svelte-core/utils/crypto.js'; + +export const FirmwareChannels = ['stable', 'beta', 'develop'] as const; +export type FirmwareChannel = (typeof FirmwareChannels)[number]; + +export interface FirmwareArtifact { + type: string; + url: string; + sha256Hash: string; + fileSize: number; +} + +export interface FirmwareBoard { + chip: string; + deprecated: boolean; + artifacts: FirmwareArtifact[]; +} + +export interface FirmwareRelease { + version: string; + channel: string; + releaseDate: string; + changelog: string; + boards: Record; +} + +export interface FirmwareUpdateResponse { + version: string; + artifact: FirmwareArtifact; +} + +export interface FirmwareVersionSummary { + version: string; + channel: string; + releaseDate: string; + changelog: string; +} + +const BASE_URL = PUBLIC_FIRMWARE_REPO_URL.replace(/\/+$/, ''); + +export async function FetchLatest(channel: FirmwareChannel): Promise { + const response = await fetch(`${BASE_URL}/v2/firmware/latest/${channel}`); + if (!response.ok) + throw new Error(`Failed to fetch latest firmware: ${response.status} ${response.statusText}`); + return await response.json(); +} + +export async function FetchVersion( + channel: FirmwareChannel, + version: string +): Promise { + const response = await fetch(`${BASE_URL}/v2/firmware/versions/${channel}/${version}`); + if (!response.ok) + throw new Error(`Failed to fetch firmware version: ${response.status} ${response.statusText}`); + return await response.json(); +} + +export async function FetchVersionHistory( + channel: FirmwareChannel, + limit = 20, + offset = 0 +): Promise<{ versions: FirmwareVersionSummary[]; total: number }> { + const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); + const response = await fetch(`${BASE_URL}/v2/firmware/versions/${channel}?${params}`); + if (!response.ok) + throw new Error(`Failed to fetch version history: ${response.status} ${response.statusText}`); + return await response.json(); +} + +export function ExtractBoards( + release: FirmwareRelease, + chip?: string | null, + includeDeprecated = false +): string[] { + const entries = Object.entries(release.boards); + const filtered = entries.filter(([, board]) => { + if (!includeDeprecated && board.deprecated) return false; + if (chip && board.chip !== chip) return false; + return true; + }); + return filtered.map(([name]) => name).sort(); +} + +export function FindArtifact( + release: FirmwareRelease, + board: string, + type: string +): FirmwareArtifact | null { + const boardInfo = release.boards[board]; + if (!boardInfo) return null; + return boardInfo.artifacts.find((a) => a.type === type) ?? null; +} + +async function DownloadBinary(url: string): Promise { + const response = await fetch(url); + if (!response.ok) + throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); + return await response.bytes(); +} + +export async function DownloadAndVerifyArtifact(artifact: FirmwareArtifact): Promise { + const binary = await DownloadBinary(artifact.url); + + const calculatedHash = await HashBuffer(binary.buffer as ArrayBuffer, 'SHA-256'); + if (calculatedHash.toUpperCase() !== artifact.sha256Hash.toUpperCase()) { + throw new Error(`Hash mismatch: expected ${artifact.sha256Hash}, got ${calculatedHash}`); + } + + return binary; +} diff --git a/src/lib/components/FirmwareChannelSelector.svelte b/src/lib/components/FirmwareChannelSelector.svelte index 563e1031..3ca5af20 100644 --- a/src/lib/components/FirmwareChannelSelector.svelte +++ b/src/lib/components/FirmwareChannelSelector.svelte @@ -1,28 +1,31 @@ diff --git a/src/routes/(app)/hubs/[hubId=guid]/update/+page.svelte b/src/routes/(app)/hubs/[hubId=guid]/update/+page.svelte index ee57feb8..0f8e5514 100644 --- a/src/routes/(app)/hubs/[hubId=guid]/update/+page.svelte +++ b/src/routes/(app)/hubs/[hubId=guid]/update/+page.svelte @@ -78,7 +78,7 @@ import { cn } from '@openshock/svelte-core/utils/shadcn.js'; import { NumberToHexPadded } from '@openshock/svelte-core/utils/convert.js'; import { onMount } from 'svelte'; - import type { FirmwareChannel } from '$lib/api/firmwareCDN'; + import type { FirmwareChannel } from '$lib/api/firmwareRepo'; import { PageHeader } from '@openshock/svelte-core/components'; let hubLoaded = $state(false); diff --git a/src/routes/terminal/+page.svelte b/src/routes/terminal/+page.svelte index 4b31985d..4f28e78f 100644 --- a/src/routes/terminal/+page.svelte +++ b/src/routes/terminal/+page.svelte @@ -8,7 +8,7 @@ Zap, } from '@lucide/svelte'; import { browser } from '$app/env'; - import type { FirmwareChannel } from '$lib/api/firmwareCDN'; + import type { FirmwareChannel, FirmwareRelease } from '$lib/api/firmwareRepo'; import { Container } from '@openshock/svelte-core/components'; import FirmwareChannelSelector from '$lib/components/FirmwareChannelSelector.svelte'; import { ChromeLogo } from '@openshock/svelte-core/components/svg'; @@ -46,6 +46,7 @@ let channel = $state('stable'); let version = $state(null); + let latestResponse = $state(null); // Tracks which channel+version the user explicitly confirmed. Changing either invalidates it. let confirmedChannel = $state(null); let confirmedVersion = $state(null); @@ -325,6 +326,7 @@ {#if version} @@ -352,7 +354,7 @@ {#if isCurrent}
@@ -381,9 +383,9 @@ {#if i === 2} - {#if isCurrent && version && board && connection} + {#if isCurrent && latestResponse && board && connection} import { Check, ChevronsUpDown } from '@lucide/svelte'; - import { FetchVersionBoards } from '$lib/api/firmwareCDN'; + import { ExtractBoards, type FirmwareRelease } from '$lib/api/firmwareRepo'; import { Button } from '@openshock/svelte-core/components/ui/button'; import * as Command from '@openshock/svelte-core/components/ui/command'; import { @@ -10,29 +10,23 @@ } from '@openshock/svelte-core/components/ui/popover'; import { cn } from '@openshock/svelte-core/utils/shadcn.js'; - /** Optional chip to constrain the list of boards to */ - //export let chip: string | null = null; interface Props { - version: string | null; + latestResponse: FirmwareRelease | null; + chip?: string | null; selectedBoard?: string | null; disabled?: boolean; } - let { version, selectedBoard = $bindable(null), disabled = false }: Props = $props(); + let { + latestResponse, + chip = null, + selectedBoard = $bindable(null), + disabled = false, + }: Props = $props(); - let boardsCache = $state<{ [key: string]: string[] }>({}); + let boards = $derived(latestResponse ? ExtractBoards(latestResponse, chip) : []); $effect(() => { - if (version && !(version in boardsCache)) { - let requestedVersion = version; - FetchVersionBoards(version).then((b) => { - boardsCache = { ...boardsCache, [requestedVersion]: b ?? [] }; - }); - } - }); - - let boards = $derived(version ? (boardsCache[version] ?? []) : []); - $effect(() => { - if (boards.length === 0) { + if (selectedBoard && !boards.includes(selectedBoard)) { selectedBoard = null; } }); diff --git a/src/routes/terminal/FirmwareFlasher.svelte b/src/routes/terminal/FirmwareFlasher.svelte index da374940..5be841b0 100644 --- a/src/routes/terminal/FirmwareFlasher.svelte +++ b/src/routes/terminal/FirmwareFlasher.svelte @@ -1,13 +1,17 @@