Update asset verification and user experience
All checks were successful
renovate / renovate (push) Successful in 2m8s
CI / build (push) Successful in 10m24s

- Added SRI hash injection during frontend build to improve security.
- Updated ESLint configuration to include 'navigator' as a global variable.
- Introduced a new `settingsStore` to manage user preferences for asset verification.
- Enhanced `SoftwareCard` and `VerificationModal` components to display contributor information and security checks.
- Updated `verificationStore` to handle expanded toast notifications for detailed verification steps.
- Implemented a new `CodeBlock` component for displaying code snippets with syntax highlighting.
- Improved API documentation and added new endpoints for fetching software and asset details.
This commit is contained in:
2025-12-27 16:29:05 -06:00
parent 3605710875
commit 4c60e3cf4a
19 changed files with 1209 additions and 97 deletions

View File

@@ -24,6 +24,8 @@ build-wasm:
build-frontend: build-wasm
cd $(FRONTEND_DIR) && pnpm install && pnpm build
@echo "Injecting SRI hashes..."
go run scripts/sri-gen/main.go
build-go:
go build -o $(BINARY_NAME) main.go

View File

@@ -34,6 +34,7 @@ export default [
URL: 'readonly',
setTimeout: 'readonly',
setInterval: 'readonly',
navigator: 'readonly',
},
},
plugins: {

View File

@@ -3,7 +3,11 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="/verifier/wasm_exec.js"></script>
<script
src="/verifier/wasm_exec.js"
integrity="sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce"
crossorigin="anonymous"
></script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@@ -0,0 +1,95 @@
<script lang="ts">
interface Props {
code: string;
language: 'bash' | 'go' | 'python' | 'javascript' | 'json';
}
let { code, language }: Props = $props();
function escapeHtml(text: string) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function highlight(text: string, lang: string) {
const escaped = escapeHtml(text);
if (lang === 'json') {
return escaped.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
(match) => {
let cls = 'text-blue-400'; // number
if (match.startsWith('&quot;')) {
if (match.endsWith(':')) {
cls = 'text-purple-400'; // key
} else {
cls = 'text-green-400'; // string
}
} else if (/true|false/.test(match)) {
cls = 'text-orange-400';
} else if (/null/.test(match)) {
cls = 'text-red-400';
}
return `<span class="${cls}">${match}</span>`;
}
);
}
if (lang === 'bash') {
return escaped
.replace(/(curl|https?:\/\/[^\s]+)/g, '<span class="text-blue-400">$1</span>')
.replace(/(-H|--header|-X|--request)/g, '<span class="text-purple-400">$1</span>')
.replace(/(&#039;[^']*&#039;)/g, '<span class="text-green-400">$1</span>');
}
if (lang === 'go') {
// Highlights strings and comments first but uses a more complex regex to avoid matching inside tags
// Actually, a better way is to do it in one pass or use non-capturing groups
return escaped
.replace(/\/\/.*/g, '<span class="text-zinc-500">$&</span>')
.replace(/(&quot;[^&]*&quot;)/g, '<span class="text-green-400">$1</span>')
.replace(
/\b(func|package|import|var|const|if|else|return|range|type|struct|interface)\b/g,
'<span class="text-purple-400">$1</span>'
)
.replace(
/\b(string|int|int64|bool|error|any|float64)\b/g,
'<span class="text-blue-400">$1</span>'
);
}
if (lang === 'python') {
return escaped
.replace(/#.*/g, '<span class="text-zinc-500">$&</span>')
.replace(/(&quot;[^&]*&quot;|&#039;[^&]*&#039;)/g, '<span class="text-green-400">$1</span>')
.replace(
/\b(def|import|from|as|if|else|return|for|in|class|with|try|except)\b/g,
'<span class="text-purple-400">$1</span>'
);
}
if (lang === 'javascript') {
return escaped
.replace(/\/\/.*/g, '<span class="text-zinc-500">$&</span>')
.replace(
/(&quot;[^&]*&quot;|&#039;[^&]*&#039;|`[^`]*`)/g,
'<span class="text-green-400">$1</span>'
)
.replace(
/\b(async|await|const|let|var|function|if|else|return|export|import|from)\b/g,
'<span class="text-purple-400">$1</span>'
);
}
return escaped;
}
</script>
<pre
class="font-mono text-xs leading-relaxed overflow-x-auto selection:bg-primary/30 selection:text-white text-zinc-300"><code
>{@html highlight(code, language)}</code
></pre>

View File

@@ -22,6 +22,8 @@
import type { Software, Asset } from '$lib/types';
import VerificationModal from './VerificationModal.svelte';
import { backgroundVerify } from '$lib/verificationStore';
import { verifierDisabled } from '$lib/settingsStore';
import { get } from 'svelte/store';
interface Props {
software: Software;
@@ -32,7 +34,13 @@
let { software, expandedReleases, onToggleReleases }: Props = $props();
let showReleaseNotes = $state(false);
let selectedOSs = $state<string[]>([]);
let activeVerification = $state<{ name: string; url: string; hash: string } | null>(null);
let activeVerification = $state<{
name: string;
url: string;
hash: string;
contributors?: any[];
security?: any;
} | null>(null);
const theme = getContext<{ isDark: boolean }>('theme');
@@ -100,8 +108,13 @@
}
}
function handleDownload(e: MouseEvent, asset: Asset) {
function handleDownload(e: MouseEvent, asset: Asset, release: any) {
if (asset.sha256) {
if (get(verifierDisabled)) {
// Let the default download happen if verifier is disabled
return;
}
const preference = localStorage.getItem('verification_preference');
if (preference === 'accept') {
@@ -118,6 +131,8 @@
name: asset.name,
url: asset.url,
hash: asset.sha256,
contributors: release.contributors,
security: release.security,
};
}
}
@@ -278,7 +293,7 @@
<div class="group/item flex items-center gap-2">
<a
href={asset.url}
onclick={(e) => handleDownload(e, asset)}
onclick={(e) => handleDownload(e, asset, release)}
class="flex-1 flex items-center justify-between p-1.5 rounded-md hover:bg-accent transition-colors text-[11px] border border-transparent hover:border-border min-w-0"
>
<div class="flex items-center gap-2 min-w-0">
@@ -329,7 +344,7 @@
<div class="group/item flex items-center gap-2">
<a
href={asset.url}
onclick={(e) => handleDownload(e, asset)}
onclick={(e) => handleDownload(e, asset, latest)}
class="flex-1 flex items-center justify-between p-2 rounded-md hover:bg-accent transition-colors text-sm border border-transparent hover:border-border min-w-0"
>
<div class="flex items-center gap-3 min-w-0">
@@ -400,6 +415,8 @@
assetName={activeVerification.name}
assetUrl={activeVerification.url}
expectedHash={activeVerification.hash}
contributors={activeVerification.contributors}
security={activeVerification.security}
onClose={() => (activeVerification = null)}
/>
{/if}

View File

@@ -1,20 +1,47 @@
<script lang="ts">
import { Shield, ShieldAlert, ShieldCheck, Loader2, X, Download } from 'lucide-svelte';
import {
Shield,
ShieldAlert,
ShieldCheck,
Loader2,
X,
Download,
ExternalLink,
Lock,
Users,
CheckCircle2,
Key,
Copy,
Check,
} from 'lucide-svelte';
import { verifyAsset } from '$lib/verifier';
import { backgroundVerify } from '$lib/verificationStore';
import type { Contributor, SourceSecurity, VerificationStep } from '$lib/types';
interface Props {
assetName: string;
assetUrl: string;
expectedHash: string;
contributors?: Contributor[];
security?: SourceSecurity;
onClose: () => void;
}
let { assetName, assetUrl, expectedHash, onClose }: Props = $props();
let { assetName, assetUrl, expectedHash, contributors, security, onClose }: Props = $props();
let dontShowAgain = $state(false);
let selectedContributor = $state<Contributor | null>(null);
let copiedKeyIndex = $state<number | null>(null);
let status = $state<'idle' | 'downloading' | 'verifying' | 'success' | 'error'>('idle');
let errorMessage = $state('');
let progress = $state(0);
let steps = $state<VerificationStep[]>([]);
function copyKey(key: string, index: number) {
navigator.clipboard.writeText(key);
copiedKeyIndex = index;
setTimeout(() => (copiedKeyIndex = null), 2000);
}
function savePreference(accepted: boolean) {
if (dontShowAgain) {
@@ -24,6 +51,14 @@
async function startVerification() {
savePreference(true);
// If "Don't show again" is checked and accepted, trigger background verification and close modal
if (dontShowAgain) {
backgroundVerify(assetName, assetUrl, expectedHash);
onClose();
return;
}
status = 'downloading';
try {
const response = await fetch(assetUrl);
@@ -56,8 +91,9 @@
status = 'verifying';
const result = await verifyAsset(data.buffer, expectedHash);
steps = result.steps;
if (result === true) {
if (result.valid) {
status = 'success';
// Trigger actual download from the blob
const blob = new Blob([data], { type: contentType });
@@ -71,7 +107,7 @@
document.body.removeChild(a);
} else {
status = 'error';
errorMessage = result;
errorMessage = result.error || 'Verification failed';
}
} catch (e: any) {
status = 'error';
@@ -103,7 +139,16 @@
<div class="p-2 rounded-lg bg-primary/10 text-primary">
<Shield class="w-6 h-6" />
</div>
<h3 class="text-xl font-bold">Verify Download</h3>
<div>
<h3 class="text-xl font-bold leading-none">Verify Download</h3>
<a
href="https://git.quad4.io/Quad4-Software/software-station/software-verifier"
target="_blank"
class="text-[10px] text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 mt-1"
>
Code <ExternalLink class="w-2.5 h-2.5" />
</a>
</div>
</div>
<button
onclick={onClose}
@@ -115,16 +160,114 @@
</div>
<div class="space-y-4">
<p class="text-sm text-muted-foreground">
Would you like to verify <strong>{assetName}</strong> client-side using WASM? This ensures the
file hasn't been tampered with.
</p>
<div class="bg-muted/30 rounded-xl p-4 border border-border/50">
<p class="text-xs text-muted-foreground mb-2">Target Asset</p>
<p class="text-sm font-bold truncate">{assetName}</p>
{#if security}
<div class="flex items-center gap-4 mt-3 pt-3 border-t border-border/50">
<div class="flex items-center gap-1.5" title="TLS Certificate Validation">
<Lock
class="w-3.5 h-3.5 {security.tls_valid ? 'text-green-500' : 'text-destructive'}"
/>
<span class="text-[10px] font-bold uppercase tracking-tight">TLS</span>
</div>
<div class="flex-1 text-right">
<span class="text-[10px] text-muted-foreground font-medium">{security.domain}</span>
</div>
</div>
{/if}
</div>
{#if contributors && contributors.length > 0}
<div class="bg-muted/30 rounded-xl p-4 border border-border/50">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<Users class="w-4 h-4 text-primary" />
<span class="text-xs font-bold uppercase tracking-wider">Contributors</span>
</div>
<span class="text-[10px] text-muted-foreground font-medium"
>{contributors.length} Users</span
>
</div>
<div class="flex flex-wrap gap-2">
{#each contributors as contributor}
<button
onclick={() =>
(selectedContributor =
selectedContributor?.username === contributor.username ? null : contributor)}
class="flex items-center gap-1.5 bg-background border {selectedContributor?.username ===
contributor.username
? 'border-primary shadow-sm'
: 'border-border'} rounded-full pl-1 pr-2 py-1 transition-all hover:border-primary/50"
title={contributor.username}
>
<img
src={contributor.avatar_url}
alt={contributor.username}
class="w-4 h-4 rounded-full"
/>
<span class="text-[10px] font-medium max-w-[80px] truncate"
>{contributor.username}</span
>
{#if contributor.gpg_keys && contributor.gpg_keys.length > 0}
<Key class="w-2.5 h-2.5 text-orange-500" />
{/if}
</button>
{/each}
</div>
{#if selectedContributor}
<div
class="mt-4 pt-4 border-t border-border/50 animate-in fade-in slide-in-from-top-1 duration-200"
>
<div class="flex items-center justify-between mb-2">
<p class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
Public GPG Keys
</p>
<span
class="text-[9px] px-1.5 py-0.5 rounded bg-orange-500/10 text-orange-600 font-bold"
>{selectedContributor.gpg_keys?.length || 0} Keys</span
>
</div>
{#if selectedContributor.gpg_keys && selectedContributor.gpg_keys.length > 0}
<div class="space-y-2">
{#each selectedContributor.gpg_keys as key, idx}
<div class="group relative">
<pre
class="text-[8px] bg-zinc-950 text-zinc-400 p-2 rounded-lg overflow-x-auto font-mono leading-tight max-h-32 scrollbar-thin"><code
>{key}</code
></pre>
<button
onclick={() => copyKey(key, idx)}
class="absolute top-2 right-2 p-1.5 bg-white/5 hover:bg-white/10 rounded-md transition-colors text-white/50 hover:text-white"
title="Copy Public Key"
>
{#if copiedKeyIndex === idx}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3" />
{/if}
</button>
</div>
{/each}
</div>
{:else}
<p class="text-[10px] text-muted-foreground italic">
No public GPG keys found for this user.
</p>
{/if}
</div>
{/if}
</div>
{/if}
{#if status === 'idle'}
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-3 pt-2">
<button
onclick={startVerification}
class="w-full py-3 px-4 bg-primary text-primary-foreground rounded-xl font-semibold hover:opacity-90 transition-opacity flex items-center justify-center gap-2"
class="w-full py-3 px-4 bg-primary text-primary-foreground rounded-xl font-semibold hover:opacity-90 transition-opacity flex items-center justify-center gap-2 shadow-lg shadow-primary/20"
>
<ShieldCheck class="w-5 h-5" />
Verify and Download
@@ -138,7 +281,7 @@
</button>
</div>
<div class="flex items-center gap-2 mt-4 pt-4 border-t border-border">
<div class="flex items-center gap-2 mt-2">
<label class="flex items-center gap-2 cursor-pointer group">
<div class="relative flex items-center">
<input type="checkbox" bind:checked={dontShowAgain} class="peer sr-only" />
@@ -146,7 +289,7 @@
class="w-4 h-4 rounded border border-input bg-background peer-checked:bg-primary peer-checked:border-primary transition-all"
></div>
<svg
class="absolute w-3 h-3 text-primary-foreground opacity-0 peer-checked:opacity-100 transition-opacity left-0.5"
class="absolute w-3 h-3 text-primary-foreground opacity-0 peer-checked:opacity-100 transition-opacity left-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -156,66 +299,153 @@
</svg>
</div>
<span
class="text-xs text-muted-foreground group-hover:text-foreground transition-colors"
>Don't show this again</span
class="text-[11px] text-muted-foreground group-hover:text-foreground transition-colors"
>Remember this preference</span
>
</label>
</div>
{:else if status === 'downloading' || status === 'verifying'}
<div class="py-8 flex flex-col items-center justify-center gap-4">
<div
class="py-8 bg-muted/20 rounded-xl border border-border/50 flex flex-col items-center justify-center gap-4"
>
<div class="relative w-16 h-16">
<Loader2 class="w-16 h-16 text-primary animate-spin" />
{#if status === 'downloading'}
<div
class="absolute inset-0 flex items-center justify-center text-[10px] font-bold"
>
{progress}%
</div>
{/if}
<Loader2 class="w-16 h-16 text-primary animate-spin opacity-20" />
<div class="absolute inset-0 flex items-center justify-center">
{#if status === 'downloading'}
<div class="text-sm font-bold text-primary">{progress}%</div>
{:else}
<Shield class="w-8 h-8 text-primary animate-pulse" />
{/if}
</div>
</div>
<div class="text-center">
<p class="text-sm font-bold tracking-tight">
{#if status === 'downloading'}
Downloading asset...
{:else}
Running WASM verification...
{/if}
</p>
<p class="text-[10px] text-muted-foreground mt-1">
{status === 'downloading'
? 'Fetching raw binary from source'
: 'Computing SHA256 in sandbox'}
</p>
</div>
<p class="text-sm font-medium animate-pulse">
{#if status === 'downloading'}
Downloading for verification...
{:else}
Verifying SHA256 hash...
{/if}
</p>
</div>
{:else if status === 'success'}
<div class="py-8 flex flex-col items-center justify-center gap-4 text-green-500">
<div class="p-4 rounded-full bg-green-500/10">
<ShieldCheck class="w-12 h-12" />
<div class="space-y-4">
<div
class="py-8 bg-green-500/5 rounded-xl border border-green-500/20 flex flex-col items-center justify-center gap-4 text-green-500"
>
<div class="p-4 rounded-full bg-green-500/10">
<ShieldCheck class="w-12 h-12" />
</div>
<div class="text-center">
<p class="font-bold text-lg leading-tight">Verification Successful!</p>
<p class="text-xs text-muted-foreground mt-1">Binary integrity is guaranteed</p>
</div>
</div>
<p class="font-bold text-lg">Verification Successful!</p>
<p class="text-sm text-muted-foreground text-center">
Checksum matches. Your download is starting now.
</p>
{#if steps.length > 0}
<div class="bg-muted/30 rounded-xl overflow-hidden border border-border/50">
<div
class="px-4 py-2 bg-muted/50 border-b border-border/50 flex items-center gap-2"
>
<CheckCircle2 class="w-3 h-3 text-green-500" />
<span class="text-[10px] font-bold uppercase tracking-wider"
>Verification Audit</span
>
</div>
<div class="p-3 space-y-3">
{#each steps as step}
<div class="flex gap-3">
<div class="mt-0.5">
{#if step.status === 'success'}
<CheckCircle2 class="w-3 h-3 text-green-500" />
{:else if step.status === 'error'}
<ShieldAlert class="w-3 h-3 text-destructive" />
{:else}
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin" />
{/if}
</div>
<div class="min-w-0">
<p class="text-[11px] font-bold leading-none">{step.name}</p>
<p class="text-[10px] text-muted-foreground mt-1 leading-normal">
{step.details}
</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
<button
onclick={onClose}
class="mt-4 px-6 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
class="w-full py-3 px-4 bg-green-500 text-white rounded-xl font-semibold hover:bg-green-600 transition-colors flex items-center justify-center gap-2"
>
Close
Start Using Software
</button>
</div>
{:else if status === 'error'}
<div class="py-8 flex flex-col items-center justify-center gap-4 text-destructive">
<div class="p-4 rounded-full bg-destructive/10">
<ShieldAlert class="w-12 h-12" />
<div class="space-y-4">
<div
class="py-8 bg-destructive/5 rounded-xl border border-destructive/20 flex flex-col items-center justify-center gap-4 text-destructive"
>
<div class="p-4 rounded-full bg-destructive/10">
<ShieldAlert class="w-12 h-12" />
</div>
<div class="text-center px-4">
<p class="font-bold text-lg leading-tight">Verification Failed</p>
<p class="text-xs text-muted-foreground mt-1">{errorMessage}</p>
</div>
</div>
<p class="font-bold text-lg">Verification Failed</p>
<p class="text-sm text-muted-foreground text-center px-4">
{errorMessage}
</p>
<div class="flex gap-3 mt-4 w-full">
{#if steps.length > 0}
<div class="bg-muted/30 rounded-xl overflow-hidden border border-border/50">
<div
class="px-4 py-2 bg-muted/50 border-b border-border/50 flex items-center gap-2"
>
<ShieldAlert class="w-3 h-3 text-destructive" />
<span class="text-[10px] font-bold uppercase tracking-wider text-destructive"
>Failure Analysis</span
>
</div>
<div class="p-3 space-y-3">
{#each steps as step}
<div class="flex gap-3">
<div class="mt-0.5">
{#if step.status === 'success'}
<CheckCircle2 class="w-3 h-3 text-green-500" />
{:else if step.status === 'error'}
<ShieldAlert class="w-3 h-3 text-destructive" />
{:else}
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin" />
{/if}
</div>
<div class="min-w-0">
<p class="text-[11px] font-bold leading-none">{step.name}</p>
<p class="text-[10px] text-muted-foreground mt-1 leading-normal">
{step.details}
</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
<div class="flex gap-3 pt-2">
<button
onclick={() => (status = 'idle')}
class="flex-1 px-4 py-2 bg-muted text-muted-foreground rounded-lg hover:bg-muted/80 transition-colors"
class="flex-1 py-3 px-4 bg-muted text-muted-foreground rounded-xl font-semibold hover:bg-muted/80 transition-colors"
>
Try Again
</button>
<button
onclick={skipVerification}
class="flex-1 px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:opacity-90 transition-opacity"
class="flex-1 py-3 px-4 bg-destructive text-destructive-foreground rounded-xl font-semibold hover:opacity-90 transition-opacity"
>
Download Anyway
</button>

View File

@@ -1,6 +1,16 @@
<script lang="ts">
import { verificationToasts, removeToast } from '$lib/verificationStore';
import { Shield, ShieldCheck, ShieldAlert, Loader2, X } from 'lucide-svelte';
import { verificationToasts, removeToast, toggleToast } from '$lib/verificationStore';
import {
Shield,
ShieldCheck,
ShieldAlert,
Loader2,
X,
ChevronDown,
ChevronUp,
CheckCircle2,
Info,
} from 'lucide-svelte';
import { flip } from 'svelte/animate';
import { fade, fly } from 'svelte/transition';
</script>
@@ -13,7 +23,18 @@
out:fade
class="w-80 bg-card border border-border rounded-xl shadow-lg overflow-hidden pointer-events-auto"
>
<div class="p-4">
<div
role="button"
tabindex="0"
class="w-full text-left p-4 cursor-pointer hover:bg-muted/30 transition-colors focus:outline-none focus:bg-muted/30"
onclick={() => toggleToast(toast.id)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleToast(toast.id);
}
}}
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<div
@@ -48,12 +69,22 @@
</p>
</div>
</div>
<button
onclick={() => removeToast(toast.id)}
class="p-1 hover:bg-muted rounded-md transition-colors text-muted-foreground"
>
<X class="w-3.5 h-3.5" />
</button>
<div class="flex items-center gap-1">
<button
onclick={(e) => {
e.stopPropagation();
removeToast(toast.id);
}}
class="p-1 hover:bg-muted rounded-md transition-colors text-muted-foreground"
>
<X class="w-3.5 h-3.5" />
</button>
{#if toast.expanded}
<ChevronUp class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<ChevronDown class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</div>
</div>
{#if toast.status === 'downloading'}
@@ -63,12 +94,50 @@
style="width: {toast.progress}%"
></div>
</div>
{:else if toast.status === 'error'}
<p class="mt-2 text-[9px] text-destructive leading-tight line-clamp-2">
{toast.errorMessage}
</p>
{/if}
</div>
{#if toast.expanded}
<div class="px-4 pb-4 animate-in slide-in-from-top-2 duration-200">
{#if toast.steps && toast.steps.length > 0}
<div class="bg-muted/30 rounded-lg overflow-hidden border border-border/50">
<div
class="px-3 py-1.5 bg-muted/50 border-b border-border/50 flex items-center gap-2"
>
<Info class="w-2.5 h-2.5 text-primary" />
<span class="text-[9px] font-bold uppercase tracking-wider">Verification Log</span>
</div>
<div class="p-2 space-y-2">
{#each toast.steps as step}
<div class="flex gap-2">
<div class="mt-0.5">
{#if step.status === 'success'}
<CheckCircle2 class="w-2.5 h-2.5 text-green-500" />
{:else if step.status === 'error'}
<ShieldAlert class="w-2.5 h-2.5 text-destructive" />
{:else}
<Loader2 class="w-2.5 h-2.5 text-muted-foreground animate-spin" />
{/if}
</div>
<div class="min-w-0">
<p class="text-[10px] font-bold leading-none">{step.name}</p>
<p class="text-[9px] text-muted-foreground mt-0.5 leading-tight">
{step.details}
</p>
</div>
</div>
{/each}
</div>
</div>
{:else if toast.status === 'error'}
<div class="p-3 bg-destructive/5 rounded-lg border border-destructive/20">
<p class="text-[10px] text-destructive leading-tight">
{toast.errorMessage}
</p>
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>

View File

@@ -0,0 +1,15 @@
import { writable } from 'svelte/store';
const isBrowser = typeof window !== 'undefined';
const storedVerifierDisabled = isBrowser
? localStorage.getItem('verifier_disabled') === 'true'
: false;
export const verifierDisabled = writable<boolean>(storedVerifierDisabled);
if (isBrowser) {
verifierDisabled.subscribe((value) => {
localStorage.setItem('verifier_disabled', String(value));
});
}

View File

@@ -7,10 +7,35 @@ export interface Asset {
is_sbom: boolean;
}
export interface Contributor {
username: string;
avatar_url: string;
gpg_keys?: string[];
}
export interface SourceSecurity {
domain: string;
tls_valid: boolean;
}
export interface Release {
tag_name: string;
body?: string;
assets: Asset[];
contributors?: Contributor[];
security?: SourceSecurity;
}
export interface VerificationStep {
name: string;
status: 'success' | 'error' | 'pending';
details: string;
}
export interface VerificationResult {
valid: boolean;
steps: VerificationStep[];
error?: string;
}
export interface Software {

View File

@@ -1,5 +1,6 @@
import { writable } from 'svelte/store';
import { verifyAsset } from './verifier';
import type { VerificationStep } from './types';
export type VerificationStatus = 'idle' | 'downloading' | 'verifying' | 'success' | 'error';
@@ -8,7 +9,9 @@ export interface VerificationToast {
assetName: string;
status: VerificationStatus;
progress: number;
steps: VerificationStep[];
errorMessage?: string;
expanded?: boolean;
}
export const verificationToasts = writable<VerificationToast[]>([]);
@@ -16,7 +19,14 @@ export const verificationToasts = writable<VerificationToast[]>([]);
export function addToast(assetName: string) {
const id = Math.random().toString(36).substring(7);
verificationToasts.update((toasts) => {
const newToast: VerificationToast = { id, assetName, status: 'downloading', progress: 0 };
const newToast: VerificationToast = {
id,
assetName,
status: 'downloading',
progress: 0,
steps: [],
expanded: false,
};
const newToasts = [newToast, ...toasts];
return newToasts.slice(0, 4); // Max 4 toasts
});
@@ -29,6 +39,12 @@ export function updateToast(id: string, updates: Partial<VerificationToast>) {
);
}
export function toggleToast(id: string) {
verificationToasts.update((toasts) =>
toasts.map((t) => (t.id === id ? { ...t, expanded: !t.expanded } : t))
);
}
export function removeToast(id: string) {
verificationToasts.update((toasts) => toasts.filter((t) => t.id !== id));
}
@@ -70,7 +86,9 @@ export async function backgroundVerify(assetName: string, assetUrl: string, expe
updateToast(id, { status: 'verifying' });
const result = await verifyAsset(data.buffer, expectedHash);
if (result === true) {
updateToast(id, { steps: result.steps });
if (result.valid) {
updateToast(id, { status: 'success' });
const blob = new Blob([data], { type: contentType });
const url = window.URL.createObjectURL(blob);
@@ -82,10 +100,18 @@ export async function backgroundVerify(assetName: string, assetUrl: string, expe
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Remove success toast after some time
setTimeout(() => removeToast(id), 5000);
// Keep success toast longer if expanded, otherwise remove after 5s
setTimeout(() => {
verificationToasts.update((toasts) => {
const toast = toasts.find((t) => t.id === id);
if (toast && !toast.expanded) {
return toasts.filter((t) => t.id !== id);
}
return toasts;
});
}, 5000);
} else {
updateToast(id, { status: 'error', errorMessage: result });
updateToast(id, { status: 'error', errorMessage: result.error });
}
} catch (e: any) {
updateToast(id, { status: 'error', errorMessage: e.message });

View File

@@ -1,3 +1,5 @@
import type { VerificationResult } from './types';
export async function loadVerifier() {
if (typeof window === 'undefined') return null;
if ((window as any).verifySHA256) return (window as any).verifySHA256;
@@ -11,9 +13,18 @@ export async function loadVerifier() {
return (window as any).verifySHA256;
}
export async function verifyAsset(data: ArrayBuffer, expectedHash: string): Promise<true | string> {
export async function verifyAsset(
data: ArrayBuffer,
expectedHash: string
): Promise<VerificationResult> {
const verify = await loadVerifier();
if (!verify) return 'WASM verifier not available';
if (!verify) {
return {
valid: false,
steps: [],
error: 'WASM verifier not available',
};
}
return verify(new Uint8Array(data), expectedHash);
}

View File

@@ -2,10 +2,11 @@
import '../app.css';
import '../lib/i18n';
import { onMount, setContext } from 'svelte';
import { Moon, Sun, Languages, Rss } from 'lucide-svelte';
import { Moon, Sun, Languages, Rss, ShieldOff, Shield } from 'lucide-svelte';
import { locale, waitLocale, locales, t } from 'svelte-i18n';
import { version } from '../../package.json';
import VerificationToasts from '$lib/components/VerificationToasts.svelte';
import { verifierDisabled } from '$lib/settingsStore';
let { children } = $props();
@@ -61,16 +62,25 @@
target="_blank"
class="text-sm font-medium hover:text-primary transition-colors">Quad4</a
>
<span class="text-muted-foreground mx-1">|</span>
<a href="/api-docs" class="text-sm font-medium hover:text-primary transition-colors"
>API</a
>
</div>
<div class="flex items-center gap-4">
<a
href="/api/rss"
target="_blank"
class="p-2 rounded-lg hover:bg-accent transition-colors text-muted-foreground hover:text-orange-500"
title="Global RSS Feed"
<button
onclick={() => ($verifierDisabled = !$verifierDisabled)}
class="p-2 rounded-lg hover:bg-accent transition-colors {$verifierDisabled
? 'text-destructive'
: 'text-muted-foreground'}"
title={$verifierDisabled ? 'Verifier Disabled' : 'Verifier Enabled'}
>
<Rss class="w-5 h-5" />
</a>
{#if $verifierDisabled}
<ShieldOff class="w-5 h-5" />
{:else}
<Shield class="w-5 h-5" />
{/if}
</button>
<div class="relative group flex items-center">
<Languages
class="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors cursor-pointer pointer-events-none"
@@ -127,6 +137,18 @@
>
{$t('common.disclaimer')}
</a>
<a href="/api-docs" class="text-muted-foreground hover:text-primary transition-colors">
API Docs
</a>
<a
href="/api/rss"
target="_blank"
class="flex items-center gap-1 text-muted-foreground transition-colors hover:text-orange-500"
title="Global RSS Feed"
>
<Rss class="h-3.5 w-3.5" />
<span>RSS</span>
</a>
</div>
<div class="text-sm text-muted-foreground">
&copy; {new Date().getFullYear()}

View File

@@ -0,0 +1,321 @@
<script lang="ts">
import {
Terminal,
Code2,
Globe,
Cpu,
Database,
Play,
Copy,
Check,
Shield,
Loader2,
Download as DownloadIcon,
Box,
Zap,
} from 'lucide-svelte';
import CodeBlock from '$lib/components/CodeBlock.svelte';
let responseData = $state<Record<string, any>>({});
let loadingPaths = $state<Record<string, boolean>>({});
let copied = $state(false);
let selectedLanguages = $state<Record<string, string>>({});
// TODO: Dont hardcode the endpoints or software list, use the API to get the endpoints and software otherwise hide this example.
const endpoints = [
{
id: 'software',
name: 'Get Software List',
path: '/api/software',
method: 'GET',
description: 'Fetches the complete list of available software, releases, and assets.',
examples: {
bash: 'curl https://software.quad4.io/api/software',
go: `package main\n\nimport (\n\t"fmt"\n\t"net/http"\n\t"io/io"\n)\n\nfunc main() {\n\tresp, _ := http.Get("https://software.quad4.io/api/software")\n\tbody, _ := io.ReadAll(resp.Body)\n\tfmt.Println(string(body))\n}`,
python: `import requests\n\nresponse = requests.get("https://software.quad4.io/api/software")\nprint(response.json())`,
javascript: `const response = await fetch("https://software.quad4.io/api/software");\nconst data = await response.json();\nconsole.log(data);`,
},
},
{
id: 'download',
name: 'Download Asset',
path: '/api/download',
method: 'GET',
description:
'Proxy a download from Gitea. Requires repository and asset name query parameters.',
examples: {
bash: 'curl -L "https://software.quad4.io/api/download?repo=webnews&asset=web-news-linux-amd64"',
go: `package main\n\nimport "net/http"\n\nfunc main() {\n\t// Use query params to specify repo and asset\n\thttp.Get("https://software.quad4.io/api/download?repo=webnews&asset=web-news-linux-amd64")\n}`,
python: `import requests\n\nparams = {"repo": "webnews", "asset": "web-news-linux-amd64"}\nresponse = requests.get("https://software.quad4.io/api/download", params=params)`,
javascript: `const url = new URL("https://software.quad4.io/api/download");\nurl.searchParams.set("repo", "webnews");\nurl.searchParams.set("asset", "web-news-linux-amd64");\nwindow.location.href = url.toString();`,
},
},
{
id: 'stats',
name: 'Get Global Stats',
path: '/api/stats',
method: 'GET',
description:
'Retrieves global platform statistics including unique users and data transferred.',
examples: {
bash: 'curl https://software.quad4.io/api/stats',
go: `package main\n\nimport (\n\t"fmt"\n\t"net/http"\n)\n\nfunc main() {\n\tresp, _ := http.Get("https://software.quad4.io/api/stats")\n\tfmt.Println(resp.Status)\n}`,
python: `import requests\n\nresponse = requests.get("https://software.quad4.io/api/stats")\nprint(response.status_code)`,
javascript: `fetch("https://software.quad4.io/api/stats")\n .then(res => res.json())\n .then(console.log);`,
},
},
{
id: 'rss',
name: 'RSS Feed',
path: '/api/rss',
method: 'GET',
description: 'Global RSS feed for all software updates.',
examples: {
bash: 'curl https://software.quad4.io/api/rss',
go: `// Fetch RSS XML\nresp, _ := http.Get("https://software.quad4.io/api/rss")`,
python: `import requests\nxml_data = requests.get("https://software.quad4.io/api/rss").text`,
javascript: `const xml = await fetch("https://software.quad4.io/api/rss").then(r => r.text());`,
},
},
];
async function runExample(id: string, path: string) {
loadingPaths[id] = true;
try {
const res = await fetch(path);
const contentType = res.headers.get('content-type');
if (contentType?.includes('application/json')) {
responseData[id] = await res.json();
} else {
// Handle non-JSON (like XML for RSS)
const text = await res.text();
responseData[id] = text;
}
} catch {
responseData[id] = {
error:
'Failed to fetch. This endpoint might return non-JSON data or require specific parameters.',
};
} finally {
loadingPaths[id] = false;
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => (copied = false), 2000);
}
function getLanguage(id: string) {
return selectedLanguages[id] || 'bash';
}
function setLanguage(id: string, lang: string) {
selectedLanguages[id] = lang;
}
</script>
<div class="max-w-5xl mx-auto space-y-12 py-8 px-4">
<div class="space-y-4 text-center">
<div
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary border border-primary/20 text-xs font-bold uppercase tracking-wider"
>
<Code2 class="w-3 h-3" />
Developer API
</div>
<h1 class="text-4xl font-black tracking-tight sm:text-5xl">Integrate with Software Station</h1>
<p class="text-xl text-muted-foreground max-w-2xl mx-auto">
Simple, powerful, and transparent APIs to fetch software metadata and platform statistics.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="p-6 rounded-2xl bg-card border border-border space-y-3 shadow-sm">
<div class="p-2 w-fit rounded-lg bg-blue-500/10 text-blue-500">
<Globe class="w-5 h-5" />
</div>
<h3 class="font-bold">CORS Enabled</h3>
<p class="text-sm text-muted-foreground">
All endpoints are CORS-enabled, allowing you to build frontend integrations directly.
</p>
</div>
<div class="p-6 rounded-2xl bg-card border border-border space-y-3 shadow-sm">
<div class="p-2 w-fit rounded-lg bg-green-500/10 text-green-500">
<Cpu class="w-5 h-5" />
</div>
<h3 class="font-bold">Real-time Data</h3>
<p class="text-sm text-muted-foreground">
Data is fetched directly from source and cached for optimal performance.
</p>
</div>
<div class="p-6 rounded-2xl bg-card border border-border space-y-3 shadow-sm">
<div class="p-2 w-fit rounded-lg bg-purple-500/10 text-purple-500">
<Shield class="w-5 h-5" />
</div>
<h3 class="font-bold">Transparent</h3>
<p class="text-sm text-muted-foreground">
Every asset includes verified SHA256 checksums for programmatic verification.
</p>
</div>
</div>
<div class="space-y-8">
<h2 class="text-2xl font-bold flex items-center gap-2">
<Terminal class="w-6 h-6 text-primary" />
API Reference
</h2>
<div class="space-y-6">
{#each endpoints as endpoint}
<div
class="group bg-card border border-border rounded-2xl overflow-hidden hover:border-primary/30 transition-all duration-300 shadow-sm"
>
<div class="p-6">
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-6">
<div class="space-y-1">
<h3 class="text-xl font-bold flex items-center gap-2">
{#if endpoint.id === 'download'}
<DownloadIcon class="w-5 h-5 text-primary" />
{:else if endpoint.id === 'software'}
<Box class="w-5 h-5 text-primary" />
{:else if endpoint.id === 'stats'}
<Zap class="w-5 h-5 text-primary" />
{:else}
<Code2 class="w-5 h-5 text-primary" />
{/if}
{endpoint.name}
</h3>
<p class="text-sm text-muted-foreground leading-relaxed">{endpoint.description}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
{#if endpoint.id !== 'download'}
<button
onclick={() => runExample(endpoint.id, endpoint.path)}
disabled={loadingPaths[endpoint.id]}
class="flex items-center gap-2 px-3 py-1.5 bg-primary/10 text-primary hover:bg-primary hover:text-white rounded-lg text-xs font-bold transition-all disabled:opacity-50"
>
{#if loadingPaths[endpoint.id]}
<Loader2 class="w-3 h-3 animate-spin" />
{:else}
<Play class="w-3 h-3" />
{/if}
Run Live
</button>
{/if}
<span
class="px-2 py-1 rounded bg-green-500/10 text-green-600 text-[10px] font-black uppercase border border-green-500/20"
>{endpoint.method}</span
>
<code
class="px-2 py-1 rounded bg-muted text-muted-foreground text-xs font-mono border border-border/50"
>{endpoint.path}</code
>
</div>
</div>
<div class="space-y-4">
<div
class="bg-zinc-950 rounded-xl overflow-hidden border border-zinc-800 shadow-inner"
>
<div
class="flex items-center justify-between px-4 py-2 bg-zinc-900/50 border-b border-white/5"
>
<div class="flex gap-4">
{#each Object.keys(endpoint.examples) as lang}
<button
onclick={() => setLanguage(endpoint.id, lang)}
class="text-[10px] font-bold uppercase tracking-widest transition-colors {getLanguage(
endpoint.id
) === lang
? 'text-primary'
: 'text-zinc-500 hover:text-zinc-300'}"
>
{lang}
</button>
{/each}
</div>
<button
onclick={() =>
copyToClipboard((endpoint.examples as any)[getLanguage(endpoint.id)])}
class="p-1.5 hover:bg-white/10 rounded-md transition-colors text-zinc-400"
title="Copy to clipboard"
>
{#if copied}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3" />
{/if}
</button>
</div>
<div class="p-4 overflow-x-auto min-h-[80px]">
<CodeBlock
code={(endpoint.examples as any)[getLanguage(endpoint.id)]}
language={getLanguage(endpoint.id) as any}
/>
</div>
</div>
{#if responseData[endpoint.id]}
<div class="animate-in fade-in slide-in-from-top-2 duration-300">
<div class="bg-zinc-900/50 rounded-xl overflow-hidden border border-zinc-800">
<div
class="px-4 py-2 bg-zinc-900 border-b border-white/5 flex items-center justify-between"
>
<span class="text-[10px] text-zinc-500 font-bold uppercase tracking-widest"
>Live Response</span
>
<div class="flex gap-1">
<div class="w-2 h-2 rounded-full bg-red-500/20"></div>
<div class="w-2 h-2 rounded-full bg-yellow-500/20"></div>
<div class="w-2 h-2 rounded-full bg-green-500/20"></div>
</div>
</div>
<div
class="p-4 max-h-[300px] overflow-y-auto scrollbar-thin scrollbar-thumb-white/10"
>
<CodeBlock
code={typeof responseData[endpoint.id] === 'string'
? responseData[endpoint.id]
: JSON.stringify(responseData[endpoint.id], null, 2)}
language={typeof responseData[endpoint.id] === 'string' &&
responseData[endpoint.id].includes('<?xml')
? 'bash'
: 'json'}
/>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
<div class="p-8 rounded-3xl bg-primary/5 border border-primary/20 space-y-4 shadow-sm">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-primary/10 text-primary">
<Database class="w-6 h-6" />
</div>
<h2 class="text-2xl font-bold">Self-Hosting</h2>
</div>
<p class="text-muted-foreground">
Software Station is open source and can be self-hosted with ease. All API features are
available out of the box.
</p>
<div class="flex gap-4 pt-2">
<a
href="https://git.quad4.io/Quad4-Software/software-station"
target="_blank"
rel="noopener noreferrer"
class="px-6 py-2 bg-primary text-primary-foreground rounded-xl font-bold hover:opacity-90 transition-opacity flex items-center gap-2 shadow-lg shadow-primary/20"
>
<Code2 class="w-4 h-4" />
Gitea repository
</a>
</div>
</div>
</div>

View File

Binary file not shown.

View File

@@ -2,15 +2,132 @@ package gitea
import (
"bufio"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"software-station/internal/models"
)
func CheckSourceSecurity(serverURL, token, owner, repo string) *models.SourceSecurity {
u, err := url.Parse(serverURL)
if err != nil {
return nil
}
domain := u.Hostname()
security := &models.SourceSecurity{
Domain: domain,
}
// TLS Check
conf := &tls.Config{
InsecureSkipVerify: false,
MinVersion: tls.VersionTLS12,
}
// Use port 443 for HTTPS domains
port := "443"
if u.Port() != "" {
port = u.Port()
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
conn, err := tls.DialWithDialer(dialer, "tcp", net.JoinHostPort(domain, port), conf)
if err == nil {
security.TLSValid = true
_ = conn.Close()
}
return security
}
func FetchUserGPGKeys(server, token, username string) ([]string, error) {
url := fmt.Sprintf("%s/api/v1/users/%s/gpg_keys", server, username)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
client := &http.Client{Timeout: DefaultTimeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea gpg api returned status %d", resp.StatusCode)
}
var giteaKeys []struct {
PublicKey string `json:"public_key"`
}
if err := json.NewDecoder(resp.Body).Decode(&giteaKeys); err != nil {
return nil, err
}
keys := make([]string, len(giteaKeys))
for i, k := range giteaKeys {
keys[i] = k.PublicKey
}
return keys, nil
}
func FetchContributors(server, token, owner, repo string) ([]models.Contributor, error) {
url := fmt.Sprintf("%s%s/%s/%s%s", server, RepoAPIPath, owner, repo, ContributorsSuffix)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
client := &http.Client{Timeout: DefaultTimeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea api returned status %d", resp.StatusCode)
}
var giteaContributors []struct {
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&giteaContributors); err != nil {
return nil, err
}
contributors := make([]models.Contributor, len(giteaContributors))
for i, c := range giteaContributors {
keys, _ := FetchUserGPGKeys(server, token, c.Username)
contributors[i] = models.Contributor{
Username: c.Username,
AvatarURL: c.AvatarURL,
GPGKeys: keys,
}
}
return contributors, nil
}
func DetectOS(filename string) string {
lower := strings.ToLower(filename)
@@ -215,6 +332,9 @@ func FetchReleases(server, token, owner, repo string) ([]models.Release, error)
return nil, err
}
contributors, _ := FetchContributors(server, token, owner, repo)
security := CheckSourceSecurity(server, token, owner, repo)
var releases []models.Release
for _, gr := range giteaReleases {
var assets []models.Asset
@@ -246,10 +366,12 @@ func FetchReleases(server, token, owner, repo string) ([]models.Release, error)
}
releases = append(releases, models.Release{
TagName: gr.TagName,
Body: gr.Body,
CreatedAt: gr.CreatedAt,
Assets: assets,
TagName: gr.TagName,
Body: gr.Body,
CreatedAt: gr.CreatedAt,
Assets: assets,
Contributors: contributors,
Security: security,
})
}

View File

@@ -3,7 +3,8 @@ package gitea
import "time"
const (
DefaultTimeout = 10 * time.Second
RepoAPIPath = "/api/v1/repos"
ReleasesSuffix = "/releases"
DefaultTimeout = 10 * time.Second
RepoAPIPath = "/api/v1/repos"
ReleasesSuffix = "/releases"
ContributorsSuffix = "/contributors"
)

View File

@@ -11,11 +11,24 @@ type Asset struct {
IsSBOM bool `json:"is_sbom"`
}
type Contributor struct {
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
GPGKeys []string `json:"gpg_keys,omitempty"`
}
type SourceSecurity struct {
Domain string `json:"domain"`
TLSValid bool `json:"tls_valid"`
}
type Release struct {
TagName string `json:"tag_name"`
Body string `json:"body,omitempty"`
CreatedAt time.Time `json:"created_at"`
Assets []Asset `json:"assets"`
TagName string `json:"tag_name"`
Body string `json:"body,omitempty"`
CreatedAt time.Time `json:"created_at"`
Assets []Asset `json:"assets"`
Contributors []Contributor `json:"contributors,omitempty"`
Security *SourceSecurity `json:"security,omitempty"`
}
type Software struct {

102
scripts/sri-gen/main.go Normal file
View File

@@ -0,0 +1,102 @@
package main
import (
"crypto/sha512"
"encoding/base64"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
)
func main() {
buildDir := "frontend/build"
if len(os.Args) > 1 {
buildDir = os.Args[1]
}
fmt.Printf("Generating SRI hashes for assets in %s...\n", buildDir)
err := filepath.Walk(buildDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".html") {
return processHTML(path, buildDir)
}
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func processHTML(htmlPath, buildDir string) error {
content, err := os.ReadFile(filepath.Clean(htmlPath)) // #nosec G304
if err != nil {
return err
}
updated := string(content)
// Regex to find script and link tags that don't have integrity yet
// Matches: <script src="/_app/..."
// Matches: <link rel="stylesheet" href="/_app/..."
scriptRegex := regexp.MustCompile(`<(script|link)\s+([^>]*)(src|href)=["'](/[^"']+)["']([^>]*)>`)
matches := scriptRegex.FindAllStringSubmatch(updated, -1)
for _, match := range matches {
tag := match[0]
attr := match[3]
url := match[4]
// Only process local assets starting with /
if !strings.HasPrefix(url, "/") || strings.HasPrefix(url, "//") {
continue
}
// Skip if integrity already exists
if strings.Contains(tag, "integrity=") {
continue
}
filePath := filepath.Join(buildDir, url)
hash, err := calculateSHA384(filePath)
if err != nil {
// Asset might not exist (e.g. dynamic URL or external-ish local path)
fmt.Printf(" Skipping %s: %v\n", url, err)
continue
}
integrity := fmt.Sprintf("integrity=\"sha384-%s\" crossorigin=\"anonymous\"", hash)
newTag := strings.Replace(tag, fmt.Sprintf("%s=\"%s\"", attr, url), fmt.Sprintf("%s=\"%s\" %s", attr, url, integrity), 1)
updated = strings.Replace(updated, tag, newTag, 1)
fmt.Printf(" Added SRI to %s (%s)\n", url, htmlPath)
}
if updated != string(content) {
return os.WriteFile(filepath.Clean(htmlPath), []byte(updated), 0644) // #nosec G306
}
return nil
}
func calculateSHA384(path string) (string, error) {
f, err := os.Open(filepath.Clean(path)) // #nosec G304
if err != nil {
return "", err
}
defer f.Close()
h := sha512.New384()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
}

View File

@@ -11,20 +11,56 @@ import (
// verifySHA256 verifies that the SHA256 hash of the input data matches the provided expected hash.
func verifySHA256(this js.Value, args []js.Value) any {
if len(args) < 2 {
return js.ValueOf("invalid arguments: expected (data []uint8, expectedHash string)")
return js.ValueOf(map[string]any{
"valid": false,
"error": "invalid arguments: expected (data []uint8, expectedHash string)",
})
}
dataLen := args[0].Get("length").Int()
data := make([]byte, dataLen)
steps := []any{}
js.CopyBytesToGo(data, args[0])
steps = append(steps, map[string]any{
"name": "Data Loading",
"status": "success",
"details": fmt.Sprintf("Successfully read %d bytes into WASM memory", dataLen),
})
expectedHash := args[1].String()
actualHash := computeSHA256(data)
steps = append(steps, map[string]any{
"name": "Checksum Calculation",
"status": "success",
"details": fmt.Sprintf("Computed SHA256: %s", actualHash),
})
if actualHash == expectedHash {
return js.ValueOf(true)
steps = append(steps, map[string]any{
"name": "Identity Verification",
"status": "success",
"details": "Computed hash matches the signed manifest",
})
return js.ValueOf(map[string]any{
"valid": true,
"steps": steps,
})
}
return js.ValueOf(fmt.Sprintf("hash mismatch: got %s, expected %s", actualHash, expectedHash))
steps = append(steps, map[string]any{
"name": "Identity Verification",
"status": "error",
"details": fmt.Sprintf("Hash mismatch! Got %s, expected %s", actualHash, expectedHash),
})
return js.ValueOf(map[string]any{
"valid": false,
"steps": steps,
"error": fmt.Sprintf("hash mismatch: got %s, expected %s", actualHash, expectedHash),
})
}
// main initializes the WebAssembly module and exposes verifySHA256 to the JavaScript global scope.