mirror of
https://github.com/tronbyt/server.git
synced 2026-08-31 06:57:10 +02:00
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:
+7
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user