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) для поддержки истории.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user