Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"

Expand All @@ -16,6 +18,20 @@ import (
"github.com/koopa0/koopa/internal/config"
)

// parseRateBurst reads KOOPA_RATE_BURST from the environment.
// Returns 0 (use default) if unset or invalid.
func parseRateBurst() int {
v := os.Getenv("KOOPA_RATE_BURST")
if v == "" {
return 0
}
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return 0
}
return n
}

// Server timeout configuration.
const (
readHeaderTimeout = 10 * time.Second
Expand Down Expand Up @@ -72,6 +88,7 @@ func runServe() error {
CORSOrigins: cfg.CORSOrigins,
IsDev: cfg.PostgresSSLMode == "disable",
TrustProxy: cfg.TrustProxy,
RateBurst: parseRateBurst(),
})
if err != nil {
return fmt.Errorf("creating API server: %w", err)
Expand Down
2 changes: 2 additions & 0 deletions db/migrations/000002_add_owner_id.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_sessions_owner_id;
ALTER TABLE sessions DROP COLUMN IF EXISTS owner_id;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential schema inconsistency:
Dropping the owner_id column without first removing any dependent constraints (e.g., foreign keys, triggers) may result in errors or leave the database in an inconsistent state. Ensure all dependencies are explicitly dropped before removing the column.

Recommended solution:
Add statements to drop any foreign key constraints or triggers related to owner_id prior to dropping the column:

ALTER TABLE sessions DROP CONSTRAINT IF EXISTS fk_sessions_owner_id;
-- Drop any triggers if applicable

6 changes: 6 additions & 0 deletions db/migrations/000002_add_owner_id.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Add owner_id to sessions for multi-session support.
-- Each session is owned by a user identified by a persistent uid cookie.
-- Existing sessions get empty owner_id (orphaned — invisible to new users).
ALTER TABLE sessions ADD COLUMN owner_id TEXT NOT NULL DEFAULT '';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ambiguous Default Value for NOT NULL Column

Using DEFAULT '' for a NOT NULL column (owner_id) introduces ambiguity, as empty string is not a true NULL and may complicate queries and logic for orphaned sessions. Consider using NULL (with owner_id nullable) or a more explicit orphan marker (e.g., 'orphan').

Recommended solution:

ALTER TABLE sessions ADD COLUMN owner_id TEXT DEFAULT NULL;

Or, if orphan marker is needed:

ALTER TABLE sessions ADD COLUMN owner_id TEXT NOT NULL DEFAULT 'orphan';


CREATE INDEX idx_sessions_owner_id ON sessions(owner_id, updated_at DESC);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-portable Index Definition (DESC)

The use of updated_at DESC in the index definition is not supported in all SQL dialects (e.g., SQLite). This may cause migration failures or ineffective indexing. Verify database compatibility or remove DESC if not supported.

Recommended solution:

CREATE INDEX idx_sessions_owner_id ON sessions(owner_id, updated_at);

16 changes: 12 additions & 4 deletions db/queries/sessions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,30 @@
-- Generated code will be in internal/sqlc/sessions.sql.go

-- name: CreateSession :one
INSERT INTO sessions (title)
VALUES ($1)
INSERT INTO sessions (title, owner_id)
VALUES ($1, sqlc.arg(owner_id))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent Parameter Usage in CreateSession

The CreateSession query uses both $1 and sqlc.arg(owner_id) in the VALUES clause:

INSERT INTO sessions (title, owner_id)
VALUES ($1, sqlc.arg(owner_id))

This inconsistency can lead to parameter binding errors or confusion when generating code with sqlc. It is recommended to use a consistent parameter style, such as sqlc.arg(title) and sqlc.arg(owner_id), for clarity and maintainability.

Recommended fix:

INSERT INTO sessions (title, owner_id)
VALUES (sqlc.arg(title), sqlc.arg(owner_id))

RETURNING *;

-- name: Session :one
SELECT id, title, created_at, updated_at
SELECT id, title, owner_id, created_at, updated_at
FROM sessions
WHERE id = $1;

-- name: Sessions :many
SELECT id, title, created_at, updated_at
SELECT id, title, owner_id, created_at, updated_at
FROM sessions
WHERE owner_id = sqlc.arg(owner_id)
ORDER BY updated_at DESC
LIMIT sqlc.arg(result_limit)
OFFSET sqlc.arg(result_offset);

-- name: SessionByIDAndOwner :one
-- Verify session exists and is owned by the given user.
-- Used for ownership checks without a separate query + comparison.
SELECT id, title, owner_id, created_at, updated_at
FROM sessions
WHERE id = sqlc.arg(session_id) AND owner_id = sqlc.arg(owner_id);

-- name: UpdateSessionUpdatedAt :exec
UPDATE sessions
SET updated_at = NOW()
Expand Down
55 changes: 37 additions & 18 deletions internal/api/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,20 @@ func (h *chatHandler) send(w http.ResponseWriter, r *http.Request) {
return
}

// Resolve session from context (set by session middleware from cookie)
sessionID, ok := sessionIDFromContext(r.Context())
if !ok {
WriteError(w, http.StatusBadRequest, "session_required", "session ID required", h.logger)
if req.SessionID == "" {
WriteError(w, http.StatusBadRequest, "session_required", "sessionId is required", h.logger)
return
}

// If body also specifies a session, verify it matches (defense-in-depth)
if req.SessionID != "" {
parsed, err := uuid.Parse(req.SessionID)
if err != nil {
WriteError(w, http.StatusBadRequest, "invalid_session", "invalid session ID", h.logger)
return
}
if parsed != sessionID {
WriteError(w, http.StatusForbidden, "forbidden", "session access denied", h.logger)
return
}
sessionID, err := uuid.Parse(req.SessionID)
if err != nil {
WriteError(w, http.StatusBadRequest, "invalid_session", "invalid session ID", h.logger)
return
}

if !h.sessionAccessAllowed(r, sessionID) {
WriteError(w, http.StatusForbidden, "forbidden", "session access denied", h.logger)
return
}

msgID := uuid.New().String()
Comment on lines 84 to 103

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ambiguous error reporting for session manager misconfiguration

If the session manager is misconfigured (e.g., h.sessions.store == nil), the error returned is a generic forbidden error. This could lead to ambiguous error reporting for clients and make debugging harder for maintainers.

Recommendation:
Consider logging a warning when session manager misconfiguration is detected:

if h.sessions == nil || h.sessions.store == nil {
    h.logger.Warn("session manager misconfigured", "sessionId", req.SessionID)
    WriteError(w, http.StatusForbidden, "forbidden", "session access denied", h.logger)
    return
}

Expand All @@ -118,6 +114,30 @@ func (h *chatHandler) send(w http.ResponseWriter, r *http.Request) {
}, h.logger)
}

// sessionAccessAllowed checks whether the request may access the session.
// Returns true when no session manager is configured (unit tests, CLI mode).
// When configured, verifies the session belongs to the authenticated user.
func (h *chatHandler) sessionAccessAllowed(r *http.Request, sessionID uuid.UUID) bool {
if h.sessions == nil {
return true // no session manager → allow (test/CLI mode)
}
if h.sessions.store == nil {
return false // configured but no store → deny
}

userID, ok := userIDFromContext(r.Context())
if !ok || userID == "" {
return false
}

sess, err := h.sessions.store.Session(r.Context(), sessionID)
if err != nil {
return false
}

return sess.OwnerID == userID
}

// stream handles GET /api/v1/chat/stream — SSE endpoint with JSON events.
func (h *chatHandler) stream(w http.ResponseWriter, r *http.Request) {
msgID := r.URL.Query().Get("msgId")
Expand All @@ -129,14 +149,13 @@ func (h *chatHandler) stream(w http.ResponseWriter, r *http.Request) {
return
}

// Verify session ownership
parsedID, err := uuid.Parse(sessionID)
if err != nil {
WriteError(w, http.StatusBadRequest, "invalid_session", "invalid session ID", h.logger)
return
}
ctxID, ok := sessionIDFromContext(r.Context())
if !ok || ctxID != parsedID {

if !h.sessionAccessAllowed(r, parsedID) {
WriteError(w, http.StatusForbidden, "forbidden", "session access denied", h.logger)
return
}
Comment on lines 149 to 161

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing logging for denied session access

When session access is denied in the stream handler, the code returns a forbidden error to the client but does not log the denial. This omission reduces the ability to audit and debug unauthorized access attempts.

Recommendation:
Add a log entry before returning the error:

if !h.sessionAccessAllowed(r, parsedID) {
    h.logger.Warn("session access denied", "sessionId", sessionID, "msgId", msgID)
    WriteError(w, http.StatusForbidden, "forbidden", "session access denied", h.logger)
    return
}

Expand Down
Loading
Loading