Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ RUN apk add --no-cache bash curl jq gcc git go musl-dev
COPY go.mod go.sum ./
RUN go mod download
COPY ./ ./
# Convert CRLF (Windows) to LF so bash scripts parse correctly inside the container
RUN sed -i 's/\r$//' build.sh entrypoint.sh
RUN bash build.sh release docker

FROM openlistteam/openlist-base-image:${BASE_IMAGE_TAG}
Expand All @@ -26,6 +28,8 @@ RUN addgroup -g ${GID} ${USER} && \

COPY --from=builder --chmod=755 --chown=${UID}:${GID} /app/bin/openlist ./
COPY --chmod=755 --chown=${UID}:${GID} entrypoint.sh /entrypoint.sh
# Ensure entrypoint.sh has LF line endings when COPYed from a Windows checkout
RUN sed -i 's/\r$//' /entrypoint.sh

USER ${USER}
RUN /entrypoint.sh version
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ require (
github.com/fclairamb/ftpserverlib v0.26.1-0.20250709223522-4a925d79caf6
github.com/foxxorcat/mopan-sdk-go v0.1.6
github.com/foxxorcat/weiyun-sdk-go v0.1.4
github.com/fsnotify/fsnotify v1.10.1
github.com/gin-contrib/cors v1.7.7
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
Expand Down
197 changes: 57 additions & 140 deletions go.sum

Large diffs are not rendered by default.

128 changes: 69 additions & 59 deletions internal/bootstrap/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,103 +94,113 @@ func InitConfig() {
}
}
if !conf.Conf.Force {
confFromEnv()
confFromEnv(conf.Conf)
}

if conf.Conf.MaxConcurrency > math.MaxInt32 {
applyMaxConcurrency(conf.Conf)
applyMemoryConfig(conf.Conf)

if len(conf.Conf.Log.Filter.Filters) == 0 {
conf.Conf.Log.Filter.Enable = false
}
convertAbsPaths(conf.Conf, pwd)

err := os.MkdirAll(conf.Conf.TempDir, 0o777)
if err != nil {
log.Fatalf("create temp dir error: %+v", err)
}
log.Debugf("config: %+v", conf.Conf)

// Validate and display proxy configuration status
validateProxyConfig()

base.InitClient()
conf.URL = initURL(conf.Conf)
}

func confFromEnv(c *conf.Config) {
prefix := "OPENLIST_"
if flags.NoPrefix {
prefix = ""
}
log.Infof("load config from env with prefix: %s", prefix)
if err := env.ParseWithOptions(c, env.Options{
Prefix: prefix,
}); err != nil {
log.Fatalf("load config from env error: %+v", err)
}
}

func initURL(c *conf.Config) *url.URL {
if !strings.Contains(c.SiteURL, "://") {
c.SiteURL = utils.FixAndCleanPath(c.SiteURL)
}
u, err := url.Parse(c.SiteURL)
if err != nil {
utils.Log.Fatalf("can't parse site_url: %+v", err)
}
return u
}

func applyMaxConcurrency(c *conf.Config) {
if c.MaxConcurrency > math.MaxInt32 {
net.DefaultConcurrencyLimit = &net.ConcurrencyLimit{Limit: math.MaxInt32}
} else if conf.Conf.MaxConcurrency > 0 {
net.DefaultConcurrencyLimit = &net.ConcurrencyLimit{Limit: uint32(conf.Conf.MaxConcurrency)}
} else if c.MaxConcurrency > 0 {
net.DefaultConcurrencyLimit = &net.ConcurrencyLimit{Limit: uint32(c.MaxConcurrency)}
}
}

func applyMemoryConfig(c *conf.Config) {
memStat, _ := mem.VirtualMemory()
if memStat != nil {
log.Infof("total memory: %dMB, available: %dMB", memStat.Total>>20, memStat.Available>>20)
if conf.Conf.MinFreeMemory < 0 {
if c.MinFreeMemory < 0 {
conf.MinFreeMemory = 0
log.Info("disable memory cache")
} else {
if conf.Conf.MinFreeMemory < 16 {
if c.MinFreeMemory < 16 {
t := (memStat.Total >> 20) / 10
conf.MinFreeMemory = max(16, min(t, 1024)) << 20
} else {
conf.MinFreeMemory = uint64(conf.Conf.MinFreeMemory) << 20
conf.MinFreeMemory = uint64(c.MinFreeMemory) << 20
}
log.Infof("min free memory: %dMB", conf.MinFreeMemory>>20)
}

if conf.Conf.MaxBlockLimit < 4 {
if c.MaxBlockLimit < 4 {
t := (memStat.Total >> 20) * 3 / 100
conf.MaxBlockLimit = max(4, min(uint64(t), 64)) << 20
} else {
conf.MaxBlockLimit = uint64(conf.Conf.MaxBlockLimit) << 20
conf.MaxBlockLimit = uint64(c.MaxBlockLimit) << 20
}
log.Infof("max block limit: %dMB", conf.MaxBlockLimit>>20)
} else {
conf.MinFreeMemory = 0
log.Warn("failed to get memory info, disable memory cache")
}

if conf.Conf.AutoMemoryLimit > 0 {
conf.AutoMemoryLimit = uint64(conf.Conf.AutoMemoryLimit) << 20
if c.AutoMemoryLimit > 0 {
conf.AutoMemoryLimit = uint64(c.AutoMemoryLimit) << 20
} else {
conf.AutoMemoryLimit = 0
}
log.Infof("auto memory limit: %dMB", conf.AutoMemoryLimit>>20)
}

if len(conf.Conf.Log.Filter.Filters) == 0 {
conf.Conf.Log.Filter.Enable = false
}
// convert abs path
func convertAbsPaths(c *conf.Config, pwd string) {
convertAbsPath := func(path *string) {
if *path != "" && !filepath.IsAbs(*path) {
*path = filepath.Join(pwd, *path)
}
}
convertAbsPath(&conf.Conf.Database.DBFile)
convertAbsPath(&conf.Conf.Scheme.CertFile)
convertAbsPath(&conf.Conf.Scheme.KeyFile)
convertAbsPath(&conf.Conf.Scheme.UnixFile)
convertAbsPath(&conf.Conf.Log.Name)
convertAbsPath(&conf.Conf.TempDir)
convertAbsPath(&conf.Conf.BleveDir)
convertAbsPath(&conf.Conf.DistDir)

err := os.MkdirAll(conf.Conf.TempDir, 0o777)
if err != nil {
log.Fatalf("create temp dir error: %+v", err)
}
log.Debugf("config: %+v", conf.Conf)

// Validate and display proxy configuration status
validateProxyConfig()

base.InitClient()
initURL()
}

func confFromEnv() {
prefix := "OPENLIST_"
if flags.NoPrefix {
prefix = ""
}
log.Infof("load config from env with prefix: %s", prefix)
if err := env.ParseWithOptions(conf.Conf, env.Options{
Prefix: prefix,
}); err != nil {
log.Fatalf("load config from env error: %+v", err)
}
}

func initURL() {
if !strings.Contains(conf.Conf.SiteURL, "://") {
conf.Conf.SiteURL = utils.FixAndCleanPath(conf.Conf.SiteURL)
}
u, err := url.Parse(conf.Conf.SiteURL)
if err != nil {
utils.Log.Fatalf("can't parse site_url: %+v", err)
}
conf.URL = u
convertAbsPath(&c.Database.DBFile)
convertAbsPath(&c.Scheme.CertFile)
convertAbsPath(&c.Scheme.KeyFile)
convertAbsPath(&c.Scheme.UnixFile)
convertAbsPath(&c.Log.Name)
convertAbsPath(&c.TempDir)
convertAbsPath(&c.BleveDir)
convertAbsPath(&c.DistDir)
}

func CleanTempDir() {
Expand Down
195 changes: 195 additions & 0 deletions internal/bootstrap/reload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package bootstrap

import (
"context"
"os"
"reflect"

"github.com/OpenListTeam/OpenList/v4/cmd/flags"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/db"
"github.com/OpenListTeam/OpenList/v4/internal/search"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/server"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/OpenListTeam/OpenList/v4/server/middlewares"
"github.com/OpenListTeam/OpenList/v4/server/static"
log "github.com/sirupsen/logrus"
)

// configWatcher watches config.json for changes and triggers reloadConfig.
var configWatcher *conf.ConfigWatcher

// reloadConfig rebuilds the configuration from the provided raw config.json
// bytes and atomically swaps conf.Conf / conf.URL, then applies only the
// side effects for fields that actually changed. It never mutates the live
// conf.Conf in place: a brand-new *conf.Config is built offline and swapped
// in as a whole, so readers always see a consistent (old or new) config.
//
// On parse failure it returns a non-nil error and leaves the running config
// untouched, so the watcher retries on the next file event.
func reloadConfig(data []byte) error {
pwd := PWD()
dataDir := flags.DataDir

newConf := conf.DefaultConfig(dataDir)
if err := utils.Json.Unmarshal(data, newConf); err != nil {
return err
}

// LastLaunchedVersion is a one-shot startup field; preserve the running
// value so reload does not re-trigger upgrade patches or produce spurious diffs.
newConf.LastLaunchedVersion = conf.Conf.LastLaunchedVersion

if !newConf.Force {
confFromEnv(newConf)
}

// Mirror the startup sanity check: an empty filter list disables filtering.
if len(newConf.Log.Filter.Filters) == 0 {
newConf.Log.Filter.Enable = false
}

convertAbsPaths(newConf, pwd)

// Ensure the (possibly new) temp dir exists. Non-fatal on reload.
if err := os.MkdirAll(newConf.TempDir, 0o777); err != nil {
log.Warnf("create temp dir on reload error: %+v", err)
}

// Derive conf.URL from the new SiteURL offline (also fixes newConf.SiteURL).
newURL := initURL(newConf)

// Snapshot the old config, then swap the pointer and URL as a whole.
oldConf := conf.Conf
conf.Conf = newConf
conf.URL = newURL

applyReloadSideEffects(oldConf, newConf)

log.Info("config reloaded successfully")
return nil
}

// applyReloadSideEffects applies runtime side effects only for config fields
// that changed between oldConf and newConf. Unchanged subsystems are left
// untouched to minimise disruption.
func applyReloadSideEffects(oldConf, newConf *conf.Config) {
// --- Log output configuration (lumberjack rotation / file) ---
logOutputChanged := oldConf.Log.Enable != newConf.Log.Enable ||
oldConf.Log.Name != newConf.Log.Name ||
oldConf.Log.MaxSize != newConf.Log.MaxSize ||
oldConf.Log.MaxBackups != newConf.Log.MaxBackups ||
oldConf.Log.MaxAge != newConf.Log.MaxAge ||
oldConf.Log.Compress != newConf.Log.Compress
if logOutputChanged {
Log()
log.Info("log output config reloaded")
}

// --- Log filter rules ---
if !reflect.DeepEqual(oldConf.Log.Filter, newConf.Log.Filter) {
middlewares.ReloadFilterList()
log.Info("log filter list reloaded")
}

// --- TLS verification / proxy (global HTTP clients) ---
if oldConf.TlsInsecureSkipVerify != newConf.TlsInsecureSkipVerify ||
oldConf.ProxyAddress != newConf.ProxyAddress {
base.InitClient()
validateProxyConfig()
log.Info("http clients reloaded")
}

// --- JWT secret (invalidates already-issued tokens) ---
if oldConf.JwtSecret != newConf.JwtSecret {
common.SecretKey = []byte(newConf.JwtSecret)
log.Info("jwt secret reloaded; previously issued tokens are now invalid")
}

// --- Site URL (conf.URL already updated above) ---
if oldConf.SiteURL != newConf.SiteURL {
log.Infof("site_url reloaded: %s", newConf.SiteURL)
}

// --- Max concurrency ---
if oldConf.MaxConcurrency != newConf.MaxConcurrency {
applyMaxConcurrency(newConf)
log.Infof("max concurrency reloaded: %d", newConf.MaxConcurrency)
}

// --- Memory thresholds ---
if oldConf.MinFreeMemory != newConf.MinFreeMemory ||
oldConf.MaxBlockLimit != newConf.MaxBlockLimit ||
oldConf.AutoMemoryLimit != newConf.AutoMemoryLimit {
applyMemoryConfig(newConf)
log.Info("memory limits reloaded")
}

// --- Group A: listener restart (scheme.* / s3.* / ftp.* / sftp.*) ---
applyEndpointChanges(oldConf, newConf)

// --- Group C: CORS live reload ---
if !reflect.DeepEqual(oldConf.Cors, newConf.Cors) {
server.ReloadCors()
log.Info("cors config reloaded")
}

// --- Group D: subsystem re-init ---
if !reflect.DeepEqual(oldConf.Database, newConf.Database) {
log.Warn("database config changed; reinitializing (HIGH RISK: in-flight queries on old handle may fail)")
if err := db.Reinit(newConf); err != nil {
log.Errorf("database reinit failed: %v", err)
} else {
log.Info("database reinitialized successfully; old handle closes after 5 min")
}
}
if oldConf.BleveDir != newConf.BleveDir || !reflect.DeepEqual(oldConf.Meilisearch, newConf.Meilisearch) {
log.Info("search config changed; reinitializing search index")
if oldConf.BleveDir != newConf.BleveDir {
log.Warn("bleve_dir changed; index content will be empty — admin must rebuild via rescan")
}
if err := search.Reinit(); err != nil {
log.Errorf("search reinit failed: %v", err)
}
}
if oldConf.DistDir != newConf.DistDir || oldConf.Cdn != newConf.Cdn {
static.Reload()
log.Info("static resources (dist_dir/cdn) reloaded")
}

// --- Group E: no-op fields (take effect at next start) ---
if oldConf.DelayedStart != newConf.DelayedStart {
log.Info("delayed_start changed; takes effect at next start")
}
if oldConf.Force != newConf.Force {
log.Info("force changed; takes effect at next start")
}
if oldConf.LastLaunchedVersion != newConf.LastLaunchedVersion {
log.Info("last_launched_version changed; takes effect at next start")
}
}

// startConfigWatcher starts the config.json hot-reload watcher.
func startConfigWatcher() {
if conf.ConfigPath == "" {
log.Warn("config path is empty, skipping config file watcher")
return
}
w := conf.NewConfigWatcher(conf.ConfigPath, reloadConfig)
if err := w.Start(context.Background()); err != nil {
log.Errorf("failed to start config file watcher: %v", err)
return
}
configWatcher = w
log.Infof("config file watcher started: %s", conf.ConfigPath)
}

// stopConfigWatcher stops the config.json hot-reload watcher. Idempotent.
func stopConfigWatcher() {
if configWatcher != nil {
configWatcher.Stop()
configWatcher = nil
}
}
Loading