7d4bf153d7
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) для поддержки истории.
277 lines
9.8 KiB
Go
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)
|
|
}
|