Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a887090e9b |
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user