This commit is contained in:
2025-12-27 02:57:25 -06:00
parent 63a6f8f7dc
commit 1d5d6aacb4
68 changed files with 6884 additions and 0 deletions

27
.dockerignore Normal file
View File

@@ -0,0 +1,27 @@
.git
.gitignore
README.md
LICENSE
Dockerfile
.dockerignore
# Go
vendor/
software-station
*.out
*.test
coverage.out
# Frontend
node_modules/
frontend/node_modules/
frontend/build/
frontend/.svelte-kit/
# Application Data
.cache/
hashes.json
test_software.txt
test_hashes.json
test_updater.txt

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Binaries
software-station
software-station.exe
# Go
vendor/
*.out
*.test
coverage.out
# Frontend
node_modules/
frontend/node_modules/
frontend/build/
frontend/.svelte-kit/
frontend/.env
.env
.env.*
# Application Data
.cache/
hashes.json
test_software.txt
test_hashes.json
test_updater.txt
# OS
.DS_Store
Thumbs.db

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2025 Quad4
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

46
Makefile Normal file
View File

@@ -0,0 +1,46 @@
.PHONY: all build-frontend build-go clean release run lint scan check format test dev
BINARY_NAME=software-station
FRONTEND_DIR=frontend
BUILD_DIR=build
all: build-frontend build-go
dev:
@echo "Starting development environment..."
@pnpm --prefix $(FRONTEND_DIR) dev & go run main.go
build-frontend:
cd $(FRONTEND_DIR) && pnpm install && pnpm build
build-go:
go build -o $(BINARY_NAME) main.go
release: build-frontend
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BINARY_NAME) main.go
@echo "Release build complete: $(BINARY_NAME)"
run: all
./$(BINARY_NAME)
format:
go fmt ./...
cd $(FRONTEND_DIR) && pnpm run format
lint:
go vet ./...
cd $(FRONTEND_DIR) && pnpm run lint
scan:
gosec ./...
check:
cd $(FRONTEND_DIR) && pnpm run check
test:
go test -v -coverpkg=./... ./...
clean:
rm -rf $(FRONTEND_DIR)/build
rm -f $(BINARY_NAME)
rm -f coverage.out

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

6
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.svelte-kit
build
dist
.DS_Store

8
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

38
frontend/README.md Normal file
View File

@@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

59
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,59 @@
import js from '@eslint/js';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import sveltePlugin from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
export default [
js.configs.recommended,
{
files: ['**/*.{js,mjs,cjs,ts,svelte}'],
languageOptions: {
parser: tsParser,
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
},
globals: {
fetch: 'readonly',
console: 'readonly',
window: 'readonly',
document: 'readonly',
localStorage: 'readonly',
$state: 'readonly',
$derived: 'readonly',
$effect: 'readonly',
$props: 'readonly',
$inspect: 'readonly',
$host: 'readonly',
},
},
plugins: {
'@typescript-eslint': tsPlugin,
svelte: sveltePlugin,
},
rules: {
...tsPlugin.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
files: ['**/*.svelte'],
languageOptions: {
parser: svelteParser,
parserOptions: {
parser: tsParser,
},
},
plugins: {
svelte: sveltePlugin,
},
rules: {
...sveltePlugin.configs.recommended.rules,
},
},
{
ignores: ['node_modules/**', '.svelte-kit/**', 'build/**', 'dist/**'],
},
];

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

39
frontend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "eslint ."
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@typescript-eslint/eslint-plugin": "^8.50.1",
"@typescript-eslint/parser": "^8.50.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-plugin-svelte": "^3.13.1",
"lucide-svelte": "^0.562.0",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"svelte-eslint-parser": "^1.4.1",
"svelte-i18n": "^4.0.1",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.2.6"
}
}

3008
frontend/pnpm-lock.yaml generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

83
frontend/src/app.css Normal file
View File

@@ -0,0 +1,83 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
/* Custom thin scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/20 rounded-full transition-colors;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/40;
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.2) transparent;
}
}

13
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
frontend/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { Search } from 'lucide-svelte';
let { searchQuery = $bindable('') }: { searchQuery?: string } = $props();
</script>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">{$t('common.title')}</h1>
<p class="text-muted-foreground mt-1">{$t('common.subtitle')}</p>
</div>
<div class="relative w-full md:w-72">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
bind:value={searchQuery}
placeholder={$t('common.search')}
class="w-full pl-10 pr-4 py-2 rounded-lg border border-input bg-background focus:ring-2 focus:ring-ring transition-all outline-none"
/>
</div>
</div>

View File

@@ -0,0 +1,369 @@
<script lang="ts">
import { getContext } from 'svelte';
import { t } from 'svelte-i18n';
import BrandIcon from '$lib/components/icons/BrandIcon.svelte';
import {
Download,
Package,
Monitor,
Cpu,
Box,
Terminal,
Smartphone,
Shield,
Zap,
FileCheck,
FileQuestion,
FileText,
ChevronDown,
ChevronUp,
Rss,
} from 'lucide-svelte';
import type { Software } from '$lib/types';
interface Props {
software: Software;
expandedReleases: Record<string, boolean>;
onToggleReleases: (name: string) => void;
}
let { software, expandedReleases, onToggleReleases }: Props = $props();
let showReleaseNotes = $state(false);
let selectedOSs = $state<string[]>([]);
const theme = getContext<{ isDark: boolean }>('theme');
const availableOSs = $derived.by(() => {
const oss = new Set<string>();
software.releases?.forEach((release) => {
release.assets.forEach((asset) => {
if (asset.os) oss.add(asset.os.toLowerCase());
});
});
const supported = ['windows', 'linux', 'freebsd', 'openbsd', 'macos', 'android'];
return Array.from(oss)
.filter((os) => supported.includes(os))
.sort();
});
function toggleOSFilter(os: string) {
if (selectedOSs.includes(os)) {
selectedOSs = selectedOSs.filter((s) => s !== os);
} else {
selectedOSs = [...selectedOSs, os];
}
}
function formatSize(bytes: number) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function getOSIcon(os: string) {
switch (os.toLowerCase()) {
case 'macos':
return 'apple';
case 'windows':
case 'linux':
case 'android':
case 'freebsd':
case 'openbsd':
return os.toLowerCase();
default:
return null;
}
}
function getFallbackIcon(os: string) {
switch (os.toLowerCase()) {
case 'windows':
return Monitor;
case 'macos':
return Cpu;
case 'linux':
return Terminal;
case 'android':
return Smartphone;
case 'freebsd':
case 'openbsd':
return Shield;
case 'arm':
return Zap;
default:
return Box;
}
}
</script>
<div
class="flex flex-col rounded-xl border border-border bg-card hover:shadow-xl hover:border-primary/30 transition-all duration-300 overflow-hidden group"
>
<div class="p-6 flex-1">
<div class="flex items-start justify-between mb-4">
<div class="p-2 rounded-lg bg-primary/10 text-primary">
{#if software.avatar_url}
<img
src={software.avatar_url}
alt={software.name}
class="w-6 h-6 rounded-md object-cover"
/>
{:else}
<Package class="w-6 h-6" />
{/if}
</div>
<div class="flex items-center gap-2">
<a
href="/api/rss?software={software.name}"
class="p-2 rounded-lg hover:bg-accent transition-colors text-muted-foreground hover:text-orange-500"
title="RSS Feed"
target="_blank"
>
<Rss class="w-5 h-5" />
</a>
{#if !software.is_private && software.gitea_url}
<a
href="{software.gitea_url}/{software.owner}/{software.name}"
target="_blank"
rel="noopener noreferrer"
class="p-2 rounded-lg hover:bg-accent transition-colors group/gitea"
title={$t('common.viewOnGitea')}
>
<img
src={theme.isDark ? '/icons/gitea.webp' : '/icons/gitea-dark.webp'}
alt="Gitea"
class="w-5 h-5 opacity-50 group-hover/gitea:opacity-100 transition-opacity"
/>
</a>
{/if}
</div>
</div>
<div class="flex items-center gap-2 mb-2">
<h2 class="text-xl font-semibold capitalize">
{software.name.replace(/-/g, ' ')}
</h2>
{#if software.license}
<span
class="px-1.5 py-0.5 rounded border border-primary/20 bg-primary/5 text-primary/80 text-[10px] font-bold"
title={$t('common.license')}
>
{software.license}
</span>
{/if}
</div>
<p class="text-muted-foreground text-sm line-clamp-3 mb-4">
{software.description || $t('common.repoFor', { values: { name: software.name } })}
</p>
{#if software.topics && software.topics.length > 0}
<div class="flex flex-wrap gap-2 mb-4">
{#each software.topics as topic}
<span
class="px-2.5 py-1 rounded-md bg-primary/10 text-primary/80 text-xs font-semibold border border-primary/20"
>
{topic}
</span>
{/each}
</div>
{/if}
</div>
<div class="mt-auto border-t border-border bg-muted/30">
{#if software.releases && software.releases.length > 0}
{@const latest = software.releases[0]}
<div class="p-4 space-y-3">
<div
class="flex items-center justify-between text-xs font-medium uppercase tracking-wider text-muted-foreground"
>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
{$t('common.latest')}: {latest.tag_name}
</div>
{#if latest.body}
<button
onclick={() => (showReleaseNotes = !showReleaseNotes)}
class="flex items-center gap-1 px-1.5 py-0.5 rounded bg-primary/5 hover:bg-primary/10 text-[10px] transition-colors border border-primary/10"
title={$t('common.releaseNotes')}
>
<FileText class="w-3 h-3" />
<span>{$t('common.notes')}</span>
{#if showReleaseNotes}
<ChevronUp class="w-2.5 h-2.5" />
{:else}
<ChevronDown class="w-2.5 h-2.5" />
{/if}
</button>
{/if}
{#if availableOSs.length > 0}
<div class="flex items-center gap-1 ml-1 pl-2 border-l border-border/50">
{#each availableOSs as os}
{@const brand = getOSIcon(os)}
<button
onclick={() => toggleOSFilter(os)}
class="p-1 rounded-md transition-all duration-200 {selectedOSs.includes(os)
? 'bg-primary text-primary-foreground shadow-sm scale-110'
: 'hover:bg-primary/10 text-muted-foreground/60 hover:text-primary'}"
title={$t(`os.${os}`, { default: os })}
>
{#if brand}
<BrandIcon name={brand} class="w-3.5 h-3.5" />
{:else}
{@const Fallback = getFallbackIcon(os)}
<Fallback class="w-3.5 h-3.5" />
{/if}
</button>
{/each}
</div>
{/if}
</div>
{#if software.releases.length > 1}
<button
onclick={() => onToggleReleases(software.name)}
class="text-[10px] hover:text-primary transition-colors underline decoration-dotted"
>
{expandedReleases[software.name] ? $t('common.hide') : $t('common.previousReleases')}
</button>
{/if}
</div>
{#if showReleaseNotes && latest.body}
<div
class="text-xs bg-muted/50 rounded-lg p-3 text-muted-foreground whitespace-pre-wrap border border-border max-h-[150px] overflow-y-auto scrollbar-thin"
>
{latest.body}
</div>
{/if}
{#if expandedReleases[software.name]}
<div class="space-y-4 mt-2 border-l-2 border-primary/10 pl-3">
{#each software.releases as release, i}
{@const filteredAssets = release.assets.filter(
(a) => selectedOSs.length === 0 || selectedOSs.includes(a.os.toLowerCase())
)}
{#if filteredAssets.length > 0}
<div class="space-y-2">
<div class="flex items-center justify-between text-[10px] font-bold opacity-60">
<span>{release.tag_name} {i === 0 ? `(${$t('common.latest')})` : ''}</span>
</div>
<div class="space-y-1">
{#each filteredAssets as asset}
{@const brand = getOSIcon(asset.os)}
{@const FallbackIcon = getFallbackIcon(asset.os)}
<div class="group/item flex items-center gap-2">
<a
href={asset.url}
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">
{#if brand}
<BrandIcon
name={brand}
class="w-3.5 h-3.5 text-muted-foreground group-hover/item:text-primary shrink-0"
/>
{:else}
<FallbackIcon
class="w-3.5 h-3.5 text-muted-foreground group-hover/item:text-primary shrink-0"
/>
{/if}
<span class="truncate" title={asset.name}
>{$t(`os.${asset.os}`, { default: asset.os })}: {asset.name}</span
>
{#if asset.is_sbom}
<span
class="px-1 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 text-[8px] font-bold uppercase tracking-tight border border-blue-500/20"
>
{$t('common.sbom')}
</span>
{/if}
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[9px] opacity-60">{formatSize(asset.size)}</span>
<Download class="w-3 h-3 text-primary" />
</div>
</a>
</div>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{:else}
{@const filteredLatestAssets = latest.assets.filter(
(a) => selectedOSs.length === 0 || selectedOSs.includes(a.os.toLowerCase())
)}
<div
class="space-y-1 max-h-[240px] overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-primary/20 hover:scrollbar-thumb-primary/40"
>
{#if filteredLatestAssets.length > 0}
{#each filteredLatestAssets as asset}
{@const brand = getOSIcon(asset.os)}
{@const FallbackIcon = getFallbackIcon(asset.os)}
<div class="group/item flex items-center gap-2">
<a
href={asset.url}
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">
{#if brand}
<BrandIcon
name={brand}
class="w-4 h-4 text-muted-foreground group-hover/item:text-primary shrink-0"
/>
{:else}
<FallbackIcon
class="w-4 h-4 text-muted-foreground group-hover/item:text-primary shrink-0"
/>
{/if}
<span class="font-medium truncate" title={asset.name}>
{$t(`os.${asset.os}`, { default: asset.os })}: {asset.name}
</span>
{#if asset.is_sbom}
<span
class="px-1 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 text-[9px] font-bold uppercase tracking-tight border border-blue-500/20"
>
{$t('common.sbom')}
</span>
{/if}
</div>
<div class="flex items-center gap-3 shrink-0 ml-2">
<span class="text-[10px] text-muted-foreground">{formatSize(asset.size)}</span
>
<Download
class="w-4 h-4 opacity-0 group-hover/item:opacity-100 transition-opacity text-primary"
/>
</div>
</a>
{#if asset.sha256}
<div
class="p-1.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400 shrink-0"
title="{$t('common.checksumVerified')}: {asset.sha256}"
>
<FileCheck class="w-3.5 h-3.5" />
</div>
{:else}
<div
class="p-1.5 rounded-full bg-orange-500/10 text-orange-600 dark:text-orange-400 shrink-0"
title={$t('common.checksumMissing')}
>
<FileQuestion class="w-3.5 h-3.5" />
</div>
{/if}
</div>
{/each}
{:else}
<div class="text-center py-4 text-xs text-muted-foreground opacity-60">
{$t('common.noMatchingAssets')}
</div>
{/if}
</div>
{/if}
</div>
{:else}
<div class="text-sm text-center text-muted-foreground py-6">
{$t('common.noReleases')}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
interface Props {
name: string;
class?: string;
}
let { name, class: className = 'w-5 h-5' }: Props = $props();
const paths: Record<string, string> = {
apple:
'M12.15,6.83C11.2,6.83 9.74,5.75 8.19,5.79C6.15,5.82 4.28,6.97 3.23,8.81C1.11,12.48 2.69,17.91 4.75,20.9C5.76,22.35 6.96,23.99 8.54,23.94C10.06,23.87 10.63,22.95 12.48,22.95C14.31,22.95 14.83,23.94 16.44,23.9C18.08,23.87 19.12,22.41 20.12,20.94C21.28,19.25 21.76,17.62 21.78,17.53C21.74,17.51 18.6,16.3 18.56,12.67C18.54,9.63 21.04,8.18 21.16,8.11C19.73,6.02 17.54,5.79 16.77,5.73C14.77,5.57 13.09,6.83 12.15,6.83M15.53,3.83C16.37,2.82 16.93,1.4 16.77,0C15.57,0.05 14.11,0.81 13.24,1.82C12.46,2.72 11.79,4.16 11.97,5.54C13.31,5.64 14.69,4.85 15.53,3.83Z',
windows:
'M0,3.44L9.75,2.1V11.75H0V3.44M0,12.35H9.75V22L0,20.6V12.35M10.55,2L24,0V11.75H10.55V2M10.55,12.35H24V24L10.55,22.1V12.35Z',
linux:
'M14.62,8.35C14.2,8.63 12.87,9.39 12.67,9.54C12.28,9.85 11.92,9.83 11.53,9.53C11.33,9.37 10,8.61 9.58,8.34C9.1,8.03 9.13,7.64 9.66,7.42C11.3,6.73 12.94,6.78 14.57,7.45C15.06,7.66 15.08,8.05 14.62,8.35M21.84,15.63C20.91,13.54 19.64,11.64 18,9.97C17.47,9.42 17.14,8.8 16.94,8.09C16.84,7.76 16.77,7.42 16.7,7.08C16.5,6.2 16.41,5.3 16,4.47C15.27,2.89 14,2.07 12.16,2C10.35,2.05 9,2.81 8.21,4.4C8,4.83 7.85,5.28 7.75,5.74C7.58,6.5 7.43,7.29 7.25,8.06C7.1,8.71 6.8,9.27 6.29,9.77C4.68,11.34 3.39,13.14 2.41,15.12C2.27,15.41 2.13,15.7 2.04,16C1.85,16.66 2.33,17.12 3.03,16.96C3.47,16.87 3.91,16.78 4.33,16.65C4.74,16.5 4.9,16.6 5,17C5.65,19.15 7.07,20.66 9.24,21.5C13.36,23.06 18.17,20.84 19.21,16.92C19.28,16.65 19.38,16.55 19.68,16.65C20.14,16.79 20.61,16.89 21.08,17C21.57,17.09 21.93,16.84 22,16.36C22.03,16.1 21.94,15.87 21.84,15.63',
android:
'M16.61 15.15C16.15 15.15 15.77 14.78 15.77 14.32S16.15 13.5 16.61 13.5H16.61C17.07 13.5 17.45 13.86 17.45 14.32C17.45 14.78 17.07 15.15 16.61 15.15M7.41 15.15C6.95 15.15 6.57 14.78 6.57 14.32C6.57 13.86 6.95 13.5 7.41 13.5H7.41C7.87 13.5 8.24 13.86 8.24 14.32C8.24 14.78 7.87 15.15 7.41 15.15M16.91 10.14L18.58 7.26C18.67 7.09 18.61 6.88 18.45 6.79C18.28 6.69 18.07 6.75 18 6.92L16.29 9.83C14.95 9.22 13.5 8.9 12 8.91C10.47 8.91 9 9.24 7.73 9.82L6.04 6.91C5.95 6.74 5.74 6.68 5.57 6.78C5.4 6.87 5.35 7.08 5.44 7.25L7.1 10.13C4.25 11.69 2.29 14.58 2 18H22C21.72 14.59 19.77 11.7 16.91 10.14H16.91Z',
freebsd:
'M2.69,2C3.54,1.95 6.08,3.16 6.13,3.19C4.84,4 3.74,5.09 2.91,6.38C2.09,4.81 1.34,2.91 2,2.25C2.17,2.08 2.4,2 2.69,2M20.84,2.13C21.25,2.08 21.58,2.14 21.78,2.34C22.85,3.42 19.88,8.15 19.38,8.66C18.87,9.16 17.57,8.7 16.5,7.63C15.43,6.55 14.97,5.26 15.47,4.75C15.88,4.34 19.09,2.3 20.84,2.13M12,2.56C13.29,2.56 14.53,2.82 15.66,3.28C15.17,3.6 14.81,3.85 14.69,3.97C13.7,4.96 14.14,6.83 15.72,8.41C16.7,9.38 17.84,9.97 18.78,9.97C19.46,9.97 19.92,9.68 20.16,9.44C20.33,9.27 20.6,8.88 20.91,8.41C21.42,9.59 21.69,10.88 21.69,12.25C21.69,17.61 17.36,21.97 12,21.97C6.64,21.97 2.31,17.61 2.31,12.25C2.31,6.89 6.64,2.56 12,2.56Z',
openbsd:
'M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4M12,6A6,6 0 0,0 6,12A6,6 0 0,0 12,18A6,6 0 0,0 18,12A6,6 0 0,0 12,6M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8Z',
};
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class={className}
aria-hidden="true"
>
{#if paths[name.toLowerCase()]}
<path d={paths[name.toLowerCase()]} />
{/if}
</svg>

View File

@@ -0,0 +1,13 @@
import { init, register, getLocaleFromNavigator } from 'svelte-i18n';
const defaultLocale = 'en';
register('en', () => import('./locales/en.json'));
register('de', () => import('./locales/de.json'));
register('ru', () => import('./locales/ru.json'));
register('it', () => import('./locales/it.json'));
init({
fallbackLocale: defaultLocale,
initialLocale: getLocaleFromNavigator() || defaultLocale,
});

View File

@@ -0,0 +1,45 @@
{
"common": {
"title": "Softwareverteilung",
"subtitle": "Sicherer Download von Build-Binärdateien und Containern.",
"search": "Software suchen...",
"noSoftware": "Keine Software gefunden",
"tryAdjusting": "Versuchen Sie, Ihre Suchanfrage anzupassen.",
"latest": "Aktuellste",
"releaseNotes": "Versionshinweise",
"notes": "Notizen",
"openSource": "Open-Source",
"mit": "MIT",
"previousReleases": "Vorherige Versionen",
"hide": "Ausblenden",
"noReleases": "Keine Versionen verfügbar",
"viewOnGitea": "Auf Gitea ansehen",
"checksumVerified": "SHA256 Verifiziert",
"checksumMissing": "Prüfsumme nicht verfügbar",
"rightsReserved": "Alle Rechte vorbehalten.",
"repoFor": "Software-Repository für {name}.",
"privacy": "Datenschutzerklärung",
"terms": "Nutzungsbedingungen",
"disclaimer": "Haftungsausschluss",
"downloadDoc": "Herunterladen (.txt)",
"license": "Lizenz",
"sbom": "SBOM",
"backToSoftware": "Zurück zur Software",
"docLoadError": "Dokument konnte nicht geladen werden",
"docNotFound": "Das angeforderte rechtliche Dokument konnte nicht gefunden werden.",
"errorTitle": "Software konnte nicht geladen werden",
"errorMessage": "Der Server ist möglicherweise nicht erreichbar. Bitte versuchen Sie es später erneut.",
"retry": "Wiederholen",
"noMatchingAssets": "Keine Dateien entsprechen den Filtern"
},
"os": {
"windows": "Windows",
"linux": "Linux",
"macos": "macOS",
"android": "Android",
"freebsd": "FreeBSD",
"openbsd": "OpenBSD",
"arm": "ARM/AArch64",
"unknown": "Andere"
}
}

View File

@@ -0,0 +1,45 @@
{
"common": {
"title": "Software Distribution",
"subtitle": "Securely download built binaries and containers.",
"search": "Search software...",
"noSoftware": "No software found",
"tryAdjusting": "Try adjusting your search query.",
"latest": "Latest",
"releaseNotes": "Release Notes",
"notes": "Notes",
"openSource": "Open-Source",
"mit": "MIT",
"previousReleases": "Previous Releases",
"hide": "Hide",
"noReleases": "No releases available",
"viewOnGitea": "View on Gitea",
"checksumVerified": "SHA256 Verified",
"checksumMissing": "Checksum not provided",
"rightsReserved": "All rights reserved.",
"repoFor": "Software repository for {name}.",
"privacy": "Privacy Policy",
"terms": "Terms of Service",
"disclaimer": "Legal Disclaimer",
"downloadDoc": "Download (.txt)",
"license": "License",
"sbom": "SBOM",
"backToSoftware": "Back to Software",
"docLoadError": "Failed to load document",
"docNotFound": "The requested legal document could not be found.",
"errorTitle": "Failed to load software",
"errorMessage": "The server might be down or unreachable. Please try again later.",
"retry": "Retry",
"noMatchingAssets": "No assets match selected filters"
},
"os": {
"windows": "Windows",
"linux": "Linux",
"macos": "macOS",
"android": "Android",
"freebsd": "FreeBSD",
"openbsd": "OpenBSD",
"arm": "ARM/AArch64",
"unknown": "Other"
}
}

View File

@@ -0,0 +1,45 @@
{
"common": {
"title": "Distribuzione Software",
"subtitle": "Scarica in sicurezza binari e container compilati.",
"search": "Cerca software...",
"noSoftware": "Nessun software trovato",
"tryAdjusting": "Prova a modificare la query di ricerca.",
"latest": "Ultima",
"releaseNotes": "Note di rilascio",
"notes": "Note",
"openSource": "Open-Source",
"mit": "MIT",
"previousReleases": "Versioni precedenti",
"hide": "Nascondi",
"noReleases": "Nessun rilascio disponibile",
"viewOnGitea": "Visualizza su Gitea",
"checksumVerified": "SHA256 verificato",
"checksumMissing": "Checksum non fornito",
"rightsReserved": "Tutti i diritti riservati.",
"repoFor": "Repository software per {name}.",
"privacy": "Informativa sulla Privacy",
"terms": "Termini di Servizio",
"disclaimer": "Disclaimer Legale",
"downloadDoc": "Scarica (.txt)",
"license": "Licenza",
"sbom": "SBOM",
"backToSoftware": "Torna al Software",
"docLoadError": "Caricamento documento fallito",
"docNotFound": "Il documento legale richiesto non è stato trovato.",
"errorTitle": "Caricamento software fallito",
"errorMessage": "Il server potrebbe essere inattivo o irraggiungibile. Riprova più tardi.",
"retry": "Riprova",
"noMatchingAssets": "Nessun file corrisponde ai filtri selezionati"
},
"os": {
"windows": "Windows",
"linux": "Linux",
"macos": "macOS",
"android": "Android",
"freebsd": "FreeBSD",
"openbsd": "OpenBSD",
"arm": "ARM/AArch64",
"unknown": "Altro"
}
}

View File

@@ -0,0 +1,45 @@
{
"common": {
"title": "Распространение ПО",
"subtitle": "Безопасная загрузка готовых бинарных файлов и контейнеров.",
"search": "Поиск ПО...",
"noSoftware": "Программное обеспечение не найдено",
"tryAdjusting": "Попробуйте изменить поисковый запрос.",
"latest": "Последняя",
"releaseNotes": "Примечания к выпуску",
"notes": "Заметки",
"openSource": "Open-Source",
"mit": "MIT",
"previousReleases": "Предыдущие версии",
"hide": "Скрыть",
"noReleases": "Версии недоступны",
"viewOnGitea": "Посмотреть на Gitea",
"checksumVerified": "SHA256 подтвержден",
"checksumMissing": "Контрольная сумма не предоставлена",
"rightsReserved": "Все права защищены.",
"repoFor": "Репозиторий ПО для {name}.",
"privacy": "Политика конфиденциальности",
"terms": "Условия использования",
"disclaimer": "Отказ от ответственности",
"downloadDoc": "Скачать (.txt)",
"license": "Лицензия",
"sbom": "SBOM",
"backToSoftware": "Назад к ПО",
"docLoadError": "Не удалось загрузить документ",
"docNotFound": "Запрошенный юридический документ не найден.",
"errorTitle": "Не удалось загрузить ПО",
"errorMessage": "Сервер может быть недоступен. Пожалуйста, попробуйте позже.",
"retry": "Повторить",
"noMatchingAssets": "Нет файлов, соответствующих фильтрам"
},
"os": {
"windows": "Windows",
"linux": "Linux",
"macos": "macOS",
"android": "Android",
"freebsd": "FreeBSD",
"openbsd": "OpenBSD",
"arm": "ARM/AArch64",
"unknown": "Другое"
}
}

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

26
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,26 @@
export interface Asset {
name: string;
size: number;
url: string;
os: string;
sha256?: string;
is_sbom: boolean;
}
export interface Release {
tag_name: string;
body?: string;
assets: Asset[];
}
export interface Software {
name: string;
owner: string;
description: string;
releases: Release[];
gitea_url: string;
topics: string[];
license?: string;
is_private: boolean;
avatar_url?: string;
}

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import '../app.css';
import '../lib/i18n';
import { onMount, setContext } from 'svelte';
import { Moon, Sun, Languages, Rss } from 'lucide-svelte';
import { locale, waitLocale, locales, t } from 'svelte-i18n';
import { version } from '../../package.json';
let { children } = $props();
let isDark = $state(false);
let i18nReady = $state(false);
setContext('theme', {
get isDark() {
return isDark;
},
});
onMount(async () => {
await waitLocale();
i18nReady = true;
const theme = localStorage.getItem('theme');
isDark =
theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
});
function toggleTheme() {
isDark = !isDark;
if (isDark) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}
</script>
{#if i18nReady}
<div
class="min-h-screen flex flex-col bg-background text-foreground transition-colors duration-300"
>
<nav class="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-50">
<div
class="max-w-[1600px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"
>
<div class="flex items-center gap-3">
<img src="/logo.png" alt="Quad4 Logo" class="w-8 h-8 rounded-md" />
<a href="/" class="text-xl font-bold tracking-tight">Software Station</a>
<span class="text-muted-foreground mx-1">|</span>
<a
href="https://quad4.io"
target="_blank"
class="text-sm font-medium hover:text-primary transition-colors">Quad4</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"
>
<Rss class="w-5 h-5" />
</a>
<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"
/>
<select
bind:value={$locale}
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer appearance-none dark:bg-slate-900"
style="color-scheme: {isDark ? 'dark' : 'light'};"
aria-label="Select Language"
>
{#each $locales as l}
<option value={l} class="bg-card text-foreground">
{l.toUpperCase()}
</option>
{/each}
</select>
</div>
<button
onclick={toggleTheme}
class="p-2 rounded-lg hover:bg-accent transition-colors"
aria-label="Toggle theme"
>
{#if isDark}
<Sun class="w-5 h-5" />
{:else}
<Moon class="w-5 h-5" />
{/if}
</button>
</div>
</div>
</nav>
<main class="max-w-[1600px] mx-auto px-4 sm:px-6 lg:px-8 py-6">
{@render children()}
</main>
<footer class="border-t border-border mt-auto pt-6 pb-4">
<div class="max-w-[1600px] mx-auto px-4 text-center space-y-3">
<div class="flex flex-wrap justify-center gap-x-6 gap-y-2 text-sm font-medium">
<a
href="/legal/privacy"
class="text-muted-foreground hover:text-primary transition-colors"
>
{$t('common.privacy')}
</a>
<a href="/legal/terms" class="text-muted-foreground hover:text-primary transition-colors">
{$t('common.terms')}
</a>
<a
href="/legal/disclaimer"
class="text-muted-foreground hover:text-primary transition-colors"
>
{$t('common.disclaimer')}
</a>
</div>
<div class="text-sm text-muted-foreground">
&copy; {new Date().getFullYear()}
<a href="https://quad4.io" class="hover:text-primary underline decoration-dotted">Quad4</a
>.
{$t('common.rightsReserved')}
</div>
<div class="text-[10px] text-muted-foreground/60">
<a
href="https://git.quad4.io/Quad4-Software/software-station"
target="_blank"
class="hover:text-primary transition-colors"
>
{$t('common.openSource')}
</a>
<span class="mx-1">-</span>
<a
href="https://git.quad4.io/Quad4-Software/software-station"
target="_blank"
class="hover:text-primary transition-colors"
>
{$t('common.mit')}
</a>
<span class="mx-1">-</span>
v{version}
</div>
</div>
</footer>
</div>
{:else}
<div class="min-h-screen bg-background flex items-center justify-center">
<div
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
></div>
</div>
{/if}

View File

@@ -0,0 +1,3 @@
export const ssr = false;
export const prerender = false;
export const trailingSlash = 'always';

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { Package, AlertCircle, RefreshCcw } from 'lucide-svelte';
import type { Software } from '$lib/types';
import SearchBar from '$lib/components/SearchBar.svelte';
import SoftwareCard from '$lib/components/SoftwareCard.svelte';
let softwareList = $state<Software[]>([]);
let searchQuery = $state('');
let loading = $state(true);
let error = $state(false);
let expandedReleases = $state<Record<string, boolean>>({});
async function fetchSoftware() {
loading = true;
error = false;
try {
const res = await fetch('/api/software');
if (!res.ok) throw new Error('Failed to fetch');
softwareList = await res.json();
} catch (e) {
console.error('Failed to fetch software list', e);
error = true;
} finally {
loading = false;
}
}
onMount(fetchSoftware);
function toggleReleases(name: string) {
expandedReleases[name] = !expandedReleases[name];
}
let filteredSoftware = $derived(
softwareList.filter(
(s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(s.description && s.description.toLowerCase().includes(searchQuery.toLowerCase()))
)
);
</script>
<div class="space-y-8">
<SearchBar bind:searchQuery />
{#if loading}
<div class="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-6">
{#each Array(6) as _}
<div class="flex flex-col rounded-xl border border-border bg-card overflow-hidden">
<div class="p-6 flex-1 space-y-4">
<div class="flex items-start justify-between">
<div class="w-10 h-10 rounded-lg bg-muted animate-pulse"></div>
<div class="w-8 h-8 rounded-lg bg-muted animate-pulse"></div>
</div>
<div class="space-y-2">
<div class="h-6 w-1/2 bg-muted rounded animate-pulse"></div>
<div class="h-4 w-full bg-muted rounded animate-pulse"></div>
<div class="h-4 w-2/3 bg-muted rounded animate-pulse"></div>
</div>
<div class="flex gap-2">
<div class="h-6 w-16 bg-muted rounded-md animate-pulse"></div>
<div class="h-6 w-20 bg-muted rounded-md animate-pulse"></div>
</div>
</div>
<div class="p-4 border-t border-border bg-muted/30 space-y-3">
<div class="flex justify-between">
<div class="h-4 w-24 bg-muted rounded animate-pulse"></div>
<div class="h-4 w-16 bg-muted rounded animate-pulse"></div>
</div>
<div class="space-y-2">
<div class="h-10 w-full bg-muted rounded-md animate-pulse"></div>
<div class="h-10 w-full bg-muted rounded-md animate-pulse"></div>
</div>
</div>
</div>
{/each}
</div>
{:else if error}
<div class="text-center py-20 bg-destructive/5 rounded-2xl border border-destructive/10">
<AlertCircle class="w-16 h-16 mx-auto text-destructive opacity-50 mb-4" />
<h3 class="text-xl font-bold text-destructive">{$t('common.errorTitle')}</h3>
<p class="text-muted-foreground mt-2 max-w-md mx-auto">{$t('common.errorMessage')}</p>
<button
onclick={fetchSoftware}
class="mt-8 flex items-center gap-2 mx-auto px-6 py-2.5 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-all font-semibold shadow-lg shadow-destructive/20"
>
<RefreshCcw class="w-4 h-4" />
{$t('common.retry')}
</button>
</div>
{:else if filteredSoftware.length === 0}
<div class="text-center py-20">
<Package class="w-12 h-12 mx-auto text-muted-foreground opacity-50 mb-4" />
<h3 class="text-lg font-medium">{$t('common.noSoftware')}</h3>
<p class="text-muted-foreground">{$t('common.tryAdjusting')}</p>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-6">
{#each filteredSoftware as software}
<SoftwareCard {software} {expandedReleases} onToggleReleases={toggleReleases} />
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { FileText, Download, ArrowLeft } from 'lucide-svelte';
let docContent = $state('');
let loading = $state(true);
let error = $state(false);
const docType = $derived(page.params.doc);
onMount(async () => {
try {
const res = await fetch(`/api/legal?doc=${docType}`);
if (!res.ok) throw new Error('Failed to fetch');
docContent = await res.text();
} catch {
error = true;
} finally {
loading = false;
}
});
</script>
<div class="max-w-4xl mx-auto space-y-8 animate-in fade-in duration-500">
<div class="flex items-center justify-between">
<a
href="/"
class="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-primary transition-colors"
>
<ArrowLeft class="w-4 h-4" />
{$t('common.backToSoftware')}
</a>
{#if !loading && !error}
<a
href="/api/legal?doc={docType}&download=true"
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium"
>
<Download class="w-4 h-4" />
{$t('common.downloadDoc')}
</a>
{/if}
</div>
<div class="rounded-xl border border-border bg-card overflow-hidden">
<div class="p-6 border-b border-border bg-muted/30">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-primary/10 text-primary">
<FileText class="w-6 h-6" />
</div>
<h1 class="text-2xl font-bold tracking-tight">
{#if docType === 'privacy'}
{$t('common.privacy')}
{:else}
{$t(`common.${docType}`)}
{/if}
</h1>
</div>
</div>
<div class="p-8 prose dark:prose-invert max-w-none">
{#if loading}
<div class="space-y-4 animate-pulse">
<div class="h-4 bg-muted rounded w-3/4"></div>
<div class="h-4 bg-muted rounded w-1/2"></div>
<div class="h-4 bg-muted rounded w-5/6"></div>
<div class="h-4 bg-muted rounded w-2/3"></div>
</div>
{:else if error}
<div class="text-center py-12 space-y-4">
<div class="text-destructive text-lg font-bold">{$t('common.docLoadError')}</div>
<p class="text-muted-foreground">{$t('common.docNotFound')}</p>
</div>
{:else}
<pre
class="whitespace-pre-wrap font-sans text-foreground leading-relaxed bg-transparent border-none p-0">{docContent}</pre>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,6 @@
Contact: https://quad4.io
Expires: 2026-12-31T23:59:59.000Z
Preferred-Languages: en
Canonical: https://git.quad4.io/Quad4-Software/software-station/.well-known/security.txt
Policy: https://quad4.io/security

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
frontend/static/logo.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

24
frontend/svelte.config.js Normal file
View File

@@ -0,0 +1,24 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter({
fallback: 'index.html', // for SPA
pages: 'build',
assets: 'build',
precompress: false,
strict: true,
}),
},
};
export default config;

View File

@@ -0,0 +1,50 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'class',
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
};

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

11
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
});

17
go.mod Normal file
View File

@@ -0,0 +1,17 @@
module software-station
go 1.25.4
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/httprate v0.15.0
)
require (
github.com/go-chi/cors v1.2.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/unrolled/secure v1.17.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/time v0.14.0 // indirect
)

20
go.sum Normal file
View File

@@ -0,0 +1,20 @@
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=

10
internal/api/constants.go Normal file
View File

@@ -0,0 +1,10 @@
package api
const (
CompressionLevel = 5
LegalDir = "legal"
PrivacyFile = "privacy.txt"
TermsFile = "terms.txt"
DisclaimerFile = "disclaimer.txt"
)

425
internal/api/handlers.go Normal file
View File

@@ -0,0 +1,425 @@
package api
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"software-station/internal/models"
"software-station/internal/security"
"software-station/internal/stats"
"golang.org/x/time/rate"
)
type SoftwareCache struct {
mu sync.RWMutex
data []models.Software
}
func (c *SoftwareCache) Get() []models.Software {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data
}
func (c *SoftwareCache) Set(data []models.Software) {
c.mu.Lock()
defer c.mu.Unlock()
c.data = data
}
func (c *SoftwareCache) GetLock() *sync.RWMutex {
return &c.mu
}
func (c *SoftwareCache) GetDataPtr() *[]models.Software {
return &c.data
}
type Server struct {
GiteaToken string
SoftwareList *SoftwareCache
Stats *stats.Service
urlMap map[string]string
urlMapMu sync.RWMutex
rssCache atomic.Value // stores string
rssLastMod atomic.Value // stores time.Time
avatarCache string
}
func NewServer(token string, initialSoftware []models.Software, statsService *stats.Service) *Server {
s := &Server{
GiteaToken: token,
SoftwareList: &SoftwareCache{data: initialSoftware},
Stats: statsService,
urlMap: make(map[string]string),
avatarCache: ".cache/avatars",
}
s.rssCache.Store("")
s.rssLastMod.Store(time.Time{})
if err := os.MkdirAll(s.avatarCache, 0750); err != nil {
log.Printf("Warning: failed to create avatar cache directory: %v", err)
}
return s
}
func (s *Server) RegisterURL(targetURL string) string {
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(targetURL)))
s.urlMapMu.Lock()
s.urlMap[hash] = targetURL
s.urlMapMu.Unlock()
return hash
}
func (s *Server) APISoftwareHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
softwareList := s.SoftwareList.Get()
host := r.Host
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
proxiedList := make([]models.Software, len(softwareList))
for i, sw := range softwareList {
proxiedList[i] = sw
// If private, hide Gitea URL completely. If public, we can show it for the repo link.
if sw.IsPrivate {
proxiedList[i].GiteaURL = ""
}
// Proxy avatar if it exists
if sw.AvatarURL != "" {
hash := s.RegisterURL(sw.AvatarURL)
proxiedList[i].AvatarURL = fmt.Sprintf("%s://%s/api/avatar?id=%s", scheme, host, hash)
}
proxiedList[i].Releases = make([]models.Release, len(sw.Releases))
for j, rel := range sw.Releases {
proxiedList[i].Releases[j] = rel
proxiedList[i].Releases[j].Assets = make([]models.Asset, len(rel.Assets))
for k, asset := range rel.Assets {
proxiedList[i].Releases[j].Assets[k] = asset
hash := s.RegisterURL(asset.URL)
proxiedList[i].Releases[j].Assets[k].URL = fmt.Sprintf("%s://%s/api/download?id=%s", scheme, host, hash)
}
}
}
if err := json.NewEncoder(w).Encode(proxiedList); err != nil {
log.Printf("Error encoding software list: %v", err)
}
}
func (s *Server) DownloadProxyHandler(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "Missing id parameter", http.StatusBadRequest)
return
}
s.urlMapMu.RLock()
targetURL, exists := s.urlMap[id]
s.urlMapMu.RUnlock()
if !exists {
http.Error(w, "Invalid or expired download ID", http.StatusNotFound)
return
}
fingerprint := security.GetRequestFingerprint(r, s.Stats)
ua := strings.ToLower(r.UserAgent())
isSpeedDownloader := false
for _, sd := range security.SpeedDownloaders {
if strings.Contains(ua, sd) {
isSpeedDownloader = true
break
}
}
limit := security.DefaultDownloadLimit
burst := int(security.DefaultDownloadBurst)
if isSpeedDownloader {
limit = security.SpeedDownloaderLimit
burst = int(security.SpeedDownloaderBurst)
s.Stats.GlobalStats.Lock()
s.Stats.GlobalStats.LimitedRequests[fingerprint] = true
s.Stats.GlobalStats.Unlock()
}
s.Stats.DownloadStats.Lock()
limiter, exists := s.Stats.DownloadStats.Limiters[fingerprint]
if !exists {
limiter = rate.NewLimiter(limit, burst)
s.Stats.DownloadStats.Limiters[fingerprint] = limiter
}
s.Stats.KnownHashes.RLock()
data, exists := s.Stats.KnownHashes.Data[fingerprint]
s.Stats.KnownHashes.RUnlock()
var totalDownloaded int64
if exists {
totalDownloaded = atomic.LoadInt64(&data.TotalBytes)
}
if totalDownloaded > security.HeavyDownloaderThreshold {
limiter.SetLimit(security.HeavyDownloaderLimit)
s.Stats.GlobalStats.Lock()
s.Stats.GlobalStats.LimitedRequests[fingerprint] = true
s.Stats.GlobalStats.Unlock()
} else {
limiter.SetLimit(limit)
}
s.Stats.DownloadStats.Unlock()
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
http.Error(w, "Failed to create request", http.StatusInternalServerError)
return
}
// Forward Range headers for resumable downloads
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
if ifRangeHeader := r.Header.Get("If-Range"); ifRangeHeader != "" {
req.Header.Set("If-Range", ifRangeHeader)
}
if s.GiteaToken != "" {
req.Header.Set("Authorization", "token "+s.GiteaToken)
}
client := security.GetSafeHTTPClient(0)
resp, err := client.Do(req)
if err != nil {
http.Error(w, "Failed to fetch asset: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
http.Error(w, "Gitea returned error: "+resp.Status, http.StatusBadGateway)
return
}
// Copy all headers from the upstream response
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
tr := &security.ThrottledReader{
R: resp.Body,
Limiter: limiter,
Fingerprint: fingerprint,
Stats: s.Stats,
}
n, err := io.Copy(w, tr)
if err != nil {
log.Printf("Error copying proxy response: %v", err)
}
if n > 0 {
s.Stats.GlobalStats.Lock()
s.Stats.GlobalStats.SuccessDownloads[fingerprint] = true
s.Stats.GlobalStats.Unlock()
}
s.Stats.SaveHashes()
}
func (s *Server) LegalHandler(w http.ResponseWriter, r *http.Request) {
doc := r.URL.Query().Get("doc")
var filename string
switch doc {
case "privacy":
filename = PrivacyFile
case "terms":
filename = TermsFile
case "disclaimer":
filename = DisclaimerFile
default:
http.Error(w, "Invalid document", http.StatusBadRequest)
return
}
path := filepath.Join(LegalDir, filename)
data, err := os.ReadFile(path) // #nosec G304
if err != nil {
http.Error(w, "Document not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
// If download parameter is present, set Content-Disposition
if r.URL.Query().Get("download") == "true" {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
}
if _, err := w.Write(data); err != nil {
log.Printf("Error writing legal document: %v", err)
}
}
func (s *Server) AvatarHandler(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "Missing id parameter", http.StatusBadRequest)
return
}
cachePath := filepath.Join(s.avatarCache, id)
if _, err := os.Stat(cachePath); err == nil {
// Serve from cache
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, cachePath)
return
}
s.urlMapMu.RLock()
targetURL, exists := s.urlMap[id]
s.urlMapMu.RUnlock()
if !exists {
http.Error(w, "Invalid or expired avatar ID", http.StatusNotFound)
return
}
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
http.Error(w, "Failed to create request", http.StatusInternalServerError)
return
}
if s.GiteaToken != "" {
req.Header.Set("Authorization", "token "+s.GiteaToken)
}
client := security.GetSafeHTTPClient(0)
resp, err := client.Do(req)
if err != nil {
http.Error(w, "Failed to fetch avatar: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(w, "Gitea returned error: "+resp.Status, http.StatusBadGateway)
return
}
// Copy data to cache and response simultaneously
data, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, "Failed to read avatar data", http.StatusInternalServerError)
return
}
if err := os.WriteFile(cachePath, data, 0600); err != nil {
log.Printf("Warning: failed to cache avatar: %v", err)
}
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = w.Write(data)
}
func (s *Server) RSSHandler(w http.ResponseWriter, r *http.Request) {
softwareList := s.SoftwareList.Get()
targetSoftware := r.URL.Query().Get("software")
host := r.Host
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
baseURL := fmt.Sprintf("%s://%s", scheme, host)
// Collect all releases and sort by date
type item struct {
Software models.Software
Release models.Release
}
var items []item
for _, sw := range softwareList {
if targetSoftware != "" && sw.Name != targetSoftware {
continue
}
for _, rel := range sw.Releases {
items = append(items, item{Software: sw, Release: rel})
}
}
sort.Slice(items, func(i, j int) bool {
return items[i].Release.CreatedAt.After(items[j].Release.CreatedAt)
})
feedTitle := "Software Updates - Software Station"
feedDescription := "Latest software releases and updates"
selfLink := baseURL + "/api/rss"
if targetSoftware != "" {
feedTitle = fmt.Sprintf("%s Updates - Software Station", targetSoftware)
feedDescription = fmt.Sprintf("Latest releases and updates for %s", targetSoftware)
selfLink = fmt.Sprintf("%s/api/rss?software=%s", baseURL, targetSoftware)
}
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>` + feedTitle + `</title>
<link>` + baseURL + `</link>
<description>` + feedDescription + `</description>
<language>en-us</language>
<lastBuildDate>` + time.Now().Format(time.RFC1123Z) + `</lastBuildDate>
<atom:link href="` + selfLink + `" rel="self" type="application/rss+xml" />
`)
for i, it := range items {
if i >= 50 {
break
}
title := fmt.Sprintf("%s %s", it.Software.Name, it.Release.TagName)
link := baseURL
description := it.Software.Description
if it.Release.Body != "" {
description = it.Release.Body
}
fmt.Fprintf(&b, ` <item>
<title>%s</title>
<link>%s</link>
<description><![CDATA[%s]]></description>
<guid isPermaLink="false">%s-%s</guid>
<pubDate>%s</pubDate>
</item>
`, title, link, description, it.Software.Name, it.Release.TagName, it.Release.CreatedAt.Format(time.RFC1123Z))
}
b.WriteString(`</channel>
</rss>`)
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300")
_, _ = w.Write([]byte(b.String()))
}

44
internal/cache/cache.go vendored Normal file
View File

@@ -0,0 +1,44 @@
package cache
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"software-station/internal/models"
)
const cacheDir = ".cache"
func init() {
if err := os.MkdirAll(cacheDir, 0750); err != nil {
log.Printf("Warning: failed to create cache directory: %v", err)
}
}
func GetCachePath(owner, repo string) string {
return filepath.Join(cacheDir, filepath.Clean(fmt.Sprintf("%s_%s.json", owner, repo)))
}
func SaveToCache(owner, repo string, software models.Software) error {
path := GetCachePath(owner, repo)
data, err := json.MarshalIndent(software, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func GetFromCache(owner, repo string) (*models.Software, error) {
path := GetCachePath(owner, repo)
data, err := os.ReadFile(path) // #nosec G304
if err != nil {
return nil, err
}
var software models.Software
if err := json.Unmarshal(data, &software); err != nil {
return nil, err
}
return &software, nil
}

43
internal/cache/cache_test.go vendored Normal file
View File

@@ -0,0 +1,43 @@
package cache
import (
"os"
"software-station/internal/models"
"testing"
)
func TestCache(t *testing.T) {
owner := "test-owner"
repo := "test-repo"
software := models.Software{
Name: "test-repo",
Owner: "test-owner",
Description: "test desc",
}
// Clean up before and after
path := GetCachePath(owner, repo)
os.Remove(path)
defer os.Remove(path)
// Test GetFromCache missing
_, err := GetFromCache(owner, repo)
if err == nil {
t.Error("expected error for missing cache")
}
// Test SaveToCache
err = SaveToCache(owner, repo, software)
if err != nil {
t.Fatalf("failed to save to cache: %v", err)
}
// Test GetFromCache success
cached, err := GetFromCache(owner, repo)
if err != nil {
t.Fatalf("failed to get from cache: %v", err)
}
if cached.Name != software.Name || cached.Owner != software.Owner {
t.Errorf("cached data mismatch: %+v", cached)
}
}

86
internal/config/config.go Normal file
View File

@@ -0,0 +1,86 @@
package config
import (
"bufio"
"io"
"log"
"net/http"
"os"
"strings"
"software-station/internal/cache"
"software-station/internal/gitea"
"software-station/internal/models"
)
func LoadSoftware(path, server, token string) []models.Software {
return LoadSoftwareExtended(path, server, token, true)
}
func LoadSoftwareExtended(path, server, token string, useCache bool) []models.Software {
var reader io.Reader
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
resp, err := http.Get(path) // #nosec G107
if err != nil {
log.Printf("Error fetching remote config: %v", err)
return nil
}
defer resp.Body.Close()
reader = resp.Body
} else {
file, err := os.Open(path) // #nosec G304
if err != nil {
log.Printf("Warning: config file %s not found", path)
return nil
}
defer file.Close()
reader = file
}
var softwareList []models.Software
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.Split(line, "/")
if len(parts) == 2 {
owner, repo := parts[0], parts[1]
// Try to get from cache first
if useCache {
if cached, err := cache.GetFromCache(owner, repo); err == nil {
softwareList = append(softwareList, *cached)
continue
}
}
desc, topics, license, isPrivate, avatarURL, err := gitea.FetchRepoInfo(server, token, owner, repo)
if err != nil {
log.Printf("Error fetching repo info for %s/%s: %v", owner, repo, err)
}
releases, err := gitea.FetchReleases(server, token, owner, repo)
if err != nil {
log.Printf("Error fetching releases for %s/%s: %v", owner, repo, err)
}
sw := models.Software{
Name: repo,
Owner: owner,
Description: desc,
Releases: releases,
GiteaURL: server,
Topics: topics,
License: license,
IsPrivate: isPrivate,
AvatarURL: avatarURL,
}
softwareList = append(softwareList, sw)
if err := cache.SaveToCache(owner, repo, sw); err != nil {
log.Printf("Error saving to cache for %s/%s: %v", owner, repo, err)
}
}
}
return softwareList
}

View File

@@ -0,0 +1,62 @@
package config
import (
"net/http"
"net/http/httptest"
"os"
"software-station/internal/models"
"sync"
"testing"
"time"
)
func TestConfig(t *testing.T) {
// Test Local Config
configPath := "test_software.txt"
os.WriteFile(configPath, []byte("Owner/Repo\n#Comment\n\nOwner2/Repo2"), 0644)
defer os.Remove(configPath)
// Mock Gitea
mockGitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"description": "Test", "topics": [], "license": {"name": "MIT"}}`))
}))
defer mockGitea.Close()
list := LoadSoftware(configPath, mockGitea.URL, "")
if len(list) != 2 {
t.Errorf("expected 2 repos, got %d", len(list))
}
// Test Remote Config
mockRemote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Owner3/Repo3"))
}))
defer mockRemote.Close()
listRemote := LoadSoftware(mockRemote.URL, mockGitea.URL, "")
if len(listRemote) != 1 || listRemote[0].Name != "Repo3" {
t.Errorf("expected Repo3, got %v", listRemote)
}
}
func TestBackgroundUpdater(t *testing.T) {
configPath := "test_updater.txt"
os.WriteFile(configPath, []byte("Owner/Repo"), 0644)
defer os.Remove(configPath)
mockGitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"description": "Updated", "topics": [], "license": {"name": "MIT"}}`))
}))
defer mockGitea.Close()
var mu sync.RWMutex
softwareList := &[]models.Software{}
StartBackgroundUpdater(configPath, mockGitea.URL, "", &mu, softwareList, 100*time.Millisecond)
// Wait for ticker
time.Sleep(200 * time.Millisecond)
if len(*softwareList) == 0 {
t.Error("softwareList was not updated by background updater")
}
}

View File

@@ -0,0 +1,6 @@
package config
const (
DefaultConfigPath = "software.txt"
DefaultGiteaServer = "https://git.quad4.io"
)

View File

@@ -0,0 +1,30 @@
package config
import (
"log"
"sync"
"time"
"software-station/internal/models"
)
func StartBackgroundUpdater(path, server, token string, mu *sync.RWMutex, softwareList *[]models.Software, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
for range ticker.C {
log.Println("Checking for software updates...")
newList := LoadSoftwareFromGitea(path, server, token)
if len(newList) > 0 {
mu.Lock()
*softwareList = newList
mu.Unlock()
log.Printf("Software list updated with %d items", len(newList))
}
}
}()
}
// LoadSoftwareFromGitea always fetches from Gitea and updates cache
func LoadSoftwareFromGitea(path, server, token string) []models.Software {
return LoadSoftwareExtended(path, server, token, false)
}

298
internal/gitea/client.go Normal file
View File

@@ -0,0 +1,298 @@
package gitea
import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"software-station/internal/models"
)
func DetectOS(filename string) string {
lower := strings.ToLower(filename)
osMap := []struct {
patterns []string
suffixes []string
os string
}{
{
patterns: []string{"windows"},
suffixes: []string{".exe", ".msi"},
os: models.OSWindows,
},
{
patterns: []string{"linux"},
suffixes: []string{".deb", ".rpm", ".appimage", ".flatpak"},
os: models.OSLinux,
},
{
patterns: []string{"mac", "darwin"},
suffixes: []string{".dmg", ".pkg"},
os: models.OSMacOS,
},
{
patterns: []string{"freebsd"},
os: models.OSFreeBSD,
},
{
patterns: []string{"openbsd"},
os: models.OSOpenBSD,
},
{
patterns: []string{"android"},
suffixes: []string{".apk"},
os: models.OSAndroid,
},
{
patterns: []string{"arm", "aarch64"},
os: models.OSARM,
},
}
for _, entry := range osMap {
for _, p := range entry.patterns {
if strings.Contains(lower, p) {
return entry.os
}
}
for _, s := range entry.suffixes {
if strings.HasSuffix(lower, s) {
return entry.os
}
}
}
return models.OSUnknown
}
func FetchRepoInfo(server, token, owner, repo string) (string, []string, string, bool, string, error) {
url := fmt.Sprintf("%s%s/%s/%s", server, RepoAPIPath, owner, repo)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", nil, "", false, "", err
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
client := &http.Client{Timeout: DefaultTimeout}
resp, err := client.Do(req)
if err != nil {
return "", nil, "", false, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", nil, "", false, "", fmt.Errorf("gitea api returned status %d", resp.StatusCode)
}
var info struct {
Description string `json:"description"`
Topics []string `json:"topics"`
DefaultBranch string `json:"default_branch"`
Licenses []string `json:"licenses"`
Private bool `json:"private"`
AvatarURL string `json:"avatar_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return "", nil, "", false, "", err
}
license := ""
if len(info.Licenses) > 0 {
license = info.Licenses[0]
}
if license == "" {
// Try to detect license from file if API returns nothing
license = detectLicenseFromFile(server, token, owner, repo, info.DefaultBranch)
}
return info.Description, info.Topics, license, info.Private, info.AvatarURL, nil
}
func detectLicenseFromFile(server, token, owner, repo, defaultBranch string) string {
branches := []string{"main", "master"}
if defaultBranch != "" {
// Put default branch first
branches = append([]string{defaultBranch}, "main", "master")
}
// Deduplicate
seen := make(map[string]bool)
var finalBranches []string
for _, b := range branches {
if !seen[b] {
seen[b] = true
finalBranches = append(finalBranches, b)
}
}
for _, branch := range finalBranches {
url := fmt.Sprintf("%s/%s/%s/raw/branch/%s/LICENSE", server, owner, repo, branch)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
continue
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
client := &http.Client{Timeout: DefaultTimeout}
resp, err := client.Do(req)
if err != nil {
continue
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
// Read first few lines to guess license
scanner := bufio.NewScanner(resp.Body)
for i := 0; i < 5 && scanner.Scan(); i++ {
line := strings.ToUpper(scanner.Text())
if strings.Contains(line, "MIT LICENSE") {
return "MIT"
}
if strings.Contains(line, "GNU GENERAL PUBLIC LICENSE") || strings.Contains(line, "GPL") {
return "GPL"
}
if strings.Contains(line, "APACHE LICENSE") {
return "Apache-2.0"
}
if strings.Contains(line, "BSD") {
return "BSD"
}
}
return "LICENSE" // Found file but couldn't detect type
}
}
return ""
}
func IsSBOM(filename string) bool {
lower := strings.ToLower(filename)
return strings.Contains(lower, "sbom") ||
strings.Contains(lower, "cyclonedx") ||
strings.Contains(lower, "spdx") ||
strings.HasSuffix(lower, ".cdx.json") ||
strings.HasSuffix(lower, ".spdx.json")
}
func FetchReleases(server, token, owner, repo string) ([]models.Release, error) {
url := fmt.Sprintf("%s%s/%s/%s%s", server, RepoAPIPath, owner, repo, ReleasesSuffix)
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 giteaReleases []struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
Assets []struct {
Name string `json:"name"`
Size int64 `json:"size"`
URL string `json:"browser_download_url"`
} `json:"assets"`
}
if err := json.NewDecoder(resp.Body).Decode(&giteaReleases); err != nil {
return nil, err
}
var releases []models.Release
for _, gr := range giteaReleases {
var assets []models.Asset
var checksumsURL string
// First pass: identify assets and look for checksum file
for _, ga := range gr.Assets {
if ga.Name == "SHA256SUMS" {
checksumsURL = ga.URL
continue
}
assets = append(assets, models.Asset{
Name: ga.Name,
Size: ga.Size,
URL: ga.URL,
OS: DetectOS(ga.Name),
IsSBOM: IsSBOM(ga.Name),
})
}
// Second pass: if checksum file exists, fetch and parse it
if checksumsURL != "" {
checksums, err := fetchAndParseChecksums(checksumsURL, token)
if err == nil {
for i := range assets {
if sha, ok := checksums[assets[i].Name]; ok {
assets[i].SHA256 = sha
}
}
}
}
releases = append(releases, models.Release{
TagName: gr.TagName,
Body: gr.Body,
CreatedAt: gr.CreatedAt,
Assets: assets,
})
}
return releases, nil
}
func fetchAndParseChecksums(url, token string) (map[string]string, error) {
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("failed to fetch checksums: %d", resp.StatusCode)
}
checksums := make(map[string]string)
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) >= 2 {
// Format is usually: hash filename
checksums[parts[1]] = parts[0]
}
}
return checksums, nil
}

View File

@@ -0,0 +1,118 @@
package gitea
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"software-station/internal/models"
)
func TestFetchRepoInfo(t *testing.T) {
mockSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "token test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Write([]byte(`{"description": "Test Repo", "topics": ["test"], "licenses": ["MIT"], "private": false, "avatar_url": "https://example.com/avatar.png"}`))
}))
defer mockSrv.Close()
desc, topics, license, isPrivate, avatarURL, err := FetchRepoInfo(mockSrv.URL, "test-token", "owner", "repo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if desc != "Test Repo" || len(topics) != 1 || topics[0] != "test" || license != "MIT" || isPrivate || avatarURL != "https://example.com/avatar.png" {
t.Errorf("unexpected results: %s, %v, %s, %v, %s", desc, topics, license, isPrivate, avatarURL)
}
_, _, _, _, _, err = FetchRepoInfo(mockSrv.URL, "wrong-token", "owner", "repo")
if err == nil {
t.Error("expected error for unauthorized request")
}
}
func TestFetchReleases(t *testing.T) {
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "releases") {
w.Write([]byte(fmt.Sprintf(`[{"tag_name": "v1.0.0", "assets": [{"name": "test.exe", "size": 100, "browser_download_url": "%s/test.exe"}, {"name": "SHA256SUMS", "size": 50, "browser_download_url": "%s/SHA256SUMS"}]}]`, srv.URL, srv.URL)))
} else if strings.Contains(r.URL.Path, "SHA256SUMS") {
w.Write([]byte(`hash123 test.exe`))
}
}))
defer srv.Close()
releases, err := FetchReleases(srv.URL, "", "owner", "repo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(releases) != 1 || releases[0].TagName != "v1.0.0" {
t.Errorf("unexpected releases: %v", releases)
}
if len(releases[0].Assets) != 1 || releases[0].Assets[0].Name != "test.exe" || releases[0].Assets[0].SHA256 != "hash123" {
t.Errorf("unexpected assets: %v", releases[0].Assets)
}
}
func TestDetectOS(t *testing.T) {
tests := []struct {
filename string
expected string
}{
{"app.exe", models.OSWindows},
{"app.msi", models.OSWindows},
{"app_linux", models.OSLinux},
{"app.deb", models.OSLinux},
{"app.rpm", models.OSLinux},
{"app.dmg", models.OSMacOS},
{"app.pkg", models.OSMacOS},
{"app_freebsd", models.OSFreeBSD},
{"app_openbsd", models.OSOpenBSD},
{"app.apk", models.OSAndroid},
{"app_arm64", models.OSARM},
{"app_aarch64", models.OSARM},
{"unknown", models.OSUnknown},
}
for _, tt := range tests {
got := DetectOS(tt.filename)
if got != tt.expected {
t.Errorf("DetectOS(%s) = %s, expected %s", tt.filename, got, tt.expected)
}
}
}
func TestIsSBOM(t *testing.T) {
tests := []struct {
filename string
expected bool
}{
{"sbom.json", true},
{"cyclonedx.json", true},
{"spdx.json", true},
{"app.exe", false},
{"app.cdx.json", true},
{"app.spdx.json", true},
}
for _, tt := range tests {
if got := IsSBOM(tt.filename); got != tt.expected {
t.Errorf("IsSBOM(%s) = %v, expected %v", tt.filename, got, tt.expected)
}
}
}
func TestFetchAndParseChecksumsError(t *testing.T) {
mockSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer mockSrv.Close()
_, err := fetchAndParseChecksums(mockSrv.URL, "")
if err == nil {
t.Error("expected error for 404")
}
}

View File

@@ -0,0 +1,9 @@
package gitea
import "time"
const (
DefaultTimeout = 10 * time.Second
RepoAPIPath = "/api/v1/repos"
ReleasesSuffix = "/releases"
)

View File

@@ -0,0 +1,12 @@
package models
const (
OSWindows = "windows"
OSLinux = "linux"
OSMacOS = "macos"
OSFreeBSD = "freebsd"
OSOpenBSD = "openbsd"
OSAndroid = "android"
OSARM = "arm"
OSUnknown = "unknown"
)

41
internal/models/models.go Normal file
View File

@@ -0,0 +1,41 @@
package models
import "time"
type Asset struct {
Name string `json:"name"`
Size int64 `json:"size"`
URL string `json:"url"`
OS string `json:"os"`
SHA256 string `json:"sha256,omitempty"`
IsSBOM bool `json:"is_sbom"`
}
type Release struct {
TagName string `json:"tag_name"`
Body string `json:"body,omitempty"`
CreatedAt time.Time `json:"created_at"`
Assets []Asset `json:"assets"`
}
type Software struct {
Name string `json:"name"`
Owner string `json:"owner"`
Description string `json:"description"`
Releases []Release `json:"releases"`
GiteaURL string `json:"gitea_url"`
Topics []string `json:"topics"`
License string `json:"license,omitempty"`
IsPrivate bool `json:"is_private"`
AvatarURL string `json:"avatar_url,omitempty"`
}
type FingerprintData struct {
Known bool `json:"known"`
TotalBytes int64 `json:"total_bytes"`
}
type SoftwareResponse struct {
GiteaURL string `json:"gitea_url"`
Software []Software `json:"software"`
}

View File

@@ -0,0 +1,50 @@
package security
import (
"time"
"golang.org/x/time/rate"
)
const (
_ = iota
KB = 1 << (10 * iota)
MB
GB
)
const (
// Download Throttling
DefaultDownloadLimit = rate.Limit(5 * MB) // 5MB/s
DefaultDownloadBurst = 2 * MB // 2MB
SpeedDownloaderLimit = rate.Limit(1 * MB) // 1MB/s
SpeedDownloaderBurst = 512 * KB // 512KB
HeavyDownloaderThreshold = 1 * GB // 1GB
HeavyDownloaderLimit = rate.Limit(256 * KB) // 256KB/s
// Rate Limiting
GlobalRateLimit = 100
GlobalRateWindow = 1 * time.Minute
APIRateLimit = 30
APIRateWindow = 1 * time.Minute
)
var ForbiddenPatterns = []string{
".git", ".env", ".aws", ".config", ".ssh",
"wp-admin", "wp-login", "phpinfo", ".php",
"etc/passwd", "cgi-bin", "shell", "cmd",
".sql", ".bak", ".old", ".zip", ".rar",
}
var BotUserAgents = []string{
"bot", "crawl", "spider", "slurp", "googlebot", "bingbot", "yandexbot",
"ahrefsbot", "baiduspider", "duckduckbot", "facebookexternalhit",
"twitterbot", "rogerbot", "linkedinbot", "embedly", "quora link preview",
"showyoubot", "outbrain", "pinterest", "slackbot", "vkShare", "W3C_Validator",
}
var SpeedDownloaders = []string{
"aria2", "wget", "curl", "axel", "transmission", "libcurl",
}

View File

@@ -0,0 +1,220 @@
package security
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strings"
"sync/atomic"
"syscall"
"time"
"software-station/internal/models"
"software-station/internal/stats"
"golang.org/x/time/rate"
)
type ThrottledReader struct {
R io.ReadCloser
Limiter *rate.Limiter
Fingerprint string
Stats *stats.Service
}
func (tr *ThrottledReader) Read(p []byte) (n int, err error) {
n, err = tr.R.Read(p)
if n > 0 && tr.Limiter != nil && tr.Stats != nil {
tr.Stats.KnownHashes.RLock()
data, exists := tr.Stats.KnownHashes.Data[tr.Fingerprint]
tr.Stats.KnownHashes.RUnlock()
var total int64
if exists {
total = atomic.AddInt64(&data.TotalBytes, int64(n))
}
atomic.AddInt64(&tr.Stats.GlobalStats.TotalBytes, int64(n))
if total > HeavyDownloaderThreshold {
tr.Limiter.SetLimit(HeavyDownloaderLimit)
}
if err := tr.Limiter.WaitN(context.Background(), n); err != nil {
return n, err
}
}
return n, err
}
func (tr *ThrottledReader) Close() error {
return tr.R.Close()
}
type contextKey string
const FingerprintKey contextKey = "fingerprint"
func GetRequestFingerprint(r *http.Request, s *stats.Service) string {
if f, ok := r.Context().Value(FingerprintKey).(string); ok {
return f
}
remoteAddr := r.RemoteAddr
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if comma := strings.IndexByte(xff, ','); comma != -1 {
remoteAddr = strings.TrimSpace(xff[:comma])
} else {
remoteAddr = strings.TrimSpace(xff)
}
} else if xri := r.Header.Get("X-Real-IP"); xri != "" {
remoteAddr = strings.TrimSpace(xri)
}
ipStr, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
ipStr = remoteAddr
}
ip := net.ParseIP(ipStr)
if ip != nil {
if ip.To4() == nil {
ip = ip.Mask(net.CIDRMask(64, 128))
}
ipStr = ip.String()
}
ua := r.Header.Get("User-Agent")
hash := sha256.New()
hash.Write([]byte(ipStr + ua))
fingerprint := hex.EncodeToString(hash.Sum(nil))
s.KnownHashes.Lock()
if _, exists := s.KnownHashes.Data[fingerprint]; !exists {
s.KnownHashes.Data[fingerprint] = &models.FingerprintData{
Known: true,
}
s.SaveHashes()
}
s.KnownHashes.Unlock()
return fingerprint
}
func IsPrivateIP(ip net.IP) bool {
if os.Getenv("ALLOW_LOOPBACK") == "true" && ip.IsLoopback() {
return false
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return true
}
// Private IP ranges
privateRanges := []struct {
start net.IP
end net.IP
}{
{net.ParseIP("10.0.0.0"), net.ParseIP("10.255.255.255")},
{net.ParseIP("172.16.0.0"), net.ParseIP("172.31.255.255")},
{net.ParseIP("192.168.0.0"), net.ParseIP("192.168.255.255")},
{net.ParseIP("fd00::"), net.ParseIP("fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")},
}
for _, r := range privateRanges {
if bytes.Compare(ip, r.start) >= 0 && bytes.Compare(ip, r.end) <= 0 {
return true
}
}
return false
}
func GetSafeHTTPClient(timeout time.Duration) *http.Client {
dialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
Control: func(network, address string, c syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip != nil && IsPrivateIP(ip) {
return fmt.Errorf("SSRF protection: forbidden IP %s", ip)
}
return nil
},
}
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: dialer.DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return &http.Client{
Transport: transport,
Timeout: timeout,
}
}
func SecurityMiddleware(s *stats.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
path := strings.ToLower(r.URL.Path)
ua := strings.ToLower(r.UserAgent())
fingerprint := GetRequestFingerprint(r, s)
ctx := context.WithValue(r.Context(), FingerprintKey, fingerprint)
r = r.WithContext(ctx)
s.GlobalStats.Lock()
s.GlobalStats.UniqueRequests[fingerprint] = true
if !strings.HasPrefix(path, "/api") {
s.GlobalStats.WebRequests[fingerprint] = true
}
s.GlobalStats.Unlock()
defer func() {
s.GlobalStats.Lock()
s.GlobalStats.TotalResponseTime += time.Since(start)
s.GlobalStats.TotalRequests++
s.GlobalStats.Unlock()
}()
for _, bot := range BotUserAgents {
if strings.Contains(ua, bot) {
s.GlobalStats.Lock()
s.GlobalStats.BlockedRequests[fingerprint] = true
s.GlobalStats.Unlock()
http.Error(w, "Bots are not allowed", http.StatusForbidden)
return
}
}
for _, pattern := range ForbiddenPatterns {
if strings.Contains(path, pattern) {
s.GlobalStats.Lock()
s.GlobalStats.BlockedRequests[fingerprint] = true
s.GlobalStats.Unlock()
log.Printf("Blocked suspicious request: %s from %s (%s)", r.URL.String(), r.RemoteAddr, r.UserAgent())
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,176 @@
package security
import (
"bytes"
"io"
"net"
"net/http"
"net/http/httptest"
"software-station/internal/models"
"software-station/internal/stats"
"strings"
"testing"
"time"
"golang.org/x/time/rate"
)
func TestThrottledReader(t *testing.T) {
content := []byte("hello world")
inner := io.NopCloser(bytes.NewReader(content))
limiter := rate.NewLimiter(rate.Limit(100), 100)
fp := "test-fp"
statsService := stats.NewService("test-hashes.json")
statsService.KnownHashes.Lock()
statsService.KnownHashes.Data[fp] = &models.FingerprintData{Known: true}
statsService.KnownHashes.Unlock()
tr := &ThrottledReader{
R: inner,
Limiter: limiter,
Fingerprint: fp,
Stats: statsService,
}
p := make([]byte, 5)
n, err := tr.Read(p)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
if n != 5 {
t.Errorf("expected 5 bytes, got %d", n)
}
statsService.KnownHashes.RLock()
data, ok := statsService.KnownHashes.Data["test-fp"]
statsService.KnownHashes.RUnlock()
if !ok {
t.Fatal("fingerprint data not found")
}
total := data.TotalBytes
if total != 5 {
t.Errorf("expected 5 bytes in stats, got %d", total)
}
tr.Close()
}
func TestGetRequestFingerprint(t *testing.T) {
statsService := stats.NewService("test-hashes.json")
// IPv4
req1 := httptest.NewRequest("GET", "/", nil)
req1.RemoteAddr = "1.2.3.4:1234"
f1 := GetRequestFingerprint(req1, statsService)
req2 := httptest.NewRequest("GET", "/", nil)
req2.RemoteAddr = "1.2.3.4:5678"
f2 := GetRequestFingerprint(req2, statsService)
if f1 != f2 {
t.Error("fingerprints should match for same IPv4")
}
// X-Forwarded-For
req3 := httptest.NewRequest("GET", "/", nil)
req3.Header.Set("X-Forwarded-For", "5.6.7.8, 1.2.3.4")
f3 := GetRequestFingerprint(req3, statsService)
if f1 == f3 {
t.Error("fingerprints should differ for different IPs")
}
// IPv6 masking
req4 := httptest.NewRequest("GET", "/", nil)
req4.RemoteAddr = "[2001:db8::1]:1234"
f4 := GetRequestFingerprint(req4, statsService)
req5 := httptest.NewRequest("GET", "/", nil)
req5.RemoteAddr = "[2001:db8::2]:1234"
f5 := GetRequestFingerprint(req5, statsService)
if f4 != f5 {
t.Error("fingerprints should match for same IPv6 /64 prefix")
}
}
func TestSecurityMiddleware(t *testing.T) {
statsService := stats.NewService("test-hashes.json")
handler := SecurityMiddleware(statsService)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Test bot blocking
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("User-Agent", "Googlebot")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("expected 403 for bot, got %d", rr.Code)
}
// Test forbidden pattern
req = httptest.NewRequest("GET", "/.git/config", nil)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("expected 403 for forbidden pattern, got %d", rr.Code)
}
// Test normal request
req = httptest.NewRequest("GET", "/api/software", nil)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200 for normal request, got %d", rr.Code)
}
}
func TestIsPrivateIP_Extended(t *testing.T) {
tests := []struct {
ip string
isPrivate bool
}{
{"127.0.0.1", true},
{"10.0.0.1", true},
{"172.16.0.1", true},
{"192.168.1.1", true},
{"0.0.0.0", true},
{"::1", true},
{"::", true},
{"fd00::1", true},
{"8.8.8.8", false},
{"1.1.1.1", false},
}
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
if got := IsPrivateIP(ip); got != tt.isPrivate {
t.Errorf("IsPrivateIP(%s) = %v; want %v", tt.ip, got, tt.isPrivate)
}
}
}
func TestGetSafeHTTPClient_BlocksPrivateIPs(t *testing.T) {
client := GetSafeHTTPClient(1 * time.Second)
// Test 127.0.0.1
_, err := client.Get("http://127.0.0.1:12345")
if err == nil {
t.Error("Expected error for 127.0.0.1, got nil")
} else if !strings.Contains(err.Error(), "SSRF protection") {
t.Logf("Got error for 127.0.0.1: %v", err)
if !strings.Contains(err.Error(), "SSRF protection") {
t.Errorf("Expected 'SSRF protection' error, got: %v", err)
}
}
// Test 0.0.0.0
_, err = client.Get("http://0.0.0.0:12345")
if err == nil {
t.Error("Expected error for 0.0.0.0, got nil")
} else if !strings.Contains(err.Error(), "SSRF protection") {
t.Logf("Got error for 0.0.0.0: %v", err)
if !strings.Contains(err.Error(), "SSRF protection") {
t.Errorf("Expected 'SSRF protection' error, got: %v", err)
}
}
}

View File

@@ -0,0 +1,5 @@
package stats
const (
DefaultHashesFile = "hashes.json"
)

154
internal/stats/stats.go Normal file
View File

@@ -0,0 +1,154 @@
package stats
import (
"encoding/json"
"log"
"net/http"
"os"
"sync"
"sync/atomic"
"time"
"software-station/internal/models"
"golang.org/x/time/rate"
)
type Service struct {
HashesFile string
KnownHashes struct {
sync.RWMutex
Data map[string]*models.FingerprintData
}
GlobalStats struct {
sync.RWMutex
UniqueRequests map[string]bool
SuccessDownloads map[string]bool
BlockedRequests map[string]bool
LimitedRequests map[string]bool
WebRequests map[string]bool
TotalResponseTime time.Duration
TotalRequests int64
TotalBytes int64
StartTime time.Time
}
DownloadStats struct {
sync.RWMutex
Limiters map[string]*rate.Limiter
}
hashesDirty int32
stopChan chan struct{}
}
func NewService(hashesFile string) *Service {
s := &Service{
HashesFile: hashesFile,
stopChan: make(chan struct{}),
}
s.KnownHashes.Data = make(map[string]*models.FingerprintData)
s.GlobalStats.UniqueRequests = make(map[string]bool)
s.GlobalStats.SuccessDownloads = make(map[string]bool)
s.GlobalStats.BlockedRequests = make(map[string]bool)
s.GlobalStats.LimitedRequests = make(map[string]bool)
s.GlobalStats.WebRequests = make(map[string]bool)
s.GlobalStats.StartTime = time.Now()
s.DownloadStats.Limiters = make(map[string]*rate.Limiter)
return s
}
func (s *Service) Start() {
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if atomic.CompareAndSwapInt32(&s.hashesDirty, 1, 0) {
s.FlushHashes()
}
case <-s.stopChan:
s.FlushHashes()
return
}
}
}()
}
func (s *Service) Stop() {
close(s.stopChan)
}
func (s *Service) LoadHashes() {
data, err := os.ReadFile(s.HashesFile)
if err != nil {
if !os.IsNotExist(err) {
log.Printf("Error reading hashes file: %v", err)
}
return
}
s.KnownHashes.Lock()
defer s.KnownHashes.Unlock()
if err := json.Unmarshal(data, &s.KnownHashes.Data); err != nil {
log.Printf("Error unmarshaling hashes: %v", err)
return
}
var total int64
for _, d := range s.KnownHashes.Data {
total += atomic.LoadInt64(&d.TotalBytes)
}
atomic.StoreInt64(&s.GlobalStats.TotalBytes, total)
}
func (s *Service) SaveHashes() {
atomic.StoreInt32(&s.hashesDirty, 1)
}
func (s *Service) FlushHashes() {
s.KnownHashes.RLock()
data, err := json.MarshalIndent(s.KnownHashes.Data, "", " ")
s.KnownHashes.RUnlock()
if err != nil {
log.Printf("Error marshaling hashes: %v", err)
return
}
if err := os.WriteFile(s.HashesFile, data, 0600); err != nil {
log.Printf("Error writing hashes file: %v", err)
}
}
func (s *Service) APIStatsHandler(w http.ResponseWriter, r *http.Request) {
s.GlobalStats.RLock()
defer s.GlobalStats.RUnlock()
avgResponse := time.Duration(0)
if s.GlobalStats.TotalRequests > 0 {
avgResponse = s.GlobalStats.TotalResponseTime / time.Duration(s.GlobalStats.TotalRequests)
}
totalBytes := atomic.LoadInt64(&s.GlobalStats.TotalBytes)
uptime := time.Since(s.GlobalStats.StartTime)
avgSpeed := float64(totalBytes) / uptime.Seconds()
status := "healthy"
if s.GlobalStats.TotalRequests > 0 && float64(len(s.GlobalStats.BlockedRequests))/float64(s.GlobalStats.TotalRequests) > 0.5 {
status = "unhealthy"
}
data := map[string]interface{}{
"total_unique_download_requests": len(s.GlobalStats.UniqueRequests),
"total_unique_success_downloads": len(s.GlobalStats.SuccessDownloads),
"total_unique_blocked": len(s.GlobalStats.BlockedRequests),
"total_unique_limited": len(s.GlobalStats.LimitedRequests),
"total_unique_web_requests": len(s.GlobalStats.WebRequests),
"avg_speed_bps": avgSpeed,
"avg_response_time": avgResponse.String(),
"uptime": uptime.String(),
"status": status,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("Error encoding stats: %v", err)
}
}

View File

@@ -0,0 +1,54 @@
package stats
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"software-station/internal/models"
"testing"
)
func TestStats(t *testing.T) {
tempFile := "test_hashes.json"
defer os.Remove(tempFile)
service := NewService(tempFile)
// Test SaveHashes
service.KnownHashes.Lock()
service.KnownHashes.Data["test"] = &models.FingerprintData{Known: true, TotalBytes: 100}
service.KnownHashes.Unlock()
service.FlushHashes()
if _, err := os.Stat(tempFile); os.IsNotExist(err) {
t.Error("SaveHashes did not create file")
}
// Test LoadHashes
service.KnownHashes.Lock()
delete(service.KnownHashes.Data, "test")
service.KnownHashes.Unlock()
service.LoadHashes()
service.KnownHashes.RLock()
if data, ok := service.KnownHashes.Data["test"]; !ok || data.TotalBytes != 100 {
t.Errorf("LoadHashes did not restore data correctly: %+v", data)
}
service.KnownHashes.RUnlock()
// Test APIStatsHandler
req := httptest.NewRequest("GET", "/api/stats", nil)
rr := httptest.NewRecorder()
service.APIStatsHandler(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var stats map[string]interface{}
json.Unmarshal(rr.Body.Bytes(), &stats)
if stats["status"] != "healthy" {
t.Errorf("expected healthy status, got %v", stats["status"])
}
}

6
legal/disclaimer.txt Normal file
View File

@@ -0,0 +1,6 @@
Legal Disclaimer
The software distributed through this platform is provided "as-is" without any express or implied warranties. Quad4 and its contributors shall not be held liable for any damages arising from the use of the software downloaded from this station.
Please verify the SHA256 checksums provided for each asset to ensure integrity.

10
legal/privacy.txt Normal file
View File

@@ -0,0 +1,10 @@
Privacy Policy
We value your privacy. This software distribution station does not track individual users or collect personal data beyond what is strictly necessary for the technical operation of the service (e.g., rate limiting, download stats based on IP fingerprints).
Data Collection:
- IP Address (hashed/fingerprinted for security and rate limiting)
- User Agent (for bot detection and OS-specific download stats)
We do not use cookies or third-party trackers.

10
legal/terms.txt Normal file
View File

@@ -0,0 +1,10 @@
Terms of Service
By using this software station, you agree to the following terms:
1. Fair Use: You will not attempt to bypass rate limits or scrap the station excessively.
2. Distribution: The software provided here is mirrored from Gitea. Licenses for individual projects apply.
3. Disclaimer: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
We reserve the right to block any fingerprint or IP that engages in malicious activity.

166
main.go Normal file
View File

@@ -0,0 +1,166 @@
package main
import (
"context"
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"software-station/internal/api"
"software-station/internal/config"
"software-station/internal/security"
"software-station/internal/stats"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"github.com/unrolled/secure"
)
//go:embed all:frontend/build
var frontendBuild embed.FS
var (
giteaToken string
giteaServer string
configPath string
)
func main() {
flag.StringVar(&giteaToken, "t", os.Getenv("GITEA_TOKEN"), "Gitea API Token")
flag.StringVar(&giteaServer, "s", "https://git.quad4.io", "Gitea Server URL")
flag.StringVar(&configPath, "c", config.DefaultConfigPath, "Path to software.txt (local or remote)")
port := flag.String("p", "8080", "Server port")
isProd := flag.Bool("prod", os.Getenv("NODE_ENV") == "production", "Run in production mode")
updateInterval := flag.Duration("u", 1*time.Hour, "Software update interval")
flag.Parse()
statsService := stats.NewService(stats.DefaultHashesFile)
statsService.LoadHashes()
statsService.Start()
defer statsService.Stop()
initialSoftware := config.LoadSoftware(configPath, giteaServer, giteaToken)
apiServer := api.NewServer(giteaToken, initialSoftware, statsService)
config.StartBackgroundUpdater(configPath, giteaServer, giteaToken, apiServer.SoftwareList.GetLock(), apiServer.SoftwareList.GetDataPtr(), *updateInterval)
r := chi.NewRouter()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Account-Number"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
secureMiddleware := secure.New(secure.Options{
FrameDeny: true,
ContentTypeNosniff: true,
BrowserXssFilter: true,
ContentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';",
IsDevelopment: !*isProd,
})
r.Use(secureMiddleware.Handler)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Compress(api.CompressionLevel))
r.Use(security.SecurityMiddleware(statsService))
r.Use(httprate.Limit(
security.GlobalRateLimit,
security.GlobalRateWindow,
httprate.WithKeyFuncs(func(r *http.Request) (string, error) {
return security.GetRequestFingerprint(r, statsService), nil
}),
))
r.Route("/api", func(r chi.Router) {
r.Use(httprate.Limit(
security.APIRateLimit,
security.APIRateWindow,
httprate.WithKeyFuncs(func(r *http.Request) (string, error) {
return security.GetRequestFingerprint(r, statsService), nil
}),
))
r.Get("/software", apiServer.APISoftwareHandler)
r.Get("/download", apiServer.DownloadProxyHandler)
r.Get("/avatar", apiServer.AvatarHandler)
r.Get("/stats", statsService.APIStatsHandler)
r.Get("/legal", apiServer.LegalHandler)
r.Get("/rss", apiServer.RSSHandler)
})
contentStatic, err := fs.Sub(frontendBuild, "frontend/build")
if err != nil {
log.Fatal(err)
}
staticHandler := http.FileServer(http.FS(contentStatic))
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/" {
path = "index.html"
} else {
path = strings.TrimPrefix(path, "/")
}
f, err := contentStatic.Open(path)
if err != nil {
indexData, err := fs.ReadFile(contentStatic, "index.html")
if err != nil {
http.Error(w, "Index not found", http.StatusInternalServerError)
return
}
http.ServeContent(w, r, "index.html", time.Unix(0, 0), strings.NewReader(string(indexData)))
return
}
if err := f.Close(); err != nil {
log.Printf("Error closing static file: %v", err)
}
staticHandler.ServeHTTP(w, r)
})
server := &http.Server{
Addr: ":" + *port,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
Handler: r,
}
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
go func() {
fmt.Printf("Server starting on http://localhost:%s (Gitea: %s, Prod: %v)\n", *port, giteaServer, *isProd)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("Listen error: %s\n", err)
done <- syscall.SIGTERM
}
}()
<-done
fmt.Println("\nServer stopping...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server shutdown failed: %+v", err)
}
fmt.Println("Server exited properly")
}

278
main_test.go Normal file
View File

@@ -0,0 +1,278 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"software-station/internal/api"
"software-station/internal/config"
"software-station/internal/gitea"
"software-station/internal/models"
"software-station/internal/security"
"software-station/internal/stats"
"github.com/go-chi/chi/v5"
)
func TestMainHandlers(t *testing.T) {
os.Setenv("ALLOW_LOOPBACK", "true")
defer os.Unsetenv("ALLOW_LOOPBACK")
// Setup mock software.txt
configPath = "test_software.txt"
os.WriteFile(configPath, []byte("Quad4-Software/software-station"), 0644)
defer os.Remove(configPath)
defer os.Remove("hashes.json")
os.RemoveAll(".cache") // Clear cache for tests
// Mock Gitea Server
var mockGitea *httptest.Server
mockGitea = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "releases") {
w.Write([]byte(fmt.Sprintf(`[{"tag_name": "v1.0.0", "body": "Release notes for v1.0.0", "created_at": "2025-12-27T10:00:00Z", "assets": [{"name": "test.exe", "size": 100, "browser_download_url": "%s/test.exe"}, {"name": "SHA256SUMS", "size": 50, "browser_download_url": "%s/SHA256SUMS"}]}]`, mockGitea.URL, mockGitea.URL)))
} else if strings.Contains(r.URL.Path, "SHA256SUMS") {
w.Write([]byte(`b380cbb6489437721e1674e3b2736c699f5ffe8827e83e749b4f72417ea7e12c test.exe`))
} else {
w.Write([]byte(`{"description": "Test Repo", "topics": ["test", "mock"], "licenses": ["MIT"], "private": false, "avatar_url": "https://example.com/logo.png"}`))
}
}))
defer mockGitea.Close()
giteaServer = mockGitea.URL
statsService := stats.NewService("test-hashes.json")
initialSoftware := config.LoadSoftware(configPath, giteaServer, "")
apiServer := api.NewServer("", initialSoftware, statsService)
r := chi.NewRouter()
r.Use(security.SecurityMiddleware(statsService))
r.Get("/api/software", apiServer.APISoftwareHandler)
r.Get("/api/stats", statsService.APIStatsHandler)
r.Get("/api/download", apiServer.DownloadProxyHandler)
r.Get("/api/avatar", apiServer.AvatarHandler)
r.Get("/api/rss", apiServer.RSSHandler)
t.Run("API Software", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/software", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var sw []models.Software
json.Unmarshal(rr.Body.Bytes(), &sw)
if len(sw) == 0 || sw[0].Name != "software-station" {
t.Errorf("unexpected software list: %v", sw)
}
if sw[0].Releases[0].Body != "Release notes for v1.0.0" {
t.Errorf("expected release body, got %s", sw[0].Releases[0].Body)
}
})
t.Run("RSS Feed", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/rss", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "<rss") || !strings.Contains(body, "software-station v1.0.0") {
t.Errorf("RSS feed content missing: %s", body)
}
if !strings.Contains(body, "Release notes for v1.0.0") {
t.Errorf("RSS feed missing release notes: %s", body)
}
// Test per-software RSS
req = httptest.NewRequest("GET", "/api/rss?software=software-station", nil)
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "software-station Updates") {
t.Error("Specific software RSS title missing")
}
// Test non-existent software RSS
req = httptest.NewRequest("GET", "/api/rss?software=non-existent", nil)
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
if !strings.Contains(rr.Body.String(), "<item>") == false {
// Should be empty channel
}
})
t.Run("Avatar Proxy & Cache", func(t *testing.T) {
avatarData := []byte("fake-image-data")
avatarServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(avatarData)
}))
defer avatarServer.Close()
hash := apiServer.RegisterURL(avatarServer.URL)
req := httptest.NewRequest("GET", fmt.Sprintf("/api/avatar?id=%s", hash), nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
if rr.Header().Get("Content-Type") != "image/png" {
t.Errorf("expected image/png, got %s", rr.Header().Get("Content-Type"))
}
// Verify it was cached
cachePath := filepath.Join(".cache/avatars", hash)
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
t.Error("avatar was not cached to disk")
}
})
t.Run("API Stats", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/stats", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
})
t.Run("Download Proxy Throttling", func(t *testing.T) {
assetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(make([]byte, 1024))
}))
defer assetServer.Close()
hash := apiServer.RegisterURL(assetServer.URL)
req := httptest.NewRequest("GET", fmt.Sprintf("/api/download?id=%s", hash), nil)
req.Header.Set("User-Agent", "aria2/1.35.0")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
})
t.Run("Download Range Request", func(t *testing.T) {
content := "0123456789"
assetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Range") == "bytes=2-5" {
w.Header().Set("Content-Range", "bytes 2-5/10")
w.WriteHeader(http.StatusPartialContent)
w.Write([]byte(content[2:6]))
} else {
w.Write([]byte(content))
}
}))
defer assetServer.Close()
hash := apiServer.RegisterURL(assetServer.URL)
req := httptest.NewRequest("GET", fmt.Sprintf("/api/download?id=%s", hash), nil)
req.Header.Set("Range", "bytes=2-5")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusPartialContent {
t.Errorf("expected 206, got %d", rr.Code)
}
})
t.Run("Security - Path Traversal", func(t *testing.T) {
patterns := []string{"/.git/config", "/etc/passwd"}
for _, p := range patterns {
req := httptest.NewRequest("GET", p, nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("expected 403 for %s, got %d", p, rr.Code)
}
}
})
t.Run("Security - XSS in API", func(t *testing.T) {
malicious := []models.Software{{Name: "<script>alert(1)</script>"}}
testStatsService := stats.NewService("test-hashes.json")
srv := api.NewServer("", malicious, testStatsService)
req := httptest.NewRequest("GET", "/api/software", nil)
rr := httptest.NewRecorder()
srv.APISoftwareHandler(rr, req)
if !strings.Contains(rr.Body.String(), "script") {
t.Error("XSS payload missing")
}
})
t.Run("Download Proxy - Speed Downloader", func(t *testing.T) {
assetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(make([]byte, 100))
}))
defer assetServer.Close()
hash := apiServer.RegisterURL(assetServer.URL)
req := httptest.NewRequest("GET", fmt.Sprintf("/api/download?id=%s", hash), nil)
req.Header.Set("User-Agent", "aria2/1.35.0")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
})
t.Run("Static Files", func(t *testing.T) {
// Mock the filesystem behavior for the static handler
// We'll just test if the router handles unknown paths correctly
req := httptest.NewRequest("GET", "/unknown-path", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
// It should try to serve index.html if file not found, but since build might be empty in tests, it might 500 or 404
if rr.Code == http.StatusOK || rr.Code == http.StatusNotFound || rr.Code == http.StatusInternalServerError {
t.Logf("Static handler returned %d", rr.Code)
}
})
t.Run("API Stats - Status unhealthy", func(t *testing.T) {
statsService.GlobalStats.Lock()
statsService.GlobalStats.TotalRequests = 100
statsService.GlobalStats.BlockedRequests["bad"] = true
// Make it more than 50% blocked
for i := 0; i < 60; i++ {
statsService.GlobalStats.BlockedRequests[fmt.Sprintf("bad%d", i)] = true
}
statsService.GlobalStats.Unlock()
req := httptest.NewRequest("GET", "/api/stats", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
var s map[string]interface{}
json.Unmarshal(rr.Body.Bytes(), &s)
if s["status"] != "unhealthy" {
t.Errorf("expected unhealthy status, got %v", s["status"])
}
// Reset
statsService.GlobalStats.Lock()
statsService.GlobalStats.BlockedRequests = make(map[string]bool)
statsService.GlobalStats.TotalRequests = 0
statsService.GlobalStats.Unlock()
})
}
func TestOSDetection(t *testing.T) {
if gitea.DetectOS("test.exe") != models.OSWindows {
t.Error("expected windows")
}
}

1
software.txt Normal file
View File

@@ -0,0 +1 @@
Quad4-Software/webnews