added features and fixes

This commit is contained in:
2026-07-02 15:34:00 +00:00
parent eb5b7d55a4
commit 89f8381e2d
30 changed files with 1718 additions and 206 deletions
+5
View File
@@ -90,10 +90,13 @@ CREATE TABLE IF NOT EXISTS channels (
CREATE TABLE IF NOT EXISTS members (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
nickname VARCHAR(100),
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, server_id)
);
ALTER TABLE members ADD COLUMN IF NOT EXISTS nickname VARCHAR(100);
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
@@ -101,6 +104,7 @@ CREATE TABLE IF NOT EXISTS messages (
content TEXT NOT NULL,
reply_to UUID REFERENCES messages(id) ON DELETE SET NULL,
edited_at TIMESTAMPTZ,
pinned BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -204,6 +208,7 @@ ALTER TABLE webhooks DROP COLUMN IF EXISTS token;
-- Ensure reply_to column exists on messages (added after initial table creation)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS reply_to UUID REFERENCES messages(id) ON DELETE SET NULL;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS push_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+327 -9
View File
@@ -47,6 +47,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/bulk-delete", h.BulkDelete)
r.Patch("/{messageID}", h.Update)
r.Delete("/{messageID}", h.Delete)
r.Get("/pinned", h.ListPinned)
r.Put("/{messageID}/pin", h.Pin)
r.Delete("/{messageID}/pin", h.Unpin)
}
type bulkDeleteRequest struct {
@@ -135,6 +138,21 @@ type embedResponse struct {
SiteName string `json:"site_name"`
}
type reactionResponse struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
UserID string `json:"user_id"`
Emoji string `json:"emoji"`
CreatedAt string `json:"created_at"`
}
type emojiGroup struct {
Emoji string `json:"emoji"`
Count int `json:"count"`
Users []string `json:"users"`
Details []reactionResponse `json:"details"`
}
type messageResponse struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
@@ -144,8 +162,10 @@ type messageResponse struct {
Content string `json:"content"`
ReplyTo *string `json:"reply_to,omitempty"`
EditedAt *string `json:"edited_at"`
Pinned bool `json:"pinned"`
CreatedAt string `json:"created_at"`
Embeds []embedResponse `json:"embeds"`
Reactions []emojiGroup `json:"reactions"`
}
// isMember checks whether the given user is a member of the given server.
@@ -229,9 +249,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
err = h.db.QueryRowContext(r.Context(), `
INSERT INTO messages (channel_id, author_id, content, reply_to)
VALUES ($1, $2, $3, $4)
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
`, channelID, userID, req.Content, replyTo).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt,
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
)
if err != nil {
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
@@ -254,6 +274,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
msg.Reactions = make([]emojiGroup, 0)
// Broadcast MESSAGE_CREATE event via WebSocket (scoped to server)
h.hub.BroadcastToServer(serverID, gateway.Event{
@@ -355,9 +376,9 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
err := h.db.QueryRowContext(r.Context(), `
UPDATE messages SET content = $1, edited_at = NOW()
WHERE id = $2 AND author_id = $3
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
`, req.Content, messageID, userID).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt,
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -386,6 +407,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
// Re-fetch embeds after edit.
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
// Look up serverID for scoped broadcast
serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID)
@@ -482,7 +504,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
var err error
if before != "" {
rows, err = h.db.QueryContext(r.Context(), `
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
FROM messages m
JOIN users u ON m.author_id = u.id
WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
@@ -491,7 +513,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
`, channelID, before, limit)
} else {
rows, err = h.db.QueryContext(r.Context(), `
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
FROM messages m
JOIN users u ON m.author_id = u.id
WHERE m.channel_id = $1
@@ -511,7 +533,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
var editedAt sql.NullString
var createdAt sql.NullString
var replyTo sql.NullString
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt)
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt)
if err != nil {
continue
}
@@ -526,6 +548,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
}
messages = h.attachEmbeds(r.Context(), messages)
messages = h.attachReactions(r.Context(), messages)
// Reverse: query returns DESC (newest first), client expects ASC (oldest first).
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
@@ -570,6 +593,85 @@ func (h *Handler) attachEmbeds(ctx context.Context, messages []messageResponse)
return messages
}
func (h *Handler) attachReactions(ctx context.Context, messages []messageResponse) []messageResponse {
if len(messages) == 0 {
return messages
}
msgIDs := make([]string, len(messages))
msgMap := make(map[string]int)
for i, msg := range messages {
msgIDs[i] = msg.ID
msgMap[msg.ID] = i
messages[i].Reactions = make([]emojiGroup, 0)
}
placeholders := make([]string, len(msgIDs))
args := make([]interface{}, len(msgIDs))
for i, id := range msgIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
query := fmt.Sprintf(`
SELECT id, message_id, user_id, emoji, created_at::text
FROM reactions
WHERE message_id IN (%s)
ORDER BY created_at ASC
`, strings.Join(placeholders, ", "))
rows, err := h.db.QueryContext(ctx, query, args...)
if err != nil {
return messages
}
defer rows.Close()
reactionsMap := make(map[string]map[string]*emojiGroup)
emojiOrderMap := make(map[string][]string)
for rows.Next() {
var reaction reactionResponse
var createdAt sql.NullString
if err := rows.Scan(&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt); err != nil {
continue
}
reaction.CreatedAt = createdAt.String
msgID := reaction.MessageID
if _, ok := reactionsMap[msgID]; !ok {
reactionsMap[msgID] = make(map[string]*emojiGroup)
emojiOrderMap[msgID] = make([]string, 0)
}
group, ok := reactionsMap[msgID][reaction.Emoji]
if !ok {
group = &emojiGroup{
Emoji: reaction.Emoji,
Users: []string{},
Details: []reactionResponse{},
}
reactionsMap[msgID][reaction.Emoji] = group
emojiOrderMap[msgID] = append(emojiOrderMap[msgID], reaction.Emoji)
}
group.Count++
group.Users = append(group.Users, reaction.UserID)
group.Details = append(group.Details, reaction)
}
for msgID, emojisMap := range reactionsMap {
idx, ok := msgMap[msgID]
if !ok {
continue
}
order := emojiOrderMap[msgID]
for _, emoji := range order {
messages[idx].Reactions = append(messages[idx].Reactions, *emojisMap[emoji])
}
}
return messages
}
// Search searches messages in a channel using PostgreSQL full-text search.
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
@@ -593,7 +695,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text,
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text,
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank
FROM messages m
JOIN users u ON m.author_id = u.id
@@ -614,7 +716,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
var createdAt sql.NullString
var replyTo sql.NullString
var rank float64
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt, &rank)
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &rank)
if err != nil {
continue
}
@@ -629,6 +731,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
}
messages = h.attachEmbeds(r.Context(), messages)
messages = h.attachReactions(r.Context(), messages)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
@@ -673,3 +776,218 @@ func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, c
return userID, serverID, true
}
func (h *Handler) Pin(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
messageID := chi.URLParam(r, "messageID")
_, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES)
if !ok {
return
}
// Verify the message exists and belongs to this channel
var msgChannelID string
err := h.db.QueryRowContext(r.Context(), "SELECT channel_id FROM messages WHERE id = $1", messageID).Scan(&msgChannelID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if msgChannelID != channelID {
http.Error(w, `{"error":"message does not belong to this channel"}`, http.StatusBadRequest)
return
}
// Check if already pinned
var isPinned bool
err = h.db.QueryRowContext(r.Context(), "SELECT pinned FROM messages WHERE id = $1", messageID).Scan(&isPinned)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if isPinned {
http.Error(w, `{"error":"message is already pinned"}`, http.StatusBadRequest)
return
}
// Count pinned messages in this channel
var count int
err = h.db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND pinned = TRUE", channelID).Scan(&count)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if count >= 5 {
http.Error(w, `{"error":"limit reached (max 5 pinned messages per channel)"}`, http.StatusBadRequest)
return
}
// Update message
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
var replyTo sql.NullString
err = h.db.QueryRowContext(r.Context(), `
UPDATE messages SET pinned = TRUE
WHERE id = $1
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
`, messageID).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
)
if err != nil {
http.Error(w, `{"error":"failed to pin message"}`, http.StatusInternalServerError)
return
}
// Fetch author details
err = h.db.QueryRowContext(r.Context(), `
SELECT username, display_name FROM users WHERE id = $1
`, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
// Attach embeds
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
// Broadcast MESSAGE_UPDATE
h.hub.BroadcastToServer(serverID, gateway.Event{
Type: gateway.EventMessageUpdate,
Data: msg,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(msg)
}
func (h *Handler) Unpin(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
messageID := chi.URLParam(r, "messageID")
_, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES)
if !ok {
return
}
// Verify the message exists and belongs to this channel
var msgChannelID string
err := h.db.QueryRowContext(r.Context(), "SELECT channel_id FROM messages WHERE id = $1", messageID).Scan(&msgChannelID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if msgChannelID != channelID {
http.Error(w, `{"error":"message does not belong to this channel"}`, http.StatusBadRequest)
return
}
// Update message
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
var replyTo sql.NullString
err = h.db.QueryRowContext(r.Context(), `
UPDATE messages SET pinned = FALSE
WHERE id = $1
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
`, messageID).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
)
if err != nil {
http.Error(w, `{"error":"failed to unpin message"}`, http.StatusInternalServerError)
return
}
// Fetch author details
err = h.db.QueryRowContext(r.Context(), `
SELECT username, display_name FROM users WHERE id = $1
`, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
// Attach embeds
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
// Broadcast MESSAGE_UPDATE
h.hub.BroadcastToServer(serverID, gateway.Event{
Type: gateway.EventMessageUpdate,
Data: msg,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(msg)
}
func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
_, _, ok := h.requireChannelAccess(w, r, channelID, permissions.VIEW_CHANNEL)
if !ok {
return
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
FROM messages m
JOIN users u ON m.author_id = u.id
WHERE m.channel_id = $1 AND m.pinned = TRUE
ORDER BY m.created_at DESC
`, channelID)
if err != nil {
http.Error(w, `{"error":"failed to list pinned messages"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
messages := make([]messageResponse, 0)
for rows.Next() {
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
var replyTo sql.NullString
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt)
if err != nil {
continue
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
messages = append(messages, msg)
}
messages = h.attachEmbeds(r.Context(), messages)
messages = h.attachReactions(r.Context(), messages)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
}
+14
View File
@@ -68,6 +68,13 @@ func (c *Checker) GetUserPermissions(ctx context.Context, serverID string, userI
// CheckPermission reports whether the user has all the required permission bits
// in the given server. ADMINISTRATOR bypasses all checks.
func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID string, required int64) (bool, error) {
// Check if the user is the server owner.
var ownerID string
err := c.db.QueryRowContext(ctx, `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
if err == nil && ownerID == userID {
return true, nil
}
perms, err := c.GetUserPermissions(ctx, serverID, userID)
if err != nil {
return false, err
@@ -85,6 +92,13 @@ func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID s
// for a specific channel. It layers channel-level overrides on top of server role
// permissions. ADMINISTRATOR bypasses all checks.
func (c *Checker) CheckChannelPermission(ctx context.Context, serverID, userID, channelID string, required int64) (bool, error) {
// Check if the user is the server owner.
var ownerID string
err := c.db.QueryRowContext(ctx, `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
if err == nil && ownerID == userID {
return true, nil
}
perms, err := c.GetUserPermissions(ctx, serverID, userID)
if err != nil {
return false, err
+3 -3
View File
@@ -21,9 +21,9 @@ func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
}
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/{messageID}/reactions", h.Add)
r.Delete("/{messageID}/reactions/{emoji}", h.Remove)
r.Get("/{messageID}/reactions", h.List)
r.Post("/", h.Add)
r.Delete("/{emoji}", h.Remove)
r.Get("/", h.List)
}
type addReactionRequest struct {
+87 -10
View File
@@ -6,28 +6,35 @@ import (
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
"github.com/go-chi/chi/v5"
)
type MemberHandler struct {
db *sql.DB
db *sql.DB
checker *permissions.Checker
}
func NewMemberHandler(db *sql.DB) *MemberHandler {
return &MemberHandler{db: db}
return &MemberHandler{
db: db,
checker: permissions.NewChecker(db),
}
}
func (h *MemberHandler) RegisterRoutes(r chi.Router) {
r.Get("/members", h.ListMembers)
r.Patch("/members/{userID}", h.UpdateMember)
}
type memberResponse struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Avatar string `json:"avatar"`
Status string `json:"status"`
StatusText string `json:"status_text"`
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Nickname *string `json:"nickname"`
Avatar string `json:"avatar"`
Status string `json:"status"`
StatusText string `json:"status_text"`
}
func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
@@ -50,7 +57,7 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT u.id, u.username, u.display_name, COALESCE(u.avatar, ''), u.status, COALESCE(u.status_text, '')
SELECT u.id, u.username, u.display_name, m.nickname, COALESCE(u.avatar, ''), u.status, COALESCE(u.status_text, '')
FROM members m
JOIN users u ON u.id = m.user_id
WHERE m.server_id = $1
@@ -65,12 +72,82 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
members := make([]memberResponse, 0)
for rows.Next() {
var m memberResponse
if err := rows.Scan(&m.ID, &m.Username, &m.DisplayName, &m.Avatar, &m.Status, &m.StatusText); err != nil {
var nick sql.NullString
if err := rows.Scan(&m.ID, &m.Username, &m.DisplayName, &nick, &m.Avatar, &m.Status, &m.StatusText); err != nil {
continue
}
if nick.Valid {
m.Nickname = &nick.String
}
members = append(members, m)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(members)
}
type updateMemberRequest struct {
Nickname *string `json:"nickname"`
}
func (h *MemberHandler) UpdateMember(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
serverID := chi.URLParam(r, "serverID")
targetUserID := chi.URLParam(r, "userID")
var req updateMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Nickname == nil {
http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest)
return
}
// Check permissions:
// - If self, need CHANGE_NICKNAME
// - If other, need MANAGE_NICKNAMES
var requiredPerm int64
if userID == targetUserID {
requiredPerm = permissions.CHANGE_NICKNAME
} else {
requiredPerm = permissions.MANAGE_NICKNAMES
}
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, requiredPerm)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if !allowed {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
// Update nickname in the database
var nickVal sql.NullString
if *req.Nickname != "" {
nickVal = sql.NullString{String: *req.Nickname, Valid: true}
} else {
nickVal = sql.NullString{Valid: false} // NULL value clears the nickname
}
_, err = h.db.ExecContext(r.Context(), `
UPDATE members
SET nickname = $1
WHERE user_id = $2 AND server_id = $3
`, nickVal, targetUserID, serverID)
if err != nil {
http.Error(w, `{"error":"failed to update nickname"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
+9 -2
View File
@@ -80,7 +80,7 @@ type setMemberRolesRequest struct {
// Helpers
// ---------------------------------------------------------------------------
// requireManageServer checks that the caller has MANAGE_SERVER permission.
// requireManageServer checks that the caller has MANAGE_SERVER or MANAGE_ROLES permission.
// Returns false (and writes the error response) if denied.
func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool {
userID, ok := middleware.UserIDFromContext(r.Context())
@@ -89,11 +89,18 @@ func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request
return false
}
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_SERVER)
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_ROLES)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return false
}
if !allowed {
allowed, err = h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_SERVER)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return false
}
}
if !allowed {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return false
+7 -25
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
"github.com/go-chi/chi/v5"
)
@@ -81,15 +82,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
return
}
if ownerID != userID {
// Check permission
var hasPerm bool
err = h.db.QueryRowContext(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM role_permissions rp
JOIN member_roles mr ON mr.role_id = rp.role_id
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
)
`, userID, serverID).Scan(&hasPerm)
checker := permissions.NewChecker(h.db)
hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
if err != nil || !hasPerm {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
@@ -223,14 +217,8 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
return
}
if ownerID != userID {
var hasPerm bool
err = h.db.QueryRowContext(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM role_permissions rp
JOIN member_roles mr ON mr.role_id = rp.role_id
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
)
`, userID, serverID).Scan(&hasPerm)
checker := permissions.NewChecker(h.db)
hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
if err != nil || !hasPerm {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
@@ -279,14 +267,8 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
if ownerID != userID {
var hasPerm bool
err = h.db.QueryRowContext(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM role_permissions rp
JOIN member_roles mr ON mr.role_id = rp.role_id
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
)
`, userID, serverID).Scan(&hasPerm)
checker := permissions.NewChecker(h.db)
hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
if err != nil || !hasPerm {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
+7 -2
View File
@@ -119,7 +119,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"url":"/%s/%s","size":%d}`, h.bucket, objectName, info.Size)
fmt.Fprintf(w, `{"url":"/files/%s","size":%d}`, objectName, info.Size)
}
func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
@@ -153,6 +153,11 @@ func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", stat.ContentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size))
w.Header().Set("Content-Disposition", "attachment")
// Inline display for images; attachment for everything else
if strings.HasPrefix(stat.ContentType, "image/") {
w.Header().Set("Content-Disposition", "inline")
} else {
w.Header().Set("Content-Disposition", "attachment")
}
io.Copy(w, obj)
}