feedbag_list.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. package state
  2. import (
  3. "errors"
  4. "fmt"
  5. "math"
  6. "slices"
  7. "github.com/mk6i/open-oscar-server/wire"
  8. )
  9. // ErrGroupNotFound is returned when a feedbag group cannot be found.
  10. var ErrGroupNotFound = errors.New("group not found")
  11. // ErrGroupExists is returned when a feedbag group cannot be created or renamed
  12. // because another group already has the target name.
  13. var ErrGroupExists = errors.New("group already exists")
  14. // ErrBuddyNotFound is returned when a feedbag buddy cannot be found.
  15. var ErrBuddyNotFound = errors.New("buddy not found")
  16. // FeedbagList provides operations for manipulating a collection of feedbag
  17. // items. It supports lookups by class/name/group, item insertion with
  18. // automatic ID generation, and transparent root group management.
  19. type FeedbagList struct {
  20. items []*wire.FeedbagItem
  21. randInt func(int) int
  22. pendingUpdates []*wire.FeedbagItem
  23. pendingDeletes []*wire.FeedbagItem
  24. }
  25. // NewFeedbagList creates a FeedbagList from the given items. The randInt
  26. // function is used for generating unique item/group IDs; inject a
  27. // deterministic function in tests to assert exact feedbag item slices.
  28. func NewFeedbagList(items []wire.FeedbagItem, randInt func(int) int) *FeedbagList {
  29. ptrs := make([]*wire.FeedbagItem, len(items))
  30. for i := range items {
  31. ptrs[i] = &items[i]
  32. }
  33. return &FeedbagList{
  34. items: ptrs,
  35. randInt: randInt,
  36. }
  37. }
  38. // SetMode upserts the permit/deny mode item.
  39. func (f *FeedbagList) SetMode(mode uint8) {
  40. f.upsertItem(wire.FeedbagItem{
  41. ClassID: wire.FeedbagClassIdPdinfo,
  42. TLVLBlock: wire.TLVLBlock{
  43. TLVList: wire.TLVList{
  44. wire.NewTLVBE(wire.FeedbagAttributesPdMode, mode),
  45. },
  46. },
  47. })
  48. }
  49. // AddGroup returns the existing group with the given name or creates a new
  50. // one with an auto-generated GroupID. When a new group is created, the root
  51. // group's order TLV is updated to include it. Call PendingUpdates to retrieve
  52. // new or modified items for persistence. Returns the group item.
  53. func (f *FeedbagList) AddGroup(name string) wire.FeedbagItem {
  54. if g := f.groupByName(name); g != nil {
  55. return *g
  56. }
  57. group := &wire.FeedbagItem{
  58. ClassID: wire.FeedbagClassIdGroup,
  59. Name: name,
  60. GroupID: f.genID(),
  61. }
  62. f.items = append(f.items, group)
  63. root := f.rootGroup()
  64. root.AppendOrderMembers(group.GroupID)
  65. f.trackUpdate(root)
  66. f.trackUpdate(group)
  67. return *group
  68. }
  69. // DeleteGroup marks a group item for deletion by name. If the group exists
  70. // and is not the root group, the root group's order TLV is updated.
  71. func (f *FeedbagList) DeleteGroup(groupName string) {
  72. groupItem := f.groupByName(groupName)
  73. if groupItem == nil {
  74. return
  75. }
  76. var toDelete []*wire.FeedbagItem
  77. for _, item := range f.items {
  78. if item.GroupID == groupItem.GroupID {
  79. toDelete = append(toDelete, item)
  80. }
  81. }
  82. for _, item := range toDelete {
  83. f.deleteItem(*item)
  84. }
  85. if len(toDelete) > 0 && groupItem.GroupID != 0 {
  86. for _, item := range f.items {
  87. if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == 0 {
  88. item.RemoveOrderMembers(groupItem.GroupID)
  89. f.trackUpdate(item)
  90. }
  91. }
  92. }
  93. }
  94. // AddBuddy upserts a buddy item in the given group (by name), optionally
  95. // attaching alias and note attributes. Returns true if a new buddy was inserted.
  96. func (f *FeedbagList) AddBuddy(groupName, screenName, alias, note string) (bool, error) {
  97. var attrs wire.TLVList
  98. if alias != "" {
  99. attrs.Append(wire.NewTLVBE(wire.FeedbagAttributesAlias, alias))
  100. }
  101. if note != "" {
  102. attrs.Append(wire.NewTLVBE(wire.FeedbagAttributesNote, note))
  103. }
  104. return f.addBuddyItem(groupName, screenName, attrs)
  105. }
  106. // addBuddyItem upserts a buddy item carrying the given attribute TLVs into the
  107. // named group. Returns true if a new buddy was inserted.
  108. func (f *FeedbagList) addBuddyItem(groupName, screenName string, attrs wire.TLVList) (bool, error) {
  109. group := f.groupByName(groupName)
  110. if group == nil {
  111. return false, fmt.Errorf("group %q not found", groupName)
  112. }
  113. item := wire.FeedbagItem{
  114. ClassID: wire.FeedbagClassIdBuddy,
  115. GroupID: group.GroupID,
  116. Name: screenName,
  117. }
  118. item.TLVList = attrs
  119. result, inserted := f.upsertItem(item)
  120. if inserted {
  121. group.AppendOrderMembers(result.ItemID)
  122. f.trackUpdate(group)
  123. }
  124. return inserted, nil
  125. }
  126. // DeleteBuddy marks a buddy item for deletion in the given group (by name).
  127. // The parent group's order TLV is updated to remove the buddy.
  128. // Pass "*" as groupName to remove the buddy from all groups.
  129. func (f *FeedbagList) DeleteBuddy(groupName, buddyName string) error {
  130. var groups []*wire.FeedbagItem
  131. if groupName == "*" {
  132. // delete from all groups
  133. for _, item := range f.items {
  134. if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID != 0 {
  135. groups = append(groups, item)
  136. }
  137. }
  138. } else {
  139. group := f.groupByName(groupName)
  140. if group == nil {
  141. return fmt.Errorf("%w: %q", ErrGroupNotFound, groupName)
  142. }
  143. groups = []*wire.FeedbagItem{group}
  144. }
  145. for _, group := range groups {
  146. deleted, found := f.deleteItem(wire.FeedbagItem{
  147. ClassID: wire.FeedbagClassIdBuddy,
  148. GroupID: group.GroupID,
  149. Name: buddyName,
  150. })
  151. if found {
  152. group.RemoveOrderMembers(deleted.ItemID)
  153. f.trackUpdate(group)
  154. }
  155. }
  156. return nil
  157. }
  158. // RenameGroup changes a group's name in place, preserving its GroupID and
  159. // ItemID. Returns ErrGroupNotFound if oldName does not exist, or ErrGroupExists
  160. // if a different group already uses newName. The group is renamed in place
  161. // rather than re-inserted because upsertItem keys groups on name and would
  162. // otherwise create a duplicate.
  163. func (f *FeedbagList) RenameGroup(oldName, newName string) error {
  164. group := f.groupByName(oldName)
  165. if group == nil {
  166. return fmt.Errorf("%w: %q", ErrGroupNotFound, oldName)
  167. }
  168. if newName != oldName && f.groupByName(newName) != nil {
  169. return fmt.Errorf("%w: %q", ErrGroupExists, newName)
  170. }
  171. if group.Name == newName {
  172. return nil
  173. }
  174. group.Name = newName
  175. f.trackUpdate(group)
  176. return nil
  177. }
  178. // MoveBuddy moves a buddy between groups and/or repositions it within a group's
  179. // order. When toGroup names a different group than fromGroup, the buddy's
  180. // feedbag item is deleted from the source group and re-inserted into the
  181. // destination (a buddy's identity includes its GroupID at the protocol level),
  182. // carrying over all of its attribute TLVs. When beforeBuddy is non-empty,
  183. // the buddy is positioned immediately before that buddy in the destination
  184. // group's order; otherwise it is appended. Returns ErrGroupNotFound or
  185. // ErrBuddyNotFound if the source group/buddy or destination group is missing.
  186. func (f *FeedbagList) MoveBuddy(fromGroup, toGroup, buddyName, beforeBuddy string) error {
  187. src := f.groupByName(fromGroup)
  188. if src == nil {
  189. return fmt.Errorf("%w: %q", ErrGroupNotFound, fromGroup)
  190. }
  191. srcBuddy := f.buddyItem(src, buddyName)
  192. if srcBuddy == nil {
  193. return fmt.Errorf("%w: %q", ErrBuddyNotFound, buddyName)
  194. }
  195. dst := src
  196. if toGroup != "" && toGroup != fromGroup {
  197. dst = f.groupByName(toGroup)
  198. if dst == nil {
  199. return fmt.Errorf("%w: %q", ErrGroupNotFound, toGroup)
  200. }
  201. // preserve every attribute TLV (alias, note, auth state, etc.), not
  202. // just alias/note. Clone so the re-inserted item does not share a
  203. // backing array with the snapshot queued for deletion.
  204. attrs := slices.Clone(srcBuddy.TLVList)
  205. if err := f.DeleteBuddy(fromGroup, buddyName); err != nil {
  206. return err
  207. }
  208. if _, err := f.addBuddyItem(toGroup, buddyName, attrs); err != nil {
  209. return err
  210. }
  211. }
  212. if beforeBuddy != "" {
  213. moved := f.buddyItem(dst, buddyName)
  214. before := f.buddyItem(dst, beforeBuddy)
  215. if moved != nil && before != nil {
  216. f.reorderInGroupOrder(dst, moved.ItemID, before.ItemID)
  217. }
  218. }
  219. return nil
  220. }
  221. // SetBuddyAlias sets (or, when alias is empty, clears) the alias attribute on
  222. // every buddy item matching buddyName across all groups. Returns true if at
  223. // least one buddy item was found.
  224. func (f *FeedbagList) SetBuddyAlias(buddyName, alias string) (bool, error) {
  225. buddies := f.buddyItemsByName(buddyName)
  226. if len(buddies) == 0 {
  227. return false, nil
  228. }
  229. for _, buddy := range buddies {
  230. if alias != "" {
  231. buddy.Set(wire.NewTLVBE(wire.FeedbagAttributesAlias, alias))
  232. } else {
  233. buddy.Remove(wire.FeedbagAttributesAlias)
  234. }
  235. f.trackUpdate(buddy)
  236. }
  237. return true, nil
  238. }
  239. // SetGroupCollapsed sets (or, when collapsed is false, clears) the collapsed
  240. // attribute on a group. An empty groupName targets the unnamed default group.
  241. // Returns ErrGroupNotFound if the group does not exist.
  242. func (f *FeedbagList) SetGroupCollapsed(groupName string, collapsed bool) error {
  243. group := f.groupByName(groupName)
  244. if group == nil {
  245. return fmt.Errorf("%w: %q", ErrGroupNotFound, groupName)
  246. }
  247. if collapsed {
  248. group.Set(wire.NewTLVBE(wire.FeedbagAttributesCollapsed, []byte{}))
  249. } else {
  250. group.Remove(wire.FeedbagAttributesCollapsed)
  251. }
  252. f.trackUpdate(group)
  253. return nil
  254. }
  255. // PermitUser upserts a permit-list entry for the given screen name.
  256. func (f *FeedbagList) PermitUser(screenName string) {
  257. f.upsertItem(wire.FeedbagItem{
  258. ClassID: wire.FeedbagClassIDPermit,
  259. Name: screenName,
  260. })
  261. }
  262. // DenyUser upserts a deny-list entry for the given screen name.
  263. func (f *FeedbagList) DenyUser(screenName string) {
  264. f.upsertItem(wire.FeedbagItem{
  265. ClassID: wire.FeedbagClassIDDeny,
  266. Name: screenName,
  267. })
  268. }
  269. // DeletePermit marks a permit-list entry for deletion.
  270. func (f *FeedbagList) DeletePermit(screenName string) {
  271. f.deleteItem(wire.FeedbagItem{
  272. ClassID: wire.FeedbagClassIDPermit,
  273. Name: screenName,
  274. })
  275. }
  276. // DeleteDeny marks a deny-list entry for deletion.
  277. func (f *FeedbagList) DeleteDeny(screenName string) {
  278. f.deleteItem(wire.FeedbagItem{
  279. ClassID: wire.FeedbagClassIDDeny,
  280. Name: screenName,
  281. })
  282. }
  283. // AddLinkedScreenName adds a linked screen name.
  284. func (f *FeedbagList) AddLinkedScreenName(screenName string) {
  285. f.upsertItem(wire.FeedbagItem{
  286. ClassID: wire.FeedbagClassIdAlInfo,
  287. Name: screenName,
  288. })
  289. }
  290. // DeleteLinkedScreenName deletes a linked screen name.
  291. func (f *FeedbagList) DeleteLinkedScreenName(screenName string) {
  292. _, deleted := f.deleteItem(wire.FeedbagItem{
  293. ClassID: wire.FeedbagClassIdAlInfo,
  294. Name: screenName,
  295. })
  296. if deleted {
  297. // touch the root group so that the client purges its local
  298. // buddy list cache
  299. f.trackUpdate(f.rootGroup())
  300. }
  301. }
  302. // LinkedScreenNames returns all linked screen names in the feedbag.
  303. func (f *FeedbagList) LinkedScreenNames() []IdentScreenName {
  304. var names []IdentScreenName
  305. for _, item := range f.items {
  306. if item.ClassID == wire.FeedbagClassIdAlInfo {
  307. names = append(names, NewIdentScreenName(item.Name))
  308. }
  309. }
  310. return names
  311. }
  312. // HasLinkedScreenName returns whether the feedbag has a linked screen name.
  313. func (f *FeedbagList) HasLinkedScreenName(screenName string) bool {
  314. return slices.ContainsFunc(f.items, func(item *wire.FeedbagItem) bool {
  315. return item.ClassID == wire.FeedbagClassIdAlInfo && item.Name == NewIdentScreenName(screenName).String()
  316. })
  317. }
  318. // PendingUpdates returns items that were explicitly upserted via upsertItem
  319. // and items that were implicitly created or modified as side effects of other
  320. // operations (e.g., group order updates from upsertItem, root group updates
  321. // from AddGroup). The pending list is cleared after each call.
  322. func (f *FeedbagList) PendingUpdates() []wire.FeedbagItem {
  323. var result []wire.FeedbagItem
  324. for _, p := range f.pendingUpdates {
  325. result = append(result, *p)
  326. }
  327. f.pendingUpdates = nil
  328. if len(result) == 0 {
  329. return nil
  330. }
  331. return result
  332. }
  333. // PendingDeletes returns items marked for deletion via deleteItem.
  334. // The pending list is cleared after each call.
  335. func (f *FeedbagList) PendingDeletes() []wire.FeedbagItem {
  336. var result []wire.FeedbagItem
  337. for _, p := range f.pendingDeletes {
  338. result = append(result, *p)
  339. }
  340. f.pendingDeletes = nil
  341. return result
  342. }
  343. func (f *FeedbagList) Items() []wire.FeedbagItem {
  344. var result []wire.FeedbagItem
  345. for _, p := range f.items {
  346. result = append(result, *p)
  347. }
  348. return result
  349. }
  350. // rootGroup retrieves the root group, creating one if non-existent.
  351. func (f *FeedbagList) rootGroup() *wire.FeedbagItem {
  352. var root *wire.FeedbagItem
  353. for _, item := range f.items {
  354. if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == 0 {
  355. root = item
  356. break
  357. }
  358. }
  359. if root == nil {
  360. root = &wire.FeedbagItem{ClassID: wire.FeedbagClassIdGroup, GroupID: 0}
  361. f.items = append(f.items, root)
  362. f.trackUpdate(root)
  363. }
  364. return root
  365. }
  366. // groupByName returns the group item with the given name, or nil if not found.
  367. // The root group (GroupID 0) is never returned; it holds the master group order
  368. // rather than buddies, and an empty name matches the unnamed default buddy group.
  369. func (f *FeedbagList) groupByName(name string) *wire.FeedbagItem {
  370. for _, item := range f.items {
  371. if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID != 0 && item.Name == name {
  372. return item
  373. }
  374. }
  375. return nil
  376. }
  377. // buddyItem returns the buddy item with the given name in the given group, or
  378. // nil if not found. Names are normalized before comparison.
  379. func (f *FeedbagList) buddyItem(group *wire.FeedbagItem, buddyName string) *wire.FeedbagItem {
  380. want := NewIdentScreenName(buddyName).String()
  381. for _, item := range f.items {
  382. if item.ClassID != wire.FeedbagClassIdBuddy || item.GroupID != group.GroupID {
  383. continue
  384. }
  385. if NewIdentScreenName(item.Name).String() == want {
  386. return item
  387. }
  388. }
  389. return nil
  390. }
  391. // buddyItemsByName returns every buddy item matching buddyName across all
  392. // groups. Names are normalized before comparison.
  393. func (f *FeedbagList) buddyItemsByName(buddyName string) []*wire.FeedbagItem {
  394. want := NewIdentScreenName(buddyName).String()
  395. var out []*wire.FeedbagItem
  396. for _, item := range f.items {
  397. if item.ClassID != wire.FeedbagClassIdBuddy {
  398. continue
  399. }
  400. if NewIdentScreenName(item.Name).String() == want {
  401. out = append(out, item)
  402. }
  403. }
  404. return out
  405. }
  406. // reorderInGroupOrder moves itemID so that it sits immediately before
  407. // beforeItemID in the group's order TLV. If beforeItemID is not present, itemID
  408. // is appended. The group is tracked as updated.
  409. func (f *FeedbagList) reorderInGroupOrder(group *wire.FeedbagItem, itemID, beforeItemID uint16) {
  410. order, ok := group.Uint16SliceBE(wire.FeedbagAttributesOrder)
  411. if !ok {
  412. return
  413. }
  414. filtered := make([]uint16, 0, len(order))
  415. for _, id := range order {
  416. if id != itemID {
  417. filtered = append(filtered, id)
  418. }
  419. }
  420. insertAt := len(filtered)
  421. for i, id := range filtered {
  422. if id == beforeItemID {
  423. insertAt = i
  424. break
  425. }
  426. }
  427. reordered := make([]uint16, 0, len(filtered)+1)
  428. reordered = append(reordered, filtered[:insertAt]...)
  429. reordered = append(reordered, itemID)
  430. reordered = append(reordered, filtered[insertAt:]...)
  431. group.Set(wire.NewTLVBE(wire.FeedbagAttributesOrder, reordered))
  432. f.trackUpdate(group)
  433. }
  434. // trackUpdate adds item to the pending-updates list if not already present.
  435. func (f *FeedbagList) trackUpdate(item *wire.FeedbagItem) {
  436. if slices.Contains(f.pendingUpdates, item) {
  437. return // already tracked
  438. }
  439. f.pendingUpdates = append(f.pendingUpdates, item)
  440. }
  441. // itemsMatch reports whether two feedbag items are considered the same for
  442. // upsert/delete (buddy: ClassID, Name, GroupID; others: ClassID and Name).
  443. // Stored items are assumed to have normalized names; the input (b) name is
  444. // normalized for comparison when the class is buddy, permit, or deny.
  445. func (f *FeedbagList) itemsMatch(a, b *wire.FeedbagItem) bool {
  446. if a.ClassID != b.ClassID {
  447. return false
  448. }
  449. var nameMatch bool
  450. if hasScreenName(a.ClassID) {
  451. nameMatch = NewIdentScreenName(a.Name).String() == NewIdentScreenName(b.Name).String()
  452. } else {
  453. nameMatch = a.Name == b.Name
  454. }
  455. if !nameMatch {
  456. return false
  457. }
  458. if a.ClassID == wire.FeedbagClassIdBuddy {
  459. return a.GroupID == b.GroupID
  460. }
  461. return true
  462. }
  463. // deleteItem removes the first item matching the same criteria as upsertItem:
  464. // buddy items by ClassID, Name, and GroupID; other items by ClassID and Name.
  465. // Returns the deleted item and true if found, or a zero item and false otherwise.
  466. func (f *FeedbagList) deleteItem(item wire.FeedbagItem) (wire.FeedbagItem, bool) {
  467. for i, existing := range f.items {
  468. if f.itemsMatch(existing, &item) {
  469. f.pendingDeletes = append(f.pendingDeletes, existing)
  470. f.items = append(f.items[:i], f.items[i+1:]...)
  471. return *existing, true
  472. }
  473. }
  474. return wire.FeedbagItem{}, false
  475. }
  476. // upsertItem updates an existing feedbag item or inserts a new one. Buddy
  477. // items are matched by GroupID, ClassID, and Name; all other items are matched
  478. // by ClassID and Name. When matched, the existing item is replaced in place
  479. // (preserving its ItemID). When no match is found, a new item is inserted with
  480. // an auto-generated ItemID. Names for buddy, permit, and deny items are
  481. // normalized before storage. Returns the stored item and true if a new item
  482. // was inserted, or the existing item and false if it was updated/unchanged.
  483. func (f *FeedbagList) upsertItem(item wire.FeedbagItem) (wire.FeedbagItem, bool) {
  484. if hasScreenName(item.ClassID) {
  485. item.Name = NewIdentScreenName(item.Name).String() // normalize name
  486. }
  487. for _, existing := range f.items {
  488. if f.itemsMatch(existing, &item) {
  489. if !existing.IsEqual(item) {
  490. item.ItemID = existing.ItemID
  491. *existing = item
  492. f.trackUpdate(existing)
  493. }
  494. return *existing, false
  495. }
  496. }
  497. item.ItemID = f.genID()
  498. f.items = append(f.items, &item)
  499. f.pendingUpdates = append(f.pendingUpdates, &item)
  500. return item, true
  501. }
  502. // genID generates a unique ID that does not conflict with any existing ItemID
  503. // or GroupID in the list.
  504. func (f *FeedbagList) genID() uint16 {
  505. num := uint16(f.randInt(math.MaxUint16))
  506. for itemID := num; itemID != num-1; itemID++ {
  507. if itemID == 0 {
  508. continue
  509. }
  510. exists := false
  511. for _, item := range f.items {
  512. if item.GroupID == itemID || item.ItemID == itemID {
  513. exists = true
  514. break
  515. }
  516. }
  517. if !exists {
  518. return itemID
  519. }
  520. }
  521. return 0
  522. }
  523. // hasScreenName reports whether the feedbag class stores a screen name that
  524. // should be normalized (buddy, permit, deny).
  525. func hasScreenName(classID uint16) bool {
  526. return classID == wire.FeedbagClassIdBuddy ||
  527. classID == wire.FeedbagClassIDPermit ||
  528. classID == wire.FeedbagClassIdAlInfo ||
  529. classID == wire.FeedbagClassIDDeny
  530. }