package main import ( "archive/tar" "archive/zip" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strings" "github.com/ulikunitz/xz" ) const ( appName = "logofclient" appVersion = "1.0.0" defaultGitea = "https://git.mypapercloud.de" defaultOwner = "fierke" defaultRepo = "logofclient" ) type Config struct { GiteaURL string Owner string Repo string } type Release struct { TagName string `json:"tag_name"` Assets []Asset `json:"assets"` } type Asset struct { Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` } func main() { config := Config{ GiteaURL: envOrDefault("GITEA_URL", defaultGitea), Owner: envOrDefault("GITEA_OWNER", defaultOwner), Repo: envOrDefault("GITEA_REPO", defaultRepo), } args := os.Args[1:] if len(args) == 0 { runTUI(config) return } switch args[0] { case "install": if err := runPrivileged(config, "install"); err != nil { printError(err) os.Exit(1) } case "update": if err := runPrivileged(config, "update"); err != nil { printError(err) os.Exit(1) } case "uninstall": if err := runPrivileged(config, "uninstall"); err != nil { printError(err) os.Exit(1) } case "-y": if len(args) != 2 { printHelp() os.Exit(1) } switch args[1] { case "install", "update", "uninstall": if err := runPrivileged(config, args[1]); err != nil { printError(err) os.Exit(1) } default: printHelp() os.Exit(1) } case "-v", "--version": fmt.Printf("%s %s\n", appName, appVersion) case "-h", "--help", "help": printHelp() default: fmt.Printf("Unbekannter Befehl: %s\n\n", args[0]) printHelp() os.Exit(1) } } // ------------------------------------------------------------ // TUI // ------------------------------------------------------------ func runTUI(config Config) { for { fmt.Println() fmt.Println("========================================") fmt.Println(" Logof Client Installer") fmt.Println("========================================") fmt.Println() fmt.Println(" 1) Logof Client installieren") fmt.Println(" 2) Nach Updates suchen") fmt.Println(" 3) Logof Client deinstallieren") fmt.Println(" 4) Beenden") fmt.Println() fmt.Print("Auswahl: ") var choice string if _, err := fmt.Scanln(&choice); err != nil { return } var err error switch strings.TrimSpace(choice) { case "1": err = runPrivileged(config, "install") case "2": err = runPrivileged(config, "update") case "3": err = runPrivileged(config, "uninstall") case "4": return default: fmt.Println("Ungültige Auswahl.") continue } if err != nil { printError(err) } fmt.Println() fmt.Print("Drücken Sie Enter, um fortzufahren...") fmt.Scanln() } } func printHelp() { fmt.Println(`Logof Client Installer Verwendung: logofclient-installer logofclient-installer install logofclient-installer update logofclient-installer uninstall Optionen: -h, --help Diese Hilfe anzeigen -v, --version Version des Installers anzeigen -y Befehl ohne interaktive Abfrage ausführen Beispiele: logofclient-installer install logofclient-installer update logofclient-installer uninstall logofclient-installer -y install`) } func runAction(config Config, action string) error { switch action { case "install": return install(config) case "update": return update(config) case "uninstall": return uninstall(config) default: return fmt.Errorf("unbekannte Aktion: %s", action) } } // ------------------------------------------------------------ // Privilegien // ------------------------------------------------------------ // runPrivileged wird von den Plattformdateien implementiert. // // Linux: // - fragt nach Bestätigung // - startet sich bei Bedarf über sudo neu // // Windows: // - die EXE wird über das Manifest mit // requireAdministrator gestartet // - hier ist keine weitere Elevation nötig func runPrivileged(config Config, action string) error { return platformRunPrivileged(config, action) } // ------------------------------------------------------------ // Installation // ------------------------------------------------------------ func install(config Config) error { fmt.Println("Fetching latest version...") release, err := getLatestRelease(config) if err != nil { return err } tag := release.TagName if tag == "" { return errors.New("Release enthält keinen Tag") } fmt.Printf("Installing %s %s...\n", appName, tag) assetName := getAssetName(tag) if assetName == "" { return fmt.Errorf( "kein Paket für Betriebssystem %s definiert", runtime.GOOS, ) } asset, err := findAsset(release, assetName) if err != nil { return err } fmt.Printf("Downloading package: %s\n", asset.Name) tempDir, err := os.MkdirTemp("", "logofclient-installer-*") if err != nil { return fmt.Errorf( "temporäres Verzeichnis konnte nicht erstellt werden: %w", err, ) } defer os.RemoveAll(tempDir) archivePath := filepath.Join(tempDir, asset.Name) if err := download(asset.BrowserDownloadURL, archivePath); err != nil { return err } installDir, err := getInstallDir() if err != nil { return err } fmt.Printf("Installing to: %s\n", installDir) if err := removeIfExists(installDir); err != nil { return fmt.Errorf( "alte Installation konnte nicht entfernt werden: %w", err, ) } if err := os.MkdirAll(installDir, 0755); err != nil { return fmt.Errorf( "Installationsverzeichnis konnte nicht erstellt werden: %w", err, ) } switch runtime.GOOS { case "linux": if err := extractTarXz(archivePath, installDir); err != nil { return err } case "windows": if err := extractZip(archivePath, installDir); err != nil { return err } default: return fmt.Errorf( "nicht unterstütztes Betriebssystem: %s", runtime.GOOS, ) } versionFile := filepath.Join(installDir, ".version") if err := os.WriteFile( versionFile, []byte(tag+"\n"), 0644, ); err != nil { return fmt.Errorf( "Version konnte nicht gespeichert werden: %w", err, ) } if err := platformInstallIntegration(installDir); err != nil { return err } fmt.Println() fmt.Printf( "%s %s wurde erfolgreich installiert.\n", appName, tag, ) return nil } // ------------------------------------------------------------ // Update // ------------------------------------------------------------ func update(config Config) error { installDir, err := getInstallDir() if err != nil { return err } versionFile := filepath.Join(installDir, ".version") currentVersionData, err := os.ReadFile(versionFile) if err != nil { if os.IsNotExist(err) { fmt.Println("Logof Client ist nicht installiert.") fmt.Println("Installiere aktuelle Version...") return install(config) } return fmt.Errorf( "installierte Version konnte nicht gelesen werden: %w", err, ) } currentVersion := strings.TrimSpace(string(currentVersionData)) fmt.Println("Fetching latest version...") release, err := getLatestRelease(config) if err != nil { return err } latestVersion := release.TagName fmt.Printf( "Installierte Version: %s\n", valueOrNone(currentVersion), ) fmt.Printf( "Neueste Version: %s\n", valueOrNone(latestVersion), ) if currentVersion == latestVersion { fmt.Println() fmt.Println("Logof Client ist bereits aktuell.") return nil } fmt.Println() fmt.Printf( "Update von %s auf %s...\n", currentVersion, latestVersion, ) return install(config) } // ------------------------------------------------------------ // Deinstallation // ------------------------------------------------------------ func uninstall(config Config) error { _ = config installDir, err := getInstallDir() if err != nil { return err } if _, err := os.Stat(installDir); os.IsNotExist(err) { fmt.Println("Logof Client ist nicht installiert.") return nil } fmt.Printf("Deinstalliere %s...\n", appName) if err := platformRemoveIntegration(); err != nil { return err } if err := removeIfExists(installDir); err != nil { return fmt.Errorf( "Installation konnte nicht entfernt werden: %w", err, ) } fmt.Println("Logof Client wurde erfolgreich deinstalliert.") return nil } // ------------------------------------------------------------ // Gitea // ------------------------------------------------------------ func getLatestRelease(config Config) (*Release, error) { url := fmt.Sprintf( "%s/api/v1/repos/%s/%s/releases/latest", strings.TrimRight(config.GiteaURL, "/"), config.Owner, config.Repo, ) resp, err := http.Get(url) if err != nil { return nil, fmt.Errorf( "Gitea Release konnte nicht abgerufen werden: %w", err, ) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll( io.LimitReader(resp.Body, 4096), ) return nil, fmt.Errorf( "Gitea API antwortete mit HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)), ) } var release Release if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return nil, fmt.Errorf( "Gitea-Antwort konnte nicht gelesen werden: %w", err, ) } return &release, nil } func findAsset(release *Release, name string) (*Asset, error) { for _, asset := range release.Assets { if asset.Name == name { return &asset, nil } } return nil, fmt.Errorf( "Release %s enthält nicht das erwartete Paket: %s", release.TagName, name, ) } func getAssetName(tag string) string { switch runtime.GOOS { case "linux": return fmt.Sprintf( "%s-%s-linux.tar.xz", appName, tag, ) case "windows": return fmt.Sprintf( "%s-%s-win.zip", appName, tag, ) default: return "" } } func download(url string, destination string) error { resp, err := http.Get(url) if err != nil { return fmt.Errorf( "Download fehlgeschlagen: %w", err, ) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf( "Download fehlgeschlagen: HTTP %d", resp.StatusCode, ) } file, err := os.Create(destination) if err != nil { return fmt.Errorf( "Download-Datei konnte nicht erstellt werden: %w", err, ) } defer file.Close() if _, err := io.Copy(file, resp.Body); err != nil { return fmt.Errorf( "Download fehlgeschlagen: %w", err, ) } return nil } // ------------------------------------------------------------ // Archive // ------------------------------------------------------------ func extractTarXz( archivePath string, destination string, ) error { file, err := os.Open(archivePath) if err != nil { return fmt.Errorf( "Archiv konnte nicht geöffnet werden: %w", err, ) } defer file.Close() xzReader, err := xz.NewReader(file) if err != nil { return fmt.Errorf( "XZ-Archiv konnte nicht gelesen werden: %w", err, ) } tarReader := tar.NewReader(xzReader) for { header, err := tarReader.Next() if errors.Is(err, io.EOF) { break } if err != nil { return fmt.Errorf( "TAR-Archiv konnte nicht gelesen werden: %w", err, ) } target, err := safeJoin( destination, header.Name, ) if err != nil { return err } switch header.Typeflag { case tar.TypeDir: if err := os.MkdirAll( target, os.FileMode(header.Mode), ); err != nil { return fmt.Errorf( "Verzeichnis %s konnte nicht erstellt werden: %w", target, err, ) } case tar.TypeReg: if err := os.MkdirAll( filepath.Dir(target), 0755, ); err != nil { return err } out, err := os.OpenFile( target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode), ) if err != nil { return fmt.Errorf( "Datei %s konnte nicht erstellt werden: %w", target, err, ) } _, copyErr := io.Copy( out, tarReader, ) closeErr := out.Close() if copyErr != nil { return fmt.Errorf( "Datei %s konnte nicht entpackt werden: %w", target, copyErr, ) } if closeErr != nil { return closeErr } case tar.TypeSymlink: if err := os.MkdirAll( filepath.Dir(target), 0755, ); err != nil { return err } if err := removeIfExists(target); err != nil { return err } if err := os.Symlink( header.Linkname, target, ); err != nil { return fmt.Errorf( "Symlink %s konnte nicht erstellt werden: %w", target, err, ) } default: return fmt.Errorf( "nicht unterstützter TAR-Eintrag: %s", header.Name, ) } } return nil } func extractZip( archivePath string, destination string, ) error { reader, err := zip.OpenReader(archivePath) if err != nil { return fmt.Errorf( "ZIP-Archiv konnte nicht geöffnet werden: %w", err, ) } defer reader.Close() for _, file := range reader.File { target, err := safeJoin( destination, file.Name, ) if err != nil { return err } if file.FileInfo().IsDir() { if err := os.MkdirAll( target, 0755, ); err != nil { return fmt.Errorf( "Verzeichnis %s konnte nicht erstellt werden: %w", target, err, ) } continue } if err := os.MkdirAll( filepath.Dir(target), 0755, ); err != nil { return err } in, err := file.Open() if err != nil { return fmt.Errorf( "ZIP-Datei %s konnte nicht geöffnet werden: %w", file.Name, err, ) } out, err := os.OpenFile( target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644, ) if err != nil { in.Close() return fmt.Errorf( "Datei %s konnte nicht erstellt werden: %w", target, err, ) } _, copyErr := io.Copy(out, in) in.Close() closeErr := out.Close() if copyErr != nil { return fmt.Errorf( "Datei %s konnte nicht entpackt werden: %w", target, copyErr, ) } if closeErr != nil { return closeErr } } return nil } // ------------------------------------------------------------ // Hilfsfunktionen // ------------------------------------------------------------ func safeJoin( base string, name string, ) (string, error) { if filepath.IsAbs(name) { return "", fmt.Errorf( "unsicherer Archivpfad: %s", name, ) } cleanName := filepath.Clean(name) if cleanName == ".." || strings.HasPrefix( cleanName, ".."+string(os.PathSeparator), ) { return "", fmt.Errorf( "unsicherer Archivpfad: %s", name, ) } target := filepath.Join( base, cleanName, ) rel, err := filepath.Rel( base, target, ) if err != nil { return "", err } if rel == ".." || strings.HasPrefix( rel, ".."+string(os.PathSeparator), ) { return "", fmt.Errorf( "unsicherer Archivpfad: %s", name, ) } return target, nil } func removeIfExists(path string) error { err := os.RemoveAll(path) if err != nil && !os.IsNotExist(err) { return err } return nil } func envOrDefault( name string, fallback string, ) string { value := strings.TrimSpace( os.Getenv(name), ) if value == "" { return fallback } return value } func valueOrNone(value string) string { if strings.TrimSpace(value) == "" { return "" } return value } func printError(err error) { fmt.Printf("\nERROR: %v\n", err) }