diff --git a/Dockerfile b/Dockerfile index dabaf7f1..6b739a3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ COPY . . ENV CGO_ENABLED=1 ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE" +RUN go run ./cmd/genassets RUN go build -ldflags "-w -s" -o build/x-ui main.go RUN ./DockerInit.sh "$TARGETARCH" diff --git a/README.md b/README.md index 45e87ad4..58379f34 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,17 @@ bash <(curl -Ls https://raw.githubusercontent.com/Sora39831/3x-ui/master/install For full documentation, please visit the [project Wiki](https://github.com/Sora39831/3x-ui/wiki). +## Building from source + +Generate fingerprinted frontend assets before compiling: + +```bash +go run ./cmd/genassets +go build -ldflags "-w -s" -o build/x-ui main.go +``` + +Production builds embed files from `web/public/assets` and `web/public/assets-manifest.json`. + ## A Special Thanks to - [alireza0](https://github.com/alireza0/) diff --git a/cmd/genassets/main.go b/cmd/genassets/main.go new file mode 100644 index 00000000..3e52ca8c --- /dev/null +++ b/cmd/genassets/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "log" + "os" + "path/filepath" + + "github.com/mhsanaei/3x-ui/v2/web/assetsgen" +) + +func main() { + const ( + sourceDir = "web/assets" + outputDir = "web/public/assets" + manifestPath = "web/public/assets-manifest.json" + ) + + if err := os.RemoveAll(outputDir); err != nil { + log.Fatalf("remove stale asset output: %v", err) + } + if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) { + log.Fatalf("remove stale asset manifest: %v", err) + } + + manifest, err := assetsgen.Generate(assetsgen.Options{ + SourceDir: sourceDir, + OutputDir: outputDir, + HashLen: 8, + }) + if err != nil { + log.Fatalf("generate fingerprinted assets: %v", err) + } + + if err := assetsgen.WriteManifest(filepath.Clean(manifestPath), manifest); err != nil { + log.Fatalf("write asset manifest: %v", err) + } +} diff --git a/config/version b/config/version index 05822b7e..96aee38d 100644 --- a/config/version +++ b/config/version @@ -1 +1 @@ -2.8.11 \ No newline at end of file +v1.3.4-beta diff --git a/database/db.go b/database/db.go index fd1da3fa..e0c50551 100644 --- a/database/db.go +++ b/database/db.go @@ -91,18 +91,24 @@ func runSeeders(isUsersEmpty bool) error { return err } - if empty && isUsersEmpty { - hashSeeder := &model.HistoryOfSeeders{ - SeederName: "UserPasswordHash", + return db.Transaction(func(tx *gorm.DB) error { + if empty && isUsersEmpty { + hashSeeder := &model.HistoryOfSeeders{ + SeederName: "UserPasswordHash", + } + return tx.Create(hashSeeder).Error } - return db.Create(hashSeeder).Error - } else { + var seedersHistory []string - db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory) + if err := tx.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil { + return err + } if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty { var users []model.User - db.Find(&users) + if err := tx.Find(&users).Error; err != nil { + return err + } for _, user := range users { hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password) @@ -110,13 +116,15 @@ func runSeeders(isUsersEmpty bool) error { log.Printf("Error hashing password for user '%s': %v", user.Username, err) return err } - db.Model(&user).Update("password", hashedPassword) + if err := tx.Model(&user).Update("password", hashedPassword).Error; err != nil { + return err + } } hashSeeder := &model.HistoryOfSeeders{ SeederName: "UserPasswordHash", } - if err := db.Create(hashSeeder).Error; err != nil { + if err := tx.Create(hashSeeder).Error; err != nil { return err } } @@ -125,21 +133,25 @@ func runSeeders(isUsersEmpty bool) error { // Drop the old unique index on client_traffics.email to allow // the same email across multiple inbounds dbType := config.GetDBTypeFromJSON() + var execErr error if dbType == "mariadb" { - db.Exec("DROP INDEX IF EXISTS idx_client_traffics_email ON client_traffics") + execErr = tx.Exec("DROP INDEX IF EXISTS idx_client_traffics_email ON client_traffics").Error } else { - db.Exec("DROP INDEX IF EXISTS idx_client_traffics_email") + execErr = tx.Exec("DROP INDEX IF EXISTS idx_client_traffics_email").Error + } + if execErr != nil { + return execErr } uniqueSeeder := &model.HistoryOfSeeders{ SeederName: "RemoveClientTrafficEmailUnique", } - if err := db.Create(uniqueSeeder).Error; err != nil { + if err := tx.Create(uniqueSeeder).Error; err != nil { return err } } - } - return nil + return nil + }) } // isTableEmpty returns true if the named table contains zero rows. diff --git a/database/db_test.go b/database/db_test.go index 9cebdd20..a51b5c64 100644 --- a/database/db_test.go +++ b/database/db_test.go @@ -212,3 +212,47 @@ func TestInitUser_OnlyOnce(t *testing.T) { t.Errorf("expected 1 user, got %d", count) } } + +func TestRunSeeders_DoesNotRecordHistoryWhenPasswordUpdateFails(t *testing.T) { + setupTestDB(t) + + if err := db.Exec("DELETE FROM history_of_seeders").Error; err != nil { + t.Fatalf("clear seeders history failed: %v", err) + } + + if err := db.Exec(` + CREATE TRIGGER fail_user_password_update + BEFORE UPDATE OF password ON users + BEGIN + SELECT RAISE(FAIL, 'boom'); + END; + `).Error; err != nil { + t.Fatalf("create trigger failed: %v", err) + } + + err := runSeeders(false) + if err == nil { + t.Fatalf("expected runSeeders to fail when user password update fails") + } + + var count int64 + if err := db.Model(&model.HistoryOfSeeders{}). + Where("seeder_name = ?", "UserPasswordHash"). + Count(&count).Error; err != nil { + t.Fatalf("count seeder history failed: %v", err) + } + if count != 0 { + t.Fatalf("expected no UserPasswordHash history row after failed seeder, got %d", count) + } +} + +func TestSettingKey_IsUnique(t *testing.T) { + setupTestDB(t) + + if err := db.Create(&model.Setting{Key: "dup", Value: "one"}).Error; err != nil { + t.Fatalf("first insert failed: %v", err) + } + if err := db.Create(&model.Setting{Key: "dup", Value: "two"}).Error; err == nil { + t.Fatal("expected duplicate setting key insert to fail") + } +} diff --git a/database/model/model.go b/database/model/model.go index 98252f62..36ba1b0f 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -102,7 +102,7 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { // Setting stores key-value configuration settings for the 3x-ui panel. type Setting struct { Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` - Key string `json:"key" form:"key"` + Key string `json:"key" form:"key" gorm:"uniqueIndex"` Value string `json:"value" form:"value"` } diff --git a/database/model/model_test.go b/database/model/model_test.go index 17c9839c..e0d0a2ab 100644 --- a/database/model/model_test.go +++ b/database/model/model_test.go @@ -1,8 +1,6 @@ package model -import ( - "testing" -) +import "testing" func TestGenXrayInboundConfig_EmptyListen(t *testing.T) { in := &Inbound{ diff --git a/docs/superpowers/specs/2026-04-07-cloudflare-cdn-assets-design.md b/docs/superpowers/specs/2026-04-07-cloudflare-cdn-assets-design.md new file mode 100644 index 00000000..6c48aee1 --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-cloudflare-cdn-assets-design.md @@ -0,0 +1,281 @@ +# Cloudflare CDN Frontend Asset Optimization Design + +## Context + +The panel currently serves frontend assets from embedded files under `web/assets` and references them directly from HTML templates. A subset of assets uses `?{{ .cur_ver }}` query strings for cache busting, while some third-party files have no version token at all. The server sets `Cache-Control: max-age=31536000` for requests under `/assets/`, and enables gzip at the Gin layer. + +This works for basic browser caching, but it is not a strong fit for Cloudflare edge caching: + +- Query-string cache busting is weaker than content-addressed filenames. +- Some assets are not versioned at all. +- HTML and static assets are not explicitly separated into short-cache vs long-cache behavior. +- The current embedded asset flow does not provide a manifest-based way to map logical asset names to hashed output names. + +The deployment model is Go binary compilation with `go:embed`, so the design must preserve compile-time embedding and avoid runtime dependence on the local filesystem. + +## Goals + +- Keep all frontend assets self-hosted. +- Optimize asset delivery for Cloudflare CDN edge caching. +- Replace query-string cache busting with content-hashed filenames. +- Preserve the current HTML templates, base path support, and embedded deployment model. +- Keep API routes, session behavior, and WebSocket endpoints unchanged. + +## Non-Goals + +- No migration to third-party script or stylesheet CDNs. +- No change to business logic, Vue component behavior, or page structure. +- No runtime asset compilation in production. +- No broad frontend bundler migration in this change. + +## Recommended Approach + +Adopt a build-time asset fingerprinting pipeline that generates a new embedded asset output tree and a manifest file. Templates will resolve logical asset paths through the manifest, and the server will serve only fingerprinted asset URLs with long-lived immutable caching headers. + +This is the recommended approach because it is the most compatible with Cloudflare's cache model and the current Go binary deployment flow. + +## Alternatives Considered + +### 1. Build-time fingerprinted assets and manifest + +This is the recommended option. + +Pros: + +- Best Cloudflare cache efficiency and invalidation behavior. +- Safe long-lived caching with `immutable`. +- Explicit and debuggable asset mapping. +- Compatible with `go:embed`. + +Cons: + +- Adds a pre-build asset generation step. +- Requires template updates to use a manifest helper. + +### 2. Runtime virtual hashed routes backed by embedded assets + +Pros: + +- No extra pre-build step. + +Cons: + +- Adds runtime complexity to compute or maintain mappings. +- Less transparent than generated files. +- Harder to reason about and test than build-time outputs. + +### 3. Keep filenames and use per-file hash query strings + +Pros: + +- Smallest code change. + +Cons: + +- Weaker fit for Cloudflare edge caching. +- Less operationally clear than immutable fingerprinted paths. +- Leaves ambiguity around caches that normalize or vary on query strings. + +## Design + +### Asset Source and Output Layout + +Keep `web/assets` as the source tree checked into the repository. + +Add a generated output tree for embedded production assets: + +- `web/public/assets/...` for fingerprinted files +- `web/public/assets-manifest.json` for logical-to-fingerprinted path mapping + +`web/public` is generated content. `go:embed` in production should target the generated tree rather than the source tree. + +Example mapping: + +- logical: `css/custom.min.css` +- output: `css/custom.min.4f3c2a1b.css` + +- logical: `js/websocket.js` +- output: `js/websocket.a9c88d71.js` + +### Build Pipeline + +Add a build-time generator command or script that: + +1. Walks `web/assets` +2. Computes a deterministic content hash for each file +3. Writes the file into `web/public/assets` with the hash inserted before the extension +4. Emits `web/public/assets-manifest.json` + +Hash requirements: + +- Deterministic for identical file content +- Stable across platforms +- Short enough for readable filenames + +An 8 to 12 character hex digest from SHA-256 is sufficient here. + +The generator must preserve subdirectories so current logical organization remains intact. + +### Manifest Format + +Use a flat JSON object keyed by logical asset path relative to `web/assets`. + +Example: + +```json +{ + "ant-design-vue/antd.min.css": "ant-design-vue/antd.min.4f3c2a1b.css", + "css/custom.min.css": "css/custom.min.182d7e0a.css", + "js/axios-init.js": "js/axios-init.bf4d1d4e.js", + "js/websocket.js": "js/websocket.a9c88d71.js", + "Vazirmatn-UI-NL-Regular.woff2": "Vazirmatn-UI-NL-Regular.4c2a16f1.woff2" +} +``` + +This keeps template lookup simple and avoids path reconstruction logic. + +### Embed Strategy + +Replace the production asset embed source in `web/web.go` so that production serving reads from generated output, not raw source assets. + +Development mode can keep serving from `web/assets` directly to avoid slowing local iteration. + +Production mode behavior: + +- embed `web/public/assets` +- load `web/public/assets-manifest.json` +- serve only the generated fingerprinted files + +### Template Asset Resolution + +Add a template function, for example `asset`, that accepts a logical asset path and returns the final URL under the current `basePath`. + +Example usage in templates: + +```gotemplate + + +``` + +This replaces direct `{{ .base_path }}assets/...` references and removes `?{{ .cur_ver }}` from static asset URLs. + +The helper behavior should be: + +- resolve the logical path through the manifest in production +- prefix with `{{ .base_path }}assets/` +- fail loudly during server init if a required manifest entry is missing + +For debug mode, the helper can return the original non-fingerprinted path so templates work unchanged during local development. + +### Cache-Control Policy + +Separate HTML caching from static asset caching. + +HTML responses: + +- `Cache-Control: no-cache, must-revalidate` + +Fingerprint asset responses: + +- `Cache-Control: public, max-age=31536000, immutable` + +This allows Cloudflare and browsers to retain asset files for a year while ensuring HTML revalidates and can reference new asset filenames after deployment. + +### ETag and Last-Modified + +This design does not require ETag for fingerprinted assets because filename changes already provide cache invalidation. ETag may still be present if provided by the underlying file serving behavior, but it is not required for correctness. + +`Last-Modified` is also non-critical for fingerprinted assets. The current `ModTime` override tied to process start is not a reliable version signal, and should not be treated as part of cache invalidation. The fingerprinted filename is the source of truth. + +### Cloudflare Behavior + +Expected Cloudflare policy after this design: + +- Cache `/assets/*` aggressively at the edge +- Do not cache HTML application pages for long durations +- Avoid purge-heavy workflows because asset invalidation is filename-based + +This design keeps Cloudflare configuration simple. New deployments produce new asset URLs; old assets remain safely cacheable until naturally evicted. + +### Backward Compatibility + +Preserve: + +- `basePath` support +- current routes outside static asset delivery +- current debug mode serving behavior + +Change: + +- production asset references move from raw names plus optional query strings to fingerprinted filenames +- production asset embed source moves to generated output + +Existing un-fingerprinted `/assets/...` paths should not remain part of the production template output. If any route continues to expose them, that should be treated as compatibility-only behavior, not a primary path. + +## Implementation Outline + +1. Add an asset generation tool under the repository, preferably Go-based for portability with the existing build stack. +2. Generate `web/public/assets` and `web/public/assets-manifest.json` from `web/assets`. +3. Update `go:embed` usage in production to embed the generated asset tree and manifest. +4. Add manifest loading during server initialization. +5. Add the `asset` template helper. +6. Replace direct static asset references in HTML templates with `asset(...)`. +7. Update asset response headers to use immutable long-lived caching for fingerprinted assets. +8. Keep HTML responses on short-cache or revalidation semantics. +9. Document the new build prerequisite in developer and release documentation. + +## Error Handling + +Server startup should fail if: + +- the manifest file is missing in production +- a manifest entry is malformed +- a template references an asset key that is absent from the manifest + +Fail-fast is preferable here because silent fallback would hide release integrity problems and produce broken pages under CDN caching. + +## Testing Strategy + +### Automated + +- Unit test the asset generator: + - stable hash naming + - preserved directory structure + - correct manifest output +- Unit test manifest loading: + - valid manifest parses + - missing or malformed entries fail +- Unit test template helper: + - returns base-path-prefixed fingerprinted URLs in production + - returns raw asset URLs in debug mode +- Integration test asset responses: + - fingerprinted asset path returns `Cache-Control: public, max-age=31536000, immutable` + - HTML response returns `Cache-Control: no-cache, must-revalidate` + +### Manual + +- Build a production binary and open the panel in a browser +- Inspect HTML and verify asset URLs contain hashes in filenames +- Confirm page reload after deployment references new filenames when a source asset changes +- Confirm Cloudflare can cache asset responses without manual purge + +## Operational Notes + +- Release workflows must run the asset generation step before `go build`. +- Developers should have a single documented command to regenerate embedded assets. +- Generated assets should either be committed consistently or regenerated in CI/build scripts. This decision should be made once and documented to avoid drift. + +## Open Decision + +One repository policy still needs to be chosen during implementation: + +- Commit generated `web/public` outputs to git +- Or treat them as build artifacts generated before release and excluded from source control + +Recommendation: + +Do not commit generated fingerprinted assets if the release pipeline reliably runs the generator before building. Committing generated outputs increases churn and review noise. If the project's release flow is manual and local builds are common, committing generated outputs may be acceptable for simplicity. + +## Summary + +Use a build-time fingerprinting pipeline to generate embedded static assets and a manifest. Resolve template asset URLs through the manifest, serve fingerprinted asset files with one-year immutable caching, and keep HTML on revalidation semantics. This gives Cloudflare a clean, robust cache model without changing the panel's runtime behavior or introducing third-party CDNs. diff --git a/main.go b/main.go index 329c5130..ec47b742 100644 --- a/main.go +++ b/main.go @@ -411,7 +411,9 @@ func migrateDb() { log.Fatal(err) } fmt.Println("Start migrating database...") - inboundService.MigrateDB() + if err := inboundService.MigrateDB(); err != nil { + log.Fatal(err) + } fmt.Println("Migration done!") } diff --git a/sub/sub.go b/sub/sub.go index 1dcd9601..7d1fde9f 100644 --- a/sub/sub.go +++ b/sub/sub.go @@ -5,12 +5,15 @@ package sub import ( "context" "crypto/tls" + "encoding/json" + "fmt" "html/template" "io" "io/fs" "net" "net/http" "os" + "path" "path/filepath" "strconv" "strings" @@ -26,6 +29,8 @@ import ( "github.com/gin-gonic/gin" ) +type subscriptionAssetManifest map[string]string + // setEmbeddedTemplates parses and sets embedded templates on the engine func setEmbeddedTemplates(engine *gin.Engine) error { t, err := template.New("").Funcs(engine.FuncMap).ParseFS( @@ -41,6 +46,50 @@ func setEmbeddedTemplates(engine *gin.Engine) error { return nil } +func subscriptionTemplateFuncMap(basePath string, manifest subscriptionAssetManifest) template.FuncMap { + i18nWebFunc := func(key string, params ...string) string { + return locale.I18n(locale.Web, key, params...) + } + assetFunc := func(logical string) string { + target := logical + if manifest != nil { + if hashed, ok := manifest[logical]; ok { + target = hashed + } + } + return path.Join(basePath, "assets", target) + } + return template.FuncMap{ + "i18n": i18nWebFunc, + "asset": assetFunc, + } +} + +func loadSubscriptionAssetManifest() (subscriptionAssetManifest, error) { + if raw, err := os.ReadFile("web/public/assets-manifest.json"); err == nil { + return decodeSubscriptionAssetManifest(raw) + } else if !os.IsNotExist(err) { + return nil, err + } + + return decodeSubscriptionAssetManifest(webpkg.EmbeddedAssetsManifest()) +} + +func decodeSubscriptionAssetManifest(raw []byte) (subscriptionAssetManifest, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("subscription asset manifest is empty") + } + + var manifest subscriptionAssetManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return nil, err + } + if len(manifest) == 0 { + return nil, fmt.Errorf("subscription asset manifest has no entries") + } + return manifest, nil +} + // Server represents the subscription server that serves subscription links and JSON configurations. type Server struct { httpServer *http.Server @@ -181,11 +230,12 @@ func (s *Server) initRouter() (*gin.Engine, error) { // set per-request localizer from headers/cookies engine.Use(locale.LocalizerMiddleware()) - // register i18n function similar to web server - i18nWebFunc := func(key string, params ...string) string { - return locale.I18n(locale.Web, key, params...) + assetManifest, err := loadSubscriptionAssetManifest() + if err != nil { + return nil, err } - engine.SetFuncMap(map[string]any{"i18n": i18nWebFunc}) + + engine.SetFuncMap(subscriptionTemplateFuncMap(basePath, assetManifest)) // Templates: prefer embedded; fallback to disk if necessary if err := setEmbeddedTemplates(engine); err != nil { @@ -212,10 +262,10 @@ func (s *Server) initRouter() (*gin.Engine, error) { // Mount assets in multiple paths to handle different URL patterns var assetsFS http.FileSystem - if _, err := os.Stat("web/assets"); err == nil { - assetsFS = http.FS(os.DirFS("web/assets")) + if _, err := os.Stat("web/public/assets"); err == nil { + assetsFS = http.FS(os.DirFS("web/public/assets")) } else { - if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil { + if subFS, err := fs.Sub(webpkg.EmbeddedPublicAssets(), "public/assets"); err == nil { assetsFS = http.FS(subFS) } else { logger.Error("sub: failed to mount embedded assets:", err) @@ -277,7 +327,7 @@ func (s *Server) getHtmlFiles() ([]string, error) { files = append(files, theme) } // page itself - page := filepath.Join(dir, "web", "html", "subpage.html") + page := filepath.Join(dir, "web", "html", "settings", "panel", "subscription", "subpage.html") if _, err := os.Stat(page); err == nil { files = append(files, page) } else { diff --git a/sub/sub_test.go b/sub/sub_test.go new file mode 100644 index 00000000..c4df2edc --- /dev/null +++ b/sub/sub_test.go @@ -0,0 +1,89 @@ +package sub + +import ( + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestSubscriptionTemplatesUseAssetHelper(t *testing.T) { + engine := gin.New() + engine.SetFuncMap(subscriptionTemplateFuncMap("/sub/", subscriptionAssetManifest{ + "moment/moment.min.js": "moment/moment.min.12345678.js", + })) + + if err := setEmbeddedTemplates(engine); err != nil { + t.Fatalf("setEmbeddedTemplates() error = %v", err) + } + + recorder := httptest.NewRecorder() + rendered := engine.HTMLRender.Instance("subpage.html", gin.H{ + "title": "subscription.title", + "host": "example.com", + "base_path": "/sub/test-subid/", + }) + + if err := rendered.Render(recorder); err != nil { + t.Fatalf("rendered.Render() error = %v", err) + } + + body := recorder.Body.String() + if !strings.Contains(body, `/sub/assets/moment/moment.min.12345678.js`) { + t.Fatalf("rendered body missing subscription asset path: %s", body) + } +} + +func TestGetHtmlFilesReturnsCurrentSubscriptionTemplatePath(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("os.Getwd() error = %v", err) + } + + tempDir := t.TempDir() + writeTestFile(t, filepath.Join(tempDir, "web", "html", "common", "page.html")) + writeTestFile(t, filepath.Join(tempDir, "web", "html", "component", "aThemeSwitch.html")) + currentTemplatePath := filepath.Join(tempDir, "web", "html", "settings", "panel", "subscription", "subpage.html") + writeTestFile(t, currentTemplatePath) + + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("os.Chdir() error = %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(wd); err != nil { + t.Fatalf("restore working directory: %v", err) + } + }) + + server := NewServer() + files, err := server.getHtmlFiles() + if err != nil { + t.Fatalf("getHtmlFiles() error = %v", err) + } + + if !containsPath(files, currentTemplatePath) { + t.Fatalf("getHtmlFiles() missing current subscription template path %q in %v", currentTemplatePath, files) + } +} + +func writeTestFile(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("os.MkdirAll(%q) error = %v", path, err) + } + if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", path, err) + } +} + +func containsPath(paths []string, want string) bool { + for _, path := range paths { + if path == want { + return true + } + } + return false +} diff --git a/web/asset_manifest.go b/web/asset_manifest.go new file mode 100644 index 00000000..54726523 --- /dev/null +++ b/web/asset_manifest.go @@ -0,0 +1,102 @@ +package web + +import ( + "encoding/json" + "fmt" + "io/fs" + "path" + "strings" +) + +type assetManifest map[string]string + +type assetResolver struct { + basePath string + debug bool + manifest assetManifest +} + +func newAssetResolver(basePath string, debug bool, manifest assetManifest) assetResolver { + return assetResolver{ + basePath: basePath, + debug: debug, + manifest: manifest, + } +} + +func (r assetResolver) URL(logical string) string { + target := logical + if !r.debug { + hashed, ok := r.manifest[logical] + if !ok { + panic(fmt.Sprintf("missing asset manifest entry for %q", logical)) + } + target = hashed + } + return path.Join(r.basePath, "assets", target) +} + +func loadAssetManifest(raw []byte) (assetManifest, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("asset manifest is empty") + } + var manifest assetManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return nil, err + } + if len(manifest) == 0 { + return nil, fmt.Errorf("asset manifest has no entries") + } + return manifest, nil +} + +func assetCacheControl(requestPath string) string { + if hasFingerprint(requestPath) { + return "public, max-age=31536000, immutable" + } + return "public, max-age=300" +} + +func assetRequestCacheControl(requestPath string, exists bool) string { + if exists { + return assetCacheControl(requestPath) + } + return "public, max-age=300" +} + +func assetExists(assetsFS fs.FS, assetPath string) bool { + if assetPath == "" { + return false + } + if _, err := fs.Stat(assetsFS, assetPath); err != nil { + return false + } + return true +} + +func hasFingerprint(requestPath string) bool { + base := path.Base(requestPath) + parts := strings.Split(base, ".") + if len(parts) < 2 { + return false + } + if isFingerprintHash(parts[len(parts)-1]) { + return true + } + if len(parts) >= 3 && isFingerprintHash(parts[len(parts)-2]) { + return true + } + return false +} + +func isFingerprintHash(hash string) bool { + if len(hash) < 6 || len(hash) > 64 { + return false + } + for _, ch := range hash { + if !strings.ContainsRune("0123456789abcdef", ch) { + return false + } + } + return true +} diff --git a/web/asset_manifest_test.go b/web/asset_manifest_test.go new file mode 100644 index 00000000..e0cffa29 --- /dev/null +++ b/web/asset_manifest_test.go @@ -0,0 +1,95 @@ +package web + +import ( + "strings" + "testing" +) + +func TestAssetResolverReturnsFingerprintedPathInProduction(t *testing.T) { + resolver := newAssetResolver("/panel/", false, assetManifest{ + "js/websocket.js": "js/websocket.12345678.js", + }) + + got := resolver.URL("js/websocket.js") + want := "/panel/assets/js/websocket.12345678.js" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestAssetResolverReturnsLogicalPathInDebug(t *testing.T) { + resolver := newAssetResolver("/panel/", true, nil) + + got := resolver.URL("js/websocket.js") + want := "/panel/assets/js/websocket.js" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestAssetResolverPreservesBasePathWithoutDoubleSlash(t *testing.T) { + resolver := newAssetResolver("/xui/", false, assetManifest{ + "css/custom.min.css": "css/custom.min.11111111.css", + }) + + got := resolver.URL("css/custom.min.css") + want := "/xui/assets/css/custom.min.11111111.css" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestAssetResolverPanicsOnMissingProductionAsset(t *testing.T) { + resolver := newAssetResolver("/", false, assetManifest{}) + + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic for missing manifest key") + } + }() + + resolver.URL("missing.js") +} + +func TestFingerprintCacheHeaderIncludesImmutable(t *testing.T) { + got := assetCacheControl("js/websocket.12345678.js") + if !strings.Contains(got, "immutable") { + t.Fatalf("expected immutable cache-control, got %q", got) + } +} + +func TestFingerprintCacheHeaderIncludesImmutableForDotfile(t *testing.T) { + got := assetCacheControl(".env.12345678") + if !strings.Contains(got, "immutable") { + t.Fatalf("expected immutable cache-control for dotfile, got %q", got) + } +} + +func TestFingerprintCacheHeaderSupportsVariableHexLength(t *testing.T) { + got := assetCacheControl("js/websocket.123456789abc.js") + if !strings.Contains(got, "immutable") { + t.Fatalf("expected immutable cache-control for variable-length hash, got %q", got) + } +} + +func TestFingerprintCacheHeaderRejectsObviousNonFingerprint(t *testing.T) { + got := assetCacheControl("js/websocket.nothex123.js") + if strings.Contains(got, "immutable") { + t.Fatalf("expected short-lived cache-control for non-fingerprint, got %q", got) + } +} + +func TestAssetRequestCacheControlDoesNotMarkMissingFingerprintPathImmutable(t *testing.T) { + got := assetRequestCacheControl("js/missing.123456789abc.js", false) + if strings.Contains(got, "immutable") { + t.Fatalf("expected missing asset path to avoid immutable cache-control, got %q", got) + } +} + +func TestAssetCacheControlForLogicalPathIsShortLived(t *testing.T) { + got := assetCacheControl("js/websocket.js") + want := "public, max-age=300" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} diff --git a/web/assetsgen/generator.go b/web/assetsgen/generator.go new file mode 100644 index 00000000..0630ecaf --- /dev/null +++ b/web/assetsgen/generator.go @@ -0,0 +1,114 @@ +package assetsgen + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +type Manifest map[string]string + +type Options struct { + SourceDir string + OutputDir string + HashLen int +} + +func Generate(opts Options) (Manifest, error) { + if opts.HashLen <= 0 { + opts.HashLen = 8 + } + if opts.HashLen > sha256.Size*2 { + opts.HashLen = sha256.Size * 2 + } + + manifest := make(Manifest) + if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil { + return nil, err + } + + err := filepath.WalkDir(opts.SourceDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + rel, err := filepath.Rel(opts.SourceDir, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + + raw, err := os.ReadFile(path) + if err != nil { + return err + } + + sum := sha256.Sum256(raw) + hash := hex.EncodeToString(sum[:])[:opts.HashLen] + target := fingerprint(rel, hash) + targetPath := filepath.Join(opts.OutputDir, filepath.FromSlash(target)) + + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(targetPath, raw, 0o644); err != nil { + return err + } + + manifest[rel] = target + return nil + }) + if err != nil { + return nil, err + } + + return manifest, nil +} + +func WriteManifest(path string, manifest Manifest) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + raw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + return os.WriteFile(path, raw, 0o644) +} + +func fingerprint(rel, hash string) string { + name := filepath.Base(rel) + if strings.HasPrefix(name, ".") && strings.Count(name, ".") == 1 { + return rel + "." + hash + } + + ext := filepath.Ext(rel) + base := strings.TrimSuffix(rel, ext) + if ext == "" { + return rel + "." + hash + } + return base + "." + hash + ext +} + +func CopyFile(dst string, src io.Reader) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + f, err := os.Create(dst) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, src) + return err +} diff --git a/web/assetsgen/generator_test.go b/web/assetsgen/generator_test.go new file mode 100644 index 00000000..b6b43fea --- /dev/null +++ b/web/assetsgen/generator_test.go @@ -0,0 +1,197 @@ +package assetsgen + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestGenerateProducesFingerprintManifestAndFiles(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + if err := os.MkdirAll(filepath.Join(src, "js"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "js", "app.js"), []byte("console.log('v1')\n"), 0o644); err != nil { + t.Fatal(err) + } + + manifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "assets"), + HashLen: 8, + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + got, ok := manifest["js/app.js"] + if !ok { + t.Fatalf("manifest missing logical path: %#v", manifest) + } + + sum := sha256.Sum256([]byte("console.log('v1')\n")) + wantHash := hex.EncodeToString(sum[:])[:8] + want := "js/app." + wantHash + ".js" + if got != want { + t.Fatalf("unexpected hashed filename: got %q want %q", got, want) + } + + if _, err := os.Stat(filepath.Join(dst, "assets", got)); err != nil { + t.Fatalf("hashed output missing: %v", err) + } + + defaultManifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "default-assets"), + }) + if err != nil { + t.Fatalf("Generate with default hash length returned error: %v", err) + } + + if gotDefault := defaultManifest["js/app.js"]; gotDefault != want { + t.Fatalf("default HashLen mismatch: got %q want %q", gotDefault, want) + } + + if _, err := os.Stat(filepath.Join(dst, "default-assets", want)); err != nil { + t.Fatalf("default hashed output missing: %v", err) + } +} + +func TestGenerateClampsHashLenToSha256HexLength(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + if err := os.WriteFile(filepath.Join(src, "main.css"), []byte("body{}\n"), 0o644); err != nil { + t.Fatal(err) + } + + manifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "assets"), + HashLen: 65, + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + sum := sha256.Sum256([]byte("body{}\n")) + wantHash := hex.EncodeToString(sum[:]) + want := "main." + wantHash + ".css" + if got := manifest["main.css"]; got != want { + t.Fatalf("unexpected hashed filename: got %q want %q", got, want) + } + + if _, err := os.Stat(filepath.Join(dst, "assets", want)); err != nil { + t.Fatalf("clamped hashed output missing: %v", err) + } +} + +func TestGeneratePreservesNestedDirectories(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + if err := os.MkdirAll(filepath.Join(src, "css"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "css", "custom.min.css"), []byte("body{}\n"), 0o644); err != nil { + t.Fatal(err) + } + + manifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "assets"), + HashLen: 8, + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + got := manifest["css/custom.min.css"] + if got == "" { + t.Fatalf("missing css/custom.min.css entry: %#v", manifest) + } + if filepath.Dir(got) != "css" { + t.Fatalf("expected nested output directory, got %q", got) + } + if filepath.Ext(got) != ".css" { + t.Fatalf("expected css extension, got %q", got) + } +} + +func TestGenerateFingerprintsDotfilesWithoutLeadingExtension(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + if err := os.MkdirAll(filepath.Join(src, "dir"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, ".env"), []byte("ROOT=1\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "dir", ".env"), []byte("NESTED=1\n"), 0o644); err != nil { + t.Fatal(err) + } + + manifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "assets"), + HashLen: 8, + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + rootSum := sha256.Sum256([]byte("ROOT=1\n")) + rootWant := ".env." + hex.EncodeToString(rootSum[:])[:8] + if got := manifest[".env"]; got != rootWant { + t.Fatalf("unexpected root dotfile fingerprint: got %q want %q", got, rootWant) + } + if _, err := os.Stat(filepath.Join(dst, "assets", rootWant)); err != nil { + t.Fatalf("root dotfile output missing: %v", err) + } + + nestedSum := sha256.Sum256([]byte("NESTED=1\n")) + nestedWant := filepath.ToSlash(filepath.Join("dir", ".env.")) + hex.EncodeToString(nestedSum[:])[:8] + if got := manifest["dir/.env"]; got != nestedWant { + t.Fatalf("unexpected nested dotfile fingerprint: got %q want %q", got, nestedWant) + } + if _, err := os.Stat(filepath.Join(dst, "assets", filepath.FromSlash(nestedWant))); err != nil { + t.Fatalf("nested dotfile output missing: %v", err) + } +} + +func TestWriteManifestSerializesStableJson(t *testing.T) { + dst := t.TempDir() + path := filepath.Join(dst, "assets-manifest.json") + manifest := Manifest{ + "css/a.css": "css/a.11111111.css", + "js/b.js": "js/b.22222222.js", + } + + if err := WriteManifest(path, manifest); err != nil { + t.Fatalf("WriteManifest returned error: %v", err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + want := "{\n \"css/a.css\": \"css/a.11111111.css\",\n \"js/b.js\": \"js/b.22222222.js\"\n}\n" + if string(raw) != want { + t.Fatalf("unexpected manifest json:\n got: %q\nwant: %q", string(raw), want) + } + + var decoded map[string]string + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("manifest json invalid: %v", err) + } + if decoded["js/b.js"] != "js/b.22222222.js" { + t.Fatalf("unexpected manifest entry: %#v", decoded) + } +} diff --git a/web/controller/access_control_test.go b/web/controller/access_control_test.go new file mode 100644 index 00000000..4744f29f --- /dev/null +++ b/web/controller/access_control_test.go @@ -0,0 +1,155 @@ +package controller + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v2/database" + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/web/global" + "github.com/mhsanaei/3x-ui/v2/web/session" + "github.com/mhsanaei/3x-ui/v2/xray" + "github.com/robfig/cron/v3" +) + +type testWebServer struct { + cron *cron.Cron +} + +func (s *testWebServer) GetCron() *cron.Cron { return s.cron } +func (s *testWebServer) GetCtx() context.Context { return context.Background() } +func (s *testWebServer) GetWSHub() any { return nil } + +func setupControllerTestDB(t *testing.T) { + t.Helper() + tmpDir := t.TempDir() + t.Setenv("XUI_DEBUG", "") + t.Setenv("XUI_DB_FOLDER", tmpDir) + dbPath := filepath.Join(tmpDir, "controller-test.db") + if err := database.InitDBWithPath(dbPath); err != nil { + t.Fatalf("InitDB failed: %v", err) + } + t.Cleanup(func() { + database.CloseDB() + }) +} + +func newTestRouter(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + + r := gin.New() + store := cookie.NewStore([]byte("test-secret")) + r.Use(sessions.Sessions("3x-ui", store)) + r.Use(func(c *gin.Context) { + c.Set("base_path", "/") + role := c.GetHeader("X-Test-Role") + if role == "" { + return + } + user := &model.User{ + Id: 1, + Username: c.GetHeader("X-Test-Username"), + Role: role, + } + if user.Username == "" { + user.Username = "tester@example.com" + } + session.SetLoginUser(c, user) + }) + return r +} + +func TestXUIController_SettingsPageRequiresAdmin(t *testing.T) { + r := newTestRouter(t) + NewXUIController(r.Group("/")) + + req := httptest.NewRequest(http.MethodGet, "/panel/settings", nil) + req.Header.Set("X-Test-Role", "user") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusTemporaryRedirect { + t.Fatalf("expected %d, got %d", http.StatusTemporaryRedirect, w.Code) + } + if got := w.Header().Get("Location"); got != "/panel/user" { + t.Fatalf("expected redirect to /panel/user, got %q", got) + } +} + +func TestAPIController_AdminEndpointsRequireAdmin(t *testing.T) { + global.SetWebServer(&testWebServer{cron: cron.New()}) + + r := newTestRouter(t) + NewAPIController(r.Group("/")) + + for _, path := range []string{ + "/panel/api/inbounds/list", + "/panel/api/server/status", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("X-Test-Role", "user") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("%s: expected %d, got %d", path, http.StatusForbidden, w.Code) + } + } +} + +func TestAPIController_UserInfoRemainsAvailableToLoggedInUser(t *testing.T) { + setupControllerTestDB(t) + global.SetWebServer(&testWebServer{cron: cron.New()}) + + inboundSettings, err := json.Marshal(map[string]any{ + "clients": []map[string]any{ + {"id": "client-1", "email": "tester@example.com", "enable": true, "subId": "sub-1"}, + }, + }) + if err != nil { + t.Fatalf("marshal inbound settings failed: %v", err) + } + + inbound := &model.Inbound{ + UserId: 1, + Port: 12001, + Protocol: model.VLESS, + Tag: "controller-user-info", + Settings: string(inboundSettings), + } + if err := database.GetDB().Create(inbound).Error; err != nil { + t.Fatalf("create inbound failed: %v", err) + } + if err := database.GetDB().Create(&xray.ClientTraffic{ + InboundId: inbound.Id, + Email: "tester@example.com", + Enable: true, + }).Error; err != nil { + t.Fatalf("create client traffic failed: %v", err) + } + + r := newTestRouter(t) + NewAPIController(r.Group("/")) + + req := httptest.NewRequest(http.MethodGet, "/panel/api/inbounds/userInfo", nil) + req.Header.Set("X-Test-Role", "user") + req.Header.Set("X-Test-Username", "tester@example.com") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, w.Code) + } +} diff --git a/web/controller/api.go b/web/controller/api.go index 74a6d301..a9ffc369 100644 --- a/web/controller/api.go +++ b/web/controller/api.go @@ -43,10 +43,14 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { // Inbounds API inbounds := api.Group("/inbounds") - a.inboundController = NewInboundController(inbounds) + a.inboundController = &InboundController{} + inbounds.GET("/userInfo", a.inboundController.getUserInfo) + inbounds.Use(a.checkAdmin) + a.inboundController.initRouter(inbounds) // Server API server := api.Group("/server") + server.Use(a.checkAdmin) a.serverController = NewServerController(server) // Users API @@ -55,7 +59,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { a.userController = NewUserController(users) // Extra routes - api.GET("/backuptotgbot", a.BackuptoTgbot) + api.GET("/backuptotgbot", a.checkAdmin, a.BackuptoTgbot) } // BackuptoTgbot sends a backup of the panel data to Telegram bot admins. diff --git a/web/controller/inbound.go b/web/controller/inbound.go index 069ca188..fbd915f6 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -2,6 +2,7 @@ package controller import ( "encoding/json" + "errors" "fmt" "strconv" "time" @@ -12,6 +13,7 @@ import ( "github.com/mhsanaei/3x-ui/v2/web/websocket" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) // InboundController handles HTTP requests related to Xray inbounds management. @@ -48,7 +50,6 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) { g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics) g.POST("/delDepletedClients/:id", a.delDepletedClients) g.POST("/import", a.importInbound) - g.GET("/userInfo", a.getUserInfo) g.POST("/onlines", a.onlines) g.POST("/lastOnline", a.lastOnline) g.POST("/updateClientTraffic/:email", a.updateClientTraffic) @@ -73,8 +74,13 @@ func (a *InboundController) getInbound(c *gin.Context) { jsonMsg(c, I18nWeb(c, "get"), err) return } - inbound, err := a.inboundService.GetInbound(id) + user := session.GetLoginUser(c) + inbound, err := a.inboundService.GetInboundForUser(user.Id, user.Role == "admin", id) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), errors.New("inbound not found")) + return + } jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } @@ -140,7 +146,8 @@ func (a *InboundController) delInbound(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), err) return } - needRestart, err := a.inboundService.DelInbound(id) + user := session.GetLoginUser(c) + needRestart, err := a.inboundService.DelInboundForUser(user.Id, user.Role == "admin", id) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -150,7 +157,6 @@ func (a *InboundController) delInbound(c *gin.Context) { a.xrayService.SetToNeedRestart() } // Broadcast inbounds update via WebSocket - user := session.GetLoginUser(c) inbounds, _ := a.inboundService.GetInbounds(user.Id) websocket.BroadcastInbounds(inbounds) } @@ -170,7 +176,8 @@ func (a *InboundController) updateInbound(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) return } - inbound, needRestart, err := a.inboundService.UpdateInbound(inbound) + user := session.GetLoginUser(c) + inbound, needRestart, err := a.inboundService.UpdateInboundForUser(user.Id, user.Role == "admin", inbound) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -180,7 +187,6 @@ func (a *InboundController) updateInbound(c *gin.Context) { a.xrayService.SetToNeedRestart() } // Broadcast inbounds update via WebSocket - user := session.GetLoginUser(c) inbounds, _ := a.inboundService.GetInbounds(user.Id) websocket.BroadcastInbounds(inbounds) } @@ -250,7 +256,8 @@ func (a *InboundController) addInboundClient(c *gin.Context) { return } - needRestart, err := a.inboundService.AddInboundClient(data) + user := session.GetLoginUser(c) + needRestart, err := a.inboundService.AddInboundClientForUser(user.Id, user.Role == "admin", data) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -270,7 +277,8 @@ func (a *InboundController) delInboundClient(c *gin.Context) { } clientId := c.Param("clientId") - needRestart, err := a.inboundService.DelInboundClient(id, clientId) + user := session.GetLoginUser(c) + needRestart, err := a.inboundService.DelInboundClientForUser(user.Id, user.Role == "admin", id, clientId) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -292,7 +300,8 @@ func (a *InboundController) updateInboundClient(c *gin.Context) { return } - needRestart, err := a.inboundService.UpdateInboundClient(inbound, clientId) + user := session.GetLoginUser(c) + needRestart, err := a.inboundService.UpdateInboundClientForUser(user.Id, user.Role == "admin", inbound, clientId) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -312,7 +321,8 @@ func (a *InboundController) resetClientTraffic(c *gin.Context) { } email := c.Param("email") - needRestart, err := a.inboundService.ResetClientTraffic(id, email) + user := session.GetLoginUser(c) + needRestart, err := a.inboundService.ResetClientTrafficForUser(user.Id, user.Role == "admin", id, email) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -343,7 +353,8 @@ func (a *InboundController) resetAllClientTraffics(c *gin.Context) { return } - err = a.inboundService.ResetAllClientTraffics(id) + user := session.GetLoginUser(c) + err = a.inboundService.ResetAllClientTrafficsForUser(user.Id, user.Role == "admin", id) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -390,7 +401,8 @@ func (a *InboundController) delDepletedClients(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) return } - err = a.inboundService.DelDepletedClients(id) + user := session.GetLoginUser(c) + err = a.inboundService.DelDepletedClientsForUser(user.Id, user.Role == "admin", id) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -444,7 +456,8 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) { } email := c.Param("email") - needRestart, err := a.inboundService.DelInboundClientByEmail(inboundId, email) + user := session.GetLoginUser(c) + needRestart, err := a.inboundService.DelInboundClientByEmailForUser(user.Id, user.Role == "admin", inboundId, email) if err != nil { jsonMsg(c, "Failed to delete client by email", err) return diff --git a/web/controller/setting.go b/web/controller/setting.go index be0629ba..92bc7b84 100644 --- a/web/controller/setting.go +++ b/web/controller/setting.go @@ -24,6 +24,7 @@ type updateUserForm struct { // SettingController handles settings and user management operations. type SettingController struct { + BaseController settingService service.SettingService userService service.UserService panelService service.PanelService @@ -39,6 +40,7 @@ func NewSettingController(g *gin.RouterGroup) *SettingController { // initRouter sets up the routes for settings management. func (a *SettingController) initRouter(g *gin.RouterGroup) { g = g.Group("/setting") + g.Use(a.checkAdmin) g.POST("/all", a.getAllSetting) g.POST("/defaultSettings", a.getDefaultSettings) diff --git a/web/controller/xui.go b/web/controller/xui.go index 44ff5e1e..607359e5 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -30,9 +30,9 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { g.GET("/", a.index) g.GET("/user", a.user) - g.GET("/inbounds", a.inbounds) - g.GET("/settings", a.settings) - g.GET("/xray", a.xraySettings) + g.GET("/inbounds", a.checkAdmin, a.inbounds) + g.GET("/settings", a.checkAdmin, a.settings) + g.GET("/xray", a.checkAdmin, a.xraySettings) g.GET("/users", a.checkAdmin, a.users) a.settingController = NewSettingController(g) diff --git a/web/html/common/page.html b/web/html/common/page.html index 058682d5..24a0cf03 100644 --- a/web/html/common/page.html +++ b/web/html/common/page.html @@ -6,8 +6,8 @@ - - + + \n '+t+'\n \n
\n \n \n