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:
WithoutPants
2023-11-22 10:01:11 +11:00
committed by GitHub
parent d95ef4059a
commit 987fa80786
42 changed files with 3484 additions and 35 deletions

12
pkg/models/package.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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{})

View 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
View 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))
}