| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587 |
- package state
- import (
- "errors"
- "fmt"
- "math"
- "slices"
- "github.com/mk6i/open-oscar-server/wire"
- )
- // ErrGroupNotFound is returned when a feedbag group cannot be found.
- var ErrGroupNotFound = errors.New("group not found")
- // ErrGroupExists is returned when a feedbag group cannot be created or renamed
- // because another group already has the target name.
- var ErrGroupExists = errors.New("group already exists")
- // ErrBuddyNotFound is returned when a feedbag buddy cannot be found.
- var ErrBuddyNotFound = errors.New("buddy not found")
- // FeedbagList provides operations for manipulating a collection of feedbag
- // items. It supports lookups by class/name/group, item insertion with
- // automatic ID generation, and transparent root group management.
- type FeedbagList struct {
- items []*wire.FeedbagItem
- randInt func(int) int
- pendingUpdates []*wire.FeedbagItem
- pendingDeletes []*wire.FeedbagItem
- }
- // NewFeedbagList creates a FeedbagList from the given items. The randInt
- // function is used for generating unique item/group IDs; inject a
- // deterministic function in tests to assert exact feedbag item slices.
- func NewFeedbagList(items []wire.FeedbagItem, randInt func(int) int) *FeedbagList {
- ptrs := make([]*wire.FeedbagItem, len(items))
- for i := range items {
- ptrs[i] = &items[i]
- }
- return &FeedbagList{
- items: ptrs,
- randInt: randInt,
- }
- }
- // SetMode upserts the permit/deny mode item.
- func (f *FeedbagList) SetMode(mode uint8) {
- f.upsertItem(wire.FeedbagItem{
- ClassID: wire.FeedbagClassIdPdinfo,
- TLVLBlock: wire.TLVLBlock{
- TLVList: wire.TLVList{
- wire.NewTLVBE(wire.FeedbagAttributesPdMode, mode),
- },
- },
- })
- }
- // AddGroup returns the existing group with the given name or creates a new
- // one with an auto-generated GroupID. When a new group is created, the root
- // group's order TLV is updated to include it. Call PendingUpdates to retrieve
- // new or modified items for persistence. Returns the group item.
- func (f *FeedbagList) AddGroup(name string) wire.FeedbagItem {
- if g := f.groupByName(name); g != nil {
- return *g
- }
- group := &wire.FeedbagItem{
- ClassID: wire.FeedbagClassIdGroup,
- Name: name,
- GroupID: f.genID(),
- }
- f.items = append(f.items, group)
- root := f.rootGroup()
- root.AppendOrderMembers(group.GroupID)
- f.trackUpdate(root)
- f.trackUpdate(group)
- return *group
- }
- // DeleteGroup marks a group item for deletion by name. If the group exists
- // and is not the root group, the root group's order TLV is updated.
- func (f *FeedbagList) DeleteGroup(groupName string) {
- groupItem := f.groupByName(groupName)
- if groupItem == nil {
- return
- }
- var toDelete []*wire.FeedbagItem
- for _, item := range f.items {
- if item.GroupID == groupItem.GroupID {
- toDelete = append(toDelete, item)
- }
- }
- for _, item := range toDelete {
- f.deleteItem(*item)
- }
- if len(toDelete) > 0 && groupItem.GroupID != 0 {
- for _, item := range f.items {
- if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == 0 {
- item.RemoveOrderMembers(groupItem.GroupID)
- f.trackUpdate(item)
- }
- }
- }
- }
- // AddBuddy upserts a buddy item in the given group (by name), optionally
- // attaching alias and note attributes. Returns true if a new buddy was inserted.
- func (f *FeedbagList) AddBuddy(groupName, screenName, alias, note string) (bool, error) {
- var attrs wire.TLVList
- if alias != "" {
- attrs.Append(wire.NewTLVBE(wire.FeedbagAttributesAlias, alias))
- }
- if note != "" {
- attrs.Append(wire.NewTLVBE(wire.FeedbagAttributesNote, note))
- }
- return f.addBuddyItem(groupName, screenName, attrs)
- }
- // addBuddyItem upserts a buddy item carrying the given attribute TLVs into the
- // named group. Returns true if a new buddy was inserted.
- func (f *FeedbagList) addBuddyItem(groupName, screenName string, attrs wire.TLVList) (bool, error) {
- group := f.groupByName(groupName)
- if group == nil {
- return false, fmt.Errorf("group %q not found", groupName)
- }
- item := wire.FeedbagItem{
- ClassID: wire.FeedbagClassIdBuddy,
- GroupID: group.GroupID,
- Name: screenName,
- }
- item.TLVList = attrs
- result, inserted := f.upsertItem(item)
- if inserted {
- group.AppendOrderMembers(result.ItemID)
- f.trackUpdate(group)
- }
- return inserted, nil
- }
- // DeleteBuddy marks a buddy item for deletion in the given group (by name).
- // The parent group's order TLV is updated to remove the buddy.
- // Pass "*" as groupName to remove the buddy from all groups.
- func (f *FeedbagList) DeleteBuddy(groupName, buddyName string) error {
- var groups []*wire.FeedbagItem
- if groupName == "*" {
- // delete from all groups
- for _, item := range f.items {
- if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID != 0 {
- groups = append(groups, item)
- }
- }
- } else {
- group := f.groupByName(groupName)
- if group == nil {
- return fmt.Errorf("%w: %q", ErrGroupNotFound, groupName)
- }
- groups = []*wire.FeedbagItem{group}
- }
- for _, group := range groups {
- deleted, found := f.deleteItem(wire.FeedbagItem{
- ClassID: wire.FeedbagClassIdBuddy,
- GroupID: group.GroupID,
- Name: buddyName,
- })
- if found {
- group.RemoveOrderMembers(deleted.ItemID)
- f.trackUpdate(group)
- }
- }
- return nil
- }
- // RenameGroup changes a group's name in place, preserving its GroupID and
- // ItemID. Returns ErrGroupNotFound if oldName does not exist, or ErrGroupExists
- // if a different group already uses newName. The group is renamed in place
- // rather than re-inserted because upsertItem keys groups on name and would
- // otherwise create a duplicate.
- func (f *FeedbagList) RenameGroup(oldName, newName string) error {
- group := f.groupByName(oldName)
- if group == nil {
- return fmt.Errorf("%w: %q", ErrGroupNotFound, oldName)
- }
- if newName != oldName && f.groupByName(newName) != nil {
- return fmt.Errorf("%w: %q", ErrGroupExists, newName)
- }
- if group.Name == newName {
- return nil
- }
- group.Name = newName
- f.trackUpdate(group)
- return nil
- }
- // MoveBuddy moves a buddy between groups and/or repositions it within a group's
- // order. When toGroup names a different group than fromGroup, the buddy's
- // feedbag item is deleted from the source group and re-inserted into the
- // destination (a buddy's identity includes its GroupID at the protocol level),
- // carrying over all of its attribute TLVs. When beforeBuddy is non-empty,
- // the buddy is positioned immediately before that buddy in the destination
- // group's order; otherwise it is appended. Returns ErrGroupNotFound or
- // ErrBuddyNotFound if the source group/buddy or destination group is missing.
- func (f *FeedbagList) MoveBuddy(fromGroup, toGroup, buddyName, beforeBuddy string) error {
- src := f.groupByName(fromGroup)
- if src == nil {
- return fmt.Errorf("%w: %q", ErrGroupNotFound, fromGroup)
- }
- srcBuddy := f.buddyItem(src, buddyName)
- if srcBuddy == nil {
- return fmt.Errorf("%w: %q", ErrBuddyNotFound, buddyName)
- }
- dst := src
- if toGroup != "" && toGroup != fromGroup {
- dst = f.groupByName(toGroup)
- if dst == nil {
- return fmt.Errorf("%w: %q", ErrGroupNotFound, toGroup)
- }
- // preserve every attribute TLV (alias, note, auth state, etc.), not
- // just alias/note. Clone so the re-inserted item does not share a
- // backing array with the snapshot queued for deletion.
- attrs := slices.Clone(srcBuddy.TLVList)
- if err := f.DeleteBuddy(fromGroup, buddyName); err != nil {
- return err
- }
- if _, err := f.addBuddyItem(toGroup, buddyName, attrs); err != nil {
- return err
- }
- }
- if beforeBuddy != "" {
- moved := f.buddyItem(dst, buddyName)
- before := f.buddyItem(dst, beforeBuddy)
- if moved != nil && before != nil {
- f.reorderInGroupOrder(dst, moved.ItemID, before.ItemID)
- }
- }
- return nil
- }
- // SetBuddyAlias sets (or, when alias is empty, clears) the alias attribute on
- // every buddy item matching buddyName across all groups. Returns true if at
- // least one buddy item was found.
- func (f *FeedbagList) SetBuddyAlias(buddyName, alias string) (bool, error) {
- buddies := f.buddyItemsByName(buddyName)
- if len(buddies) == 0 {
- return false, nil
- }
- for _, buddy := range buddies {
- if alias != "" {
- buddy.Set(wire.NewTLVBE(wire.FeedbagAttributesAlias, alias))
- } else {
- buddy.Remove(wire.FeedbagAttributesAlias)
- }
- f.trackUpdate(buddy)
- }
- return true, nil
- }
- // SetGroupCollapsed sets (or, when collapsed is false, clears) the collapsed
- // attribute on a group. An empty groupName targets the unnamed default group.
- // Returns ErrGroupNotFound if the group does not exist.
- func (f *FeedbagList) SetGroupCollapsed(groupName string, collapsed bool) error {
- group := f.groupByName(groupName)
- if group == nil {
- return fmt.Errorf("%w: %q", ErrGroupNotFound, groupName)
- }
- if collapsed {
- group.Set(wire.NewTLVBE(wire.FeedbagAttributesCollapsed, []byte{}))
- } else {
- group.Remove(wire.FeedbagAttributesCollapsed)
- }
- f.trackUpdate(group)
- return nil
- }
- // PermitUser upserts a permit-list entry for the given screen name.
- func (f *FeedbagList) PermitUser(screenName string) {
- f.upsertItem(wire.FeedbagItem{
- ClassID: wire.FeedbagClassIDPermit,
- Name: screenName,
- })
- }
- // DenyUser upserts a deny-list entry for the given screen name.
- func (f *FeedbagList) DenyUser(screenName string) {
- f.upsertItem(wire.FeedbagItem{
- ClassID: wire.FeedbagClassIDDeny,
- Name: screenName,
- })
- }
- // DeletePermit marks a permit-list entry for deletion.
- func (f *FeedbagList) DeletePermit(screenName string) {
- f.deleteItem(wire.FeedbagItem{
- ClassID: wire.FeedbagClassIDPermit,
- Name: screenName,
- })
- }
- // DeleteDeny marks a deny-list entry for deletion.
- func (f *FeedbagList) DeleteDeny(screenName string) {
- f.deleteItem(wire.FeedbagItem{
- ClassID: wire.FeedbagClassIDDeny,
- Name: screenName,
- })
- }
- // AddLinkedScreenName adds a linked screen name.
- func (f *FeedbagList) AddLinkedScreenName(screenName string) {
- f.upsertItem(wire.FeedbagItem{
- ClassID: wire.FeedbagClassIdAlInfo,
- Name: screenName,
- })
- }
- // DeleteLinkedScreenName deletes a linked screen name.
- func (f *FeedbagList) DeleteLinkedScreenName(screenName string) {
- _, deleted := f.deleteItem(wire.FeedbagItem{
- ClassID: wire.FeedbagClassIdAlInfo,
- Name: screenName,
- })
- if deleted {
- // touch the root group so that the client purges its local
- // buddy list cache
- f.trackUpdate(f.rootGroup())
- }
- }
- // LinkedScreenNames returns all linked screen names in the feedbag.
- func (f *FeedbagList) LinkedScreenNames() []IdentScreenName {
- var names []IdentScreenName
- for _, item := range f.items {
- if item.ClassID == wire.FeedbagClassIdAlInfo {
- names = append(names, NewIdentScreenName(item.Name))
- }
- }
- return names
- }
- // HasLinkedScreenName returns whether the feedbag has a linked screen name.
- func (f *FeedbagList) HasLinkedScreenName(screenName string) bool {
- return slices.ContainsFunc(f.items, func(item *wire.FeedbagItem) bool {
- return item.ClassID == wire.FeedbagClassIdAlInfo && item.Name == NewIdentScreenName(screenName).String()
- })
- }
- // PendingUpdates returns items that were explicitly upserted via upsertItem
- // and items that were implicitly created or modified as side effects of other
- // operations (e.g., group order updates from upsertItem, root group updates
- // from AddGroup). The pending list is cleared after each call.
- func (f *FeedbagList) PendingUpdates() []wire.FeedbagItem {
- var result []wire.FeedbagItem
- for _, p := range f.pendingUpdates {
- result = append(result, *p)
- }
- f.pendingUpdates = nil
- if len(result) == 0 {
- return nil
- }
- return result
- }
- // PendingDeletes returns items marked for deletion via deleteItem.
- // The pending list is cleared after each call.
- func (f *FeedbagList) PendingDeletes() []wire.FeedbagItem {
- var result []wire.FeedbagItem
- for _, p := range f.pendingDeletes {
- result = append(result, *p)
- }
- f.pendingDeletes = nil
- return result
- }
- func (f *FeedbagList) Items() []wire.FeedbagItem {
- var result []wire.FeedbagItem
- for _, p := range f.items {
- result = append(result, *p)
- }
- return result
- }
- // rootGroup retrieves the root group, creating one if non-existent.
- func (f *FeedbagList) rootGroup() *wire.FeedbagItem {
- var root *wire.FeedbagItem
- for _, item := range f.items {
- if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == 0 {
- root = item
- break
- }
- }
- if root == nil {
- root = &wire.FeedbagItem{ClassID: wire.FeedbagClassIdGroup, GroupID: 0}
- f.items = append(f.items, root)
- f.trackUpdate(root)
- }
- return root
- }
- // groupByName returns the group item with the given name, or nil if not found.
- // The root group (GroupID 0) is never returned; it holds the master group order
- // rather than buddies, and an empty name matches the unnamed default buddy group.
- func (f *FeedbagList) groupByName(name string) *wire.FeedbagItem {
- for _, item := range f.items {
- if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID != 0 && item.Name == name {
- return item
- }
- }
- return nil
- }
- // buddyItem returns the buddy item with the given name in the given group, or
- // nil if not found. Names are normalized before comparison.
- func (f *FeedbagList) buddyItem(group *wire.FeedbagItem, buddyName string) *wire.FeedbagItem {
- want := NewIdentScreenName(buddyName).String()
- for _, item := range f.items {
- if item.ClassID != wire.FeedbagClassIdBuddy || item.GroupID != group.GroupID {
- continue
- }
- if NewIdentScreenName(item.Name).String() == want {
- return item
- }
- }
- return nil
- }
- // buddyItemsByName returns every buddy item matching buddyName across all
- // groups. Names are normalized before comparison.
- func (f *FeedbagList) buddyItemsByName(buddyName string) []*wire.FeedbagItem {
- want := NewIdentScreenName(buddyName).String()
- var out []*wire.FeedbagItem
- for _, item := range f.items {
- if item.ClassID != wire.FeedbagClassIdBuddy {
- continue
- }
- if NewIdentScreenName(item.Name).String() == want {
- out = append(out, item)
- }
- }
- return out
- }
- // reorderInGroupOrder moves itemID so that it sits immediately before
- // beforeItemID in the group's order TLV. If beforeItemID is not present, itemID
- // is appended. The group is tracked as updated.
- func (f *FeedbagList) reorderInGroupOrder(group *wire.FeedbagItem, itemID, beforeItemID uint16) {
- order, ok := group.Uint16SliceBE(wire.FeedbagAttributesOrder)
- if !ok {
- return
- }
- filtered := make([]uint16, 0, len(order))
- for _, id := range order {
- if id != itemID {
- filtered = append(filtered, id)
- }
- }
- insertAt := len(filtered)
- for i, id := range filtered {
- if id == beforeItemID {
- insertAt = i
- break
- }
- }
- reordered := make([]uint16, 0, len(filtered)+1)
- reordered = append(reordered, filtered[:insertAt]...)
- reordered = append(reordered, itemID)
- reordered = append(reordered, filtered[insertAt:]...)
- group.Set(wire.NewTLVBE(wire.FeedbagAttributesOrder, reordered))
- f.trackUpdate(group)
- }
- // trackUpdate adds item to the pending-updates list if not already present.
- func (f *FeedbagList) trackUpdate(item *wire.FeedbagItem) {
- if slices.Contains(f.pendingUpdates, item) {
- return // already tracked
- }
- f.pendingUpdates = append(f.pendingUpdates, item)
- }
- // itemsMatch reports whether two feedbag items are considered the same for
- // upsert/delete (buddy: ClassID, Name, GroupID; others: ClassID and Name).
- // Stored items are assumed to have normalized names; the input (b) name is
- // normalized for comparison when the class is buddy, permit, or deny.
- func (f *FeedbagList) itemsMatch(a, b *wire.FeedbagItem) bool {
- if a.ClassID != b.ClassID {
- return false
- }
- var nameMatch bool
- if hasScreenName(a.ClassID) {
- nameMatch = NewIdentScreenName(a.Name).String() == NewIdentScreenName(b.Name).String()
- } else {
- nameMatch = a.Name == b.Name
- }
- if !nameMatch {
- return false
- }
- if a.ClassID == wire.FeedbagClassIdBuddy {
- return a.GroupID == b.GroupID
- }
- return true
- }
- // deleteItem removes the first item matching the same criteria as upsertItem:
- // buddy items by ClassID, Name, and GroupID; other items by ClassID and Name.
- // Returns the deleted item and true if found, or a zero item and false otherwise.
- func (f *FeedbagList) deleteItem(item wire.FeedbagItem) (wire.FeedbagItem, bool) {
- for i, existing := range f.items {
- if f.itemsMatch(existing, &item) {
- f.pendingDeletes = append(f.pendingDeletes, existing)
- f.items = append(f.items[:i], f.items[i+1:]...)
- return *existing, true
- }
- }
- return wire.FeedbagItem{}, false
- }
- // upsertItem updates an existing feedbag item or inserts a new one. Buddy
- // items are matched by GroupID, ClassID, and Name; all other items are matched
- // by ClassID and Name. When matched, the existing item is replaced in place
- // (preserving its ItemID). When no match is found, a new item is inserted with
- // an auto-generated ItemID. Names for buddy, permit, and deny items are
- // normalized before storage. Returns the stored item and true if a new item
- // was inserted, or the existing item and false if it was updated/unchanged.
- func (f *FeedbagList) upsertItem(item wire.FeedbagItem) (wire.FeedbagItem, bool) {
- if hasScreenName(item.ClassID) {
- item.Name = NewIdentScreenName(item.Name).String() // normalize name
- }
- for _, existing := range f.items {
- if f.itemsMatch(existing, &item) {
- if !existing.IsEqual(item) {
- item.ItemID = existing.ItemID
- *existing = item
- f.trackUpdate(existing)
- }
- return *existing, false
- }
- }
- item.ItemID = f.genID()
- f.items = append(f.items, &item)
- f.pendingUpdates = append(f.pendingUpdates, &item)
- return item, true
- }
- // genID generates a unique ID that does not conflict with any existing ItemID
- // or GroupID in the list.
- func (f *FeedbagList) genID() uint16 {
- num := uint16(f.randInt(math.MaxUint16))
- for itemID := num; itemID != num-1; itemID++ {
- if itemID == 0 {
- continue
- }
- exists := false
- for _, item := range f.items {
- if item.GroupID == itemID || item.ItemID == itemID {
- exists = true
- break
- }
- }
- if !exists {
- return itemID
- }
- }
- return 0
- }
- // hasScreenName reports whether the feedbag class stores a screen name that
- // should be normalized (buddy, permit, deny).
- func hasScreenName(classID uint16) bool {
- return classID == wire.FeedbagClassIdBuddy ||
- classID == wire.FeedbagClassIDPermit ||
- classID == wire.FeedbagClassIdAlInfo ||
- classID == wire.FeedbagClassIDDeny
- }
|