mirror of
https://github.com/stashapp/stash.git
synced 2025-12-16 20:07:05 +03:00
Scraper and plugin manager (#4242)
* Add package manager * Add SettingModal validate * Reverse modal button order * Add plugin package management * Refactor ClearableInput
This commit is contained in:
12
pkg/models/package.go
Normal file
12
pkg/models/package.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
type PackageSpecInput struct {
|
||||
ID string `json:"id"`
|
||||
SourceURL string `json:"sourceURL"`
|
||||
}
|
||||
|
||||
type PackageSource struct {
|
||||
Name *string `json:"name"`
|
||||
LocalPath string `json:"localPath"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
82
pkg/pkg/cache.go
Normal file
82
pkg/pkg/cache.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/hash/md5"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
)
|
||||
|
||||
const cacheSubDir = "package_lists"
|
||||
|
||||
type repositoryCache struct {
|
||||
cachePath string
|
||||
}
|
||||
|
||||
func (c *repositoryCache) path(url string) string {
|
||||
// convert the url to md5
|
||||
hash := md5.FromString(url)
|
||||
|
||||
return filepath.Join(c.cachePath, cacheSubDir, hash)
|
||||
}
|
||||
|
||||
func (c *repositoryCache) lastModified(url string) *time.Time {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
path := c.path(url)
|
||||
s, err := os.Stat(path)
|
||||
if err != nil {
|
||||
// ignore
|
||||
logger.Debugf("error getting cached file %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
ret := s.ModTime()
|
||||
return &ret
|
||||
}
|
||||
|
||||
func (c *repositoryCache) getPackageList(url string) (io.ReadCloser, error) {
|
||||
path := c.path(url)
|
||||
ret, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file %q: %w", path, err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (c *repositoryCache) cacheFile(url string, data io.ReadCloser) (io.ReadCloser, error) {
|
||||
if c == nil {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
path := c.path(url)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
// ignore, just return the original file
|
||||
logger.Debugf("error creating cache path %s: %v", filepath.Dir(path), err)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
// ignore, just return the original file
|
||||
logger.Debugf("error creating cached file %s: %v", path, err)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
defer data.Close()
|
||||
if _, err := io.Copy(f, data); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("writing to cache file %s - %w", path, err)
|
||||
}
|
||||
|
||||
_ = f.Close()
|
||||
return c.getPackageList(url)
|
||||
}
|
||||
268
pkg/pkg/manager.go
Normal file
268
pkg/pkg/manager.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
type CachePathGetter interface {
|
||||
GetCachePath() string
|
||||
}
|
||||
|
||||
// SourcePathGetter gets the source path for a given package URL.
|
||||
type SourcePathGetter interface {
|
||||
// GetAllSourcePaths gets all source paths.
|
||||
GetAllSourcePaths() []string
|
||||
|
||||
// GetSourcePath gets the source path for the given package URL.
|
||||
GetSourcePath(srcURL string) string
|
||||
}
|
||||
|
||||
// Manager manages the installation of paks.
|
||||
type Manager struct {
|
||||
Local *Store
|
||||
PackagePathGetter SourcePathGetter
|
||||
CachePathGetter CachePathGetter
|
||||
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func (m *Manager) remoteFromURL(path string) (*httpRepository, error) {
|
||||
u, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing path: %w", err)
|
||||
}
|
||||
|
||||
cachePath := m.CachePathGetter.GetCachePath()
|
||||
var cache *repositoryCache
|
||||
if cachePath != "" {
|
||||
cache = &repositoryCache{cachePath: cachePath}
|
||||
}
|
||||
|
||||
return newHttpRepository(*u, m.Client, cache), nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListInstalled(ctx context.Context) (LocalPackageIndex, error) {
|
||||
paths := m.PackagePathGetter.GetAllSourcePaths()
|
||||
|
||||
var installedList []Manifest
|
||||
|
||||
for _, p := range paths {
|
||||
store := m.Local.sub(p)
|
||||
|
||||
srcList, err := store.List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing local packages: %w", err)
|
||||
}
|
||||
|
||||
installedList = append(installedList, srcList...)
|
||||
}
|
||||
|
||||
return localPackageIndexFromList(installedList), nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListRemote(ctx context.Context, remoteURL string) (RemotePackageIndex, error) {
|
||||
r, err := m.remoteFromURL(remoteURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating remote repository: %w", err)
|
||||
}
|
||||
|
||||
list, err := r.List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing remote packages: %w", err)
|
||||
}
|
||||
|
||||
// add link to RemotePackage
|
||||
for i := range list {
|
||||
list[i].Repository = r
|
||||
}
|
||||
|
||||
ret := remotePackageIndexFromList(list)
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListInstalledRemotes(ctx context.Context, installed LocalPackageIndex) (RemotePackageIndex, error) {
|
||||
// get remotes for all installed packages
|
||||
allRemoteList := make(RemotePackageIndex)
|
||||
|
||||
remoteURLs := installed.remoteURLs()
|
||||
for _, remoteURL := range remoteURLs {
|
||||
remoteList, err := m.ListRemote(ctx, remoteURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allRemoteList.merge(remoteList)
|
||||
}
|
||||
|
||||
return allRemoteList, nil
|
||||
}
|
||||
|
||||
func (m *Manager) InstalledStatus(ctx context.Context) (PackageStatusIndex, error) {
|
||||
// get all installed packages
|
||||
installed, err := m.ListInstalled(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// get remotes for all installed packages
|
||||
allRemoteList, err := m.ListInstalledRemotes(ctx, installed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := MakePackageStatusIndex(installed, allRemoteList)
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (m *Manager) packageByID(ctx context.Context, spec models.PackageSpecInput) (*RemotePackage, error) {
|
||||
l, err := m.ListRemote(ctx, spec.SourceURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pkg, found := l[spec]
|
||||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
func (m *Manager) getStore(remoteURL string) *Store {
|
||||
srcPath := m.PackagePathGetter.GetSourcePath(remoteURL)
|
||||
store := m.Local.sub(srcPath)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func (m *Manager) Install(ctx context.Context, spec models.PackageSpecInput) error {
|
||||
remote, err := m.remoteFromURL(spec.SourceURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating remote repository: %w", err)
|
||||
}
|
||||
|
||||
pkg, err := m.packageByID(ctx, spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting remote package: %w", err)
|
||||
}
|
||||
|
||||
fromRemote, err := remote.GetPackageZip(ctx, *pkg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting remote package: %w", err)
|
||||
}
|
||||
|
||||
defer fromRemote.Close()
|
||||
|
||||
d, err := io.ReadAll(fromRemote)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading package data: %w", err)
|
||||
}
|
||||
|
||||
sha := fmt.Sprintf("%x", sha256.Sum256(d))
|
||||
if sha != pkg.Sha256 {
|
||||
return fmt.Errorf("package data (%s) does not match expected SHA256 (%s)", sha, pkg.Sha256)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(d), int64(len(d)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading zip data: %w", err)
|
||||
}
|
||||
|
||||
store := m.getStore(spec.SourceURL)
|
||||
|
||||
// uninstall existing package if present
|
||||
if _, err := store.getManifest(ctx, pkg.ID); err == nil {
|
||||
if err := m.deletePackageFiles(ctx, store, pkg.ID); err != nil {
|
||||
return fmt.Errorf("uninstalling existing package: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.installPackage(*pkg, store, zr); err != nil {
|
||||
return fmt.Errorf("installing package: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) installPackage(pkg RemotePackage, store *Store, zr *zip.Reader) error {
|
||||
manifest := Manifest{
|
||||
ID: pkg.ID,
|
||||
Name: pkg.Name,
|
||||
Metadata: pkg.Metadata,
|
||||
PackageVersion: pkg.PackageVersion,
|
||||
RepositoryURL: pkg.Repository.Path(),
|
||||
}
|
||||
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
i, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fn := filepath.Clean(f.Name)
|
||||
if err := store.writeFile(pkg.ID, fn, f.Mode(), i); err != nil {
|
||||
i.Close()
|
||||
return fmt.Errorf("writing file %q: %w", fn, err)
|
||||
}
|
||||
|
||||
i.Close()
|
||||
manifest.Files = append(manifest.Files, fn)
|
||||
}
|
||||
|
||||
if err := store.writeManifest(pkg.ID, manifest); err != nil {
|
||||
return fmt.Errorf("writing manifest: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Uninstall uninstalls the given package.
|
||||
func (m *Manager) Uninstall(ctx context.Context, spec models.PackageSpecInput) error {
|
||||
store := m.getStore(spec.SourceURL)
|
||||
|
||||
if err := m.deletePackageFiles(ctx, store, spec.ID); err != nil {
|
||||
return fmt.Errorf("deleting local package: %w", err)
|
||||
}
|
||||
|
||||
// also delete the directory
|
||||
// ignore errors
|
||||
_ = store.deletePackageDir(spec.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) deletePackageFiles(ctx context.Context, store *Store, id string) error {
|
||||
manifest, err := store.getManifest(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting manifest: %w", err)
|
||||
}
|
||||
|
||||
for _, f := range manifest.Files {
|
||||
if err := store.deleteFile(id, f); err != nil {
|
||||
// ignore
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.deleteManifest(id); err != nil {
|
||||
return fmt.Errorf("deleting manifest: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
198
pkg/pkg/pkg.go
Normal file
198
pkg/pkg/pkg.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
)
|
||||
|
||||
const timeFormat = "2006-01-02 15:04:05 -0700"
|
||||
|
||||
type Time struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
func (t *Time) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||
var s string
|
||||
if err := unmarshal(&s); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := time.Parse(timeFormat, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Time = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t Time) MarshalYAML() (interface{}, error) {
|
||||
return t.Format(timeFormat), nil
|
||||
}
|
||||
|
||||
type PackageMetadata map[string]interface{}
|
||||
|
||||
type PackageVersion struct {
|
||||
Version string `yaml:"version"`
|
||||
Date Time `yaml:"date"`
|
||||
}
|
||||
|
||||
func (v PackageVersion) Upgradable(o PackageVersion) bool {
|
||||
return o.Date.After(v.Date.Time)
|
||||
}
|
||||
|
||||
func (v PackageVersion) String() string {
|
||||
ret := v.Version
|
||||
if !v.Date.IsZero() {
|
||||
date := v.Date.Format("2006-01-02")
|
||||
if ret != "" {
|
||||
ret += fmt.Sprintf(" (%s)", date)
|
||||
} else {
|
||||
ret = date
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
type PackageLocation struct {
|
||||
// Path is the path to the package zip file.
|
||||
// This may be relative or absolute.
|
||||
Path string `yaml:"path"`
|
||||
Sha256 string `yaml:"sha256"`
|
||||
}
|
||||
|
||||
type RemotePackage struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Repository remoteRepository `yaml:"-"`
|
||||
Requires []string `yaml:"requires"`
|
||||
Metadata PackageMetadata `yaml:"metadata"`
|
||||
PackageVersion `yaml:",inline"`
|
||||
PackageLocation `yaml:",inline"`
|
||||
}
|
||||
|
||||
func (p RemotePackage) PackageSpecInput() models.PackageSpecInput {
|
||||
return models.PackageSpecInput{
|
||||
ID: p.ID,
|
||||
SourceURL: p.Repository.Path(),
|
||||
}
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Metadata PackageMetadata `yaml:"metadata"`
|
||||
PackageVersion `yaml:",inline"`
|
||||
Requires []string `yaml:"requires"`
|
||||
|
||||
RepositoryURL string `yaml:"source_repository"`
|
||||
Files []string `yaml:"files"`
|
||||
}
|
||||
|
||||
func (m Manifest) PackageSpecInput() models.PackageSpecInput {
|
||||
return models.PackageSpecInput{
|
||||
ID: m.ID,
|
||||
SourceURL: m.RepositoryURL,
|
||||
}
|
||||
}
|
||||
|
||||
// RemotePackageIndex is a map of package name to RemotePackage
|
||||
type RemotePackageIndex map[models.PackageSpecInput]RemotePackage
|
||||
|
||||
func (i RemotePackageIndex) merge(o RemotePackageIndex) {
|
||||
for id, pkg := range o {
|
||||
if existing, found := i[id]; found {
|
||||
if existing.Date.After(pkg.Date.Time) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
i[id] = pkg
|
||||
}
|
||||
}
|
||||
|
||||
func remotePackageIndexFromList(packages []RemotePackage) RemotePackageIndex {
|
||||
index := make(RemotePackageIndex)
|
||||
for _, pkg := range packages {
|
||||
specInput := pkg.PackageSpecInput()
|
||||
|
||||
// if package already exists in map, choose the newest
|
||||
if existing, found := index[specInput]; found {
|
||||
if existing.Date.After(pkg.Date.Time) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
index[specInput] = pkg
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// LocalPackageIndex is a map of package name to RemotePackage
|
||||
type LocalPackageIndex map[models.PackageSpecInput]Manifest
|
||||
|
||||
func (i LocalPackageIndex) remoteURLs() []string {
|
||||
var ret []string
|
||||
|
||||
for _, pkg := range i {
|
||||
ret = sliceutil.AppendUnique(ret, pkg.RepositoryURL)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func localPackageIndexFromList(packages []Manifest) LocalPackageIndex {
|
||||
index := make(LocalPackageIndex)
|
||||
for _, pkg := range packages {
|
||||
index[pkg.PackageSpecInput()] = pkg
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
type PackageStatus struct {
|
||||
Local *Manifest
|
||||
Remote *RemotePackage
|
||||
}
|
||||
|
||||
func (s PackageStatus) Upgradable() bool {
|
||||
if s.Local == nil || s.Remote == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return s.Local.Upgradable(s.Remote.PackageVersion)
|
||||
}
|
||||
|
||||
type PackageStatusIndex map[models.PackageSpecInput]PackageStatus
|
||||
|
||||
func MakePackageStatusIndex(installed LocalPackageIndex, remote RemotePackageIndex) PackageStatusIndex {
|
||||
i := make(PackageStatusIndex)
|
||||
|
||||
for spec, pkg := range installed {
|
||||
pkgCopy := pkg
|
||||
s := PackageStatus{
|
||||
Local: &pkgCopy,
|
||||
}
|
||||
|
||||
if remotePkg, found := remote[spec]; found {
|
||||
s.Remote = &remotePkg
|
||||
}
|
||||
|
||||
i[spec] = s
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
||||
|
||||
func (i PackageStatusIndex) Upgradable() []PackageStatus {
|
||||
var ret []PackageStatus
|
||||
|
||||
for _, s := range i {
|
||||
if s.Upgradable() {
|
||||
ret = append(ret, s)
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
22
pkg/pkg/repository.go
Normal file
22
pkg/pkg/repository.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// remoteRepository is a repository that can be used to get paks from.
|
||||
type remoteRepository interface {
|
||||
RemotePackageLister
|
||||
RemotePackageGetter
|
||||
Path() string
|
||||
}
|
||||
|
||||
type RemotePackageLister interface {
|
||||
// List returns all specs in the repository.
|
||||
List(ctx context.Context) ([]RemotePackage, error)
|
||||
}
|
||||
|
||||
type RemotePackageGetter interface {
|
||||
GetPackageZip(ctx context.Context, pkg RemotePackage) (io.ReadCloser, error)
|
||||
}
|
||||
205
pkg/pkg/repository_http.go
Normal file
205
pkg/pkg/repository_http.go
Normal file
@@ -0,0 +1,205 @@
|
||||
// Package http provides a repository implementation for HTTP.
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
// DefaultCacheTTL is the default time to live for the index cache.
|
||||
const DefaultCacheTTL = 5 * time.Minute
|
||||
|
||||
// httpRepository is a HTTP based repository.
|
||||
// It is configured with a package list URL. Packages are located from the Path field of the package.
|
||||
//
|
||||
// The index is cached for the duration of CacheTTL. The first request after the cache expires will cause the index to be reloaded.
|
||||
type httpRepository struct {
|
||||
packageListURL url.URL
|
||||
client *http.Client
|
||||
|
||||
cache *repositoryCache
|
||||
}
|
||||
|
||||
// newHttpRepository creates a new Repository. If client is nil then http.DefaultClient is used.
|
||||
func newHttpRepository(packageListURL url.URL, client *http.Client, cache *repositoryCache) *httpRepository {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &httpRepository{
|
||||
packageListURL: packageListURL,
|
||||
client: client,
|
||||
cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *httpRepository) Path() string {
|
||||
return r.packageListURL.String()
|
||||
}
|
||||
|
||||
func (r *httpRepository) List(ctx context.Context) ([]RemotePackage, error) {
|
||||
u := r.packageListURL
|
||||
|
||||
// the package list URL may be file://, in which case we need to use the local file system
|
||||
var (
|
||||
f io.ReadCloser
|
||||
err error
|
||||
)
|
||||
if u.Scheme == "file" {
|
||||
f, err = r.getLocalFile(ctx, u.Path)
|
||||
} else {
|
||||
f, err = r.getFileCached(ctx, u)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get package list: %w", err)
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read package list: %w", err)
|
||||
}
|
||||
|
||||
var index []RemotePackage
|
||||
if err := yaml.Unmarshal(data, &index); err != nil {
|
||||
return nil, fmt.Errorf("reading package list: %w", err)
|
||||
}
|
||||
|
||||
return index, nil
|
||||
}
|
||||
|
||||
func isURL(s string) bool {
|
||||
u, err := url.Parse(s)
|
||||
return err == nil && u.Scheme != "" && (u.Scheme == "file" || u.Host != "")
|
||||
}
|
||||
|
||||
func (r *httpRepository) resolvePath(p string) url.URL {
|
||||
// if the path can be resolved to a URL, then use that
|
||||
if isURL(p) {
|
||||
// isURL ensures URL is valid
|
||||
u, _ := url.Parse(p)
|
||||
return *u
|
||||
}
|
||||
|
||||
// otherwise, determine if the path is relative or absolute
|
||||
// if it's relative, then join it with the package list URL
|
||||
u := r.packageListURL
|
||||
|
||||
if path.IsAbs(p) {
|
||||
u.Path = p
|
||||
} else {
|
||||
u.Path = path.Join(path.Dir(u.Path), p)
|
||||
}
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func (r *httpRepository) GetPackageZip(ctx context.Context, pkg RemotePackage) (io.ReadCloser, error) {
|
||||
p := pkg.Path
|
||||
|
||||
u := r.resolvePath(p)
|
||||
|
||||
var (
|
||||
f io.ReadCloser
|
||||
err error
|
||||
)
|
||||
|
||||
// the package list URL may be file://, in which case we need to use the local file system
|
||||
// the package zip path may be a URL. A remotely hosted list may _not_ use local files.
|
||||
if u.Scheme == "file" {
|
||||
if r.packageListURL.Scheme != "file" {
|
||||
return nil, fmt.Errorf("%s is invalid for a remotely hosted package list", u.String())
|
||||
}
|
||||
|
||||
f, err = r.getLocalFile(ctx, u.Path)
|
||||
} else {
|
||||
f, err = r.getFile(ctx, u)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get package file: %w", err)
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// getFileCached tries to get the list from the local cache.
|
||||
// If it is not found or is stale, then it gets it normally.
|
||||
func (r *httpRepository) getFileCached(ctx context.Context, u url.URL) (io.ReadCloser, error) {
|
||||
// check if the file is in the cache first
|
||||
localModTime := r.cache.lastModified(u.String())
|
||||
|
||||
if localModTime != nil {
|
||||
// get the update time of the file
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, u.String(), nil)
|
||||
if err != nil {
|
||||
// shouldn't happen
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get remote file: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("failed to get remote file: %s", resp.Status)
|
||||
}
|
||||
|
||||
lastModified := resp.Header.Get("Last-Modified")
|
||||
if lastModified != "" {
|
||||
remoteModTime, _ := time.Parse(http.TimeFormat, lastModified)
|
||||
|
||||
if !remoteModTime.After(*localModTime) {
|
||||
logger.Debugf("cached version of %s is equal or newer than remote", u.String())
|
||||
return r.cache.getPackageList(u.String())
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debugf("cached version of %s is older than remote", u.String())
|
||||
}
|
||||
|
||||
return r.getFile(ctx, u)
|
||||
}
|
||||
|
||||
func (r *httpRepository) getFile(ctx context.Context, u url.URL) (io.ReadCloser, error) {
|
||||
logger.Debugf("fetching %s", u.String())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
// shouldn't happen
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get remote file: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("failed to get remote file: %s", resp.Status)
|
||||
}
|
||||
|
||||
return r.cache.cacheFile(u.String(), resp.Body)
|
||||
}
|
||||
|
||||
func (r *httpRepository) getLocalFile(ctx context.Context, path string) (fs.File, error) {
|
||||
ret, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file %q: %w", path, err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
var _ = remoteRepository(&httpRepository{})
|
||||
55
pkg/pkg/repository_http_test.go
Normal file
55
pkg/pkg/repository_http_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Package http provides a repository implementation for HTTP.
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHttpRepository_resolvePath(t *testing.T) {
|
||||
mustParse := func(s string) url.URL {
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return *u
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
packageListURL url.URL
|
||||
p string
|
||||
want url.URL
|
||||
}{
|
||||
{
|
||||
name: "relative",
|
||||
packageListURL: mustParse("https://example.com/foo/packages.yaml"),
|
||||
p: "bar",
|
||||
want: mustParse("https://example.com/foo/bar"),
|
||||
},
|
||||
{
|
||||
name: "absolute",
|
||||
packageListURL: mustParse("https://example.com/foo/packages.yaml"),
|
||||
p: "/bar",
|
||||
want: mustParse("https://example.com/bar"),
|
||||
},
|
||||
{
|
||||
name: "different server",
|
||||
packageListURL: mustParse("https://example.com/foo/packages.yaml"),
|
||||
p: "http://example.org/bar",
|
||||
want: mustParse("http://example.org/bar"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := &httpRepository{
|
||||
packageListURL: tt.packageListURL,
|
||||
}
|
||||
got := r.resolvePath(tt.p)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("HttpRepository.resolvePath() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
158
pkg/pkg/store.go
Normal file
158
pkg/pkg/store.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
// ManifestFile is the default filename for the package manifest.
|
||||
const ManifestFile = "manifest"
|
||||
|
||||
// Store is a folder-based local repository.
|
||||
// Packages are installed in their own directory under BaseDir.
|
||||
// The package details are stored in a file named based on PackageFile.
|
||||
type Store struct {
|
||||
BaseDir string
|
||||
// ManifestFile is the filename of the package file.
|
||||
ManifestFile string
|
||||
}
|
||||
|
||||
// sub returns a new Store with the given path appended to the BaseDir.
|
||||
func (r *Store) sub(path string) *Store {
|
||||
if path == "" || path == "." {
|
||||
return r
|
||||
}
|
||||
|
||||
return &Store{
|
||||
BaseDir: filepath.Join(r.BaseDir, path),
|
||||
ManifestFile: r.ManifestFile,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Store) List(ctx context.Context) ([]Manifest, error) {
|
||||
e, err := os.ReadDir(r.BaseDir)
|
||||
// ignore if directory cannot be read
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var ret []Manifest
|
||||
|
||||
for _, ee := range e {
|
||||
if !ee.IsDir() {
|
||||
// ignore non-directories
|
||||
continue
|
||||
}
|
||||
|
||||
pkg, err := r.getManifest(ctx, ee.Name())
|
||||
if err != nil {
|
||||
// ignore if manifest does not exist
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret = append(ret, *pkg)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *Store) packageDir(id string) string {
|
||||
return filepath.Join(r.BaseDir, id)
|
||||
}
|
||||
|
||||
func (r *Store) manifestPath(id string) string {
|
||||
return filepath.Join(r.packageDir(id), r.ManifestFile)
|
||||
}
|
||||
|
||||
func (r *Store) getManifest(ctx context.Context, packageID string) (*Manifest, error) {
|
||||
pfp := r.manifestPath(packageID)
|
||||
|
||||
data, err := os.ReadFile(pfp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading manifest file %q: %w", pfp, err)
|
||||
}
|
||||
|
||||
var manifest Manifest
|
||||
if err := yaml.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("reading manifest file %q: %w", pfp, err)
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
func (r *Store) ensurePackageExists(packageID string) error {
|
||||
// ensure the manifest file exists
|
||||
if _, err := os.Stat(r.manifestPath(packageID)); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("package %q does not exist", packageID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Store) writeFile(packageID string, name string, mode fs.FileMode, i io.Reader) error {
|
||||
fn := filepath.Join(r.packageDir(packageID), name)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fn), os.ModePerm); err != nil {
|
||||
return fmt.Errorf("creating directory %v: %w", fn, err)
|
||||
}
|
||||
|
||||
o, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer o.Close()
|
||||
|
||||
if _, err := io.Copy(o, i); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Store) writeManifest(packageID string, m Manifest) error {
|
||||
pfp := r.manifestPath(packageID)
|
||||
data, err := yaml.Marshal(m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling manifest: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(pfp, data, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("writing manifest file %q: %w", pfp, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Store) deleteFile(packageID string, name string) error {
|
||||
// ensure the package exists
|
||||
if err := r.ensurePackageExists(packageID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pkgDir := r.packageDir(packageID)
|
||||
fp := filepath.Join(pkgDir, name)
|
||||
|
||||
return os.Remove(fp)
|
||||
}
|
||||
|
||||
func (r *Store) deleteManifest(packageID string) error {
|
||||
return r.deleteFile(packageID, r.ManifestFile)
|
||||
}
|
||||
|
||||
func (r *Store) deletePackageDir(packageID string) error {
|
||||
return os.Remove(r.packageDir(packageID))
|
||||
}
|
||||
Reference in New Issue
Block a user