diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af7defe..0192195 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,14 @@ on: push: {} workflow_dispatch: inputs: {} + +permissions: + contents: read + packages: write + jobs: - ci: - name: CI + build: + name: Build Binaries runs-on: ubuntu-latest steps: - name: Checkout @@ -16,7 +21,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: '1.19' + go-version: '1.21.5' check-latest: true cache: true - name: Build @@ -31,4 +36,4 @@ jobs: version: latest args: release --rm-dist --config .github/goreleaser.yml env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/handler/handler.go b/handler/handler.go index 76c0db5..6c34fa2 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -26,14 +26,15 @@ const ( var ( isTermRe = regexp.MustCompile(`(?i)^(curl|wget)\/`) isHomebrewRe = regexp.MustCompile(`(?i)^homebrew`) + isPowershell = regexp.MustCompile(`(?i)windows`) errMsgRe = regexp.MustCompile(`[^A-Za-z0-9\ :\/\.]`) errNotFound = errors.New("not found") ) type Query struct { - User, Program, AsProgram, Release string - MoveToPath, Search, Insecure bool - SudoMove bool // deprecated: not used, now automatically detected + User, Program, AsProgram, Release, BinSource string + MoveToPath, Search, Insecure bool + SudoMove bool // deprecated: not used, now automatically detected } type Result struct { @@ -69,17 +70,22 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ext := "" script := "" qtype := r.URL.Query().Get("type") + if qtype == "" { ua := r.Header.Get("User-Agent") + switch { case isTermRe.MatchString(ua): qtype = "script" case isHomebrewRe.MatchString(ua): qtype = "ruby" + case isPowershell.MatchString(ua): + qtype = "powershell" default: qtype = "text" } } + // type specific error response showError := func(msg string, code int) { // prevent shell injection @@ -98,6 +104,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/ruby") ext = "rb" script = string(scripts.Homebrew) + case "powershell": + w.Header().Set("Content-Type", "text/plain") + ext = "ps1" + script = string(scripts.Powershell) case "text": w.Header().Set("Content-Type", "text/plain") ext = "txt" @@ -112,7 +122,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { Release: "", Insecure: r.URL.Query().Get("insecure") == "1", AsProgram: r.URL.Query().Get("as"), + BinSource: r.URL.Query().Get("source"), } + + if q.AsProgram == "" && q.BinSource != "" { + q.AsProgram = q.BinSource + } + // set query from route path := strings.TrimPrefix(r.URL.Path, "/") // move to path with ! @@ -123,55 +139,84 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var rest string q.User, rest = splitHalf(path, "/") q.Program, q.Release = splitHalf(rest, "@") + // no program? treat first part as program, use default user if q.Program == "" { q.Program = q.User q.User = h.Config.User q.Search = true } + if q.Release == "" { q.Release = "latest" } + // micro > nano! if q.User == "" && q.Program == "micro" { q.User = "zyedidia" } + // force user/repo if h.Config.ForceUser != "" { q.User = h.Config.ForceUser } + if h.Config.ForceRepo != "" { q.Program = h.Config.ForceRepo } + // validate query valid := q.Program != "" if !valid && path == "" { http.Redirect(w, r, "https://github.com/jpillora/installer", http.StatusMovedPermanently) return } + if !valid { log.Printf("invalid path: query: %#v", q) showError("Invalid path", http.StatusBadRequest) return } + // fetch assets result, err := h.execute(q) if err != nil { showError(err.Error(), http.StatusBadGateway) return } + // load template - t, err := template.New("installer").Parse(script) - if err != nil { + + t := template.New("installer") + funcs := template.FuncMap{} + + funcs["getArchURL"] = func(res Result, os string, arch string) (string, error) { + for _, asset := range res.Assets { + if string(asset.OS) == os && string(asset.Arch) == arch { + return string(asset.URL), nil + } + } + return "", fmt.Errorf("no asset found for %s/%s", os, arch) + } + + t = t.Funcs(funcs) + + if _, err := t.Parse(script); err != nil { showError("installer BUG: "+err.Error(), http.StatusInternalServerError) return } + + if result.BinSource == "" { + result.BinSource = result.Program + } + // execute template buff := bytes.Buffer{} if err := t.Execute(&buff, result); err != nil { - showError("Template error: "+err.Error(), http.StatusInternalServerError) + // showError("Template error: "+err.Error(), http.StatusInternalServerError) return } + log.Printf("serving script %s/%s@%s (%s)", q.User, q.Program, q.Release, ext) // ready w.Write(buff.Bytes()) diff --git a/handler/handler_execute.go b/handler/handler_execute.go index f04292c..e514e9b 100644 --- a/handler/handler_execute.go +++ b/handler/handler_execute.go @@ -26,6 +26,7 @@ func (h *Handler) execute(q Query) (Result, error) { //do real operation ts := time.Now() release, assets, err := h.getAssetsNoCache(q) + if err == nil { //didn't need search q.Search = false @@ -64,6 +65,8 @@ func (h *Handler) execute(q Query) (Result, error) { h.cacheMut.Lock() h.cache[key] = result h.cacheMut.Unlock() + + // fmt.Printf("result: %v\n", result.Assets) return result, nil } @@ -74,6 +77,7 @@ func (h *Handler) getAssetsNoCache(q Query) (string, Assets, error) { //not cached - ask github log.Printf("fetching asset info for %s/%s@%s", user, repo, release) url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", user, repo) + ghas := ghAssets{} if release == "" || release == "latest" { url += "/latest" @@ -81,13 +85,16 @@ func (h *Handler) getAssetsNoCache(q Query) (string, Assets, error) { if err := h.get(url, &ghr); err != nil { return release, nil, err } + release = ghr.TagName //discovered ghas = ghr.Assets } else { + ghrs := []ghRelease{} if err := h.get(url, &ghrs); err != nil { return release, nil, err } + found := false for _, ghr := range ghrs { if ghr.TagName == release { @@ -95,21 +102,26 @@ func (h *Handler) getAssetsNoCache(q Query) (string, Assets, error) { if err := h.get(ghr.AssetsURL, &ghas); err != nil { return release, nil, err } + ghas = ghr.Assets break } } + if !found { return release, nil, fmt.Errorf("release tag '%s' not found", release) } } + if len(ghas) == 0 { return release, nil, errors.New("no assets found") } + sumIndex, _ := ghas.getSumIndex() if l := len(sumIndex); l > 0 { log.Printf("fetched %d asset shasums", l) } + assets := Assets{} index := map[string]bool{} for _, ga := range ghas { @@ -117,6 +129,14 @@ func (h *Handler) getAssetsNoCache(q Query) (string, Assets, error) { //only binary containers are supported //TODO deb,rpm etc fext := getFileExt(url) + + if q.BinSource != "" { + //filter by bin source + if !strings.Contains(ga.Name[0:len(q.BinSource)+1], fmt.Sprint(q.BinSource, "-")) { + continue + } + } + if fext == "" && ga.Size > 1024*1024 { fext = ".bin" // +1MB binary } @@ -131,8 +151,8 @@ func (h *Handler) getAssetsNoCache(q Query) (string, Assets, error) { os := getOS(ga.Name) arch := getArch(ga.Name) //windows not supported yet - if os == "windows" { - log.Printf("fetched asset is for windows: %s", ga.Name) + if os == "windows" && fext != ".zip" { + // log.Printf("fetched asset is for windows: %s", ga.Name) //TODO: powershell // EG: iwr https://deno.land/x/install/install.ps1 -useb | iex continue @@ -143,6 +163,7 @@ func (h *Handler) getAssetsNoCache(q Query) (string, Assets, error) { continue } log.Printf("fetched asset: %s", ga.Name) + asset := Asset{ OS: os, Arch: arch, @@ -151,6 +172,7 @@ func (h *Handler) getAssetsNoCache(q Query) (string, Assets, error) { Type: fext, SHA256: sumIndex[ga.Name], } + //there can only be 1 file for each OS/Arch if index[asset.Key()] { continue diff --git a/scripts/install.ps1.tmpl b/scripts/install.ps1.tmpl new file mode 100644 index 0000000..8f3f594 --- /dev/null +++ b/scripts/install.ps1.tmpl @@ -0,0 +1,76 @@ +{{/* $appversion = "v1.0.3" */}} + +{{- $urlamd64 := getArchURL . "windows" "amd64" }} +{{- $urlarm64 := getArchURL . "windows" "arm64" }} + +$url = "--not-generated--" + +$urlamd64 = "{{$urlamd64}}" +$urlarm64 = "{{$urlarm64}}" +$destinationPath = "$env:USERPROFILE\Documents\kl" + +$arch = "$env:PROCESSOR_ARCHITECTURE" + +$filename = "app-windows-$arch.zip" + +$zipFilePath = "$env:TEMP\$filename" +$tempPath = "$env:TEMP\kl" + +$arch_lower=$arch.ToLower() + +Write-Host "" +Write-Host "Installing binary {{.BinSource}} ($arch_lower) version {{.Release}} at path '$destinationPath'" + +if ($arch -eq "ARM64") { + $url = $urlarm64 +} else { + $url = $urlamd64 +} + +# Create the destination directory if it doesn't exist +if (-not (Test-Path $destinationPath)) { + New-Item -ItemType Directory -Force -Path $destinationPath +} + +# Use Invoke-WebRequest to download the file +Invoke-WebRequest -Uri $url -OutFile $zipFilePath + +# Expand the archive +Expand-Archive -Path $zipFilePath -DestinationPath $tempPath + +Get-ChildItem -Path $tempPath -Filter *.exe -Recurse | Move-Item -Destination $destinationPath -Force + +# Clean up the downloaded ZIP and temporary extracted folder +Remove-Item -Path $zipFilePath -Force +Remove-Item -Path $tempPath -Recurse -Force + +# Get the current user's PATH environment variable +$currentPath = [System.Environment]::GetEnvironmentVariable("PATH", [System.EnvironmentVariableTarget]::User) + +# Split the PATH variable into an array of individual paths +$pathArray = $currentPath -split ";" + +$hasPath = "false" +# Iterate over each path in the PATH variable +foreach ($path in $pathArray) { + # Check if the current path contains the specific directory + if ($path -eq $destinationPath) { + $hasPath = "true" + } +} + +if ($hasPath -eq "false") { +# Update the PATH environment variable + if (-not [string]::IsNullOrWhiteSpace($currentPath)) { + $updatedPath = $currentPath + ";" + $destinationPath + } else { + $updatedPath = $destinationPath + } +} + +# Set the updated PATH +[System.Environment]::SetEnvironmentVariable("PATH", $updatedPath, [System.EnvironmentVariableTarget]::User) + +Write-Host "" +Write-Host "[#] installation complete, use `{{.BinSource}} --help` to get started." +Write-Host "" diff --git a/scripts/scripts.go b/scripts/scripts.go index 1701816..c581770 100644 --- a/scripts/scripts.go +++ b/scripts/scripts.go @@ -2,7 +2,7 @@ package scripts import _ "embed" -//go:embed install.txt.tmpl +// go:embed install.txt.tmpl var Text []byte //go:embed install.sh.tmpl @@ -10,3 +10,6 @@ var Shell []byte //go:embed install.rb.tmpl var Homebrew []byte + +//go:embed install.ps1.tmpl +var Powershell []byte