Files
EvoBGP/internal/httpapi/routes_crud.go
T
Denozordec 1e13ef9e70
CI / changes (push) Successful in 6s
CI / openapi (push) Successful in 23s
CI / go (push) Successful in 45s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m14s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m7s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 16s
CI / docker-go-prime (push) Successful in 23s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m2s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 6m21s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m32s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m26s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m26s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m8s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m25s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m28s
feat: add last refreshed timestamp to modules and CDN sources
Introduced a new field, last_refreshed_at, to track the last successful update time for modules and CDN sources. Updated the database schema, API responses, and internal logic to support this feature. Enhanced the frontend to display the last refreshed timestamp, improving visibility into the data update status for users.
2026-04-09 16:10:00 +07:00

1225 lines
36 KiB
Go

package httpapi
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"evobgp/internal/pipeline"
"evobgp/internal/store"
)
func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
m.HandleFunc("POST /modules", s.handlePostModule)
m.HandleFunc("PATCH /modules/{module_id}", s.handlePatchModule)
m.HandleFunc("DELETE /modules/{module_id}", s.handleDeleteModule)
m.HandleFunc("GET /modules/{module_id}/cdn-sources", s.handleListCDNSources)
m.HandleFunc("POST /modules/{module_id}/cdn-sources", s.handlePostCDNSource)
m.HandleFunc("POST /modules/{module_id}/cdn-sources/preview", s.handlePreviewCDNSource)
m.HandleFunc("PATCH /modules/{module_id}/cdn-sources/{source_id}", s.handlePatchCDNSource)
m.HandleFunc("DELETE /modules/{module_id}/cdn-sources/{source_id}", s.handleDeleteCDNSource)
m.HandleFunc("GET /modules/{module_id}/as-entries", s.handleListAS)
m.HandleFunc("POST /modules/{module_id}/as-entries", s.handlePostAS)
m.HandleFunc("PATCH /modules/{module_id}/as-entries/{entry_id}", s.handlePatchAS)
m.HandleFunc("DELETE /modules/{module_id}/as-entries/{entry_id}", s.handleDeleteAS)
m.HandleFunc("GET /modules/{module_id}/domain-entries", s.handleListDomain)
m.HandleFunc("POST /modules/{module_id}/domain-entries", s.handlePostDomain)
m.HandleFunc("PATCH /modules/{module_id}/domain-entries/{entry_id}", s.handlePatchDomain)
m.HandleFunc("DELETE /modules/{module_id}/domain-entries/{entry_id}", s.handleDeleteDomain)
m.HandleFunc("GET /modules/{module_id}/ip-range-entries", s.handleListIPRange)
m.HandleFunc("POST /modules/{module_id}/ip-range-entries", s.handlePostIPRange)
m.HandleFunc("PATCH /modules/{module_id}/ip-range-entries/{entry_id}", s.handlePatchIPRange)
m.HandleFunc("DELETE /modules/{module_id}/ip-range-entries/{entry_id}", s.handleDeleteIPRange)
m.HandleFunc("GET /modules/{module_id}/entries.csv", s.handleExportModuleEntriesCSV)
m.HandleFunc("POST /modules/{module_id}/entries.csv", s.handleImportModuleEntriesCSV)
m.HandleFunc("GET /doh-profiles", s.handleListDoh)
m.HandleFunc("POST /doh-profiles", s.handlePostDoh)
m.HandleFunc("GET /doh-profiles/{id}", s.handleGetDoh)
m.HandleFunc("PATCH /doh-profiles/{id}", s.handlePatchDoh)
m.HandleFunc("DELETE /doh-profiles/{id}", s.handleDeleteDoh)
m.HandleFunc("GET /communities", s.handleListComm)
m.HandleFunc("POST /communities", s.handlePostComm)
m.HandleFunc("GET /communities/{id}", s.handleGetComm)
m.HandleFunc("PATCH /communities/{id}", s.handlePatchComm)
m.HandleFunc("DELETE /communities/{id}", s.handleDeleteComm)
m.HandleFunc("POST /peers", s.handlePostPeer)
m.HandleFunc("GET /peers/{id}", s.handleGetPeer)
m.HandleFunc("PATCH /peers/{id}", s.handlePatchPeer)
m.HandleFunc("DELETE /peers/{id}", s.handleDeletePeer)
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID)
m.HandleFunc("PATCH /speakers/{speaker_id}", s.handlePatchSpeaker)
m.HandleFunc("GET /revisions/{revision_id}/prefixes", s.handleRevisionPrefixes)
m.HandleFunc("GET /settings", s.handleGetSettings)
m.HandleFunc("PATCH /settings", s.handlePatchSettings)
}
func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body struct {
Type string `json:"type"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
RefreshIntervalSec int `json:"refresh_interval_sec"`
CronExpr string `json:"cron_expr"`
DefaultCommunityID *string `json:"default_community_id"`
DohProfileID *string `json:"doh_profile_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mod, err := s.store.CreateModule(a.TenantID, &store.Module{
Type: body.Type, Name: body.Name, Enabled: body.Enabled, Priority: body.Priority,
RefreshIntervalSec: body.RefreshIntervalSec, CronExpr: body.CronExpr,
DefaultCommunityID: body.DefaultCommunityID, DohProfileID: body.DohProfileID,
})
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, moduleJSON(mod))
}
func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
rawBody, err := io.ReadAll(r.Body)
if err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid body")
return
}
var body store.ModulePatch
if err := json.Unmarshal(rawBody, &body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
// NOTE:
// In Go, unmarshalling JSON `null` into pointer fields results in nil,
// which is indistinguishable from "field omitted". For PATCH we need to
// distinguish these cases so clients can explicitly clear nullable fields.
var raw map[string]json.RawMessage
if err := json.Unmarshal(rawBody, &raw); err == nil {
if v, ok := raw["default_community_id"]; ok && string(v) == "null" {
empty := ""
body.DefaultCommunityID = &empty
}
if v, ok := raw["doh_profile_id"]; ok && string(v) == "null" {
empty := ""
body.DohProfileID = &empty
}
if v, ok := raw["cron_expr"]; ok && string(v) == "null" {
empty := ""
body.CronExpr = &empty
}
if v, ok := raw["refresh_interval_sec"]; ok && string(v) == "null" {
zero := 0
body.RefreshIntervalSec = &zero
}
}
mod, err := s.store.UpdateModule(a.TenantID, r.PathValue("module_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, moduleJSON(mod))
}
func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
if err := s.store.SoftDeleteModule(a.TenantID, r.PathValue("module_id")); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func writeStoreErr(w http.ResponseWriter, err error) {
if err == store.ErrNotFound || err == store.ErrTenantScope {
writeProblem(w, http.StatusNotFound, "Not Found", err.Error())
return
}
if err == store.ErrInvalidInput {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
return
}
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
}
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
list, err := s.store.ListCDNSources(a.TenantID, r.PathValue("module_id"))
if err != nil {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, cdnSourceJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": nil, "has_more": false})
}
func cdnSourceJSON(x *store.CDNSource) map[string]any {
m := map[string]any{"id": x.ID, "source_kind": x.SourceKind, "url": x.URL, "prefix_path": x.PrefixPath, "etag": x.Etag}
if x.RefreshIntervalSec != nil {
m["refresh_interval_sec"] = *x.RefreshIntervalSec
} else {
m["refresh_interval_sec"] = nil
}
if x.CommunityID != nil {
m["community_id"] = *x.CommunityID
} else {
m["community_id"] = nil
}
if x.LastRefreshedAt != nil {
m["last_refreshed_at"] = x.LastRefreshedAt.UTC().Format(time.RFC3339Nano)
} else {
m["last_refreshed_at"] = nil
}
return m
}
func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body struct {
URL string `json:"url"`
SourceKind string `json:"source_kind"`
PrefixPath string `json:"prefix_path"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
u := strings.TrimSpace(body.URL)
if u == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "url is required")
return
}
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
if err != nil {
writeStoreErr(w, err)
return
}
if mod.Type != "CDN_CIDRS" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "module type must be CDN_CIDRS")
return
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, u, nil)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid url")
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
writeProblem(w, http.StatusBadGateway, "Bad Gateway", fmt.Sprintf("upstream status: %s", resp.Status))
return
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error())
return
}
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
return
}
items := make([]string, 0, len(pfxs))
const previewLimit = 100
for i, p := range pfxs {
if i >= previewLimit {
break
}
items = append(items, p.String())
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"total": len(pfxs),
"truncated": len(pfxs) > previewLimit,
"source_url": u,
})
}
func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.CDNSource
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mid := r.PathValue("module_id")
x, err := s.store.CreateCDNSource(a.TenantID, mid, &body)
if err != nil {
writeStoreErr(w, err)
return
}
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_create")
writeJSON(w, http.StatusCreated, cdnSourceJSON(x))
}
func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.CDNSourcePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mid := r.PathValue("module_id")
x, err := s.store.UpdateCDNSource(a.TenantID, mid, r.PathValue("source_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_patch")
writeJSON(w, http.StatusOK, cdnSourceJSON(x))
}
func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
mid := r.PathValue("module_id")
if err := s.store.DeleteCDNSource(a.TenantID, mid, r.PathValue("source_id")); err != nil {
writeStoreErr(w, err)
return
}
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_delete")
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
list, err := s.store.ListASEntries(a.TenantID, r.PathValue("module_id"))
if err != nil {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, asEntryJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func asEntryJSON(x *store.ASEntry) map[string]any {
m := map[string]any{"id": x.ID, "asn": x.ASN}
if x.CommunityID != nil {
m["community_id"] = *x.CommunityID
} else {
m["community_id"] = nil
}
if strings.TrimSpace(x.ASNName) != "" {
m["asn_name"] = strings.TrimSpace(x.ASNName)
} else {
m["asn_name"] = nil
}
if x.PrefixCount != nil {
m["prefix_count"] = *x.PrefixCount
} else {
m["prefix_count"] = nil
}
if x.ASNResolvedAt != nil {
m["asn_resolved_at"] = x.ASNResolvedAt.UTC().Format(time.RFC3339Nano)
} else {
m["asn_resolved_at"] = nil
}
return m
}
func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.ASEntry
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mid := r.PathValue("module_id")
x, err := s.store.CreateASEntry(a.TenantID, mid, &body)
if err != nil {
writeStoreErr(w, err)
return
}
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_create")
writeJSON(w, http.StatusCreated, asEntryJSON(x))
}
func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.ASEntryPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mid := r.PathValue("module_id")
x, err := s.store.UpdateASEntry(a.TenantID, mid, r.PathValue("entry_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_patch")
writeJSON(w, http.StatusOK, asEntryJSON(x))
}
func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
mid := r.PathValue("module_id")
if err := s.store.DeleteASEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
writeStoreErr(w, err)
return
}
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_delete")
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
list, err := s.store.ListDomainEntries(a.TenantID, r.PathValue("module_id"))
if err != nil {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, domainEntryJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func domainEntryJSON(x *store.DomainEntry) map[string]any {
m := map[string]any{"id": x.ID, "fqdn": x.FQDN}
if x.CommunityID != nil {
m["community_id"] = *x.CommunityID
} else {
m["community_id"] = nil
}
return m
}
func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.DomainEntry
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mid := r.PathValue("module_id")
x, err := s.store.CreateDomainEntry(a.TenantID, mid, &body)
if err != nil {
writeStoreErr(w, err)
return
}
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_create")
writeJSON(w, http.StatusCreated, domainEntryJSON(x))
}
func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.DomainEntryPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mid := r.PathValue("module_id")
x, err := s.store.UpdateDomainEntry(a.TenantID, mid, r.PathValue("entry_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_patch")
writeJSON(w, http.StatusOK, domainEntryJSON(x))
}
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
mid := r.PathValue("module_id")
if err := s.store.DeleteDomainEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
writeStoreErr(w, err)
return
}
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_delete")
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
list, err := s.store.ListIPRangeEntries(a.TenantID, r.PathValue("module_id"))
if err != nil {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, ipRangeJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
m := map[string]any{"id": x.ID, "prefix": x.Prefix}
if x.CommunityID != nil {
m["community_id"] = *x.CommunityID
} else {
m["community_id"] = nil
}
return m
}
func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.IPRangeEntry
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mid := r.PathValue("module_id")
x, err := s.store.CreateIPRangeEntry(a.TenantID, mid, &body)
if err != nil {
writeStoreErr(w, err)
return
}
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_create")
writeJSON(w, http.StatusCreated, ipRangeJSON(x))
}
func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.IPRangePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mid := r.PathValue("module_id")
x, err := s.store.UpdateIPRangeEntry(a.TenantID, mid, r.PathValue("entry_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_patch")
writeJSON(w, http.StatusOK, ipRangeJSON(x))
}
func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
mid := r.PathValue("module_id")
if err := s.store.DeleteIPRangeEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
writeStoreErr(w, err)
return
}
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_delete")
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleExportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
moduleID := r.PathValue("module_id")
mod, err := s.store.GetModule(a.TenantID, moduleID)
if err != nil {
writeStoreErr(w, err)
return
}
communities, err := s.store.ListCommunities(a.TenantID)
if err != nil {
writeStoreErr(w, err)
return
}
communityByID := make(map[string]string, len(communities))
for _, c := range communities {
communityByID[c.ID] = strings.TrimSpace(c.Community)
}
records := make([][]string, 0, 64)
switch mod.Type {
case "AS_PREFIXES":
records = append(records, []string{"asn", "community"})
list, err := s.store.ListASEntries(a.TenantID, moduleID)
if err != nil {
writeStoreErr(w, err)
return
}
for _, x := range list {
community := ""
if x.CommunityID != nil {
community = communityByID[*x.CommunityID]
}
records = append(records, []string{strconv.FormatInt(x.ASN, 10), community})
}
case "DOMAINS":
records = append(records, []string{"domain", "community"})
list, err := s.store.ListDomainEntries(a.TenantID, moduleID)
if err != nil {
writeStoreErr(w, err)
return
}
for _, x := range list {
community := ""
if x.CommunityID != nil {
community = communityByID[*x.CommunityID]
}
records = append(records, []string{x.FQDN, community})
}
case "IP_RANGES":
records = append(records, []string{"ipRange", "community"})
list, err := s.store.ListIPRangeEntries(a.TenantID, moduleID)
if err != nil {
writeStoreErr(w, err)
return
}
for _, x := range list {
community := ""
if x.CommunityID != nil {
community = communityByID[*x.CommunityID]
}
records = append(records, []string{x.Prefix, community})
}
default:
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="module-entries.csv"`)
w.WriteHeader(http.StatusOK)
cw := csv.NewWriter(w)
for _, rec := range records {
if err := cw.Write(rec); err != nil {
return
}
}
cw.Flush()
}
func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
moduleID := r.PathValue("module_id")
mod, err := s.store.GetModule(a.TenantID, moduleID)
if err != nil {
writeStoreErr(w, err)
return
}
cr := csv.NewReader(io.LimitReader(r.Body, 8<<20))
cr.TrimLeadingSpace = true
cr.FieldsPerRecord = -1
rows, err := cr.ReadAll()
if err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid csv")
return
}
if len(rows) == 0 {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv is empty")
return
}
communities, err := s.store.ListCommunities(a.TenantID)
if err != nil {
writeStoreErr(w, err)
return
}
communityIDByID := make(map[string]string, len(communities))
communityIDByValue := make(map[string]string, len(communities))
for _, c := range communities {
communityIDByID[c.ID] = c.ID
communityIDByValue[strings.TrimSpace(c.Community)] = c.ID
}
resolveCommunity := func(raw string, required bool) (*string, error) {
v := strings.TrimSpace(raw)
if v == "" {
if required {
return nil, fmt.Errorf("community is required")
}
return nil, nil
}
if id, ok := communityIDByID[v]; ok {
return &id, nil
}
if id, ok := communityIDByValue[v]; ok {
return &id, nil
}
return nil, fmt.Errorf("unknown community %q", v)
}
start := 0
if len(rows[0]) >= 2 {
key := strings.ToLower(strings.TrimSpace(rows[0][0]))
switch key {
case "asn", "domain", "iprange":
start = 1
}
}
imported := 0
switch mod.Type {
case "AS_PREFIXES":
for i := start; i < len(rows); i++ {
rec := rows[i]
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
continue
}
if len(rec) < 2 {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
return
}
asn, err := strconv.ParseInt(strings.TrimSpace(rec[0]), 10, 64)
if err != nil || asn <= 0 {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: invalid asn", i+1))
return
}
cid, err := resolveCommunity(rec[1], false)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
return
}
_, err = s.store.CreateASEntry(a.TenantID, moduleID, &store.ASEntry{ASN: asn, CommunityID: cid})
if err != nil {
writeStoreErr(w, err)
return
}
imported++
}
if imported > 0 {
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "as_entry_import_csv")
}
case "DOMAINS":
for i := start; i < len(rows); i++ {
rec := rows[i]
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
continue
}
if len(rec) < 2 {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
return
}
fqdn := strings.TrimSpace(rec[0])
if fqdn == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: domain is required", i+1))
return
}
cid, err := resolveCommunity(rec[1], false)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
return
}
_, err = s.store.CreateDomainEntry(a.TenantID, moduleID, &store.DomainEntry{FQDN: fqdn, CommunityID: cid})
if err != nil {
writeStoreErr(w, err)
return
}
imported++
}
if imported > 0 {
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "domain_entry_import_csv")
}
case "IP_RANGES":
for i := start; i < len(rows); i++ {
rec := rows[i]
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
continue
}
if len(rec) < 2 {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
return
}
prefix := strings.TrimSpace(rec[0])
if prefix == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: ipRange is required", i+1))
return
}
cid, err := resolveCommunity(rec[1], true)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
return
}
_, err = s.store.CreateIPRangeEntry(a.TenantID, moduleID, &store.IPRangeEntry{Prefix: prefix, CommunityID: cid})
if err != nil {
writeStoreErr(w, err)
return
}
imported++
}
if imported > 0 {
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "ip_range_import_csv")
}
default:
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"imported": imported,
"module_type": mod.Type,
})
}
func (s *Server) handleListDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
list, err := s.store.ListDohProfiles(a.TenantID)
if err != nil {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, dohJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func dohJSON(x *store.DohProfile) map[string]any {
m := map[string]any{"id": x.ID, "name": x.Name, "url": x.URL}
if x.TimeoutMs != nil {
m["timeout_ms"] = *x.TimeoutMs
} else {
m["timeout_ms"] = nil
}
if x.SecretRef != nil {
m["vault_secret_ref"] = *x.SecretRef
} else {
m["vault_secret_ref"] = nil
}
return m
}
func (s *Server) handleGetDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
x, err := s.store.GetDohProfile(a.TenantID, r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, dohJSON(x))
}
func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.DohProfile
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.CreateDohProfile(a.TenantID, &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, dohJSON(x))
}
func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.DohProfilePatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.UpdateDohProfile(a.TenantID, r.PathValue("id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, dohJSON(x))
}
func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
if err := s.store.DeleteDohProfile(a.TenantID, r.PathValue("id")); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
list, err := s.store.ListCommunities(a.TenantID)
if err != nil {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, commJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func commJSON(x *store.Community) map[string]any {
var v any
if err := json.Unmarshal([]byte(x.ValueJSON), &v); err != nil {
v = x.ValueJSON
}
return map[string]any{"id": x.ID, "community": x.Community, "title": x.Title, "value_json": v}
}
func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
x, err := s.store.GetCommunity(a.TenantID, r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, commJSON(x))
}
func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.Community
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.CreateCommunity(a.TenantID, &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, commJSON(x))
}
func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.CommunityPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.UpdateCommunity(a.TenantID, r.PathValue("id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, commJSON(x))
}
func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
if err := s.store.DeleteCommunity(a.TenantID, r.PathValue("id")); err != nil {
writeStoreErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.BGPPeer
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
body.TenantID = a.TenantID
x, err := s.store.CreatePeer(a.TenantID, &body)
if err != nil {
writeStoreErr(w, err)
return
}
s.enqueuePeerReconcile(a.TenantID, "peer_create")
writeJSON(w, http.StatusCreated, peerJSON(x))
}
func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
x, err := s.store.GetPeer(a.TenantID, r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, peerJSON(x))
}
func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.PeerPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.UpdatePeer(a.TenantID, r.PathValue("id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
s.enqueuePeerReconcile(a.TenantID, "peer_patch")
writeJSON(w, http.StatusOK, peerJSON(x))
}
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
if err := s.store.DeletePeer(a.TenantID, r.PathValue("id")); err != nil {
writeStoreErr(w, err)
return
}
s.enqueuePeerReconcile(a.TenantID, "peer_delete")
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.Speaker
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.CreateSpeaker(a.TenantID, &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, speakerJSON(x))
}
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
x, err := s.store.GetSpeaker(a.TenantID, r.PathValue("speaker_id"))
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, speakerJSON(x))
}
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
return
}
var body store.SpeakerPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.UpdateSpeaker(a.TenantID, r.PathValue("speaker_id"), &body)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, speakerJSON(x))
}
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit == 0 {
limit = 50
}
cursor := r.URL.Query().Get("cursor")
rows, next, more := s.store.ListRevisionPrefixes(a.TenantID, r.PathValue("revision_id"), cursor, limit)
items := make([]map[string]any, 0, len(rows))
for _, pr := range rows {
m := map[string]any{"prefix": pr.Prefix, "source": pr.Source}
if pr.CommunityID != nil {
m["community_id"] = *pr.CommunityID
} else {
m["community_id"] = nil
}
items = append(items, m)
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": strPtrOrNull(next), "has_more": more})
}
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
m, err := s.store.ListGlobalSettings(a.TenantID)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, m)
}
func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
if raw, ok := body["revision_retention_minutes"]; ok && raw != nil {
v, ok := parseRevisionRetentionMinutes(raw)
if !ok {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "revision_retention_minutes must be an integer in range 15..43200")
return
}
body["revision_retention_minutes"] = v
}
if err := s.store.PatchGlobalSettings(a.TenantID, body); err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func parseRevisionRetentionMinutes(v any) (int, bool) {
const minMinutes = 15
const maxMinutes = 30 * 24 * 60
var out int
switch x := v.(type) {
case float64:
if x != float64(int(x)) {
return 0, false
}
out = int(x)
case int:
out = x
case int64:
out = int(x)
case string:
n, err := strconv.Atoi(strings.TrimSpace(x))
if err != nil {
return 0, false
}
out = n
default:
return 0, false
}
if out < minMinutes || out > maxMinutes {
return 0, false
}
return out, true
}