Files

871 lines
15 KiB
Go

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),
}
if err := platformEnsureElevated(); err != nil {
printError(err)
os.Exit(1)
}
args := os.Args[1:]
if len(args) > 0 && args[0] == "--elevated" {
args = 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> 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 "<unbekannt>"
}
return value
}
func printError(err error) {
fmt.Printf("\nERROR: %v\n", err)
}