Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3290d6ed1a |
@@ -723,3 +723,87 @@ func TestCopyDownstreamHeadersDropsTheRightThings(t *testing.T) {
|
||||
}
|
||||
|
||||
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
|
||||
|
||||
// The Preview button posts the draft on the operator's screen. It used to be
|
||||
// ignored: the handler assembled the STORED group, so tightening a filter and
|
||||
// pressing Preview returned a byte-identical answer and the button looked dead.
|
||||
func TestGroupPreviewHonoursTheDraftBody(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
panel := newPanel(t, strings.Join([]string{
|
||||
"vless://11111111-1111-1111-1111-111111111111@nl.example:443#NL Amsterdam",
|
||||
"vless://22222222-2222-2222-2222-222222222222@de.example:443#DE Frankfurt",
|
||||
}, "\n"))
|
||||
s := mustSource(t, e.st, &model.Source{Name: "panel", URL: panel.URL, Enabled: true})
|
||||
|
||||
g := &model.Group{
|
||||
Name: "everything", Enabled: true,
|
||||
Members: []model.GroupMember{{SourceID: s.ID}},
|
||||
}
|
||||
if err := e.st.CreateGroup(g); err != nil {
|
||||
t.Fatalf("CreateGroup: %v", err)
|
||||
}
|
||||
|
||||
// Stored group, no body: both nodes.
|
||||
resp := e.do(http.MethodPost, "/api/groups/"+strconv.FormatInt(g.ID, 10)+"/preview", e.auth(), nil)
|
||||
if body := readBody(t, resp); !strings.Contains(body, "Frankfurt") {
|
||||
t.Fatalf("the stored group should preview both nodes: %s", body)
|
||||
}
|
||||
|
||||
// Same group, draft body that drops one: the draft must win.
|
||||
draft := map[string]any{
|
||||
"name": "everything",
|
||||
"members": []map[string]any{{"source_id": s.ID}},
|
||||
"filter": map[string]any{"exclude_name": "Frankfurt"},
|
||||
"enabled": true,
|
||||
}
|
||||
resp = e.do(http.MethodPost, "/api/groups/"+strconv.FormatInt(g.ID, 10)+"/preview", e.auth(), draft)
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("preview with a draft = %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if strings.Contains(body, "Frankfurt") {
|
||||
t.Errorf("the draft filter was ignored — this is the dead Preview button:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Amsterdam") {
|
||||
t.Errorf("the draft filter dropped too much:\n%s", body)
|
||||
}
|
||||
|
||||
// Previewing must not write: the stored group still has no filter.
|
||||
stored, err := e.st.GetGroup(g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroup: %v", err)
|
||||
}
|
||||
if stored.Filter.ExcludeName != "" {
|
||||
t.Errorf("preview persisted the draft filter: %q", stored.Filter.ExcludeName)
|
||||
}
|
||||
}
|
||||
|
||||
// A group that does not exist yet can be previewed too, and pressing Preview in
|
||||
// the editor must not leave a half-made group behind.
|
||||
func TestGroupDraftPreviewCreatesNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
panel := newPanel(t, "vless://11111111-1111-1111-1111-111111111111@nl.example:443#NL Amsterdam")
|
||||
s := mustSource(t, e.st, &model.Source{Name: "panel", URL: panel.URL, Enabled: true})
|
||||
|
||||
draft := map[string]any{
|
||||
"name": "unsaved",
|
||||
"members": []map[string]any{{"source_id": s.ID}},
|
||||
"enabled": true,
|
||||
}
|
||||
resp := e.do(http.MethodPost, "/api/groups/preview", e.auth(), draft)
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("draft preview = %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(body, "Amsterdam") {
|
||||
t.Errorf("draft preview returned no nodes: %s", body)
|
||||
}
|
||||
|
||||
groups, err := e.st.ListGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("ListGroups: %v", err)
|
||||
}
|
||||
if len(groups) != 0 {
|
||||
t.Errorf("previewing a draft created %d group(s)", len(groups))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -99,12 +100,66 @@ type previewResponse struct {
|
||||
|
||||
// previewGroup runs the real assembly pipeline so what the operator sees is
|
||||
// what a client would get, minus the rendering step.
|
||||
//
|
||||
// The posted body is the DRAFT on the operator's screen and it wins over the
|
||||
// stored row. Without that the button is a lie: you tighten a regex, press
|
||||
// Preview, and get a byte-identical answer assembled from the group as it was
|
||||
// last saved. Nothing is written here — previewing must never be a side effect.
|
||||
func (a *api) previewGroup(w http.ResponseWriter, r *http.Request) {
|
||||
g, ok := a.loadGroup(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !a.applyDraft(w, r, g) {
|
||||
return
|
||||
}
|
||||
a.respondPreview(w, r, g)
|
||||
}
|
||||
|
||||
// previewDraftGroup previews a group that does not exist yet, so the editor can
|
||||
// show a merged list before anything is created. Nothing is stored.
|
||||
func (a *api) previewDraftGroup(w http.ResponseWriter, r *http.Request) {
|
||||
g := &model.Group{Enabled: true}
|
||||
if err := decodeJSON(r, g); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return
|
||||
}
|
||||
// A draft has no identity: an id or token in the body is meaningless here
|
||||
// and must not make this look like it touched a stored row.
|
||||
g.ID, g.Token = 0, ""
|
||||
normalizeGroup(g)
|
||||
if err := a.validateGroup(*g); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
a.respondPreview(w, r, g)
|
||||
}
|
||||
|
||||
// applyDraft overlays an optional request body on a loaded group. An empty body
|
||||
// is valid and means "preview exactly what is stored". The identity fields stay
|
||||
// the stored ones whatever the body claims.
|
||||
func (a *api) applyDraft(w http.ResponseWriter, r *http.Request, g *model.Group) bool {
|
||||
if r.Body == nil || r.ContentLength == 0 {
|
||||
return true
|
||||
}
|
||||
id, token, createdAt := g.ID, g.Token, g.CreatedAt
|
||||
if err := decodeJSON(r, g); err != nil {
|
||||
if errors.Is(err, io.EOF) { // a body that turned out to be empty
|
||||
return true
|
||||
}
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return false
|
||||
}
|
||||
g.ID, g.Token, g.CreatedAt = id, token, createdAt
|
||||
normalizeGroup(g)
|
||||
if err := a.validateGroup(*g); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *api) respondPreview(w http.ResponseWriter, r *http.Request, g *model.Group) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), a.upstreamTimeout()+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
||||
@@ -93,6 +93,9 @@ func Router(deps Deps) http.Handler {
|
||||
r.Get("/{id}", a.getGroup)
|
||||
r.Put("/{id}", a.updateGroup)
|
||||
r.Delete("/{id}", a.deleteGroup)
|
||||
// Draft preview: no id, nothing stored. Lets the editor show what a
|
||||
// group WOULD produce before it exists.
|
||||
r.Post("/preview", a.previewDraftGroup)
|
||||
r.Post("/{id}/preview", a.previewGroup)
|
||||
})
|
||||
|
||||
|
||||
@@ -161,6 +161,11 @@ export const api = {
|
||||
previewGroup: (id: number, draft?: Partial<Group>) =>
|
||||
request<PreviewResult>('POST', `/api/groups/${id}/preview`, draft ?? {}),
|
||||
|
||||
// Preview a group that does not exist yet. Nothing is stored, so opening the
|
||||
// editor and pressing Preview never leaves a half-made group behind.
|
||||
previewDraftGroup: (draft: Partial<Group>) =>
|
||||
request<PreviewResult>('POST', '/api/groups/preview', draft),
|
||||
|
||||
// ------------------------------------------------------------- clients
|
||||
|
||||
listClients: async (q: ClientQuery): Promise<Page<Client>> => {
|
||||
|
||||
@@ -368,15 +368,11 @@ function GroupDrawer({
|
||||
setPreviewing(true)
|
||||
setPreviewError(null)
|
||||
try {
|
||||
// A group must exist before it can be previewed; save the draft first so
|
||||
// the preview always reflects what is on screen.
|
||||
let id = form.id
|
||||
if (isNew) {
|
||||
const saved = await save()
|
||||
if (!saved) return
|
||||
id = saved.id
|
||||
}
|
||||
const res = await api.previewGroup(id, payload())
|
||||
// The draft on screen is what gets previewed, saved or not — a new group
|
||||
// goes to the id-less route so pressing Preview never creates anything.
|
||||
const res = isNew
|
||||
? await api.previewDraftGroup(payload())
|
||||
: await api.previewGroup(form.id, payload())
|
||||
setPreview(res)
|
||||
} catch (e) {
|
||||
setPreview(null)
|
||||
|
||||
Reference in New Issue
Block a user