feat: update button (download, verify, stage)
This commit is contained in:
1 parent
834493a4f5
commit
70460e4170
41 files changed
+5345
-97
No files matched your search
@@ -0,0 +1,44 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
errSignatureEncoding = errors.New("the signature is not a base64 encoded ed25519 signature")
|
||||
errSignatureMismatch = errors.New("the signature does not match the checksums file")
|
||||
)
|
||||
|
||||
type Ed25519SignatureVerifier struct {
|
||||
key ed25519.PublicKey
|
||||
}
|
||||
|
||||
var _ domain.SignatureVerifier = &Ed25519SignatureVerifier{}
|
||||
|
||||
func NewEd25519SignatureVerifier(key ed25519.PublicKey) *Ed25519SignatureVerifier {
|
||||
return &Ed25519SignatureVerifier{key: key}
|
||||
}
|
||||
|
||||
func (v *Ed25519SignatureVerifier) Verify(checksumsPath string, signaturePath string) error {
|
||||
content, err := os.ReadFile(checksumsPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawSignature, err := os.ReadFile(signaturePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(rawSignature)))
|
||||
if err != nil || len(signature) != ed25519.SignatureSize {
|
||||
return errSignatureEncoding
|
||||
}
|
||||
if len(v.key) != ed25519.PublicKeySize || !ed25519.Verify(v.key, content, signature) {
|
||||
return errSignatureMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const stagedBinaryRunTimeout = 15 * time.Second
|
||||
|
||||
type ExecStagedBinaryRunner struct{}
|
||||
|
||||
var _ domain.StagedBinaryRunner = &ExecStagedBinaryRunner{}
|
||||
|
||||
func NewExecStagedBinaryRunner() *ExecStagedBinaryRunner {
|
||||
return &ExecStagedBinaryRunner{}
|
||||
}
|
||||
|
||||
func (r *ExecStagedBinaryRunner) Version(binaryPath string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), stagedBinaryRunTimeout)
|
||||
defer cancel()
|
||||
output, err := exec.CommandContext(ctx, binaryPath, "--version").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
type FileHelperDetector struct {
|
||||
statusPath string
|
||||
}
|
||||
|
||||
var _ domain.HelperDetector = &FileHelperDetector{}
|
||||
|
||||
func NewFileHelperDetector(statusPath string) *FileHelperDetector {
|
||||
return &FileHelperDetector{statusPath: statusPath}
|
||||
}
|
||||
|
||||
func (d *FileHelperDetector) Installed() bool {
|
||||
content, err := os.ReadFile(d.statusPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var status map[string]any
|
||||
return json.Unmarshal(content, &status) == nil && status != nil
|
||||
}
|
||||
@@ -24,11 +24,18 @@ type GiteaReleaseFeed struct {
|
||||
var _ domain.ReleaseFeed = &GiteaReleaseFeed{}
|
||||
|
||||
type giteaRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Body string `json:"body"`
|
||||
Draft bool `json:"draft"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Body string `json:"body"`
|
||||
Draft bool `json:"draft"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
Assets []giteaAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type giteaAsset struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
DownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
func NewGiteaReleaseFeed(baseURL string) *GiteaReleaseFeed {
|
||||
@@ -73,7 +80,16 @@ func toReleases(raw []giteaRelease) []domain.Release {
|
||||
URL: item.HTMLURL,
|
||||
PublishedAt: item.PublishedAt,
|
||||
Notes: domain.BuildNotes(item.TagName, item.PublishedAt, item.Body),
|
||||
Assets: toAssets(item.Assets),
|
||||
})
|
||||
}
|
||||
return releases
|
||||
}
|
||||
|
||||
func toAssets(raw []giteaAsset) []domain.Asset {
|
||||
assets := make([]domain.Asset, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
assets = append(assets, domain.Asset{Name: item.Name, URL: item.DownloadURL, Size: item.Size})
|
||||
}
|
||||
return assets
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxBinaryBytes int64 = 200 << 20
|
||||
maxChecksumsBytes int64 = 64 << 10
|
||||
maxSignatureBytes int64 = 1 << 10
|
||||
assetDownloadTimeout = 10 * time.Minute
|
||||
maxAssetRedirects = 5
|
||||
assetFilePermission = 0o644
|
||||
signatureSuffix = ".sig"
|
||||
)
|
||||
|
||||
var (
|
||||
errAssetURLRefused = errors.New("the asset url is not https on the release feed host")
|
||||
errAssetStatus = errors.New("the asset server answered with an error status")
|
||||
errAssetTooLarge = errors.New("the asset is larger than its size cap")
|
||||
errTooManyRedirects = errors.New("the asset url redirected too many times")
|
||||
)
|
||||
|
||||
type FileOpener func(path string) (io.WriteCloser, error)
|
||||
|
||||
type HTTPAssetFetcher struct {
|
||||
allowedHost string
|
||||
allowInsecure bool
|
||||
sizeCap int64
|
||||
timeout time.Duration
|
||||
transport http.RoundTripper
|
||||
openFile FileOpener
|
||||
}
|
||||
|
||||
var _ domain.AssetFetcher = &HTTPAssetFetcher{}
|
||||
|
||||
type FetcherOption func(*HTTPAssetFetcher)
|
||||
|
||||
func WithInsecureScheme() FetcherOption {
|
||||
return func(f *HTTPAssetFetcher) { f.allowInsecure = true }
|
||||
}
|
||||
|
||||
func WithSizeCap(bytes int64) FetcherOption {
|
||||
return func(f *HTTPAssetFetcher) { f.sizeCap = bytes }
|
||||
}
|
||||
|
||||
func WithTimeout(timeout time.Duration) FetcherOption {
|
||||
return func(f *HTTPAssetFetcher) { f.timeout = timeout }
|
||||
}
|
||||
|
||||
func WithTransport(transport http.RoundTripper) FetcherOption {
|
||||
return func(f *HTTPAssetFetcher) { f.transport = transport }
|
||||
}
|
||||
|
||||
func WithFileOpener(opener FileOpener) FetcherOption {
|
||||
return func(f *HTTPAssetFetcher) { f.openFile = opener }
|
||||
}
|
||||
|
||||
func NewHTTPAssetFetcher(feedURL string, options ...FetcherOption) *HTTPAssetFetcher {
|
||||
fetcher := &HTTPAssetFetcher{
|
||||
allowedHost: domain.HostOf(feedURL),
|
||||
timeout: assetDownloadTimeout,
|
||||
openFile: createAssetFile,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(fetcher)
|
||||
}
|
||||
return fetcher
|
||||
}
|
||||
|
||||
func (f *HTTPAssetFetcher) Fetch(asset domain.Asset, destination string, report domain.ProgressFunc) error {
|
||||
if !f.isAllowed(asset.URL) {
|
||||
return errAssetURLRefused
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := f.client().Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: %d", errAssetStatus, response.StatusCode)
|
||||
}
|
||||
limit := f.capFor(asset.Name)
|
||||
if response.ContentLength > limit {
|
||||
return errAssetTooLarge
|
||||
}
|
||||
if err := f.writeBody(response.Body, destination, limit, asset.Size, report); err != nil {
|
||||
_ = os.Remove(destination)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *HTTPAssetFetcher) writeBody(body io.Reader, destination string, limit int64, declaredSize int64, report domain.ProgressFunc) error {
|
||||
file, err := f.openFile(destination)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
counter := &progressWriter{destination: file, total: declaredSize, report: report}
|
||||
written, copyErr := io.Copy(counter, io.LimitReader(body, limit+1))
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if written > limit {
|
||||
return errAssetTooLarge
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *HTTPAssetFetcher) isAllowed(rawURL string) bool {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || f.allowedHost == "" || parsed.Host != f.allowedHost {
|
||||
return false
|
||||
}
|
||||
return parsed.Scheme == "https" || (f.allowInsecure && parsed.Scheme == "http")
|
||||
}
|
||||
|
||||
func (f *HTTPAssetFetcher) client() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: f.transport,
|
||||
CheckRedirect: func(next *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxAssetRedirects {
|
||||
return errTooManyRedirects
|
||||
}
|
||||
if !f.isAllowed(next.URL.String()) {
|
||||
return errAssetURLRefused
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *HTTPAssetFetcher) capFor(name string) int64 {
|
||||
if f.sizeCap > 0 {
|
||||
return f.sizeCap
|
||||
}
|
||||
if name == domain.ChecksumsAssetName {
|
||||
return maxChecksumsBytes
|
||||
}
|
||||
if strings.HasSuffix(name, signatureSuffix) {
|
||||
return maxSignatureBytes
|
||||
}
|
||||
return maxBinaryBytes
|
||||
}
|
||||
|
||||
type progressWriter struct {
|
||||
destination io.Writer
|
||||
total int64
|
||||
done int64
|
||||
report domain.ProgressFunc
|
||||
}
|
||||
|
||||
func (w *progressWriter) Write(chunk []byte) (int, error) {
|
||||
written, err := w.destination.Write(chunk)
|
||||
w.done += int64(written)
|
||||
w.report(w.done, w.total)
|
||||
return written, err
|
||||
}
|
||||
|
||||
func createAssetFile(path string) (io.WriteCloser, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, assetFilePermission)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type JSONJobStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ domain.JobStore = &JSONJobStore{}
|
||||
|
||||
func NewJSONJobStore(path string) *JSONJobStore {
|
||||
store := &JSONJobStore{path: path}
|
||||
store.failInterruptedJob()
|
||||
return store
|
||||
}
|
||||
|
||||
func (s *JSONJobStore) failInterruptedJob() {
|
||||
job, found := s.read()
|
||||
if !found || !job.IsRunning() {
|
||||
return
|
||||
}
|
||||
job.State = domain.StateFailed
|
||||
job.Error = domain.ErrorKindDownload
|
||||
job.DownloadedBytes = 0
|
||||
job.TotalBytes = 0
|
||||
_ = s.write(job)
|
||||
}
|
||||
|
||||
func (s *JSONJobStore) Load() (domain.Job, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
job, found := s.read()
|
||||
if !found {
|
||||
return domain.Job{}, false
|
||||
}
|
||||
if job.State == domain.StateReady && !fileExists(job.StagedBinary) {
|
||||
return domain.Job{}, false
|
||||
}
|
||||
return job, true
|
||||
}
|
||||
|
||||
func (s *JSONJobStore) Save(job domain.Job) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.write(job)
|
||||
}
|
||||
|
||||
func (s *JSONJobStore) read() (domain.Job, bool) {
|
||||
content, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return domain.Job{}, false
|
||||
}
|
||||
var job domain.Job
|
||||
if err := json.Unmarshal(content, &job); err != nil || job.State == "" {
|
||||
return domain.Job{}, false
|
||||
}
|
||||
return job, true
|
||||
}
|
||||
|
||||
func (s *JSONJobStore) write(job domain.Job) error {
|
||||
content, err := json.Marshal(job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary := s.path + ".tmp"
|
||||
if err := os.WriteFile(temporary, content, statusFilePermissions); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporary, s.path)
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/update"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
stagedDirectoryPermissions = 0o755
|
||||
stagedBinaryPermissions = 0o755
|
||||
)
|
||||
|
||||
type OSStagingArea struct {
|
||||
root string
|
||||
}
|
||||
|
||||
var _ update.StagingArea = &OSStagingArea{}
|
||||
|
||||
func NewOSStagingArea(root string) *OSStagingArea {
|
||||
return &OSStagingArea{root: root}
|
||||
}
|
||||
|
||||
func (a *OSStagingArea) Prepare(tag string) error {
|
||||
directory := filepath.Join(a.root, tag)
|
||||
if err := os.RemoveAll(directory); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(directory, stagedDirectoryPermissions)
|
||||
}
|
||||
|
||||
func (a *OSStagingArea) PathFor(tag string, name string) string {
|
||||
return filepath.Join(a.root, tag, name)
|
||||
}
|
||||
|
||||
func (a *OSStagingArea) Size(path string) (int64, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
func (a *OSStagingArea) ReadChecksums(path string) (string, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
func (a *OSStagingArea) MakeExecutable(path string) error {
|
||||
return os.Chmod(path, stagedBinaryPermissions)
|
||||
}
|
||||
|
||||
func (a *OSStagingArea) Remove(tag string) error {
|
||||
return os.RemoveAll(filepath.Join(a.root, tag))
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var errBackupExists = errors.New("a backup with this name already exists")
|
||||
|
||||
const (
|
||||
backupsToKeep = 3
|
||||
backupFileExtension = ".db"
|
||||
backupFilePrefix = "renewcms-"
|
||||
backupTimeLayout = "20060102T150405.000000000Z"
|
||||
)
|
||||
|
||||
type SQLiteDatabaseBackup struct {
|
||||
db *gorm.DB
|
||||
directory string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
var _ domain.DatabaseBackup = &SQLiteDatabaseBackup{}
|
||||
|
||||
func NewSQLiteDatabaseBackup(db *gorm.DB, directory string, now func() time.Time) *SQLiteDatabaseBackup {
|
||||
return &SQLiteDatabaseBackup{db: db, directory: directory, now: now}
|
||||
}
|
||||
|
||||
func (b *SQLiteDatabaseBackup) Backup() (string, error) {
|
||||
if err := os.MkdirAll(b.directory, os.ModePerm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := filepath.Join(b.directory, backupFilePrefix+b.now().UTC().Format(backupTimeLayout)+backupFileExtension)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return "", errBackupExists
|
||||
}
|
||||
if err := b.db.Exec("VACUUM INTO ?", target).Error; err != nil {
|
||||
_ = os.Remove(target)
|
||||
return "", err
|
||||
}
|
||||
if err := b.prune(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
type backupFile struct {
|
||||
path string
|
||||
modified time.Time
|
||||
}
|
||||
|
||||
func (b *SQLiteDatabaseBackup) prune() error {
|
||||
entries, err := os.ReadDir(b.directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files := make([]backupFile, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), backupFileExtension) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, backupFile{path: filepath.Join(b.directory, entry.Name()), modified: info.ModTime()})
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
if files[i].modified.Equal(files[j].modified) {
|
||||
return files[i].path > files[j].path
|
||||
}
|
||||
return files[i].modified.After(files[j].modified)
|
||||
})
|
||||
for _, stale := range files[min(backupsToKeep, len(files)):] {
|
||||
if err := os.Remove(stale.path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user