Files
dns-autoresolver/internal/api/schedule_handlers.go
T
vasyansk 7d4bf153d7 feat(api): CRUD расписания/каналов + тест-отправка + история проверок
Task 5 Фазы 3: GET/PUT /schedule (дефолт при отсутствии строки, валидация
interval>=60), POST/GET/DELETE /channels (секрет шифруется Cipher, никогда
не возвращается в ответах), POST /channels/{cid}/test через узкий
TestSender-интерфейс (200/502 без утечки секрета), GET /domains/{did}/history
(сначала GetDomain для project-scoping, затем ListCheckRuns — иначе IDOR
через check_runs, который сам по себе не scoped по project).

Добавлены store.GetDomain (обёртка над существующим sqlc-запросом) и
store.ListCheckRuns (новый запрос + sqlc regen) для поддержки истории.
2026-07-04 13:24:50 +07:00

277 lines
9.8 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/vasyakrg/dns-autoresolver/internal/store"
)
// ScheduleStore is the persistence surface the schedule/channels/history
// handlers depend on. *store.Store satisfies it directly via its thin
// wrapper methods (see internal/store/tenant.go, internal/store/loader.go);
// tests can supply their own mock.
type ScheduleStore interface {
GetSchedule(ctx context.Context, projectID uuid.UUID) (store.Schedule, error)
UpsertSchedule(ctx context.Context, projectID uuid.UUID, interval int32, enabled bool) (store.Schedule, error)
CreateChannel(ctx context.Context, projectID uuid.UUID, ctype string, config json.RawMessage, secretEnc string) (store.Channel, error)
ListChannels(ctx context.Context, projectID uuid.UUID) ([]store.Channel, error)
GetChannel(ctx context.Context, id, projectID uuid.UUID) (store.Channel, error)
DeleteChannel(ctx context.Context, id, projectID uuid.UUID) error
// GetDomain verifies domainID belongs to projectID — required before
// ListCheckRuns, which is not itself scoped by project.
GetDomain(ctx context.Context, id, projectID uuid.UUID) (store.Domain, error)
ListCheckRuns(ctx context.Context, domainID uuid.UUID) ([]store.CheckRun, error)
}
// TestSender sends a one-off test notification through a single
// notification channel (POST /channels/{cid}/test). It's a narrow surface
// deliberately decoupled from notify.Dispatcher (which fans a single event
// out to every enabled channel of a project by project ID, not a single
// channel by ID) — cmd/server wiring (Task 6) supplies a concrete adapter
// over internal/notify's Notifiers; tests supply a mock.
type TestSender interface {
SendTest(ctx context.Context, channelType string, config json.RawMessage, secret string) error
}
const minScheduleIntervalSeconds = 60
// defaultScheduleResponse is what GET /schedule returns when the project has
// never created a schedule row (store.GetSchedule returns pgx.ErrNoRows).
var defaultScheduleResponse = scheduleResponse{IntervalSeconds: 3600, Enabled: false}
type scheduleRequest struct {
IntervalSeconds int32 `json:"intervalSeconds"`
Enabled bool `json:"enabled"`
}
type scheduleResponse struct {
IntervalSeconds int32 `json:"intervalSeconds"`
Enabled bool `json:"enabled"`
LastRunAt *time.Time `json:"lastRunAt,omitempty"`
}
func toScheduleResponse(s store.Schedule) scheduleResponse {
return scheduleResponse{IntervalSeconds: s.IntervalSeconds, Enabled: s.Enabled, LastRunAt: s.LastRunAt}
}
// --- schedule ---
func (a *API) handleGetSchedule(w http.ResponseWriter, r *http.Request) {
// pid is guaranteed present and owned by the caller — RequireProjectAccess
// validated it before this handler ever runs.
pid, _ := projectIDFrom(r.Context())
sc, err := a.Schedule.GetSchedule(r.Context(), pid)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusOK, defaultScheduleResponse)
return
}
log.Printf("api: get schedule failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, toScheduleResponse(sc))
}
func (a *API) handlePutSchedule(w http.ResponseWriter, r *http.Request) {
// pid is guaranteed present and owned by the caller — RequireProjectAccess
// validated it before this handler ever runs.
pid, _ := projectIDFrom(r.Context())
var req scheduleRequest
if !decodeBody(w, r, &req) {
return
}
if req.IntervalSeconds < minScheduleIntervalSeconds {
writeErr(w, http.StatusBadRequest, "intervalSeconds must be >= 60")
return
}
sc, err := a.Schedule.UpsertSchedule(r.Context(), pid, req.IntervalSeconds, req.Enabled)
if err != nil {
log.Printf("api: upsert schedule failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, toScheduleResponse(sc))
}
// --- channels ---
type channelRequest struct {
Type string `json:"type"`
Config json.RawMessage `json:"config"`
Secret string `json:"secret"`
}
// channelResponse deliberately excludes the secret (plaintext or encrypted) —
// bot tokens/webhook signing keys must never reach an API response.
type channelResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Config json.RawMessage `json:"config"`
Enabled bool `json:"enabled"`
}
func toChannelResponse(c store.Channel) channelResponse {
return channelResponse{ID: c.ID.String(), Type: c.Type, Config: c.Config, Enabled: c.Enabled}
}
func (a *API) handleCreateChannel(w http.ResponseWriter, r *http.Request) {
// pid is guaranteed present and owned by the caller — RequireProjectAccess
// validated it before this handler ever runs.
pid, _ := projectIDFrom(r.Context())
var req channelRequest
if !decodeBody(w, r, &req) {
return
}
if req.Type == "" {
writeErr(w, http.StatusBadRequest, "type is required")
return
}
if len(req.Config) == 0 {
req.Config = json.RawMessage("{}")
}
secretEnc := ""
if req.Secret != "" {
enc, err := a.Cipher.Encrypt([]byte(req.Secret))
if err != nil {
log.Printf("api: encrypt channel secret failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
secretEnc = enc
}
ch, err := a.Schedule.CreateChannel(r.Context(), pid, req.Type, req.Config, secretEnc)
if err != nil {
log.Printf("api: create channel failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, toChannelResponse(ch))
}
func (a *API) handleListChannels(w http.ResponseWriter, r *http.Request) {
// pid is guaranteed present and owned by the caller — RequireProjectAccess
// validated it before this handler ever runs.
pid, _ := projectIDFrom(r.Context())
chs, err := a.Schedule.ListChannels(r.Context(), pid)
if err != nil {
log.Printf("api: list channels failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
resp := make([]channelResponse, 0, len(chs))
for _, c := range chs {
resp = append(resp, toChannelResponse(c))
}
writeJSON(w, http.StatusOK, resp)
}
func (a *API) handleDeleteChannel(w http.ResponseWriter, r *http.Request) {
// pid is guaranteed present and owned by the caller — RequireProjectAccess
// validated it before this handler ever runs.
pid, _ := projectIDFrom(r.Context())
cid, err := uuid.Parse(chi.URLParam(r, "cid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid channel id")
return
}
if err := a.Schedule.DeleteChannel(r.Context(), cid, pid); err != nil {
log.Printf("api: delete channel failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleTestChannel sends a one-off test notification through a single
// channel so a user can verify their bot_token/chat_id or webhook URL work
// before enabling the schedule. The channel's secret is decrypted only in
// memory to make the outbound call — it's never echoed back, and a failure
// from the remote channel (bad token, unreachable webhook) is reported as
// 502 without including any secret material in the error body.
func (a *API) handleTestChannel(w http.ResponseWriter, r *http.Request) {
// pid is guaranteed present and owned by the caller — RequireProjectAccess
// validated it before this handler ever runs.
pid, _ := projectIDFrom(r.Context())
cid, err := uuid.Parse(chi.URLParam(r, "cid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid channel id")
return
}
ch, err := a.Schedule.GetChannel(r.Context(), cid, pid)
if err != nil {
writeErr(w, http.StatusNotFound, "channel not found")
return
}
secret := ""
if ch.SecretEnc != "" {
dec, err := a.Cipher.Decrypt(ch.SecretEnc)
if err != nil {
log.Printf("api: decrypt channel secret failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
secret = string(dec)
}
if err := a.Dispatch.SendTest(r.Context(), ch.Type, ch.Config, secret); err != nil {
log.Printf("api: test channel %s failed: %v", cid, err)
writeErr(w, http.StatusBadGateway, "channel test failed")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// --- history ---
type checkRunResponse struct {
ID string `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Result json.RawMessage `json:"result"`
}
func toCheckRunResponse(c store.CheckRun) checkRunResponse {
return checkRunResponse{ID: c.ID.String(), CreatedAt: c.CreatedAt, Result: c.Result}
}
// handleDomainHistory returns the most recent check_runs for a domain.
// check_runs.domain_id has no project scoping of its own, so this handler
// must first confirm the domain belongs to the caller's project (GetDomain)
// before listing its history — otherwise a caller could enumerate another
// tenant's domain IDs to read their check history (IDOR).
func (a *API) handleDomainHistory(w http.ResponseWriter, r *http.Request) {
// pid is guaranteed present and owned by the caller — RequireProjectAccess
// validated it before this handler ever runs.
pid, _ := projectIDFrom(r.Context())
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
if _, err := a.Schedule.GetDomain(r.Context(), did, pid); err != nil {
writeErr(w, http.StatusNotFound, "domain not found")
return
}
runs, err := a.Schedule.ListCheckRuns(r.Context(), did)
if err != nil {
log.Printf("api: list check runs failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
resp := make([]checkRunResponse, 0, len(runs))
for _, cr := range runs {
resp = append(resp, toCheckRunResponse(cr))
}
writeJSON(w, http.StatusOK, resp)
}