Files
terdut-server/internal/api/schedule.go
T
Niklas Ye f15db0e20a Add gosec to CI; fix the one real finding it surfaced
Part of the same security-hardening pass as the last two commits. make
lint was go vet only; govulncheck and gitleaks already scanned deps and
secrets on every push, but nothing read this repo's own source for
risky patterns (weak crypto, injection shapes, insecure cookies, ...).

New `make security-code` runs gosec, wired into ci.yaml's security job
alongside the other two. G104 (unchecked error) is excluded at the
Makefile level: every one of its 41 initial hits was this codebase's
existing, deliberate idiom for a best-effort write or an already-
reviewed json.Unmarshal of its own JSONB, predating gosec, and the rule
cannot tell that apart from a mistake -- seventeen individual #nosec
comments would hide a future real G104 regression in the suppression
noise rather than surface it. Reasoning is on the Makefile target.

Of the 12 remaining hits:
  - Genuinely real: oidc.go's callback logged error_description (and,
    two call sites down, identity.Subject) via %s before the request's
    state was even checked against its cookie -- an attacker-reachable
    value going into the log unquoted. Switched to %q, matching
    identity.Username's existing treatment, so a value holding a
    newline can't forge a second log line.
  - False positives, annotated inline rather than globally suppressed:
    4x G124 on cookies that already set Secure via cookieSecure(...)
    (a function call, not the literal `true` the rule wants), 3x G202
    on sqlArgs-built queries that only ever splice in a "$N"
    placeholder, never a value, and the remaining 5x G706 on log lines
    that were already %q-quoted -- gosec's taint analysis doesn't
    model format verbs, so it flags the tainted argument regardless.

Also fixed handleMe's swallowed Scan error (gosec's catch, pre-fix):
a transient DB error left hash/dismissed at their zero values and the
response claimed no password and no onboarding dismissal regardless
of the truth, rather than surfacing a 500.

Checked both workflow files for the injection class letsvisit found
there (a `${{ }}` expression spliced straight into a `run:` block):
every one here already goes through `env:` as a quoted shell variable,
documented in ci.yaml's own header comment. Nothing to fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 22:12:31 +02:00

265 lines
8.0 KiB
Go

package api
import (
"context"
"database/sql"
"net/http"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5"
)
// The schedule is per team: each team keeps its own rota, so two teams can have
// two different people on call on the same day. Editing it is an owner's job,
// like the rest of a team's configuration; reading it is any member's.
func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamOwner(w, r, teamID) {
return
}
var req struct {
UserID int64 `json:"user_id"`
Dates []string `json:"dates"`
// Replace takes dates that somebody else already holds. It defaults
// to off so that the plain call cannot quietly move a shift off the
// person expecting to be paged for it — reassigning has to be asked
// for.
Replace bool `json:"replace"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if req.UserID == 0 {
respond(w, http.StatusBadRequest, errResp("user_id is required"))
return
}
if len(req.Dates) == 0 {
respond(w, http.StatusBadRequest, errResp("dates must not be empty"))
return
}
for _, d := range req.Dates {
if _, err := time.Parse("2006-01-02", d); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid date: "+d+" (expected YYYY-MM-DD)"))
return
}
}
// The person taking the shift has to be in the team: paging somebody
// who cannot open the incident is worse than paging nobody.
var exists int
if err := db.QueryRowContext(r.Context(),
"SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2",
teamID, req.UserID).Scan(&exists); err != nil {
respond(w, http.StatusNotFound, errResp("user is not a member of this team"))
return
}
// All-or-nothing, in both directions: without replace, one taken date
// rejects the whole request; with it, either every date moves or none
// does. The rota must never be left with a hole where a shift used to
// be, so the delete and the insert share one transaction.
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer tx.Rollback()
for _, d := range req.Dates {
if req.Replace {
if _, err := tx.ExecContext(r.Context(),
"DELETE FROM schedule_entries WHERE team_id = $1 AND date = $2",
teamID, d); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
}
if _, err := tx.ExecContext(r.Context(),
"INSERT INTO schedule_entries (team_id, user_id, date) VALUES ($1, $2, $3)",
teamID, req.UserID, d); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict,
errResp("date already assigned: "+d+" (pass replace to take it)"))
return
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
// Return the newly created entries.
dateSet := make(map[string]bool, len(req.Dates))
for _, d := range req.Dates {
dateSet[d] = true
}
all, err := scheduleRange(r.Context(), db, teamID, req.Dates[0], req.Dates[len(req.Dates)-1])
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
created := []models.ScheduleEntry{}
for _, e := range all {
if dateSet[e.Date] {
created = append(created, e)
}
}
respond(w, http.StatusCreated, created)
}
}
func handleListSchedule(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamMember(w, r, teamID) {
return
}
q := r.URL.Query()
from, to := q.Get("from"), q.Get("to")
if from != "" {
if _, err := time.Parse("2006-01-02", from); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid from date (expected YYYY-MM-DD)"))
return
}
}
if to != "" {
if _, err := time.Parse("2006-01-02", to); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid to date (expected YYYY-MM-DD)"))
return
}
}
entries, err := scheduleRange(r.Context(), db, teamID, from, to)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, entries)
}
}
func handleDeleteSchedule(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamOwner(w, r, teamID) {
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid schedule id"))
return
}
res, err := db.ExecContext(r.Context(),
"DELETE FROM schedule_entries WHERE id = $1 AND team_id = $2", id, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("schedule entry not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleCurrentSchedule answers "who is on call right now" for every team the
// caller belongs to — one entry per team, so somebody on two rotas sees both.
// A team with nobody scheduled today simply does not appear.
func handleCurrentSchedule(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
today := time.Now().UTC().Format("2006-01-02")
rows, err := db.QueryContext(r.Context(), `
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
FROM schedule_entries s
JOIN users u ON u.id = s.user_id
JOIN teams t ON t.id = s.team_id
WHERE s.date = $1 AND s.team_id = ANY($2)
ORDER BY t.name`, today, callerTeamIDs(r.Context()))
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
entries := []models.ScheduleEntry{}
for rows.Next() {
var e models.ScheduleEntry
var ts int64
if err := rows.Scan(&e.ID, &e.TeamID, &e.TeamName, &e.UserID, &e.Username, &e.Date, &ts); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
e.CreatedAt = time.Unix(ts, 0).UTC()
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, entries)
}
}
// scheduleRange returns schedule entries ordered by date.
// from and to are YYYY-MM-DD strings; an empty string means unbounded on that side.
func scheduleRange(ctx context.Context, db *sql.DB, teamID int64, from, to string) ([]models.ScheduleEntry, error) {
args := &sqlArgs{}
where := []string{"s.team_id = " + args.add(teamID)}
if from != "" {
where = append(where, "s.date >= "+args.add(from))
}
if to != "" {
where = append(where, "s.date <= "+args.add(to))
}
clause := strings.Join(where, " AND ")
// #nosec G202 -- clause is built from sqlArgs.add's "$N" placeholders
// only, never a value; every value travels through args.all() as a
// bound parameter. See the sqlArgs doc comment in helpers.go.
rows, err := db.QueryContext(ctx, `
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
FROM schedule_entries s
JOIN users u ON u.id = s.user_id
JOIN teams t ON t.id = s.team_id
WHERE `+clause+`
ORDER BY s.date ASC`, args.all()...)
if err != nil {
return nil, err
}
defer rows.Close()
entries := []models.ScheduleEntry{}
for rows.Next() {
var e models.ScheduleEntry
var ts int64
if err := rows.Scan(&e.ID, &e.TeamID, &e.TeamName, &e.UserID, &e.Username, &e.Date, &ts); err != nil {
return nil, err
}
e.CreatedAt = time.Unix(ts, 0).UTC()
entries = append(entries, e)
}
return entries, rows.Err()
}