Files
software-station/internal/gitea/client.go
Sudo-Ivan d8748bba77
All checks were successful
CI / build (push) Successful in 1m8s
renovate / renovate (push) Successful in 1m42s
Update asset caching and documentation features
- Updated the API server to support asset caching with a new flag for enabling/disabling caching.
- Implemented asset caching logic in the DownloadProxyHandler to store and retrieve assets efficiently.
- Added tests for asset caching functionality, ensuring proper behavior for cache hits and misses.
- Introduced new documentation files for software, including multi-language support.
- Enhanced the SoftwareCard component to display documentation links for software with available docs.
- Updated the Software model to include a flag indicating the presence of documentation.
- Improved the user interface for documentation navigation and search functionality.
2025-12-27 19:08:36 -06:00

447 lines
10 KiB
Go

package gitea
import (
"bufio"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"software-station/internal/models"
)
func CheckSourceSecurity(serverURL, token, owner, repo string) *models.SourceSecurity {
u, err := url.Parse(serverURL)
if err != nil {
return nil
}
domain := u.Hostname()
security := &models.SourceSecurity{
Domain: domain,
}
// TLS Check
conf := &tls.Config{
InsecureSkipVerify: false,
MinVersion: tls.VersionTLS12,
}
// Use port 443 for HTTPS domains
port := "443"
if u.Port() != "" {
port = u.Port()
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
conn, err := tls.DialWithDialer(dialer, "tcp", net.JoinHostPort(domain, port), conf)
if err == nil {
security.TLSValid = true
_ = conn.Close()
}
return security
}
func FetchUserGPGKeys(server, token, username string) ([]string, error) {
url := fmt.Sprintf("%s/api/v1/users/%s/gpg_keys", server, username)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
client := &http.Client{Timeout: DefaultTimeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea gpg api returned status %d", resp.StatusCode)
}
var giteaKeys []struct {
PublicKey string `json:"public_key"`
}
if err := json.NewDecoder(resp.Body).Decode(&giteaKeys); err != nil {
return nil, err
}
keys := make([]string, len(giteaKeys))
for i, k := range giteaKeys {
keys[i] = k.PublicKey
}
return keys, nil
}
func FetchContributors(server, token, owner, repo string) ([]models.Contributor, error) {
url := fmt.Sprintf("%s%s/%s/%s%s", server, RepoAPIPath, owner, repo, ContributorsSuffix)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
client := &http.Client{Timeout: DefaultTimeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea api returned status %d", resp.StatusCode)
}
var giteaContributors []struct {
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&giteaContributors); err != nil {
return nil, err
}
contributors := make([]models.Contributor, len(giteaContributors))
for i, c := range giteaContributors {
keys, _ := FetchUserGPGKeys(server, token, c.Username)
contributors[i] = models.Contributor{
Username: c.Username,
AvatarURL: c.AvatarURL,
GPGKeys: keys,
}
}
return contributors, nil
}
func DetectOS(filename string) string {
lower := strings.ToLower(filename)
if strings.HasSuffix(lower, ".whl") {
if strings.Contains(lower, "win") || strings.Contains(lower, "windows") {
return models.OSWindows
}
if strings.Contains(lower, "macosx") || strings.Contains(lower, "darwin") {
return models.OSMacOS
}
if strings.Contains(lower, "linux") {
return models.OSLinux
}
return models.OSUnknown
}
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") || strings.HasSuffix(lower, ".zip") {
if strings.Contains(lower, "win") || strings.Contains(lower, "windows") {
return models.OSWindows
}
if strings.Contains(lower, "mac") || strings.Contains(lower, "darwin") || strings.Contains(lower, "osx") {
return models.OSMacOS
}
if strings.Contains(lower, "linux") {
return models.OSLinux
}
if strings.Contains(lower, "freebsd") {
return models.OSFreeBSD
}
if strings.Contains(lower, "openbsd") {
return models.OSOpenBSD
}
return models.OSUnknown
}
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 != "" {
branches = append([]string{defaultBranch}, "main", "master")
}
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 {
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"
}
}
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
}
contributors, _ := FetchContributors(server, token, owner, repo)
security := CheckSourceSecurity(server, token, owner, repo)
var releases []models.Release
for _, gr := range giteaReleases {
var assets []models.Asset
var checksumsURL string
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),
})
}
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,
Contributors: contributors,
Security: security,
})
}
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 {
checksums[parts[1]] = parts[0]
}
}
return checksums, nil
}