using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using EntityFrameworkCore.Projectables; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Shared.Helpers; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Iceshrimp.Backend.Core.Database.Tables; [Table("user")] [Index(nameof(Host))] [Index(nameof(UsernameLower), nameof(Host), IsUnique = true)] [Index(nameof(UpdatedAt))] [Index(nameof(UsernameLower))] [Index(nameof(Uri))] [Index(nameof(LastActiveDate))] [Index(nameof(IsExplorable))] [Index(nameof(IsAdmin))] [Index(nameof(IsModerator))] [Index(nameof(CreatedAt))] [Index(nameof(Tags))] [Index(nameof(AvatarId), IsUnique = true)] [Index(nameof(BannerId), IsUnique = true)] [Index(nameof(IsSuspended))] public class User : IIdentifiable { /// /// The created date of the User. /// [Column("createdAt")] public DateTime CreatedAt { get; set; } /// /// The updated date of the User. /// [Column("updatedAt")] public DateTime? UpdatedAt { get; set; } [Column("lastFetchedAt")] public DateTime? LastFetchedAt { get; set; } [Column("outboxFetchedAt")] public DateTime? OutboxFetchedAt { get; set; } [NotMapped] [Projectable] public bool NeedsUpdate => Host != null && (LastFetchedAt == null || LastFetchedAt < DateTime.Now - TimeSpan.FromHours(24)); /// /// The username of the User. /// [Column("username")] [StringLength(128)] public string Username { get; set; } = null!; /// /// The username (lowercased) of the User. /// [Column("usernameLower")] [StringLength(128)] public string UsernameLower { get; set; } = null!; /// /// The name of the User. /// [Column("name")] [StringLength(128)] public string? DisplayName { get; set; } /// /// The count of followers. /// [Column("followersCount")] public int FollowersCount { get; set; } /// /// The count of following. /// [Column("followingCount")] public int FollowingCount { get; set; } /// /// The count of notes. /// [Column("notesCount")] public int NotesCount { get; set; } /// /// The ID of avatar DriveFile. /// [Column("avatarId")] [StringLength(32)] public string? AvatarId { get; set; } /// /// The ID of banner DriveFile. /// [Column("bannerId")] [StringLength(32)] public string? BannerId { get; set; } [Column("tags", TypeName = "character varying(128)[]")] public List Tags { get; set; } = []; /// /// Whether the User is suspended. /// [Column("isSuspended")] public bool IsSuspended { get; set; } /// /// Whether the User is silenced. /// [Column("isSilenced")] public bool IsSilenced { get; set; } /// /// Whether the User is locked. /// [Column("isLocked")] public bool IsLocked { get; set; } /// /// Whether the User is a bot. /// [Column("isBot")] public bool IsBot { get; set; } [Column("isSystem")] public bool IsSystemUser { get; set; } [Column("isRelayActor")] public bool IsRelayActor { get; set; } /// /// Whether the User is a cat. /// [Column("isCat")] public bool IsCat { get; set; } /// /// Whether the User is the admin. /// [Column("isAdmin")] public bool IsAdmin { get; set; } /// /// Whether the User is a moderator. /// [Column("isModerator")] public bool IsModerator { get; set; } [Column("emojis", TypeName = "character varying(128)[]")] public List Emojis { get; set; } = []; /// /// The host of the User. It will be null if the origin of the user is local. /// [Column("host")] [StringLength(512)] public string? Host { get; set; } [NotMapped] [Projectable] public string Acct => Username + (Host != null ? "@" + Host : ""); [NotMapped] [Projectable] public string AcctWithPrefix => "acct:" + Acct; /// /// The inbox URL of the User. It will be null if the origin of the user is local. /// [Column("inbox")] [StringLength(512)] public string? Inbox { get; set; } /// /// The outbox URL of the User. It will be null if the origin of the user is local, or if no outbox is present. /// [Column("outbox")] [StringLength(512)] public string? Outbox { get; set; } /// /// The sharedInbox URL of the User. It will be null if the origin of the user is local. /// [Column("sharedInbox")] [StringLength(512)] public string? SharedInbox { get; set; } /// /// The featured URL of the User. It will be null if the origin of the user is local. /// [Column("featured")] [StringLength(512)] public string? Featured { get; set; } /// /// The URI of the User. It will be null if the origin of the user is local. /// [Column("uri")] [StringLength(512)] public string? Uri { get; set; } /// /// Whether the User is explorable. /// [Column("isExplorable")] public bool IsExplorable { get; set; } /// /// The URI of the user Follower Collection. It will be null if the origin of the user is local. /// [Column("followersUri")] [StringLength(512)] public string? FollowersUri { get; set; } [Column("lastActiveDate")] public DateTime? LastActiveDate { get; set; } /// /// Whether the User is deleted. /// [Column("isDeleted")] public bool IsDeleted { get; set; } /// /// Overrides user drive capacity limit /// [Column("driveCapacityOverrideMb")] public int? DriveCapacityOverrideMb { get; set; } /// /// The URI of the new account of the User /// [Column("movedToUri")] [StringLength(512)] public string? MovedToUri { get; set; } /// /// URIs the user is known as too /// [Column("alsoKnownAs")] public List? AlsoKnownAs { get; set; } /// /// Whether to speak as a cat if isCat. /// [Column("speakAsCat")] public bool SpeakAsCat { get; set; } /// /// The blurhash of the avatar DriveFile /// [Column("avatarBlurhash")] [StringLength(128)] public string? AvatarBlurhash { get; set; } /// /// The blurhash of the banner DriveFile /// [Column("bannerBlurhash")] [StringLength(128)] public string? BannerBlurhash { get; set; } [Column("splitDomainResolved")] public bool SplitDomainResolved { get; set; } [InverseProperty(nameof(AbuseUserReport.Assignee))] public virtual ICollection AbuseUserReportAssignees { get; set; } = new List(); [InverseProperty(nameof(AbuseUserReport.Reporter))] public virtual ICollection AbuseUserReportReporters { get; set; } = new List(); [InverseProperty(nameof(AbuseUserReport.TargetUser))] public virtual ICollection AbuseUserReportTargetUsers { get; set; } = new List(); [InverseProperty(nameof(AnnouncementRead.User))] public virtual ICollection AnnouncementReads { get; set; } = new List(); [InverseProperty(nameof(Antenna.User))] public virtual ICollection Antennas { get; set; } = new List(); [InverseProperty(nameof(AttestationChallenge.User))] public virtual ICollection AttestationChallenges { get; set; } = new List(); [ForeignKey(nameof(AvatarId))] [InverseProperty(nameof(DriveFile.UserAvatar))] public virtual DriveFile? Avatar { get; set; } [ForeignKey(nameof(BannerId))] [InverseProperty(nameof(DriveFile.UserBanner))] public virtual DriveFile? Banner { get; set; } [InverseProperty(nameof(Tables.Blocking.Blockee))] public virtual ICollection IncomingBlocks { get; set; } = new List(); [InverseProperty(nameof(Tables.Blocking.Blocker))] public virtual ICollection OutgoingBlocks { get; set; } = new List(); [NotMapped] [Projectable] public virtual IEnumerable BlockedBy => IncomingBlocks.Select(p => p.Blocker); [NotMapped] [Projectable] public virtual IEnumerable Blocking => OutgoingBlocks.Select(p => p.Blockee); [InverseProperty(nameof(ChannelFollowing.Follower))] public virtual ICollection ChannelFollowings { get; set; } = new List(); [InverseProperty(nameof(Channel.User))] public virtual ICollection Channels { get; set; } = new List(); [InverseProperty(nameof(Clip.User))] public virtual ICollection Clips { get; set; } = new List(); [InverseProperty(nameof(DriveFile.User))] public virtual ICollection DriveFiles { get; set; } = new List(); [InverseProperty(nameof(DriveFolder.User))] public virtual ICollection DriveFolders { get; set; } = new List(); [InverseProperty(nameof(FollowRequest.Followee))] public virtual ICollection IncomingFollowRequests { get; set; } = new List(); [InverseProperty(nameof(FollowRequest.Follower))] public virtual ICollection OutgoingFollowRequests { get; set; } = new List(); [NotMapped] [Projectable] public virtual IEnumerable ReceivedFollowRequests => IncomingFollowRequests.Select(p => p.Follower); [NotMapped] [Projectable] public virtual IEnumerable SentFollowRequests => OutgoingFollowRequests.Select(p => p.Followee); [InverseProperty(nameof(Tables.Following.Followee))] public virtual ICollection IncomingFollowRelationships { get; set; } = new List(); [InverseProperty(nameof(Tables.Following.Follower))] public virtual ICollection OutgoingFollowRelationships { get; set; } = new List(); [NotMapped] [Projectable] public virtual IEnumerable Followers => IncomingFollowRelationships.Select(p => p.Follower); [NotMapped] [Projectable] public virtual IEnumerable Following => OutgoingFollowRelationships.Select(p => p.Followee); [InverseProperty(nameof(GalleryLike.User))] public virtual ICollection GalleryLikes { get; set; } = new List(); [InverseProperty(nameof(GalleryPost.User))] public virtual ICollection GalleryPosts { get; set; } = new List(); [InverseProperty(nameof(Marker.User))] public virtual ICollection Markers { get; set; } = new List(); [InverseProperty(nameof(MessagingMessage.Recipient))] public virtual ICollection MessagingMessageRecipients { get; set; } = new List(); [InverseProperty(nameof(MessagingMessage.User))] public virtual ICollection MessagingMessageUsers { get; set; } = new List(); [InverseProperty(nameof(ModerationLog.User))] public virtual ICollection ModerationLogs { get; set; } = new List(); [InverseProperty(nameof(Tables.Muting.Mutee))] public virtual ICollection IncomingMutes { get; set; } = new List(); [InverseProperty(nameof(Tables.Muting.Muter))] public virtual ICollection OutgoingMutes { get; set; } = new List(); [NotMapped] [Projectable] public virtual IEnumerable MutedBy => IncomingMutes.Select(p => p.Muter); [NotMapped] [Projectable] public virtual IEnumerable Muting => OutgoingMutes.Select(p => p.Mutee); [InverseProperty(nameof(NoteBookmark.User))] public virtual ICollection NoteBookmarks { get; set; } = new List(); [NotMapped] [Projectable] public virtual IEnumerable BookmarkedNotes => NoteBookmarks.Select(p => p.Note); [InverseProperty(nameof(NoteReaction.User))] public virtual ICollection NoteLikes { get; set; } = new List(); [NotMapped] [Projectable] public virtual IEnumerable LikedNotes => NoteLikes.Select(p => p.Note); [InverseProperty(nameof(NoteReaction.User))] public virtual ICollection NoteReactions { get; set; } = new List(); [NotMapped] [Projectable] public virtual IEnumerable ReactedNotes => NoteReactions.Select(p => p.Note).Distinct(); [InverseProperty(nameof(NoteThreadMuting.User))] public virtual ICollection NoteThreadMutings { get; set; } = new List(); [InverseProperty(nameof(NoteUnread.User))] public virtual ICollection NoteUnreads { get; set; } = new List(); [InverseProperty(nameof(NoteWatching.User))] public virtual ICollection NoteWatchings { get; set; } = new List(); [InverseProperty(nameof(Note.User))] public virtual ICollection Notes { get; set; } = new List(); [InverseProperty(nameof(Notification.Notifiee))] public virtual ICollection NotificationNotifiees { get; set; } = new List(); [InverseProperty(nameof(Notification.Notifier))] public virtual ICollection NotificationNotifiers { get; set; } = new List(); [InverseProperty(nameof(OauthToken.User))] public virtual ICollection OauthTokens { get; set; } = new List(); [InverseProperty(nameof(PageLike.User))] public virtual ICollection PageLikes { get; set; } = new List(); [InverseProperty(nameof(Page.User))] public virtual ICollection Pages { get; set; } = new List(); [InverseProperty(nameof(PasswordResetRequest.User))] public virtual ICollection PasswordResetRequests { get; set; } = new List(); [InverseProperty(nameof(PollVote.User))] public virtual ICollection PollVotes { get; set; } = new List(); [InverseProperty(nameof(PromoRead.User))] public virtual ICollection PromoReads { get; set; } = new List(); [InverseProperty(nameof(RegistryItem.User))] public virtual ICollection RegistryItems { get; set; } = new List(); [InverseProperty(nameof(RenoteMuting.Mutee))] public virtual ICollection RenoteMutingMutees { get; set; } = new List(); [InverseProperty(nameof(RenoteMuting.Muter))] public virtual ICollection RenoteMutingMuters { get; set; } = new List(); [InverseProperty(nameof(Session.User))] public virtual ICollection Sessions { get; set; } = new List(); [InverseProperty(nameof(SwSubscription.User))] public virtual ICollection SwSubscriptions { get; set; } = new List(); [InverseProperty(nameof(PushSubscription.User))] public virtual ICollection PushSubscriptions { get; set; } = new List(); [InverseProperty(nameof(UserGroupInvitation.User))] public virtual ICollection UserGroupInvitations { get; set; } = new List(); [InverseProperty(nameof(UserGroupMember.User))] public virtual ICollection UserGroupMemberships { get; set; } = new List(); [InverseProperty(nameof(UserGroup.User))] public virtual ICollection UserGroups { get; set; } = new List(); [InverseProperty(nameof(Tables.UserKeypair.User))] public virtual UserKeypair? UserKeypair { get; set; } [InverseProperty(nameof(UserListMember.User))] public virtual ICollection UserListMembers { get; set; } = new List(); [InverseProperty(nameof(UserList.User))] public virtual ICollection UserLists { get; set; } = new List(); [InverseProperty(nameof(UserNotePin.User))] public virtual ICollection UserNotePins { get; set; } = new List(); [NotMapped] [Projectable] public virtual IEnumerable PinnedNotes => UserNotePins.Select(p => p.Note); [InverseProperty(nameof(Tables.UserProfile.User))] public virtual UserProfile? UserProfile { get; set; } [InverseProperty(nameof(Tables.UserPublickey.User))] public virtual UserPublickey? UserPublickey { get; set; } [InverseProperty(nameof(Tables.UserSettings.User))] public virtual UserSettings? UserSettings { get; set; } [InverseProperty(nameof(UserSecurityKey.User))] public virtual ICollection UserSecurityKeys { get; set; } = new List(); [InverseProperty(nameof(Webhook.User))] public virtual ICollection Webhooks { get; set; } = new List(); [InverseProperty(nameof(Filter.User))] public virtual ICollection Filters { get; set; } = new List(); [NotMapped] public bool? PrecomputedIsBlocking { get; set; } [NotMapped] public bool? PrecomputedIsBlockedBy { get; set; } [NotMapped] public bool? PrecomputedIsMuting { get; set; } [NotMapped] public bool? PrecomputedIsMutedBy { get; set; } [NotMapped] public bool? PrecomputedIsFollowing { get; set; } [NotMapped] public bool? PrecomputedIsFollowedBy { get; set; } [NotMapped] public bool? PrecomputedIsRequested { get; set; } [NotMapped] public bool? PrecomputedIsRequestedBy { get; set; } [Projectable] public bool IsLocalUser => Host == null; [Projectable] public bool IsRemoteUser => Host != null; [Projectable] public string IdenticonUrlPath => $"/identicon/{Id}"; [Key] [Column("id")] [StringLength(32)] public string Id { get; set; } = null!; [Projectable] public string GetFqnLower(string accountDomain) => UsernameLower + "@" + (Host ?? accountDomain); [Projectable] public string GetFqn(string accountDomain) => Username + "@" + (Host ?? accountDomain); [Projectable] public bool DisplayNameContainsCaseInsensitive(string str) => DisplayName != null && EF.Functions.ILike(DisplayName, "%" + EfHelpers.EscapeLikeQuery(str) + "%", @"\"); [Projectable] public bool UsernameContainsCaseInsensitive(string str) => UsernameLower.Contains(str.ToLowerInvariant()); [Projectable] public bool FqnContainsCaseInsensitive(string str, string accountDomain) => GetFqnLower(accountDomain).Contains(str.ToLowerInvariant()); [Projectable] public bool UsernameOrFqnContainsCaseInsensitive(string str, string accountDomain) => str.Contains('@') ? FqnContainsCaseInsensitive(str, accountDomain) : UsernameContainsCaseInsensitive(str); [Projectable] public bool DisplayNameOrUsernameOrFqnContainsCaseInsensitive(string str, string accountDomain) => str.Contains('@') && !str.Contains(' ') ? FqnContainsCaseInsensitive(str, accountDomain) : UsernameContainsCaseInsensitive(str) || DisplayNameContainsCaseInsensitive(str); [Projectable] public bool IsBlockedBy(User user) => BlockedBy.Contains(user); [Projectable] public bool IsBlocking(User user) => Blocking.Contains(user); [Projectable] public bool IsFollowedBy(User user) => Followers.Contains(user); [Projectable] public bool IsFollowing(User user) => Following.Contains(user); [Projectable] public bool IsRequestedBy(User user) => ReceivedFollowRequests.Contains(user); [Projectable] public bool IsRequested(User user) => SentFollowRequests.Contains(user); [Projectable] public bool IsMutedBy(User user) => MutedBy.Contains(user); [Projectable] public bool IsMuting(User user) => Muting.Contains(user); [Projectable] public bool HasPinned(Note note) => PinnedNotes.Contains(note); [Projectable] public bool HasBookmarked(Note note) => BookmarkedNotes.Contains(note); [Projectable] public bool HasLiked(Note note) => LikedNotes.Contains(note); [Projectable] public bool HasReacted(Note note) => ReactedNotes.Contains(note); [Projectable] public bool HasRenoted(Note note) => Notes.Any(p => p.Renote == note && p.User == this); [Projectable] public bool HasReplied(Note note) => Notes.Any(p => p.Reply == note && p.User == this); [Projectable] public bool HasVoted(Note note) => PollVotes.Any(p => p.Note == note && p.User == this); [Projectable] public bool HasInteractedWith(Note note) => HasLiked(note) || HasReacted(note) || HasBookmarked(note) || HasReplied(note) || HasRenoted(note) || HasVoted(note); [Projectable] public bool ProhibitInteractionWith(User user) => IsBlocking(user) || IsBlockedBy(user); public User WithPrecomputedBlockStatus(bool blocking, bool blockedBy) { PrecomputedIsBlocking = blocking; PrecomputedIsBlockedBy = blockedBy; return this; } public User WithPrecomputedMuteStatus(bool muting, bool mutedBy) { PrecomputedIsMuting = muting; PrecomputedIsMutedBy = mutedBy; return this; } public User WithPrecomputedFollowStatus(bool following, bool followedBy, bool requested, bool requestedBy) { PrecomputedIsFollowing = following; PrecomputedIsFollowedBy = followedBy; PrecomputedIsRequested = requested; PrecomputedIsRequestedBy = requestedBy; return this; } public string GetPublicUrl(Config.InstanceSection config) => GetPublicUrl(config.WebDomain); public string GetPublicUri(Config.InstanceSection config) => GetPublicUri(config.WebDomain); public string GetUriOrPublicUri(Config.InstanceSection config) => GetUriOrPublicUri(config.WebDomain); public string GetIdenticonUrl(Config.InstanceSection config) => GetIdenticonUrl(config.WebDomain); public string GetPublicUri(string webDomain) => Host == null ? $"https://{webDomain}/users/{Id}" : throw new Exception("Cannot access PublicUri for remote user"); public string GetUriOrPublicUri(string webDomain) => Uri ?? GetPublicUri(webDomain); public string GetPublicUrl(string webDomain) => Host == null ? $"https://{webDomain}{PublicUrlPath}" : throw new Exception("Cannot access PublicUrl for remote user"); [Projectable] public string PublicUrlPath => $"/@{Username}"; public string GetIdenticonUrl(string webDomain) => $"https://{webDomain}{IdenticonUrlPath}"; public string GetAvatarUrl(Config.InstanceSection config) => $"https://{config.WebDomain}/avatars/{Id}/{AvatarId ?? "identicon"}"; public string? GetBannerUrl(Config.InstanceSection config) => BannerId != null ? $"https://{config.WebDomain}/banners/{Id}/{BannerId}" : null; private class EntityTypeConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder entity) { entity.Property(e => e.AlsoKnownAs).HasComment("URIs the user is known as too"); entity.Property(e => e.AvatarBlurhash).HasComment("The blurhash of the avatar DriveFile"); entity.Property(e => e.AvatarId).HasComment("The ID of avatar DriveFile."); entity.Property(e => e.BannerBlurhash).HasComment("The blurhash of the banner DriveFile"); entity.Property(e => e.BannerId).HasComment("The ID of banner DriveFile."); entity.Property(e => e.CreatedAt).HasComment("The created date of the User."); entity.Property(e => e.DriveCapacityOverrideMb).HasComment("Overrides user drive capacity limit"); entity.Property(e => e.Emojis).HasDefaultValueSql("'{}'::character varying[]"); entity.Property(e => e.Featured) .HasComment("The featured URL of the User. It will be null if the origin of the user is local."); entity.Property(e => e.FollowersCount) .HasDefaultValue(0) .HasComment("The count of followers."); entity.Property(e => e.FollowersUri) .HasComment("The URI of the user Follower Collection. It will be null if the origin of the user is local."); entity.Property(e => e.FollowingCount) .HasDefaultValue(0) .HasComment("The count of following."); entity.Property(e => e.Host) .HasComment("The host of the User. It will be null if the origin of the user is local."); entity.Property(e => e.Inbox) .HasComment("The inbox URL of the User. It will be null if the origin of the user is local."); entity.Property(e => e.IsAdmin) .HasDefaultValue(false) .HasComment("Whether the User is the admin."); entity.Property(e => e.IsBot) .HasDefaultValue(false) .HasComment("Whether the User is a bot."); entity.Property(e => e.IsCat) .HasDefaultValue(false) .HasComment("Whether the User is a cat."); entity.Property(e => e.IsDeleted) .HasDefaultValue(false) .HasComment("Whether the User is deleted."); entity.Property(e => e.IsExplorable) .HasDefaultValue(true) .HasComment("Whether the User is explorable."); entity.Property(e => e.IsLocked) .HasDefaultValue(false) .HasComment("Whether the User is locked."); entity.Property(e => e.IsModerator) .HasDefaultValue(false) .HasComment("Whether the User is a moderator."); entity.Property(e => e.IsSilenced) .HasDefaultValue(false) .HasComment("Whether the User is silenced."); entity.Property(e => e.IsSuspended) .HasDefaultValue(false) .HasComment("Whether the User is suspended."); entity.Property(e => e.MovedToUri).HasComment("The URI of the new account of the User"); entity.Property(e => e.DisplayName).HasComment("The name of the User."); entity.Property(e => e.NotesCount) .HasDefaultValue(0) .HasComment("The count of notes."); entity.Property(e => e.SharedInbox) .HasComment("The sharedInbox URL of the User. It will be null if the origin of the user is local."); entity.Property(e => e.SpeakAsCat) .HasDefaultValue(true) .HasComment("Whether to speak as a cat if isCat."); entity.Property(e => e.Tags).HasDefaultValueSql("'{}'::character varying[]"); entity.Property(e => e.UpdatedAt).HasComment("The updated date of the User."); entity.Property(e => e.Uri) .HasComment("The URI of the User. It will be null if the origin of the user is local."); entity.Property(e => e.Username).HasComment("The username of the User."); entity.Property(e => e.UsernameLower).HasComment("The username (lowercased) of the User."); entity.Property(e => e.SplitDomainResolved).HasDefaultValue(false); entity.HasOne(d => d.Avatar) .WithOne(p => p.UserAvatar) .OnDelete(DeleteBehavior.SetNull); entity.HasOne(d => d.Banner) .WithOne(p => p.UserBanner) .OnDelete(DeleteBehavior.SetNull); } } }