using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using Content.Shared.Access.Components; using Content.Shared.DeviceLinking.Events; using Content.Shared.Emag.Systems; using Content.Shared.Examine; using Content.Shared.GameTicking; using Content.Shared.Hands.EntitySystems; using Content.Shared.IdentityManagement; using Content.Shared.Inventory; using Content.Shared.Localizations; using Content.Shared.Lock; using Content.Shared.NameIdentifier; using Content.Shared.PDA; using Content.Shared.StationRecords; using Content.Shared.Tag; using Robust.Shared.Containers; using Robust.Shared.Collections; using Robust.Shared.GameStates; using Robust.Shared.Prototypes; using Robust.Shared.Timing; namespace Content.Shared.Access.Systems; public sealed class AccessReaderSystem : EntitySystem { [Dependency] private readonly IPrototypeManager _prototype = default!; [Dependency] private readonly InventorySystem _inventorySystem = default!; [Dependency] private readonly IGameTiming _gameTiming = default!; [Dependency] private readonly EmagSystem _emag = default!; [Dependency] private readonly TagSystem _tag = default!; [Dependency] private readonly SharedGameTicker _gameTicker = default!; [Dependency] private readonly SharedHandsSystem _handsSystem = default!; [Dependency] private readonly SharedContainerSystem _containerSystem = default!; [Dependency] private readonly SharedStationRecordsSystem _recordsSystem = default!; private static readonly ProtoId PreventAccessLoggingTag = "PreventAccessLogging"; public override void Initialize() { base.Initialize(); SubscribeLocalEvent(OnExamined); SubscribeLocalEvent(OnEmagged); SubscribeLocalEvent(OnLinkAttempt); SubscribeLocalEvent(OnConfigurationAttempt); SubscribeLocalEvent(OnFindAvailableLocks); SubscribeLocalEvent(OnCheckLockAccess); SubscribeLocalEvent(OnGetState); SubscribeLocalEvent(OnHandleState); } private void OnExamined(Entity ent, ref ExaminedEvent args) { if (!GetMainAccessReader(ent, out var mainAccessReader)) return; mainAccessReader.Value.Comp.AccessListsOriginal ??= new(mainAccessReader.Value.Comp.AccessLists); var accessHasBeenModified = mainAccessReader.Value.Comp.AccessLists.Count != mainAccessReader.Value.Comp.AccessListsOriginal.Count; if (!accessHasBeenModified) { foreach (var accessSubgroup in mainAccessReader.Value.Comp.AccessLists) { if (!mainAccessReader.Value.Comp.AccessListsOriginal.Any(y => y.SetEquals(accessSubgroup))) { accessHasBeenModified = true; break; } } } var examiner = args.Examiner; var canSeeAccessModification = accessHasBeenModified && (HasComp(examiner) || _inventorySystem.TryGetInventoryEntity(examiner, out _)); if (canSeeAccessModification) { var localizedCurrentNames = GetLocalizedAccessNames(mainAccessReader.Value.Comp.AccessLists); var accessesFormatted = ContentLocalizationManager.FormatListToOr(localizedCurrentNames); var currentSettingsMessage = localizedCurrentNames.Count > 0 ? Loc.GetString("access-reader-access-settings-modified-message", ("access", accessesFormatted)) : Loc.GetString("access-reader-access-settings-removed-message"); args.PushMarkup(currentSettingsMessage); return; } var localizedOriginalNames = GetLocalizedAccessNames(mainAccessReader.Value.Comp.AccessListsOriginal); // If the string list is empty either there were no access restrictions or the localized names were invalid if (localizedOriginalNames.Count == 0) return; var originalAccessesFormatted = ContentLocalizationManager.FormatListToOr(localizedOriginalNames); var originalSettingsMessage = Loc.GetString(mainAccessReader.Value.Comp.ExaminationText, ("access", originalAccessesFormatted)); args.PushMarkup(originalSettingsMessage); } private void OnGetState(EntityUid uid, AccessReaderComponent component, ref ComponentGetState args) { args.State = new AccessReaderComponentState( component.Enabled, component.DenyTags, component.AccessLists, component.AccessListsOriginal, _recordsSystem.Convert(component.AccessKeys), component.AccessLog, component.AccessLogLimit); } private void OnHandleState(EntityUid uid, AccessReaderComponent component, ref ComponentHandleState args) { if (args.Current is not AccessReaderComponentState state) return; component.Enabled = state.Enabled; component.AccessKeys.Clear(); foreach (var key in state.AccessKeys) { var id = EnsureEntity(key.Item1, uid); if (!id.IsValid()) continue; component.AccessKeys.Add(new StationRecordKey(key.Item2, id)); } component.AccessLists = new(state.AccessLists); component.AccessListsOriginal = state.AccessListsOriginal == null ? null : new(state.AccessListsOriginal); component.DenyTags = new(state.DenyTags); component.AccessLog = new(state.AccessLog); component.AccessLogLimit = state.AccessLogLimit; } private void OnLinkAttempt(EntityUid uid, AccessReaderComponent component, LinkAttemptEvent args) { if (args.User == null) // AutoLink (and presumably future external linkers) have no user. return; if (!IsAllowed(args.User.Value, uid, component)) args.Cancel(); } private void OnEmagged(EntityUid uid, AccessReaderComponent reader, ref GotEmaggedEvent args) { if (!_emag.CompareFlag(args.Type, EmagType.Access)) return; if (!reader.BreakOnAccessBreaker) return; if (!GetMainAccessReader(uid, out var accessReader)) return; if (accessReader.Value.Comp.AccessLists.Count < 1) return; args.Repeatable = true; args.Handled = true; accessReader.Value.Comp.AccessLists.Clear(); accessReader.Value.Comp.AccessLog.Clear(); Dirty(uid, reader); } private void OnConfigurationAttempt(Entity ent, ref AccessReaderConfigurationAttemptEvent args) { // The first time that the access list of the reader is modified, // make a copy of the original settings ent.Comp.AccessListsOriginal ??= new(ent.Comp.AccessLists); } private void OnFindAvailableLocks(Entity ent, ref FindAvailableLocksEvent args) { args.FoundReaders |= LockTypes.Access; } private void OnCheckLockAccess(Entity ent, ref CheckUserHasLockAccessEvent args) { // Are we looking for an access lock? if (!args.FoundReaders.HasFlag(LockTypes.Access)) return; // If the user has access to this lock, we pass it into the event. if (IsAllowed(args.User, ent)) args.HasAccess |= LockTypes.Access; } /// /// Searches the source for access tags /// then compares it with the all targets accesses to see if it is allowed. /// /// The entity that wants access. /// The entity to search for an access reader /// Optional reader from the target entity public bool IsAllowed(EntityUid user, EntityUid target, AccessReaderComponent? reader = null) { if (!Resolve(target, ref reader, false)) return true; if (!reader.Enabled) return true; var accessSources = FindPotentialAccessItems(user); var access = FindAccessTags(user, accessSources); FindStationRecordKeys(user, out var stationKeys, accessSources); if (!IsAllowed(access, stationKeys, target, reader)) return false; if (!_tag.HasTag(user, PreventAccessLoggingTag)) LogAccess((target, reader), user); return true; } /// /// Searches an entity for an access reader. This is either the entity itself or an entity in its . /// /// The entity being searched for an access reader. /// The returned access reader entity. public bool GetMainAccessReader(EntityUid uid, [NotNullWhen(true)] out Entity? ent) { ent = null; if (!TryComp(uid, out var accessReader)) return false; ent = (uid, accessReader); if (ent.Value.Comp.ContainerAccessProvider == null) return true; if (!_containerSystem.TryGetContainer(uid, ent.Value.Comp.ContainerAccessProvider, out var container)) return true; foreach (var entity in container.ContainedEntities) { if (TryComp(entity, out var containedReader)) { ent = (entity, containedReader); return true; } } return true; } /// /// Check whether the given access permissions satisfy an access reader's requirements. /// /// A collection of access permissions being used on the access reader. /// A collection of station record keys being used on the access reader. /// The entity being checked. /// The access reader being checked. public bool IsAllowed( ICollection> access, ICollection stationKeys, EntityUid target, AccessReaderComponent reader) { if (!reader.Enabled) return true; if (reader.ContainerAccessProvider == null) return IsAllowedInternal(access, stationKeys, reader); if (!_containerSystem.TryGetContainer(target, reader.ContainerAccessProvider, out var container)) return false; // If entity is paused then always allow it at this point. // Door electronics is kind of a mess but yeah, it should only be an unpaused ent interacting with it if (Paused(target)) return true; foreach (var entity in container.ContainedEntities) { if (!TryComp(entity, out AccessReaderComponent? containedReader)) continue; if (IsAllowed(access, stationKeys, entity, containedReader)) return true; } return false; } private bool IsAllowedInternal(ICollection> access, ICollection stationKeys, AccessReaderComponent reader) { return !reader.Enabled || AreAccessTagsAllowed(access, reader) || AreStationRecordKeysAllowed(stationKeys, reader); } /// /// Compares the given tags with the readers access list to see if it is allowed. /// /// A list of access tags. /// The access reader to check against. public bool AreAccessTagsAllowed(ICollection> accessTags, AccessReaderComponent reader) { if (reader.DenyTags.Overlaps(accessTags)) { // Sec owned by cargo. // Note that in resolving the issue with only one specific item "counting" for access, this became a bit more strict. // As having an ID card in any slot that "counts" with a denied access group will cause denial of access. // DenyTags doesn't seem to be used right now anyway, though, so it'll be dependent on whoever uses it to figure out if this matters. return false; } if (reader.AccessLists.Count == 0) return true; foreach (var set in reader.AccessLists) { if (set.IsSubsetOf(accessTags)) return true; } return false; } /// /// Compares the given stationrecordkeys with the accessreader to see if it is allowed. /// /// The collection of station record keys being used against the access reader. /// The access reader that is being checked. public bool AreStationRecordKeysAllowed(ICollection keys, AccessReaderComponent reader) { foreach (var key in reader.AccessKeys) { if (keys.Contains(key)) return true; } return false; } /// /// Finds all the items that could potentially give access to an entity. /// /// The entity that is being searched. public HashSet FindPotentialAccessItems(EntityUid uid) { FindAccessItemsInventory(uid, out var items); var ev = new GetAdditionalAccessEvent { Entities = items }; RaiseLocalEvent(uid, ref ev); foreach (var item in new ValueList(items)) { items.UnionWith(FindPotentialAccessItems(item)); } items.Add(uid); return items; } /// /// Finds the access tags on an entity. /// /// The entity that is being searched. /// All of the items to search for access. If none are passed in, will be used. public ICollection> FindAccessTags(EntityUid uid, HashSet? items = null) { HashSet>? tags = null; var owned = false; items ??= FindPotentialAccessItems(uid); foreach (var ent in items) { FindAccessTagsItem(ent, ref tags, ref owned); } return (ICollection>?)tags ?? Array.Empty>(); } /// /// Finds any station record keys on an entity. /// /// The entity that is being searched. /// A collection of the station record keys that were found. /// All of the items to search for access. If none are passed in, will be used. public bool FindStationRecordKeys(EntityUid uid, out ICollection recordKeys, HashSet? items = null) { recordKeys = new HashSet(); items ??= FindPotentialAccessItems(uid); foreach (var ent in items) { if (FindStationRecordKeyItem(ent, out var key)) recordKeys.Add(key.Value); } return recordKeys.Any(); } /// /// Try to find on this item or inside this item (if it's a PDA). /// This version merges into a set or replaces the set. /// /// The entity that is being searched. /// The access tags being merged or replaced. /// If true, the tags will be merged. Otherwise they are replaced. private void FindAccessTagsItem(EntityUid uid, ref HashSet>? tags, ref bool owned) { if (!FindAccessTagsItem(uid, out var targetTags)) { // no tags, no problem return; } if (tags != null) { // existing tags, so copy to make sure we own them if (!owned) { tags = new(tags); owned = true; } // then merge tags.UnionWith(targetTags); } else { // no existing tags, so now they're ours tags = targetTags; owned = false; } } #region: AccessLists API /// /// Tries to clear the entity's . /// /// The access reader entity which is having its access permissions cleared. public void TryClearAccesses(Entity ent) { if (CanConfigureAccessReader(ent)) { ClearAccesses(ent); } } /// /// Clears the entity's . /// /// The access reader entity which is having its access permissions cleared. private void ClearAccesses(Entity ent) { ent.Comp.AccessLists.Clear(); Dirty(ent); RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent()); } /// /// Tries to replace the access permissions in an entity's with a supplied list. /// /// The access reader entity which is having its list of access permissions replaced. /// The list of access permissions replacing the original one. public void TrySetAccesses(Entity ent, List>> accesses) { if (CanConfigureAccessReader(ent)) { SetAccesses(ent, accesses); } } /// /// Replaces the access permissions in an entity's with a supplied list. /// /// The access reader entity which is having its list of access permissions replaced. /// The list of access permissions replacing the original one. private void SetAccesses(Entity ent, List>> accesses) { ent.Comp.AccessLists.Clear(); AddAccesses(ent, accesses); } /// public void TrySetAccesses(Entity ent, List> accesses) { if (CanConfigureAccessReader(ent)) { SetAccesses(ent, accesses); } } /// private void SetAccesses(Entity ent, List> accesses) { ent.Comp.AccessLists.Clear(); AddAccesses(ent, accesses); } /// /// Tries to add a collection of access permissions to an access reader entity's /// /// The access reader entity to which the new access permissions are being added. /// The list of access permissions being added. public void TryAddAccesses(Entity ent, List>> accesses) { if (CanConfigureAccessReader(ent)) { AddAccesses(ent, accesses); } } /// /// Adds a collection of access permissions to an access reader entity's /// /// The access reader entity to which the new access permissions are being added. /// The list of access permissions being added. private void AddAccesses(Entity ent, List>> accesses) { foreach (var access in accesses) { AddAccess(ent, access, false); } Dirty(ent); RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent()); } /// public void TryAddAccesses(Entity ent, List> accesses) { if (CanConfigureAccessReader(ent)) { AddAccesses(ent, accesses); } } /// private void AddAccesses(Entity ent, List> accesses) { foreach (var access in accesses) { AddAccess(ent, access, false); } Dirty(ent); RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent()); } /// /// Tries to add an access permission to an access reader entity's /// /// The access reader entity to which the access permission is being added. /// The access permission being added. /// If true, the component will be marked as changed afterward. public void TryAddAccess(Entity ent, HashSet> access) { if (CanConfigureAccessReader(ent)) { AddAccess(ent, access); } } /// /// Adds an access permission to an access reader entity's /// /// The access reader entity to which the access permission is being added. /// The access permission being added. /// If true, the component will be marked as changed afterward. private void AddAccess(Entity ent, HashSet> access, bool dirty = true) { ent.Comp.AccessLists.Add(access); if (!dirty) return; Dirty(ent); RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent()); } /// public void TryAddAccess(Entity ent, ProtoId access) { if (CanConfigureAccessReader(ent)) { AddAccess(ent, access); } } /// private void AddAccess(Entity ent, ProtoId access, bool dirty = true) { AddAccess(ent, new HashSet>() { access }, dirty); } /// /// Tries to remove a collection of access permissions from an access reader entity's /// /// The access reader entity from which the access permissions are being removed. /// The list of access permissions being removed. public void TryRemoveAccesses(Entity ent, List>> accesses) { if (CanConfigureAccessReader(ent)) { RemoveAccesses(ent, accesses); } } /// /// Removes a collection of access permissions from an access reader entity's /// /// The access reader entity from which the access permissions are being removed. /// The list of access permissions being removed. private void RemoveAccesses(Entity ent, List>> accesses) { foreach (var access in accesses) { RemoveAccess(ent, access, false); } Dirty(ent); RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent()); } /// public void TryRemoveAccesses(Entity ent, List> accesses) { if (CanConfigureAccessReader(ent)) { RemoveAccesses(ent, accesses); } } /// private void RemoveAccesses(Entity ent, List> accesses) { foreach (var access in accesses) { RemoveAccess(ent, access, false); } Dirty(ent); RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent()); } /// /// Tries to removes an access permission from an access reader entity's /// /// The access reader entity from which the access permission is being removed. /// The access permission being removed. /// If true, the component will be marked as changed afterward. public void TryRemoveAccess(Entity ent, HashSet> access) { if (CanConfigureAccessReader(ent)) { RemoveAccess(ent, access); } } /// /// Removes an access permission from an access reader entity's /// /// The access reader entity from which the access permission is being removed. /// The access permission being removed. /// If true, the component will be marked as changed afterward. private void RemoveAccess(Entity ent, HashSet> access, bool dirty = true) { for (int i = ent.Comp.AccessLists.Count - 1; i >= 0; i--) { if (ent.Comp.AccessLists[i].SetEquals(access)) { ent.Comp.AccessLists.RemoveAt(i); } } if (!dirty) return; Dirty(ent); RaiseLocalEvent(ent, new AccessReaderConfigurationChangedEvent()); } /// public void TryRemoveAccess(Entity ent, ProtoId access) { if (CanConfigureAccessReader(ent)) { RemoveAccess(ent, new HashSet>() { access }); } } /// private void RemoveAccess(Entity ent, ProtoId access, bool dirty = true) { RemoveAccess(ent, new HashSet>() { access }, dirty); } private bool CanConfigureAccessReader(Entity ent) { var ev = new AccessReaderConfigurationAttemptEvent(); RaiseLocalEvent(ent, ev); return !ev.Cancelled; } #endregion #region: AccessKeys API /// /// Clears all access keys from an access reader. /// /// The access reader entity. public void ClearAccessKeys(Entity ent) { ent.Comp.AccessKeys.Clear(); Dirty(ent); } /// /// Replaces all access keys on an access reader with those from a supplied list. /// /// The access reader entity. /// The new access keys that are replacing the old ones. public void SetAccessKeys(Entity ent, HashSet keys) { ent.Comp.AccessKeys.Clear(); foreach (var key in keys) { ent.Comp.AccessKeys.Add(key); } Dirty(ent); } /// /// Adds an access key to an access reader. /// /// The access reader entity. /// The access key being added. public void AddAccessKey(Entity ent, StationRecordKey key) { ent.Comp.AccessKeys.Add(key); Dirty(ent); } /// /// Removes an access key from an access reader. /// /// The access reader entity. /// The access key being removed. public void RemoveAccessKey(Entity ent, StationRecordKey key) { ent.Comp.AccessKeys.Remove(key); Dirty(ent); } #endregion #region: DenyTags API /// /// Clears all deny tags from an access reader. /// /// The access reader entity. public void ClearDenyTags(Entity ent) { ent.Comp.DenyTags.Clear(); Dirty(ent); } /// /// Replaces all deny tags on an access reader with those from a supplied list. /// /// The access reader entity. /// The new tags that are replacing the old. public void SetDenyTags(Entity ent, HashSet> tags) { ent.Comp.DenyTags.Clear(); foreach (var tag in tags) { ent.Comp.DenyTags.Add(tag); } Dirty(ent); } /// /// Adds a tag to an access reader that will be used to deny access. /// /// The access reader entity. /// The tag being added. public void AddDenyTag(Entity ent, ProtoId tag) { ent.Comp.DenyTags.Add(tag); Dirty(ent); } /// /// Removes a tag from an access reader that denied a user access. /// /// The access reader entity. /// The tag being removed. public void RemoveDenyTag(Entity ent, ProtoId tag) { ent.Comp.DenyTags.Remove(tag); Dirty(ent); } #endregion /// /// Enables/disables the access reader on an entity. /// /// The access reader entity. /// Enable/disable the access reader. public void SetActive(Entity ent, bool enabled) { ent.Comp.Enabled = enabled; Dirty(ent); } /// /// Enables/disables the logging of access attempts on an access reader entity. /// /// The access reader entity. /// Enable/disable logging. public void SetLoggingActive(Entity ent, bool enabled) { ent.Comp.LoggingDisabled = !enabled; Dirty(ent); } /// /// Searches an entity's hand and ID slot for any contained items. /// /// The entity being searched. /// The collection of found items. /// True if one or more items were found. public bool FindAccessItemsInventory(EntityUid uid, out HashSet items) { items = new(_handsSystem.EnumerateHeld(uid)); // maybe its inside an inventory slot? if (_inventorySystem.TryGetSlotEntity(uid, "id", out var idUid)) { items.Add(idUid.Value); } return items.Any(); } /// /// Try to find on this entity or inside it (if it's a PDA). /// /// The entity being searched. /// The access tags that were found. /// True if one or more access tags were found. private bool FindAccessTagsItem(EntityUid uid, out HashSet> tags) { tags = new(); var ev = new GetAccessTagsEvent(tags, _prototype); RaiseLocalEvent(uid, ref ev); return tags.Count != 0; } /// /// Try to find on this entity or inside it (if it's a PDA). /// /// The entity being searched. /// The station record key that was found. /// True if a station record key was found. private bool FindStationRecordKeyItem(EntityUid uid, [NotNullWhen(true)] out StationRecordKey? key) { if (TryComp(uid, out StationRecordKeyStorageComponent? storage) && storage.Key != null) { key = storage.Key; return true; } if (TryComp(uid, out var pda) && pda.ContainedId is { Valid: true } id) { if (TryComp(id, out var pdastorage) && pdastorage.Key != null) { key = pdastorage.Key; return true; } } key = null; return false; } /// /// Logs an access for a specific entity. /// /// The reader to log the access on /// The accessor to log public void LogAccess(Entity ent, EntityUid accessor) { if (IsPaused(ent) || ent.Comp.LoggingDisabled) return; string? name = null; if (TryComp(accessor, out var nameIdentifier)) name = nameIdentifier.FullIdentifier; // TODO pass the ID card on IsAllowed() instead of using this expensive method // Set name if the accessor has a card and that card has a name and allows itself to be recorded var getIdentityShortInfoEvent = new TryGetIdentityShortInfoEvent(ent, accessor, true); RaiseLocalEvent(getIdentityShortInfoEvent); if (getIdentityShortInfoEvent.Title != null) { name = getIdentityShortInfoEvent.Title; } LogAccess(ent, name ?? Loc.GetString("access-reader-unknown-id")); } /// /// Logs an access with a predetermined name /// /// The reader to log the access on /// The name to log as public void LogAccess(Entity ent, string name, TimeSpan? accessTime = null, bool force = false) { if (!force) { if (IsPaused(ent) || ent.Comp.LoggingDisabled) return; if (ent.Comp.AccessLog.Count >= ent.Comp.AccessLogLimit) ent.Comp.AccessLog.Dequeue(); } var stationTime = accessTime ?? _gameTiming.CurTime.Subtract(_gameTicker.RoundStartTimeSpan); ent.Comp.AccessLog.Enqueue(new AccessRecord(stationTime, name)); Dirty(ent); } private List GetLocalizedAccessNames(List>> accessLists) { var localizedNames = new List(); string? andSeparator = null; foreach (var accessHashSet in accessLists) { var sb = new StringBuilder(); var accessSubset = accessHashSet.ToList(); // Combine the names of all access levels in the subset into a single string foreach (var access in accessSubset) { var accessName = Loc.GetString("access-reader-unknown-id"); if (_prototype.Resolve(access, out var accessProto) && !string.IsNullOrWhiteSpace(accessProto.Name)) accessName = Loc.GetString(accessProto.Name); sb.Append(Loc.GetString("access-reader-access-label", ("access", accessName))); if (accessSubset.IndexOf(access) < (accessSubset.Count - 1)) { andSeparator ??= " " + Loc.GetString("generic-and") + " "; sb.Append(andSeparator); } } // Add this string to the list localizedNames.Add(sb.ToString()); } return localizedNames; } }