// This file was originally part of the project "LURE - Linux User REpository", created by Elara Musayelyan.
// It has been modified as part of "ALR - Any Linux Repository" by Евгений Храмов.
//
// ALR - Any Linux Repository
// Copyright (C) 2025 Евгений Храмов
//
// 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 .
// Пакет dl содержит абстракции для загрузки файлов и каталогов
// из различных источников.
package dl
import (
"context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"errors"
"fmt"
"hash"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/PuerkitoBio/purell"
"github.com/leonelquinteros/gotext"
"github.com/vmihailenco/msgpack/v5"
"golang.org/x/crypto/blake2b"
"golang.org/x/crypto/blake2s"
"golang.org/x/exp/slices"
"gitea.plemya-x.ru/Plemya-x/ALR/internal/config"
"gitea.plemya-x.ru/Plemya-x/ALR/internal/dlcache"
)
// Константа для имени файла манифеста кэша
const manifestFileName = ".alr_cache_manifest"
// Объявление ошибок для несоответствия контрольной суммы и отсутствия алгоритма хеширования
var (
ErrChecksumMismatch = errors.New("dl: checksums did not match")
ErrNoSuchHashAlgo = errors.New("dl: invalid hashing algorithm")
)
// Массив доступных загрузчиков в порядке их проверки
var Downloaders = []Downloader{
GitDownloader{},
TorrentDownloader{},
FileDownloader{},
}
// Тип данных, представляющий тип загрузки (файл или каталог)
type Type uint8
// Объявление констант для типов загрузки
const (
TypeFile Type = iota
TypeDir
)
// Метод для получения строки, представляющей тип загрузки
func (t Type) String() string {
switch t {
case TypeFile:
return "file"
case TypeDir:
return "dir"
}
return ""
}
// Структура Options содержит параметры для загрузки файлов и каталогов
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 хранит информацию о типе и имени загруженного файла или каталога
type Manifest struct {
Type Type
Name string
}
// Интерфейс Downloader для реализации различных загрузчиков
type Downloader interface {
Name() string
MatchURL(string) bool
Download(Options) (Type, string, error)
}
// Интерфейс UpdatingDownloader расширяет Downloader методом Update
type UpdatingDownloader interface {
Downloader
Update(Options) (bool, error)
}
// Функция Download загружает файл или каталог с использованием указанных параметров
func Download(ctx context.Context, opts Options) (err error) {
cfg := config.GetInstance(ctx)
dc := dlcache.New(cfg)
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 := dc.Get(ctx, opts.URL)
if ok {
var updated bool
if d, ok := d.(UpdatingDownloader); ok {
slog.Info(
gotext.Get("Source can be updated, updating if required"),
"source", opts.Name,
"downloader", d.Name(),
)
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 {
slog.Info(
gotext.Get("Source found in cache and linked to destination"),
"source", opts.Name,
"type", t,
)
return nil
} else if ok {
slog.Info(
gotext.Get("Source updated and linked to destination"),
"source", opts.Name,
"type", t,
)
return nil
}
} else {
err = os.RemoveAll(cacheDir)
if err != nil {
return err
}
}
}
slog.Info(gotext.Get("Downloading source"), "source", opts.Name, "downloader", d.Name())
cacheDir, err = dc.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 записывает манифест в указанный каталог кэша
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 считывает манифест из указанного каталога кэша
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 создает жесткие ссылки для файлов из каталога кэша в каталог назначения
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 рекурсивно создает жесткие ссылки для файлов из каталога src в каталог dest
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)
})
}
// Функция getDownloader возвращает загрузчик, соответствующий URL
func getDownloader(u string) Downloader {
for _, d := range Downloaders {
if d.MatchURL(u) {
return d
}
}
return nil
}
// Функция normalizeURL нормализует строку URL, чтобы незначительные различия не изменяли хеш
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
}
// Исправление URL-адресов magnet после нормализации
u = strings.Replace(u, "magnet://", "magnet:", 1)
return u, nil
}