Update asset verification and user experience
- 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:
@@ -34,6 +34,7 @@ export default [
|
||||
URL: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
navigator: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -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">
|
||||
|
||||
95
frontend/src/lib/components/CodeBlock.svelte
Normal file
95
frontend/src/lib/components/CodeBlock.svelte
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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('"')) {
|
||||
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(/('[^']*')/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(/("[^&]*")/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(/("[^&]*"|'[^&]*')/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(
|
||||
/("[^&]*"|'[^&]*'|`[^`]*`)/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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
frontend/src/lib/settingsStore.ts
Normal file
15
frontend/src/lib/settingsStore.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
© {new Date().getFullYear()}
|
||||
|
||||
321
frontend/src/routes/api-docs/+page.svelte
Normal file
321
frontend/src/routes/api-docs/+page.svelte
Normal 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>
|
||||
Binary file not shown.
Reference in New Issue
Block a user