diff options
Diffstat (limited to 'hotline')
| -rw-r--r-- | hotline/config.go | 55 | ||||
| -rw-r--r-- | hotline/news.go | 25 | ||||
| -rw-r--r-- | hotline/time.go | 25 | ||||
| -rw-r--r-- | hotline/time_test.go | 13 |
4 files changed, 96 insertions, 22 deletions
diff --git a/hotline/config.go b/hotline/config.go index 8b6adff..b3fdcb8 100644 --- a/hotline/config.go +++ b/hotline/config.go @@ -1,19 +1,44 @@ package hotline +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// NewsFeedConfig maps an existing threaded-news category to an RSS or Atom +// feed. Feed entries are imported as ordinary articles in ThreadedNews.yaml. +type NewsFeedConfig struct { + CategoryPath []string `yaml:"CategoryPath" validate:"required,min=1,dive,required"` + URL string `yaml:"URL" validate:"required"` +} + +func (c *NewsFeedConfig) UnmarshalYAML(value *yaml.Node) error { + for i := 0; i+1 < len(value.Content); i += 2 { + key := value.Content[i].Value + if key != "CategoryPath" && key != "URL" { + return fmt.Errorf("unknown NewsFeeds setting %q", key) + } + } + type plainNewsFeedConfig NewsFeedConfig + return value.Decode((*plainNewsFeedConfig)(c)) +} + type Config struct { - Name string `yaml:"Name" validate:"required,max=50"` // Name used for Tracker registration - Description string `yaml:"Description" validate:"required,max=200"` // Description used for Tracker registration - BannerFile string `yaml:"BannerFile" validate:"omitempty,bannerext"` // Path to Banner jpg or gif - FileRoot string `yaml:"FileRoot" validate:"required"` // Path to Files - EnableTrackerRegistration bool `yaml:"EnableTrackerRegistration"` // Toggle Tracker Registration - Trackers []string `yaml:"Trackers" validate:"dive,hostname_port"` // List of trackers that the server should register with - NewsDelimiter string `yaml:"NewsDelimiter"` // String used to separate news posts - NewsDateFormat string `yaml:"NewsDateFormat"` // Go template string to customize news date format - MaxDownloads int `yaml:"MaxDownloads"` // Global simultaneous download limit - MaxDownloadsPerClient int `yaml:"MaxDownloadsPerClient"` // Per client simultaneous download limit - MaxConnectionsPerIP int `yaml:"MaxConnectionsPerIP"` // Max connections per IP - PreserveResourceForks bool `yaml:"PreserveResourceForks"` // Enable preservation of file info and resource forks in sidecar files - IgnoreFiles []string `yaml:"IgnoreFiles"` // List of regular expression for filtering files from the file list - EnableBonjour bool `yaml:"EnableBonjour"` // Enable service announcement on local network with Bonjour - Encoding string `yaml:"Encoding" validate:"omitempty,oneof=macintosh utf8"` // Text encoding for client communication + Name string `yaml:"Name" validate:"required,max=50"` // Name used for Tracker registration + Description string `yaml:"Description" validate:"required,max=200"` // Description used for Tracker registration + BannerFile string `yaml:"BannerFile" validate:"omitempty,bannerext"` // Path to Banner jpg or gif + FileRoot string `yaml:"FileRoot" validate:"required"` // Path to Files + EnableTrackerRegistration bool `yaml:"EnableTrackerRegistration"` // Toggle Tracker Registration + Trackers []string `yaml:"Trackers" validate:"dive,hostname_port"` // List of trackers that the server should register with + NewsDelimiter string `yaml:"NewsDelimiter"` // String used to separate news posts + NewsDateFormat string `yaml:"NewsDateFormat"` // Go template string to customize news date format + MaxDownloads int `yaml:"MaxDownloads"` // Global simultaneous download limit + MaxDownloadsPerClient int `yaml:"MaxDownloadsPerClient"` // Per client simultaneous download limit + MaxConnectionsPerIP int `yaml:"MaxConnectionsPerIP"` // Max connections per IP + PreserveResourceForks bool `yaml:"PreserveResourceForks"` // Enable preservation of file info and resource forks in sidecar files + IgnoreFiles []string `yaml:"IgnoreFiles"` // List of regular expression for filtering files from the file list + EnableBonjour bool `yaml:"EnableBonjour"` // Enable service announcement on local network with Bonjour + Encoding string `yaml:"Encoding" validate:"omitempty,oneof=macintosh utf8"` // Encoding for filesystem names and server-generated feed news + NewsFeeds []NewsFeedConfig `yaml:"NewsFeeds" validate:"dive"` // Existing threaded-news categories populated from remote feeds } diff --git a/hotline/news.go b/hotline/news.go index 033badb..b0e511b 100644 --- a/hotline/news.go +++ b/hotline/news.go @@ -28,14 +28,25 @@ type ThreadedNews struct { Categories map[string]NewsCategoryListData15 `yaml:"Categories"` } +// NewsFeedCategoryState is durable import metadata for a feed-mapped category. +// It is YAML-only: NewsCategoryListData15.Read explicitly serializes the +// Hotline protocol fields and therefore never exposes this state to clients. +type NewsFeedCategoryState struct { + SourceURL string `yaml:"SourceURL"` + ETag string `yaml:"ETag,omitempty"` + LastModified string `yaml:"LastModified,omitempty"` + Imported map[string]uint32 `yaml:"Imported"` +} + type NewsCategoryListData15 struct { - Type [2]byte `yaml:"Type,flow"` // Bundle (2) or category (3) - Name string `yaml:"Name"` - Articles map[uint32]*NewsArtData `yaml:"Articles"` // Optional, if Type is Category - SubCats map[string]NewsCategoryListData15 `yaml:"SubCats"` - GUID [16]byte `yaml:"-"` // What does this do? Undocumented and seeming unused. - AddSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused. - DeleteSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused. + Type [2]byte `yaml:"Type,flow"` // Bundle (2) or category (3) + Name string `yaml:"Name"` + Articles map[uint32]*NewsArtData `yaml:"Articles"` // Optional, if Type is Category + SubCats map[string]NewsCategoryListData15 `yaml:"SubCats"` + FeedState *NewsFeedCategoryState `yaml:"FeedState,omitempty"` + GUID [16]byte `yaml:"-"` // What does this do? Undocumented and seeming unused. + AddSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused. + DeleteSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused. readOffset int // Internal offset to track read progress writeOffset int // Internal offset to track write progress diff --git a/hotline/time.go b/hotline/time.go index b6d2d9c..47972d4 100644 --- a/hotline/time.go +++ b/hotline/time.go @@ -36,6 +36,22 @@ func NewTime(t time.Time) (b Time) { )) } +// NewNewsTime converts a time.Time to the year-relative timestamp encoding +// used by threaded-news article lists. Classic Hotline clients combine the +// leading year field with seconds elapsed since January 1 of that year. +// +// This intentionally differs from NewTime's Mac-epoch encoding, which is +// needed for compatibility with clients such as Pitbull Pro in other protocol +// fields. +func NewNewsTime(t time.Time) (b Time) { + localTime := t.In(time.Local) + startOfYear := time.Date(localTime.Year(), time.January, 1, 0, 0, 0, 0, time.Local) + + binary.BigEndian.PutUint16(b[0:2], uint16(localTime.Year())) + binary.BigEndian.PutUint32(b[4:8], uint32(localTime.Sub(startOfYear).Seconds())) + return b +} + // Time converts the Hotline Time format to a Go time.Time. // The Hotline format stores: Year (2 bytes) + milliseconds (2 bytes, unused) + // seconds since the Mac OS epoch (4 bytes). @@ -45,6 +61,15 @@ func (t Time) Time() time.Time { return macEpoch.Add(time.Duration(seconds) * time.Second) } +// NewsTime decodes the year-relative timestamp format produced by +// NewNewsTime. +func (t Time) NewsTime() time.Time { + year := binary.BigEndian.Uint16(t[0:2]) + seconds := binary.BigEndian.Uint32(t[4:8]) + startOfYear := time.Date(int(year), time.January, 1, 0, 0, 0, 0, time.Local) + return startOfYear.Add(time.Duration(seconds) * time.Second) +} + // Format returns the time formatted according to the layout string. // This is a convenience wrapper around Time().Format(). func (t Time) Format(layout string) string { diff --git a/hotline/time_test.go b/hotline/time_test.go index b52f3ef..f9ee5c4 100644 --- a/hotline/time_test.go +++ b/hotline/time_test.go @@ -47,3 +47,16 @@ func TestNewTime_SecondsSinceMacEpoch(t *testing.T) { t.Errorf("Mac-epoch decode = %v, want %v", decoded, tm) } } + +func TestNewNewsTime_UsesSecondsSinceStartOfYear(t *testing.T) { + want := time.Date(2026, time.August, 22, 18, 9, 55, 0, time.Local) + + encoded := NewNewsTime(want) + seconds := binary.BigEndian.Uint32(encoded[4:8]) + if seconds >= 366*24*60*60 { + t.Fatalf("news seconds field = %d, want a year-relative value", seconds) + } + if got := encoded.NewsTime(); !got.Equal(want) { + t.Errorf("news time round trip = %v, want %v", got, want) + } +} |