feat: implement CDN source preview functionality and enhance data handling. Add a new endpoint for previewing CDN sources, allowing users to fetch and parse CIDR prefixes from specified URLs. Update OpenAPI documentation to include new request and response schemas, and modify internal logic to support JSON parsing with prefix path traversal. Enhance UI components to accommodate new preview features, improving user experience and data management.
CI / changes (push) Successful in 6s
CI / openapi (push) Successful in 25s
CI / go (push) Successful in 29s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m1s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m1s
CI / docker-bird (push) Successful in 42s
CI / bird2 (push) Successful in 14s
CI / docker-go-prime (push) Successful in 1m22s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 58s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 2m0s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m16s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m14s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m17s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m2s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m15s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m15s
CI / changes (push) Successful in 6s
CI / openapi (push) Successful in 25s
CI / go (push) Successful in 29s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m1s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m1s
CI / docker-bird (push) Successful in 42s
CI / bird2 (push) Successful in 14s
CI / docker-go-prime (push) Successful in 1m22s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 58s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 2m0s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m16s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m14s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m17s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m2s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m15s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m15s
This commit is contained in:
@@ -186,8 +186,11 @@ jobs:
|
|||||||
# Docker: Go-бинарники (api, all, scheduler, ingest, render, deploy, node, agent).
|
# Docker: Go-бинарники (api, all, scheduler, ingest, render, deploy, node, agent).
|
||||||
# Запускается если изменился Go-код или Go-Dockerfile.
|
# Запускается если изменился Go-код или Go-Dockerfile.
|
||||||
# Если Go-код менялся — требуем успех go-тестов; если только Dockerfile — go skipped, ОК.
|
# Если Go-код менялся — требуем успех go-тестов; если только Dockerfile — go skipped, ОК.
|
||||||
|
#
|
||||||
|
# docker-go-prime: один раз собирает stage `deps` (go mod download) и пишет BuildKit cache
|
||||||
|
# в registry — матрица docker-go не качает модули восемь раз подряд.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
docker-go:
|
docker-go-prime:
|
||||||
needs: [changes, go]
|
needs: [changes, go]
|
||||||
if: >-
|
if: >-
|
||||||
always() &&
|
always() &&
|
||||||
@@ -197,6 +200,49 @@ jobs:
|
|||||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||||
(needs.changes.outputs.go == 'true' || needs.changes.outputs.docker_go == 'true')
|
(needs.changes.outputs.go == 'true' || needs.changes.outputs.docker_go == 'true')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Prepare image metadata
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.shts.su
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
- name: Prime Go module layer (deps)
|
||||||
|
env:
|
||||||
|
OWNER_LC: ${{ steps.meta.outputs.owner_lc }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
WS="${{ github.workspace }}"
|
||||||
|
CACHE_REF="git.shts.su/${OWNER_LC}/evobgp-buildcache:go-buildcache"
|
||||||
|
cd "$WS"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--file deploy/docker/gobinary/Dockerfile \
|
||||||
|
--target deps \
|
||||||
|
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||||
|
--cache-to "type=registry,ref=${CACHE_REF},mode=max" \
|
||||||
|
"$WS"
|
||||||
|
|
||||||
|
docker-go:
|
||||||
|
needs: [changes, go, docker-go-prime]
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
needs.changes.result == 'success' &&
|
||||||
|
needs.go.result != 'failure' &&
|
||||||
|
needs.docker-go-prime.result == 'success' &&
|
||||||
|
github.event_name == 'push' &&
|
||||||
|
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||||
|
(needs.changes.outputs.go == 'true' || needs.changes.outputs.docker_go == 'true')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -271,6 +317,7 @@ jobs:
|
|||||||
WS="${{ github.workspace }}"
|
WS="${{ github.workspace }}"
|
||||||
cd "$WS"
|
cd "$WS"
|
||||||
|
|
||||||
|
CACHE_REF="git.shts.su/${OWNER_LC}/evobgp-buildcache:go-buildcache"
|
||||||
IMG="git.shts.su/${OWNER_LC}/${IMAGE}"
|
IMG="git.shts.su/${OWNER_LC}/${IMAGE}"
|
||||||
BUILD_ARGS=()
|
BUILD_ARGS=()
|
||||||
if [ -n "${BIN:-}" ]; then
|
if [ -n "${BIN:-}" ]; then
|
||||||
@@ -285,6 +332,8 @@ jobs:
|
|||||||
--platform linux/amd64 \
|
--platform linux/amd64 \
|
||||||
--file "$DF" \
|
--file "$DF" \
|
||||||
"${BUILD_ARGS[@]}" \
|
"${BUILD_ARGS[@]}" \
|
||||||
|
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||||
|
--cache-to "type=registry,ref=${CACHE_REF},mode=max" \
|
||||||
--tag "${IMG}:latest" \
|
--tag "${IMG}:latest" \
|
||||||
--tag "${IMG}:${SHORT_SHA}" \
|
--tag "${IMG}:${SHORT_SHA}" \
|
||||||
--tag "${IMG}:sha-${{ github.sha }}" \
|
--tag "${IMG}:sha-${{ github.sha }}" \
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
# evobgp-agent + bird2 из репозитория Ubuntu Noble (тот же стек, что evobgp-bird2).
|
# evobgp-agent + bird2 из репозитория Ubuntu Noble (тот же стек, что evobgp-bird2).
|
||||||
# См. gobinary/Dockerfile — ECR Public вместо прямого pull с Docker Hub.
|
# См. gobinary/Dockerfile — ECR Public вместо прямого pull с Docker Hub.
|
||||||
FROM public.ecr.aws/docker/library/golang:1.22-bookworm AS build
|
# Порядок слоёв как в gobinary: кэш модулей отдельно от исходников (CI/CD BuildKit).
|
||||||
|
FROM public.ecr.aws/docker/library/golang:1.22-bookworm AS deps
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN go build -trimpath -ldflags="-s -w" -o /out/evobgp-agent ./cmd/evobgp-agent
|
RUN go build -trimpath -ldflags="-s -w" -o /out/evobgp-agent ./cmd/evobgp-agent
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
# Универсальная сборка бинаря из cmd/* (ARG BIN=evobgp-api | evobgp-all).
|
# Универсальная сборка бинаря из cmd/* (ARG BIN=evobgp-api | evobgp-all).
|
||||||
# INSTALL_BIRDC=1 собирает BIRD 2.14 из исходников (birdc) для EVOBGP_BIRDC_SOCKET; иначе пакет Debian не используется.
|
# INSTALL_BIRDC=1 собирает BIRD 2.14 из исходников (birdc) для EVOBGP_BIRDC_SOCKET; иначе пакет Debian не используется.
|
||||||
# Базовые образы из ECR Public (официальное зеркало library/*), чтобы CI не зависел от auth.docker.io.
|
# Базовые образы из ECR Public (официальное зеркало library/*), чтобы CI не зависел от auth.docker.io.
|
||||||
FROM public.ecr.aws/docker/library/golang:1.22-bookworm AS build
|
# Отдельный stage для кэша модулей (CI: один раз --target deps, затем параллельные сборки с cache-from).
|
||||||
|
FROM public.ecr.aws/docker/library/golang:1.22-bookworm AS deps
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG BIN=evobgp-api
|
ARG BIN=evobgp-api
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/evobgp ./cmd/${BIN}
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/evobgp ./cmd/${BIN}
|
||||||
|
|||||||
@@ -397,6 +397,9 @@ components:
|
|||||||
source_kind:
|
source_kind:
|
||||||
type: string
|
type: string
|
||||||
description: Формат скачанного списка / парсер.
|
description: Формат скачанного списка / парсер.
|
||||||
|
prefix_path:
|
||||||
|
type: string
|
||||||
|
description: Путь до поля с префиксами для source_kind=json (например data.items[].cidr).
|
||||||
community_id:
|
community_id:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
refresh_interval_sec:
|
refresh_interval_sec:
|
||||||
@@ -413,6 +416,8 @@ components:
|
|||||||
format: uri
|
format: uri
|
||||||
source_kind:
|
source_kind:
|
||||||
type: string
|
type: string
|
||||||
|
prefix_path:
|
||||||
|
type: string
|
||||||
community_id:
|
community_id:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
|
|
||||||
@@ -424,11 +429,40 @@ components:
|
|||||||
format: uri
|
format: uri
|
||||||
source_kind:
|
source_kind:
|
||||||
type: string
|
type: string
|
||||||
|
prefix_path:
|
||||||
|
type: string
|
||||||
community_id:
|
community_id:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
refresh_interval_sec:
|
refresh_interval_sec:
|
||||||
type: ["integer", "null"]
|
type: ["integer", "null"]
|
||||||
|
|
||||||
|
CdnPreviewRequest:
|
||||||
|
type: object
|
||||||
|
required: [url, source_kind]
|
||||||
|
properties:
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
source_kind:
|
||||||
|
type: string
|
||||||
|
prefix_path:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
CdnPreviewResponse:
|
||||||
|
type: object
|
||||||
|
required: [items, total, truncated, source_url]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
truncated:
|
||||||
|
type: boolean
|
||||||
|
source_url:
|
||||||
|
type: string
|
||||||
|
|
||||||
AsEntry:
|
AsEntry:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -1535,6 +1569,40 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/modules/{module_id}/cdn-sources/preview:
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- $ref: "#/components/parameters/ModuleId"
|
||||||
|
post:
|
||||||
|
tags: [Modules]
|
||||||
|
summary: Предпросмотр префиксов из CDN-источника
|
||||||
|
description: >
|
||||||
|
Загружает URL, парсит как plaintext или json и возвращает список извлечённых префиксов (до 100 записей).
|
||||||
|
operationId: previewCdnSource
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CdnPreviewRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Успешно.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CdnPreviewResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"422":
|
||||||
|
$ref: "#/components/responses/UnprocessableEntity"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/doh-profiles/{id}:
|
/v1/doh-profiles/{id}:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/TenantId"
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/pipeline"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +20,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
|||||||
|
|
||||||
m.HandleFunc("GET /modules/{module_id}/cdn-sources", s.handleListCDNSources)
|
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", 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("PATCH /modules/{module_id}/cdn-sources/{source_id}", s.handlePatchCDNSource)
|
||||||
m.HandleFunc("DELETE /modules/{module_id}/cdn-sources/{source_id}", s.handleDeleteCDNSource)
|
m.HandleFunc("DELETE /modules/{module_id}/cdn-sources/{source_id}", s.handleDeleteCDNSource)
|
||||||
|
|
||||||
@@ -68,14 +72,14 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
RefreshIntervalSec int `json:"refresh_interval_sec"`
|
RefreshIntervalSec int `json:"refresh_interval_sec"`
|
||||||
CronExpr string `json:"cron_expr"`
|
CronExpr string `json:"cron_expr"`
|
||||||
DefaultCommunityID *string `json:"default_community_id"`
|
DefaultCommunityID *string `json:"default_community_id"`
|
||||||
DohProfileID *string `json:"doh_profile_id"`
|
DohProfileID *string `json:"doh_profile_id"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||||
@@ -153,7 +157,7 @@ func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cdnSourceJSON(x *store.CDNSource) map[string]any {
|
func cdnSourceJSON(x *store.CDNSource) map[string]any {
|
||||||
m := map[string]any{"id": x.ID, "source_kind": x.SourceKind, "url": x.URL, "etag": x.Etag}
|
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 {
|
if x.RefreshIntervalSec != nil {
|
||||||
m["refresh_interval_sec"] = *x.RefreshIntervalSec
|
m["refresh_interval_sec"] = *x.RefreshIntervalSec
|
||||||
} else {
|
} else {
|
||||||
@@ -167,6 +171,76 @@ func cdnSourceJSON(x *store.CDNSource) map[string]any {
|
|||||||
return m
|
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) {
|
func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||||
a, ok := authFromContext(r.Context())
|
a, ok := authFromContext(r.Context())
|
||||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package pipeline
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -31,6 +32,105 @@ func ParseCIDRLines(body string) []netip.Prefix {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtractCIDRs parses CIDRs from either plaintext lines or JSON payload.
|
||||||
|
// For sourceKind="json", prefixPath supports dotted traversal, with [] for arrays:
|
||||||
|
// e.g. "prefixes[]", "data.items[].cidr".
|
||||||
|
func ExtractCIDRs(body, sourceKind, prefixPath string) ([]netip.Prefix, error) {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(sourceKind), "json") {
|
||||||
|
return parseCIDRsFromJSON(body, prefixPath)
|
||||||
|
}
|
||||||
|
return ParseCIDRLines(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCIDRsFromJSON(body, prefixPath string) ([]netip.Prefix, error) {
|
||||||
|
var root any
|
||||||
|
if err := json.Unmarshal([]byte(body), &root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values := jsonValuesAtPath(root, prefixPath)
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var out []netip.Prefix
|
||||||
|
for _, raw := range values {
|
||||||
|
pfx := parseOneCIDR(raw)
|
||||||
|
if !pfx.IsValid() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := pfx.Masked()
|
||||||
|
s := m.String()
|
||||||
|
if _, ok := seen[s]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[s] = struct{}{}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonValuesAtPath(root any, prefixPath string) []string {
|
||||||
|
path := strings.TrimSpace(prefixPath)
|
||||||
|
if path == "" {
|
||||||
|
return flattenJSONStrings(root)
|
||||||
|
}
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
|
nodes := []any{root}
|
||||||
|
for _, p := range parts {
|
||||||
|
part := strings.TrimSpace(p)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
iter := strings.HasSuffix(part, "[]")
|
||||||
|
key := strings.TrimSuffix(part, "[]")
|
||||||
|
var next []any
|
||||||
|
for _, n := range nodes {
|
||||||
|
obj, ok := n.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
child, ok := obj[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if iter {
|
||||||
|
if arr, ok := child.([]any); ok {
|
||||||
|
next = append(next, arr...)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next = append(next, child)
|
||||||
|
}
|
||||||
|
nodes = next
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, n := range nodes {
|
||||||
|
out = append(out, flattenJSONStrings(n)...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenJSONStrings(v any) []string {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
return []string{strings.TrimSpace(x)}
|
||||||
|
case []any:
|
||||||
|
var out []string
|
||||||
|
for _, item := range x {
|
||||||
|
out = append(out, flattenJSONStrings(item)...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case map[string]any:
|
||||||
|
var out []string
|
||||||
|
for _, item := range x {
|
||||||
|
out = append(out, flattenJSONStrings(item)...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func parseOneCIDR(s string) netip.Prefix {
|
func parseOneCIDR(s string) netip.Prefix {
|
||||||
if p, err := netip.ParsePrefix(s); err == nil {
|
if p, err := netip.ParsePrefix(s); err == nil {
|
||||||
return p
|
return p
|
||||||
|
|||||||
@@ -182,7 +182,11 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
|
|||||||
e := etag
|
e := etag
|
||||||
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, &store.CDNSourcePatch{Etag: &e})
|
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, &store.CDNSourcePatch{Etag: &e})
|
||||||
}
|
}
|
||||||
for _, pfx := range ParseCIDRLines(string(body)) {
|
pfxs, err := ExtractCIDRs(string(body), src.SourceKind, src.PrefixPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cdn parse %s: %w", u, err)
|
||||||
|
}
|
||||||
|
for _, pfx := range pfxs {
|
||||||
comm := src.CommunityID
|
comm := src.CommunityID
|
||||||
if comm == nil && mod.DefaultCommunityID != nil {
|
if comm == nil && mod.DefaultCommunityID != nil {
|
||||||
c := *mod.DefaultCommunityID
|
c := *mod.DefaultCommunityID
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func (p *Postgres) ListCDNSources(tenantID, moduleID string) ([]*store.CDNSource
|
|||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rows, err := p.pool.Query(ctx, `
|
rows, err := p.pool.Query(ctx, `
|
||||||
SELECT id::text, source_kind, url, COALESCE(etag,''), refresh_interval_sec, community_id::text
|
SELECT id::text, source_kind, url, COALESCE(prefix_path,''), COALESCE(etag,''), refresh_interval_sec, community_id::text
|
||||||
FROM module_cdn_source WHERE module_id=$1 ORDER BY url`, moduleID)
|
FROM module_cdn_source WHERE module_id=$1 ORDER BY url`, moduleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -35,7 +35,7 @@ func (p *Postgres) ListCDNSources(tenantID, moduleID string) ([]*store.CDNSource
|
|||||||
s.ModuleID = moduleID
|
s.ModuleID = moduleID
|
||||||
var ri *int32
|
var ri *int32
|
||||||
var comm *string
|
var comm *string
|
||||||
if err := rows.Scan(&s.ID, &s.SourceKind, &s.URL, &s.Etag, &ri, &comm); err != nil {
|
if err := rows.Scan(&s.ID, &s.SourceKind, &s.URL, &s.PrefixPath, &s.Etag, &ri, &comm); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ri != nil {
|
if ri != nil {
|
||||||
@@ -62,9 +62,9 @@ func (p *Postgres) CreateCDNSource(tenantID, moduleID string, in *store.CDNSourc
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id := uuid.NewString()
|
id := uuid.NewString()
|
||||||
_, err = p.pool.Exec(ctx, `
|
_, err = p.pool.Exec(ctx, `
|
||||||
INSERT INTO module_cdn_source (id, module_id, source_kind, url, etag, refresh_interval_sec, community_id)
|
INSERT INTO module_cdn_source (id, module_id, source_kind, url, prefix_path, etag, refresh_interval_sec, community_id)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6, NULLIF($7::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`,
|
VALUES ($1,$2,$3,$4,$5,$6,$7, NULLIF($8::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`,
|
||||||
id, moduleID, in.SourceKind, strings.TrimSpace(in.URL), in.Etag, nullInt32Ptr(in.RefreshIntervalSec), uuidOrNilPtr(in.CommunityID))
|
id, moduleID, in.SourceKind, strings.TrimSpace(in.URL), strings.TrimSpace(in.PrefixPath), in.Etag, nullInt32Ptr(in.RefreshIntervalSec), uuidOrNilPtr(in.CommunityID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -77,8 +77,8 @@ func (p *Postgres) getCDNSource(ctx context.Context, moduleID, id string) (*stor
|
|||||||
var ri *int32
|
var ri *int32
|
||||||
var comm *string
|
var comm *string
|
||||||
err := p.pool.QueryRow(ctx, `
|
err := p.pool.QueryRow(ctx, `
|
||||||
SELECT id::text, source_kind, url, COALESCE(etag,''), refresh_interval_sec, community_id::text
|
SELECT id::text, source_kind, url, COALESCE(prefix_path,''), COALESCE(etag,''), refresh_interval_sec, community_id::text
|
||||||
FROM module_cdn_source WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(&s.ID, &s.SourceKind, &s.URL, &s.Etag, &ri, &comm)
|
FROM module_cdn_source WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(&s.ID, &s.SourceKind, &s.URL, &s.PrefixPath, &s.Etag, &ri, &comm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,9 @@ func (p *Postgres) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *s
|
|||||||
if patch.URL != nil {
|
if patch.URL != nil {
|
||||||
cur.URL = strings.TrimSpace(*patch.URL)
|
cur.URL = strings.TrimSpace(*patch.URL)
|
||||||
}
|
}
|
||||||
|
if patch.PrefixPath != nil {
|
||||||
|
cur.PrefixPath = strings.TrimSpace(*patch.PrefixPath)
|
||||||
|
}
|
||||||
if patch.Etag != nil {
|
if patch.Etag != nil {
|
||||||
cur.Etag = *patch.Etag
|
cur.Etag = *patch.Etag
|
||||||
}
|
}
|
||||||
@@ -130,10 +133,10 @@ func (p *Postgres) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *s
|
|||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, err = p.pool.Exec(ctx, `
|
_, err = p.pool.Exec(ctx, `
|
||||||
UPDATE module_cdn_source SET source_kind=$3, url=$4, etag=$5, refresh_interval_sec=$6,
|
UPDATE module_cdn_source SET source_kind=$3, url=$4, prefix_path=$5, etag=$6, refresh_interval_sec=$7,
|
||||||
community_id=NULLIF($7::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now()
|
community_id=NULLIF($8::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now()
|
||||||
WHERE id=$1 AND module_id=$2`,
|
WHERE id=$1 AND module_id=$2`,
|
||||||
sourceID, moduleID, cur.SourceKind, cur.URL, cur.Etag, nullInt32Ptr(cur.RefreshIntervalSec), uuidOrNilPtr(cur.CommunityID))
|
sourceID, moduleID, cur.SourceKind, cur.URL, cur.PrefixPath, cur.Etag, nullInt32Ptr(cur.RefreshIntervalSec), uuidOrNilPtr(cur.CommunityID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-31
@@ -82,29 +82,31 @@ type Backend interface {
|
|||||||
|
|
||||||
// ModulePatch is a partial update for module.
|
// ModulePatch is a partial update for module.
|
||||||
type ModulePatch struct {
|
type ModulePatch struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
Enabled *bool `json:"enabled,omitempty"`
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
Priority *int `json:"priority,omitempty"`
|
Priority *int `json:"priority,omitempty"`
|
||||||
RefreshIntervalSec *int `json:"refresh_interval_sec,omitempty"`
|
RefreshIntervalSec *int `json:"refresh_interval_sec,omitempty"`
|
||||||
CronExpr *string `json:"cron_expr,omitempty"`
|
CronExpr *string `json:"cron_expr,omitempty"`
|
||||||
DefaultCommunityID *string `json:"default_community_id,omitempty"`
|
DefaultCommunityID *string `json:"default_community_id,omitempty"`
|
||||||
DohProfileID *string `json:"doh_profile_id,omitempty"`
|
DohProfileID *string `json:"doh_profile_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CDNSource is a row under a CDN module.
|
// CDNSource is a row under a CDN module.
|
||||||
type CDNSource struct {
|
type CDNSource struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
ModuleID string `json:"module_id,omitempty"`
|
ModuleID string `json:"module_id,omitempty"`
|
||||||
SourceKind string `json:"source_kind"`
|
SourceKind string `json:"source_kind"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Etag string `json:"etag"`
|
PrefixPath string `json:"prefix_path,omitempty"`
|
||||||
RefreshIntervalSec *int `json:"refresh_interval_sec"`
|
Etag string `json:"etag"`
|
||||||
CommunityID *string `json:"community_id"`
|
RefreshIntervalSec *int `json:"refresh_interval_sec"`
|
||||||
|
CommunityID *string `json:"community_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CDNSourcePatch struct {
|
type CDNSourcePatch struct {
|
||||||
SourceKind *string `json:"source_kind,omitempty"`
|
SourceKind *string `json:"source_kind,omitempty"`
|
||||||
URL *string `json:"url,omitempty"`
|
URL *string `json:"url,omitempty"`
|
||||||
|
PrefixPath *string `json:"prefix_path,omitempty"`
|
||||||
Etag *string `json:"etag,omitempty"`
|
Etag *string `json:"etag,omitempty"`
|
||||||
RefreshIntervalSec *int `json:"refresh_interval_sec,omitempty"`
|
RefreshIntervalSec *int `json:"refresh_interval_sec,omitempty"`
|
||||||
CommunityID *string `json:"community_id,omitempty"`
|
CommunityID *string `json:"community_id,omitempty"`
|
||||||
@@ -131,10 +133,10 @@ func ValidASN(n int64) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DomainEntry struct {
|
type DomainEntry struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
ModuleID string `json:"module_id,omitempty"`
|
ModuleID string `json:"module_id,omitempty"`
|
||||||
FQDN string `json:"fqdn"`
|
FQDN string `json:"fqdn"`
|
||||||
CommunityID *string `json:"community_id"`
|
CommunityID *string `json:"community_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DomainEntryPatch struct {
|
type DomainEntryPatch struct {
|
||||||
@@ -143,10 +145,10 @@ type DomainEntryPatch struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type IPRangeEntry struct {
|
type IPRangeEntry struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
ModuleID string `json:"module_id,omitempty"`
|
ModuleID string `json:"module_id,omitempty"`
|
||||||
Prefix string `json:"prefix"`
|
Prefix string `json:"prefix"`
|
||||||
CommunityID *string `json:"community_id"`
|
CommunityID *string `json:"community_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type IPRangePatch struct {
|
type IPRangePatch struct {
|
||||||
@@ -155,12 +157,12 @@ type IPRangePatch struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DohProfile struct {
|
type DohProfile struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
TenantID string `json:"tenant_id,omitempty"`
|
TenantID string `json:"tenant_id,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
TimeoutMs *int `json:"timeout_ms"`
|
TimeoutMs *int `json:"timeout_ms"`
|
||||||
SecretRef *string `json:"vault_secret_ref"`
|
SecretRef *string `json:"vault_secret_ref"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DohProfilePatch struct {
|
type DohProfilePatch struct {
|
||||||
@@ -202,7 +204,7 @@ type SpeakerPatch struct {
|
|||||||
|
|
||||||
// PrefixRow is one materialized prefix for GET /revisions/.../prefixes.
|
// PrefixRow is one materialized prefix for GET /revisions/.../prefixes.
|
||||||
type PrefixRow struct {
|
type PrefixRow struct {
|
||||||
Prefix string
|
Prefix string
|
||||||
CommunityID *string
|
CommunityID *string
|
||||||
Source string
|
Source string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,16 +22,16 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
|
|||||||
}
|
}
|
||||||
id := uuid.NewString()
|
id := uuid.NewString()
|
||||||
mod := &Module{
|
mod := &Module{
|
||||||
ID: id,
|
ID: id,
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
Type: in.Type,
|
Type: in.Type,
|
||||||
Name: strings.TrimSpace(in.Name),
|
Name: strings.TrimSpace(in.Name),
|
||||||
Enabled: in.Enabled,
|
Enabled: in.Enabled,
|
||||||
Priority: in.Priority,
|
Priority: in.Priority,
|
||||||
RefreshIntervalSec: in.RefreshIntervalSec,
|
RefreshIntervalSec: in.RefreshIntervalSec,
|
||||||
CronExpr: in.CronExpr,
|
CronExpr: in.CronExpr,
|
||||||
DefaultCommunityID: in.DefaultCommunityID,
|
DefaultCommunityID: in.DefaultCommunityID,
|
||||||
DohProfileID: in.DohProfileID,
|
DohProfileID: in.DohProfileID,
|
||||||
}
|
}
|
||||||
m.modules[id] = mod
|
m.modules[id] = mod
|
||||||
return mod, nil
|
return mod, nil
|
||||||
@@ -139,6 +139,7 @@ func (m *Memory) CreateCDNSource(tenantID, moduleID string, in *CDNSource) (*CDN
|
|||||||
ModuleID: moduleID,
|
ModuleID: moduleID,
|
||||||
SourceKind: in.SourceKind,
|
SourceKind: in.SourceKind,
|
||||||
URL: strings.TrimSpace(in.URL),
|
URL: strings.TrimSpace(in.URL),
|
||||||
|
PrefixPath: strings.TrimSpace(in.PrefixPath),
|
||||||
Etag: in.Etag,
|
Etag: in.Etag,
|
||||||
RefreshIntervalSec: in.RefreshIntervalSec,
|
RefreshIntervalSec: in.RefreshIntervalSec,
|
||||||
CommunityID: in.CommunityID,
|
CommunityID: in.CommunityID,
|
||||||
@@ -166,6 +167,9 @@ func (m *Memory) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *CDN
|
|||||||
if patch.URL != nil {
|
if patch.URL != nil {
|
||||||
s.URL = strings.TrimSpace(*patch.URL)
|
s.URL = strings.TrimSpace(*patch.URL)
|
||||||
}
|
}
|
||||||
|
if patch.PrefixPath != nil {
|
||||||
|
s.PrefixPath = strings.TrimSpace(*patch.PrefixPath)
|
||||||
|
}
|
||||||
if patch.Etag != nil {
|
if patch.Etag != nil {
|
||||||
s.Etag = *patch.Etag
|
s.Etag = *patch.Etag
|
||||||
}
|
}
|
||||||
@@ -654,7 +658,7 @@ func (m *Memory) CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error) {
|
|||||||
p := &BGPPeer{
|
p := &BGPPeer{
|
||||||
ID: id, TenantID: tenantID, SpeakerID: in.SpeakerID, Name: in.Name,
|
ID: id, TenantID: tenantID, SpeakerID: in.SpeakerID, Name: in.Name,
|
||||||
Neighbor: neighbor, RemoteASN: in.RemoteASN,
|
Neighbor: neighbor, RemoteASN: in.RemoteASN,
|
||||||
Enabled: EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState),
|
Enabled: EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState),
|
||||||
SessionState: in.SessionState, PoliciesJSON: in.PoliciesJSON,
|
SessionState: in.SessionState, PoliciesJSON: in.PoliciesJSON,
|
||||||
}
|
}
|
||||||
m.peers[id] = p
|
m.peers[id] = p
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE module_cdn_source
|
||||||
|
DROP COLUMN prefix_path;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE module_cdn_source
|
||||||
|
ADD COLUMN prefix_path TEXT NOT NULL DEFAULT '';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE module_cdn_source
|
||||||
|
DROP COLUMN prefix_path;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE module_cdn_source
|
||||||
|
ADD COLUMN prefix_path TEXT NOT NULL DEFAULT '';
|
||||||
@@ -60,17 +60,26 @@ export type CdnSource = {
|
|||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
source_kind: string;
|
source_kind: string;
|
||||||
|
prefix_path: string;
|
||||||
community_id: string | null;
|
community_id: string | null;
|
||||||
refresh_interval_sec: number | null;
|
refresh_interval_sec: number | null;
|
||||||
};
|
};
|
||||||
export type CdnSourceCreate = {
|
export type CdnSourceCreate = {
|
||||||
url: string;
|
url: string;
|
||||||
source_kind: string;
|
source_kind: string;
|
||||||
|
prefix_path?: string;
|
||||||
community_id?: string | null;
|
community_id?: string | null;
|
||||||
};
|
};
|
||||||
export type CdnSourcePatch = Partial<CdnSourceCreate> & { refresh_interval_sec?: number | null };
|
export type CdnSourcePatch = Partial<CdnSourceCreate> & { refresh_interval_sec?: number | null };
|
||||||
export type CdnSourcesResponse = Page<CdnSource>;
|
export type CdnSourcesResponse = Page<CdnSource>;
|
||||||
|
|
||||||
|
export type CdnPreviewResponse = {
|
||||||
|
items: string[];
|
||||||
|
total: number;
|
||||||
|
truncated: boolean;
|
||||||
|
source_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
// ---- Domain Entries ----
|
// ---- Domain Entries ----
|
||||||
export type DomainEntry = {
|
export type DomainEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
CdnSource,
|
CdnSource,
|
||||||
CdnSourceCreate,
|
CdnSourceCreate,
|
||||||
CdnSourcesResponse,
|
CdnSourcesResponse,
|
||||||
|
CdnPreviewResponse,
|
||||||
DomainEntry,
|
DomainEntry,
|
||||||
DomainEntryCreate,
|
DomainEntryCreate,
|
||||||
DomainEntriesResponse,
|
DomainEntriesResponse,
|
||||||
@@ -91,9 +92,20 @@
|
|||||||
let cdnSources = $state<CdnSource[]>([]);
|
let cdnSources = $state<CdnSource[]>([]);
|
||||||
let cdnDialog = $state(false);
|
let cdnDialog = $state(false);
|
||||||
let cdnEdit = $state<CdnSource | null>(null);
|
let cdnEdit = $state<CdnSource | null>(null);
|
||||||
let cdnForm = $state<CdnSourceCreate & { refresh_interval_sec?: number | null }>({ url: '', source_kind: '', community_id: null });
|
let cdnForm = $state<CdnSourceCreate & { refresh_interval_sec?: number | null }>({
|
||||||
|
url: '',
|
||||||
|
source_kind: 'plaintext',
|
||||||
|
prefix_path: '',
|
||||||
|
community_id: null
|
||||||
|
});
|
||||||
let cdnSaving = $state(false);
|
let cdnSaving = $state(false);
|
||||||
let cdnDeleteTarget = $state<CdnSource | null>(null);
|
let cdnDeleteTarget = $state<CdnSource | null>(null);
|
||||||
|
let cdnPreviewLoading = $state(false);
|
||||||
|
let cdnPreviewItems = $state<string[]>([]);
|
||||||
|
let cdnPreviewTotal = $state(0);
|
||||||
|
let cdnPreviewTruncated = $state(false);
|
||||||
|
let cdnPreviewError = $state<string | null>(null);
|
||||||
|
let cdnPreviewOk = $state(false);
|
||||||
|
|
||||||
// Domain entries
|
// Domain entries
|
||||||
let domainEntries = $state<DomainEntry[]>([]);
|
let domainEntries = $state<DomainEntry[]>([]);
|
||||||
@@ -164,6 +176,19 @@
|
|||||||
|
|
||||||
onMount(loadMod);
|
onMount(loadMod);
|
||||||
|
|
||||||
|
function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
|
||||||
|
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCdnPreview() {
|
||||||
|
cdnPreviewLoading = false;
|
||||||
|
cdnPreviewItems = [];
|
||||||
|
cdnPreviewTotal = 0;
|
||||||
|
cdnPreviewTruncated = false;
|
||||||
|
cdnPreviewError = null;
|
||||||
|
cdnPreviewOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Module Actions ---
|
// --- Module Actions ---
|
||||||
async function openEditMod() {
|
async function openEditMod() {
|
||||||
if (!mod) return;
|
if (!mod) return;
|
||||||
@@ -270,24 +295,77 @@
|
|||||||
// --- CDN Sources ---
|
// --- CDN Sources ---
|
||||||
function openCdnCreate() {
|
function openCdnCreate() {
|
||||||
cdnEdit = null;
|
cdnEdit = null;
|
||||||
cdnForm = { url: '', source_kind: '', community_id: null };
|
clearCdnPreview();
|
||||||
|
cdnForm = { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null };
|
||||||
cdnDialog = true;
|
cdnDialog = true;
|
||||||
}
|
}
|
||||||
function openCdnEdit(src: CdnSource) {
|
function openCdnEdit(src: CdnSource) {
|
||||||
cdnEdit = src;
|
cdnEdit = src;
|
||||||
cdnForm = { url: src.url, source_kind: src.source_kind, community_id: src.community_id, refresh_interval_sec: src.refresh_interval_sec };
|
clearCdnPreview();
|
||||||
|
cdnForm = {
|
||||||
|
url: src.url,
|
||||||
|
source_kind: normalizeCdnSourceKind(src.source_kind),
|
||||||
|
prefix_path: src.prefix_path ?? '',
|
||||||
|
community_id: src.community_id,
|
||||||
|
refresh_interval_sec: src.refresh_interval_sec
|
||||||
|
};
|
||||||
cdnDialog = true;
|
cdnDialog = true;
|
||||||
}
|
}
|
||||||
|
async function previewCdn() {
|
||||||
|
const urlTrim = cdnForm.url.trim();
|
||||||
|
if (!urlTrim) {
|
||||||
|
toast.error('Укажите URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cdnPreviewLoading = true;
|
||||||
|
cdnPreviewError = null;
|
||||||
|
cdnPreviewOk = false;
|
||||||
|
try {
|
||||||
|
const res = await apiMutate<CdnPreviewResponse>(
|
||||||
|
`/v1/modules/${moduleId}/cdn-sources/preview`,
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
url: urlTrim,
|
||||||
|
source_kind: cdnForm.source_kind,
|
||||||
|
prefix_path: cdnForm.prefix_path?.trim() ?? ''
|
||||||
|
}
|
||||||
|
);
|
||||||
|
cdnPreviewItems = res.items;
|
||||||
|
cdnPreviewTotal = res.total;
|
||||||
|
cdnPreviewTruncated = res.truncated;
|
||||||
|
cdnPreviewOk = true;
|
||||||
|
} catch (e) {
|
||||||
|
cdnPreviewError = e instanceof Error ? e.message : String(e);
|
||||||
|
cdnPreviewItems = [];
|
||||||
|
cdnPreviewTotal = 0;
|
||||||
|
cdnPreviewTruncated = false;
|
||||||
|
cdnPreviewOk = false;
|
||||||
|
} finally {
|
||||||
|
cdnPreviewLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
async function saveCdn() {
|
async function saveCdn() {
|
||||||
|
const urlTrim = cdnForm.url.trim();
|
||||||
|
if (!urlTrim) {
|
||||||
|
toast.error('Укажите URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
cdnSaving = true;
|
cdnSaving = true;
|
||||||
try {
|
try {
|
||||||
|
const body = {
|
||||||
|
...cdnForm,
|
||||||
|
url: urlTrim,
|
||||||
|
source_kind: cdnForm.source_kind,
|
||||||
|
prefix_path: cdnForm.prefix_path?.trim() ?? ''
|
||||||
|
};
|
||||||
if (cdnEdit) {
|
if (cdnEdit) {
|
||||||
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${cdnEdit.id}`, 'PATCH', cdnForm);
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${cdnEdit.id}`, 'PATCH', body);
|
||||||
toast.success('Источник обновлён');
|
toast.success('Источник обновлён');
|
||||||
} else {
|
} else {
|
||||||
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', cdnForm);
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body);
|
||||||
toast.success('Источник добавлен');
|
toast.success('Источник добавлен');
|
||||||
}
|
}
|
||||||
|
clearCdnPreview();
|
||||||
cdnDialog = false;
|
cdnDialog = false;
|
||||||
await loadEntries();
|
await loadEntries();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -559,7 +637,16 @@
|
|||||||
{#each cdnSources as src (src.id)}
|
{#each cdnSources as src (src.id)}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell class="font-mono text-xs max-w-xs truncate">{src.url}</TableCell>
|
<TableCell class="font-mono text-xs max-w-xs truncate">{src.url}</TableCell>
|
||||||
<TableCell><Badge variant="outline">{src.source_kind}</Badge></TableCell>
|
<TableCell>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<Badge variant="outline">{normalizeCdnSourceKind(src.source_kind)}</Badge>
|
||||||
|
{#if src.prefix_path?.trim()}
|
||||||
|
<span class="text-muted-foreground font-mono text-xs break-all" title={src.prefix_path}
|
||||||
|
>{src.prefix_path}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
<TableCell class="text-muted-foreground text-sm">{communityLabel(src.community_id)}</TableCell>
|
<TableCell class="text-muted-foreground text-sm">{communityLabel(src.community_id)}</TableCell>
|
||||||
<TableCell class="text-muted-foreground text-sm">{src.refresh_interval_sec ? `${src.refresh_interval_sec}с` : '—'}</TableCell>
|
<TableCell class="text-muted-foreground text-sm">{src.refresh_interval_sec ? `${src.refresh_interval_sec}с` : '—'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -805,8 +892,13 @@
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
<!-- ================== CDN Source Dialog ================== -->
|
<!-- ================== CDN Source Dialog ================== -->
|
||||||
<Dialog bind:open={cdnDialog}>
|
<Dialog
|
||||||
<DialogContent class="sm:max-w-md">
|
bind:open={cdnDialog}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) clearCdnPreview();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent class="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{cdnEdit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
|
<DialogTitle>{cdnEdit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -817,7 +909,34 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="cdn-kind">Тип источника</Label>
|
<Label for="cdn-kind">Тип источника</Label>
|
||||||
<Input id="cdn-kind" placeholder="plaintext_cidr" bind:value={cdnForm.source_kind} />
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={cdnForm.source_kind}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
cdnForm.source_kind = v || 'plaintext';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="cdn-kind" class="w-full">
|
||||||
|
{cdnForm.source_kind === 'json' ? 'json' : 'plaintext'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="plaintext">plaintext</SelectItem>
|
||||||
|
<SelectItem value="json">json</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-prefix-path">JSON path (prefix_path)</Label>
|
||||||
|
<Input
|
||||||
|
id="cdn-prefix-path"
|
||||||
|
placeholder="напр. prefixes[] или data.items[].cidr"
|
||||||
|
bind:value={cdnForm.prefix_path}
|
||||||
|
/>
|
||||||
|
{#if cdnForm.source_kind === 'json' && !cdnForm.prefix_path?.trim()}
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="cdn-comm">Community</Label>
|
<Label for="cdn-comm">Community</Label>
|
||||||
@@ -837,6 +956,28 @@
|
|||||||
<Label for="cdn-interval">Интервал обновления (сек)</Label>
|
<Label for="cdn-interval">Интервал обновления (сек)</Label>
|
||||||
<Input id="cdn-interval" type="number" placeholder="3600" bind:value={cdnForm.refresh_interval_sec} />
|
<Input id="cdn-interval" type="number" placeholder="3600" bind:value={cdnForm.refresh_interval_sec} />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="border-border flex flex-col gap-2 rounded-lg border p-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" onclick={previewCdn} disabled={cdnPreviewLoading}>
|
||||||
|
{cdnPreviewLoading ? 'Загрузка…' : 'Предпросмотр'}
|
||||||
|
</Button>
|
||||||
|
{#if cdnPreviewError}
|
||||||
|
<span class="text-destructive text-sm">{cdnPreviewError}</span>
|
||||||
|
{:else if cdnPreviewOk}
|
||||||
|
<span class="text-muted-foreground text-sm">
|
||||||
|
Всего: {cdnPreviewTotal}{#if cdnPreviewTruncated}
|
||||||
|
<span class="text-amber-600 dark:text-amber-500"> (обрезано)</span>{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if cdnPreviewItems.length}
|
||||||
|
<ul class="bg-muted/40 max-h-48 overflow-y-auto rounded-md border p-2 font-mono text-xs">
|
||||||
|
{#each cdnPreviewItems as item, i (`${i}-${item}`)}
|
||||||
|
<li class="py-0.5">{item}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onclick={() => (cdnDialog = false)}>Отмена</Button>
|
<Button variant="outline" onclick={() => (cdnDialog = false)}>Отмена</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user