f15db0e20a
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>
265 lines
8.0 KiB
Go
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()
|
|
}
|