feat: update button (download, verify, stage)

This commit is contained in:
fsylvain committed 2026-10-01 18:13:45 +02:00
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
}