diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d619014 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.mypapercloud.de/fierke/logofclient-installer + +go 1.26.8 + +require github.com/ulikunitz/xz v0.5.17 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cd0bbdf --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/ulikunitz/xz v0.5.17 h1:flR0y/x1hgM8EGV1AW3Xll6T413G0glV8UfBwR617V4= +github.com/ulikunitz/xz v0.5.17/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c95cd87 --- /dev/null +++ b/main.go @@ -0,0 +1,973 @@ +package main + +import ( + "archive/tar" + "archive/zip" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "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 "-v", "--version": + fmt.Printf("%s %s\n", appName, appVersion) + + case "-h", "--help", "help": + printHelp() + + case "-y": + if len(args) < 2 { + printHelp() + os.Exit(1) + } + + switch args[1] { + 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) + } + + default: + printHelp() + os.Exit(1) + } + + 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 { + fmt.Println() + return + } + + var err error + + switch 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 TUI ausführen + +Beispiele: + logofclient-installer install + logofclient-installer update + logofclient-installer uninstall + logofclient-installer -y install`) +} + +// ------------------------------------------------------------ +// Privileges +// ------------------------------------------------------------ + +func runPrivileged(config Config, action string) error { + // Windows: + // Die EXE wird später über ein Manifest mit + // requestedExecutionLevel="requireAdministrator" + // gestartet. Hier ist daher keine Elevation nötig. + if runtime.GOOS != "linux" { + return runAction(config, action) + } + + // Linux: bereits root + if os.Geteuid() == 0 { + return runAction(config, action) + } + + // Linux: mit sudo neu starten + fmt.Println() + fmt.Println("Für diese Aktion werden Administratorrechte benötigt.") + fmt.Println("Starte Installer mit sudo...") + fmt.Println() + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("Installer-Pfad konnte nicht ermittelt werden: %w", err) + } + + cmd := exec.Command("sudo", exe, action) + + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +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) + } +} + +// ------------------------------------------------------------ +// 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) + + 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) + + // Alte Installation entfernen + 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) + } + + // Archiv entpacken + 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) + } + + // installierte Version speichern + 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) + } + + // Plattformintegration + switch runtime.GOOS { + case "linux": + if err := installLinuxIntegration(installDir); err != nil { + return err + } + + case "windows": + if err := installWindowsIntegration(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) + + // install() entfernt die alte Version selbst. + return install(config) +} + +// ------------------------------------------------------------ +// Uninstall +// ------------------------------------------------------------ + +func uninstall(config Config) error { + _ = config // aktuell nicht benötigt + + 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) + + switch runtime.GOOS { + case "linux": + if err := removeLinuxIntegration(); err != nil { + return err + } + + case "windows": + if err := removeWindowsIntegration(); 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 +} + +// ------------------------------------------------------------ +// Linux +// ------------------------------------------------------------ + +func getInstallDir() (string, error) { + switch runtime.GOOS { + case "linux": + return "/opt/logofclient", nil + + case "windows": + programFiles := os.Getenv("ProgramFiles") + + if programFiles == "" { + return "", errors.New("ProgramFiles ist nicht gesetzt") + } + + return filepath.Join(programFiles, "logofclient"), nil + + default: + return "", fmt.Errorf("nicht unterstütztes Betriebssystem: %s", runtime.GOOS) + } +} + +func installLinuxIntegration(installDir string) error { + binaryPath := filepath.Join(installDir, "Logof Client") + linkPath := "/usr/local/bin/logofclient" + + fmt.Println("Creating command symlink...") + + if err := removeIfExists(linkPath); err != nil { + return fmt.Errorf("alter Symlink konnte nicht entfernt werden: %w", err) + } + + if err := os.Symlink(binaryPath, linkPath); err != nil { + return fmt.Errorf("Symlink konnte nicht erstellt werden: %w", err) + } + + desktopDir := "/usr/share/applications" + desktopFile := filepath.Join(desktopDir, "logofclient.desktop") + + if err := os.MkdirAll(desktopDir, 0755); err != nil { + return fmt.Errorf("Desktop-Verzeichnis konnte nicht erstellt werden: %w", err) + } + + iconPath := filepath.Join( + installDir, + "assets", + "icon.png", + ) + + desktopEntry := fmt.Sprintf(`[Desktop Entry] +Name=Logof Client +Comment=Logof Client +Exec=%s +Terminal=false +Type=Application +Icon=%s +Categories=Office; +`, linkPath, iconPath) + + if err := os.WriteFile( + desktopFile, + []byte(desktopEntry), + 0644, + ); err != nil { + return fmt.Errorf( + "Desktop-Datei konnte nicht geschrieben werden: %w", + err, + ) + } + + return nil +} + +func removeLinuxIntegration() error { + linkPath := "/usr/local/bin/logofclient" + desktopFile := "/usr/share/applications/logofclient.desktop" + + if err := removeIfExists(linkPath); err != nil { + return fmt.Errorf("Symlink konnte nicht entfernt werden: %w", err) + } + + if err := removeIfExists(desktopFile); err != nil { + return fmt.Errorf("Desktop-Datei konnte nicht entfernt werden: %w", err) + } + + return nil +} + +// ------------------------------------------------------------ +// Windows +// ------------------------------------------------------------ + +func installWindowsIntegration(installDir string) error { + startMenu := filepath.Join( + os.Getenv("ProgramData"), + "Microsoft", + "Windows", + "Start Menu", + "Programs", + ) + + if err := os.MkdirAll(startMenu, 0755); err != nil { + return fmt.Errorf( + "Startmenü-Verzeichnis konnte nicht erstellt werden: %w", + err, + ) + } + + shortcut := filepath.Join( + startMenu, + "Logof Client.lnk", + ) + + target := filepath.Join( + installDir, + "Logof Client.exe", + ) + + icon := filepath.Join( + installDir, + "assets", + "icon.ico", + ) + + // PowerShell wird hier nur intern zur Erstellung der Windows- + // Shell-Verknüpfung verwendet. Der Benutzer muss kein + // PowerShell-Skript ausführen. + script := ` +$ws = New-Object -ComObject WScript.Shell +$sc = $ws.CreateShortcut($args[0]) +$sc.TargetPath = $args[1] +$sc.WorkingDirectory = $args[2] +$sc.Description = "Logof Client" +if (Test-Path $args[3]) { + $sc.IconLocation = $args[3] +} +$sc.Save() +` + + cmd := exec.Command( + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-Command", + script, + shortcut, + target, + installDir, + icon, + ) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf( + "Startmenü-Verknüpfung konnte nicht erstellt werden: %w", + err, + ) + } + + return nil +} + +func removeWindowsIntegration() error { + startMenu := filepath.Join( + os.Getenv("ProgramData"), + "Microsoft", + "Windows", + "Start Menu", + "Programs", + ) + + shortcut := filepath.Join( + startMenu, + "Logof Client.lnk", + ) + + if err := removeIfExists(shortcut); err != nil { + return fmt.Errorf( + "Startmenü-Verknüpfung konnte nicht entfernt werden: %w", + err, + ) + } + + return nil +} + +// ------------------------------------------------------------ +// Archive extraction +// ------------------------------------------------------------ + +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 + } + + linkTarget := header.Linkname + + if err := os.Symlink(linkTarget, 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 +} + +// ------------------------------------------------------------ +// Helpers +// ------------------------------------------------------------ + +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) +} \ No newline at end of file