diff --git a/modules/plugins/loader.go b/modules/plugins/loader.go new file mode 100644 index 0000000000..1c8e87686c --- /dev/null +++ b/modules/plugins/loader.go @@ -0,0 +1,107 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package plugins + +import ( + "fmt" + "os" + "path/filepath" + "plugin" + + "code.gitcaddy.com/server/modules/log" + "code.gitcaddy.com/server/modules/setting" +) + +// LoadAll loads all plugins from the configured plugin directory +func LoadAll() error { + if !setting.Plugins.Enabled { + log.Info("Plugin system is disabled") + return nil + } + + pluginDir := setting.Plugins.Path + if pluginDir == "" { + pluginDir = filepath.Join(setting.AppDataPath, "plugins") + } + + // Check if plugin directory exists + info, err := os.Stat(pluginDir) + if os.IsNotExist(err) { + log.Info("Plugin directory does not exist: %s", pluginDir) + return nil + } + if err != nil { + return fmt.Errorf("failed to stat plugin directory: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("plugin path is not a directory: %s", pluginDir) + } + + log.Info("Loading plugins from: %s", pluginDir) + + // Find all .so files in the plugin directory + entries, err := os.ReadDir(pluginDir) + if err != nil { + return fmt.Errorf("failed to read plugin directory: %w", err) + } + + loadedCount := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if filepath.Ext(name) != ".so" { + continue + } + + pluginPath := filepath.Join(pluginDir, name) + if err := loadPlugin(pluginPath); err != nil { + log.Error("Failed to load plugin %s: %v", name, err) + // Continue loading other plugins + continue + } + loadedCount++ + } + + log.Info("Loaded %d plugin(s)", loadedCount) + return nil +} + +// loadPlugin loads a single plugin from a .so file +func loadPlugin(path string) error { + log.Info("Loading plugin: %s", path) + + // Open the plugin + p, err := plugin.Open(path) + if err != nil { + return fmt.Errorf("failed to open plugin: %w", err) + } + + // Look for the Plugin symbol + sym, err := p.Lookup("Plugin") + if err != nil { + return fmt.Errorf("plugin does not export 'Plugin' symbol: %w", err) + } + + // The Plugin symbol should be a pointer to a Plugin interface implementation + plug, ok := sym.(*Plugin) + if !ok { + // Try to see if it's the interface directly + plugDirect, ok := sym.(Plugin) + if !ok { + return fmt.Errorf("Plugin symbol is not a plugins.Plugin (got %T)", sym) + } + Register(plugDirect) + return nil + } + + if plug == nil || *plug == nil { + return fmt.Errorf("Plugin symbol is nil") + } + + Register(*plug) + return nil +} diff --git a/modules/plugins/plugin.go b/modules/plugins/plugin.go index 7cc912a0bc..2fdf5cf181 100644 --- a/modules/plugins/plugin.go +++ b/modules/plugins/plugin.go @@ -6,7 +6,6 @@ package plugins import ( "context" - "github.com/go-chi/chi/v5" "xorm.io/xorm" ) @@ -44,7 +43,8 @@ type WebRoutesPlugin interface { Plugin // RegisterWebRoutes adds routes to the web UI router - RegisterWebRoutes(m chi.Router) + // The router is a *web.Router from the gitcaddy server + RegisterWebRoutes(m any) } // APIRoutesPlugin is implemented by plugins that add API routes @@ -52,7 +52,8 @@ type APIRoutesPlugin interface { Plugin // RegisterAPIRoutes adds routes to the API router - RegisterAPIRoutes(m chi.Router) + // The router is a *web.Router from the gitcaddy server + RegisterAPIRoutes(m any) } // RepoRoutesPlugin is implemented by plugins that add per-repository routes @@ -60,10 +61,12 @@ type RepoRoutesPlugin interface { Plugin // RegisterRepoWebRoutes adds routes under /{owner}/{repo}/ - RegisterRepoWebRoutes(m chi.Router) + // The router is a *web.Router from the gitcaddy server + RegisterRepoWebRoutes(m any) // RegisterRepoAPIRoutes adds routes under /api/v1/repos/{owner}/{repo}/ - RegisterRepoAPIRoutes(m chi.Router) + // The router is a *web.Router from the gitcaddy server + RegisterRepoAPIRoutes(m any) } // LicensedPlugin is implemented by plugins that require a license diff --git a/modules/plugins/registry.go b/modules/plugins/registry.go index 97796a056b..ff8527a4c2 100644 --- a/modules/plugins/registry.go +++ b/modules/plugins/registry.go @@ -9,7 +9,6 @@ import ( "code.gitcaddy.com/server/modules/log" - "github.com/go-chi/chi/v5" "xorm.io/xorm" ) @@ -126,7 +125,7 @@ func MigrateAll(ctx context.Context, x *xorm.Engine) error { } // RegisterWebRoutes registers web UI routes from all plugins -func RegisterWebRoutes(m chi.Router) { +func RegisterWebRoutes(m any) { registryLock.RLock() defer registryLock.RUnlock() @@ -139,7 +138,7 @@ func RegisterWebRoutes(m chi.Router) { } // RegisterAPIRoutes registers API routes from all plugins -func RegisterAPIRoutes(m chi.Router) { +func RegisterAPIRoutes(m any) { registryLock.RLock() defer registryLock.RUnlock() @@ -152,7 +151,7 @@ func RegisterAPIRoutes(m chi.Router) { } // RegisterRepoWebRoutes registers per-repository web routes from all plugins -func RegisterRepoWebRoutes(m chi.Router) { +func RegisterRepoWebRoutes(m any) { registryLock.RLock() defer registryLock.RUnlock() @@ -165,7 +164,7 @@ func RegisterRepoWebRoutes(m chi.Router) { } // RegisterRepoAPIRoutes registers per-repository API routes from all plugins -func RegisterRepoAPIRoutes(m chi.Router) { +func RegisterRepoAPIRoutes(m any) { registryLock.RLock() defer registryLock.RUnlock() diff --git a/modules/setting/plugins.go b/modules/setting/plugins.go new file mode 100644 index 0000000000..6743decb87 --- /dev/null +++ b/modules/setting/plugins.go @@ -0,0 +1,21 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +// Plugins settings +var ( + Plugins = struct { + Enabled bool + Path string + }{ + Enabled: false, // Disabled by default + Path: "", // Empty means default to APP_DATA_PATH/plugins + } +) + +func loadPluginsFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("plugins") + Plugins.Enabled = sec.Key("ENABLED").MustBool(false) + Plugins.Path = sec.Key("PATH").MustString("") +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 7c22cc2e5b..a6e8a75cf5 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -150,6 +150,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { loadMarkupFrom(cfg) loadGlobalLockFrom(cfg) loadOtherFrom(cfg) + loadPluginsFrom(cfg) return nil } diff --git a/routers/init.go b/routers/init.go index 6f843613b0..800265bb8c 100644 --- a/routers/init.go +++ b/routers/init.go @@ -12,6 +12,7 @@ import ( "code.gitcaddy.com/server/models" ai_model "code.gitcaddy.com/server/models/ai" authmodel "code.gitcaddy.com/server/models/auth" + "code.gitcaddy.com/server/models/db" "code.gitcaddy.com/server/modules/cache" "code.gitcaddy.com/server/modules/eventsource" "code.gitcaddy.com/server/modules/git" @@ -20,6 +21,7 @@ import ( "code.gitcaddy.com/server/modules/log" "code.gitcaddy.com/server/modules/markup" "code.gitcaddy.com/server/modules/markup/external" + "code.gitcaddy.com/server/modules/plugins" "code.gitcaddy.com/server/modules/setting" "code.gitcaddy.com/server/modules/ssh" "code.gitcaddy.com/server/modules/storage" @@ -58,6 +60,8 @@ import ( "code.gitcaddy.com/server/services/task" "code.gitcaddy.com/server/services/uinotification" "code.gitcaddy.com/server/services/webhook" + + "xorm.io/xorm" ) func mustInit(fn func() error) { @@ -144,8 +148,22 @@ func InitWebInstalled(ctx context.Context) { log.Fatal("SQLite3 support is disabled, but it is used for database setting. Please get or build a Gitea release with SQLite3 support.") } + // Load plugins before DB init so they can register their models + mustInit(plugins.LoadAll) + mustInitCtx(ctx, common.InitDBEngine) log.Info("ORM engine initialization successful!") + + // Run plugin migrations after DB init + if err := plugins.MigrateAll(ctx, db.GetEngine(ctx).(*xorm.Engine)); err != nil { + log.Fatal("Plugin migrations failed: %v", err) + } + + // Initialize all plugins + if err := plugins.InitAll(ctx); err != nil { + log.Fatal("Plugin initialization failed: %v", err) + } + mustInit(system.Init) mustInitCtx(ctx, oauth2.Init) mustInitCtx(ctx, oauth2_provider.Init) diff --git a/routers/web/web.go b/routers/web/web.go index de9d1b8c95..e17829a732 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -15,6 +15,7 @@ import ( "code.gitcaddy.com/server/modules/graceful" "code.gitcaddy.com/server/modules/log" "code.gitcaddy.com/server/modules/metrics" + "code.gitcaddy.com/server/modules/plugins" "code.gitcaddy.com/server/modules/public" "code.gitcaddy.com/server/modules/setting" "code.gitcaddy.com/server/modules/storage" @@ -1799,6 +1800,14 @@ func registerWebRoutes(m *web.Router) { }) } + // Register plugin web routes + plugins.RegisterWebRoutes(m) + + // Register plugin repo routes (mounted under /{username}/{reponame}) + m.Group("/{username}/{reponame}", func() { + plugins.RegisterRepoWebRoutes(m) + }, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty) + m.NotFound(func(w http.ResponseWriter, req *http.Request) { ctx := context.GetWebContext(req.Context()) defer routing.RecordFuncInfo(ctx, routing.GetFuncInfo(ctx.NotFound, "WebNotFound"))()