The Preview button posted the draft the operator was editing and the handler
threw it away — it loaded the group from the store and assembled that. Tighten
a regex, press Preview, and the answer came back byte-identical (and, after the
first call, from cache in under a millisecond), so the button looked dead.
Reproduced in the panel: a filter dropping five countries left all 32 nodes on
screen, unchanged.
- POST /api/groups/{id}/preview now overlays the posted body on the stored
group before assembling. The body stays optional, so an empty request still
means "preview exactly what is stored", and identity fields (id, token,
created_at) keep their stored values whatever the body claims. Nothing is
written: previewing must not be a side effect.
- New POST /api/groups/preview previews a group that does not exist yet. The
editor used to SAVE an unsaved group just to get an id to preview it, which
left a half-made group behind whenever someone pressed Preview to look
around and then cancelled.
Covered by two tests: the draft filter must change the result and must not be
persisted, and previewing a draft must create no group.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding a subscription from the panel failed with
invalid JSON body: parsing time "" as "2006-01-02T15:04:05Z07:00":
cannot parse "" as "2006"
The forms hold a whole Source/Group so a row can be read, edited and shown
back, and a freshly built one carries created_at/updated_at as "". The API
decoded straight onto model.Source, so the stdlib's time decoder rejected the
request before a single field was looked at. Groups had the identical bug; only
hand-written curl payloads, which omit the server-owned fields, ever worked.
Fixed on both sides:
- model.Source and model.Group now decode empty and null timestamps as
"not provided" and keep whatever the record already had, so an update that
decodes onto a loaded row cannot wipe its stamps. A malformed date is still
an error — the tolerance is for empty, not for junk.
- the panel sends only the operator-writable fields, so timestamps, hit
counters and last-fetch state never travel back at all.
Covered by internal/model/json_test.go, which decodes the exact body from the
report, and verified through the panel's own form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Upstream panels (Remnawave and friends) pin a subscription to one device
through the x-hwid header. Prizma holds that HWID per source, presents it on
every upstream fetch, and hands out its own link that any number of devices
may use. Everything else — the client's User-Agent, the response body, the
profile-title / subscription-userinfo / announce headers — is proxied through
untouched.
Two link kinds behind /sub/{token}:
source byte-for-byte proxy of one upstream, format chosen by the client
group several sources merged into one link: parallel fetch, parse, regex
filtering by node name and by node content, protocol allow-list,
dedupe, rename template, rendered in the negotiated format
Formats parse and render both ways: URI lists, base64, Clash/Mihomo YAML,
sing-box JSON, and Xray JSON including the Happ-style array of whole configs.
A node keeps the raw payload it was born from, so same-format rendering is
byte-identical and no vendor-specific field is ever dropped.
Access control is HWID-based and self-switching: an empty whitelist means
everyone passes except banned devices; whitelisting a single device locks the
links to the whitelist. Every device that fetches a link is recorded with its
UA, IP, hit count and timestamps, and can be banned, whitelisted or labelled
from the panel.
Ships as one static binary with the React admin panel embedded (CGO-free, so
linux/amd64+arm64, windows and darwin cross-compile from anywhere), as a
docker image, and with Gitea CI that gates releases on the test suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>