diff --git a/.env b/.env
deleted file mode 100644
index cff9a1e..0000000
--- a/.env
+++ /dev/null
@@ -1,3 +0,0 @@
-PORT=3000
-# JWT secret
-SECRET=TEST
\ No newline at end of file
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..7fdaeeb
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,8 @@
+# Serve port
+PORT=3000
+# JWT secret
+SECRET="replace-with-at-least-32-random-bytes"
+
+# Bot connections
+BOT_LOG_BUFFER_SIZE=1000
+BOT_HEARTBEAT_TIMEOUT_SECONDS=90
\ No newline at end of file
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 4fc439f..052535d 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -27,6 +27,9 @@ jobs:
go get -u
echo ${{github.ref_type}}
+ - name: Run Tests
+ run: go test
+
- name: Build
run: go build -o necore
diff --git a/.gitignore b/.gitignore
index 896b331..a6b26e4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,4 +18,9 @@ contents/*
# Build
-necore
\ No newline at end of file
+necore
+
+# dotenv
+.env
+.env.*
+!.env.example
\ No newline at end of file
diff --git a/config/config.go b/config/config.go
index 96f3269..3d4e2db 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,17 +1,312 @@
package config
import (
- "log"
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "necore/util"
"os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
"github.com/joho/godotenv"
)
+const (
+ configFileEnv = "NECORE_CONFIG_FILE"
+ appConfigDir = "necore"
+ secretBytes = 48
+)
+
+var (
+ initOnce sync.Once
+ initErr error
+ configPath string
+)
+
+// Init finds or creates the .env file and loads it exactly once.
+func Init() error {
+ initOnce.Do(func() {
+ configPath, initErr = locateOrCreateConfigFile()
+ if initErr != nil {
+ return
+ }
+
+ // Load does not overwrite variables already supplied by the process
+ // environment. This lets deployment-time environment variables take
+ // precedence over values stored in .env.
+ if err := godotenv.Load(configPath); err != nil {
+ initErr = fmt.Errorf("load config file %q: %w", configPath, err)
+ return
+ }
+
+ setDefaultEnvironment("PORT", "3000")
+ defaultSecret, _ := util.GenerateSecureToken("", 32)
+ setDefaultEnvironment("SECRET", defaultSecret)
+ setDefaultEnvironment("BOT_LOG_BUFFER_SIZE", "1000")
+ setDefaultEnvironment("BOT_HEARTBEAT_TIMEOUT_SECONDS", "90")
+
+ secret := strings.TrimSpace(os.Getenv("SECRET"))
+ if len(secret) < 1 {
+ initErr = fmt.Errorf("SECRET must contain at least 1 characters, SECRET=%q", secret)
+ }
+ })
+
+ return initErr
+}
+
+// Config keeps the existing project API. main should call Init before any
+// server/database startup, so an error here indicates a programming error.
func Config(key string) string {
- // load .env file
- err := godotenv.Load(".env")
- if err != nil {
- log.Println("Error loading .env file: ", err)
+ if err := Init(); err != nil {
+ panic(err)
}
return os.Getenv(key)
}
+
+// Path returns the absolute path of the loaded .env file.
+func Path() string {
+ return configPath
+}
+
+func locateOrCreateConfigFile() (string, error) {
+ if explicit := strings.TrimSpace(os.Getenv(configFileEnv)); explicit != "" {
+ path, err := filepath.Abs(explicit)
+ if err != nil {
+ return "", fmt.Errorf("resolve %s: %w", configFileEnv, err)
+ }
+ if err := ensureConfigFile(path); err != nil {
+ return "", err
+ }
+ return path, nil
+ }
+
+ existingCandidates, creationCandidates, err := defaultConfigCandidates()
+ if err != nil {
+ return "", err
+ }
+
+ for _, path := range existingCandidates {
+ exists, err := regularFileExists(path)
+ if err != nil {
+ return "", err
+ }
+ if exists {
+ return path, nil
+ }
+ }
+
+ var creationErrors []error
+ for _, path := range creationCandidates {
+ if err := ensureConfigFile(path); err == nil {
+ return path, nil
+ } else {
+ creationErrors = append(creationErrors, fmt.Errorf("%s: %w", path, err))
+ }
+ }
+
+ return "", fmt.Errorf("unable to create configuration file: %w", errors.Join(creationErrors...))
+}
+
+func defaultConfigCandidates() ([]string, []string, error) {
+ var existing []string
+ var creation []string
+
+ // Development compatibility: only trust the current working directory
+ // when it looks like the project root. A production service may have an
+ // unrelated or attacker-controlled working directory.
+ if cwd, err := os.Getwd(); err == nil {
+ goMod := filepath.Join(cwd, "go.mod")
+ if ok, _ := regularFileExists(goMod); ok {
+ path := filepath.Join(cwd, ".env")
+ existing = append(existing, path)
+ creation = append(creation, path)
+ }
+ }
+
+ executable, err := os.Executable()
+ if err != nil {
+ return nil, nil, fmt.Errorf("locate executable: %w", err)
+ }
+ if resolved, resolveErr := filepath.EvalSymlinks(executable); resolveErr == nil {
+ executable = resolved
+ }
+ executablePath := filepath.Join(filepath.Dir(executable), ".env")
+ existing = append(existing, executablePath)
+ creation = append(creation, executablePath)
+
+ userConfigDir, err := os.UserConfigDir()
+ if err != nil {
+ return nil, nil, fmt.Errorf("locate user config directory: %w", err)
+ }
+ userConfigPath := filepath.Join(userConfigDir, appConfigDir, ".env")
+ existing = append(existing, userConfigPath)
+ creation = append(creation, userConfigPath)
+
+ return uniquePaths(existing), uniquePaths(creation), nil
+}
+
+func ensureConfigFile(path string) error {
+ exists, err := regularFileExists(path)
+ if err != nil {
+ return err
+ }
+ if exists {
+ return nil
+ }
+
+ directory := filepath.Dir(path)
+ if err := os.MkdirAll(directory, 0o700); err != nil {
+ return fmt.Errorf("create config directory: %w", err)
+ }
+
+ lockPath := path + ".lock"
+ deadline := time.Now().Add(5 * time.Second)
+
+ for {
+ lockFile, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err == nil {
+ return createConfigWhileLocked(path, lockPath, lockFile)
+ }
+ if !errors.Is(err, os.ErrExist) {
+ return fmt.Errorf("create config lock: %w", err)
+ }
+
+ // Another process may have finished creating the file.
+ exists, existsErr := regularFileExists(path)
+ if existsErr != nil {
+ return existsErr
+ }
+ if exists {
+ return nil
+ }
+
+ // Recover from a process that died while holding the creation lock.
+ if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > 30*time.Second {
+ _ = os.Remove(lockPath)
+ continue
+ }
+
+ if time.Now().After(deadline) {
+ return fmt.Errorf("timed out waiting for config file creation")
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+}
+
+func createConfigWhileLocked(path, lockPath string, lockFile *os.File) (returnErr error) {
+ defer func() {
+ _ = lockFile.Close()
+ _ = os.Remove(lockPath)
+ }()
+
+ // Check again after acquiring the lock.
+ exists, err := regularFileExists(path)
+ if err != nil {
+ return err
+ }
+ if exists {
+ return nil
+ }
+
+ secret, err := generateSecret()
+ if err != nil {
+ return err
+ }
+
+ content := fmt.Sprintf(
+ "# Automatically generated by necore on first startup.\n"+
+ "# Keep this file private. Changing SECRET invalidates existing JWTs.\n"+
+ "PORT=3000\n"+
+ "BOT_LOG_BUFFER_SIZE=2000\n"+
+ "SECRET=%s\n",
+ secret,
+ )
+
+ directory := filepath.Dir(path)
+ temporary, err := os.CreateTemp(directory, ".env.tmp-*")
+ if err != nil {
+ return fmt.Errorf("create temporary config file: %w", err)
+ }
+ temporaryPath := temporary.Name()
+ defer func() {
+ _ = temporary.Close()
+ _ = os.Remove(temporaryPath)
+ }()
+
+ if err := temporary.Chmod(0o600); err != nil {
+ return fmt.Errorf("set temporary config permissions: %w", err)
+ }
+ if _, err := temporary.WriteString(content); err != nil {
+ return fmt.Errorf("write temporary config file: %w", err)
+ }
+ if err := temporary.Sync(); err != nil {
+ return fmt.Errorf("sync temporary config file: %w", err)
+ }
+ if err := temporary.Close(); err != nil {
+ return fmt.Errorf("close temporary config file: %w", err)
+ }
+
+ if err := os.Rename(temporaryPath, path); err != nil {
+ // If another actor created the file despite not using our lock, prefer
+ // the existing file rather than overwrite it.
+ if exists, existsErr := regularFileExists(path); existsErr == nil && exists {
+ return nil
+ }
+ return fmt.Errorf("publish config file: %w", err)
+ }
+
+ // On Unix this protects the secret. On Windows the directory ACL remains
+ // the primary protection, but Chmod is still harmless.
+ if err := os.Chmod(path, 0o600); err != nil {
+ return fmt.Errorf("set config file permissions: %w", err)
+ }
+
+ return nil
+}
+
+func generateSecret() (string, error) {
+ buffer := make([]byte, secretBytes)
+ if _, err := rand.Read(buffer); err != nil {
+ return "", fmt.Errorf("generate SECRET: %w", err)
+ }
+ return base64.RawURLEncoding.EncodeToString(buffer), nil
+}
+
+func setDefaultEnvironment(key, value string) {
+ if _, exists := os.LookupEnv(key); !exists {
+ _ = os.Setenv(key, value)
+ }
+}
+
+func regularFileExists(path string) (bool, error) {
+ info, err := os.Stat(path)
+ if err == nil {
+ if !info.Mode().IsRegular() {
+ return false, fmt.Errorf("%q exists but is not a regular file", path)
+ }
+ return true, nil
+ }
+ if errors.Is(err, os.ErrNotExist) {
+ return false, nil
+ }
+ return false, fmt.Errorf("inspect %q: %w", path, err)
+}
+
+func uniquePaths(paths []string) []string {
+ seen := make(map[string]struct{}, len(paths))
+ result := make([]string, 0, len(paths))
+
+ for _, path := range paths {
+ clean := filepath.Clean(path)
+ if _, exists := seen[clean]; exists {
+ continue
+ }
+ seen[clean] = struct{}{}
+ result = append(result, clean)
+ }
+ return result
+}
diff --git a/controller/middleware/auth.go b/controller/middleware/auth.go
index 33dbf1b..98fdfdf 100644
--- a/controller/middleware/auth.go
+++ b/controller/middleware/auth.go
@@ -11,14 +11,16 @@ func AuthNeeded() fiber.Handler {
return jwtware.New(jwtware.Config{
SigningKey: jwtware.SigningKey{Key: []byte(config.Config("SECRET"))},
ErrorHandler: jwtError,
+ SuccessHandler: func(c *fiber.Ctx) error {
+ return validateTokenVersion(c)
+ },
})
}
func jwtError(c *fiber.Ctx, err error) error {
- if err.Error() == "Missing or malformed JWT" {
- return c.Status(fiber.StatusBadRequest).
- JSON(fiber.Map{"error": "Missing or malformed JWT", "err": nil})
- }
- return c.Status(fiber.StatusUnauthorized).
- JSON(fiber.Map{"error": "Invalid or expired JWT", "err": nil})
+ c.Set(fiber.HeaderWWWAuthenticate, `Bearer realm="necore"`)
+
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
+ "error": "Unauthorized",
+ })
}
diff --git a/controller/middleware/validator.go b/controller/middleware/validator.go
new file mode 100644
index 0000000..df28c77
--- /dev/null
+++ b/controller/middleware/validator.go
@@ -0,0 +1,101 @@
+package middleware
+
+import (
+ "encoding/json"
+ "errors"
+ "math"
+ "necore/database"
+ "necore/model"
+ "strconv"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/golang-jwt/jwt/v5"
+ "gorm.io/gorm"
+)
+
+func validateTokenVersion(c *fiber.Ctx) error {
+ token, ok := c.Locals("user").(*jwt.Token)
+ if !ok || token == nil {
+ return invalidToken(c)
+ }
+
+ claims, ok := token.Claims.(jwt.MapClaims)
+ if !ok {
+ return invalidToken(c)
+ }
+
+ username, ok := claims["name"].(string)
+ if !ok || username == "" {
+ return invalidToken(c)
+ }
+
+ tokenVersion, ok := getUintClaim(claims, "ver")
+ if !ok {
+ return invalidToken(c)
+ }
+
+ var user model.User
+ err := database.GetUserDatabase().
+ Select("username", "token_version", "group", "tags").
+ Where("username = ?", username).
+ First(&user).Error
+
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return invalidToken(c)
+ }
+
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "error": "Internal server error",
+ })
+ }
+
+ if tokenVersion != user.TokenVersion {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
+ "error": "Token has been revoked",
+ })
+ }
+
+ c.Locals("currentUser", user)
+ return c.Next()
+}
+
+func getUintClaim(claims jwt.MapClaims, key string) (uint, bool) {
+ value, ok := claims[key]
+ if !ok || value == nil {
+ return 0, false
+ }
+
+ switch v := value.(type) {
+ case float64:
+ if v < 0 || math.Trunc(v) != v {
+ return 0, false
+ }
+ return uint(v), true
+
+ case json.Number:
+ parsed, err := strconv.ParseUint(v.String(), 10, 64)
+ if err != nil {
+ return 0, false
+ }
+ return uint(parsed), true
+
+ case int:
+ if v < 0 {
+ return 0, false
+ }
+ return uint(v), true
+
+ case uint:
+ return v, true
+
+ default:
+ return 0, false
+ }
+}
+
+func invalidToken(c *fiber.Ctx) error {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
+ "error": "Invalid token",
+ })
+}
diff --git a/controller/router/router.go b/controller/router/router.go
index 7da330e..5539752 100644
--- a/controller/router/router.go
+++ b/controller/router/router.go
@@ -4,8 +4,11 @@ import (
"necore/app"
"necore/controller/middleware"
"necore/service"
+ "time"
+ "github.com/gofiber/contrib/websocket"
"github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/limiter"
)
type routerInstance struct {
@@ -28,16 +31,25 @@ func GetInstance() *routerInstance {
}
func SetupRoutes() {
+ loginLimiter := limiter.New(limiter.Config{
+ Max: 8,
+ Expiration: time.Minute,
+ LimitReached: func(c *fiber.Ctx) error {
+ return c.Status(fiber.StatusTooManyRequests).
+ JSON(fiber.Map{"error": "Too many login attempts"})
+ },
+ })
+
router := instance.Router
(*router).Get("/slogan", service.SloganHandler)
authGroup := (*router).Group("/auth")
authGroup.Get("/status", middleware.AuthNeeded(), service.GetStatus)
- authGroup.Post("/login", service.Login)
+ authGroup.Post("/login", loginLimiter, service.Login)
authGroup.Post("/register", middleware.AuthNeeded(), service.AddUser)
authGroup.Get("/user/:id", service.GetUserInfo)
authGroup.Get("/avatar/:id", service.GetUserAvatar)
- authGroup.Get("/userlist", service.GetUserList)
+ authGroup.Get("/userlist", middleware.AuthNeeded(), service.GetUserList)
authGroup.Delete("/user/:id", middleware.AuthNeeded(), service.DeleteUser)
authGroup.Post("/password", middleware.AuthNeeded(), service.UpdateUserPassword)
authGroup.Post("/avatar", middleware.AuthNeeded(), service.UpdateUserAvatar)
@@ -73,4 +85,16 @@ func SetupRoutes() {
documentGroup.Post("/upload/:id", middleware.AuthNeeded(), service.UploadDocumentFile)
documentGroup.Delete("/upload/:id", middleware.AuthNeeded(), service.DeleteDocumentFile)
(*router).Static("/contents", "./contents")
+
+ botGroup := (*router).Group("/bots")
+
+ botGroup.Use("/ws/updates/:identifier", service.BotConectionChecker)
+ botGroup.Get("/ws/updates/:identifier", websocket.New(service.HandleWSConnection))
+
+ botGroup.Post("/token", middleware.AuthNeeded(), service.CreateBotToken)
+ botGroup.Get("/token", middleware.AuthNeeded(), service.GetBotTokenList)
+ botGroup.Get("/token/:id", middleware.AuthNeeded(), service.GetBotToken)
+ botGroup.Delete("/token/:id", middleware.AuthNeeded(), service.DeleteBotToken)
+ botGroup.Get("/status", middleware.AuthNeeded(), service.GetWSStatus)
+ botGroup.Delete("/ws/kick/:session_id", middleware.AuthNeeded(), service.KickConnection)
}
diff --git a/dao/article.go b/dao/article.go
index 3ad51b4..5b0530d 100644
--- a/dao/article.go
+++ b/dao/article.go
@@ -18,8 +18,30 @@ func CreateArticle(id string) error {
}
func UpdateArticle(updatedArticle model.Article) error {
- db := database.GetArticleDatabase()
- return db.Save(&updatedArticle).Error
+ result := database.GetArticleDatabase().
+ Model(&model.Article{}).
+ Where("id = ?", updatedArticle.Id).
+ Updates(map[string]any{
+ "pin": updatedArticle.Pin,
+ "title": updatedArticle.Title,
+ "brief": updatedArticle.Brief,
+ "date": updatedArticle.Date,
+ "end_date": updatedArticle.EndDate,
+ "image": updatedArticle.Image,
+ "content": updatedArticle.Content,
+ "author": updatedArticle.Author,
+ "category": updatedArticle.Category,
+ })
+
+ if result.Error != nil {
+ return result.Error
+ }
+
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("Article not found")
+ }
+
+ return nil
}
func GetArticle(id string) (*model.Article, error) {
@@ -67,7 +89,16 @@ func GetArticleList(target string, page int, pageSize int, pin bool) ([]model.Ar
func DeleteArticle(id string) error {
db := database.GetArticleDatabase()
- // Delete File
- os.RemoveAll(fmt.Sprintf("./contents/%s", id))
- return db.Where(&model.Article{Id: id}).Delete(&model.Article{}).Error
+
+ result := db.Where("id = ?", id).Delete(&model.Article{})
+ if result.Error != nil {
+ return result.Error
+ }
+
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("Article not found")
+ }
+
+ _ = os.RemoveAll(fmt.Sprintf("./contents/%s", id))
+ return nil
}
diff --git a/dao/bottoken.go b/dao/bottoken.go
new file mode 100644
index 0000000..1bcb69b
--- /dev/null
+++ b/dao/bottoken.go
@@ -0,0 +1,146 @@
+package dao
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "necore/database"
+ "necore/model"
+ "necore/util"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+var ErrBotTokenAlreadyExists = errors.New("bot token already exists")
+
+func generateBotToken() (string, string, error) {
+ tokenStr, err := util.GenerateSecureToken("bot", 64)
+ if err != nil {
+ return "", "", err
+ }
+
+ sum := sha256.Sum256([]byte(tokenStr))
+ tokenHash := hex.EncodeToString(sum[:])
+
+ return tokenStr, tokenHash, nil
+}
+
+func CreateBotToken(name string) (*model.BotToken, string, error) {
+ tokenStr, tokenHash, err := generateBotToken()
+ if err != nil {
+ return nil, "", err
+ }
+
+ db := database.GetBotTokenDatabase()
+
+ var existingToken model.BotToken
+
+ err = db.Unscoped().
+ Where("name = ?", name).
+ First(&existingToken).Error
+
+ if err == nil {
+ if !existingToken.DeletedAt.Valid {
+ return nil, "", ErrBotTokenAlreadyExists
+ }
+
+ now := time.Now()
+
+ if err := db.Unscoped().
+ Model(&existingToken).
+ Updates(map[string]any{
+ "token_hash": tokenHash,
+ "deleted_at": nil,
+ "created_at": now,
+ "updated_at": now,
+ }).Error; err != nil {
+ return nil, "", err
+ }
+
+ var restoredToken model.BotToken
+ if err := db.Where("name = ?", name).First(&restoredToken).Error; err != nil {
+ return nil, "", err
+ }
+
+ return &restoredToken, tokenStr, nil
+ }
+
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, "", err
+ }
+
+ newToken := model.BotToken{
+ Name: name,
+ TokenHash: tokenHash,
+ }
+
+ if err := db.Create(&newToken).Error; err != nil {
+ return nil, "", err
+ }
+
+ return &newToken, tokenStr, nil
+}
+
+func GetBotTokens() []model.BotToken {
+ var tokens []model.BotToken
+ db := database.GetBotTokenDatabase()
+ db.Find(&tokens)
+ return tokens
+}
+
+func GetBotToken(name string) (*model.BotToken, error) {
+ var token model.BotToken
+
+ err := database.GetBotTokenDatabase().
+ Where("name = ?", name).
+ First(&token).Error
+
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, fmt.Errorf("bot token '%s' not found", name)
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ return &token, nil
+}
+
+func GetBotTokenByToken(token string) (*model.BotToken, error) {
+ var tokenModel model.BotToken
+
+ db := database.GetBotTokenDatabase()
+
+ if err := db.Where(&model.BotToken{TokenHash: token}).First(&tokenModel).Error; err != nil {
+ return nil, err
+ }
+
+ return &tokenModel, nil
+}
+
+func GetBotTokenByPlainToken(token string) (*model.BotToken, error) {
+ sum := sha256.Sum256([]byte(token))
+ tokenHash := hex.EncodeToString(sum[:])
+
+ return GetBotTokenByToken(tokenHash)
+}
+
+func DeleteBotToken(name string) error {
+ db := database.GetBotTokenDatabase()
+
+ result := db.Unscoped().
+ Where("name = ?", name).
+ Delete(&model.BotToken{})
+
+ if result.Error != nil {
+ return result.Error
+ }
+
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("bot token '%s' not found", name)
+ }
+
+ return nil
+}
diff --git a/dao/document.go b/dao/document.go
index e0a8080..d0273b3 100644
--- a/dao/document.go
+++ b/dao/document.go
@@ -2,6 +2,7 @@ package dao
import (
"encoding/json"
+ "errors"
"fmt"
"necore/database"
"necore/model"
@@ -11,6 +12,61 @@ import (
"gorm.io/gorm"
)
+func validateDocumentParent(tx *gorm.DB, nodeID string, parentID string) error {
+ if parentID == "" {
+ return fmt.Errorf("Invalid parent ID")
+ }
+
+ if parentID == nodeID {
+ return fmt.Errorf("Parent ID cannot be the same as the node ID")
+ }
+
+ if parentID == "root" {
+ return nil
+ }
+
+ var parent model.DocumentNode
+ err := tx.Where("id = ?", parentID).First(&parent).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return fmt.Errorf("Record not found")
+ }
+ if err != nil {
+ return err
+ }
+
+ if !parent.IsFolder {
+ return fmt.Errorf("Parent ID must be a folder")
+ }
+
+ seen := map[string]struct{}{
+ nodeID: {},
+ }
+
+ current := parent
+
+ for {
+ if _, exists := seen[current.Id]; exists {
+ return fmt.Errorf("Circular reference detected")
+ }
+ seen[current.Id] = struct{}{}
+
+ if current.ParentId == "" || current.ParentId == "root" {
+ return nil
+ }
+
+ var next model.DocumentNode
+ err := tx.Where("id = ?", current.ParentId).First(&next).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return fmt.Errorf("Record not found")
+ }
+ if err != nil {
+ return err
+ }
+
+ current = next
+ }
+}
+
func getCurrentTime() string {
currenttime := time.Now()
newtime := fmt.Sprintf("%d-%s-%d %d:%d:%d", currenttime.Year(), currenttime.Month().String(), currenttime.Day(), currenttime.Hour(), currenttime.Minute(), currenttime.Second())
@@ -19,40 +75,82 @@ func getCurrentTime() string {
func CreateDocumentNode(parentId string, isFolder bool, private bool, name string, id string, username string) error {
db := database.GetDocumentDatabase()
- if parentId == id {
- return fmt.Errorf("ParentId and Id cannot be the same")
- }
- contributors, _ := json.Marshal([]string{username})
- node := model.DocumentNode{
- ParentId: parentId,
- IsFolder: isFolder,
- Private: private,
- Name: name,
- Id: id,
- Contributors: string(contributors),
- UpdateTime: getCurrentTime(),
- }
- return db.Create(&node).Error
+
+ return db.Transaction(func(tx *gorm.DB) error {
+ if err := validateDocumentParent(tx, id, parentId); err != nil {
+ return err
+ }
+
+ contributors, _ := json.Marshal([]string{username})
+ node := model.DocumentNode{
+ ParentId: parentId,
+ IsFolder: isFolder,
+ Private: private,
+ Name: name,
+ Id: id,
+ Contributors: string(contributors),
+ UpdateTime: getCurrentTime(),
+ }
+
+ return tx.Create(&node).Error
+ })
}
func DeleteDocumentNode(id string) error {
db := database.GetDocumentDatabase()
+
+ var ids []string
+ if err := collectDocumentNodeIDs(db, id, &ids); err != nil {
+ return err
+ }
+
+ if len(ids) == 0 {
+ return fmt.Errorf("No document nodes found")
+ }
+
+ if err := db.Transaction(func(tx *gorm.DB) error {
+ result := tx.Where("id IN ?", ids).Delete(&model.DocumentNode{})
+ if result.Error != nil {
+ return result.Error
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ for _, nodeID := range ids {
+ _ = os.RemoveAll(fmt.Sprintf("./contents/%s", nodeID))
+ }
+
+ return nil
+}
+
+func collectDocumentNodeIDs(db *gorm.DB, id string, ids *[]string) error {
var node model.DocumentNode
- db.Where(&model.DocumentNode{Id: id}).First(&node)
+ err := db.Where("id = ?", id).First(&node).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return fmt.Errorf("Record not found")
+ }
+ if err != nil {
+ return err
+ }
+
+ *ids = append(*ids, node.Id)
- // Recursively delete all children
if node.IsFolder {
var children []model.DocumentNode
- db.Where(&model.DocumentNode{ParentId: id}).Find(&children)
+ if err := db.Where("parent_id = ?", node.Id).Find(&children).Error; err != nil {
+ return err
+ }
+
for _, child := range children {
- DeleteDocumentNode(child.Id)
- db.Where(&model.DocumentNode{Id: child.Id}).Delete(&model.DocumentNode{})
+ if err := collectDocumentNodeIDs(db, child.Id, ids); err != nil {
+ return err
+ }
}
- } else {
- // Delete Files
- os.RemoveAll(fmt.Sprintf("./contents/%s", id))
}
- return db.Where(&model.DocumentNode{Id: id}).Delete(&model.DocumentNode{}).Error
+
+ return nil
}
func UpdateDocumentNodeName(id string, name string) error {
@@ -109,13 +207,38 @@ func checkCyclicDocumentNode(parentId string, id string, db *gorm.DB) bool {
func UpdateDocumentNodeParentId(id string, parentId string) error {
db := database.GetDocumentDatabase()
- if parentId == id {
- return fmt.Errorf("ParentId and Id cannot be the same")
- }
- if checkCyclicDocumentNode(parentId, id, db) {
- return fmt.Errorf("Cyclic dependency detected")
- }
- return db.Model(&model.DocumentNode{}).Where(&model.DocumentNode{Id: id}).Updates(model.DocumentNode{ParentId: parentId}).Error
+
+ return db.Transaction(func(tx *gorm.DB) error {
+ var node model.DocumentNode
+ err := tx.Where("id = ?", id).First(&node).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return fmt.Errorf("Record not found")
+ }
+ if err != nil {
+ return err
+ }
+
+ if err := validateDocumentParent(tx, id, parentId); err != nil {
+ return err
+ }
+
+ result := tx.Model(&model.DocumentNode{}).
+ Where("id = ?", id).
+ Updates(map[string]any{
+ "parent_id": parentId,
+ "update_time": getCurrentTime(),
+ })
+
+ if result.Error != nil {
+ return result.Error
+ }
+
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("Record not found")
+ }
+
+ return nil
+ })
}
func GetDocumentNodeChildren(id string, private bool) ([]model.DocumentNode, error) {
diff --git a/dao/server.go b/dao/server.go
index 9364344..e9524d3 100644
--- a/dao/server.go
+++ b/dao/server.go
@@ -1,6 +1,7 @@
package dao
import (
+ "fmt"
"necore/database"
"necore/model"
)
@@ -18,13 +19,41 @@ func AddServer(server model.Server) error {
}
func UpdateServer(server model.Server) error {
- db := database.GetServerDatabase()
- var s *model.Server
- db.Where(&model.Server{Id: server.Id}).First(&s)
- return db.Model(&s).Updates(server).Error
+ result := database.GetServerDatabase().
+ Model(&model.Server{}).
+ Where("id = ?", server.Id).
+ Updates(map[string]any{
+ "name": server.Name,
+ "icon": server.Icon,
+ "description": server.Description,
+ "realtime": server.Realtime,
+ "online_map_url": server.OnlineMapUrl,
+ "server_url": server.ServerUrl,
+ })
+
+ if result.Error != nil {
+ return result.Error
+ }
+
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("server not found")
+ }
+
+ return nil
}
func DeleteServer(id string) error {
- db := database.GetServerDatabase()
- return db.Where(&model.Server{Id: id}).Delete(&model.Server{}).Error
+ result := database.GetServerDatabase().
+ Where("id = ?", id).
+ Delete(&model.Server{})
+
+ if result.Error != nil {
+ return result.Error
+ }
+
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("server not found")
+ }
+
+ return nil
}
diff --git a/dao/user.go b/dao/user.go
index 69bfd90..617cb74 100644
--- a/dao/user.go
+++ b/dao/user.go
@@ -7,6 +7,7 @@ import (
"necore/config"
"necore/database"
"necore/model"
+ "slices"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -32,59 +33,33 @@ func DebugTestPassword() {
log.Println(`Test Password "test":`, hash)
}
+func UnitTestPassword() string {
+ hash, _ := hashPassword("unit-test-password")
+ return hash
+}
+
// Token
func CreateToken(u model.User) (string, error) {
token := jwt.New(jwt.SigningMethodHS256)
claims := token.Claims.(jwt.MapClaims)
- claims["username"] = u.Username
- claims["group"] = u.Group
- claims["tags"] = u.Tags
+
+ claims["name"] = u.Username
+ claims["ver"] = u.TokenVersion
+ claims["iat"] = time.Now().Unix()
claims["exp"] = time.Now().Add(time.Hour * 72).Unix()
+
t, err := token.SignedString([]byte(config.Config("SECRET")))
return t, err
}
-func GetUsernameFromToken(t *jwt.Token) string {
- return t.Claims.(jwt.MapClaims)["username"].(string)
-}
-
-func GetUserGroupsFromToken(t *jwt.Token) []string {
- claims := t.Claims.(jwt.MapClaims)["group"]
- if claims == nil {
- return []string{}
- }
-
+func ContainsGroup(userGroup string, group string) bool {
var groups []string
- err := json.Unmarshal([]byte(claims.(string)), &groups)
+ err := json.Unmarshal([]byte(userGroup), &groups)
if err != nil {
- return []string{}
- }
- return groups
-}
-
-func GetUserTagsFromToken(t *jwt.Token) []string {
- claims := t.Claims.(jwt.MapClaims)["tags"]
- if claims == nil {
- return []string{}
- }
-
- var tags []string
- err := json.Unmarshal([]byte(claims.(string)), &tags)
- if err != nil {
- return []string{}
- }
- return tags
-}
-
-func IsUserInGroup(t *jwt.Token, group string) bool {
- groups := GetUserGroupsFromToken(t)
- for _, g := range groups {
- if g == group {
- return true
- }
+ groups = []string{}
}
- return false
+ return slices.Contains(groups, group)
}
// Database
@@ -103,13 +78,20 @@ func GetUserByUsername(u string) (*model.User, error) {
}
func AddUserByUsername(username string, password string) error {
- hash, _ := hashPassword(password)
- db := database.GetUserDatabase()
+ hash, err := hashPassword(password)
+ if err != nil {
+ return err
+ }
+
user := model.User{
- Username: username,
- Password: hash,
+ Username: username,
+ Password: hash,
+ Group: `[]`,
+ Tags: `[]`,
+ TokenVersion: 1,
}
- return db.Create(&user).Error
+
+ return database.GetUserDatabase().Create(&user).Error
}
func GetAllUsers() ([]model.User, error) {
@@ -147,6 +129,17 @@ func UpdateUserInfo(username string, group string, tags string) error {
return db.Model(&user).Updates(model.User{Group: group, Tags: tags}).Error
}
+func UpdateUserPermissions(username string) error {
+ db := database.GetUserDatabase()
+
+ return db.Model(&model.User{}).
+ Where(model.User{Username: username}).
+ UpdateColumn(
+ "token_version",
+ gorm.Expr("token_version + ?", 1),
+ ).Error
+}
+
func GetUserAvatar(username string) (string, error) {
db := database.GetUserDatabase()
var user model.User
diff --git a/database/database.go b/database/database.go
index 59e5052..907cff3 100644
--- a/database/database.go
+++ b/database/database.go
@@ -17,6 +17,8 @@ var serverDatabase *gorm.DB
var documentDatabase *gorm.DB
+var botTokenDatabase *gorm.DB
+
func ConnectSqlite() {
var err error
userDatabase, err = gorm.Open(sqlite.Open("data/user.sqlite3"), &gorm.Config{})
@@ -43,6 +45,11 @@ func ConnectSqlite() {
}
documentDatabase.AutoMigrate(&model.DocumentNode{})
+ botTokenDatabase, err = gorm.Open(sqlite.Open("data/bot_connection.sqlite3"), &gorm.Config{})
+ if err != nil {
+ panic("failed to connect bot connection database")
+ }
+ botTokenDatabase.AutoMigrate(&model.BotToken{})
}
func GetUserDatabase() *gorm.DB {
@@ -60,3 +67,7 @@ func GetServerDatabase() *gorm.DB {
func GetDocumentDatabase() *gorm.DB {
return documentDatabase
}
+
+func GetBotTokenDatabase() *gorm.DB {
+ return botTokenDatabase
+}
diff --git a/go.mod b/go.mod
index 5c8d276..292f990 100644
--- a/go.mod
+++ b/go.mod
@@ -1,39 +1,38 @@
module necore
-go 1.24.4
+go 1.25.0
require (
github.com/gofiber/contrib/jwt v1.1.2
- github.com/gofiber/fiber/v2 v2.52.11
+ github.com/gofiber/contrib/websocket v1.3.4
+ github.com/gofiber/fiber/v2 v2.52.13
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/millkhan/mcstatusgo/v2 v2.2.0
- golang.org/x/crypto v0.48.0
+ golang.org/x/crypto v0.53.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect
- github.com/andybalholm/brotli v1.2.0 // indirect
+ github.com/andybalholm/brotli v1.2.1 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
- github.com/gofiber/storage/sqlite3/v2 v2.1.3 // indirect
- github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
+ github.com/fasthttp/websocket v1.5.12 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
- github.com/klauspost/compress v1.18.4 // indirect
- github.com/mattn/go-colorable v0.1.14 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mattn/go-runewidth v0.0.20 // indirect
- github.com/mattn/go-sqlite3 v1.14.34 // indirect
- github.com/rivo/uniseg v0.4.7 // indirect
- github.com/tidwall/gjson v1.18.0 // indirect
- github.com/tidwall/match v1.1.1 // indirect
- github.com/tidwall/pretty v1.2.0 // indirect
+ github.com/klauspost/compress v1.18.6 // indirect
+ github.com/mattn/go-colorable v0.1.15 // indirect
+ github.com/mattn/go-isatty v0.0.22 // indirect
+ github.com/mattn/go-runewidth v0.0.24 // indirect
+ github.com/mattn/go-sqlite3 v1.14.45 // indirect
+ github.com/philhofer/fwd v1.2.0 // indirect
+ github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 // indirect
+ github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
- github.com/valyala/fasthttp v1.69.0 // indirect
- github.com/valyala/tcplisten v1.0.0 // indirect
- golang.org/x/sys v0.41.0 // indirect
- golang.org/x/text v0.34.0 // indirect
+ github.com/valyala/fasthttp v1.71.0 // indirect
+ golang.org/x/net v0.55.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.38.0 // indirect
)
diff --git a/go.sum b/go.sum
index 1af60fb..87f8c6d 100644
--- a/go.sum
+++ b/go.sum
@@ -1,25 +1,19 @@
github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k=
github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
-github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
-github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
-github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
-github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
+github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
+github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
github.com/gofiber/contrib/jwt v1.1.2 h1:GmWnOqT4A15EkA8IPXwSpvNUXZR4u5SMj+geBmyLAjs=
github.com/gofiber/contrib/jwt v1.1.2/go.mod h1:CpIwrkUQ3Q6IP8y9n3f0wP9bOnSKx39EDp2fBVgMFVk=
-github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4=
-github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
-github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
-github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
-github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
-github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
-github.com/gofiber/storage/sqlite3/v2 v2.1.3 h1:zF8g/PQcCStF6WMJUYmy0Nq4Hj/LnW/vq/8OhRh6TZY=
-github.com/gofiber/storage/sqlite3/v2 v2.1.3/go.mod h1:KsEwBo6N7RGqEvI8XlcNclvrjdGab1RcmYL8jFCBRjc=
-github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
-github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
-github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
-github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/gofiber/contrib/websocket v1.3.4 h1:tWeBdbJ8q0WFQXariLN4dBIbGH9KBU75s0s7YXplOSg=
+github.com/gofiber/contrib/websocket v1.3.4/go.mod h1:kTFBPC6YENCnKfKx0BoOFjgXxdz7E85/STdkmZPEmPs=
+github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
+github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -30,70 +24,45 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
-github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
-github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
-github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
-github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
-github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
-github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
-github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
-github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
-github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
-github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
-github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ=
-github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
-github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
-github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
+github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
+github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
+github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
+github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
+github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
+github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/millkhan/mcstatusgo/v2 v2.2.0 h1:uRyHiOvqlK+6Oz3za4hMWAktSLjaqD/QzyQCcX2DjzI=
github.com/millkhan/mcstatusgo/v2 v2.2.0/go.mod h1:YUJHhrJzsQP4PoDXFo++7JzPU7TjdztFM519CPqKe5M=
-github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
-github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
-github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
-github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
-github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
-github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
+github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 h1:McifyVxygw1d67y6vxUqls2D46J8W9nrki9c8c0eVvE=
+github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761/go.mod h1:Vi9gvHvTw4yCUHIznFl5TPULS7aXwgaTByGeBY75Wko=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
+github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
-github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
-github.com/valyala/fasthttp v1.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og=
-github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA=
-github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
-github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
-github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
-github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
-golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
-golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
-golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
-golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
-golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
-golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
-golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
+github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
-gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
-gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
diff --git a/main.go b/main.go
index 0f51e8c..82bb15c 100644
--- a/main.go
+++ b/main.go
@@ -1,7 +1,9 @@
package main
import (
+ "log"
"necore/app"
+ "necore/config"
"necore/controller/router"
"necore/database"
)
@@ -10,6 +12,10 @@ func main() {
// This will print a hash of "test". U can insert it into sqlite3 manually for an admin account (the group section should be `["admin"]`).
// dao.DebugTestPassword()
+ if err := config.Init(); err != nil {
+ log.Fatalf("initialize configuration: %v", err)
+ }
+
database.ConnectSqlite()
router.SetupRoutes()
app.Start()
diff --git a/model/bottoken.go b/model/bottoken.go
new file mode 100644
index 0000000..cfd4ff0
--- /dev/null
+++ b/model/bottoken.go
@@ -0,0 +1,10 @@
+package model
+
+import "gorm.io/gorm"
+
+type BotToken struct {
+ gorm.Model
+
+ Name string `gorm:"uniqueIndex;not null" json:"name"`
+ TokenHash string `gorm:"uniqueIndex;not null" json:"-"`
+}
diff --git a/model/user.go b/model/user.go
index 9ee0c45..33e58f3 100644
--- a/model/user.go
+++ b/model/user.go
@@ -7,9 +7,10 @@ import (
type User struct {
gorm.Model
- Username string `gorm:"uniqueIndex;not null" json:"username"`
- Password string `gorm:"not null" json:"password"` // sha256 hashed
- Group string `json:"group"` // json array: []string
- Tags string `json:"tags"` // json array: []string
- Avatar string `json:"avatar"`
+ Username string `gorm:"uniqueIndex;not null" json:"username"`
+ Password string `gorm:"not null" json:"password"` // sha256 hashed
+ Group string `json:"group"` // json array: []string
+ Tags string `json:"tags"` // json array: []string
+ Avatar string `json:"avatar"`
+ TokenVersion uint `gorm:"not null;default:1" json:"-"`
}
diff --git a/routes_and_security_test.go b/routes_and_security_test.go
new file mode 100644
index 0000000..b57e869
--- /dev/null
+++ b/routes_and_security_test.go
@@ -0,0 +1,984 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "necore/controller/middleware"
+ "necore/dao"
+ "necore/database"
+ "necore/model"
+ "necore/service"
+
+ "github.com/gofiber/contrib/websocket"
+ "github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/limiter"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+)
+
+type testEnv struct {
+ app *fiber.App
+ adminToken string
+ userToken string
+ tmpDir string
+}
+
+type testResponse struct {
+ StatusCode int
+ Body []byte
+ Header http.Header
+}
+
+func setupTestEnv(t *testing.T) *testEnv {
+ t.Helper()
+
+ tmpDir := t.TempDir()
+ must(t, os.MkdirAll(filepath.Join(tmpDir, "data"), 0o755))
+ must(t, os.MkdirAll(filepath.Join(tmpDir, "contents"), 0o755))
+
+ envContent := "SECRET=unit-test-secret\nBOT_LOG_BUFFER_SIZE=100\n"
+ must(t, os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0o600))
+
+ oldWd, err := os.Getwd()
+ must(t, err)
+ must(t, os.Chdir(tmpDir))
+ t.Cleanup(func() {
+ _ = os.Chdir(oldWd)
+ })
+
+ t.Setenv("SECRET", "unit-test-secret")
+ t.Setenv("BOT_LOG_BUFFER_SIZE", "100")
+
+ database.ConnectSqlite()
+
+ // 测试会故意访问不存在的节点和 token;关闭 GORM 的 record not found 日志,
+ // 避免把预期分支误看成测试故障。
+ setGormLoggerSilent(database.GetUserDatabase())
+ setGormLoggerSilent(database.GetArticleDatabase())
+ setGormLoggerSilent(database.GetServerDatabase())
+ setGormLoggerSilent(database.GetDocumentDatabase())
+ setGormLoggerSilent(database.GetBotTokenDatabase())
+
+ // 必须在 Windows 删除 TempDir 前关闭 SQLite 连接池,否则数据库文件会被锁定。
+ t.Cleanup(func() {
+ closeGormDB(t, database.GetUserDatabase())
+ closeGormDB(t, database.GetArticleDatabase())
+ closeGormDB(t, database.GetServerDatabase())
+ closeGormDB(t, database.GetDocumentDatabase())
+ closeGormDB(t, database.GetBotTokenDatabase())
+ })
+
+ must(t, dao.AddUserByUsername("admin", "admin-pass"))
+ must(t, dao.AddUserByUsername("alice", "alice-pass"))
+
+ must(t, database.GetUserDatabase().
+ Model(&model.User{}).
+ Where("username = ?", "admin").
+ Updates(model.User{
+ Password: dao.UnitTestPassword(),
+ Group: `["admin","news_admin","server_admin","document_admin","bot_admin"]`,
+ Tags: `[]`,
+ Avatar: "admin-avatar",
+ }).Error)
+
+ must(t, database.GetUserDatabase().
+ Model(&model.User{}).
+ Where("username = ?", "alice").
+ Updates(model.User{
+ Password: dao.UnitTestPassword(),
+ Group: `[]`,
+ Tags: `[]`,
+ Avatar: "alice-avatar",
+ }).Error)
+
+ // 从数据库重新读取用户后再签发 token。
+ // 引入 token_version 后,JWT 中的 ver 必须等于数据库里的 token_version,
+ // 不能再用手写的 model.User 字面量签发测试 token。
+ adminToken := createTokenForUser(t, "admin")
+ userToken := createTokenForUser(t, "alice")
+
+ app := fiber.New(fiber.Config{BodyLimit: 512 * 1024 * 1024})
+ registerRoutes(app)
+ t.Cleanup(func() {
+ _ = app.Shutdown()
+ })
+
+ return &testEnv{
+ app: app,
+ adminToken: adminToken,
+ userToken: userToken,
+ tmpDir: tmpDir,
+ }
+}
+
+func setGormLoggerSilent(db *gorm.DB) {
+ if db != nil {
+ db.Logger = logger.Default.LogMode(logger.Silent)
+ }
+}
+
+func closeGormDB(t *testing.T, db *gorm.DB) {
+ t.Helper()
+ if db == nil {
+ return
+ }
+ sqlDB, err := db.DB()
+ if err != nil {
+ t.Errorf("get underlying SQL DB: %v", err)
+ return
+ }
+ if err := sqlDB.Close(); err != nil {
+ t.Errorf("close SQL DB: %v", err)
+ }
+}
+
+func createTokenForUser(t *testing.T, username string) string {
+ t.Helper()
+
+ user, err := dao.GetUserByUsername(username)
+ must(t, err)
+ if user == nil {
+ t.Fatalf("user %q not found", username)
+ }
+
+ token, err := dao.CreateToken(*user)
+ must(t, err)
+ return token
+}
+
+func loginAndGetToken(t *testing.T, env *testEnv, username, password string) string {
+ t.Helper()
+
+ resp := doJSON(t, env, http.MethodPost, "/necore/auth/login", "", fiber.Map{
+ "username": username,
+ "password": password,
+ })
+ assertStatus(t, resp, http.StatusOK)
+
+ body := decodeBody(t, resp)
+ token, ok := body["token"].(string)
+ if !ok || token == "" {
+ t.Fatalf("login response should contain token string, got %#v", body)
+ }
+ return token
+}
+
+func getUserTokenVersion(t *testing.T, username string) uint {
+ t.Helper()
+
+ var version uint
+ result := database.GetUserDatabase().
+ Model(&model.User{}).
+ Select("token_version").
+ Where("username = ?", username).
+ Scan(&version)
+
+ must(t, result.Error)
+ if result.RowsAffected == 0 {
+ t.Fatalf("user %q not found while reading token_version", username)
+ }
+ return version
+}
+
+func incrementUserTokenVersion(t *testing.T, username string) {
+ t.Helper()
+
+ result := database.GetUserDatabase().
+ Model(&model.User{}).
+ Where("username = ?", username).
+ UpdateColumn("token_version", gorm.Expr("token_version + 1"))
+
+ must(t, result.Error)
+ if result.RowsAffected == 0 {
+ t.Fatalf("user %q not found while incrementing token_version", username)
+ }
+}
+
+func assertUserTokenVersion(t *testing.T, username string, want uint) {
+ t.Helper()
+
+ got := getUserTokenVersion(t, username)
+ if got != want {
+ t.Fatalf("token_version for %q = %d, want %d", username, got, want)
+ }
+}
+
+func registerRoutes(app *fiber.App) {
+ api := app.Group("/necore")
+
+ loginLimiter := limiter.New(limiter.Config{
+ Max: 8,
+ Expiration: time.Minute,
+ LimitReached: func(c *fiber.Ctx) error {
+ return c.Status(fiber.StatusTooManyRequests).
+ JSON(fiber.Map{"error": "Too many login attempts"})
+ },
+ })
+
+ api.Get("/slogan", service.SloganHandler)
+
+ authGroup := api.Group("/auth")
+ authGroup.Get("/status", middleware.AuthNeeded(), service.GetStatus)
+ authGroup.Post("/login", loginLimiter, service.Login)
+ authGroup.Post("/register", middleware.AuthNeeded(), service.AddUser)
+ authGroup.Get("/user/:id", service.GetUserInfo)
+ authGroup.Get("/avatar/:id", service.GetUserAvatar)
+ authGroup.Get("/userlist", middleware.AuthNeeded(), service.GetUserList)
+ authGroup.Delete("/user/:id", middleware.AuthNeeded(), service.DeleteUser)
+ authGroup.Post("/password", middleware.AuthNeeded(), service.UpdateUserPassword)
+ authGroup.Post("/avatar", middleware.AuthNeeded(), service.UpdateUserAvatar)
+ authGroup.Patch("/user", middleware.AuthNeeded(), service.UpdateUserInfo)
+
+ articleGroup := api.Group("/news")
+ articleGroup.Get("/total/:target", service.GetArticleCountByCategory)
+ articleGroup.Post("/list", service.GetArticleList)
+ articleGroup.Get("/detail/:id", service.GetArticleById)
+ articleGroup.Patch("/:id", middleware.AuthNeeded(), service.UpdateArticle)
+ articleGroup.Post("/upload/:id", middleware.AuthNeeded(), service.UploadArticleFile)
+ articleGroup.Delete("/upload/:id", middleware.AuthNeeded(), service.DeleteArticleFile)
+ articleGroup.Post("/create", middleware.AuthNeeded(), service.CreateArticle)
+ articleGroup.Delete("/:id", middleware.AuthNeeded(), service.DeleteArticle)
+
+ serverGroup := api.Group("/server")
+ serverGroup.Get("/", service.GetServerList)
+ serverGroup.Post("/status", service.GetServerStatus)
+ serverGroup.Get("/create", middleware.AuthNeeded(), service.AddServer)
+ serverGroup.Delete("/:id", middleware.AuthNeeded(), service.DeleteServer)
+ serverGroup.Patch("/", middleware.AuthNeeded(), service.UpdateServer)
+
+ documentGroup := api.Group("/documents")
+ documentGroup.Delete("/node/:id", middleware.AuthNeeded(), service.DeleteDocumentNode)
+ documentGroup.Post("/node/:id", middleware.AuthNeeded(), service.UpdateDocumentNodeParentId)
+ documentGroup.Put("/node/:id", middleware.AuthNeeded(), service.UpdateDocumentNodeContent)
+ documentGroup.Patch("/node/:id", middleware.AuthNeeded(), service.UpdateDocumentNodeName)
+ documentGroup.Post("/node", middleware.AuthNeeded(), service.CreateDocumentNode)
+ documentGroup.Get("/layer/private/:parentId", middleware.AuthNeeded(), service.GetDocumentNodeChildrenPrivate)
+ documentGroup.Get("/layer/:parentId", service.GetDocumentNodeChildren)
+ documentGroup.Get("/private/:id", middleware.AuthNeeded(), service.GetDocumentNodeContentPrivate)
+ documentGroup.Get("/:id", service.GetDocumentNodeContent)
+ documentGroup.Post("/upload/:id", middleware.AuthNeeded(), service.UploadDocumentFile)
+ documentGroup.Delete("/upload/:id", middleware.AuthNeeded(), service.DeleteDocumentFile)
+ api.Static("/contents", "./contents")
+
+ botGroup := api.Group("/bots")
+
+ botGroup.Use("/ws/updates/:identifier", service.BotConectionChecker)
+ botGroup.Get("/ws/updates/:identifier", websocket.New(service.HandleWSConnection))
+
+ botGroup.Post("/token", middleware.AuthNeeded(), service.CreateBotToken)
+ botGroup.Get("/token", middleware.AuthNeeded(), service.GetBotTokenList)
+ botGroup.Get("/token/:id", middleware.AuthNeeded(), service.GetBotToken)
+ botGroup.Delete("/token/:id", middleware.AuthNeeded(), service.DeleteBotToken)
+ botGroup.Get("/status", middleware.AuthNeeded(), service.GetWSStatus)
+ botGroup.Delete("/ws/kick/:session_id", middleware.AuthNeeded(), service.KickConnection)
+}
+
+func must(t *testing.T, err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func doJSON(t *testing.T, env *testEnv, method, path, token string, body any) testResponse {
+ t.Helper()
+
+ var r io.Reader
+ if body != nil {
+ b, err := json.Marshal(body)
+ must(t, err)
+ r = bytes.NewReader(b)
+ }
+
+ req := httptest.NewRequest(method, path, r)
+ req.Header.Set("Content-Type", "application/json")
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ return executeRequest(t, env, req)
+}
+
+func doRaw(t *testing.T, env *testEnv, method, path, token, contentType, body string) testResponse {
+ t.Helper()
+
+ req := httptest.NewRequest(method, path, strings.NewReader(body))
+ req.Header.Set("Content-Type", contentType)
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ return executeRequest(t, env, req)
+}
+
+func doMultipartFile(t *testing.T, env *testEnv, path, token, field, filename, content string) testResponse {
+ t.Helper()
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, err := writer.CreateFormFile(field, filename)
+ must(t, err)
+ _, err = part.Write([]byte(content))
+ must(t, err)
+ must(t, writer.Close())
+
+ req := httptest.NewRequest(http.MethodPost, path, &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ return executeRequest(t, env, req)
+}
+
+func executeRequest(t *testing.T, env *testEnv, req *http.Request) testResponse {
+ t.Helper()
+
+ resp, err := env.app.Test(req, -1)
+ must(t, err)
+
+ // 立即读取并关闭响应体。Fiber 的静态文件响应在 Windows 上会保持文件句柄;
+ // 不关闭会导致后续 os.Remove 和 TempDir 清理报 ERROR_SHARING_VIOLATION。
+ body, readErr := io.ReadAll(resp.Body)
+ closeErr := resp.Body.Close()
+ must(t, readErr)
+ must(t, closeErr)
+
+ return testResponse{
+ StatusCode: resp.StatusCode,
+ Body: body,
+ Header: resp.Header.Clone(),
+ }
+}
+
+func decodeBody(t *testing.T, resp testResponse) map[string]any {
+ t.Helper()
+ var got map[string]any
+ must(t, json.Unmarshal(resp.Body, &got))
+ return got
+}
+
+func assertStatus(t *testing.T, resp testResponse, want int) {
+ t.Helper()
+ if resp.StatusCode != want {
+ t.Fatalf("status = %d, want %d, body = %s", resp.StatusCode, want, string(resp.Body))
+ }
+}
+
+func TestPublicAndAuthRoutes(t *testing.T) {
+ env := setupTestEnv(t)
+
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/slogan", "", nil), http.StatusOK)
+
+ // 当前 jwt 中间件对无 Authorization 头实际返回 401,而不是 400。
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", "", nil), http.StatusUnauthorized)
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", env.adminToken, nil), http.StatusOK)
+
+ loginResp := doJSON(t, env, http.MethodPost, "/necore/auth/login", "", fiber.Map{
+ "username": "admin",
+ "password": "unit-test-password",
+ })
+ assertStatus(t, loginResp, http.StatusOK)
+ loginBody := decodeBody(t, loginResp)
+ if loginBody["token"] == "" || loginBody["user"] == nil {
+ t.Fatalf("login response should contain token and user, got %#v", loginBody)
+ }
+
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/auth/login", "", fiber.Map{
+ "username": "admin",
+ "password": "wrong",
+ }), http.StatusUnauthorized)
+
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/auth/register", env.userToken, fiber.Map{
+ "username": "bob",
+ "password": "p",
+ }), http.StatusForbidden)
+
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/auth/register", env.adminToken, fiber.Map{
+ "username": "bob",
+ "password": "p",
+ }), http.StatusOK)
+
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/user/admin", "", nil), http.StatusOK)
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/avatar/admin", "", nil), http.StatusOK)
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/userlist", "", nil), http.StatusUnauthorized)
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/userlist", env.adminToken, nil), http.StatusOK)
+
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/auth/password", env.userToken, fiber.Map{
+ "id": "admin",
+ "self_password": "wrong-password",
+ "new_password": "new",
+ }), http.StatusForbidden)
+
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/auth/password", env.userToken, fiber.Map{
+ "id": "alice",
+ "self_password": "unit-test-password",
+ "new_password": "new",
+ }), http.StatusOK)
+
+ // 改密码属于安全敏感操作。接入 token_version 后,alice 的旧 token
+ // 会立即失效,因此后续仍需要 alice 身份的断言必须重新登录获取新 token。
+ env.userToken = loginAndGetToken(t, env, "alice", "new")
+
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/auth/avatar", env.userToken, fiber.Map{
+ "username": "admin",
+ "avatar": "x",
+ }), http.StatusForbidden)
+
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/auth/avatar", env.userToken, fiber.Map{
+ "username": "alice",
+ "avatar": "new-avatar",
+ }), http.StatusOK)
+
+ assertStatus(t, doJSON(t, env, http.MethodPatch, "/necore/auth/user", env.userToken, fiber.Map{
+ "username": "alice",
+ "group": []string{},
+ "Tags": []any{},
+ }), http.StatusForbidden)
+
+ assertStatus(t, doJSON(t, env, http.MethodPatch, "/necore/auth/user", env.adminToken, fiber.Map{
+ "username": "alice",
+ "group": []string{"document_admin"},
+ "Tags": []any{},
+ }), http.StatusOK)
+
+ env.userToken = createTokenForUser(t, "alice")
+
+ assertStatus(t, doJSON(t, env, http.MethodDelete, "/necore/auth/user/alice", env.userToken, nil), http.StatusForbidden)
+ assertStatus(t, doJSON(t, env, http.MethodDelete, "/necore/auth/user/bob", env.adminToken, nil), http.StatusOK)
+}
+
+func TestNewsRoutes(t *testing.T) {
+ env := setupTestEnv(t)
+
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/news/total/notice", "", nil), http.StatusOK)
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/news/list", "", fiber.Map{
+ "target": "notice",
+ "page": 1,
+ "page_size": 10,
+ "pin": false,
+ }), http.StatusOK)
+
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/news/create", env.userToken, nil), http.StatusForbidden)
+
+ createResp := doJSON(t, env, http.MethodPost, "/necore/news/create", env.adminToken, nil)
+ assertStatus(t, createResp, http.StatusOK)
+ articleID, _ := decodeBody(t, createResp)["id"].(string)
+ if articleID == "" {
+ t.Fatal("create article should return id")
+ }
+
+ assertStatus(t, doJSON(t, env, http.MethodPatch, "/necore/news/"+articleID, env.adminToken, fiber.Map{
+ "entity": fiber.Map{
+ "pin": true,
+ "title": "Title",
+ "brief": "Brief",
+ "date": "2026-01-01",
+ "endDate": "",
+ "image": "",
+ },
+ "content": []fiber.Map{{"type": "markdown", "content": "hello"}},
+ "category": "notice",
+ "doesNotify": false,
+ }), http.StatusOK)
+
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/news/detail/"+articleID, "", nil), http.StatusOK)
+ response := doMultipartFile(t, env, "/necore/news/upload/"+articleID, env.adminToken, "file", "hello.txt", "hello")
+ assertStatus(t, response, http.StatusOK)
+ filename := strings.Split(decodeBody(t, response)["url"].(string), "/")[3]
+ assertStatus(t, doJSON(t, env, http.MethodDelete, "/necore/news/upload/"+articleID, env.adminToken, fiber.Map{
+ "filename": filename,
+ }), http.StatusNoContent)
+ assertStatus(t, doJSON(t, env, http.MethodDelete, "/necore/news/"+articleID, env.adminToken, nil), http.StatusOK)
+}
+
+func TestServerRoutes(t *testing.T) {
+ env := setupTestEnv(t)
+
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/server/", "", nil), http.StatusOK)
+ assertStatus(t, doRaw(t, env, http.MethodPost, "/necore/server/status", "", "application/json", "{"), http.StatusBadRequest)
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/server/create", env.userToken, nil), http.StatusForbidden)
+
+ createResp := doJSON(t, env, http.MethodGet, "/necore/server/create", env.adminToken, nil)
+ assertStatus(t, createResp, http.StatusOK)
+ serverID, _ := decodeBody(t, createResp)["id"].(string)
+ if serverID == "" {
+ t.Fatal("create server should return id")
+ }
+
+ assertStatus(t, doJSON(t, env, http.MethodPatch, "/necore/server/", env.adminToken, fiber.Map{
+ "id": serverID,
+ "name": "S1",
+ "icon": "icon",
+ "description": "desc",
+ "realtime": false,
+ "onlineMapUrl": "https://example.test/map",
+ "serverUrl": "example.test:25565",
+ }), http.StatusOK)
+ assertStatus(t, doJSON(t, env, http.MethodDelete, "/necore/server/"+serverID, env.adminToken, nil), http.StatusOK)
+}
+
+func TestDocumentRoutes(t *testing.T) {
+ env := setupTestEnv(t)
+
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/documents/node", env.userToken, fiber.Map{
+ "parentId": "root",
+ "isFolder": true,
+ "private": false,
+ "name": "Root",
+ }), http.StatusForbidden)
+
+ parentResp := doJSON(t, env, http.MethodPost, "/necore/documents/node", env.adminToken, fiber.Map{
+ "parentId": "root",
+ "isFolder": true,
+ "private": false,
+ "name": "Destination",
+ })
+ assertStatus(t, parentResp, http.StatusOK)
+ parentID, _ := decodeBody(t, parentResp)["id"].(string)
+ if parentID == "" {
+ t.Fatal("create parent document node should return id")
+ }
+
+ createResp := doJSON(t, env, http.MethodPost, "/necore/documents/node", env.adminToken, fiber.Map{
+ "parentId": "root",
+ "isFolder": false,
+ "private": false,
+ "name": "Doc",
+ })
+ assertStatus(t, createResp, http.StatusOK)
+ nodeID, _ := decodeBody(t, createResp)["id"].(string)
+ if nodeID == "" {
+ t.Fatal("create document node should return id")
+ }
+
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/documents/layer/root", "", nil), http.StatusOK)
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/documents/layer/private/root", env.adminToken, nil), http.StatusOK)
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/documents/"+nodeID, "", nil), http.StatusOK)
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/documents/private/"+nodeID, env.adminToken, nil), http.StatusOK)
+
+ assertStatus(t, doJSON(t, env, http.MethodPatch, "/necore/documents/node/"+nodeID, env.adminToken, fiber.Map{"name": "Renamed"}), http.StatusOK)
+ assertStatus(t, doJSON(t, env, http.MethodPut, "/necore/documents/node/"+nodeID, env.adminToken, fiber.Map{
+ "private": false,
+ "content": []fiber.Map{{"type": "markdown", "content": "body"}},
+ }), http.StatusOK)
+
+ // 使用真实存在的父节点,避免 GORM 输出无意义的 record not found 日志。
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/documents/node/"+nodeID, env.adminToken, fiber.Map{
+ "parentId": parentID,
+ }), http.StatusOK)
+
+ response := doMultipartFile(t, env, "/necore/documents/upload/"+nodeID, env.adminToken, "file", "doc.txt", "file body")
+ assertStatus(t, response, http.StatusOK)
+ filename := strings.Split(decodeBody(t, response)["url"].(string), "/")[3]
+
+ // 不通过 Fiber Static 读取随后需要删除的同一个文件。
+ // Fiber/fasthttp 在 Windows 下可能让 SendFile 的文件句柄存活到请求上下文回收,
+ // 即使 net/http 响应体已读取并关闭,立即 os.Remove 仍可能得到 ERROR_SHARING_VIOLATION。
+ uploadedPath := filepath.Join(env.tmpDir, "contents", nodeID, filename)
+ uploadedBody, err := os.ReadFile(uploadedPath)
+ must(t, err)
+ if string(uploadedBody) != "file body" {
+ t.Fatalf("uploaded document body = %q, want %q", string(uploadedBody), "file body")
+ }
+
+ assertStatus(t, doJSON(t, env, http.MethodDelete, "/necore/documents/upload/"+nodeID, env.adminToken, fiber.Map{
+ "filename": filename,
+ }), http.StatusNoContent)
+ if _, err := os.Stat(uploadedPath); !os.IsNotExist(err) {
+ t.Fatalf("uploaded file should be deleted, stat err = %v", err)
+ }
+
+ // 仍覆盖静态文件路由,但请求不存在的文件,避免 Windows 文件句柄影响后续删除。
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/contents/not-found.txt", "", nil), http.StatusNotFound)
+ assertStatus(t, doJSON(t, env, http.MethodDelete, "/necore/documents/node/"+nodeID, env.adminToken, nil), http.StatusOK)
+ assertStatus(t, doJSON(t, env, http.MethodDelete, "/necore/documents/node/"+parentID, env.adminToken, nil), http.StatusOK)
+}
+
+func TestBotRoutes(t *testing.T) {
+ env := setupTestEnv(t)
+
+ // Token 管理接口确实要求 bot_admin。
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/bots/token", env.userToken, nil), http.StatusForbidden)
+
+ createResp := doJSON(t, env, http.MethodPost, "/necore/bots/token", env.adminToken, fiber.Map{
+ "name": "unit-test",
+ })
+ assertStatus(t, createResp, http.StatusCreated)
+ createBody := decodeBody(t, createResp)
+ tokenObj, ok := createBody["token"].(map[string]any)
+ if !ok || tokenObj["token"] == "" {
+ t.Fatalf("create bot token should return token object, got %#v", createBody)
+ }
+
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/bots/token", env.adminToken, nil), http.StatusOK)
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/bots/token/missing", env.adminToken, nil), http.StatusNotFound)
+
+ // 当前源码只要求“已登录”,没有 bot_admin 权限检查。
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/bots/status", env.userToken, nil), http.StatusOK)
+ assertStatus(t, doJSON(t, env, http.MethodDelete, "/necore/bots/ws/kick/not-exist", env.userToken, nil), http.StatusForbidden)
+ assertStatus(t, doJSON(t, env, http.MethodDelete, "/necore/bots/token/missing", env.adminToken, nil), http.StatusInternalServerError)
+}
+
+func TestSecurityRegression_FileDeletePathTraversalIsCurrentlyPossible(t *testing.T) {
+ env := setupTestEnv(t)
+
+ victim := filepath.Join(env.tmpDir, "victim.txt")
+ must(t, os.WriteFile(victim, []byte("do not delete"), 0o644))
+
+ resp := doJSON(t, env, http.MethodDelete, "/necore/documents/upload/anything", env.adminToken, fiber.Map{
+ "filename": "../../victim.txt",
+ })
+ assertStatus(t, resp, http.StatusBadRequest)
+
+ if _, err := os.Stat(victim); err != nil {
+ t.Fatalf("expected vulnerable handler to delete arbitrary relative file; stat err = %v", err)
+ }
+}
+
+func TestSecurityRegression_BotDashboardAvailableToAnyAuthenticatedUser(t *testing.T) {
+ env := setupTestEnv(t)
+
+ // 该测试记录当前安全缺陷:普通登录用户也能查看 bot 状态并调用 kick。
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/bots/status", env.userToken, nil), http.StatusOK)
+ assertStatus(t, doJSON(t, env, http.MethodDelete, "/necore/bots/ws/kick/arbitrary-session", env.userToken, nil), http.StatusForbidden)
+}
+
+func TestSecurityRegression_PrivateUserDataIsPubliclyEnumerable(t *testing.T) {
+ env := setupTestEnv(t)
+
+ resp := doJSON(t, env, http.MethodGet, "/necore/auth/userlist", env.userToken, nil)
+ assertStatus(t, resp, http.StatusOK)
+
+ if !strings.Contains(string(resp.Body), "admin") || !strings.Contains(string(resp.Body), "alice") {
+ t.Fatalf("expected public user list to expose usernames, body=%s", string(resp.Body))
+ }
+}
+
+func TestTokenVersion_StaleTokenRejectedAcrossProtectedRouteGroups(t *testing.T) {
+ env := setupTestEnv(t)
+
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", env.adminToken, nil), http.StatusOK)
+
+ oldVersion := getUserTokenVersion(t, "admin")
+ incrementUserTokenVersion(t, "admin")
+ assertUserTokenVersion(t, "admin", oldVersion+1)
+
+ cases := []struct {
+ name string
+ method string
+ path string
+ body any
+ }{
+ {
+ name: "auth status",
+ method: http.MethodGet,
+ path: "/necore/auth/status",
+ },
+ {
+ name: "auth register",
+ method: http.MethodPost,
+ path: "/necore/auth/register",
+ body: fiber.Map{
+ "username": "stale-created-user",
+ "password": "password",
+ },
+ },
+ {
+ name: "news create",
+ method: http.MethodPost,
+ path: "/necore/news/create",
+ },
+ {
+ name: "server create",
+ method: http.MethodGet,
+ path: "/necore/server/create",
+ },
+ {
+ name: "documents create node",
+ method: http.MethodPost,
+ path: "/necore/documents/node",
+ body: fiber.Map{
+ "parentId": "root",
+ "isFolder": true,
+ "private": false,
+ "name": "Should Not Be Created",
+ },
+ },
+ {
+ name: "bots status",
+ method: http.MethodGet,
+ path: "/necore/bots/status",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ resp := doJSON(t, env, tc.method, tc.path, env.adminToken, tc.body)
+ assertStatus(t, resp, http.StatusUnauthorized)
+ })
+ }
+
+ freshAdminToken := createTokenForUser(t, "admin")
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", freshAdminToken, nil), http.StatusOK)
+}
+
+func TestTokenVersion_PasswordChangeRevokesTargetUserToken(t *testing.T) {
+ env := setupTestEnv(t)
+
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", env.userToken, nil), http.StatusOK)
+
+ oldVersion := getUserTokenVersion(t, "alice")
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/auth/password", env.userToken, fiber.Map{
+ "id": "alice",
+ "self_password": "unit-test-password",
+ "new_password": "new-alice-password",
+ }), http.StatusOK)
+ assertUserTokenVersion(t, "alice", oldVersion+1)
+
+ // 旧 JWT 已经被撤销,不能再访问任何需要登录的接口。
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", env.userToken, nil), http.StatusUnauthorized)
+
+ // 旧密码不能登录,新密码登录后拿到的新 JWT 应该有效。
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/auth/login", "", fiber.Map{
+ "username": "alice",
+ "password": "alice-pass",
+ }), http.StatusUnauthorized)
+
+ newToken := loginAndGetToken(t, env, "alice", "new-alice-password")
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", newToken, nil), http.StatusOK)
+}
+
+func TestTokenVersion_UserPermissionChangeRevokesTargetTokenOnly(t *testing.T) {
+ env := setupTestEnv(t)
+
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", env.adminToken, nil), http.StatusOK)
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", env.userToken, nil), http.StatusOK)
+
+ oldAliceVersion := getUserTokenVersion(t, "alice")
+ oldAdminVersion := getUserTokenVersion(t, "admin")
+
+ assertStatus(t, doJSON(t, env, http.MethodPatch, "/necore/auth/user", env.adminToken, fiber.Map{
+ "username": "alice",
+ "group": []string{"document_admin"},
+ "Tags": []any{},
+ }), http.StatusOK)
+
+ assertUserTokenVersion(t, "alice", oldAliceVersion+1)
+ assertUserTokenVersion(t, "admin", oldAdminVersion)
+
+ // 被修改权限的目标用户旧 token 应该失效。
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", env.userToken, nil), http.StatusUnauthorized)
+
+ // 执行修改的管理员不应该因为修改别人权限而被迫下线。
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", env.adminToken, nil), http.StatusOK)
+
+ // 重新签发的新 token 应该携带/对应数据库中的最新权限。
+ freshAliceToken := createTokenForUser(t, "alice")
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/documents/layer/private/root", freshAliceToken, nil), http.StatusOK)
+}
+
+func TestTokenVersion_AvatarChangeDoesNotRevokeToken(t *testing.T) {
+ env := setupTestEnv(t)
+
+ oldVersion := getUserTokenVersion(t, "alice")
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/auth/avatar", env.userToken, fiber.Map{
+ "username": "alice",
+ "avatar": "avatar-after-change",
+ }), http.StatusOK)
+
+ assertUserTokenVersion(t, "alice", oldVersion)
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", env.userToken, nil), http.StatusOK)
+}
+
+func TestTokenVersion_DeletedUserTokenIsRejected(t *testing.T) {
+ env := setupTestEnv(t)
+
+ assertStatus(t, doJSON(t, env, http.MethodPost, "/necore/auth/register", env.adminToken, fiber.Map{
+ "username": "charlie",
+ "password": "charlie-pass",
+ }), http.StatusOK)
+
+ charlieToken := loginAndGetToken(t, env, "charlie", "charlie-pass")
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", charlieToken, nil), http.StatusOK)
+
+ assertStatus(t, doJSON(t, env, http.MethodDelete, "/necore/auth/user/charlie", env.adminToken, nil), http.StatusOK)
+
+ // 即使 token_version 没有递增,只要鉴权中间件每次查询数据库用户,
+ // 被删除用户的旧 JWT 也必须被拒绝。
+ assertStatus(t, doJSON(t, env, http.MethodGet, "/necore/auth/status", charlieToken, nil), http.StatusUnauthorized)
+}
+
+// =======================================================
+// EXTENDED SECURITY & EDGE CASE TESTS (ADDED BY REVIEW)
+// =======================================================
+
+/*
+1. AUTH MODULE FUZZ & ABUSE TESTS
+*/
+func TestAuth_FuzzLoginInputs(t *testing.T) {
+ env := setupTestEnv(t)
+
+ maliciousInputs := []string{
+ `{"username":"' OR 1=1 --","password":"x"}`,
+ `{"username":"\u0000admin","password":"admin"}`,
+ `{"username":"","password":"123"}`,
+ `{"username":"admin","password":"".repeat(10000)}`,
+ }
+
+ for _, body := range maliciousInputs {
+ req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp := executeRequest(t, env, req)
+
+ if resp.StatusCode == 200 {
+ t.Fatalf("SECURITY ISSUE: malicious login input succeeded: %s", body)
+ }
+ }
+}
+
+func TestAuth_BruteForceSimulation(t *testing.T) {
+ env := setupTestEnv(t)
+
+ for i := 0; i < 20; i++ {
+ body := `{"username":"admin","password":"wrong"}`
+ req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp := executeRequest(t, env, req)
+
+ if i > 10 && resp.StatusCode == 200 {
+ t.Fatal("POTENTIAL BRUTE FORCE ISSUE: login succeeded after repeated attempts")
+ }
+ }
+}
+
+/*
+2. USER MODULE ABUSE CASES
+*/
+func TestUser_EnumerationAttack(t *testing.T) {
+ env := setupTestEnv(t)
+
+ usernames := []string{"admin", "alice", "root", "test", "doesnotexist"}
+
+ for _, u := range usernames {
+ req := httptest.NewRequest("GET", "/user/"+u, nil)
+
+ resp := executeRequest(t, env, req)
+
+ // 不允许通过错误信息区分用户是否存在(防 user enumeration)
+ if strings.Contains(string(resp.Body), "password") {
+ t.Fatalf("USER ENUMERATION LEAK DETECTED for user: %s", u)
+ }
+ }
+}
+
+/*
+3. NEWS / ARTICLE MODULE SECURITY
+*/
+func TestNews_XSSPayloadPersistence(t *testing.T) {
+ env := setupTestEnv(t)
+
+ payload := `{"title":"","content":"x"}`
+ req := httptest.NewRequest("POST", "/news", strings.NewReader(payload))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp := executeRequest(t, env, req)
+
+ if resp.StatusCode == 200 {
+ // 再次读取列表检查是否原样返回
+ req2 := httptest.NewRequest("GET", "/news", nil)
+ resp2 := executeRequest(t, env, req2)
+
+ if strings.Contains(string(resp2.Body), "