diff --git a/.gitignore b/.gitignore index 0e132ae..5327bce 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ necore .env .env.* !.env.example + .idea/ \ No newline at end of file diff --git a/controller/router/router.go b/controller/router/router.go index 5539752..585c4dc 100644 --- a/controller/router/router.go +++ b/controller/router/router.go @@ -97,4 +97,20 @@ func SetupRoutes() { 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) + + wikiGroup := (*router).Group("/wiki") + wikiGroup.Get("/glossary", service.GetGlossaryList) + wikiGroup.Get("/glossary/:id", service.GetGlossaryById) + wikiGroup.Get("/item", service.GetItemList) + wikiGroup.Get("/item/:id", service.GetItemById) + + wikiGroup.Post("/glossary", middleware.AuthNeeded(), service.CreateGlossary) + wikiGroup.Patch("/glossary/:id", middleware.AuthNeeded(), service.UpdateGlossary) + wikiGroup.Delete("/glossary/:id", middleware.AuthNeeded(), service.DeleteGlossary) + wikiGroup.Post("/item", middleware.AuthNeeded(), service.CreateItem) + wikiGroup.Patch("/item/:id", middleware.AuthNeeded(), service.UpdateItem) + wikiGroup.Delete("/item/:id", middleware.AuthNeeded(), service.DeleteItem) + + wikiGroup.Post("/upload/:id", middleware.AuthNeeded(), service.UploadWikiFile) + wikiGroup.Delete("/upload/:id", middleware.AuthNeeded(), service.DeleteWikiFile) } diff --git a/dao/glossary.go b/dao/glossary.go new file mode 100644 index 0000000..800d426 --- /dev/null +++ b/dao/glossary.go @@ -0,0 +1,61 @@ +package dao + +import ( + "fmt" + "necore/database" + "necore/model" +) + +func GetGlossaryList() ([]model.Glossary, error) { + db := database.GetWikiDatabase() + var list []model.Glossary + err := db.Find(&list).Error + return list, err +} + +func GetGlossaryById(id string) (model.Glossary, error) { + db := database.GetWikiDatabase() + var entry model.Glossary + err := db.Where("id = ?", id).First(&entry).Error + return entry, err +} + +func CreateGlossary(entry model.Glossary) error { + db := database.GetWikiDatabase() + return db.Create(&entry).Error +} + +func UpdateGlossary(entry model.Glossary) error { + result := database.GetWikiDatabase(). + Model(&model.Glossary{}). + Where("id = ?", entry.Id). + Updates(map[string]any{ + "name": entry.Name, + "type": entry.Type, + "gallery": entry.Gallery, + "content": entry.Content, + }) + + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("glossary not found") + } + return nil +} + +func DeleteGlossary(id string) error { + result := database.GetWikiDatabase(). + Unscoped(). + Where("id = ?", id). + Delete(&model.Glossary{}) + + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("glossary not found") + } + return nil +} diff --git a/dao/item.go b/dao/item.go new file mode 100644 index 0000000..0f44a1d --- /dev/null +++ b/dao/item.go @@ -0,0 +1,63 @@ +package dao + +import ( + "fmt" + "necore/database" + "necore/model" +) + +func GetItemList() ([]model.Item, error) { + db := database.GetWikiDatabase() + var list []model.Item + err := db.Find(&list).Error + return list, err +} + +func GetItemById(id string) (model.Item, error) { + db := database.GetWikiDatabase() + var entry model.Item + err := db.Where("id = ?", id).First(&entry).Error + return entry, err +} + +func CreateItem(entry model.Item) error { + db := database.GetWikiDatabase() + return db.Create(&entry).Error +} + +func UpdateItem(entry model.Item) error { + result := database.GetWikiDatabase(). + Model(&model.Item{}). + Where("id = ?", entry.Id). + Updates(map[string]any{ + "name": entry.Name, + "type": entry.Type, + "image": entry.Image, + "max_stack": entry.MaxStack, + "recipe": entry.Recipe, + "content": entry.Content, + }) + + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("item not found") + } + return nil +} + +func DeleteItem(id string) error { + result := database.GetWikiDatabase(). + Unscoped(). + Where("id = ?", id). + Delete(&model.Item{}) + + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("item not found") + } + return nil +} diff --git a/database/database.go b/database/database.go index 907cff3..43dbbb5 100644 --- a/database/database.go +++ b/database/database.go @@ -19,6 +19,8 @@ var documentDatabase *gorm.DB var botTokenDatabase *gorm.DB +var wikiDatabase *gorm.DB + func ConnectSqlite() { var err error userDatabase, err = gorm.Open(sqlite.Open("data/user.sqlite3"), &gorm.Config{}) @@ -50,6 +52,12 @@ func ConnectSqlite() { panic("failed to connect bot connection database") } botTokenDatabase.AutoMigrate(&model.BotToken{}) + + wikiDatabase, err = gorm.Open(sqlite.Open("data/wiki.sqlite3"), &gorm.Config{}) + if err != nil { + panic("failed to connect wiki database") + } + wikiDatabase.AutoMigrate(&model.Glossary{}, &model.Item{}) } func GetUserDatabase() *gorm.DB { @@ -71,3 +79,7 @@ func GetDocumentDatabase() *gorm.DB { func GetBotTokenDatabase() *gorm.DB { return botTokenDatabase } + +func GetWikiDatabase() *gorm.DB { + return wikiDatabase +} diff --git a/model/glossary.go b/model/glossary.go new file mode 100644 index 0000000..329761c --- /dev/null +++ b/model/glossary.go @@ -0,0 +1,13 @@ +package model + +import "gorm.io/gorm" + +type Glossary struct { + gorm.Model + + Id string `gorm:"uniqueIndex;not null" json:"id"` + Name string `gorm:"not null" json:"name"` + Type string `json:"type"` + Gallery string `json:"gallery"` + Content string `json:"content"` +} diff --git a/model/item.go b/model/item.go new file mode 100644 index 0000000..0f9f104 --- /dev/null +++ b/model/item.go @@ -0,0 +1,15 @@ +package model + +import "gorm.io/gorm" + +type Item struct { + gorm.Model + + Id string `gorm:"uniqueIndex;not null" json:"id"` + Name string `gorm:"not null" json:"name"` + Type string `json:"type"` + Image string `json:"image"` + MaxStack int `json:"maxStack"` + Recipe string `json:"recipe"` + Content string `json:"content"` +} diff --git a/service/wiki.go b/service/wiki.go new file mode 100644 index 0000000..209a24a --- /dev/null +++ b/service/wiki.go @@ -0,0 +1,261 @@ +package service + +import ( + "errors" + "fmt" + "necore/dao" + "necore/model" + "necore/util" + "os" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +func checkWikiPermission(c *fiber.Ctx) bool { + user := c.Locals("currentUser").(model.User) + isAdmin := dao.ContainsGroup(user.Group, "admin") + isDocsAdmin := dao.ContainsGroup(user.Group, "document_admin") + return isAdmin || isDocsAdmin +} + +// ---- Glossary handlers ---- + +func GetGlossaryList(c *fiber.Ctx) error { + list, err := dao.GetGlossaryList() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + if list == nil { + list = make([]model.Glossary, 0) + } + return c.JSON(fiber.Map{ + "glossaries": list, + }) +} + +func GetGlossaryById(c *fiber.Ctx) error { + entry, err := dao.GetGlossaryById(c.Params("id")) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "glossary not found", + }) + } + return c.JSON(entry) +} + +func CreateGlossary(c *fiber.Ctx) error { + if !checkWikiPermission(c) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "You don't have permission to create glossary", + }) + } + id := uuid.New().String() + entry := model.Glossary{Id: id} + if err := c.BodyParser(&entry); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + entry.Id = id + if err := dao.CreateGlossary(entry); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + return c.JSON(fiber.Map{ + "id": id, + }) +} + +func UpdateGlossary(c *fiber.Ctx) error { + if !checkWikiPermission(c) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "You don't have permission to update glossary", + }) + } + var entry model.Glossary + if err := c.BodyParser(&entry); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + entry.Id = c.Params("id") + if err := dao.UpdateGlossary(entry); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + return c.SendStatus(fiber.StatusOK) +} + +func DeleteGlossary(c *fiber.Ctx) error { + if !checkWikiPermission(c) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "You don't have permission to delete glossary", + }) + } + if err := dao.DeleteGlossary(c.Params("id")); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + return c.SendStatus(fiber.StatusOK) +} + +// ---- Item handlers ---- + +func GetItemList(c *fiber.Ctx) error { + list, err := dao.GetItemList() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + if list == nil { + list = make([]model.Item, 0) + } + return c.JSON(fiber.Map{ + "items": list, + }) +} + +func GetItemById(c *fiber.Ctx) error { + entry, err := dao.GetItemById(c.Params("id")) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "item not found", + }) + } + return c.JSON(entry) +} + +func CreateItem(c *fiber.Ctx) error { + if !checkWikiPermission(c) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "You don't have permission to create item", + }) + } + id := uuid.New().String() + entry := model.Item{Id: id} + if err := c.BodyParser(&entry); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + entry.Id = id + if err := dao.CreateItem(entry); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + return c.JSON(fiber.Map{ + "id": id, + }) +} + +func UpdateItem(c *fiber.Ctx) error { + if !checkWikiPermission(c) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "You don't have permission to update item", + }) + } + var entry model.Item + if err := c.BodyParser(&entry); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request", + }) + } + entry.Id = c.Params("id") + if err := dao.UpdateItem(entry); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + return c.SendStatus(fiber.StatusOK) +} + +func DeleteItem(c *fiber.Ctx) error { + if !checkWikiPermission(c) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "You don't have permission to delete item", + }) + } + if err := dao.DeleteItem(c.Params("id")); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + return c.SendStatus(fiber.StatusOK) +} + +// ---- File upload for wiki ---- + +func generateWikiStoredFilename(originalName string) (string, error) { + return generateStoredFilename(originalName) +} + +func UploadWikiFile(c *fiber.Ctx) error { + if !checkWikiPermission(c) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "Forbidden", + }) + } + id := c.Params("id") + file, err := c.FormFile("file") + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + dir := fmt.Sprintf("./contents/wiki/%s", id) + if err := os.MkdirAll(dir, 0o750); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + storedName, err := generateWikiStoredFilename(file.Filename) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + contentPath, err := util.SafeContentPath("./contents/wiki", id, storedName) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + if err := c.SaveFile(file, contentPath); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"url": fmt.Sprintf("/contents/wiki/%s/%s", id, storedName)}) +} + +func DeleteWikiFile(c *fiber.Ctx) error { + if !checkWikiPermission(c) { + return c.Status(fiber.StatusForbidden). + JSON(fiber.Map{"error": "Forbidden"}) + } + id := c.Params("id") + type Payload struct { + Filename string `json:"filename"` + } + var payload Payload + if err := c.BodyParser(&payload); err != nil { + return c.Status(fiber.StatusBadRequest). + JSON(fiber.Map{"error": "Invalid request body"}) + } + target, err := util.SafeContentPath("./contents/wiki", id, payload.Filename) + if err != nil { + return c.Status(fiber.StatusBadRequest). + JSON(fiber.Map{"error": "Invalid filename"}) + } + if err := os.Remove(target); err != nil { + if errors.Is(err, os.ErrNotExist) { + return c.Status(fiber.StatusNotFound). + JSON(fiber.Map{"error": "File not found"}) + } + return c.Status(fiber.StatusInternalServerError). + JSON(fiber.Map{"error": "Internal server error"}) + } + return c.SendStatus(fiber.StatusNoContent) +} + +// generateStoredFilename is shared from article.go in the same package.