0.1.0
This commit is contained in:
27
.dockerignore
Normal file
27
.dockerignore
Normal 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
30
.gitignore
vendored
Normal 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
22
LICENSE
Normal 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
46
Makefile
Normal 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
23
frontend/.gitignore
vendored
Normal 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
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
6
frontend/.prettierignore
Normal file
6
frontend/.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
8
frontend/.prettierrc
Normal file
8
frontend/.prettierrc
Normal 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
38
frontend/README.md
Normal 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
59
frontend/eslint.config.js
Normal 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/**'],
|
||||||
|
},
|
||||||
|
];
|
||||||
BIN
frontend/frontend/static/icons/gitea-dark.webp
Normal file
BIN
frontend/frontend/static/icons/gitea-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
39
frontend/package.json
Normal file
39
frontend/package.json
Normal 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
3008
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
83
frontend/src/app.css
Normal file
83
frontend/src/app.css
Normal 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
13
frontend/src/app.d.ts
vendored
Normal 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
11
frontend/src/app.html
Normal 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>
|
||||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal 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 |
22
frontend/src/lib/components/SearchBar.svelte
Normal file
22
frontend/src/lib/components/SearchBar.svelte
Normal 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>
|
||||||
369
frontend/src/lib/components/SoftwareCard.svelte
Normal file
369
frontend/src/lib/components/SoftwareCard.svelte
Normal 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>
|
||||||
35
frontend/src/lib/components/icons/BrandIcon.svelte
Normal file
35
frontend/src/lib/components/icons/BrandIcon.svelte
Normal 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>
|
||||||
13
frontend/src/lib/i18n/index.ts
Normal file
13
frontend/src/lib/i18n/index.ts
Normal 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,
|
||||||
|
});
|
||||||
45
frontend/src/lib/i18n/locales/de.json
Normal file
45
frontend/src/lib/i18n/locales/de.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
45
frontend/src/lib/i18n/locales/en.json
Normal file
45
frontend/src/lib/i18n/locales/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
45
frontend/src/lib/i18n/locales/it.json
Normal file
45
frontend/src/lib/i18n/locales/it.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
45
frontend/src/lib/i18n/locales/ru.json
Normal file
45
frontend/src/lib/i18n/locales/ru.json
Normal 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": "Другое"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal 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
26
frontend/src/lib/types.ts
Normal 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;
|
||||||
|
}
|
||||||
162
frontend/src/routes/+layout.svelte
Normal file
162
frontend/src/routes/+layout.svelte
Normal 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">
|
||||||
|
© {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}
|
||||||
3
frontend/src/routes/+layout.ts
Normal file
3
frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const ssr = false;
|
||||||
|
export const prerender = false;
|
||||||
|
export const trailingSlash = 'always';
|
||||||
106
frontend/src/routes/+page.svelte
Normal file
106
frontend/src/routes/+page.svelte
Normal 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>
|
||||||
81
frontend/src/routes/legal/[doc]/+page.svelte
Normal file
81
frontend/src/routes/legal/[doc]/+page.svelte
Normal 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>
|
||||||
6
frontend/static/.well-known/security.txt
Normal file
6
frontend/static/.well-known/security.txt
Normal 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
|
||||||
|
|
||||||
BIN
frontend/static/icons/gitea-dark.webp
Normal file
BIN
frontend/static/icons/gitea-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend/static/icons/gitea.webp
Normal file
BIN
frontend/static/icons/gitea.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend/static/logo.png
Normal file
BIN
frontend/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
3
frontend/static/robots.txt
Normal file
3
frontend/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
24
frontend/svelte.config.js
Normal file
24
frontend/svelte.config.js
Normal 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;
|
||||||
50
frontend/tailwind.config.js
Normal file
50
frontend/tailwind.config.js
Normal 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
20
frontend/tsconfig.json
Normal 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
11
frontend/vite.config.ts
Normal 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
17
go.mod
Normal 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
20
go.sum
Normal 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
10
internal/api/constants.go
Normal 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
425
internal/api/handlers.go
Normal 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
44
internal/cache/cache.go
vendored
Normal 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
43
internal/cache/cache_test.go
vendored
Normal 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
86
internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
62
internal/config/config_test.go
Normal file
62
internal/config/config_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
6
internal/config/constants.go
Normal file
6
internal/config/constants.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultConfigPath = "software.txt"
|
||||||
|
DefaultGiteaServer = "https://git.quad4.io"
|
||||||
|
)
|
||||||
30
internal/config/updater.go
Normal file
30
internal/config/updater.go
Normal 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
298
internal/gitea/client.go
Normal 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
|
||||||
|
}
|
||||||
118
internal/gitea/client_test.go
Normal file
118
internal/gitea/client_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
9
internal/gitea/constants.go
Normal file
9
internal/gitea/constants.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultTimeout = 10 * time.Second
|
||||||
|
RepoAPIPath = "/api/v1/repos"
|
||||||
|
ReleasesSuffix = "/releases"
|
||||||
|
)
|
||||||
12
internal/models/constants.go
Normal file
12
internal/models/constants.go
Normal 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
41
internal/models/models.go
Normal 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"`
|
||||||
|
}
|
||||||
50
internal/security/constants.go
Normal file
50
internal/security/constants.go
Normal 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",
|
||||||
|
}
|
||||||
220
internal/security/security.go
Normal file
220
internal/security/security.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
176
internal/security/security_test.go
Normal file
176
internal/security/security_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
internal/stats/constants.go
Normal file
5
internal/stats/constants.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultHashesFile = "hashes.json"
|
||||||
|
)
|
||||||
154
internal/stats/stats.go
Normal file
154
internal/stats/stats.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
54
internal/stats/stats_test.go
Normal file
54
internal/stats/stats_test.go
Normal 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
6
legal/disclaimer.txt
Normal 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
10
legal/privacy.txt
Normal 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
10
legal/terms.txt
Normal 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
166
main.go
Normal 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
278
main_test.go
Normal 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
1
software.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Quad4-Software/webnews
|
||||||
Reference in New Issue
Block a user