feedbag_list.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. package state
  2. import (
  3. "fmt"
  4. "math"
  5. "slices"
  6. "github.com/mk6i/open-oscar-server/wire"
  7. )
  8. // FeedbagList provides operations for manipulating a collection of feedbag
  9. // items. It supports lookups by class/name/group, item insertion with
  10. // automatic ID generation, and transparent root group management.
  11. type FeedbagList struct {
  12. items []*wire.FeedbagItem
  13. randInt func(int) int
  14. pendingUpdates []*wire.FeedbagItem
  15. pendingDeletes []*wire.FeedbagItem
  16. }
  17. // NewFeedbagList creates a FeedbagList from the given items. The randInt
  18. // function is used for generating unique item/group IDs; inject a
  19. // deterministic function in tests to assert exact feedbag item slices.
  20. func NewFeedbagList(items []wire.FeedbagItem, randInt func(int) int) *FeedbagList {
  21. ptrs := make([]*wire.FeedbagItem, len(items))
  22. for i := range items {
  23. ptrs[i] = &items[i]
  24. }
  25. return &FeedbagList{
  26. items: ptrs,
  27. randInt: randInt,
  28. }
  29. }
  30. // SetMode upserts the permit/deny mode item.
  31. func (f *FeedbagList) SetMode(mode uint8) {
  32. f.upsertItem(wire.FeedbagItem{
  33. ClassID: wire.FeedbagClassIdPdinfo,
  34. TLVLBlock: wire.TLVLBlock{
  35. TLVList: wire.TLVList{
  36. wire.NewTLVBE(wire.FeedbagAttributesPdMode, mode),
  37. },
  38. },
  39. })
  40. }
  41. // AddGroup returns the existing group with the given name or creates a new
  42. // one with an auto-generated GroupID. When a new group is created, the root
  43. // group's order TLV is updated to include it. Call PendingUpdates to retrieve
  44. // new or modified items for persistence. Returns the group item.
  45. func (f *FeedbagList) AddGroup(name string) wire.FeedbagItem {
  46. if g := f.groupByName(name); g != nil {
  47. return *g
  48. }
  49. var root *wire.FeedbagItem
  50. for _, item := range f.items {
  51. if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == 0 {
  52. root = item
  53. break
  54. }
  55. }
  56. if root == nil {
  57. root = &wire.FeedbagItem{ClassID: wire.FeedbagClassIdGroup, GroupID: 0}
  58. f.items = append(f.items, root)
  59. f.trackUpdate(root)
  60. }
  61. group := &wire.FeedbagItem{
  62. ClassID: wire.FeedbagClassIdGroup,
  63. Name: name,
  64. GroupID: f.genID(),
  65. }
  66. f.items = append(f.items, group)
  67. root.AppendOrderMembers(group.GroupID)
  68. f.trackUpdate(root)
  69. f.trackUpdate(group)
  70. return *group
  71. }
  72. // DeleteGroup marks a group item for deletion by name. If the group exists
  73. // and is not the root group, the root group's order TLV is updated.
  74. func (f *FeedbagList) DeleteGroup(groupName string) {
  75. groupItem := f.groupByName(groupName)
  76. if groupItem == nil {
  77. return
  78. }
  79. var toDelete []*wire.FeedbagItem
  80. for _, item := range f.items {
  81. if item.GroupID == groupItem.GroupID {
  82. toDelete = append(toDelete, item)
  83. }
  84. }
  85. for _, item := range toDelete {
  86. f.deleteItem(*item)
  87. }
  88. if len(toDelete) > 0 && groupItem.GroupID != 0 {
  89. for _, item := range f.items {
  90. if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == 0 {
  91. item.RemoveOrderMembers(groupItem.GroupID)
  92. f.trackUpdate(item)
  93. }
  94. }
  95. }
  96. }
  97. // AddBuddy upserts a buddy item in the given group (by name), optionally
  98. // attaching alias and note attributes. Returns true if a new buddy was inserted.
  99. func (f *FeedbagList) AddBuddy(groupName, screenName, alias, note string) (bool, error) {
  100. group := f.groupByName(groupName)
  101. if group == nil {
  102. return false, fmt.Errorf("group %q not found", groupName)
  103. }
  104. item := wire.FeedbagItem{
  105. ClassID: wire.FeedbagClassIdBuddy,
  106. GroupID: group.GroupID,
  107. Name: screenName,
  108. }
  109. if alias != "" {
  110. item.Append(wire.NewTLVBE(wire.FeedbagAttributesAlias, alias))
  111. }
  112. if note != "" {
  113. item.Append(wire.NewTLVBE(wire.FeedbagAttributesNote, note))
  114. }
  115. result, inserted := f.upsertItem(item)
  116. if inserted {
  117. group.AppendOrderMembers(result.ItemID)
  118. f.trackUpdate(group)
  119. }
  120. return inserted, nil
  121. }
  122. // DeleteBuddy marks a buddy item for deletion in the given group (by name).
  123. // The parent group's order TLV is updated to remove the buddy.
  124. func (f *FeedbagList) DeleteBuddy(groupName, buddyName string) error {
  125. group := f.groupByName(groupName)
  126. if group == nil {
  127. return fmt.Errorf("group %q not found", groupName)
  128. }
  129. deleted, found := f.deleteItem(wire.FeedbagItem{
  130. ClassID: wire.FeedbagClassIdBuddy,
  131. GroupID: group.GroupID,
  132. Name: buddyName,
  133. })
  134. if found {
  135. group.RemoveOrderMembers(deleted.ItemID)
  136. f.trackUpdate(group)
  137. }
  138. return nil
  139. }
  140. // PermitUser upserts a permit-list entry for the given screen name.
  141. func (f *FeedbagList) PermitUser(screenName string) {
  142. f.upsertItem(wire.FeedbagItem{
  143. ClassID: wire.FeedbagClassIDPermit,
  144. Name: screenName,
  145. })
  146. }
  147. // DenyUser upserts a deny-list entry for the given screen name.
  148. func (f *FeedbagList) DenyUser(screenName string) {
  149. f.upsertItem(wire.FeedbagItem{
  150. ClassID: wire.FeedbagClassIDDeny,
  151. Name: screenName,
  152. })
  153. }
  154. // DeletePermit marks a permit-list entry for deletion.
  155. func (f *FeedbagList) DeletePermit(screenName string) {
  156. f.deleteItem(wire.FeedbagItem{
  157. ClassID: wire.FeedbagClassIDPermit,
  158. Name: screenName,
  159. })
  160. }
  161. // DeleteDeny marks a deny-list entry for deletion.
  162. func (f *FeedbagList) DeleteDeny(screenName string) {
  163. f.deleteItem(wire.FeedbagItem{
  164. ClassID: wire.FeedbagClassIDDeny,
  165. Name: screenName,
  166. })
  167. }
  168. // PendingUpdates returns items that were explicitly upserted via upsertItem
  169. // and items that were implicitly created or modified as side effects of other
  170. // operations (e.g., group order updates from upsertItem, root group updates
  171. // from AddGroup). The pending list is cleared after each call.
  172. func (f *FeedbagList) PendingUpdates() []wire.FeedbagItem {
  173. var result []wire.FeedbagItem
  174. for _, p := range f.pendingUpdates {
  175. result = append(result, *p)
  176. }
  177. f.pendingUpdates = nil
  178. if len(result) == 0 {
  179. return nil
  180. }
  181. return result
  182. }
  183. // PendingDeletes returns items marked for deletion via deleteItem.
  184. // The pending list is cleared after each call.
  185. func (f *FeedbagList) PendingDeletes() []wire.FeedbagItem {
  186. var result []wire.FeedbagItem
  187. for _, p := range f.pendingDeletes {
  188. result = append(result, *p)
  189. }
  190. f.pendingDeletes = nil
  191. return result
  192. }
  193. // groupByName returns the group item with the given name, or nil if not found.
  194. func (f *FeedbagList) groupByName(name string) *wire.FeedbagItem {
  195. for _, item := range f.items {
  196. if item.ClassID == wire.FeedbagClassIdGroup && item.Name == name {
  197. return item
  198. }
  199. }
  200. return nil
  201. }
  202. // trackUpdate adds item to the pending-updates list if not already present.
  203. func (f *FeedbagList) trackUpdate(item *wire.FeedbagItem) {
  204. if slices.Contains(f.pendingUpdates, item) {
  205. return // already tracked
  206. }
  207. f.pendingUpdates = append(f.pendingUpdates, item)
  208. }
  209. // itemsMatch reports whether two feedbag items are considered the same for
  210. // upsert/delete (buddy: ClassID, Name, GroupID; others: ClassID and Name).
  211. // Stored items are assumed to have normalized names; the input (b) name is
  212. // normalized for comparison when the class is buddy, permit, or deny.
  213. func (f *FeedbagList) itemsMatch(a, b *wire.FeedbagItem) bool {
  214. if a.ClassID != b.ClassID {
  215. return false
  216. }
  217. var nameMatch bool
  218. if hasScreenName(a.ClassID) {
  219. nameMatch = NewIdentScreenName(a.Name).String() == NewIdentScreenName(b.Name).String()
  220. } else {
  221. nameMatch = a.Name == b.Name
  222. }
  223. if !nameMatch {
  224. return false
  225. }
  226. if a.ClassID == wire.FeedbagClassIdBuddy {
  227. return a.GroupID == b.GroupID
  228. }
  229. return true
  230. }
  231. // deleteItem removes the first item matching the same criteria as upsertItem:
  232. // buddy items by ClassID, Name, and GroupID; other items by ClassID and Name.
  233. // Returns the deleted item and true if found, or a zero item and false otherwise.
  234. func (f *FeedbagList) deleteItem(item wire.FeedbagItem) (wire.FeedbagItem, bool) {
  235. for i, existing := range f.items {
  236. if f.itemsMatch(existing, &item) {
  237. f.pendingDeletes = append(f.pendingDeletes, existing)
  238. f.items = append(f.items[:i], f.items[i+1:]...)
  239. return *existing, true
  240. }
  241. }
  242. return wire.FeedbagItem{}, false
  243. }
  244. // upsertItem updates an existing feedbag item or inserts a new one. Buddy
  245. // items are matched by GroupID, ClassID, and Name; all other items are matched
  246. // by ClassID and Name. When matched, the existing item is replaced in place
  247. // (preserving its ItemID). When no match is found, a new item is inserted with
  248. // an auto-generated ItemID. Names for buddy, permit, and deny items are
  249. // normalized before storage. Returns the stored item and true if a new item
  250. // was inserted, or the existing item and false if it was updated/unchanged.
  251. func (f *FeedbagList) upsertItem(item wire.FeedbagItem) (wire.FeedbagItem, bool) {
  252. if hasScreenName(item.ClassID) {
  253. item.Name = NewIdentScreenName(item.Name).String() // normalize name
  254. }
  255. for _, existing := range f.items {
  256. if f.itemsMatch(existing, &item) {
  257. if !existing.IsEqual(item) {
  258. item.ItemID = existing.ItemID
  259. *existing = item
  260. f.trackUpdate(existing)
  261. }
  262. return *existing, false
  263. }
  264. }
  265. item.ItemID = f.genID()
  266. f.items = append(f.items, &item)
  267. f.pendingUpdates = append(f.pendingUpdates, &item)
  268. return item, true
  269. }
  270. // genID generates a unique ID that does not conflict with any existing ItemID
  271. // or GroupID in the list.
  272. func (f *FeedbagList) genID() uint16 {
  273. num := uint16(f.randInt(math.MaxUint16))
  274. for itemID := num; itemID != num-1; itemID++ {
  275. if itemID == 0 {
  276. continue
  277. }
  278. exists := false
  279. for _, item := range f.items {
  280. if item.GroupID == itemID || item.ItemID == itemID {
  281. exists = true
  282. break
  283. }
  284. }
  285. if !exists {
  286. return itemID
  287. }
  288. }
  289. return 0
  290. }
  291. // hasScreenName reports whether the feedbag class stores a screen name that
  292. // should be normalized (buddy, permit, deny).
  293. func hasScreenName(classID uint16) bool {
  294. return classID == wire.FeedbagClassIdBuddy ||
  295. classID == wire.FeedbagClassIDPermit ||
  296. classID == wire.FeedbagClassIDDeny
  297. }