diff --git a/backend/character/handlers.go b/backend/character/handlers.go index 37794c9..66855fb 100644 --- a/backend/character/handlers.go +++ b/backend/character/handlers.go @@ -30,6 +30,17 @@ func respondWithError(c *gin.Context, status int, message string) { c.JSON(status, gin.H{"error": message}) } +// checkCharacterOwnership verifies that the logged-in user owns the character +func checkCharacterOwnership(c *gin.Context, character *models.Char) bool { + userID := c.GetUint("userID") + if character.UserID != userID { + logger.Warn("Unauthorized access attempt: user %d tried to modify character %d owned by user %d", userID, character.ID, character.UserID) + respondWithError(c, http.StatusForbidden, "You are not authorized to modify this character") + return false + } + return true +} + func ListCharacters(c *gin.Context) { logger.Debug("ListCharacters aufgerufen") @@ -59,6 +70,14 @@ func ListCharacters(c *gin.Context) { respondWithError(c, http.StatusInternalServerError, "Failed to retrieve public characters") return } + listShared, err := models.FindSharedCharList(c.GetUint("userID")) + if err != nil { + logger.Error("Fehler beim Laden der geteilten Charaktere: %s", err.Error()) + respondWithError(c, http.StatusInternalServerError, "Failed to retrieve shared characters") + return + } + listPublic = append(listPublic, listShared...) + allCharacters.Others = listPublic logger.Info("Charakterliste erfolgreich geladen: %d Charaktere", len(listOfChars)) @@ -101,6 +120,11 @@ func UpdateCharacter(c *gin.Context) { return } + // Check ownership + if !checkCharacterOwnership(c, &character) { + return + } + // Store the original ID to preserve it originalID := character.ID originalGameSystem := character.GameSystem @@ -133,6 +157,12 @@ func DeleteCharacter(c *gin.Context) { respondWithError(c, http.StatusNotFound, "Character not found") return } + + // Check ownership + if !checkCharacterOwnership(c, &character) { + return + } + err = character.Delete() if err != nil { respondWithError(c, http.StatusInternalServerError, "Failed to delete character") @@ -267,6 +297,11 @@ func UpdateCharacterExperience(c *gin.Context) { return } + // Check ownership + if !checkCharacterOwnership(c, &character) { + return + } + // Parse Request var req UpdateExperienceRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -357,6 +392,11 @@ func UpdateCharacterWealth(c *gin.Context) { return } + // Check ownership + if !checkCharacterOwnership(c, &character) { + return + } + // Parse Request var req UpdateWealthRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -567,6 +607,11 @@ func LearnSkill(c *gin.Context) { return } + // Check ownership + if !checkCharacterOwnership(c, &character) { + return + } + // Verwende gsmaster.LernCostRequest direkt var request gsmaster.LernCostRequest if err := c.ShouldBindJSON(&request); err != nil { @@ -1052,6 +1097,11 @@ func ImproveSkill(c *gin.Context) { return } + // Check ownership + if !checkCharacterOwnership(c, char) { + return + } + // 2. Skill validieren und Level ermitteln characterClass, skillInfo, currentLevel, err := validateSkillForImprovement(char, &request) if err != nil { @@ -1225,6 +1275,18 @@ func LearnSpell(c *gin.Context) { } charID := uint(charIDInt) + // Load character to check ownership + var character models.Char + if err := character.FirstID(char_ID); err != nil { + respondWithError(c, http.StatusNotFound, "Charakter nicht gefunden") + return + } + + // Check ownership + if !checkCharacterOwnership(c, &character) { + return + } + var lernRequest gsmaster.LernCostRequest if err := c.ShouldBindJSON(&lernRequest); err != nil { respondWithError(c, http.StatusBadRequest, "Ungültige Anfrageparameter: "+err.Error()) diff --git a/backend/character/image_handler.go b/backend/character/image_handler.go index b88b6da..f79bc86 100644 --- a/backend/character/image_handler.go +++ b/backend/character/image_handler.go @@ -25,6 +25,11 @@ func UpdateCharacterImage(c *gin.Context) { return } + // Check ownership + if !checkCharacterOwnership(c, &character) { + return + } + var request ImageUpdateRequest if err := c.ShouldBindJSON(&request); err != nil { logger.Error("Invalid request data: %s", err.Error()) diff --git a/backend/character/ownership_guard_test.go b/backend/character/ownership_guard_test.go new file mode 100644 index 0000000..48fcf57 --- /dev/null +++ b/backend/character/ownership_guard_test.go @@ -0,0 +1,339 @@ +package character + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "bamort/database" + "bamort/models" + "bamort/testutils" + "bamort/user" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestOwnershipGuardsBlockMutations(t *testing.T) { + testutils.SetupTestEnvironment(t) + gin.SetMode(gin.TestMode) + + database.SetupTestDB(true, true) + t.Cleanup(database.ResetTestDB) + + require.NoError(t, models.MigrateStructure()) + + t.Run("UpdateCharacter blocks non-owner", func(t *testing.T) { + owner := ensureUserExists(t, 101) + char := createCharacterOwnedBy(t, owner.UserID) + originalName := char.Name + + ctx, w := buildJSONContext(t, http.MethodPut, map[string]any{"name": "New Name"}, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)}) + + UpdateCharacter(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) + reloaded := reloadCharacter(t, char.ID) + require.Equal(t, originalName, reloaded.Name) + }) + + t.Run("DeleteCharacter blocks non-owner", func(t *testing.T) { + owner := ensureUserExists(t, 102) + char := createCharacterOwnedBy(t, owner.UserID) + + ctx, w := buildJSONContext(t, http.MethodDelete, nil, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)}) + + DeleteCharacter(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) + var exists models.Char + err := database.DB.First(&exists, char.ID).Error + require.NoError(t, err) + }) + + t.Run("UpdateCharacterExperience blocks non-owner", func(t *testing.T) { + owner := ensureUserExists(t, 103) + char := createCharacterOwnedBy(t, owner.UserID) + seedExperience(t, char, 25) + + ctx, w := buildJSONContext(t, http.MethodPost, map[string]any{"experience_points": 50}, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)}) + + UpdateCharacterExperience(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) + reloaded := reloadCharacterWithPreloads(t, char.ID) + require.Equal(t, 25, reloaded.Erfahrungsschatz.EP) + }) + + t.Run("UpdateCharacterWealth blocks non-owner", func(t *testing.T) { + owner := ensureUserExists(t, 104) + char := createCharacterOwnedBy(t, owner.UserID) + seedWealth(t, char, 5, 4, 3) + + ctx, w := buildJSONContext(t, http.MethodPost, map[string]any{"goldstücke": 50}, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)}) + + UpdateCharacterWealth(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) + reloaded := reloadCharacterWithPreloads(t, char.ID) + require.Equal(t, 5, reloaded.Vermoegen.Goldstuecke) + require.Equal(t, 4, reloaded.Vermoegen.Silberstuecke) + require.Equal(t, 3, reloaded.Vermoegen.Kupferstuecke) + }) + + t.Run("LearnSkill blocks non-owner", func(t *testing.T) { + owner := ensureUserExists(t, 105) + char := createCharacterOwnedBy(t, owner.UserID) + before := countSkills(t, char.ID) + + ctx, w := buildJSONContext(t, http.MethodPost, map[string]any{"name": "Test Skill", "target_level": 1, "type": "skill"}, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)}) + + LearnSkill(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) + after := countSkills(t, char.ID) + require.Equal(t, before, after) + }) + + t.Run("ImproveSkill blocks non-owner", func(t *testing.T) { + owner := ensureUserExists(t, 106) + char := createCharacterOwnedBy(t, owner.UserID) + seedSkill(t, char, "Athletik", 1, 0) + before := countSkills(t, char.ID) + + payload := map[string]any{ + "char_id": char.ID, + "name": "Athletik", + "current_level": 1, + "target_level": 2, + "type": "skill", + "action": "improve", + "reward": "default", + } + ctx, w := buildJSONContext(t, http.MethodPost, payload, char.UserID+1, nil) + + ImproveSkill(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) + after := countSkills(t, char.ID) + require.Equal(t, before, after) + }) + + t.Run("LearnSpell blocks non-owner", func(t *testing.T) { + owner := ensureUserExists(t, 107) + char := createCharacterOwnedBy(t, owner.UserID) + before := countSpells(t, char.ID) + + ctx, w := buildJSONContext(t, http.MethodPost, map[string]any{"name": "Test Spell"}, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)}) + + LearnSpell(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) + after := countSpells(t, char.ID) + require.Equal(t, before, after) + }) + + t.Run("UpdatePracticePoints blocks non-owner", func(t *testing.T) { + owner := ensureUserExists(t, 108) + char := createCharacterOwnedBy(t, owner.UserID) + seedSkill(t, char, "Menschenkenntnis", 1, 2) + before := fetchSkillPp(t, char.ID, "Menschenkenntnis") + + payload := []map[string]any{{"skill_name": "Menschenkenntnis", "amount": 0}} + ctx, w := buildJSONContext(t, http.MethodPost, payload, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)}) + + UpdatePracticePoints(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) + after := fetchSkillPp(t, char.ID, "Menschenkenntnis") + require.Equal(t, before, after) + }) + + t.Run("AddPracticePoint blocks non-owner", func(t *testing.T) { + owner := ensureUserExists(t, 109) + char := createCharacterOwnedBy(t, owner.UserID) + seedSkill(t, char, "Athletik", 1, 1) + before := fetchSkillPp(t, char.ID, "Athletik") + + payload := map[string]any{"skill_name": "Athletik", "amount": 3} + ctx, w := buildJSONContext(t, http.MethodPost, payload, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)}) + + AddPracticePoint(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) + after := fetchSkillPp(t, char.ID, "Athletik") + require.Equal(t, before, after) + }) + + t.Run("UsePracticePoint blocks non-owner", func(t *testing.T) { + owner := ensureUserExists(t, 110) + char := createCharacterOwnedBy(t, owner.UserID) + seedSkill(t, char, "Athletik", 1, 2) + before := fetchSkillPp(t, char.ID, "Athletik") + + payload := map[string]any{"skill_name": "Athletik", "amount": 1} + ctx, w := buildJSONContext(t, http.MethodPost, payload, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)}) + + UsePracticePoint(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) + after := fetchSkillPp(t, char.ID, "Athletik") + require.Equal(t, before, after) + }) + + t.Run("UpdateCharacterShares blocks non-owner", func(t *testing.T) { + owner := ensureUserExists(t, 111) + shareTarget := ensureUserExists(t, 112) + char := createCharacterOwnedBy(t, owner.UserID) + + payload := map[string]any{"user_ids": []uint{shareTarget.UserID}} + ctx, w := buildJSONContext(t, http.MethodPost, payload, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)}) + + UpdateCharacterShares(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) + var shares []models.CharShare + err := database.DB.Where("character_id = ?", char.ID).Find(&shares).Error + require.NoError(t, err) + require.Empty(t, shares) + }) + + t.Run("UpdateCharacterImage blocks non-owner", func(t *testing.T) { + owner := ensureUserExists(t, 113) + char := createCharacterOwnedBy(t, owner.UserID) + char.Image = "initial.png" + require.NoError(t, database.DB.Save(&char).Error) + + ctx, w := buildJSONContext(t, http.MethodPost, map[string]any{"image": "new.png"}, char.UserID+1, map[string]string{"id": fmt.Sprint(char.ID)}) + + UpdateCharacterImage(ctx) + + require.Equal(t, http.StatusForbidden, w.Code) + reloaded := reloadCharacter(t, char.ID) + require.Equal(t, "initial.png", reloaded.Image) + }) +} + +func ensureUserExists(t *testing.T, id uint) user.User { + var existing user.User + err := database.DB.First(&existing, "user_id = ?", id).Error + if err == nil { + return existing + } + + if err != nil && err != gorm.ErrRecordNotFound { + require.NoError(t, err) + } + + newUser := user.User{ + UserID: id, + Username: fmt.Sprintf("user-%d-%d", id, time.Now().UnixNano()), + Email: fmt.Sprintf("user-%d@example.com", id), + Role: user.RoleStandardUser, + } + require.NoError(t, database.DB.Create(&newUser).Error) + return newUser +} + +func createCharacterOwnedBy(t *testing.T, ownerID uint) models.Char { + char := models.Char{ + BamortBase: models.BamortBase{Name: fmt.Sprintf("Char-%d", time.Now().UnixNano())}, + UserID: ownerID, + Typ: "Krieger", + Rasse: "Mensch", + Grad: 1, + } + require.NoError(t, database.DB.Create(&char).Error) + return char +} + +func seedExperience(t *testing.T, char models.Char, ep int) { + exp := models.Erfahrungsschatz{ + BamortCharTrait: models.BamortCharTrait{CharacterID: char.ID, UserID: char.UserID}, + EP: ep, + } + require.NoError(t, database.DB.Create(&exp).Error) +} + +func seedWealth(t *testing.T, char models.Char, gold, silver, copper int) { + wealth := models.Vermoegen{ + BamortCharTrait: models.BamortCharTrait{CharacterID: char.ID, UserID: char.UserID}, + Goldstuecke: gold, + Silberstuecke: silver, + Kupferstuecke: copper, + } + require.NoError(t, database.DB.Create(&wealth).Error) +} + +func seedSkill(t *testing.T, char models.Char, name string, level, pp int) { + skill := models.SkFertigkeit{ + BamortCharTrait: models.BamortCharTrait{ + BamortBase: models.BamortBase{Name: name}, + CharacterID: char.ID, + UserID: char.UserID, + }, + Fertigkeitswert: level, + Pp: pp, + Improvable: true, + Category: "Test", + } + require.NoError(t, database.DB.Create(&skill).Error) +} + +func countSkills(t *testing.T, charID uint) int { + var skills []models.SkFertigkeit + require.NoError(t, database.DB.Where("character_id = ?", charID).Find(&skills).Error) + return len(skills) +} + +func countSpells(t *testing.T, charID uint) int { + var spells []models.SkZauber + require.NoError(t, database.DB.Where("character_id = ?", charID).Find(&spells).Error) + return len(spells) +} + +func fetchSkillPp(t *testing.T, charID uint, name string) int { + var skill models.SkFertigkeit + err := database.DB.Where("character_id = ? AND name = ?", charID, name).First(&skill).Error + require.NoError(t, err) + return skill.Pp +} + +func reloadCharacter(t *testing.T, id uint) models.Char { + var char models.Char + require.NoError(t, database.DB.First(&char, id).Error) + return char +} + +func reloadCharacterWithPreloads(t *testing.T, id uint) models.Char { + var char models.Char + require.NoError(t, database.DB.Preload("Erfahrungsschatz").Preload("Vermoegen").First(&char, id).Error) + return char +} + +func buildJSONContext(t *testing.T, method string, body any, userID uint, params map[string]string) (*gin.Context, *httptest.ResponseRecorder) { + var buf bytes.Buffer + if body != nil { + require.NoError(t, json.NewEncoder(&buf).Encode(body)) + } + + req, err := http.NewRequest(method, "/", &buf) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request = req + ctx.Set("userID", userID) + + for k, v := range params { + ctx.Params = append(ctx.Params, gin.Param{Key: k, Value: v}) + } + + return ctx, w +} diff --git a/backend/character/practice_points_handler.go b/backend/character/practice_points_handler.go index 5c3d41a..714dca0 100644 --- a/backend/character/practice_points_handler.go +++ b/backend/character/practice_points_handler.go @@ -63,6 +63,11 @@ func UpdatePracticePoints(c *gin.Context) { return } + // Check ownership + if !checkCharacterOwnership(c, &character) { + return + } + // Request-Parameter abrufen var practicePoints []PracticePointResponse if err := c.ShouldBindJSON(&practicePoints); err != nil { @@ -120,6 +125,11 @@ func AddPracticePoint(c *gin.Context) { return } + // Check ownership + if !checkCharacterOwnership(c, &character) { + return + } + // Request-Parameter abrufen type AddPPRequest struct { SkillName string `json:"skill_name" binding:"required"` @@ -218,6 +228,11 @@ func UsePracticePoint(c *gin.Context) { return } + // Check ownership + if !checkCharacterOwnership(c, &character) { + return + } + // Request-Parameter abrufen type UsePPRequest struct { SkillName string `json:"skill_name" binding:"required"` diff --git a/backend/character/routes.go b/backend/character/routes.go index bc39338..9604c8f 100644 --- a/backend/character/routes.go +++ b/backend/character/routes.go @@ -10,10 +10,17 @@ func RegisterRoutes(r *gin.RouterGroup) { charGrp.POST("", CreateCharacter) charGrp.GET("/:id", GetCharacter) charGrp.PUT("/:id", UpdateCharacter) + charGrp.PATCH("/:id", UpdateCharacter) + charGrp.DELETE("/:id", DeleteCharacter) charGrp.PUT("/:id/image", UpdateCharacterImage) charGrp.GET("/:id/datasheet-options", GetDatasheetOptions) + // Character Sharing + charGrp.GET("/:id/shares", GetCharacterShares) + charGrp.PUT("/:id/shares", UpdateCharacterShares) + charGrp.GET("/:id/available-users", GetAvailableUsersForSharing) + // Erfahrung und Vermögen charGrp.GET("/:id/experience-wealth", GetCharacterExperienceAndWealth) // NewSystem charGrp.PUT("/:id/experience", UpdateCharacterExperience) // NewSystem diff --git a/backend/character/share_handlers.go b/backend/character/share_handlers.go new file mode 100644 index 0000000..2c326cd --- /dev/null +++ b/backend/character/share_handlers.go @@ -0,0 +1,146 @@ +package character + +import ( + "bamort/database" + "bamort/models" + "bamort/user" + "net/http" + + "github.com/gin-gonic/gin" +) + +// GetCharacterShares returns the list of users a character is shared with +func GetCharacterShares(c *gin.Context) { + charID := c.Param("id") + + var character models.Char + if err := character.FirstID(charID); err != nil { + respondWithError(c, http.StatusNotFound, "Character not found") + return + } + + // Check ownership + if !checkCharacterOwnership(c, &character) { + return + } + + var shares []models.CharShare + if err := database.DB.Where("character_id = ?", character.ID).Find(&shares).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to retrieve shares") + return + } + + // Get user details for each share + type ShareWithUser struct { + models.CharShare + Username string `json:"username"` + Email string `json:"email"` + } + + var sharesWithUsers []ShareWithUser + for _, share := range shares { + var u user.User + if err := u.FirstId(share.UserID); err == nil { + sharesWithUsers = append(sharesWithUsers, ShareWithUser{ + CharShare: share, + Username: u.Username, + Email: u.Email, + }) + } + } + + c.JSON(http.StatusOK, sharesWithUsers) +} + +// UpdateCharacterShares updates the list of users a character is shared with +func UpdateCharacterShares(c *gin.Context) { + charID := c.Param("id") + + var character models.Char + if err := character.FirstID(charID); err != nil { + respondWithError(c, http.StatusNotFound, "Character not found") + return + } + + // Check ownership + if !checkCharacterOwnership(c, &character) { + return + } + + type UpdateSharesRequest struct { + UserIDs []uint `json:"user_ids" binding:"required"` + } + + var request UpdateSharesRequest + if err := c.ShouldBindJSON(&request); err != nil { + respondWithError(c, http.StatusBadRequest, err.Error()) + return + } + + // Delete existing shares + if err := database.DB.Where("character_id = ?", character.ID).Delete(&models.CharShare{}).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to delete existing shares") + return + } + + // Create new shares + for _, userID := range request.UserIDs { + // Don't share with yourself + if userID == character.UserID { + continue + } + + share := models.CharShare{ + CharacterID: character.ID, + UserID: userID, + Permission: "read", + } + + if err := database.DB.Create(&share).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to create share") + return + } + } + + c.JSON(http.StatusOK, gin.H{"message": "Shares updated successfully"}) +} + +// GetAvailableUsersForSharing returns a list of users (excluding the owner) +func GetAvailableUsersForSharing(c *gin.Context) { + charID := c.Param("id") + + var character models.Char + if err := character.FirstID(charID); err != nil { + respondWithError(c, http.StatusNotFound, "Character not found") + return + } + + // Check ownership + if !checkCharacterOwnership(c, &character) { + return + } + + var users []user.User + if err := database.DB.Where("user_id != ?", character.UserID).Find(&users).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to retrieve users") + return + } + + // Remove sensitive data + type UserInfo struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + } + + var userInfos []UserInfo + for _, u := range users { + userInfos = append(userInfos, UserInfo{ + UserID: u.UserID, + Username: u.Username, + Email: u.Email, + }) + } + + c.JSON(http.StatusOK, userInfos) +} diff --git a/backend/config/version.go b/backend/config/version.go index dae56b2..f42b99c 100644 --- a/backend/config/version.go +++ b/backend/config/version.go @@ -1,7 +1,7 @@ package config // Version is the application version -const Version = "0.2.0" +const Version = "0.2.1" var ( // GitCommit will be set by build flags or detected at runtime diff --git a/backend/equipment/handlers.go b/backend/equipment/handlers.go index be68e2f..abe4e16 100644 --- a/backend/equipment/handlers.go +++ b/backend/equipment/handlers.go @@ -19,6 +19,21 @@ func respondWithError(c *gin.Context, status int, message string) { c.JSON(status, gin.H{"error": message}) } +// checkEquipmentOwnership verifies that the logged-in user owns the equipment's character +func checkEquipmentOwnership(c *gin.Context, characterID uint) bool { + userID := c.GetUint("userID") + var character models.Char + if err := database.DB.Select("id", "user_id").First(&character, characterID).Error; err != nil { + respondWithError(c, http.StatusNotFound, "Character not found") + return false + } + if character.UserID != userID { + respondWithError(c, http.StatusForbidden, "You are not authorized to modify this character's equipment") + return false + } + return true +} + func CreateAusruestung(c *gin.Context) { var ausruestung models.EqAusruestung if err := c.ShouldBindJSON(&ausruestung); err != nil { @@ -26,6 +41,11 @@ func CreateAusruestung(c *gin.Context) { return } + // Check ownership + if !checkEquipmentOwnership(c, ausruestung.CharacterID) { + return + } + if err := database.DB.Create(&ausruestung).Error; err != nil { respondWithError(c, http.StatusInternalServerError, "Failed to create Ausruestung") return @@ -55,6 +75,11 @@ func UpdateAusruestung(c *gin.Context) { return } + // Check ownership + if !checkEquipmentOwnership(c, ausruestung.CharacterID) { + return + } + if err := c.ShouldBindJSON(&ausruestung); err != nil { respondWithError(c, http.StatusBadRequest, err.Error()) return @@ -70,7 +95,19 @@ func UpdateAusruestung(c *gin.Context) { func DeleteAusruestung(c *gin.Context) { ausruestungID := c.Param("ausruestung_id") - if err := database.DB.Delete(&models.EqAusruestung{}, ausruestungID).Error; err != nil { + + var ausruestung models.EqAusruestung + if err := database.DB.First(&ausruestung, ausruestungID).Error; err != nil { + respondWithError(c, http.StatusNotFound, "Ausruestung not found") + return + } + + // Check ownership + if !checkEquipmentOwnership(c, ausruestung.CharacterID) { + return + } + + if err := database.DB.Delete(&ausruestung).Error; err != nil { respondWithError(c, http.StatusInternalServerError, "Failed to delete Ausruestung") return } @@ -89,6 +126,11 @@ func CreateWaffe(c *gin.Context) { return } + // Check ownership + if !checkEquipmentOwnership(c, waffe.CharacterID) { + return + } + if err := database.DB.Create(&waffe).Error; err != nil { respondWithError(c, http.StatusInternalServerError, "Failed to create Waffe") return @@ -118,6 +160,11 @@ func UpdateWaffe(c *gin.Context) { return } + // Check ownership + if !checkEquipmentOwnership(c, waffe.CharacterID) { + return + } + if err := c.ShouldBindJSON(&waffe); err != nil { respondWithError(c, http.StatusBadRequest, err.Error()) return @@ -133,7 +180,19 @@ func UpdateWaffe(c *gin.Context) { func DeleteWaffe(c *gin.Context) { waffeID := c.Param("waffe_id") - if err := database.DB.Delete(&models.EqWaffe{}, waffeID).Error; err != nil { + + var waffe models.EqWaffe + if err := database.DB.First(&waffe, waffeID).Error; err != nil { + respondWithError(c, http.StatusNotFound, "Waffe not found") + return + } + + // Check ownership + if !checkEquipmentOwnership(c, waffe.CharacterID) { + return + } + + if err := database.DB.Delete(&waffe).Error; err != nil { respondWithError(c, http.StatusInternalServerError, "Failed to delete Waffe") return } diff --git a/backend/equipment/handlers_test.go b/backend/equipment/handlers_test.go index 79f383d..5094b01 100644 --- a/backend/equipment/handlers_test.go +++ b/backend/equipment/handlers_test.go @@ -82,8 +82,8 @@ func TestCreateAusruestung(t *testing.T) { BamortBase: models.BamortBase{ Name: "Test Sword", }, - CharacterID: 1, - UserID: 1, + CharacterID: 21, + UserID: 4, }, Magisch: models.Magisch{ IstMagisch: false, @@ -111,13 +111,17 @@ func TestCreateAusruestung(t *testing.T) { { name: "Empty JSON", payload: map[string]interface{}{}, - expectedStatus: http.StatusCreated, + expectedStatus: http.StatusNotFound, shouldContain: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // u := user.User{} + // u.FirstId(1) + // token := user.GenerateToken(&u) + w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -129,9 +133,12 @@ func TestCreateAusruestung(t *testing.T) { body = *bytes.NewBuffer(jsonData) } + c.Set("userID", uint(4)) // Simulate logged-in user with ID 4 c.Request = httptest.NewRequest("POST", "/ausruestung", &body) c.Request.Header.Set("Content-Type", "application/json") + //c.Request.Header.Set("Authorization", "Bearer "+token) + CreateAusruestung(c) assert.Equal(t, tt.expectedStatus, w.Code) @@ -235,8 +242,8 @@ func TestUpdateAusruestung(t *testing.T) { BamortBase: models.BamortBase{ Name: "Original Equipment", }, - CharacterID: 123, - UserID: 1, + CharacterID: 21, + UserID: 4, }, Magisch: models.Magisch{ IstMagisch: false, @@ -297,6 +304,8 @@ func TestUpdateAusruestung(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) + c.Set("userID", uint(4)) + c.Params = gin.Params{ {Key: "ausruestung_id", Value: tt.ausruestungID}, } @@ -332,8 +341,8 @@ func TestDeleteAusruestung(t *testing.T) { BamortBase: models.BamortBase{ Name: "Equipment to Delete", }, - CharacterID: 123, - UserID: 1, + CharacterID: 21, + UserID: 4, }, Magisch: models.Magisch{ IstMagisch: false, @@ -368,14 +377,14 @@ func TestDeleteAusruestung(t *testing.T) { { name: "Non-existent Ausruestung", ausruestungID: "999", - expectedStatus: http.StatusOK, // GORM doesn't fail on deleting non-existent records - shouldContain: "deleted successfully", + expectedStatus: http.StatusNotFound, // GORM doesn't fail on deleting non-existent records + shouldContain: "Ausruestung not found", }, { name: "Invalid Ausruestung ID", ausruestungID: "invalid", - expectedStatus: http.StatusInternalServerError, - shouldContain: "error", + expectedStatus: http.StatusNotFound, + shouldContain: "Ausruestung not found", }, } @@ -383,11 +392,11 @@ func TestDeleteAusruestung(t *testing.T) { t.Run(tt.name, func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) + c.Set("userID", uint(4)) c.Params = gin.Params{ {Key: "ausruestung_id", Value: tt.ausruestungID}, } - DeleteAusruestung(c) assert.Equal(t, tt.expectedStatus, w.Code) diff --git a/backend/gsmaster/routes.go b/backend/gsmaster/routes.go index 02d8e5a..be9c905 100644 --- a/backend/gsmaster/routes.go +++ b/backend/gsmaster/routes.go @@ -8,50 +8,52 @@ import ( func RegisterRoutes(r *gin.RouterGroup) { maintGrp := r.Group("/maintenance") + + maintGrp.GET("", GetMasterData) + maintGrp.GET("/skills", GetMDSkills) + maintGrp.GET("/skills-enhanced", GetEnhancedMDSkills) // New enhanced endpoint + maintGrp.GET("/skills/:id", GetMDSkill) + maintGrp.GET("/skills-enhanced/:id", GetEnhancedMDSkill) // New enhanced endpoint + maintGrp.GET("/weaponskills", GetMDWeaponSkills) + maintGrp.GET("/weaponskills-enhanced", GetEnhancedMDWeaponSkills) // New enhanced endpoint + maintGrp.GET("/weaponskills/:id", GetMDWeaponSkill) + maintGrp.GET("/weaponskills-enhanced/:id", GetEnhancedMDWeaponSkill) // New enhanced endpoint + maintGrp.GET("/spells", GetMDSpells) + maintGrp.GET("/spells-enhanced", GetEnhancedMDSpells) // New enhanced endpoint + maintGrp.GET("/spells/:id", GetMDSpell) + maintGrp.GET("/spells-enhanced/:id", GetEnhancedMDSpell) // New enhanced endpoint + maintGrp.GET("/equipment", GetMDEquipments) + maintGrp.GET("/equipment-enhanced", GetEnhancedMDEquipment) // New enhanced endpoint + maintGrp.GET("/equipment/:id", GetMDEquipment) + maintGrp.GET("/equipment-enhanced/:id", GetEnhancedMDEquipmentItem) // New enhanced endpoint + maintGrp.GET("/weapons", GetMDWeapons) + maintGrp.GET("/weapons-enhanced", GetEnhancedMDWeapons) // New enhanced endpoint + maintGrp.GET("/weapons/:id", GetMDWeapon) + maintGrp.GET("/weapons-enhanced/:id", GetEnhancedMDWeapon) // New enhanced endpoint + maintGrp.Use(user.RequireMaintainer()) { - maintGrp.GET("", GetMasterData) - maintGrp.GET("/skills", GetMDSkills) - maintGrp.GET("/skills-enhanced", GetEnhancedMDSkills) // New enhanced endpoint maintGrp.POST("/skills-enhanced", CreateEnhancedMDSkill) // Create new skill - maintGrp.GET("/skills/:id", GetMDSkill) - maintGrp.GET("/skills-enhanced/:id", GetEnhancedMDSkill) // New enhanced endpoint maintGrp.PUT("/skills/:id", UpdateMDSkill) maintGrp.PUT("/skills-enhanced/:id", UpdateEnhancedMDSkill) // New enhanced endpoint maintGrp.POST("/skills", AddSkill) maintGrp.DELETE("/skills/:id", DeleteMDSkill) - maintGrp.GET("/weaponskills", GetMDWeaponSkills) - maintGrp.GET("/weaponskills-enhanced", GetEnhancedMDWeaponSkills) // New enhanced endpoint - maintGrp.GET("/weaponskills/:id", GetMDWeaponSkill) - maintGrp.GET("/weaponskills-enhanced/:id", GetEnhancedMDWeaponSkill) // New enhanced endpoint maintGrp.PUT("/weaponskills/:id", UpdateMDWeaponSkill) maintGrp.PUT("/weaponskills-enhanced/:id", UpdateEnhancedMDWeaponSkill) // New enhanced endpoint maintGrp.POST("/weaponskills", AddWeaponSkill) maintGrp.DELETE("/weaponskills/:id", DeleteMDWeaponSkill) - maintGrp.GET("/spells", GetMDSpells) - maintGrp.GET("/spells-enhanced", GetEnhancedMDSpells) // New enhanced endpoint - maintGrp.GET("/spells/:id", GetMDSpell) - maintGrp.GET("/spells-enhanced/:id", GetEnhancedMDSpell) // New enhanced endpoint maintGrp.PUT("/spells/:id", UpdateMDSpell) maintGrp.PUT("/spells-enhanced/:id", UpdateEnhancedMDSpell) // New enhanced endpoint maintGrp.POST("/spells", AddSpell) maintGrp.DELETE("/spells/:id", DeleteMDSpell) - maintGrp.GET("/equipment", GetMDEquipments) - maintGrp.GET("/equipment-enhanced", GetEnhancedMDEquipment) // New enhanced endpoint - maintGrp.GET("/equipment/:id", GetMDEquipment) - maintGrp.GET("/equipment-enhanced/:id", GetEnhancedMDEquipmentItem) // New enhanced endpoint maintGrp.PUT("/equipment/:id", UpdateMDEquipment) maintGrp.PUT("/equipment-enhanced/:id", UpdateEnhancedMDEquipmentItem) // New enhanced endpoint maintGrp.POST("/equipment", AddEquipment) maintGrp.DELETE("/equipment/:id", DeleteMDEquipment) - maintGrp.GET("/weapons", GetMDWeapons) - maintGrp.GET("/weapons-enhanced", GetEnhancedMDWeapons) // New enhanced endpoint - maintGrp.GET("/weapons/:id", GetMDWeapon) - maintGrp.GET("/weapons-enhanced/:id", GetEnhancedMDWeapon) // New enhanced endpoint maintGrp.PUT("/weapons/:id", UpdateMDWeapon) maintGrp.PUT("/weapons-enhanced/:id", UpdateEnhancedMDWeapon) // New enhanced endpoint maintGrp.POST("/weapons", AddWeapon) diff --git a/backend/maintenance/handlers.go b/backend/maintenance/handlers.go index c43974c..b151d38 100644 --- a/backend/maintenance/handlers.go +++ b/backend/maintenance/handlers.go @@ -295,6 +295,9 @@ func copyMariaDBToSQLite(mariaDB, sqliteDB *gorm.DB) error { // Audit Logging (abhängig von Char) &models.AuditLogEntry{}, + // Char Shares (abhängig von Char und User) + &models.CharShare{}, + // View-Strukturen ohne eigene Tabellen werden nicht kopiert: // SkillLearningInfo, SpellLearningInfo, CharList, FeChar, etc. } diff --git a/backend/models/database.go b/backend/models/database.go index 2f81806..662e087 100644 --- a/backend/models/database.go +++ b/backend/models/database.go @@ -49,6 +49,7 @@ func MigrateStructure(db ...*gorm.DB) error { return nil } + func gameSystemMigrateStructure(db ...*gorm.DB) error { // Use provided DB or default to database.DB var targetDB *gorm.DB @@ -66,6 +67,7 @@ func gameSystemMigrateStructure(db ...*gorm.DB) error { } return nil } + func gsMasterMigrateStructure(db ...*gorm.DB) error { // Use provided DB or default to database.DB var targetDB *gorm.DB @@ -112,6 +114,7 @@ func characterMigrateStructure(db ...*gorm.DB) error { &Bennies{}, &Vermoegen{}, &CharacterCreationSession{}, + &CharShare{}, ) if err != nil { return err diff --git a/backend/models/model_character.go b/backend/models/model_character.go index 36cfc4c..2ae3775 100644 --- a/backend/models/model_character.go +++ b/backend/models/model_character.go @@ -255,6 +255,21 @@ func (object *Char) FindByUserID(userID uint) ([]Char, error) { return chars, nil } +func FindSharedCharList(userID uint) ([]CharList, error) { + var chars []CharList + gs := GetGameSystem(0, "midgard") + err := database.DB.Table("char_chars"). + Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, users.username as owner"). + Joins("LEFT JOIN users ON char_chars.user_id = users.user_id"). + Joins("INNER JOIN char_shares ON char_shares.character_id = char_chars.id"). + Where("char_shares.user_id = ? AND (char_chars.game_system = ? OR char_chars.game_system_id = ?)", userID, gs.Name, gs.ID). + Find(&chars).Error + if err != nil { + return nil, err + } + return chars, nil +} + func FindPublicCharList() ([]CharList, error) { var chars []CharList gs := GetGameSystem(0, "midgard") diff --git a/backend/models/model_character_share.go b/backend/models/model_character_share.go new file mode 100644 index 0000000..d09f30d --- /dev/null +++ b/backend/models/model_character_share.go @@ -0,0 +1,32 @@ +package models + +import ( + "bamort/database" + "fmt" +) + +type CharShare struct { + ID uint `gorm:"primaryKey" json:"id"` + CharacterID uint `gorm:"index" json:"character_id"` // ID of the character being shared + UserID uint `gorm:"index" json:"user_id"` // ID of the user with whom the character is shared + Permission string `json:"permission"` // Permission level (e.g., "read", "write") +} + +func (object *CharShare) TableName() string { + dbPrefix := "char" + return dbPrefix + "_" + "shares" +} + +func (object *CharShare) FirstByChar(id uint) error { + if id == 0 { + return fmt.Errorf("invalid character ID") + } + return database.DB.First(object, "character_id = ?", id).Error +} + +func (object *CharShare) FirstByUser(id uint) error { + if id == 0 { + return fmt.Errorf("invalid user ID") + } + return database.DB.First(object, "user_id = ?", id).Error +} diff --git a/backend/router/setup.go b/backend/router/setup.go index 92d0b42..81385a5 100644 --- a/backend/router/setup.go +++ b/backend/router/setup.go @@ -21,7 +21,7 @@ func SetupGin(r *gin.Engine) { r.Use(cors.New(cors.Config{ //AllowOrigins: []string{"http://localhost:3000"}, // Replace with your frontend's URL AllowOrigins: allowedOrigins, - AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, diff --git a/frontend/VERSION.md b/frontend/VERSION.md index 34e6c04..d0c68ac 100644 --- a/frontend/VERSION.md +++ b/frontend/VERSION.md @@ -1,6 +1,6 @@ # Frontend Version Management -## Current Version: 0.2.0 +## Current Version: 0.2.1 The frontend version is managed independently from the backend. diff --git a/frontend/package.json b/frontend/package.json index 896819a..78f3ca9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "bamort-frontend", - "version": "0.2.0", + "version": "0.2.1", "private": true, "license": "SEE LICENSE IN LICENSE", "type": "module", diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index f017fb5..f551688 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -210,12 +210,14 @@ a:focus { } /* Global fullwidth page class for view components */ +/* .fullwidth-page { - /*width: 100%; + width: 100%; min-height: calc(100vh - 120px); padding: 20px; - box-sizing: border-box;*/ + box-sizing: border-box; } +*/ /* Common Card Layouts */ .card { @@ -2544,7 +2546,7 @@ a:focus { } .remove-btn { - position: absolute; + /*position: absolute;*/ right: 0; top: 50%; transform: translateY(-50%); @@ -3999,10 +4001,15 @@ a:focus { padding: 2rem; text-align: center; color: white; + display: grid; + grid-template-rows: 1fr auto; + align-items: center; + justify-items: center; + row-gap: 1.5rem; } .dragon-container { - margin-bottom: 2rem; + margin-bottom: 0; } .dragon-image { diff --git a/frontend/src/components/CharacterDetails.vue b/frontend/src/components/CharacterDetails.vue index 7e1116f..275f1b0 100644 --- a/frontend/src/components/CharacterDetails.vue +++ b/frontend/src/components/CharacterDetails.vue @@ -6,6 +6,9 @@ +