feat: add max repo size limit for system and user app repositories (#878)

* feat: add max repo size limit for system and user app repositories

* update env.example
This commit is contained in:
Tavis
2026-08-13 16:39:43 -07:00
committed by GitHub
parent 29d53f1f72
commit ab0ffdd05b
7 changed files with 102 additions and 27 deletions
+7 -2
View File
@@ -1,12 +1,15 @@
# These are optional settings. To use any of these settings, copy this file to .env and then make edits.
# These are OPTIONAL settings. The server should work without setting any of these options.
# To use any of these settings, copy this file to .env and then make edits.
# This is the port that docker will expose externally. default is 8000
# SERVER_PORT=443
# PRODUCTION=true
# LOG_LEVEL=INFO
# ENABLE_PPROF=false
# SYSTEM_APPS_REPO="https://github.com/tronbyt/apps.git"
# default set to 750MB
# MAX_REPO_SIZE_MB=300
# User Registration Settings
# Set to "true" to enable open user registration (anyone can create an account)
# Set to "false" (default) to only allow admin users to create new accounts
@@ -18,3 +21,5 @@
# Only works from: localhost, 192.168.x.x, 10.x.x.x, 172.16.x.x
# Public IPs still require authentication
# SINGLE_USER_AUTO_LOGIN=true
# ENABLE_PPROF=false
+1 -1
View File
@@ -39,7 +39,7 @@ func run(cmd *cobra.Command, args []string) error {
}
// Clone/Update System Apps Repo
if err := gitutils.EnsureRepo(cfg.SystemAppsDir(), cfg.SystemAppsRepo, cfg.GitHubToken, cfg.Production); err != nil {
if err := gitutils.EnsureRepo(cfg.SystemAppsDir(), cfg.SystemAppsRepo, cfg.GitHubToken, cfg.Production, cfg.MaxRepoSize()); err != nil {
slog.Error("Failed to update system apps repo", "error", err)
}
+1 -1
View File
@@ -23,7 +23,7 @@ func run(cmd *cobra.Command, _ []string) error {
}
// Clone/Update System Apps Repo
if err := gitutils.EnsureRepo(cfg.SystemAppsDir(), cfg.SystemAppsRepo, cfg.GitHubToken, true); err != nil {
if err := gitutils.EnsureRepo(cfg.SystemAppsDir(), cfg.SystemAppsRepo, cfg.GitHubToken, true, cfg.MaxRepoSize()); err != nil {
return fmt.Errorf("failed to update system apps repo: %w", err)
}
return nil
+7
View File
@@ -19,6 +19,7 @@ type Settings struct {
SingleUserAutoLogin bool `env:"SINGLE_USER_AUTO_LOGIN"`
SystemAppsAutoRefresh bool `env:"SYSTEM_APPS_AUTO_REFRESH"`
SystemAppsRepo string `env:"SYSTEM_APPS_REPO" envDefault:"https://github.com/tronbyt/apps.git"`
MaxRepoSizeMB int64 `env:"MAX_REPO_SIZE_MB" envDefault:"750"`
CustomAppsAutoRefresh bool `env:"CUSTOM_APPS_AUTO_REFRESH"`
GitHubToken string `env:"GITHUB_TOKEN"`
RedisURL string `env:"REDIS_URL"`
@@ -48,6 +49,12 @@ func (s *Settings) SystemAppsDir() string {
return filepath.Join(s.DataDir, "system-apps")
}
// MaxRepoSize returns the maximum allowed size in bytes of a git repo
// (system apps or custom app repos) before it is re-cloned from scratch.
func (s *Settings) MaxRepoSize() int64 {
return s.MaxRepoSizeMB * 1024 * 1024
}
// TemplateConfig holds configuration values needed in templates.
type TemplateConfig struct {
EnableUserRegistration bool
+82 -19
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/go-git/go-git/v6"
@@ -94,7 +95,9 @@ func GetRepoInfo(path string, remoteURL string) (*RepoInfo, error) {
}
// EnsureRepo clones a repo if it doesn't exist, or pulls if it does and update is true.
func EnsureRepo(path string, repoURL string, token string, update bool) error {
// maxSize is the maximum allowed size in bytes of the repo directory; a repo
// larger than that is re-cloned from scratch on the next update. Pass 0 to disable.
func EnsureRepo(path string, repoURL string, token string, update bool, maxSize int64) error {
slog.Info("Checking git repo", "path", path, "url", repoURL)
var clientOpts []client.Option
@@ -113,20 +116,7 @@ func EnsureRepo(path string, repoURL string, token string, update bool) error {
// Check if path exists
if _, err := os.Stat(path); os.IsNotExist(err) {
slog.Info("Cloning repo", "url", repoURL)
r, err := git.PlainClone(path, &git.CloneOptions{
URL: repoURL,
Progress: &logWriter{},
Depth: 1,
SingleBranch: true,
Tags: git.NoTags,
ClientOptions: clientOpts,
})
if err == nil {
_ = r.Close()
}
return err
return cloneRepo(path, repoURL, clientOpts)
}
// Repo exists, open it
@@ -137,7 +127,7 @@ func EnsureRepo(path string, repoURL string, token string, update bool) error {
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("failed to remove invalid repo directory: %w", err)
}
return EnsureRepo(path, repoURL, token, update)
return EnsureRepo(path, repoURL, token, update, maxSize)
}
// If not a git repo, maybe remove and re-clone?
// For safety, error out.
@@ -162,7 +152,7 @@ func EnsureRepo(path string, repoURL string, token string, update bool) error {
return fmt.Errorf("failed to remove old repo: %w", err)
}
return EnsureRepo(path, repoURL, token, update)
return EnsureRepo(path, repoURL, token, update, maxSize)
}
if !update {
@@ -170,6 +160,22 @@ func EnsureRepo(path string, repoURL string, token string, update bool) error {
return nil
}
// Shallow fetches accumulate objects that go-git never prunes, so the
// repository grows unboundedly over time. Once it exceeds the size limit,
// re-clone it from scratch to reset it. The new clone goes to a temp dir
// and is swapped in only on success, so a failed re-clone leaves the
// existing repository untouched.
if maxSize > 0 {
size, err := dirSize(path)
if err != nil {
slog.Warn("Failed to measure repo size, skipping re-clone check", "error", err)
} else if size > maxSize {
slog.Info("Repo too large, re-cloning", "size", size, "limit", maxSize)
_ = r.Close()
return recloneRepo(path, repoURL, clientOpts)
}
}
// Pull
w, err := r.Worktree()
if err != nil {
@@ -212,7 +218,7 @@ func EnsureRepo(path string, repoURL string, token string, update bool) error {
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("failed to remove broken repo: %w", err)
}
return EnsureRepo(path, repoURL, token, update)
return EnsureRepo(path, repoURL, token, update, maxSize)
}
return fmt.Errorf("failed to fetch repo: %w", err)
}
@@ -227,7 +233,7 @@ func EnsureRepo(path string, repoURL string, token string, update bool) error {
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("failed to remove broken repo: %w", err)
}
return EnsureRepo(path, repoURL, token, update)
return EnsureRepo(path, repoURL, token, update, maxSize)
}
// Hard Reset the worktree to the remote commit
@@ -246,3 +252,60 @@ func EnsureRepo(path string, repoURL string, token string, update bool) error {
return nil
}
// dirSize returns the total size in bytes of all regular files under path.
func dirSize(path string) (int64, error) {
var size int64
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Mode().IsRegular() {
size += info.Size()
}
return nil
})
return size, err
}
// cloneRepo performs a shallow, single-branch, tag-less clone of repoURL into path.
func cloneRepo(path string, repoURL string, clientOpts []client.Option) error {
slog.Info("Cloning repo", "url", repoURL)
r, err := git.PlainClone(path, &git.CloneOptions{
URL: repoURL,
Progress: &logWriter{},
Depth: 1,
SingleBranch: true,
Tags: git.NoTags,
ClientOptions: clientOpts,
})
if err == nil {
_ = r.Close()
}
return err
}
// recloneRepo re-clones repoURL into path atomically: the new clone is written
// to a temporary directory in the same parent and moved into place only after
// it succeeds, so a failed re-clone leaves the existing repository intact.
func recloneRepo(path string, repoURL string, clientOpts []client.Option) error {
tmp, err := os.MkdirTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-")
if err != nil {
return fmt.Errorf("failed to create temp dir for re-clone: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
if err := os.RemoveAll(tmp); err != nil {
return fmt.Errorf("failed to prepare temp dir for re-clone: %w", err)
}
if err := cloneRepo(tmp, repoURL, clientOpts); err != nil {
return fmt.Errorf("failed to re-clone repo: %w", err)
}
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("failed to remove oversized repo: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("failed to move re-cloned repo into place: %w", err)
}
return nil
}
+1 -1
View File
@@ -87,7 +87,7 @@ func (s *Server) autoRefreshSystemRepo() {
func (s *Server) refreshSystemRepo() error {
repoURL := s.Config.SystemAppsRepo
appsPath := filepath.Join(s.DataDir, "system-apps")
if err := gitutils.EnsureRepo(appsPath, repoURL, s.Config.GitHubToken, true); err != nil {
if err := gitutils.EnsureRepo(appsPath, repoURL, s.Config.GitHubToken, true, s.Config.MaxRepoSize()); err != nil {
return err
}
+3 -3
View File
@@ -252,7 +252,7 @@ func (s *Server) handleSetUserRepo(w http.ResponseWriter, r *http.Request) {
return
}
} else {
if err := gitutils.EnsureRepo(appsPath, repoURL, s.Config.GitHubToken, true); err != nil {
if err := gitutils.EnsureRepo(appsPath, repoURL, s.Config.GitHubToken, true, s.Config.MaxRepoSize()); err != nil {
slog.Error("Failed to sync user repo", "error", err)
s.flashAndRedirect(w, r, "Failed to sync user repository. Check server logs.", "/settings/content", http.StatusSeeOther)
return
@@ -267,7 +267,7 @@ func (s *Server) handleRefreshUserRepo(w http.ResponseWriter, r *http.Request) {
if user.AppRepoURL != "" {
appsPath := filepath.Join(s.DataDir, "users", user.Username, "repo")
if err := gitutils.EnsureRepo(appsPath, user.AppRepoURL, s.Config.GitHubToken, true); err != nil {
if err := gitutils.EnsureRepo(appsPath, user.AppRepoURL, s.Config.GitHubToken, true, s.Config.MaxRepoSize()); err != nil {
slog.Error("Failed to refresh user repo", "error", err)
s.flashAndRedirect(w, r, "Failed to refresh user repository. Check server logs.", "/settings/content", http.StatusSeeOther)
return
@@ -310,7 +310,7 @@ func (s *Server) refreshAllCustomAppsRepos() {
for _, user := range users {
appsPath := filepath.Join(s.DataDir, "users", user.Username, "repo")
if err := gitutils.EnsureRepo(appsPath, user.AppRepoURL, s.Config.GitHubToken, true); err != nil {
if err := gitutils.EnsureRepo(appsPath, user.AppRepoURL, s.Config.GitHubToken, true, s.Config.MaxRepoSize()); err != nil {
slog.Error("Scheduled refresh of custom apps repo failed", "user", user.Username, "error", err)
}
}