forked from Plemya-x/ALR
		
	Initial commit
This commit is contained in:
		
							
								
								
									
										379
									
								
								internal/dl/dl.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										379
									
								
								internal/dl/dl.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,379 @@
 | 
			
		||||
/*
 | 
			
		||||
 * LURE - Linux User REpository
 | 
			
		||||
 * Copyright (C) 2023 Elara Musayelyan
 | 
			
		||||
 *
 | 
			
		||||
 * This program is free software: you can redistribute it and/or modify
 | 
			
		||||
 * it under the terms of the GNU General Public License as published by
 | 
			
		||||
 * the Free Software Foundation, either version 3 of the License, or
 | 
			
		||||
 * (at your option) any later version.
 | 
			
		||||
 *
 | 
			
		||||
 * This program is distributed in the hope that it will be useful,
 | 
			
		||||
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
			
		||||
 * GNU General Public License for more details.
 | 
			
		||||
 *
 | 
			
		||||
 * You should have received a copy of the GNU General Public License
 | 
			
		||||
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
// Package dl contains abstractions for downloadingfiles and directories
 | 
			
		||||
// from various sources.
 | 
			
		||||
package dl
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"crypto/md5"
 | 
			
		||||
	"crypto/sha1"
 | 
			
		||||
	"crypto/sha256"
 | 
			
		||||
	"crypto/sha512"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"hash"
 | 
			
		||||
	"io"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/PuerkitoBio/purell"
 | 
			
		||||
	"github.com/vmihailenco/msgpack/v5"
 | 
			
		||||
	"golang.org/x/crypto/blake2b"
 | 
			
		||||
	"golang.org/x/crypto/blake2s"
 | 
			
		||||
	"golang.org/x/exp/slices"
 | 
			
		||||
	"lure.sh/lure/internal/dlcache"
 | 
			
		||||
	"lure.sh/lure/pkg/loggerctx"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const manifestFileName = ".lure_cache_manifest"
 | 
			
		||||
 | 
			
		||||
// ErrChecksumMismatch occurs when the checksum of a downloaded file
 | 
			
		||||
// does not match the expected checksum provided in the Options struct.
 | 
			
		||||
var (
 | 
			
		||||
	ErrChecksumMismatch = errors.New("dl: checksums did not match")
 | 
			
		||||
	ErrNoSuchHashAlgo   = errors.New("dl: invalid hashing algorithm")
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Downloaders contains all the downloaders in the order in which
 | 
			
		||||
// they should be checked
 | 
			
		||||
var Downloaders = []Downloader{
 | 
			
		||||
	GitDownloader{},
 | 
			
		||||
	TorrentDownloader{},
 | 
			
		||||
	FileDownloader{},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Type represents the type of download (file or directory)
 | 
			
		||||
type Type uint8
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	TypeFile Type = iota
 | 
			
		||||
	TypeDir
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func (t Type) String() string {
 | 
			
		||||
	switch t {
 | 
			
		||||
	case TypeFile:
 | 
			
		||||
		return "file"
 | 
			
		||||
	case TypeDir:
 | 
			
		||||
		return "dir"
 | 
			
		||||
	}
 | 
			
		||||
	return "<unknown>"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Options contains the options for downloading
 | 
			
		||||
// files and directories
 | 
			
		||||
type Options struct {
 | 
			
		||||
	Hash             []byte
 | 
			
		||||
	HashAlgorithm    string
 | 
			
		||||
	Name             string
 | 
			
		||||
	URL              string
 | 
			
		||||
	Destination      string
 | 
			
		||||
	CacheDisabled    bool
 | 
			
		||||
	PostprocDisabled bool
 | 
			
		||||
	Progress         io.Writer
 | 
			
		||||
	LocalDir         string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (opts Options) NewHash() (hash.Hash, error) {
 | 
			
		||||
	switch opts.HashAlgorithm {
 | 
			
		||||
	case "", "sha256":
 | 
			
		||||
		return sha256.New(), nil
 | 
			
		||||
	case "sha224":
 | 
			
		||||
		return sha256.New224(), nil
 | 
			
		||||
	case "sha512":
 | 
			
		||||
		return sha512.New(), nil
 | 
			
		||||
	case "sha384":
 | 
			
		||||
		return sha512.New384(), nil
 | 
			
		||||
	case "sha1":
 | 
			
		||||
		return sha1.New(), nil
 | 
			
		||||
	case "md5":
 | 
			
		||||
		return md5.New(), nil
 | 
			
		||||
	case "blake2s-128":
 | 
			
		||||
		return blake2s.New256(nil)
 | 
			
		||||
	case "blake2s-256":
 | 
			
		||||
		return blake2s.New256(nil)
 | 
			
		||||
	case "blake2b-256":
 | 
			
		||||
		return blake2b.New(32, nil)
 | 
			
		||||
	case "blake2b-512":
 | 
			
		||||
		return blake2b.New(64, nil)
 | 
			
		||||
	default:
 | 
			
		||||
		return nil, fmt.Errorf("%w: %s", ErrNoSuchHashAlgo, opts.HashAlgorithm)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Manifest holds information about the type and name
 | 
			
		||||
// of a downloaded file or directory. It is stored inside
 | 
			
		||||
// each cache directory for later use.
 | 
			
		||||
type Manifest struct {
 | 
			
		||||
	Type Type
 | 
			
		||||
	Name string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Downloader interface {
 | 
			
		||||
	// Name returns the name of the downloader
 | 
			
		||||
	Name() string
 | 
			
		||||
	// MatchURL checks if the given URL matches
 | 
			
		||||
	// the downloader.
 | 
			
		||||
	MatchURL(string) bool
 | 
			
		||||
	// Download downloads the object at the URL
 | 
			
		||||
	// provided in the options, to the destination
 | 
			
		||||
	// given in the options. It returns a type,
 | 
			
		||||
	// a name for the downloaded object (this may be empty),
 | 
			
		||||
	// and an error.
 | 
			
		||||
	Download(Options) (Type, string, error)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpdatingDownloader extends the Downloader interface
 | 
			
		||||
// with an Update method for protocols such as git, which
 | 
			
		||||
// allow for incremental updates without changing the URL.
 | 
			
		||||
type UpdatingDownloader interface {
 | 
			
		||||
	Downloader
 | 
			
		||||
	// Update checks for and performs any
 | 
			
		||||
	// available updates for the object
 | 
			
		||||
	// described in the options. It returns
 | 
			
		||||
	// true if an update was performed, or
 | 
			
		||||
	// false if no update was required.
 | 
			
		||||
	Update(Options) (bool, error)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Download downloads a file or directory using the specified options.
 | 
			
		||||
// It first gets the appropriate downloader for the URL, then checks
 | 
			
		||||
// if caching is enabled. If caching is enabled, it attempts to get
 | 
			
		||||
// the cache directory for the URL and update it if necessary.
 | 
			
		||||
// If the source is found in the cache, it links it to the destination
 | 
			
		||||
// using hard links. If the source is not found in the cache,
 | 
			
		||||
// it downloads the source to a new cache directory and links it
 | 
			
		||||
// to the destination.
 | 
			
		||||
func Download(ctx context.Context, opts Options) (err error) {
 | 
			
		||||
	log := loggerctx.From(ctx)
 | 
			
		||||
	normalized, err := normalizeURL(opts.URL)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	opts.URL = normalized
 | 
			
		||||
 | 
			
		||||
	d := getDownloader(opts.URL)
 | 
			
		||||
 | 
			
		||||
	if opts.CacheDisabled {
 | 
			
		||||
		_, _, err = d.Download(opts)
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var t Type
 | 
			
		||||
	cacheDir, ok := dlcache.Get(ctx, opts.URL)
 | 
			
		||||
	if ok {
 | 
			
		||||
		var updated bool
 | 
			
		||||
		if d, ok := d.(UpdatingDownloader); ok {
 | 
			
		||||
			log.Info("Source can be updated, updating if required").Str("source", opts.Name).Str("downloader", d.Name()).Send()
 | 
			
		||||
 | 
			
		||||
			updated, err = d.Update(Options{
 | 
			
		||||
				Hash:          opts.Hash,
 | 
			
		||||
				HashAlgorithm: opts.HashAlgorithm,
 | 
			
		||||
				Name:          opts.Name,
 | 
			
		||||
				URL:           opts.URL,
 | 
			
		||||
				Destination:   cacheDir,
 | 
			
		||||
				Progress:      opts.Progress,
 | 
			
		||||
				LocalDir:      opts.LocalDir,
 | 
			
		||||
			})
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		m, err := getManifest(cacheDir)
 | 
			
		||||
		if err == nil {
 | 
			
		||||
			t = m.Type
 | 
			
		||||
 | 
			
		||||
			dest := filepath.Join(opts.Destination, m.Name)
 | 
			
		||||
			ok, err := handleCache(cacheDir, dest, m.Name, t)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if ok && !updated {
 | 
			
		||||
				log.Info("Source found in cache and linked to destination").Str("source", opts.Name).Stringer("type", t).Send()
 | 
			
		||||
				return nil
 | 
			
		||||
			} else if ok {
 | 
			
		||||
				log.Info("Source updated and linked to destination").Str("source", opts.Name).Stringer("type", t).Send()
 | 
			
		||||
				return nil
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			// If we cannot read the manifest,
 | 
			
		||||
			// this cache entry is invalid and
 | 
			
		||||
			// the source must be re-downloaded.
 | 
			
		||||
			err = os.RemoveAll(cacheDir)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Info("Downloading source").Str("source", opts.Name).Str("downloader", d.Name()).Send()
 | 
			
		||||
 | 
			
		||||
	cacheDir, err = dlcache.New(ctx, opts.URL)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	t, name, err := d.Download(Options{
 | 
			
		||||
		Hash:          opts.Hash,
 | 
			
		||||
		HashAlgorithm: opts.HashAlgorithm,
 | 
			
		||||
		Name:          opts.Name,
 | 
			
		||||
		URL:           opts.URL,
 | 
			
		||||
		Destination:   cacheDir,
 | 
			
		||||
		Progress:      opts.Progress,
 | 
			
		||||
		LocalDir:      opts.LocalDir,
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = writeManifest(cacheDir, Manifest{t, name})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	dest := filepath.Join(opts.Destination, name)
 | 
			
		||||
	_, err = handleCache(cacheDir, dest, name, t)
 | 
			
		||||
	return err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// writeManifest writes the manifest to the specified cache directory.
 | 
			
		||||
func writeManifest(cacheDir string, m Manifest) error {
 | 
			
		||||
	fl, err := os.Create(filepath.Join(cacheDir, manifestFileName))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer fl.Close()
 | 
			
		||||
	return msgpack.NewEncoder(fl).Encode(m)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getManifest reads the manifest from the specified cache directory.
 | 
			
		||||
func getManifest(cacheDir string) (m Manifest, err error) {
 | 
			
		||||
	fl, err := os.Open(filepath.Join(cacheDir, manifestFileName))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return Manifest{}, err
 | 
			
		||||
	}
 | 
			
		||||
	defer fl.Close()
 | 
			
		||||
 | 
			
		||||
	err = msgpack.NewDecoder(fl).Decode(&m)
 | 
			
		||||
	return
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleCache links the cache directory or a file within it to the destination
 | 
			
		||||
func handleCache(cacheDir, dest, name string, t Type) (bool, error) {
 | 
			
		||||
	switch t {
 | 
			
		||||
	case TypeFile:
 | 
			
		||||
		cd, err := os.Open(cacheDir)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return false, err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		names, err := cd.Readdirnames(0)
 | 
			
		||||
		if err == io.EOF {
 | 
			
		||||
			break
 | 
			
		||||
		} else if err != nil {
 | 
			
		||||
			return false, err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		cd.Close()
 | 
			
		||||
 | 
			
		||||
		if slices.Contains(names, name) {
 | 
			
		||||
			err = os.Link(filepath.Join(cacheDir, name), dest)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return false, err
 | 
			
		||||
			}
 | 
			
		||||
			return true, nil
 | 
			
		||||
		}
 | 
			
		||||
	case TypeDir:
 | 
			
		||||
		err := linkDir(cacheDir, dest)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return false, err
 | 
			
		||||
		}
 | 
			
		||||
		return true, nil
 | 
			
		||||
	}
 | 
			
		||||
	return false, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// linkDir recursively walks through a directory, creating
 | 
			
		||||
// hard links for each file from the src directory to the
 | 
			
		||||
// dest directory. If it encounters a directory, it will
 | 
			
		||||
// create a directory with the same name and permissions
 | 
			
		||||
// in the dest directory, because hard links cannot be
 | 
			
		||||
// created for directories.
 | 
			
		||||
func linkDir(src, dest string) error {
 | 
			
		||||
	return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if info.Name() == manifestFileName {
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		rel, err := filepath.Rel(src, path)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		newPath := filepath.Join(dest, rel)
 | 
			
		||||
		if info.IsDir() {
 | 
			
		||||
			return os.MkdirAll(newPath, info.Mode())
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return os.Link(path, newPath)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getDownloader(u string) Downloader {
 | 
			
		||||
	for _, d := range Downloaders {
 | 
			
		||||
		if d.MatchURL(u) {
 | 
			
		||||
			return d
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// normalizeURL normalizes a URL string, so that insignificant
 | 
			
		||||
// differences don't change the hash.
 | 
			
		||||
func normalizeURL(u string) (string, error) {
 | 
			
		||||
	const normalizationFlags = purell.FlagRemoveTrailingSlash |
 | 
			
		||||
		purell.FlagRemoveDefaultPort |
 | 
			
		||||
		purell.FlagLowercaseHost |
 | 
			
		||||
		purell.FlagLowercaseScheme |
 | 
			
		||||
		purell.FlagRemoveDuplicateSlashes |
 | 
			
		||||
		purell.FlagRemoveFragment |
 | 
			
		||||
		purell.FlagRemoveUnnecessaryHostDots |
 | 
			
		||||
		purell.FlagSortQuery |
 | 
			
		||||
		purell.FlagDecodeHexHost |
 | 
			
		||||
		purell.FlagDecodeOctalHost |
 | 
			
		||||
		purell.FlagDecodeUnnecessaryEscapes |
 | 
			
		||||
		purell.FlagRemoveEmptyPortSeparator
 | 
			
		||||
 | 
			
		||||
	u, err := purell.NormalizeURLString(u, normalizationFlags)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Fix magnet URLs after normalization
 | 
			
		||||
	u = strings.Replace(u, "magnet://", "magnet:", 1)
 | 
			
		||||
	return u, nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										264
									
								
								internal/dl/file.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										264
									
								
								internal/dl/file.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,264 @@
 | 
			
		||||
/*
 | 
			
		||||
 * LURE - Linux User REpository
 | 
			
		||||
 * Copyright (C) 2023 Elara Musayelyan
 | 
			
		||||
 *
 | 
			
		||||
 * This program is free software: you can redistribute it and/or modify
 | 
			
		||||
 * it under the terms of the GNU General Public License as published by
 | 
			
		||||
 * the Free Software Foundation, either version 3 of the License, or
 | 
			
		||||
 * (at your option) any later version.
 | 
			
		||||
 *
 | 
			
		||||
 * This program is distributed in the hope that it will be useful,
 | 
			
		||||
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
			
		||||
 * GNU General Public License for more details.
 | 
			
		||||
 *
 | 
			
		||||
 * You should have received a copy of the GNU General Public License
 | 
			
		||||
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package dl
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"context"
 | 
			
		||||
	"io"
 | 
			
		||||
	"mime"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/mholt/archiver/v4"
 | 
			
		||||
	"github.com/schollz/progressbar/v3"
 | 
			
		||||
	"lure.sh/lure/internal/shutils/handlers"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// FileDownloader downloads files using HTTP
 | 
			
		||||
type FileDownloader struct{}
 | 
			
		||||
 | 
			
		||||
// Name always returns "file"
 | 
			
		||||
func (FileDownloader) Name() string {
 | 
			
		||||
	return "file"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MatchURL always returns true, as FileDownloader
 | 
			
		||||
// is used as a fallback if nothing else matches
 | 
			
		||||
func (FileDownloader) MatchURL(string) bool {
 | 
			
		||||
	return true
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Download downloads a file using HTTP. If the file is
 | 
			
		||||
// compressed using a supported format, it will be extracted
 | 
			
		||||
func (FileDownloader) Download(opts Options) (Type, string, error) {
 | 
			
		||||
	u, err := url.Parse(opts.URL)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	query := u.Query()
 | 
			
		||||
 | 
			
		||||
	name := query.Get("~name")
 | 
			
		||||
	query.Del("~name")
 | 
			
		||||
 | 
			
		||||
	archive := query.Get("~archive")
 | 
			
		||||
	query.Del("~archive")
 | 
			
		||||
 | 
			
		||||
	u.RawQuery = query.Encode()
 | 
			
		||||
 | 
			
		||||
	var r io.ReadCloser
 | 
			
		||||
	var size int64
 | 
			
		||||
	if u.Scheme == "local" {
 | 
			
		||||
		localFl, err := os.Open(filepath.Join(opts.LocalDir, u.Path))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return 0, "", err
 | 
			
		||||
		}
 | 
			
		||||
		fi, err := localFl.Stat()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return 0, "", err
 | 
			
		||||
		}
 | 
			
		||||
		size = fi.Size()
 | 
			
		||||
		if name == "" {
 | 
			
		||||
			name = fi.Name()
 | 
			
		||||
		}
 | 
			
		||||
		r = localFl
 | 
			
		||||
	} else {
 | 
			
		||||
		res, err := http.Get(u.String())
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return 0, "", err
 | 
			
		||||
		}
 | 
			
		||||
		size = res.ContentLength
 | 
			
		||||
		if name == "" {
 | 
			
		||||
			name = getFilename(res)
 | 
			
		||||
		}
 | 
			
		||||
		r = res.Body
 | 
			
		||||
	}
 | 
			
		||||
	defer r.Close()
 | 
			
		||||
 | 
			
		||||
	opts.PostprocDisabled = archive == "false"
 | 
			
		||||
 | 
			
		||||
	path := filepath.Join(opts.Destination, name)
 | 
			
		||||
	fl, err := os.Create(path)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, "", err
 | 
			
		||||
	}
 | 
			
		||||
	defer fl.Close()
 | 
			
		||||
 | 
			
		||||
	var bar io.WriteCloser
 | 
			
		||||
	if opts.Progress != nil {
 | 
			
		||||
		bar = progressbar.NewOptions64(
 | 
			
		||||
			size,
 | 
			
		||||
			progressbar.OptionSetDescription(name),
 | 
			
		||||
			progressbar.OptionSetWriter(opts.Progress),
 | 
			
		||||
			progressbar.OptionShowBytes(true),
 | 
			
		||||
			progressbar.OptionSetWidth(10),
 | 
			
		||||
			progressbar.OptionThrottle(65*time.Millisecond),
 | 
			
		||||
			progressbar.OptionShowCount(),
 | 
			
		||||
			progressbar.OptionOnCompletion(func() {
 | 
			
		||||
				_, _ = io.WriteString(opts.Progress, "\n")
 | 
			
		||||
			}),
 | 
			
		||||
			progressbar.OptionSpinnerType(14),
 | 
			
		||||
			progressbar.OptionFullWidth(),
 | 
			
		||||
			progressbar.OptionSetRenderBlankState(true),
 | 
			
		||||
		)
 | 
			
		||||
		defer bar.Close()
 | 
			
		||||
	} else {
 | 
			
		||||
		bar = handlers.NopRWC{}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	h, err := opts.NewHash()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var w io.Writer
 | 
			
		||||
	if opts.Hash != nil {
 | 
			
		||||
		w = io.MultiWriter(fl, h, bar)
 | 
			
		||||
	} else {
 | 
			
		||||
		w = io.MultiWriter(fl, bar)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err = io.Copy(w, r)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, "", err
 | 
			
		||||
	}
 | 
			
		||||
	r.Close()
 | 
			
		||||
 | 
			
		||||
	if opts.Hash != nil {
 | 
			
		||||
		sum := h.Sum(nil)
 | 
			
		||||
		if !bytes.Equal(sum, opts.Hash) {
 | 
			
		||||
			return 0, "", ErrChecksumMismatch
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if opts.PostprocDisabled {
 | 
			
		||||
		return TypeFile, name, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err = fl.Seek(0, io.SeekStart)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	format, ar, err := archiver.Identify(name, fl)
 | 
			
		||||
	if err == archiver.ErrNoMatch {
 | 
			
		||||
		return TypeFile, name, nil
 | 
			
		||||
	} else if err != nil {
 | 
			
		||||
		return 0, "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = extractFile(ar, format, name, opts)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = os.Remove(path)
 | 
			
		||||
	return TypeDir, "", err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// extractFile extracts an archive or decompresses a file
 | 
			
		||||
func extractFile(r io.Reader, format archiver.Format, name string, opts Options) (err error) {
 | 
			
		||||
	fname := format.Name()
 | 
			
		||||
 | 
			
		||||
	switch format := format.(type) {
 | 
			
		||||
	case archiver.Extractor:
 | 
			
		||||
		err = format.Extract(context.Background(), r, nil, func(ctx context.Context, f archiver.File) error {
 | 
			
		||||
			fr, err := f.Open()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
			defer fr.Close()
 | 
			
		||||
			fi, err := f.Stat()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
			fm := fi.Mode()
 | 
			
		||||
 | 
			
		||||
			path := filepath.Join(opts.Destination, f.NameInArchive)
 | 
			
		||||
 | 
			
		||||
			err = os.MkdirAll(filepath.Dir(path), 0o755)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if f.IsDir() {
 | 
			
		||||
				err = os.Mkdir(path, 0o755)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					return err
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
				outFl, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fm.Perm())
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					return err
 | 
			
		||||
				}
 | 
			
		||||
				defer outFl.Close()
 | 
			
		||||
 | 
			
		||||
				_, err = io.Copy(outFl, fr)
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
			return nil
 | 
			
		||||
		})
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	case archiver.Decompressor:
 | 
			
		||||
		rc, err := format.OpenReader(r)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
		defer rc.Close()
 | 
			
		||||
 | 
			
		||||
		path := filepath.Join(opts.Destination, name)
 | 
			
		||||
		path = strings.TrimSuffix(path, fname)
 | 
			
		||||
 | 
			
		||||
		outFl, err := os.Create(path)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		_, err = io.Copy(outFl, rc)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getFilename attempts to parse the Content-Disposition
 | 
			
		||||
// HTTP response header and extract a filename. If the
 | 
			
		||||
// header does not exist, it will use the last element
 | 
			
		||||
// of the path.
 | 
			
		||||
func getFilename(res *http.Response) (name string) {
 | 
			
		||||
	_, params, err := mime.ParseMediaType(res.Header.Get("Content-Disposition"))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return path.Base(res.Request.URL.Path)
 | 
			
		||||
	}
 | 
			
		||||
	if filename, ok := params["filename"]; ok {
 | 
			
		||||
		return filename
 | 
			
		||||
	} else {
 | 
			
		||||
		return path.Base(res.Request.URL.Path)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										198
									
								
								internal/dl/git.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										198
									
								
								internal/dl/git.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,198 @@
 | 
			
		||||
/*
 | 
			
		||||
 * LURE - Linux User REpository
 | 
			
		||||
 * Copyright (C) 2023 Elara Musayelyan
 | 
			
		||||
 *
 | 
			
		||||
 * This program is free software: you can redistribute it and/or modify
 | 
			
		||||
 * it under the terms of the GNU General Public License as published by
 | 
			
		||||
 * the Free Software Foundation, either version 3 of the License, or
 | 
			
		||||
 * (at your option) any later version.
 | 
			
		||||
 *
 | 
			
		||||
 * This program is distributed in the hope that it will be useful,
 | 
			
		||||
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
			
		||||
 * GNU General Public License for more details.
 | 
			
		||||
 *
 | 
			
		||||
 * You should have received a copy of the GNU General Public License
 | 
			
		||||
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package dl
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"path"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/go-git/go-git/v5"
 | 
			
		||||
	"github.com/go-git/go-git/v5/config"
 | 
			
		||||
	"github.com/go-git/go-git/v5/plumbing"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// GitDownloader downloads Git repositories
 | 
			
		||||
type GitDownloader struct{}
 | 
			
		||||
 | 
			
		||||
// Name always returns "git"
 | 
			
		||||
func (GitDownloader) Name() string {
 | 
			
		||||
	return "git"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MatchURL matches any URLs that start with "git+"
 | 
			
		||||
func (GitDownloader) MatchURL(u string) bool {
 | 
			
		||||
	return strings.HasPrefix(u, "git+")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Download uses git to clone the repository from the specified URL.
 | 
			
		||||
// It allows specifying the revision, depth and recursion options
 | 
			
		||||
// via query string
 | 
			
		||||
func (GitDownloader) Download(opts Options) (Type, string, error) {
 | 
			
		||||
	u, err := url.Parse(opts.URL)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, "", err
 | 
			
		||||
	}
 | 
			
		||||
	u.Scheme = strings.TrimPrefix(u.Scheme, "git+")
 | 
			
		||||
 | 
			
		||||
	query := u.Query()
 | 
			
		||||
 | 
			
		||||
	rev := query.Get("~rev")
 | 
			
		||||
	query.Del("~rev")
 | 
			
		||||
 | 
			
		||||
	name := query.Get("~name")
 | 
			
		||||
	query.Del("~name")
 | 
			
		||||
 | 
			
		||||
	depthStr := query.Get("~depth")
 | 
			
		||||
	query.Del("~depth")
 | 
			
		||||
 | 
			
		||||
	recursive := query.Get("~recursive")
 | 
			
		||||
	query.Del("~recursive")
 | 
			
		||||
 | 
			
		||||
	u.RawQuery = query.Encode()
 | 
			
		||||
 | 
			
		||||
	depth := 0
 | 
			
		||||
	if depthStr != "" {
 | 
			
		||||
		depth, err = strconv.Atoi(depthStr)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return 0, "", err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	co := &git.CloneOptions{
 | 
			
		||||
		URL:               u.String(),
 | 
			
		||||
		Depth:             depth,
 | 
			
		||||
		Progress:          opts.Progress,
 | 
			
		||||
		RecurseSubmodules: git.NoRecurseSubmodules,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if recursive == "true" {
 | 
			
		||||
		co.RecurseSubmodules = git.DefaultSubmoduleRecursionDepth
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	r, err := git.PlainClone(opts.Destination, false, co)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = r.Fetch(&git.FetchOptions{
 | 
			
		||||
		RefSpecs: []config.RefSpec{"+refs/*:refs/*"},
 | 
			
		||||
	})
 | 
			
		||||
	if err != git.NoErrAlreadyUpToDate && err != nil {
 | 
			
		||||
		return 0, "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if rev != "" {
 | 
			
		||||
		h, err := r.ResolveRevision(plumbing.Revision(rev))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return 0, "", err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		w, err := r.Worktree()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return 0, "", err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		err = w.Checkout(&git.CheckoutOptions{
 | 
			
		||||
			Hash: *h,
 | 
			
		||||
		})
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return 0, "", err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if name == "" {
 | 
			
		||||
		name = strings.TrimSuffix(path.Base(u.Path), ".git")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return TypeDir, name, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Update uses git to pull the repository and update it
 | 
			
		||||
// to the latest revision. It allows specifying the depth
 | 
			
		||||
// and recursion options via query string. It returns
 | 
			
		||||
// true if update was successful and false if the
 | 
			
		||||
// repository is already up-to-date
 | 
			
		||||
func (GitDownloader) Update(opts Options) (bool, error) {
 | 
			
		||||
	u, err := url.Parse(opts.URL)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
	u.Scheme = strings.TrimPrefix(u.Scheme, "git+")
 | 
			
		||||
 | 
			
		||||
	query := u.Query()
 | 
			
		||||
	query.Del("~rev")
 | 
			
		||||
 | 
			
		||||
	depthStr := query.Get("~depth")
 | 
			
		||||
	query.Del("~depth")
 | 
			
		||||
 | 
			
		||||
	recursive := query.Get("~recursive")
 | 
			
		||||
	query.Del("~recursive")
 | 
			
		||||
 | 
			
		||||
	u.RawQuery = query.Encode()
 | 
			
		||||
 | 
			
		||||
	r, err := git.PlainOpen(opts.Destination)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	w, err := r.Worktree()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	depth := 0
 | 
			
		||||
	if depthStr != "" {
 | 
			
		||||
		depth, err = strconv.Atoi(depthStr)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return false, err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	po := &git.PullOptions{
 | 
			
		||||
		Depth:             depth,
 | 
			
		||||
		Progress:          opts.Progress,
 | 
			
		||||
		RecurseSubmodules: git.NoRecurseSubmodules,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if recursive == "true" {
 | 
			
		||||
		po.RecurseSubmodules = git.DefaultSubmoduleRecursionDepth
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	m, err := getManifest(opts.Destination)
 | 
			
		||||
	manifestOK := err == nil
 | 
			
		||||
 | 
			
		||||
	err = w.Pull(po)
 | 
			
		||||
	if errors.Is(err, git.NoErrAlreadyUpToDate) {
 | 
			
		||||
		return false, nil
 | 
			
		||||
	} else if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if manifestOK {
 | 
			
		||||
		err = writeManifest(opts.Destination, m)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return true, err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return true, nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										90
									
								
								internal/dl/torrent.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								internal/dl/torrent.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,90 @@
 | 
			
		||||
package dl
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/exec"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"regexp"
 | 
			
		||||
	"strings"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	urlMatchRegex       = regexp.MustCompile(`(magnet|torrent\+https?):.*`)
 | 
			
		||||
	ErrAria2NotFound    = errors.New("aria2 must be installed for torrent functionality")
 | 
			
		||||
	ErrDestinationEmpty = errors.New("the destination directory is empty")
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type TorrentDownloader struct{}
 | 
			
		||||
 | 
			
		||||
// Name always returns "file"
 | 
			
		||||
func (TorrentDownloader) Name() string {
 | 
			
		||||
	return "torrent"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MatchURL returns true if the URL is a magnet link
 | 
			
		||||
// or an http(s) link with a "torrent+" prefix
 | 
			
		||||
func (TorrentDownloader) MatchURL(u string) bool {
 | 
			
		||||
	return urlMatchRegex.MatchString(u)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Download downloads a file over the BitTorrent protocol.
 | 
			
		||||
func (TorrentDownloader) Download(opts Options) (Type, string, error) {
 | 
			
		||||
	aria2Path, err := exec.LookPath("aria2c")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, "", ErrAria2NotFound
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	opts.URL = strings.TrimPrefix(opts.URL, "torrent+")
 | 
			
		||||
 | 
			
		||||
	cmd := exec.Command(aria2Path, "--summary-interval=0", "--log-level=warn", "--seed-time=0", "--dir="+opts.Destination, opts.URL)
 | 
			
		||||
	cmd.Stdout = os.Stdout
 | 
			
		||||
	cmd.Stderr = os.Stderr
 | 
			
		||||
	err = cmd.Run()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, "", fmt.Errorf("aria2c returned an error: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = removeTorrentFiles(opts.Destination)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return determineType(opts.Destination)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func removeTorrentFiles(path string) error {
 | 
			
		||||
	filePaths, err := filepath.Glob(filepath.Join(path, "*.torrent"))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, filePath := range filePaths {
 | 
			
		||||
		err = os.Remove(filePath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func determineType(path string) (Type, string, error) {
 | 
			
		||||
	files, err := os.ReadDir(path)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(files) > 1 {
 | 
			
		||||
		return TypeDir, "", nil
 | 
			
		||||
	} else if len(files) == 1 {
 | 
			
		||||
		if files[0].IsDir() {
 | 
			
		||||
			return TypeDir, files[0].Name(), nil
 | 
			
		||||
		} else {
 | 
			
		||||
			return TypeFile, files[0].Name(), nil
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return 0, "", ErrDestinationEmpty
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user