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), "