1 Commits
Author SHA1 Message Date
omarandClaude Opus 5 a887090e9b fix(api): accept the whole object a client reads back, empty stamps and all
test / go vet + go test + spa build (push) Successful in 6m35s
release / test gate (push) Successful in 14s
release / binaries + release (push) Successful in 54s
release / docker image (push) Successful in 29s
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>
2026-08-08 17:56:31 +03:00
5 changed files with 203 additions and 9 deletions
+81
View File
@@ -0,0 +1,81 @@
package model
import (
"encoding/json"
"time"
)
// Timestamps on Source and Group are server-owned: the store stamps them and
// the API only ever reports them back. Clients none the less round-trip whole
// objects — read one, edit a field, write it back — so the zero value of a
// freshly built object arrives as `"created_at": ""`, which the stdlib decoder
// rejects with `cannot parse "" as "2006"`. That is a decoder detail leaking
// into an API contract, and it made "add a subscription" fail outright.
//
// flexTime accepts the three shapes a client actually sends for such a field:
// a real RFC3339 stamp, `null`, and the empty string. Anything else is still a
// hard error — a typo'd date should not be silently swallowed.
type flexTime struct {
Time time.Time
Set bool
}
func (t *flexTime) UnmarshalJSON(b []byte) error {
s := string(b)
if s == "null" || s == `""` {
return nil
}
var v time.Time
if err := json.Unmarshal(b, &v); err != nil {
return err
}
t.Time, t.Set = v, true
return nil
}
// keep returns the incoming value when the client sent a usable one, and the
// value already on the record otherwise. Updates decode ONTO the loaded record,
// so "not sent" and "sent empty" must both leave the stored stamp alone.
func keep(in flexTime, cur time.Time) time.Time {
if in.Set {
return in.Time
}
return cur
}
func (s *Source) UnmarshalJSON(b []byte) error {
type alias Source // sheds the method set, so this does not recurse
aux := struct {
CreatedAt flexTime `json:"created_at"`
UpdatedAt flexTime `json:"updated_at"`
LastFetchAt flexTime `json:"last_fetch_at"`
*alias
}{alias: (*alias)(s)}
if err := json.Unmarshal(b, &aux); err != nil {
return err
}
s.CreatedAt = keep(aux.CreatedAt, s.CreatedAt)
s.UpdatedAt = keep(aux.UpdatedAt, s.UpdatedAt)
if aux.LastFetchAt.Set {
t := aux.LastFetchAt.Time
s.LastFetchAt = &t
}
return nil
}
func (g *Group) UnmarshalJSON(b []byte) error {
type alias Group
aux := struct {
CreatedAt flexTime `json:"created_at"`
UpdatedAt flexTime `json:"updated_at"`
*alias
}{alias: (*alias)(g)}
if err := json.Unmarshal(b, &aux); err != nil {
return err
}
g.CreatedAt = keep(aux.CreatedAt, g.CreatedAt)
g.UpdatedAt = keep(aux.UpdatedAt, g.UpdatedAt)
return nil
}
+82
View File
@@ -0,0 +1,82 @@
package model
import (
"encoding/json"
"testing"
"time"
)
// The exact body the admin panel sent when "add a subscription" started
// failing: a whole Source object with the server-owned stamps left empty.
const panelCreateBody = `{"id":0,"name":"renawave","url":"https://sub.example.net/abc",` +
`"hwid":"4378f94bab5347708357b21787b1dd12","device_os":"","ver_os":"","device_model":"",` +
`"user_agent":"","forward_client_ua":true,"extra_headers":{},"cache_ttl":0,"enabled":true,` +
`"token":"","note":"","created_at":"","updated_at":"","last_fetch_ok":false,` +
`"last_node_count":0,"hits":0}`
func TestSourceUnmarshalEmptyTimestamps(t *testing.T) {
var s Source
if err := json.Unmarshal([]byte(panelCreateBody), &s); err != nil {
t.Fatalf("panel create body must decode, got: %v", err)
}
if s.Name != "renawave" || s.HWID != "4378f94bab5347708357b21787b1dd12" {
t.Fatalf("payload fields lost: %+v", s)
}
if !s.ForwardClientUA || !s.Enabled {
t.Fatalf("booleans lost: forward=%v enabled=%v", s.ForwardClientUA, s.Enabled)
}
if !s.CreatedAt.IsZero() || !s.UpdatedAt.IsZero() {
t.Fatalf("empty stamps must stay zero, got %v / %v", s.CreatedAt, s.UpdatedAt)
}
}
func TestSourceUnmarshalKeepsExistingStamps(t *testing.T) {
// Updates decode onto the record loaded from the store; an empty or absent
// stamp in the request must not wipe the stored one.
born := time.Date(2026, 7, 31, 10, 0, 0, 0, time.UTC)
for _, body := range []string{
`{"name":"edited","created_at":"","updated_at":null}`,
`{"name":"edited"}`,
} {
s := Source{Name: "old", CreatedAt: born, UpdatedAt: born}
if err := json.Unmarshal([]byte(body), &s); err != nil {
t.Fatalf("%s: %v", body, err)
}
if s.Name != "edited" {
t.Fatalf("%s: name not applied", body)
}
if !s.CreatedAt.Equal(born) || !s.UpdatedAt.Equal(born) {
t.Fatalf("%s: stamps clobbered: %v / %v", body, s.CreatedAt, s.UpdatedAt)
}
}
}
func TestSourceUnmarshalRealAndBadTimestamps(t *testing.T) {
var s Source
if err := json.Unmarshal([]byte(`{"created_at":"2026-07-31T10:00:00Z"}`), &s); err != nil {
t.Fatalf("RFC3339 must still decode: %v", err)
}
if s.CreatedAt.Year() != 2026 {
t.Fatalf("real timestamp not applied: %v", s.CreatedAt)
}
// A malformed stamp is still an error — tolerance is for empty, not for junk.
if err := json.Unmarshal([]byte(`{"created_at":"31 July 2026"}`), &Source{}); err == nil {
t.Fatal("a malformed timestamp must not be swallowed")
}
}
func TestGroupUnmarshalEmptyTimestamps(t *testing.T) {
body := `{"id":0,"name":"mix","token":"","members":[{"source_id":1}],` +
`"filter":{},"output_format":"","name_template":"","dedupe":true,` +
`"enabled":true,"note":"","created_at":"","updated_at":"","hits":0}`
var g Group
if err := json.Unmarshal([]byte(body), &g); err != nil {
t.Fatalf("panel group body must decode, got: %v", err)
}
if g.Name != "mix" || len(g.Members) != 1 || g.Members[0].SourceID != 1 {
t.Fatalf("payload fields lost: %+v", g)
}
if !g.Dedupe || !g.Enabled {
t.Fatalf("booleans lost: dedupe=%v enabled=%v", g.Dedupe, g.Enabled)
}
}
+30
View File
@@ -212,3 +212,33 @@ export const emptyGroup = (): Group => ({
updated_at: '',
hits: 0,
})
// The forms hold a whole Source/Group so a row can be read, edited and shown
// back; only these fields are the operator's to set. Everything else —
// timestamps, hit counters, last-fetch state — belongs to the server, and
// sending it back is at best noise and at worst a conflict.
export const writableSource = (s: Source): Partial<Source> => ({
name: s.name,
url: s.url,
hwid: s.hwid,
device_os: s.device_os,
ver_os: s.ver_os,
device_model: s.device_model,
user_agent: s.user_agent,
forward_client_ua: s.forward_client_ua,
extra_headers: s.extra_headers,
cache_ttl: s.cache_ttl,
enabled: s.enabled,
note: s.note,
})
export const writableGroup = (g: Group): Partial<Group> => ({
name: g.name,
members: g.members,
filter: g.filter,
output_format: g.output_format,
name_template: g.name_template,
dedupe: g.dedupe,
enabled: g.enabled,
note: g.note,
})
+7 -6
View File
@@ -8,7 +8,7 @@ import type {
PreviewResult,
Source,
} from '../lib/types'
import { emptyFilter, emptyGroup } from '../lib/types'
import { emptyFilter, emptyGroup, writableGroup } from '../lib/types'
import { compactNumber, cx, publicLink, timeAgo, useAsync } from '../lib/util'
import { sourceHue } from '../lib/spectrum'
import { PageHeader } from '../components/Layout'
@@ -335,11 +335,12 @@ function GroupDrawer({
return Object.keys(e).length === 0
}
const payload = (): Partial<Group> => ({
...form,
name: form.name.trim(),
name_template: form.name_template.trim(),
})
const payload = (): Partial<Group> =>
writableGroup({
...form,
name: form.name.trim(),
name_template: form.name_template.trim(),
})
const save = async (): Promise<Group | null> => {
if (busy) return null
+3 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { api, ApiError } from '../lib/api'
import type { Source, TestResult } from '../lib/types'
import { emptySource } from '../lib/types'
import { emptySource, writableSource } from '../lib/types'
import {
compactNumber,
cx,
@@ -422,14 +422,14 @@ function SourceDrawer({
if (busy) return
if (!validate()) return
setBusy(true)
const payload: Partial<Source> = {
const payload: Partial<Source> = writableSource({
...form,
name: form.name.trim(),
url: form.url.trim(),
hwid: form.hwid.trim(),
user_agent: form.user_agent.trim(),
cache_ttl: Number(form.cache_ttl) || 0,
}
})
try {
if (isNew) {
await api.createSource(payload)