From 5a8608f465ca8e904bff2980ae35db48e02449b6 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Mon, 30 Mar 2026 10:48:44 +0200 Subject: [PATCH 1/5] Initial implementation of repository server support --- .env | 3 +- .env.development | 1 + .env.production | 3 +- src/lib/api/firmwareCDN.ts | 125 ------------------ src/lib/api/firmwareRepo.ts | 67 ++++++++++ .../components/FirmwareChannelSelector.svelte | 35 +++-- .../hubs/[hubId=guid]/update/+page.svelte | 2 +- src/routes/flashtool/+page.svelte | 11 +- .../flashtool/FirmwareBoardSelector.svelte | 20 +-- src/routes/flashtool/FirmwareFlasher.svelte | 21 ++- svelte.config.js | 2 +- 11 files changed, 122 insertions(+), 168 deletions(-) delete mode 100644 src/lib/api/firmwareCDN.ts create mode 100644 src/lib/api/firmwareRepo.ts diff --git a/.env b/.env index e66f28c5..e428f590 100644 --- a/.env +++ b/.env @@ -12,4 +12,5 @@ PUBLIC_DEVELOPMENT_BANNER=false 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 \ No newline at end of file +PUBLIC_GATEWAY_CSP_WILDCARD=https://*.openshock.app +PUBLIC_FIRMWARE_REPO_URL=https://repo.openshock.org \ No newline at end of file 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/.env.production b/.env.production index f239c486..16f1e1b0 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,5 @@ 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 \ No newline at end of file +PUBLIC_GATEWAY_CSP_WILDCARD=https://*.openshock.app +PUBLIC_FIRMWARE_REPO_URL=https://repo.openshock.org \ 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 a746dc72..00000000 --- a/src/lib/api/firmwareCDN.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { HashBuffer } from '$lib/utils/crypto'; - -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..b44db640 --- /dev/null +++ b/src/lib/api/firmwareRepo.ts @@ -0,0 +1,67 @@ +import { PUBLIC_FIRMWARE_REPO_URL } from '$env/static/public'; +import { HashBuffer } from '$lib/utils/crypto'; + +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 FirmwareLatestResponse { + version: string; + channel: string; + releaseDate: string; + artifacts: Record; +} + +const BASE_URL = PUBLIC_FIRMWARE_REPO_URL.replace(/\/+$/, ''); + +export async function FetchLatest( + channel: FirmwareChannel, + board?: string +): Promise { + let url = `${BASE_URL}/v2/firmware/latest/${channel}`; + if (board) { + url += `?board=${encodeURIComponent(board)}`; + } + const response = await fetch(url); + if (!response.ok) + throw new Error(`Failed to fetch latest firmware: ${response.status} ${response.statusText}`); + return await response.json(); +} + +export function ExtractBoards(latest: FirmwareLatestResponse): string[] { + return Object.keys(latest.artifacts).sort(); +} + +export function FindArtifact( + latest: FirmwareLatestResponse, + board: string, + type: string +): FirmwareArtifact | null { + const boardArtifacts = latest.artifacts[board]; + if (!boardArtifacts) return null; + return boardArtifacts.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 e5fb9730..4d9b0e88 100644 --- a/src/lib/components/FirmwareChannelSelector.svelte +++ b/src/lib/components/FirmwareChannelSelector.svelte @@ -1,27 +1,28 @@ diff --git a/src/routes/(app)/hubs/[hubId=guid]/update/+page.svelte b/src/routes/(app)/hubs/[hubId=guid]/update/+page.svelte index 9ebff57a..4e8aba18 100644 --- a/src/routes/(app)/hubs/[hubId=guid]/update/+page.svelte +++ b/src/routes/(app)/hubs/[hubId=guid]/update/+page.svelte @@ -82,7 +82,7 @@ import { cn } from '$lib/utils'; import { NumberToHexPadded } from '$lib/utils/convert'; import { onMount } from 'svelte'; - import type { FirmwareChannel } from '$lib/api/firmwareCDN'; + import type { FirmwareChannel } from '$lib/api/firmwareRepo'; let hubLoaded = $state(false); let otaLogs = $state([]); diff --git a/src/routes/flashtool/+page.svelte b/src/routes/flashtool/+page.svelte index 6029b39d..4524dadd 100644 --- a/src/routes/flashtool/+page.svelte +++ b/src/routes/flashtool/+page.svelte @@ -2,7 +2,7 @@ import { MessageCircleQuestionMark, SquareTerminal } from '@lucide/svelte'; import { browser } from '$app/environment'; import { PUBLIC_DISCORD_INVITE_URL } from '$env/static/public'; - import type { FirmwareChannel } from '$lib/api/firmwareCDN'; + import type { FirmwareChannel, FirmwareLatestResponse } from '$lib/api/firmwareRepo'; import Container from '$lib/components/Container.svelte'; import FirmwareChannelSelector from '$lib/components/FirmwareChannelSelector.svelte'; import TextInput from '$lib/components/input/TextInput.svelte'; @@ -60,6 +60,7 @@ let channel = $state('stable'); let version = $state(null); + let latestResponse = $state(null); let board = $state(null); let eraseBeforeFlash = $state(false); @@ -106,10 +107,10 @@ {#if manager}

Select Channel

- +

Select Board

- +
@@ -127,9 +128,9 @@
- {#if version && board} + {#if latestResponse && board} import { Check, ChevronsUpDown } from '@lucide/svelte'; - import { FetchVersionBoards } from '$lib/api/firmwareCDN'; + import { ExtractBoards, type FirmwareLatestResponse } from '$lib/api/firmwareRepo'; import { Button } from '$lib/components/ui/button'; import * as Command from '$lib/components/ui/command'; import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover'; import { cn } from '$lib/utils'; - /** Optional chip to constrain the list of boards to */ - //export let chip: string | null = null; interface Props { - version: string | null; + latestResponse: FirmwareLatestResponse | null; selectedBoard?: string | null; disabled?: boolean; } - let { version, selectedBoard = $bindable(null), disabled = false }: Props = $props(); + let { latestResponse, selectedBoard = $bindable(null), disabled = false }: Props = $props(); - let boardsCache = $state<{ [key: string]: string[] }>({}); - $effect(() => { - if (version && !(version in boardsCache)) { - let requestedVersion = version; - FetchVersionBoards(version).then((b) => { - boardsCache = { ...boardsCache, [requestedVersion]: b ?? [] }; - }); - } - }); - - let boards = $derived(version ? (boardsCache[version] ?? []) : []); + let boards = $derived(latestResponse ? ExtractBoards(latestResponse) : []); $effect(() => { if (boards.length === 0) { selectedBoard = null; diff --git a/src/routes/flashtool/FirmwareFlasher.svelte b/src/routes/flashtool/FirmwareFlasher.svelte index 27917377..4cd1ba57 100644 --- a/src/routes/flashtool/FirmwareFlasher.svelte +++ b/src/routes/flashtool/FirmwareFlasher.svelte @@ -1,13 +1,17 @@