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

@@ -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.