• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / iznik-server-go / c3524367-60e2-4d3a-8349-b787855e8a87

10 Feb 2026 04:15PM UTC coverage: 73.028%. First build
c3524367-60e2-4d3a-8349-b787855e8a87

Pull #23

circleci

edwh
fix: Add message type validation and ownership check to message writes

C2: handleOutcome now validates that Taken is only used on Offer messages
and Received is only used on Wanted messages, matching PHP behavior.

C3: handleAddBy/handleRemoveBy now check that the caller is the message
poster or a moderator/owner of a group the message is on. Previously any
authenticated user could modify any message's "by" entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pull Request #23: feat: v2 POST /message writes

486 of 567 new or added lines in 4 files covered. (85.71%)

7359 of 10077 relevant lines covered (73.03%)

9.06 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.6
/message/message_write.go
1
package message
2

3
import (
4
        "github.com/freegle/iznik-server-go/database"
5
        "github.com/freegle/iznik-server-go/user"
6
        "github.com/freegle/iznik-server-go/utils"
7
        "github.com/gofiber/fiber/v2"
8
        "gorm.io/gorm"
9
        "time"
10
)
11

12
// PostMessageRequest handles action-based POST to /message.
13
type PostMessageRequest struct {
14
        ID       uint64  `json:"id"`
15
        Action   string  `json:"action"`
16
        Userid   *uint64 `json:"userid"`
17
        Count    *int    `json:"count"`
18
        Outcome  string  `json:"outcome"`
19
        Happiness *string `json:"happiness"`
20
        Comment  *string `json:"comment"`
21
        Message  *string `json:"message"`
22
}
23

24
// PostMessage dispatches POST /message actions.
25
func PostMessage(c *fiber.Ctx) error {
21✔
26
        myid := user.WhoAmI(c)
21✔
27
        if myid == 0 {
22✔
28
                return fiber.NewError(fiber.StatusUnauthorized, "Not logged in")
1✔
29
        }
1✔
30

31
        var req PostMessageRequest
20✔
32
        if err := c.BodyParser(&req); err != nil {
20✔
NEW
33
                return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
×
NEW
34
        }
×
35

36
        if req.ID == 0 {
21✔
37
                return fiber.NewError(fiber.StatusBadRequest, "id is required")
1✔
38
        }
1✔
39

40
        switch req.Action {
19✔
41
        case "Promise":
3✔
42
                return handlePromise(c, myid, req)
3✔
43
        case "Renege":
1✔
44
                return handleRenege(c, myid, req)
1✔
45
        case "OutcomeIntended":
2✔
46
                return handleOutcomeIntended(c, myid, req)
2✔
47
        case "Outcome":
5✔
48
                return handleOutcome(c, myid, req)
5✔
49
        case "AddBy":
3✔
50
                return handleAddBy(c, myid, req)
3✔
51
        case "RemoveBy":
2✔
52
                return handleRemoveBy(c, myid, req)
2✔
53
        case "View":
2✔
54
                return handleView(c, myid, req)
2✔
55
        default:
1✔
56
                return fiber.NewError(fiber.StatusBadRequest, "Unknown action")
1✔
57
        }
58
}
59

60
// handlePromise records a promise of an item to a user.
61
func handlePromise(c *fiber.Ctx, myid uint64, req PostMessageRequest) error {
3✔
62
        db := database.DBConn
3✔
63

3✔
64
        // Verify message exists and is owned by the current user.
3✔
65
        var msgUserid uint64
3✔
66
        db.Raw("SELECT fromuser FROM messages WHERE id = ?", req.ID).Scan(&msgUserid)
3✔
67
        if msgUserid == 0 {
4✔
68
                return fiber.NewError(fiber.StatusNotFound, "Message not found")
1✔
69
        }
1✔
70
        if msgUserid != myid {
3✔
71
                return fiber.NewError(fiber.StatusForbidden, "Not your message")
1✔
72
        }
1✔
73

74
        promisedTo := myid
1✔
75
        if req.Userid != nil && *req.Userid > 0 {
2✔
76
                promisedTo = *req.Userid
1✔
77
        }
1✔
78

79
        // REPLACE INTO - idempotent.
80
        db.Exec("REPLACE INTO messages_promises (msgid, userid) VALUES (?, ?)", req.ID, promisedTo)
1✔
81

1✔
82
        // Create a chat message of type Promised if promising to another user.
1✔
83
        if req.Userid != nil && *req.Userid > 0 && *req.Userid != myid {
2✔
84
                createSystemChatMessage(db, myid, *req.Userid, req.ID, utils.CHAT_MESSAGE_PROMISED)
1✔
85
        }
1✔
86

87
        return c.JSON(fiber.Map{"ret": 0, "status": "Success"})
1✔
88
}
89

90
// handleRenege removes a promise and records reliability data.
91
func handleRenege(c *fiber.Ctx, myid uint64, req PostMessageRequest) error {
1✔
92
        db := database.DBConn
1✔
93

1✔
94
        var msgUserid uint64
1✔
95
        db.Raw("SELECT fromuser FROM messages WHERE id = ?", req.ID).Scan(&msgUserid)
1✔
96
        if msgUserid == 0 {
1✔
NEW
97
                return fiber.NewError(fiber.StatusNotFound, "Message not found")
×
NEW
98
        }
×
99
        if msgUserid != myid {
1✔
NEW
100
                return fiber.NewError(fiber.StatusForbidden, "Not your message")
×
NEW
101
        }
×
102

103
        promisedTo := myid
1✔
104
        if req.Userid != nil && *req.Userid > 0 {
2✔
105
                promisedTo = *req.Userid
1✔
106
        }
1✔
107

108
        // Record renege for reliability tracking (only if not reneging on self).
109
        if promisedTo != myid {
2✔
110
                db.Exec("INSERT INTO messages_reneged (userid, msgid) VALUES (?, ?)", promisedTo, req.ID)
1✔
111
        }
1✔
112

113
        // Delete the promise.
114
        db.Exec("DELETE FROM messages_promises WHERE msgid = ? AND userid = ?", req.ID, promisedTo)
1✔
115

1✔
116
        // Create a chat message of type Reneged if reneging on another user.
1✔
117
        if req.Userid != nil && *req.Userid > 0 && *req.Userid != myid {
2✔
118
                createSystemChatMessage(db, myid, *req.Userid, req.ID, utils.CHAT_MESSAGE_RENEGED)
1✔
119
        }
1✔
120

121
        return c.JSON(fiber.Map{"ret": 0, "status": "Success"})
1✔
122
}
123

124
// handleOutcomeIntended records an intended outcome.
125
func handleOutcomeIntended(c *fiber.Ctx, myid uint64, req PostMessageRequest) error {
2✔
126
        db := database.DBConn
2✔
127

2✔
128
        if req.Outcome == "" {
2✔
NEW
129
                return fiber.NewError(fiber.StatusBadRequest, "outcome is required")
×
NEW
130
        }
×
131

132
        // Verify valid outcome.
133
        if req.Outcome != utils.OUTCOME_TAKEN && req.Outcome != utils.OUTCOME_RECEIVED && req.Outcome != utils.OUTCOME_WITHDRAWN {
3✔
134
                return fiber.NewError(fiber.StatusBadRequest, "Invalid outcome")
1✔
135
        }
1✔
136

137
        // Simple insert-or-update.
138
        db.Exec("INSERT INTO messages_outcomes_intended (msgid, outcome) VALUES (?, ?) ON DUPLICATE KEY UPDATE outcome = VALUES(outcome)",
1✔
139
                req.ID, req.Outcome)
1✔
140

1✔
141
        return c.JSON(fiber.Map{"ret": 0, "status": "Success"})
1✔
142
}
143

144
// handleOutcome marks a message with an outcome (Taken, Received, Withdrawn).
145
// This has complex async side effects that PHP handles via background jobs.
146
// We record the outcome in the DB and queue background processing.
147
func handleOutcome(c *fiber.Ctx, myid uint64, req PostMessageRequest) error {
5✔
148
        db := database.DBConn
5✔
149

5✔
150
        if req.Outcome == "" {
5✔
NEW
151
                return fiber.NewError(fiber.StatusBadRequest, "outcome is required")
×
NEW
152
        }
×
153

154
        if req.Outcome != utils.OUTCOME_TAKEN && req.Outcome != utils.OUTCOME_RECEIVED && req.Outcome != utils.OUTCOME_WITHDRAWN {
5✔
NEW
155
                return fiber.NewError(fiber.StatusBadRequest, "Invalid outcome")
×
NEW
156
        }
×
157

158
        // Verify message exists and get type for validation.
159
        type msgInfo struct {
5✔
160
                Fromuser uint64
5✔
161
                Type     string
5✔
162
        }
5✔
163
        var msg msgInfo
5✔
164
        db.Raw("SELECT fromuser, type FROM messages WHERE id = ?", req.ID).Scan(&msg)
5✔
165
        if msg.Fromuser == 0 {
6✔
166
                return fiber.NewError(fiber.StatusNotFound, "Message not found")
1✔
167
        }
1✔
168

169
        // Validate outcome against message type (Taken only on Offer, Received only on Wanted).
170
        if req.Outcome == utils.OUTCOME_TAKEN && msg.Type != "Offer" {
5✔
171
                return fiber.NewError(fiber.StatusBadRequest, "Taken outcome only valid for Offer messages")
1✔
172
        }
1✔
173
        if req.Outcome == utils.OUTCOME_RECEIVED && msg.Type != "Wanted" {
4✔
174
                return fiber.NewError(fiber.StatusBadRequest, "Received outcome only valid for Wanted messages")
1✔
175
        }
1✔
176

177
        // Check for existing outcome (prevent duplicates unless expired).
178
        var existingOutcome string
2✔
179
        db.Raw("SELECT outcome FROM messages_outcomes WHERE msgid = ?", req.ID).Scan(&existingOutcome)
2✔
180
        if existingOutcome != "" && existingOutcome != utils.OUTCOME_EXPIRED {
3✔
181
                return fiber.NewError(fiber.StatusConflict, "Outcome already recorded")
1✔
182
        }
1✔
183

184
        // Clear any intended outcome.
185
        db.Exec("DELETE FROM messages_outcomes_intended WHERE msgid = ?", req.ID)
1✔
186

1✔
187
        // Clear any existing outcome (for expired overwrite).
1✔
188
        db.Exec("DELETE FROM messages_outcomes WHERE msgid = ?", req.ID)
1✔
189

1✔
190
        // Record the outcome.
1✔
191
        happiness := ""
1✔
192
        if req.Happiness != nil {
2✔
193
                happiness = *req.Happiness
1✔
194
        }
1✔
195
        comment := ""
1✔
196
        if req.Comment != nil {
2✔
197
                comment = *req.Comment
1✔
198
        }
1✔
199

200
        if happiness != "" {
2✔
201
                db.Exec("INSERT INTO messages_outcomes (msgid, outcome, happiness, comments) VALUES (?, ?, ?, ?)",
1✔
202
                        req.ID, req.Outcome, happiness, comment)
1✔
203
        } else {
1✔
NEW
204
                db.Exec("INSERT INTO messages_outcomes (msgid, outcome, comments) VALUES (?, ?, ?)",
×
NEW
205
                        req.ID, req.Outcome, comment)
×
NEW
206
        }
×
207

208
        // Queue background processing for notifications/chat messages.
209
        // PHP's backgroundMark() handles: logging, chat notifications to interested users,
210
        // marking chats as up-to-date.
211
        messageForOthers := ""
1✔
212
        if req.Message != nil {
1✔
NEW
213
                messageForOthers = *req.Message
×
NEW
214
        }
×
215
        userid := uint64(0)
1✔
216
        if req.Userid != nil {
1✔
NEW
217
                userid = *req.Userid
×
NEW
218
        }
×
219

220
        db.Exec("INSERT INTO background_tasks (task_type, data) VALUES (?, JSON_OBJECT('msgid', ?, 'outcome', ?, 'happiness', ?, 'comment', ?, 'userid', ?, 'byuser', ?, 'message', ?))",
1✔
221
                "message_outcome", req.ID, req.Outcome, happiness, comment, userid, myid, messageForOthers)
1✔
222

1✔
223
        return c.JSON(fiber.Map{"ret": 0, "status": "Success"})
1✔
224
}
225

226
// canModifyMessage checks if the user is the message poster or a moderator/owner of a group the message is on.
227
func canModifyMessage(db *gorm.DB, myid uint64, msgid uint64) bool {
5✔
228
        var msgUserid uint64
5✔
229
        db.Raw("SELECT fromuser FROM messages WHERE id = ?", msgid).Scan(&msgUserid)
5✔
230
        if msgUserid == myid {
8✔
231
                return true
3✔
232
        }
3✔
233

234
        // Check if user is a moderator/owner of any group the message is on.
235
        var modCount int64
2✔
236
        db.Raw("SELECT COUNT(*) FROM messages_groups mg JOIN memberships m ON mg.groupid = m.groupid WHERE mg.msgid = ? AND m.userid = ? AND m.role IN ('Moderator', 'Owner')",
2✔
237
                msgid, myid).Scan(&modCount)
2✔
238
        return modCount > 0
2✔
239
}
240

241
// handleAddBy records who is taking items from a message.
242
func handleAddBy(c *fiber.Ctx, myid uint64, req PostMessageRequest) error {
3✔
243
        db := database.DBConn
3✔
244

3✔
245
        if !canModifyMessage(db, myid, req.ID) {
4✔
246
                return fiber.NewError(fiber.StatusForbidden, "Not allowed to modify this message")
1✔
247
        }
1✔
248

249
        count := 1
2✔
250
        if req.Count != nil {
4✔
251
                count = *req.Count
2✔
252
        }
2✔
253

254
        userid := uint64(0)
2✔
255
        if req.Userid != nil {
4✔
256
                userid = *req.Userid
2✔
257
        }
2✔
258

259
        // Check if this user already has an entry.
260
        type byEntry struct {
2✔
261
                ID    uint64
2✔
262
                Count int
2✔
263
        }
2✔
264
        var existing byEntry
2✔
265
        db.Raw("SELECT id, count FROM messages_by WHERE msgid = ? AND userid = ?",
2✔
266
                req.ID, userid).Scan(&existing)
2✔
267
        existingID := existing.ID
2✔
268
        existingCount := existing.Count
2✔
269

2✔
270
        if existingID > 0 {
3✔
271
                // Restore old count before updating.
1✔
272
                db.Exec("UPDATE messages SET availablenow = LEAST(availableinitially, availablenow + ?) WHERE id = ?",
1✔
273
                        existingCount, req.ID)
1✔
274
                db.Exec("UPDATE messages_by SET count = ? WHERE id = ?", count, existingID)
1✔
275
        } else {
2✔
276
                db.Exec("INSERT INTO messages_by (userid, msgid, count) VALUES (?, ?, ?)",
1✔
277
                        userid, req.ID, count)
1✔
278
        }
1✔
279

280
        // Reduce available count.
281
        db.Exec("UPDATE messages SET availablenow = GREATEST(LEAST(availableinitially, availablenow - ?), 0) WHERE id = ?",
2✔
282
                count, req.ID)
2✔
283

2✔
284
        return c.JSON(fiber.Map{"ret": 0, "status": "Success"})
2✔
285
}
286

287
// handleRemoveBy removes a taker and restores available count.
288
func handleRemoveBy(c *fiber.Ctx, myid uint64, req PostMessageRequest) error {
2✔
289
        db := database.DBConn
2✔
290

2✔
291
        if !canModifyMessage(db, myid, req.ID) {
3✔
292
                return fiber.NewError(fiber.StatusForbidden, "Not allowed to modify this message")
1✔
293
        }
1✔
294

295
        userid := uint64(0)
1✔
296
        if req.Userid != nil {
2✔
297
                userid = *req.Userid
1✔
298
        }
1✔
299

300
        // Find the entry.
301
        type byEntry struct {
1✔
302
                ID    uint64
1✔
303
                Count int
1✔
304
        }
1✔
305
        var entry byEntry
1✔
306
        db.Raw("SELECT id, count FROM messages_by WHERE msgid = ? AND userid = ?",
1✔
307
                req.ID, userid).Scan(&entry)
1✔
308
        entryID := entry.ID
1✔
309
        entryCount := entry.Count
1✔
310

1✔
311
        if entryID > 0 {
2✔
312
                // Restore count and delete entry.
1✔
313
                db.Exec("UPDATE messages SET availablenow = LEAST(availableinitially, availablenow + ?) WHERE id = ?",
1✔
314
                        entryCount, req.ID)
1✔
315
                db.Exec("DELETE FROM messages_by WHERE id = ?", entryID)
1✔
316
        }
1✔
317

318
        return c.JSON(fiber.Map{"ret": 0, "status": "Success"})
1✔
319
}
320

321
// handleView records a message view, de-duplicating within 30 minutes.
322
func handleView(c *fiber.Ctx, myid uint64, req PostMessageRequest) error {
2✔
323
        db := database.DBConn
2✔
324

2✔
325
        // Check for a recent view within 30 minutes to avoid redundant writes.
2✔
326
        var recentCount int64
2✔
327
        db.Raw("SELECT COUNT(*) FROM messages_likes WHERE msgid = ? AND userid = ? AND type = 'View' AND timestamp >= DATE_SUB(NOW(), INTERVAL 30 MINUTE)",
2✔
328
                req.ID, myid).Scan(&recentCount)
2✔
329

2✔
330
        if recentCount == 0 {
3✔
331
                db.Exec("INSERT INTO messages_likes (msgid, userid, type) VALUES (?, ?, 'View') ON DUPLICATE KEY UPDATE timestamp = NOW(), count = count + 1",
1✔
332
                        req.ID, myid)
1✔
333
        }
1✔
334

335
        return c.JSON(fiber.Map{"ret": 0, "status": "Success"})
2✔
336
}
337

338
// createSystemChatMessage creates a system chat message between two users for a message.
339
func createSystemChatMessage(db *gorm.DB, fromUser uint64, toUser uint64, refmsgid uint64, msgType string) {
2✔
340
        // Find or create chat room between these users about this message.
2✔
341
        var chatID uint64
2✔
342
        db.Raw("SELECT id FROM chat_rooms WHERE (user1 = ? AND user2 = ?) OR (user1 = ? AND user2 = ?) LIMIT 1",
2✔
343
                fromUser, toUser, toUser, fromUser).Scan(&chatID)
2✔
344

2✔
345
        if chatID == 0 {
2✔
NEW
346
                return
×
NEW
347
        }
×
348

349
        // Insert chat message.
350
        db.Exec("INSERT INTO chat_messages (chatid, userid, type, refmsgid, date, message, processingrequired) VALUES (?, ?, ?, ?, ?, '', 1)",
2✔
351
                chatID, fromUser, msgType, refmsgid, time.Now())
2✔
352
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc